diff --git a/app/api/incidents/[id]/addenda/route.ts b/app/api/incidents/[id]/addenda/route.ts index 42926f6..6f028fd 100644 --- a/app/api/incidents/[id]/addenda/route.ts +++ b/app/api/incidents/[id]/addenda/route.ts @@ -1,8 +1,12 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { aliasedTable } from 'drizzle-orm' import { getSession } from '@/lib/auth/get-session' +import { withUser } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { incidentAddenda, users } from '@/lib/db/schema' +import { eq, asc } from 'drizzle-orm' export async function GET( _request: NextRequest, @@ -14,16 +18,24 @@ export async function GET( if (!['hse', 'admin', 'supervisor'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() + const authorAlias = aliasedTable(users, 'author_user') + const data = await withUser(session.sub, async tx => + tx.select({ + id: incidentAddenda.id, + body: incidentAddenda.body, + createdAt: incidentAddenda.createdAt, + authorName: authorAlias.name, + }) + .from(incidentAddenda) + .leftJoin(authorAlias, eq(incidentAddenda.author, authorAlias.id)) + .where(eq(incidentAddenda.incidentId, id)) + .orderBy(asc(incidentAddenda.createdAt)) + ) - const { data, error } = await supabase - .from('incident_addenda') - .select('id, body, created_at, author:users!author (name)') - .eq('incident_id', id) - .order('created_at', { ascending: true }) - - if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 }) - return NextResponse.json(data ?? []) + return NextResponse.json(data.map(a => ({ + id: a.id, body: a.body, created_at: a.createdAt, + author: { name: a.authorName }, + }))) } export async function POST( @@ -36,28 +48,27 @@ export async function POST( if (!['hse', 'admin', 'supervisor'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - const body: { body?: string } = await request.json().catch(() => ({})) const text = (body.body ?? '').trim() if (!text) return NextResponse.json({ error: 'body required' }, { status: 422 }) if (text.length > 5000) return NextResponse.json({ error: 'body must be 5000 characters or fewer' }, { status: 422 }) - const { data: addendum, error } = await supabase - .from('incident_addenda') - .insert({ incident_id: id, author: session.sub, body: text }) - .select('id') - .single() + let addendumId!: string + await withUser(session.sub, async tx => { + const [addendum] = await tx.insert(incidentAddenda).values({ + incidentId: id, + author: session.sub, + body: text, + }).returning({ id: incidentAddenda.id }) - if (error || !addendum) return NextResponse.json({ error: 'Insert failed' }, { status: 500 }) + if (!addendum) throw new Error('Insert failed') + addendumId = addendum.id - await supabase.rpc('write_audit_log', { - p_table_name: 'incident_addenda', - p_record_id: addendum.id, - p_action: 'INSERT', - p_new_value: { incident_id: id, author: session.sub, body: text }, + await writeAuditLog(tx, 'incident_addenda', addendum.id, 'INSERT', { + incident_id: id, author: session.sub, body: text, + }) }) - return NextResponse.json({ id: addendum.id }, { status: 201 }) + return NextResponse.json({ id: addendumId }, { status: 201 }) } diff --git a/app/api/incidents/[id]/ai/rca-draft/route.ts b/app/api/incidents/[id]/ai/rca-draft/route.ts index 864d422..7c6bde0 100644 --- a/app/api/incidents/[id]/ai/rca-draft/route.ts +++ b/app/api/incidents/[id]/ai/rca-draft/route.ts @@ -1,8 +1,11 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' +import { withUser, asAdmin } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { incidents, auditLog, sites, zones } from '@/lib/db/schema' +import { eq, and, gte, sql } from 'drizzle-orm' import { createDeepSeekClient } from '@/lib/claude/client' import { getApiKey } from '@/lib/settings' @@ -16,45 +19,43 @@ export async function POST( if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - - const since = new Date(Date.now() - 60_000).toISOString() - const { count: recentCount } = await supabase - .from('audit_log') - .select('id', { count: 'exact', head: true }) - .eq('changed_by', session.sub) - .eq('action', 'ai_rca_draft') - .gte('changed_at', since) - if ((recentCount ?? 0) > 0) + // Rate limit + 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.action, 'ai_rca_draft'), + gte(auditLog.changedAt, since), + )) + ) + if (Number(rateRow?.cnt ?? 0) > 0) return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) const deepseekKey = await getApiKey('DEEPSEEK_API_KEY') const client = createDeepSeekClient(deepseekKey) - const { data: incident } = await supabase - .from('incidents') - .select(` - id, incident_type, description, severity, injury_involved, medical_status, - is_fatality, is_serious_bodily_injury, triage_notes, - sites (name), zones (name) - `) - .eq('id', id) - .single() + const [incident] = await withUser(session.sub, async tx => + tx.select({ + incidentType: incidents.incidentType, + description: incidents.description, + severity: incidents.severity, + injuryInvolved: incidents.injuryInvolved, + medicalStatus: incidents.medicalStatus, + isFatality: incidents.isFatality, + isSeriousBodilyInjury: incidents.isSeriousBodilyInjury, + triageNotes: incidents.triageNotes, + siteName: sites.name, + zoneName: zones.name, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .where(eq(incidents.id, id)) + .limit(1) + ) if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - const inc = incident as { - incident_type: string - description: string - severity: number | null - injury_involved: boolean - medical_status: string | null - is_fatality: boolean - is_serious_bodily_injury: boolean - triage_notes: string | null - } - const siteName = (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown' - const zoneName = (incident.zones as unknown as { name: string } | null)?.name ?? 'Unknown' - let res: Awaited> try { res = await client.chat.completions.create({ @@ -99,15 +100,15 @@ export async function POST( 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} -Incident type: ${inc.incident_type} -Description: ${inc.description} -Severity: ${inc.severity ?? 'not yet assigned'}/5 -Injury involved: ${inc.injury_involved ? `yes — ${inc.medical_status}` : 'no'} -Fatality: ${inc.is_fatality ? 'yes' : 'no'} -Serious bodily injury: ${inc.is_serious_bodily_injury ? 'yes' : 'no'} -Triage notes: ${inc.triage_notes ?? 'none'} +Site: ${incident.siteName ?? 'Unknown'} +Zone: ${incident.zoneName ?? 'Unknown'} +Incident type: ${incident.incidentType} +Description: ${incident.description} +Severity: ${incident.severity ?? 'not yet assigned'}/5 +Injury involved: ${incident.injuryInvolved ? `yes — ${incident.medicalStatus}` : 'no'} +Fatality: ${incident.isFatality ? 'yes' : 'no'} +Serious bodily injury: ${incident.isSeriousBodilyInjury ? 'yes' : 'no'} +Triage notes: ${incident.triageNotes ?? '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.`, }], @@ -131,14 +132,11 @@ Provide 3–5 Why steps drilling from immediate cause to root cause. Give a one- return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) } - await supabase.rpc('write_audit_log', { - p_table_name: 'incidents', - p_record_id: id, - p_action: 'ai_rca_draft', - p_new_value: { - root_cause_summary: draft.root_cause_summary, + await withUser(session.sub, async tx => { + await writeAuditLog(tx, 'incidents', id, 'ai_rca_draft', { + root_cause_summary: draft.root_cause_summary as string, model: 'deepseek-chat', - } as never, + }) }) return NextResponse.json(draft) diff --git a/app/api/incidents/[id]/ai/triage-suggest/route.ts b/app/api/incidents/[id]/ai/triage-suggest/route.ts index 445686e..c46607d 100644 --- a/app/api/incidents/[id]/ai/triage-suggest/route.ts +++ b/app/api/incidents/[id]/ai/triage-suggest/route.ts @@ -1,8 +1,11 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' +import { withUser, asAdmin } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { incidents, auditLog } from '@/lib/db/schema' +import { eq, and, gte, sql } from 'drizzle-orm' import { createDeepSeekClient } from '@/lib/claude/client' import { getApiKey } from '@/lib/settings' @@ -16,36 +19,34 @@ export async function POST( if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - - const since = new Date(Date.now() - 60_000).toISOString() - const { count: recentCount } = await supabase - .from('audit_log') - .select('id', { count: 'exact', head: true }) - .eq('changed_by', session.sub) - .eq('action', 'ai_triage_suggest') - .gte('changed_at', since) - if ((recentCount ?? 0) > 0) + // Rate limit + 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.action, 'ai_triage_suggest'), + gte(auditLog.changedAt, since), + )) + ) + if (Number(rateRow?.cnt ?? 0) > 0) return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) const deepseekKey = await getApiKey('DEEPSEEK_API_KEY') const client = createDeepSeekClient(deepseekKey) - const { data: incident } = await supabase - .from('incidents') - .select('id, incident_type, description, injury_involved, asset_involved, medical_status') - .eq('id', id) - .single() + const [incident] = await withUser(session.sub, async tx => + tx.select({ + incidentType: incidents.incidentType, + description: incidents.description, + injuryInvolved: incidents.injuryInvolved, + assetInvolved: incidents.assetInvolved, + medicalStatus: incidents.medicalStatus, + }) + .from(incidents).where(eq(incidents.id, id)).limit(1) + ) if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - const inc = incident as { - incident_type: string - description: string - injury_involved: boolean - asset_involved: boolean - medical_status: string | null - } - let res: Awaited> try { res = await client.chat.completions.create({ @@ -78,11 +79,11 @@ export async function POST( 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} -Injury involved: ${inc.injury_involved ? 'yes' : 'no'} -Medical status: ${inc.medical_status ?? 'N/A'} -Asset/equipment involved: ${inc.asset_involved ? 'yes' : 'no'} +Incident type: ${incident.incidentType} +Description: ${incident.description} +Injury involved: ${incident.injuryInvolved ? 'yes' : 'no'} +Medical status: ${incident.medicalStatus ?? 'N/A'} +Asset/equipment involved: ${incident.assetInvolved ? 'yes' : 'no'} Suggest severity (1–5) and tick the appropriate NADOPOD 2004 flags. Give a one-sentence rationale.`, }], @@ -116,11 +117,8 @@ Suggest severity (1–5) and tick the appropriate NADOPOD 2004 flags. Give a one return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) } - await supabase.rpc('write_audit_log', { - p_table_name: 'incidents', - p_record_id: id, - p_action: 'ai_triage_suggest', - p_new_value: { suggestion: input, model: 'deepseek-chat' } as never, + await withUser(session.sub, async tx => { + await writeAuditLog(tx, 'incidents', id, 'ai_triage_suggest', { suggestion: input, model: 'deepseek-chat' }) }) return NextResponse.json(input) diff --git a/app/api/incidents/[id]/close/route.ts b/app/api/incidents/[id]/close/route.ts index 64b975c..7f4551f 100644 --- a/app/api/incidents/[id]/close/route.ts +++ b/app/api/incidents/[id]/close/route.ts @@ -1,8 +1,11 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' +import { withUser } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { incidents, capaActions } from '@/lib/db/schema' +import { eq, and, not, inArray } from 'drizzle-orm' import { createInAppNotifications } from '@/lib/notifications/in-app' export async function POST( @@ -15,49 +18,43 @@ export async function POST( if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - - const { data: incident } = await supabase - .from('incidents') - .select('status, reference_no, reported_by') - .eq('id', id) - .single() + const [incident] = await withUser(session.sub, async tx => + tx.select({ status: incidents.status, referenceNo: incidents.referenceNo, reportedBy: incidents.reportedBy }) + .from(incidents).where(eq(incidents.id, id)).limit(1) + ) if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) if (incident.status === 'closed') return NextResponse.json({ error: 'Already closed' }, { status: 409 }) if (incident.status !== 'verification') return NextResponse.json({ error: 'Incident must be in verification status' }, { status: 409 }) - const { data: openCapas } = await supabase - .from('capa_actions') - .select('id') - .eq('incident_id', id) - .not('status', 'in', '(verified,closed)') - if (openCapas && openCapas.length > 0) + const openCapas = await withUser(session.sub, async tx => + tx.select({ id: capaActions.id }) + .from(capaActions) + .where(and( + eq(capaActions.incidentId, id), + not(inArray(capaActions.status, ['verified', 'closed'])), + )) + ) + if (openCapas.length > 0) return NextResponse.json({ error: 'All CAPA actions must be verified before closure' }, { status: 409 }) - const closedAt = new Date().toISOString() - const { error } = await supabase - .from('incidents') - .update({ status: 'closed', closed_at: closedAt }) - .eq('id', id) - if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 }) - - await supabase.rpc('write_audit_log', { - p_table_name: 'incidents', - p_record_id: id, - p_action: 'closed', - p_new_value: { status: 'closed', closed_at: closedAt, closed_by: session.sub }, + const closedAt = new Date() + await withUser(session.sub, async tx => { + await tx.update(incidents).set({ status: 'closed', closedAt }).where(eq(incidents.id, id)) + await writeAuditLog(tx, 'incidents', id, 'closed', { + status: 'closed', closed_at: closedAt.toISOString(), closed_by: session.sub, + }) }) - if (incident.reported_by) { + if (incident.reportedBy) { await createInAppNotifications([{ - userId: incident.reported_by, - title: `Your incident report ${incident.reference_no ?? ''} has been closed`, + userId: incident.reportedBy, + title: `Your incident report ${incident.referenceNo ?? ''} has been closed`, link: '/reporter', incidentId: id, }]) } - return NextResponse.json({ ok: true, closed_at: closedAt }) + return NextResponse.json({ ok: true, closed_at: closedAt.toISOString() }) } diff --git a/app/api/incidents/[id]/investigation/route.ts b/app/api/incidents/[id]/investigation/route.ts index fad525e..3a45790 100644 --- a/app/api/incidents/[id]/investigation/route.ts +++ b/app/api/incidents/[id]/investigation/route.ts @@ -1,8 +1,11 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' +import { withUser } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { incidents, investigations } from '@/lib/db/schema' +import { eq, and, sql } from 'drizzle-orm' export async function POST( request: NextRequest, @@ -14,56 +17,47 @@ export async function POST( if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - - const { data: incident } = await supabase - .from('incidents').select('status').eq('id', id).single() + const [incident] = await withUser(session.sub, async tx => + tx.select({ status: incidents.status }).from(incidents).where(eq(incidents.id, id)).limit(1) + ) if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) if (incident.status !== 'triaged') return NextResponse.json({ error: 'Incident must be triaged first' }, { status: 409 }) - const { count: existingCount } = await supabase - .from('investigations') - .select('id', { count: 'exact', head: true }) - .eq('incident_id', id) - if ((existingCount ?? 0) > 0) + const [existingCount] = await withUser(session.sub, async tx => + tx.select({ cnt: sql`count(*)` }).from(investigations).where(eq(investigations.incidentId, id)) + ) + if (Number(existingCount?.cnt ?? 0) > 0) return NextResponse.json({ error: 'Investigation already exists for this incident' }, { status: 409 }) const body = await request.json() const method: 'five_why' | 'fishbone' | 'other' = body.method ?? 'five_why' - const { data: inv, error } = await supabase - .from('investigations') - .insert({ - incident_id: id, - investigator_id: session.sub, + let invId!: string + await withUser(session.sub, async tx => { + const [inv] = await tx.insert(investigations).values({ + incidentId: id, + investigatorId: session.sub, method, - findings_text: body.findings_text ?? null, - root_cause_summary: body.root_cause_summary ?? null, - five_why_steps: method === 'five_why' ? (body.five_why_steps ?? []) : null, - fishbone_categories: method === 'fishbone' ? (body.fishbone_categories ?? []) : null, - alcohol_test_result: body.alcohol_test_result ?? null, - urine_test_result: body.urine_test_result ?? null, - witness_statement_refs: Array.isArray(body.witness_statement_refs) ? body.witness_statement_refs : [], + findingsText: body.findings_text ?? null, + rootCauseSummary: body.root_cause_summary ?? null, + fiveWhySteps: method === 'five_why' ? (body.five_why_steps ?? []) : null, + fishboneCategories: method === 'fishbone' ? (body.fishbone_categories ?? []) : null, + alcoholTestResult: body.alcohol_test_result ?? null, + urineTestResult: body.urine_test_result ?? null, + witnessStatementRefs: Array.isArray(body.witness_statement_refs) ? body.witness_statement_refs : [], + }).returning({ id: investigations.id }) + + if (!inv) throw new Error('Insert failed') + invId = inv.id + + await tx.update(incidents).set({ status: 'investigating' }).where(eq(incidents.id, id)) + await writeAuditLog(tx, 'incidents', id, 'investigation_started', { + status: 'investigating', investigation_id: inv.id, }) - .select('id') - .single() - - if (error || !inv) return NextResponse.json({ error: 'Insert failed' }, { status: 500 }) - - await supabase - .from('incidents') - .update({ status: 'investigating' }) - .eq('id', id) - - await supabase.rpc('write_audit_log', { - p_table_name: 'incidents', - p_record_id: id, - p_action: 'investigation_started', - p_new_value: { status: 'investigating', investigation_id: inv.id }, }) - return NextResponse.json({ id: inv.id }) + return NextResponse.json({ id: invId }) } export async function PATCH( @@ -76,45 +70,32 @@ export async function PATCH( if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - const body = await request.json() const { investigation_id, complete, ...fields } = body if (!investigation_id) return NextResponse.json({ error: 'investigation_id required' }, { status: 422 }) - const updateData: Record = { - findings_text: fields.findings_text ?? null, - root_cause_summary: fields.root_cause_summary ?? null, - five_why_steps: fields.five_why_steps ?? null, - fishbone_categories: fields.fishbone_categories ?? null, - alcohol_test_result: fields.alcohol_test_result ?? null, - urine_test_result: fields.urine_test_result ?? null, - witness_statement_refs: Array.isArray(fields.witness_statement_refs) ? fields.witness_statement_refs : [], + const updateData: Partial = { + findingsText: fields.findings_text ?? null, + rootCauseSummary: fields.root_cause_summary ?? null, + fiveWhySteps: fields.five_why_steps ?? null, + fishboneCategories: fields.fishbone_categories ?? null, + alcoholTestResult: fields.alcohol_test_result ?? null, + urineTestResult: fields.urine_test_result ?? null, + witnessStatementRefs: Array.isArray(fields.witness_statement_refs) ? fields.witness_statement_refs : [], } - if (complete) updateData.completed_at = new Date().toISOString() + if (complete) updateData.completedAt = new Date() - const { error } = await supabase - .from('investigations') - .update(updateData) - .eq('id', investigation_id) - .eq('incident_id', id) + await withUser(session.sub, async tx => { + await tx.update(investigations) + .set(updateData) + .where(and(eq(investigations.id, investigation_id), eq(investigations.incidentId, id))) - if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 }) - - if (complete) { - await supabase - .from('incidents') - .update({ status: 'capa_pending' }) - .eq('id', id) - - await supabase.rpc('write_audit_log', { - p_table_name: 'incidents', - p_record_id: id, - p_action: 'investigation_completed', - p_new_value: { status: 'capa_pending' }, - }) - } + if (complete) { + await tx.update(incidents).set({ status: 'capa_pending' }).where(eq(incidents.id, id)) + await writeAuditLog(tx, 'incidents', id, 'investigation_completed', { status: 'capa_pending' }) + } + }) return NextResponse.json({ ok: true }) } diff --git a/app/api/incidents/[id]/jkkp-pdf/route.ts b/app/api/incidents/[id]/jkkp-pdf/route.ts index 89dc23a..31eb629 100644 --- a/app/api/incidents/[id]/jkkp-pdf/route.ts +++ b/app/api/incidents/[id]/jkkp-pdf/route.ts @@ -1,8 +1,11 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { aliasedTable } from 'drizzle-orm' import { getSession } from '@/lib/auth/get-session' +import { withUser } from '@/lib/db/with-user' +import { incidents, sites, users } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' import { buildJkkp6Pdf, buildJkkp7Pdf, type JkkpIncident } from '@/lib/pdf/jkkp' import { computeDoshObligation } from '@/lib/incidents/dosh' @@ -20,44 +23,55 @@ export async function GET( if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() + const reporterAlias = aliasedTable(users, 'reporter') - const { data: incident } = await supabase - .from('incidents') - .select(` - reference_no, incident_type, description, reported_at, severity, lost_days, - is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease, - sites (name), - reporter:users!reported_by (name) - `) - .eq('id', id) - .single() + const [incident] = await withUser(session.sub, async tx => + tx.select({ + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + description: incidents.description, + reportedAt: incidents.reportedAt, + severity: incidents.severity, + lostDays: incidents.lostDays, + isFatality: incidents.isFatality, + isSeriousBodilyInjury: incidents.isSeriousBodilyInjury, + isDangerousOccurrence: incidents.isDangerousOccurrence, + isOccupationalDisease: incidents.isOccupationalDisease, + siteName: sites.name, + reporterName: reporterAlias.name, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .leftJoin(reporterAlias, eq(incidents.reportedBy, reporterAlias.id)) + .where(eq(incidents.id, id)) + .limit(1) + ) if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) const dosh = computeDoshObligation({ - is_fatality: (incident as { is_fatality: boolean }).is_fatality, - is_serious_bodily_injury: (incident as { is_serious_bodily_injury: boolean }).is_serious_bodily_injury, - is_dangerous_occurrence: (incident as { is_dangerous_occurrence: boolean }).is_dangerous_occurrence, - is_occupational_disease: (incident as { is_occupational_disease: boolean }).is_occupational_disease, - lost_days: (incident as { lost_days: number | null }).lost_days, + is_fatality: incident.isFatality, + is_serious_bodily_injury: incident.isSeriousBodilyInjury, + is_dangerous_occurrence: incident.isDangerousOccurrence, + is_occupational_disease: incident.isOccupationalDisease, + lost_days: incident.lostDays, }) const required = form === 'jkkp6' ? dosh.requires_jkkp6 : dosh.requires_jkkp7 if (!required) return NextResponse.json({ error: 'This form is not required for this incident' }, { status: 400 }) const jkkpIncident: JkkpIncident = { - reference_no: (incident as { reference_no: string | null }).reference_no, - incident_type: (incident as { incident_type: string }).incident_type, - description: (incident as { description: string }).description, - reported_at: (incident as { reported_at: string }).reported_at, - severity: (incident as { severity: number | null }).severity, - is_fatality: (incident as { is_fatality: boolean }).is_fatality, - is_serious_bodily_injury: (incident as { is_serious_bodily_injury: boolean }).is_serious_bodily_injury, - is_dangerous_occurrence: (incident as { is_dangerous_occurrence: boolean }).is_dangerous_occurrence, - lost_days: (incident as { lost_days: number | null }).lost_days, - site_name: (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown', - reporter_name: (incident.reporter as unknown as { name: string } | null)?.name ?? 'Unknown', + reference_no: incident.referenceNo, + incident_type: incident.incidentType, + description: incident.description, + reported_at: incident.reportedAt instanceof Date ? incident.reportedAt.toISOString() : (incident.reportedAt as string), + severity: incident.severity, + is_fatality: incident.isFatality, + is_serious_bodily_injury: incident.isSeriousBodilyInjury, + is_dangerous_occurrence: incident.isDangerousOccurrence, + lost_days: incident.lostDays, + site_name: incident.siteName ?? 'Unknown', + reporter_name: incident.reporterName ?? 'Unknown', } const pdfBytes = form === 'jkkp6' diff --git a/app/api/incidents/[id]/route.ts b/app/api/incidents/[id]/route.ts index 8352445..42a7699 100644 --- a/app/api/incidents/[id]/route.ts +++ b/app/api/incidents/[id]/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { aliasedTable } from 'drizzle-orm' import { getSession } from '@/lib/auth/get-session' +import { withUser } from '@/lib/db/with-user' +import { incidents, evidenceFiles, sites, zones, users } from '@/lib/db/schema' +import { eq, and } from 'drizzle-orm' export const dynamic = 'force-dynamic' @@ -9,34 +12,73 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str const session = await getSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const supabase = await createClient() - const role = session.role + const reporterAlias = aliasedTable(users, 'reporter') - const { data: incident, error } = await supabase - .from('incidents') - .select(` - id, reference_no, incident_type, description, severity, status, - injury_involved, asset_involved, medical_status, lost_days, - reported_at, closed_at, - sites (id, name), - zones (id, name), - reporter:users!reported_by (id, name, email), - evidence_files (id, stage, file_url, file_type, uploaded_at) - `) - .eq('id', id) - .eq('evidence_files.deleted', false) - .single() + const [incident] = await withUser(session.sub, async tx => + tx.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + description: incidents.description, + severity: incidents.severity, + status: incidents.status, + injuryInvolved: incidents.injuryInvolved, + assetInvolved: incidents.assetInvolved, + medicalStatus: incidents.medicalStatus, + lostDays: incidents.lostDays, + reportedAt: incidents.reportedAt, + closedAt: incidents.closedAt, + siteId: sites.id, + siteName: sites.name, + zoneId: zones.id, + zoneName: zones.name, + reporterId: reporterAlias.id, + reporterName: reporterAlias.name, + reporterEmail: reporterAlias.email, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .leftJoin(reporterAlias, eq(incidents.reportedBy, reporterAlias.id)) + .where(eq(incidents.id, id)) + .limit(1) + ) + if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - if (error || !incident) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } + const isOwner = incident.reporterId === session.sub + const isSiteStaff = ['hse', 'supervisor', 'admin'].includes(session.role) + if (!isOwner && !isSiteStaff) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const reporter = incident.reporter as unknown as { id: string } | null - const isOwner = reporter?.id === session.sub - const isSiteStaff = ['hse', 'supervisor', 'admin'].includes(role) - if (!isOwner && !isSiteStaff) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const evidenceFileRows = await withUser(session.sub, async tx => + tx.select({ + id: evidenceFiles.id, + stage: evidenceFiles.stage, + fileUrl: evidenceFiles.fileUrl, + fileType: evidenceFiles.fileType, + uploadedAt: evidenceFiles.uploadedAt, + }) + .from(evidenceFiles) + .where(and(eq(evidenceFiles.incidentId, id), eq(evidenceFiles.deleted, false))) + ) - return NextResponse.json(incident) + return NextResponse.json({ + id: incident.id, + reference_no: incident.referenceNo, + incident_type: incident.incidentType, + description: incident.description, + severity: incident.severity, + status: incident.status, + injury_involved: incident.injuryInvolved, + asset_involved: incident.assetInvolved, + medical_status: incident.medicalStatus, + lost_days: incident.lostDays, + reported_at: incident.reportedAt, + closed_at: incident.closedAt, + sites: incident.siteId ? { id: incident.siteId, name: incident.siteName } : null, + zones: incident.zoneId ? { id: incident.zoneId, name: incident.zoneName } : null, + reporter: incident.reporterId ? { id: incident.reporterId, name: incident.reporterName, email: incident.reporterEmail } : null, + evidence_files: evidenceFileRows.map(ef => ({ + id: ef.id, stage: ef.stage, file_url: ef.fileUrl, file_type: ef.fileType, uploaded_at: ef.uploadedAt, + })), + }) } diff --git a/app/api/incidents/[id]/similar/route.ts b/app/api/incidents/[id]/similar/route.ts index 6bac5af..c436146 100644 --- a/app/api/incidents/[id]/similar/route.ts +++ b/app/api/incidents/[id]/similar/route.ts @@ -1,8 +1,10 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' +import { withUser, asAdmin } from '@/lib/db/with-user' +import { incidents } from '@/lib/db/schema' +import { eq, sql } from 'drizzle-orm' import { embedText } from '@/lib/claude/embed' import { getApiKey } from '@/lib/settings' @@ -16,42 +18,39 @@ export async function GET( if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - const googleAiKey = await getApiKey('GOOGLE_AI_API_KEY') - const { data: incident } = await supabase - .from('incidents') - .select('id, description, embedding, status') - .eq('id', id) - .single() - if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - - const inc = incident as { id: string; description: string; embedding: string | null; status: string } + const [incidentRow] = await withUser(session.sub, async tx => + tx.select({ id: incidents.id, description: incidents.description, embedding: incidents.embedding, status: incidents.status }) + .from(incidents).where(eq(incidents.id, id)).limit(1) + ) + if (!incidentRow) return NextResponse.json({ error: 'Not found' }, { status: 404 }) let embeddingVec: number[] - try { - if (inc.embedding) { - embeddingVec = JSON.parse(inc.embedding) as number[] - } else { - embeddingVec = await embedText(inc.description, googleAiKey) - // Closed incidents are locked at the DB level — the trigger would reject - // this backfill. The vector still serves the similarity query below. - if (inc.status !== 'closed') { - const { error: persistError } = await supabase.from('incidents').update({ - embedding: `[${embeddingVec.join(',')}]` as unknown as string, - }).eq('id', id) - if (persistError) console.error('embedding backfill error:', persistError) + if (incidentRow.embedding) { + // pg driver may return the vector as a string — parse if needed + const raw = incidentRow.embedding + embeddingVec = typeof raw === 'string' ? (JSON.parse(raw) as number[]) : (raw as number[]) + } else { + try { + embeddingVec = await embedText(incidentRow.description, googleAiKey) + if (incidentRow.status !== 'closed') { + const embStr = `[${embeddingVec.join(',')}]` + await asAdmin(db => + db.execute(sql`UPDATE incidents SET embedding = ${embStr}::vector(768) WHERE id = ${id}::uuid`) + ) } + } catch { + return NextResponse.json({ error: 'Embedding service unavailable' }, { status: 503 }) } - } catch { - return NextResponse.json({ error: 'Embedding service unavailable' }, { status: 503 }) } - const { data: similar } = await supabase.rpc('match_incidents', { - query_embedding: `[${embeddingVec.join(',')}]`, - exclude_id: id, - match_count: 5, + const embStr = `[${embeddingVec.join(',')}]` + const similar = await withUser(session.sub, async tx => { + const result = await tx.execute( + sql`SELECT * FROM match_incidents(${embStr}::vector(768), ${id}::uuid, ${5})` + ) + return result.rows }) return NextResponse.json(similar ?? []) diff --git a/app/api/incidents/[id]/triage/route.ts b/app/api/incidents/[id]/triage/route.ts index 8eea758..29c78b5 100644 --- a/app/api/incidents/[id]/triage/route.ts +++ b/app/api/incidents/[id]/triage/route.ts @@ -1,8 +1,11 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' +import { withUser } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { incidents } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' interface TriageBody { severity: number @@ -23,40 +26,33 @@ export async function PATCH( if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - const body: TriageBody = await request.json() if (body.severity < 1 || body.severity > 5) return NextResponse.json({ error: 'severity must be 1–5' }, { status: 422 }) - const { data: incident } = await supabase - .from('incidents').select('status').eq('id', id).single() + const [incident] = await withUser(session.sub, async tx => + tx.select({ status: incidents.status }).from(incidents).where(eq(incidents.id, id)).limit(1) + ) if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) if (incident.status !== 'reported') return NextResponse.json({ error: 'Incident is not in reported status' }, { status: 409 }) - const { error } = await supabase - .from('incidents') - .update({ + await withUser(session.sub, async tx => { + await tx.update(incidents).set({ severity: body.severity, - is_fatality: body.is_fatality, - is_serious_bodily_injury: body.is_serious_bodily_injury, - is_dangerous_occurrence: body.is_dangerous_occurrence, - is_occupational_disease: body.is_occupational_disease, - triage_notes: body.triage_notes ?? null, - triaged_by: session.sub, - triaged_at: new Date().toISOString(), + isFatality: body.is_fatality, + isSeriousBodilyInjury: body.is_serious_bodily_injury, + isDangerousOccurrence: body.is_dangerous_occurrence, + isOccupationalDisease: body.is_occupational_disease, + triageNotes: body.triage_notes ?? null, + triagedBy: session.sub, + triagedAt: new Date(), status: 'triaged', + }).where(eq(incidents.id, id)) + + await writeAuditLog(tx, 'incidents', id, 'triage', { + severity: body.severity, status: 'triaged', triaged_by: session.sub, }) - .eq('id', id) - - if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 }) - - await supabase.rpc('write_audit_log', { - p_table_name: 'incidents', - p_record_id: id, - p_action: 'triage', - p_new_value: { severity: body.severity, status: 'triaged', triaged_by: session.sub }, }) return NextResponse.json({ ok: true }) diff --git a/app/api/incidents/ai/quality-check/route.ts b/app/api/incidents/ai/quality-check/route.ts index 0493f83..dfdb562 100644 --- a/app/api/incidents/ai/quality-check/route.ts +++ b/app/api/incidents/ai/quality-check/route.ts @@ -1,8 +1,11 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' +import { withUser, asAdmin } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { auditLog } from '@/lib/db/schema' +import { eq, and, gte, sql } from 'drizzle-orm' import { createDeepSeekClient } from '@/lib/claude/client' import { getApiKey } from '@/lib/settings' @@ -10,16 +13,17 @@ export async function POST(request: NextRequest) { 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: recentCount } = await supabase - .from('audit_log') - .select('id', { count: 'exact', head: true }) - .eq('changed_by', session.sub) - .eq('action', 'ai_quality_check') - .gte('changed_at', since) - if ((recentCount ?? 0) > 0) + // Rate limit + 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.action, 'ai_quality_check'), + gte(auditLog.changedAt, since), + )) + ) + if (Number(rateRow?.cnt ?? 0) > 0) return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) const deepseekKey = await getApiKey('DEEPSEEK_API_KEY') @@ -92,11 +96,11 @@ Score 1–10 based on: specificity (location, time, persons involved), completen return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) } - await supabase.rpc('write_audit_log', { - p_table_name: 'incidents', - p_record_id: session.sub, - p_action: 'ai_quality_check', - p_new_value: { score: input.score, passes: input.passes, model: 'deepseek-chat' } as never, + // audit write uses session.sub as a pseudo record-id (no incident_id at this stage) + await withUser(session.sub, async tx => { + await writeAuditLog(tx, 'incidents', session.sub, 'ai_quality_check', { + score: input.score, passes: input.passes, model: 'deepseek-chat', + }) }) return NextResponse.json(input) diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index 80f9f65..9fc9fc0 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -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`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 @@ -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 = [] 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 }) }