Files
ims/app/api/incidents/[id]/ai/rca-draft/route.ts
T
adminandClaude Sonnet 4.6 16dd62df11 fix: P1 API security hardening — rate limits, auth guards, duplicate prevention
- verify/route.ts: setDate → setUTCDate to avoid timezone off-by-one on recheck date
- triage-suggest, rca-draft, quality-check: 60s per-user rate limit via audit_log
- quality-check: add write_audit_log (was missing, CLAUDE.md violation)
- investigation POST: 409 if investigation already exists for incident
- incidents POST: 60s per-user rate limit via audit_log
- addenda GET: restrict to hse/admin/supervisor roles
- dashboard/stats GET: restrict to hse/admin/management roles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
2026-07-12 20:47:32 +08:00

147 lines
5.2 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 { createAnthropicClient } 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 supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
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', user.id)
.eq('action', 'ai_rca_draft')
.gte('changed_at', since)
if ((recentCount ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY')
const anthropic = createAnthropicClient(anthropicKey)
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()
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 message: Awaited<ReturnType<typeof anthropic.messages.create>>
try {
message = await anthropic.messages.create({
model: 'claude-opus-4-8',
thinking: { type: 'adaptive' },
max_tokens: 2048,
tools: [{
name: 'draft_rca',
description: 'Draft a 5-Why root cause analysis and CAPA suggestions for an HSE incident',
input_schema: {
type: 'object' as const,
properties: {
five_why_steps: {
type: 'array',
items: {
type: 'object',
properties: {
why: { type: 'string', description: 'The why question' },
answer: { type: 'string', description: 'The finding or answer' },
},
required: ['why', 'answer'],
},
description: '3 to 5 why steps',
},
root_cause_summary: {
type: 'string',
description: 'One-sentence root cause statement',
},
capa_suggestions: {
type: 'array',
items: { type: 'string' },
description: 'Up to 3 corrective/preventive action suggestions',
},
},
required: ['five_why_steps', 'root_cause_summary', 'capa_suggestions'],
},
}],
tool_choice: { type: 'tool', name: 'draft_rca' },
messages: [{
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'}
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.`,
}],
})
} catch {
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
}
const toolBlock = message.content.find(b => b.type === 'tool_use')
if (!toolBlock || toolBlock.type !== 'tool_use')
return NextResponse.json({ error: 'AI draft failed' }, { status: 500 })
const draft = toolBlock.input as {
five_why_steps?: unknown
root_cause_summary?: unknown
capa_suggestions?: unknown
}
if (
!Array.isArray(draft.five_why_steps) ||
typeof draft.root_cause_summary !== 'string' ||
!Array.isArray(draft.capa_suggestions)
) {
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,
model: 'claude-opus-4-8',
} as never,
})
return NextResponse.json(draft)
}