229 lines
8.4 KiB
TypeScript
229 lines
8.4 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { getSession } from '@/lib/auth/get-session'
|
|
import { validateIncidentInput, validateTypeDetails, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
|
|
import { uploadEvidenceFile } from '@/lib/storage/evidence'
|
|
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<number>`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<string, unknown>
|
|
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<string, unknown>
|
|
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<string, unknown> | 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<string, unknown>
|
|
}
|
|
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 — local filesystem storage with HMAC-signed URLs
|
|
const evidenceInserts: Array<typeof evidenceFiles.$inferInsert> = []
|
|
for (const file of files) {
|
|
try {
|
|
const { publicUrl, hash } = await uploadEvidenceFile(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 })
|
|
}
|