feat(db): phase 4 group 3 — incident routes to Drizzle
Convert all 11 incident API routes from Supabase PostgREST to Drizzle ORM with withUser/asAdmin/writeAuditLog patterns and RLS enforcement. Only uploadEvidenceFile retains supabase client (Phase 5 storage work). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+112
-101
@@ -2,11 +2,15 @@ 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, type EvidenceStage } from '@/lib/supabase/storage'
|
||||
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'
|
||||
|
||||
@@ -23,17 +27,19 @@ async function handlePost(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
const since = new Date(Date.now() - 60_000).toISOString()
|
||||
const { count: recentIncidents } = await supabase
|
||||
.from('audit_log')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('changed_by', session.sub)
|
||||
.eq('table_name', 'incidents')
|
||||
.eq('action', 'INSERT')
|
||||
.gte('changed_at', since)
|
||||
if ((recentIncidents ?? 0) > 0)
|
||||
// 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>
|
||||
@@ -67,8 +73,12 @@ async function handlePost(request: Request) {
|
||||
|
||||
let truckId: string | null = null
|
||||
if (input.incident_type === 'transport') {
|
||||
const { data: truck } = await supabase
|
||||
.from('trucks').select('id').eq('id', input.truck_id).eq('active', true).single()
|
||||
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
|
||||
}
|
||||
@@ -88,132 +98,133 @@ async function handlePost(request: Request) {
|
||||
return NextResponse.json({ error: 'Validation failed', details: detailsCheck.errors }, { status: 422 })
|
||||
}
|
||||
|
||||
const { data: zone, error: zoneError } = await supabase
|
||||
.from('zones')
|
||||
.select('id, site_id, active, sites(active)')
|
||||
.eq('qr_code_token', input.zone_token)
|
||||
.single()
|
||||
// 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 (zoneError || !zone) {
|
||||
if (!zone) {
|
||||
return NextResponse.json({ error: 'Zone not found' }, { status: 404 })
|
||||
}
|
||||
if (zone.active === false || (zone.sites as { active?: boolean } | null)?.active === false) {
|
||||
if (zone.active === false || zone.siteActive === false) {
|
||||
return NextResponse.json({ error: 'This location is no longer active for reporting.' }, { status: 409 })
|
||||
}
|
||||
|
||||
const { data: incident, error: incidentError } = await supabase
|
||||
.from('incidents')
|
||||
.insert({
|
||||
incident_type: input.incident_type,
|
||||
site_id: zone.site_id,
|
||||
zone_id: zone.id,
|
||||
reported_by: session.sub,
|
||||
// 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(),
|
||||
injury_involved: input.injury_involved,
|
||||
asset_involved: input.asset_involved,
|
||||
medical_status: input.injury_involved ? (input.medical_status ?? 'none') : null,
|
||||
type_details: detailsCheck.sanitized,
|
||||
truck_id: truckId,
|
||||
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,
|
||||
})
|
||||
.select('id, reference_no')
|
||||
.single()
|
||||
|
||||
if (incidentError || !incident) {
|
||||
console.error('incident insert error:', incidentError)
|
||||
return NextResponse.json({ error: 'Failed to create incident' }, { status: 500 })
|
||||
}
|
||||
|
||||
const evidenceRows: Array<{
|
||||
incident_id: string
|
||||
stage: EvidenceStage
|
||||
file_url: string
|
||||
file_type: string
|
||||
file_hash: string
|
||||
uploaded_by: string
|
||||
}> = []
|
||||
})
|
||||
|
||||
// Evidence upload — storage still uses supabase (Phase 5 replaces this)
|
||||
const supabase = await createClient()
|
||||
const evidenceInserts: Array<typeof evidenceFiles.$inferInsert> = []
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incident.id, 'report', session.sub)
|
||||
evidenceRows.push({
|
||||
incident_id: incident.id,
|
||||
const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incidentId, 'report', session.sub)
|
||||
evidenceInserts.push({
|
||||
incidentId,
|
||||
stage: 'report',
|
||||
file_url: publicUrl,
|
||||
file_type: file.type,
|
||||
file_hash: hash,
|
||||
uploaded_by: session.sub,
|
||||
fileUrl: publicUrl,
|
||||
fileType: file.type,
|
||||
fileHash: hash,
|
||||
uploadedBy: session.sub,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('file upload error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (evidenceRows.length > 0) {
|
||||
await supabase.from('evidence_files').insert(evidenceRows)
|
||||
if (evidenceInserts.length > 0) {
|
||||
await withUser(session.sub, async tx => {
|
||||
await tx.insert(evidenceFiles).values(evidenceInserts)
|
||||
})
|
||||
}
|
||||
|
||||
await supabase.rpc('write_audit_log', {
|
||||
p_table_name: 'incidents',
|
||||
p_record_id: incident.id,
|
||||
p_action: 'INSERT',
|
||||
p_new_value: { incident_type: input.incident_type, reported_by: session.sub },
|
||||
})
|
||||
|
||||
sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type)
|
||||
sendNewIncidentEmail(incidentId, zone.siteId, referenceNo ?? '', input.incident_type)
|
||||
.catch(err => console.error('email notification failed:', err))
|
||||
|
||||
// WhatsApp alert — fire-and-forget alongside email
|
||||
// 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 { data: siteData } = await supabase
|
||||
.from('sites').select('name').eq('id', zone.site_id).single()
|
||||
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
|
||||
const { data: recipients } = await supabase
|
||||
.from('users')
|
||||
.select('id, phone')
|
||||
.in('role', ['supervisor', 'hse'])
|
||||
.eq('site_id', zone.site_id)
|
||||
|
||||
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: { id: string }) => ({
|
||||
recipients.map(r => ({
|
||||
userId: r.id,
|
||||
title: `New ${input.incident_type.replace(/_/g, ' ')} incident ${incident.reference_no ?? ''} at ${siteName}`,
|
||||
link: `/hse/incidents/${incident.id}`,
|
||||
incidentId: incident.id,
|
||||
title: `New ${input.incident_type.replace(/_/g, ' ')} incident ${referenceNo ?? ''} at ${siteName}`,
|
||||
link: `/hse/incidents/${incidentId}`,
|
||||
incidentId,
|
||||
})),
|
||||
)
|
||||
|
||||
for (const r of recipients ?? []) {
|
||||
const phone = (r as { phone: string | null }).phone ?? ''
|
||||
if (!phone) continue
|
||||
await sendWhatsAppMessage(
|
||||
phone,
|
||||
'ims_incident_alert',
|
||||
[incident.reference_no ?? '', input.incident_type, siteName],
|
||||
phoneNumberId,
|
||||
accessToken,
|
||||
)
|
||||
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 for future similarity search
|
||||
const supabaseForEmbed = supabase
|
||||
import('@/lib/settings').then(({ getApiKey }) =>
|
||||
getApiKey('GOOGLE_AI_API_KEY').then(googleAiKey =>
|
||||
import('@/lib/claude/embed').then(({ embedText }) =>
|
||||
embedText(input.description.trim(), googleAiKey).then(embedding =>
|
||||
supabase.from('incidents').update({
|
||||
embedding: `[${embedding.join(',')}]` as unknown as string,
|
||||
}).eq('id', incident.id)
|
||||
// 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: incident.id, reference_no: incident.reference_no }, { status: 201 })
|
||||
return NextResponse.json({ id: incidentId, reference_no: referenceNo }, { status: 201 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user