export const dynamic = 'force-dynamic' import { 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, sites, zones, auditLog } from '@/lib/db/schema' import { eq, and, gte, desc } from 'drizzle-orm' import { createDeepSeekClient } from '@/lib/claude/client' import { getApiKey } from '@/lib/settings' type ZoneAggregate = { zone: string site: string total: number near_miss: number hazard: number injury: number avg_severity: number | null first_half: number second_half: number } export async function POST() { const session = await getSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!['hse', 'admin', 'management'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) const now = new Date() const ninetyDaysAgo = new Date(now) ninetyDaysAgo.setDate(now.getDate() - 90) const midpoint = new Date(now) midpoint.setDate(now.getDate() - 45) const rows = await withUser(session.sub, async tx => tx.select({ incidentType: incidents.incidentType, severity: incidents.severity, reportedAt: incidents.reportedAt, zoneName: zones.name, siteName: sites.name, }) .from(incidents) .leftJoin(zones, eq(incidents.zoneId, zones.id)) .leftJoin(sites, eq(incidents.siteId, sites.id)) .where(gte(incidents.reportedAt, ninetyDaysAgo)) ) if (rows.length === 0) return NextResponse.json({ flags: [], summary: 'No incidents in the last 90 days.' }) const zoneMap = new Map() for (const r of rows) { const zone = r.zoneName ?? 'Unknown zone' const site = r.siteName ?? 'Unknown site' const key = `${site}|${zone}` let agg = zoneMap.get(key) if (!agg) { agg = { zone, site, total: 0, near_miss: 0, hazard: 0, injury: 0, avg_severity: null, first_half: 0, second_half: 0, severitySum: 0, severityCount: 0, } zoneMap.set(key, agg) } agg.total++ if (r.incidentType === 'near_miss') agg.near_miss++ if (r.incidentType === 'hazard') agg.hazard++ if (r.incidentType === 'injury') agg.injury++ if (typeof r.severity === 'number') { agg.severitySum += r.severity agg.severityCount++ } if (r.reportedAt && new Date(r.reportedAt) < midpoint) agg.first_half++ else agg.second_half++ } const aggregates: ZoneAggregate[] = [...zoneMap.values()].map(a => ({ zone: a.zone, site: a.site, total: a.total, near_miss: a.near_miss, hazard: a.hazard, injury: a.injury, avg_severity: a.severityCount > 0 ? Math.round((a.severitySum / a.severityCount) * 10) / 10 : null, first_half: a.first_half, second_half: a.second_half, })) // Rate limit: 1 AI call per 60s per user (checked via audit_log) const [lastCall] = await asAdmin(db => db.select({ changedAt: auditLog.changedAt }) .from(auditLog) .where(and( eq(auditLog.changedBy, session.sub), eq(auditLog.action, 'ai_risk_flags'), )) .orderBy(desc(auditLog.changedAt)) .limit(1) ) if (lastCall && Date.now() - new Date(lastCall.changedAt!).getTime() < 60_000) { return NextResponse.json({ error: 'Rate limit: wait 60 seconds between AI requests' }, { status: 429 }) } const deepseekKey = await getApiKey('DEEPSEEK_API_KEY') const client = createDeepSeekClient(deepseekKey) let res: Awaited> try { res = await client.chat.completions.create({ model: 'deepseek-v4-pro', max_tokens: 2048, messages: [ { role: 'system', content: `You are an HSE risk analyst for a Malaysian 3PL warehouse operator. You must respond with valid JSON only — no markdown, no explanation outside the JSON object. Output exactly this JSON structure: { "flags": [ { "zone": "string", "site": "string", "risk_level": "low" | "medium" | "high", "rationale": "One or two sentences citing the numbers", "recommended_action": "One concrete preventive action" } ], "summary": "Two-sentence overall risk picture" } Flag only zones with rising or elevated risk (at most 5 flags; do not flag healthy zones). Base every rationale strictly on the numbers given. "first_half" is incidents in days 90-46, "second_half" is days 45-0 — a rising second_half means worsening trend. Near-miss and hazard reports are leading indicators; injuries are lagging.`, }, { role: 'user', content: `Below are 90-day incident aggregates per zone. Respond with JSON only.\n\n\n${JSON.stringify(aggregates, null, 2)}\n`, }, ], }) } catch (err) { console.error('DeepSeek risk-flags error:', err) return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 }) } const raw = res.choices[0]?.message?.content if (!raw) return NextResponse.json({ error: 'AI returned empty response' }, { status: 500 }) // Strip markdown code fences if present const json = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim() let input: { flags?: unknown; summary?: unknown } try { input = JSON.parse(json) } catch { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) } if (!Array.isArray(input.flags) || typeof input.summary !== 'string') return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) const flags = input.flags.filter( (f: unknown): f is { zone: string; site: string; risk_level: string; rationale: string; recommended_action: string } => typeof f === 'object' && f !== null && typeof (f as Record).zone === 'string' && typeof (f as Record).site === 'string' && ['low', 'medium', 'high'].includes((f as Record).risk_level as string) && typeof (f as Record).rationale === 'string' && typeof (f as Record).recommended_action === 'string', ) await withUser(session.sub, async tx => writeAuditLog(tx, 'incidents', session.sub, 'ai_risk_flags', { flags, summary: input.summary, model: 'deepseek-v4-pro' }) ) return NextResponse.json({ flags, summary: input.summary }) }