diff --git a/app/api/incidents/[id]/ai/rca-draft/route.ts b/app/api/incidents/[id]/ai/rca-draft/route.ts index b1c2c0e..d684219 100644 --- a/app/api/incidents/[id]/ai/rca-draft/route.ts +++ b/app/api/incidents/[id]/ai/rca-draft/route.ts @@ -41,45 +41,47 @@ export async function POST( const siteName = (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown' const zoneName = (incident.zones as unknown as { name: string } | null)?.name ?? 'Unknown' - const 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' }, + let message: Awaited> + 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'], }, - 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', }, - 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'], }, - 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. + }], + 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} @@ -92,8 +94,11 @@ Serious bodily injury: ${inc.is_serious_bodily_injury ? 'yes' : 'no'} Triage notes: ${inc.triage_notes ?? 'none'} Provide 3–5 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') diff --git a/app/api/incidents/[id]/ai/triage-suggest/route.ts b/app/api/incidents/[id]/ai/triage-suggest/route.ts index 6b77856..08d9279 100644 --- a/app/api/incidents/[id]/ai/triage-suggest/route.ts +++ b/app/api/incidents/[id]/ai/triage-suggest/route.ts @@ -32,33 +32,35 @@ export async function POST( medical_status: string | null } - const message = await anthropic.messages.create({ - model: 'claude-opus-4-8', - thinking: { type: 'adaptive' }, - max_tokens: 1024, - tools: [{ - name: 'suggest_triage', - description: 'Suggest severity rating and NADOPOD 2004 DOSH classification for a warehouse incident', - input_schema: { - type: 'object' as const, - 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' }, + let message: Awaited> + try { + message = await anthropic.messages.create({ + model: 'claude-opus-4-8', + thinking: { type: 'adaptive' }, + max_tokens: 1024, + tools: [{ + name: 'suggest_triage', + description: 'Suggest severity rating and NADOPOD 2004 DOSH classification for a warehouse incident', + input_schema: { + type: 'object' as const, + 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', + ], }, - required: [ - 'severity', 'is_fatality', 'is_serious_bodily_injury', - 'is_dangerous_occurrence', 'is_occupational_disease', 'rationale', - ], - }, - }], - tool_choice: { type: 'tool', name: 'suggest_triage' }, - messages: [{ - role: 'user', - content: `You are an HSE triage specialist for a Malaysian 3PL warehouse. Assess this incident under NADOPOD 2004. + }], + tool_choice: { type: 'tool', 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} @@ -67,8 +69,11 @@ Medical status: ${inc.medical_status ?? 'N/A'} Asset/equipment involved: ${inc.asset_involved ? 'yes' : 'no'} Suggest severity (1–5) and tick the appropriate NADOPOD 2004 flags. Give a one-sentence rationale.`, - }], - }) + }], + }) + } 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') diff --git a/app/api/incidents/[id]/similar/route.ts b/app/api/incidents/[id]/similar/route.ts index 0043856..19d1f01 100644 --- a/app/api/incidents/[id]/similar/route.ts +++ b/app/api/incidents/[id]/similar/route.ts @@ -27,13 +27,17 @@ export async function GET( const inc = incident as { id: string; description: string; embedding: string | null } let embeddingVec: number[] - if (inc.embedding) { - embeddingVec = JSON.parse(inc.embedding) as number[] - } else { - embeddingVec = await embedText(inc.description) - await supabase.from('incidents').update({ - embedding: `[${embeddingVec.join(',')}]` as unknown as string, - }).eq('id', id) + try { + if (inc.embedding) { + embeddingVec = JSON.parse(inc.embedding) as number[] + } else { + embeddingVec = await embedText(inc.description) + await supabase.from('incidents').update({ + embedding: `[${embeddingVec.join(',')}]` as unknown as string, + }).eq('id', id) + } + } catch { + return NextResponse.json({ error: 'Embedding service unavailable' }, { status: 503 }) } const { data: similar } = await supabase.rpc('match_incidents', { diff --git a/app/api/incidents/ai/quality-check/route.ts b/app/api/incidents/ai/quality-check/route.ts index e83407d..1f0019c 100644 --- a/app/api/incidents/ai/quality-check/route.ts +++ b/app/api/incidents/ai/quality-check/route.ts @@ -19,39 +19,44 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'description and incident_type required' }, { status: 422 }) } - const message = await anthropic.messages.create({ - model: 'claude-opus-4-8', - thinking: { type: 'adaptive' }, - max_tokens: 1024, - tools: [{ - name: 'assess_quality', - description: 'Assess HSE incident report description quality', - input_schema: { - type: 'object' as const, - properties: { - score: { type: 'number', description: '1-10 quality score' }, - passes: { type: 'boolean', description: 'True when score is 6 or above' }, - feedback: { type: 'string', description: 'One-sentence quality summary' }, - suggestions: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 3 concrete suggestions to improve the description', + let message: Awaited> + try { + message = await anthropic.messages.create({ + model: 'claude-opus-4-8', + thinking: { type: 'adaptive' }, + max_tokens: 1024, + tools: [{ + name: 'assess_quality', + description: 'Assess HSE incident report description quality', + input_schema: { + type: 'object' as const, + properties: { + score: { type: 'number', description: '1-10 quality score' }, + passes: { type: 'boolean', description: 'True when score is 6 or above' }, + feedback: { type: 'string', description: 'One-sentence quality summary' }, + suggestions: { + type: 'array', + items: { type: 'string' }, + description: 'Up to 3 concrete suggestions to improve the description', + }, }, + required: ['score', 'passes', 'feedback', 'suggestions'], }, - required: ['score', 'passes', 'feedback', 'suggestions'], - }, - }], - tool_choice: { type: 'tool', name: 'assess_quality' }, - messages: [{ - role: 'user', - content: `You are an HSE reporting assistant for a Malaysian 3PL warehouse. Assess this incident report description. + }], + tool_choice: { type: 'tool', name: 'assess_quality' }, + messages: [{ + role: 'user', + content: `You are an HSE reporting assistant for a Malaysian 3PL warehouse. Assess this incident report description. Incident type: ${body.incident_type} Description: ${body.description} Score 1–10 based on: specificity (location, time, persons involved), completeness (what happened + immediate actions), and clarity. Score 6 or above passes. If score is below 6, give up to 3 actionable suggestions.`, - }], - }) + }], + }) + } 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') { diff --git a/supabase/migrations/20260711000013_match_incidents_auth_guard.sql b/supabase/migrations/20260711000013_match_incidents_auth_guard.sql new file mode 100644 index 0000000..8c6a1c6 --- /dev/null +++ b/supabase/migrations/20260711000013_match_incidents_auth_guard.sql @@ -0,0 +1,43 @@ +-- Add authorization guard to match_incidents to enforce DB-level access control. +-- Without this, SECURITY DEFINER bypasses RLS for any direct caller. +create or replace function match_incidents( + query_embedding vector(1024), + exclude_id uuid, + match_count int default 5 +) +returns table ( + id uuid, + reference_no text, + incident_type text, + description text, + severity int, + similarity float +) +language plpgsql +security definer +as $$ +begin + -- Enforce that only hse/admin roles can call this function directly + if not exists ( + select 1 from public.users + where id = auth.uid() + and role in ('hse', 'admin') + ) then + raise exception 'Forbidden' using errcode = 'PGRST301'; + end if; + + return query + select + i.id, + i.reference_no, + i.incident_type, + i.description, + i.severity, + 1 - (i.embedding <=> query_embedding) as similarity + from incidents i + where i.id != exclude_id + and i.embedding is not null + order by i.embedding <=> query_embedding + limit match_count; +end; +$$;