import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' import { validateIncidentInput, validateTypeDetails, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate' import { uploadEvidenceFile } from '@/lib/supabase/storage' import { sendNewIncidentEmail } from '@/lib/notifications/email' import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp' import { createInAppNotifications } from '@/lib/notifications/in-app' import { getApiKey } from '@/lib/settings' import { withUser, asAdmin } from '@/lib/db/with-user' import { writeAuditLog } from '@/lib/db/audit' import { incidents, evidenceFiles, auditLog, sites, zones, trucks, users } from '@/lib/db/schema' import { eq, and, inArray, gte, sql } from 'drizzle-orm' export const dynamic = 'force-dynamic' export async function POST(request: Request) { try { return await handlePost(request) } catch (err) { console.error('Unhandled error in POST /api/incidents:', err) return NextResponse.json({ error: 'Internal server error', details: [String(err)] }, { status: 500 }) } } async function handlePost(request: Request) { const session = await getSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) // Rate limit check const since = new Date(Date.now() - 60_000) const [rateRow] = await asAdmin(db => db.select({ cnt: sql`count(*)` }) .from(auditLog) .where(and( eq(auditLog.changedBy, session.sub), eq(auditLog.tableName, 'incidents'), eq(auditLog.action, 'INSERT'), gte(auditLog.changedAt, since), )) ) if (Number(rateRow?.cnt ?? 0) > 0) return NextResponse.json({ error: 'Rate limited — please wait 60 seconds before submitting another incident' }, { status: 429 }) let body: Record let files: File[] = [] const contentType = request.headers.get('content-type') ?? '' if (contentType.includes('multipart/form-data')) { const form = await request.formData() body = Object.fromEntries( [...form.entries()].filter(([, v]) => typeof v === 'string') ) as Record files = form.getAll('files').filter((v): v is File => v instanceof File) } else { body = await request.json() } const input = { zone_token: body.zone_token as string, incident_type: body.incident_type as IncidentType, description: body.description as string, injury_involved: body.injury_involved === 'true' || body.injury_involved === true, asset_involved: body.asset_involved === 'true' || body.asset_involved === true, medical_status: body.medical_status as MedicalStatus | undefined || undefined, truck_id: (body.truck_id as string) || null, } const validation = validateIncidentInput(input) if (!validation.ok) { return NextResponse.json({ error: 'Validation failed', details: validation.errors }, { status: 422 }) } let truckId: string | null = null if (input.incident_type === 'transport') { const [truck] = await asAdmin(db => db.select({ id: trucks.id }) .from(trucks) .where(and(eq(trucks.id, input.truck_id!), eq(trucks.active, true))) .limit(1) ) if (!truck) return NextResponse.json({ error: 'Validation failed', details: ['truck not found or inactive'] }, { status: 422 }) truckId = truck.id } let rawDetails: Record | undefined if (typeof body.type_details === 'string' && body.type_details) { try { rawDetails = JSON.parse(body.type_details) } catch { return NextResponse.json({ error: 'Validation failed', details: ['type_details must be valid JSON'] }, { status: 422 }) } } else if (body.type_details && typeof body.type_details === 'object') { rawDetails = body.type_details as Record } const detailsCheck = validateTypeDetails(input.incident_type, rawDetails) if (!detailsCheck.ok) { return NextResponse.json({ error: 'Validation failed', details: detailsCheck.errors }, { status: 422 }) } // Zone lookup with site active check const [zone] = await asAdmin(db => db.select({ id: zones.id, siteId: zones.siteId, active: zones.active, siteActive: sites.active, }) .from(zones) .leftJoin(sites, eq(zones.siteId, sites.id)) .where(eq(zones.qrCodeToken, input.zone_token)) .limit(1) ) if (!zone) { return NextResponse.json({ error: 'Zone not found' }, { status: 404 }) } if (zone.active === false || zone.siteActive === false) { return NextResponse.json({ error: 'This location is no longer active for reporting.' }, { status: 409 }) } // Incident insert + audit in one withUser transaction let incidentId!: string let referenceNo: string | null = null await withUser(session.sub, async tx => { const [incident] = await tx.insert(incidents).values({ incidentType: input.incident_type as typeof incidents.$inferInsert['incidentType'], siteId: zone.siteId, zoneId: zone.id, reportedBy: session.sub, description: input.description.trim(), injuryInvolved: input.injury_involved, assetInvolved: input.asset_involved, medicalStatus: input.injury_involved ? ((input.medical_status ?? 'none') as typeof incidents.$inferInsert['medicalStatus']) : null, typeDetails: detailsCheck.sanitized ?? null, truckId: truckId ?? null, }).returning({ id: incidents.id, referenceNo: incidents.referenceNo }) if (!incident) throw new Error('Insert failed') incidentId = incident.id referenceNo = incident.referenceNo ?? null await writeAuditLog(tx, 'incidents', incident.id, 'INSERT', { incident_type: input.incident_type, reported_by: session.sub, }) }) // Evidence upload — storage still uses supabase (Phase 5 replaces this) const supabase = await createClient() const evidenceInserts: Array = [] for (const file of files) { try { const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incidentId, 'report', session.sub) evidenceInserts.push({ incidentId, stage: 'report', fileUrl: publicUrl, fileType: file.type, fileHash: hash, uploadedBy: session.sub, }) } catch (err) { console.error('file upload error:', err) } } if (evidenceInserts.length > 0) { await withUser(session.sub, async tx => { await tx.insert(evidenceFiles).values(evidenceInserts) }) } sendNewIncidentEmail(incidentId, zone.siteId, referenceNo ?? '', input.incident_type) .catch(err => console.error('email notification failed:', err)) // WhatsApp/in-app alert — fire-and-forget ;(async () => { try { const phoneNumberId = await getApiKey('META_WHATSAPP_PHONE_NUMBER_ID') const accessToken = await getApiKey('META_WHATSAPP_ACCESS_TOKEN') const [siteRow] = await asAdmin(db => db.select({ name: sites.name }).from(sites).where(eq(sites.id, zone.siteId)).limit(1) ) const siteName = siteRow?.name ?? 'Unknown' const recipients = await asAdmin(db => db.select({ id: users.id, phone: users.phone }) .from(users) .where(and( inArray(users.role, ['supervisor', 'hse']), eq(users.siteId, zone.siteId), )) ) await createInAppNotifications( recipients.map(r => ({ userId: r.id, title: `New ${input.incident_type.replace(/_/g, ' ')} incident ${referenceNo ?? ''} at ${siteName}`, link: `/hse/incidents/${incidentId}`, incidentId, })), ) for (const r of recipients) { if (!r.phone) continue await sendWhatsAppMessage(r.phone, 'ims_incident_alert', [referenceNo ?? '', input.incident_type, siteName], phoneNumberId, accessToken) } } catch (err) { console.error('WhatsApp incident alert error:', err) } })() // Embed description asynchronously getApiKey('GOOGLE_AI_API_KEY').then(googleAiKey => import('@/lib/claude/embed').then(({ embedText }) => embedText(input.description.trim(), googleAiKey).then(embeddingVec => { const embStr = `[${(embeddingVec as number[]).join(',')}]` return asAdmin(db => db.execute(sql`UPDATE incidents SET embedding = ${embStr}::vector(768) WHERE id = ${incidentId}::uuid`) ) }) ) ).catch(err => console.error('embed error:', err)) return NextResponse.json({ id: incidentId, reference_no: referenceNo }, { status: 201 }) }