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:
@@ -1,8 +1,12 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
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 { 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(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
@@ -14,16 +18,24 @@ export async function GET(
|
|||||||
if (!['hse', 'admin', 'supervisor'].includes(session.role))
|
if (!['hse', 'admin', 'supervisor'].includes(session.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
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
|
return NextResponse.json(data.map(a => ({
|
||||||
.from('incident_addenda')
|
id: a.id, body: a.body, created_at: a.createdAt,
|
||||||
.select('id, body, created_at, author:users!author (name)')
|
author: { name: a.authorName },
|
||||||
.eq('incident_id', id)
|
})))
|
||||||
.order('created_at', { ascending: true })
|
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
|
|
||||||
return NextResponse.json(data ?? [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@@ -36,28 +48,27 @@ export async function POST(
|
|||||||
if (!['hse', 'admin', 'supervisor'].includes(session.role))
|
if (!['hse', 'admin', 'supervisor'].includes(session.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
|
|
||||||
const body: { body?: string } = await request.json().catch(() => ({}))
|
const body: { body?: string } = await request.json().catch(() => ({}))
|
||||||
const text = (body.body ?? '').trim()
|
const text = (body.body ?? '').trim()
|
||||||
if (!text) return NextResponse.json({ error: 'body required' }, { status: 422 })
|
if (!text) return NextResponse.json({ error: 'body required' }, { status: 422 })
|
||||||
if (text.length > 5000)
|
if (text.length > 5000)
|
||||||
return NextResponse.json({ error: 'body must be 5000 characters or fewer' }, { status: 422 })
|
return NextResponse.json({ error: 'body must be 5000 characters or fewer' }, { status: 422 })
|
||||||
|
|
||||||
const { data: addendum, error } = await supabase
|
let addendumId!: string
|
||||||
.from('incident_addenda')
|
await withUser(session.sub, async tx => {
|
||||||
.insert({ incident_id: id, author: session.sub, body: text })
|
const [addendum] = await tx.insert(incidentAddenda).values({
|
||||||
.select('id')
|
incidentId: id,
|
||||||
.single()
|
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', {
|
await writeAuditLog(tx, 'incident_addenda', addendum.id, 'INSERT', {
|
||||||
p_table_name: 'incident_addenda',
|
incident_id: id, author: session.sub, body: text,
|
||||||
p_record_id: addendum.id,
|
})
|
||||||
p_action: 'INSERT',
|
|
||||||
p_new_value: { incident_id: id, author: session.sub, body: text },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ id: addendum.id }, { status: 201 })
|
return NextResponse.json({ id: addendumId }, { status: 201 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { getSession } from '@/lib/auth/get-session'
|
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 { createDeepSeekClient } from '@/lib/claude/client'
|
||||||
import { getApiKey } from '@/lib/settings'
|
import { getApiKey } from '@/lib/settings'
|
||||||
|
|
||||||
@@ -16,45 +19,43 @@ export async function POST(
|
|||||||
if (!['hse', 'admin'].includes(session.role))
|
if (!['hse', 'admin'].includes(session.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
// Rate limit
|
||||||
|
const since = new Date(Date.now() - 60_000)
|
||||||
const since = new Date(Date.now() - 60_000).toISOString()
|
const [rateRow] = await asAdmin(db =>
|
||||||
const { count: recentCount } = await supabase
|
db.select({ cnt: sql<number>`count(*)` }).from(auditLog)
|
||||||
.from('audit_log')
|
.where(and(
|
||||||
.select('id', { count: 'exact', head: true })
|
eq(auditLog.changedBy, session.sub),
|
||||||
.eq('changed_by', session.sub)
|
eq(auditLog.action, 'ai_rca_draft'),
|
||||||
.eq('action', 'ai_rca_draft')
|
gte(auditLog.changedAt, since),
|
||||||
.gte('changed_at', since)
|
))
|
||||||
if ((recentCount ?? 0) > 0)
|
)
|
||||||
|
if (Number(rateRow?.cnt ?? 0) > 0)
|
||||||
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
||||||
|
|
||||||
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
|
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
|
||||||
const client = createDeepSeekClient(deepseekKey)
|
const client = createDeepSeekClient(deepseekKey)
|
||||||
|
|
||||||
const { data: incident } = await supabase
|
const [incident] = await withUser(session.sub, async tx =>
|
||||||
.from('incidents')
|
tx.select({
|
||||||
.select(`
|
incidentType: incidents.incidentType,
|
||||||
id, incident_type, description, severity, injury_involved, medical_status,
|
description: incidents.description,
|
||||||
is_fatality, is_serious_bodily_injury, triage_notes,
|
severity: incidents.severity,
|
||||||
sites (name), zones (name)
|
injuryInvolved: incidents.injuryInvolved,
|
||||||
`)
|
medicalStatus: incidents.medicalStatus,
|
||||||
.eq('id', id)
|
isFatality: incidents.isFatality,
|
||||||
.single()
|
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 })
|
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<ReturnType<typeof client.chat.completions.create>>
|
let res: Awaited<ReturnType<typeof client.chat.completions.create>>
|
||||||
try {
|
try {
|
||||||
res = await client.chat.completions.create({
|
res = await client.chat.completions.create({
|
||||||
@@ -99,15 +100,15 @@ export async function POST(
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content: `You are an experienced HSE investigator for a Malaysian 3PL warehouse. Draft a 5-Why root cause analysis for this incident.
|
content: `You are an experienced HSE investigator for a Malaysian 3PL warehouse. Draft a 5-Why root cause analysis for this incident.
|
||||||
|
|
||||||
Site: ${siteName}
|
Site: ${incident.siteName ?? 'Unknown'}
|
||||||
Zone: ${zoneName}
|
Zone: ${incident.zoneName ?? 'Unknown'}
|
||||||
Incident type: ${inc.incident_type}
|
Incident type: ${incident.incidentType}
|
||||||
Description: ${inc.description}
|
Description: ${incident.description}
|
||||||
Severity: ${inc.severity ?? 'not yet assigned'}/5
|
Severity: ${incident.severity ?? 'not yet assigned'}/5
|
||||||
Injury involved: ${inc.injury_involved ? `yes — ${inc.medical_status}` : 'no'}
|
Injury involved: ${incident.injuryInvolved ? `yes — ${incident.medicalStatus}` : 'no'}
|
||||||
Fatality: ${inc.is_fatality ? 'yes' : 'no'}
|
Fatality: ${incident.isFatality ? 'yes' : 'no'}
|
||||||
Serious bodily injury: ${inc.is_serious_bodily_injury ? 'yes' : 'no'}
|
Serious bodily injury: ${incident.isSeriousBodilyInjury ? 'yes' : 'no'}
|
||||||
Triage notes: ${inc.triage_notes ?? 'none'}
|
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.`,
|
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 })
|
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await supabase.rpc('write_audit_log', {
|
await withUser(session.sub, async tx => {
|
||||||
p_table_name: 'incidents',
|
await writeAuditLog(tx, 'incidents', id, 'ai_rca_draft', {
|
||||||
p_record_id: id,
|
root_cause_summary: draft.root_cause_summary as string,
|
||||||
p_action: 'ai_rca_draft',
|
|
||||||
p_new_value: {
|
|
||||||
root_cause_summary: draft.root_cause_summary,
|
|
||||||
model: 'deepseek-chat',
|
model: 'deepseek-chat',
|
||||||
} as never,
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json(draft)
|
return NextResponse.json(draft)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { getSession } from '@/lib/auth/get-session'
|
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 { createDeepSeekClient } from '@/lib/claude/client'
|
||||||
import { getApiKey } from '@/lib/settings'
|
import { getApiKey } from '@/lib/settings'
|
||||||
|
|
||||||
@@ -16,36 +19,34 @@ export async function POST(
|
|||||||
if (!['hse', 'admin'].includes(session.role))
|
if (!['hse', 'admin'].includes(session.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
// Rate limit
|
||||||
|
const since = new Date(Date.now() - 60_000)
|
||||||
const since = new Date(Date.now() - 60_000).toISOString()
|
const [rateRow] = await asAdmin(db =>
|
||||||
const { count: recentCount } = await supabase
|
db.select({ cnt: sql<number>`count(*)` }).from(auditLog)
|
||||||
.from('audit_log')
|
.where(and(
|
||||||
.select('id', { count: 'exact', head: true })
|
eq(auditLog.changedBy, session.sub),
|
||||||
.eq('changed_by', session.sub)
|
eq(auditLog.action, 'ai_triage_suggest'),
|
||||||
.eq('action', 'ai_triage_suggest')
|
gte(auditLog.changedAt, since),
|
||||||
.gte('changed_at', since)
|
))
|
||||||
if ((recentCount ?? 0) > 0)
|
)
|
||||||
|
if (Number(rateRow?.cnt ?? 0) > 0)
|
||||||
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
||||||
|
|
||||||
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
|
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
|
||||||
const client = createDeepSeekClient(deepseekKey)
|
const client = createDeepSeekClient(deepseekKey)
|
||||||
|
|
||||||
const { data: incident } = await supabase
|
const [incident] = await withUser(session.sub, async tx =>
|
||||||
.from('incidents')
|
tx.select({
|
||||||
.select('id, incident_type, description, injury_involved, asset_involved, medical_status')
|
incidentType: incidents.incidentType,
|
||||||
.eq('id', id)
|
description: incidents.description,
|
||||||
.single()
|
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 })
|
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<ReturnType<typeof client.chat.completions.create>>
|
let res: Awaited<ReturnType<typeof client.chat.completions.create>>
|
||||||
try {
|
try {
|
||||||
res = await client.chat.completions.create({
|
res = await client.chat.completions.create({
|
||||||
@@ -78,11 +79,11 @@ export async function POST(
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content: `You are an HSE triage specialist for a Malaysian 3PL warehouse. Assess this incident under NADOPOD 2004.
|
content: `You are an HSE triage specialist for a Malaysian 3PL warehouse. Assess this incident under NADOPOD 2004.
|
||||||
|
|
||||||
Incident type: ${inc.incident_type}
|
Incident type: ${incident.incidentType}
|
||||||
Description: ${inc.description}
|
Description: ${incident.description}
|
||||||
Injury involved: ${inc.injury_involved ? 'yes' : 'no'}
|
Injury involved: ${incident.injuryInvolved ? 'yes' : 'no'}
|
||||||
Medical status: ${inc.medical_status ?? 'N/A'}
|
Medical status: ${incident.medicalStatus ?? 'N/A'}
|
||||||
Asset/equipment involved: ${inc.asset_involved ? 'yes' : 'no'}
|
Asset/equipment involved: ${incident.assetInvolved ? 'yes' : 'no'}
|
||||||
|
|
||||||
Suggest severity (1–5) and tick the appropriate NADOPOD 2004 flags. Give a one-sentence rationale.`,
|
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 })
|
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await supabase.rpc('write_audit_log', {
|
await withUser(session.sub, async tx => {
|
||||||
p_table_name: 'incidents',
|
await writeAuditLog(tx, 'incidents', id, 'ai_triage_suggest', { suggestion: input, model: 'deepseek-chat' })
|
||||||
p_record_id: id,
|
|
||||||
p_action: 'ai_triage_suggest',
|
|
||||||
p_new_value: { suggestion: input, model: 'deepseek-chat' } as never,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json(input)
|
return NextResponse.json(input)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { getSession } from '@/lib/auth/get-session'
|
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'
|
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@@ -15,49 +18,43 @@ export async function POST(
|
|||||||
if (!['hse', 'admin'].includes(session.role))
|
if (!['hse', 'admin'].includes(session.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
const [incident] = await withUser(session.sub, async tx =>
|
||||||
|
tx.select({ status: incidents.status, referenceNo: incidents.referenceNo, reportedBy: incidents.reportedBy })
|
||||||
const { data: incident } = await supabase
|
.from(incidents).where(eq(incidents.id, id)).limit(1)
|
||||||
.from('incidents')
|
)
|
||||||
.select('status, reference_no, reported_by')
|
|
||||||
.eq('id', id)
|
|
||||||
.single()
|
|
||||||
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
if (incident.status === 'closed')
|
if (incident.status === 'closed')
|
||||||
return NextResponse.json({ error: 'Already closed' }, { status: 409 })
|
return NextResponse.json({ error: 'Already closed' }, { status: 409 })
|
||||||
if (incident.status !== 'verification')
|
if (incident.status !== 'verification')
|
||||||
return NextResponse.json({ error: 'Incident must be in verification status' }, { status: 409 })
|
return NextResponse.json({ error: 'Incident must be in verification status' }, { status: 409 })
|
||||||
|
|
||||||
const { data: openCapas } = await supabase
|
const openCapas = await withUser(session.sub, async tx =>
|
||||||
.from('capa_actions')
|
tx.select({ id: capaActions.id })
|
||||||
.select('id')
|
.from(capaActions)
|
||||||
.eq('incident_id', id)
|
.where(and(
|
||||||
.not('status', 'in', '(verified,closed)')
|
eq(capaActions.incidentId, id),
|
||||||
if (openCapas && openCapas.length > 0)
|
not(inArray(capaActions.status, ['verified', 'closed'])),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
if (openCapas.length > 0)
|
||||||
return NextResponse.json({ error: 'All CAPA actions must be verified before closure' }, { status: 409 })
|
return NextResponse.json({ error: 'All CAPA actions must be verified before closure' }, { status: 409 })
|
||||||
|
|
||||||
const closedAt = new Date().toISOString()
|
const closedAt = new Date()
|
||||||
const { error } = await supabase
|
await withUser(session.sub, async tx => {
|
||||||
.from('incidents')
|
await tx.update(incidents).set({ status: 'closed', closedAt }).where(eq(incidents.id, id))
|
||||||
.update({ status: 'closed', closed_at: closedAt })
|
await writeAuditLog(tx, 'incidents', id, 'closed', {
|
||||||
.eq('id', id)
|
status: 'closed', closed_at: closedAt.toISOString(), closed_by: session.sub,
|
||||||
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 },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (incident.reported_by) {
|
if (incident.reportedBy) {
|
||||||
await createInAppNotifications([{
|
await createInAppNotifications([{
|
||||||
userId: incident.reported_by,
|
userId: incident.reportedBy,
|
||||||
title: `Your incident report ${incident.reference_no ?? ''} has been closed`,
|
title: `Your incident report ${incident.referenceNo ?? ''} has been closed`,
|
||||||
link: '/reporter',
|
link: '/reporter',
|
||||||
incidentId: id,
|
incidentId: id,
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, closed_at: closedAt })
|
return NextResponse.json({ ok: true, closed_at: closedAt.toISOString() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { getSession } from '@/lib/auth/get-session'
|
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(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -14,56 +17,47 @@ export async function POST(
|
|||||||
if (!['hse', 'admin'].includes(session.role))
|
if (!['hse', 'admin'].includes(session.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
const [incident] = await withUser(session.sub, async tx =>
|
||||||
|
tx.select({ status: incidents.status }).from(incidents).where(eq(incidents.id, id)).limit(1)
|
||||||
const { data: incident } = await supabase
|
)
|
||||||
.from('incidents').select('status').eq('id', id).single()
|
|
||||||
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
if (incident.status !== 'triaged')
|
if (incident.status !== 'triaged')
|
||||||
return NextResponse.json({ error: 'Incident must be triaged first' }, { status: 409 })
|
return NextResponse.json({ error: 'Incident must be triaged first' }, { status: 409 })
|
||||||
|
|
||||||
const { count: existingCount } = await supabase
|
const [existingCount] = await withUser(session.sub, async tx =>
|
||||||
.from('investigations')
|
tx.select({ cnt: sql<number>`count(*)` }).from(investigations).where(eq(investigations.incidentId, id))
|
||||||
.select('id', { count: 'exact', head: true })
|
)
|
||||||
.eq('incident_id', id)
|
if (Number(existingCount?.cnt ?? 0) > 0)
|
||||||
if ((existingCount ?? 0) > 0)
|
|
||||||
return NextResponse.json({ error: 'Investigation already exists for this incident' }, { status: 409 })
|
return NextResponse.json({ error: 'Investigation already exists for this incident' }, { status: 409 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const method: 'five_why' | 'fishbone' | 'other' = body.method ?? 'five_why'
|
const method: 'five_why' | 'fishbone' | 'other' = body.method ?? 'five_why'
|
||||||
|
|
||||||
const { data: inv, error } = await supabase
|
let invId!: string
|
||||||
.from('investigations')
|
await withUser(session.sub, async tx => {
|
||||||
.insert({
|
const [inv] = await tx.insert(investigations).values({
|
||||||
incident_id: id,
|
incidentId: id,
|
||||||
investigator_id: session.sub,
|
investigatorId: session.sub,
|
||||||
method,
|
method,
|
||||||
findings_text: body.findings_text ?? null,
|
findingsText: body.findings_text ?? null,
|
||||||
root_cause_summary: body.root_cause_summary ?? null,
|
rootCauseSummary: body.root_cause_summary ?? null,
|
||||||
five_why_steps: method === 'five_why' ? (body.five_why_steps ?? []) : null,
|
fiveWhySteps: method === 'five_why' ? (body.five_why_steps ?? []) : null,
|
||||||
fishbone_categories: method === 'fishbone' ? (body.fishbone_categories ?? []) : null,
|
fishboneCategories: method === 'fishbone' ? (body.fishbone_categories ?? []) : null,
|
||||||
alcohol_test_result: body.alcohol_test_result ?? null,
|
alcoholTestResult: body.alcohol_test_result ?? null,
|
||||||
urine_test_result: body.urine_test_result ?? null,
|
urineTestResult: body.urine_test_result ?? null,
|
||||||
witness_statement_refs: Array.isArray(body.witness_statement_refs) ? body.witness_statement_refs : [],
|
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(
|
export async function PATCH(
|
||||||
@@ -76,45 +70,32 @@ export async function PATCH(
|
|||||||
if (!['hse', 'admin'].includes(session.role))
|
if (!['hse', 'admin'].includes(session.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { investigation_id, complete, ...fields } = body
|
const { investigation_id, complete, ...fields } = body
|
||||||
|
|
||||||
if (!investigation_id) return NextResponse.json({ error: 'investigation_id required' }, { status: 422 })
|
if (!investigation_id) return NextResponse.json({ error: 'investigation_id required' }, { status: 422 })
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {
|
const updateData: Partial<typeof investigations.$inferInsert> = {
|
||||||
findings_text: fields.findings_text ?? null,
|
findingsText: fields.findings_text ?? null,
|
||||||
root_cause_summary: fields.root_cause_summary ?? null,
|
rootCauseSummary: fields.root_cause_summary ?? null,
|
||||||
five_why_steps: fields.five_why_steps ?? null,
|
fiveWhySteps: fields.five_why_steps ?? null,
|
||||||
fishbone_categories: fields.fishbone_categories ?? null,
|
fishboneCategories: fields.fishbone_categories ?? null,
|
||||||
alcohol_test_result: fields.alcohol_test_result ?? null,
|
alcoholTestResult: fields.alcohol_test_result ?? null,
|
||||||
urine_test_result: fields.urine_test_result ?? null,
|
urineTestResult: fields.urine_test_result ?? null,
|
||||||
witness_statement_refs: Array.isArray(fields.witness_statement_refs) ? fields.witness_statement_refs : [],
|
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
|
await withUser(session.sub, async tx => {
|
||||||
.from('investigations')
|
await tx.update(investigations)
|
||||||
.update(updateData)
|
.set(updateData)
|
||||||
.eq('id', investigation_id)
|
.where(and(eq(investigations.id, investigation_id), eq(investigations.incidentId, id)))
|
||||||
.eq('incident_id', id)
|
|
||||||
|
|
||||||
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
|
if (complete) {
|
||||||
|
await tx.update(incidents).set({ status: 'capa_pending' }).where(eq(incidents.id, id))
|
||||||
if (complete) {
|
await writeAuditLog(tx, 'incidents', id, 'investigation_completed', { status: 'capa_pending' })
|
||||||
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' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true })
|
return NextResponse.json({ ok: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
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 { 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 { buildJkkp6Pdf, buildJkkp7Pdf, type JkkpIncident } from '@/lib/pdf/jkkp'
|
||||||
import { computeDoshObligation } from '@/lib/incidents/dosh'
|
import { computeDoshObligation } from '@/lib/incidents/dosh'
|
||||||
|
|
||||||
@@ -20,44 +23,55 @@ export async function GET(
|
|||||||
if (!['hse', 'admin'].includes(session.role))
|
if (!['hse', 'admin'].includes(session.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
const reporterAlias = aliasedTable(users, 'reporter')
|
||||||
|
|
||||||
const { data: incident } = await supabase
|
const [incident] = await withUser(session.sub, async tx =>
|
||||||
.from('incidents')
|
tx.select({
|
||||||
.select(`
|
referenceNo: incidents.referenceNo,
|
||||||
reference_no, incident_type, description, reported_at, severity, lost_days,
|
incidentType: incidents.incidentType,
|
||||||
is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
|
description: incidents.description,
|
||||||
sites (name),
|
reportedAt: incidents.reportedAt,
|
||||||
reporter:users!reported_by (name)
|
severity: incidents.severity,
|
||||||
`)
|
lostDays: incidents.lostDays,
|
||||||
.eq('id', id)
|
isFatality: incidents.isFatality,
|
||||||
.single()
|
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 })
|
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
const dosh = computeDoshObligation({
|
const dosh = computeDoshObligation({
|
||||||
is_fatality: (incident as { is_fatality: boolean }).is_fatality,
|
is_fatality: incident.isFatality,
|
||||||
is_serious_bodily_injury: (incident as { is_serious_bodily_injury: boolean }).is_serious_bodily_injury,
|
is_serious_bodily_injury: incident.isSeriousBodilyInjury,
|
||||||
is_dangerous_occurrence: (incident as { is_dangerous_occurrence: boolean }).is_dangerous_occurrence,
|
is_dangerous_occurrence: incident.isDangerousOccurrence,
|
||||||
is_occupational_disease: (incident as { is_occupational_disease: boolean }).is_occupational_disease,
|
is_occupational_disease: incident.isOccupationalDisease,
|
||||||
lost_days: (incident as { lost_days: number | null }).lost_days,
|
lost_days: incident.lostDays,
|
||||||
})
|
})
|
||||||
|
|
||||||
const required = form === 'jkkp6' ? dosh.requires_jkkp6 : dosh.requires_jkkp7
|
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 })
|
if (!required) return NextResponse.json({ error: 'This form is not required for this incident' }, { status: 400 })
|
||||||
|
|
||||||
const jkkpIncident: JkkpIncident = {
|
const jkkpIncident: JkkpIncident = {
|
||||||
reference_no: (incident as { reference_no: string | null }).reference_no,
|
reference_no: incident.referenceNo,
|
||||||
incident_type: (incident as { incident_type: string }).incident_type,
|
incident_type: incident.incidentType,
|
||||||
description: (incident as { description: string }).description,
|
description: incident.description,
|
||||||
reported_at: (incident as { reported_at: string }).reported_at,
|
reported_at: incident.reportedAt instanceof Date ? incident.reportedAt.toISOString() : (incident.reportedAt as string),
|
||||||
severity: (incident as { severity: number | null }).severity,
|
severity: incident.severity,
|
||||||
is_fatality: (incident as { is_fatality: boolean }).is_fatality,
|
is_fatality: incident.isFatality,
|
||||||
is_serious_bodily_injury: (incident as { is_serious_bodily_injury: boolean }).is_serious_bodily_injury,
|
is_serious_bodily_injury: incident.isSeriousBodilyInjury,
|
||||||
is_dangerous_occurrence: (incident as { is_dangerous_occurrence: boolean }).is_dangerous_occurrence,
|
is_dangerous_occurrence: incident.isDangerousOccurrence,
|
||||||
lost_days: (incident as { lost_days: number | null }).lost_days,
|
lost_days: incident.lostDays,
|
||||||
site_name: (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown',
|
site_name: incident.siteName ?? 'Unknown',
|
||||||
reporter_name: (incident.reporter as unknown as { name: string } | null)?.name ?? 'Unknown',
|
reporter_name: incident.reporterName ?? 'Unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBytes = form === 'jkkp6'
|
const pdfBytes = form === 'jkkp6'
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { aliasedTable } from 'drizzle-orm'
|
||||||
import { getSession } from '@/lib/auth/get-session'
|
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'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -9,34 +12,73 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
const reporterAlias = aliasedTable(users, 'reporter')
|
||||||
const role = session.role
|
|
||||||
|
|
||||||
const { data: incident, error } = await supabase
|
const [incident] = await withUser(session.sub, async tx =>
|
||||||
.from('incidents')
|
tx.select({
|
||||||
.select(`
|
id: incidents.id,
|
||||||
id, reference_no, incident_type, description, severity, status,
|
referenceNo: incidents.referenceNo,
|
||||||
injury_involved, asset_involved, medical_status, lost_days,
|
incidentType: incidents.incidentType,
|
||||||
reported_at, closed_at,
|
description: incidents.description,
|
||||||
sites (id, name),
|
severity: incidents.severity,
|
||||||
zones (id, name),
|
status: incidents.status,
|
||||||
reporter:users!reported_by (id, name, email),
|
injuryInvolved: incidents.injuryInvolved,
|
||||||
evidence_files (id, stage, file_url, file_type, uploaded_at)
|
assetInvolved: incidents.assetInvolved,
|
||||||
`)
|
medicalStatus: incidents.medicalStatus,
|
||||||
.eq('id', id)
|
lostDays: incidents.lostDays,
|
||||||
.eq('evidence_files.deleted', false)
|
reportedAt: incidents.reportedAt,
|
||||||
.single()
|
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) {
|
const isOwner = incident.reporterId === session.sub
|
||||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
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 evidenceFileRows = await withUser(session.sub, async tx =>
|
||||||
const isOwner = reporter?.id === session.sub
|
tx.select({
|
||||||
const isSiteStaff = ['hse', 'supervisor', 'admin'].includes(role)
|
id: evidenceFiles.id,
|
||||||
if (!isOwner && !isSiteStaff) {
|
stage: evidenceFiles.stage,
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
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,
|
||||||
|
})),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { getSession } from '@/lib/auth/get-session'
|
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 { embedText } from '@/lib/claude/embed'
|
||||||
import { getApiKey } from '@/lib/settings'
|
import { getApiKey } from '@/lib/settings'
|
||||||
|
|
||||||
@@ -16,42 +18,39 @@ export async function GET(
|
|||||||
if (!['hse', 'admin'].includes(session.role))
|
if (!['hse', 'admin'].includes(session.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
|
|
||||||
const googleAiKey = await getApiKey('GOOGLE_AI_API_KEY')
|
const googleAiKey = await getApiKey('GOOGLE_AI_API_KEY')
|
||||||
|
|
||||||
const { data: incident } = await supabase
|
const [incidentRow] = await withUser(session.sub, async tx =>
|
||||||
.from('incidents')
|
tx.select({ id: incidents.id, description: incidents.description, embedding: incidents.embedding, status: incidents.status })
|
||||||
.select('id, description, embedding, status')
|
.from(incidents).where(eq(incidents.id, id)).limit(1)
|
||||||
.eq('id', id)
|
)
|
||||||
.single()
|
if (!incidentRow) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
||||||
|
|
||||||
const inc = incident as { id: string; description: string; embedding: string | null; status: string }
|
|
||||||
|
|
||||||
let embeddingVec: number[]
|
let embeddingVec: number[]
|
||||||
try {
|
if (incidentRow.embedding) {
|
||||||
if (inc.embedding) {
|
// pg driver may return the vector as a string — parse if needed
|
||||||
embeddingVec = JSON.parse(inc.embedding) as number[]
|
const raw = incidentRow.embedding
|
||||||
} else {
|
embeddingVec = typeof raw === 'string' ? (JSON.parse(raw) as number[]) : (raw as number[])
|
||||||
embeddingVec = await embedText(inc.description, googleAiKey)
|
} else {
|
||||||
// Closed incidents are locked at the DB level — the trigger would reject
|
try {
|
||||||
// this backfill. The vector still serves the similarity query below.
|
embeddingVec = await embedText(incidentRow.description, googleAiKey)
|
||||||
if (inc.status !== 'closed') {
|
if (incidentRow.status !== 'closed') {
|
||||||
const { error: persistError } = await supabase.from('incidents').update({
|
const embStr = `[${embeddingVec.join(',')}]`
|
||||||
embedding: `[${embeddingVec.join(',')}]` as unknown as string,
|
await asAdmin(db =>
|
||||||
}).eq('id', id)
|
db.execute(sql`UPDATE incidents SET embedding = ${embStr}::vector(768) WHERE id = ${id}::uuid`)
|
||||||
if (persistError) console.error('embedding backfill error:', persistError)
|
)
|
||||||
}
|
}
|
||||||
|
} 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', {
|
const embStr = `[${embeddingVec.join(',')}]`
|
||||||
query_embedding: `[${embeddingVec.join(',')}]`,
|
const similar = await withUser(session.sub, async tx => {
|
||||||
exclude_id: id,
|
const result = await tx.execute(
|
||||||
match_count: 5,
|
sql`SELECT * FROM match_incidents(${embStr}::vector(768), ${id}::uuid, ${5})`
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json(similar ?? [])
|
return NextResponse.json(similar ?? [])
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { getSession } from '@/lib/auth/get-session'
|
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 {
|
interface TriageBody {
|
||||||
severity: number
|
severity: number
|
||||||
@@ -23,40 +26,33 @@ export async function PATCH(
|
|||||||
if (!['hse', 'admin'].includes(session.role))
|
if (!['hse', 'admin'].includes(session.role))
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
|
|
||||||
const body: TriageBody = await request.json()
|
const body: TriageBody = await request.json()
|
||||||
if (body.severity < 1 || body.severity > 5)
|
if (body.severity < 1 || body.severity > 5)
|
||||||
return NextResponse.json({ error: 'severity must be 1–5' }, { status: 422 })
|
return NextResponse.json({ error: 'severity must be 1–5' }, { status: 422 })
|
||||||
|
|
||||||
const { data: incident } = await supabase
|
const [incident] = await withUser(session.sub, async tx =>
|
||||||
.from('incidents').select('status').eq('id', id).single()
|
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) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
if (incident.status !== 'reported')
|
if (incident.status !== 'reported')
|
||||||
return NextResponse.json({ error: 'Incident is not in reported status' }, { status: 409 })
|
return NextResponse.json({ error: 'Incident is not in reported status' }, { status: 409 })
|
||||||
|
|
||||||
const { error } = await supabase
|
await withUser(session.sub, async tx => {
|
||||||
.from('incidents')
|
await tx.update(incidents).set({
|
||||||
.update({
|
|
||||||
severity: body.severity,
|
severity: body.severity,
|
||||||
is_fatality: body.is_fatality,
|
isFatality: body.is_fatality,
|
||||||
is_serious_bodily_injury: body.is_serious_bodily_injury,
|
isSeriousBodilyInjury: body.is_serious_bodily_injury,
|
||||||
is_dangerous_occurrence: body.is_dangerous_occurrence,
|
isDangerousOccurrence: body.is_dangerous_occurrence,
|
||||||
is_occupational_disease: body.is_occupational_disease,
|
isOccupationalDisease: body.is_occupational_disease,
|
||||||
triage_notes: body.triage_notes ?? null,
|
triageNotes: body.triage_notes ?? null,
|
||||||
triaged_by: session.sub,
|
triagedBy: session.sub,
|
||||||
triaged_at: new Date().toISOString(),
|
triagedAt: new Date(),
|
||||||
status: 'triaged',
|
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 })
|
return NextResponse.json({ ok: true })
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { getSession } from '@/lib/auth/get-session'
|
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 { createDeepSeekClient } from '@/lib/claude/client'
|
||||||
import { getApiKey } from '@/lib/settings'
|
import { getApiKey } from '@/lib/settings'
|
||||||
|
|
||||||
@@ -10,16 +13,17 @@ export async function POST(request: NextRequest) {
|
|||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
// Rate limit
|
||||||
|
const since = new Date(Date.now() - 60_000)
|
||||||
const since = new Date(Date.now() - 60_000).toISOString()
|
const [rateRow] = await asAdmin(db =>
|
||||||
const { count: recentCount } = await supabase
|
db.select({ cnt: sql<number>`count(*)` }).from(auditLog)
|
||||||
.from('audit_log')
|
.where(and(
|
||||||
.select('id', { count: 'exact', head: true })
|
eq(auditLog.changedBy, session.sub),
|
||||||
.eq('changed_by', session.sub)
|
eq(auditLog.action, 'ai_quality_check'),
|
||||||
.eq('action', 'ai_quality_check')
|
gte(auditLog.changedAt, since),
|
||||||
.gte('changed_at', since)
|
))
|
||||||
if ((recentCount ?? 0) > 0)
|
)
|
||||||
|
if (Number(rateRow?.cnt ?? 0) > 0)
|
||||||
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
|
||||||
|
|
||||||
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
|
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 })
|
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await supabase.rpc('write_audit_log', {
|
// audit write uses session.sub as a pseudo record-id (no incident_id at this stage)
|
||||||
p_table_name: 'incidents',
|
await withUser(session.sub, async tx => {
|
||||||
p_record_id: session.sub,
|
await writeAuditLog(tx, 'incidents', session.sub, 'ai_quality_check', {
|
||||||
p_action: 'ai_quality_check',
|
score: input.score, passes: input.passes, model: 'deepseek-chat',
|
||||||
p_new_value: { score: input.score, passes: input.passes, model: 'deepseek-chat' } as never,
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json(input)
|
return NextResponse.json(input)
|
||||||
|
|||||||
+112
-101
@@ -2,11 +2,15 @@ import { NextResponse } from 'next/server'
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { getSession } from '@/lib/auth/get-session'
|
import { getSession } from '@/lib/auth/get-session'
|
||||||
import { validateIncidentInput, validateTypeDetails, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
|
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 { sendNewIncidentEmail } from '@/lib/notifications/email'
|
||||||
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||||
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||||
import { getApiKey } from '@/lib/settings'
|
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 const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -23,17 +27,19 @@ async function handlePost(request: Request) {
|
|||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
const supabase = await createClient()
|
// Rate limit check
|
||||||
|
const since = new Date(Date.now() - 60_000)
|
||||||
const since = new Date(Date.now() - 60_000).toISOString()
|
const [rateRow] = await asAdmin(db =>
|
||||||
const { count: recentIncidents } = await supabase
|
db.select({ cnt: sql<number>`count(*)` })
|
||||||
.from('audit_log')
|
.from(auditLog)
|
||||||
.select('id', { count: 'exact', head: true })
|
.where(and(
|
||||||
.eq('changed_by', session.sub)
|
eq(auditLog.changedBy, session.sub),
|
||||||
.eq('table_name', 'incidents')
|
eq(auditLog.tableName, 'incidents'),
|
||||||
.eq('action', 'INSERT')
|
eq(auditLog.action, 'INSERT'),
|
||||||
.gte('changed_at', since)
|
gte(auditLog.changedAt, since),
|
||||||
if ((recentIncidents ?? 0) > 0)
|
))
|
||||||
|
)
|
||||||
|
if (Number(rateRow?.cnt ?? 0) > 0)
|
||||||
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds before submitting another incident' }, { status: 429 })
|
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds before submitting another incident' }, { status: 429 })
|
||||||
|
|
||||||
let body: Record<string, unknown>
|
let body: Record<string, unknown>
|
||||||
@@ -67,8 +73,12 @@ async function handlePost(request: Request) {
|
|||||||
|
|
||||||
let truckId: string | null = null
|
let truckId: string | null = null
|
||||||
if (input.incident_type === 'transport') {
|
if (input.incident_type === 'transport') {
|
||||||
const { data: truck } = await supabase
|
const [truck] = await asAdmin(db =>
|
||||||
.from('trucks').select('id').eq('id', input.truck_id).eq('active', true).single()
|
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 })
|
if (!truck) return NextResponse.json({ error: 'Validation failed', details: ['truck not found or inactive'] }, { status: 422 })
|
||||||
truckId = truck.id
|
truckId = truck.id
|
||||||
}
|
}
|
||||||
@@ -88,132 +98,133 @@ async function handlePost(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Validation failed', details: detailsCheck.errors }, { status: 422 })
|
return NextResponse.json({ error: 'Validation failed', details: detailsCheck.errors }, { status: 422 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: zone, error: zoneError } = await supabase
|
// Zone lookup with site active check
|
||||||
.from('zones')
|
const [zone] = await asAdmin(db =>
|
||||||
.select('id, site_id, active, sites(active)')
|
db.select({
|
||||||
.eq('qr_code_token', input.zone_token)
|
id: zones.id,
|
||||||
.single()
|
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 })
|
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 })
|
return NextResponse.json({ error: 'This location is no longer active for reporting.' }, { status: 409 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: incident, error: incidentError } = await supabase
|
// Incident insert + audit in one withUser transaction
|
||||||
.from('incidents')
|
let incidentId!: string
|
||||||
.insert({
|
let referenceNo: string | null = null
|
||||||
incident_type: input.incident_type,
|
|
||||||
site_id: zone.site_id,
|
await withUser(session.sub, async tx => {
|
||||||
zone_id: zone.id,
|
const [incident] = await tx.insert(incidents).values({
|
||||||
reported_by: session.sub,
|
incidentType: input.incident_type as typeof incidents.$inferInsert['incidentType'],
|
||||||
|
siteId: zone.siteId,
|
||||||
|
zoneId: zone.id,
|
||||||
|
reportedBy: session.sub,
|
||||||
description: input.description.trim(),
|
description: input.description.trim(),
|
||||||
injury_involved: input.injury_involved,
|
injuryInvolved: input.injury_involved,
|
||||||
asset_involved: input.asset_involved,
|
assetInvolved: input.asset_involved,
|
||||||
medical_status: input.injury_involved ? (input.medical_status ?? 'none') : null,
|
medicalStatus: input.injury_involved
|
||||||
type_details: detailsCheck.sanitized,
|
? ((input.medical_status ?? 'none') as typeof incidents.$inferInsert['medicalStatus'])
|
||||||
truck_id: truckId,
|
: 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) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incident.id, 'report', session.sub)
|
const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incidentId, 'report', session.sub)
|
||||||
evidenceRows.push({
|
evidenceInserts.push({
|
||||||
incident_id: incident.id,
|
incidentId,
|
||||||
stage: 'report',
|
stage: 'report',
|
||||||
file_url: publicUrl,
|
fileUrl: publicUrl,
|
||||||
file_type: file.type,
|
fileType: file.type,
|
||||||
file_hash: hash,
|
fileHash: hash,
|
||||||
uploaded_by: session.sub,
|
uploadedBy: session.sub,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('file upload error:', err)
|
console.error('file upload error:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (evidenceInserts.length > 0) {
|
||||||
if (evidenceRows.length > 0) {
|
await withUser(session.sub, async tx => {
|
||||||
await supabase.from('evidence_files').insert(evidenceRows)
|
await tx.insert(evidenceFiles).values(evidenceInserts)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await supabase.rpc('write_audit_log', {
|
sendNewIncidentEmail(incidentId, zone.siteId, referenceNo ?? '', input.incident_type)
|
||||||
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)
|
|
||||||
.catch(err => console.error('email notification failed:', err))
|
.catch(err => console.error('email notification failed:', err))
|
||||||
|
|
||||||
// WhatsApp alert — fire-and-forget alongside email
|
// WhatsApp/in-app alert — fire-and-forget
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const phoneNumberId = await getApiKey('META_WHATSAPP_PHONE_NUMBER_ID')
|
const phoneNumberId = await getApiKey('META_WHATSAPP_PHONE_NUMBER_ID')
|
||||||
const accessToken = await getApiKey('META_WHATSAPP_ACCESS_TOKEN')
|
const accessToken = await getApiKey('META_WHATSAPP_ACCESS_TOKEN')
|
||||||
const { data: siteData } = await supabase
|
|
||||||
.from('sites').select('name').eq('id', zone.site_id).single()
|
const [siteRow] = await asAdmin(db =>
|
||||||
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
|
db.select({ name: sites.name }).from(sites).where(eq(sites.id, zone.siteId)).limit(1)
|
||||||
const { data: recipients } = await supabase
|
)
|
||||||
.from('users')
|
const siteName = siteRow?.name ?? 'Unknown'
|
||||||
.select('id, phone')
|
|
||||||
.in('role', ['supervisor', 'hse'])
|
const recipients = await asAdmin(db =>
|
||||||
.eq('site_id', zone.site_id)
|
db.select({ id: users.id, phone: users.phone })
|
||||||
|
.from(users)
|
||||||
|
.where(and(
|
||||||
|
inArray(users.role, ['supervisor', 'hse']),
|
||||||
|
eq(users.siteId, zone.siteId),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
await createInAppNotifications(
|
await createInAppNotifications(
|
||||||
(recipients ?? []).map((r: { id: string }) => ({
|
recipients.map(r => ({
|
||||||
userId: r.id,
|
userId: r.id,
|
||||||
title: `New ${input.incident_type.replace(/_/g, ' ')} incident ${incident.reference_no ?? ''} at ${siteName}`,
|
title: `New ${input.incident_type.replace(/_/g, ' ')} incident ${referenceNo ?? ''} at ${siteName}`,
|
||||||
link: `/hse/incidents/${incident.id}`,
|
link: `/hse/incidents/${incidentId}`,
|
||||||
incidentId: incident.id,
|
incidentId,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const r of recipients ?? []) {
|
for (const r of recipients) {
|
||||||
const phone = (r as { phone: string | null }).phone ?? ''
|
if (!r.phone) continue
|
||||||
if (!phone) continue
|
await sendWhatsAppMessage(r.phone, 'ims_incident_alert',
|
||||||
await sendWhatsAppMessage(
|
[referenceNo ?? '', input.incident_type, siteName], phoneNumberId, accessToken)
|
||||||
phone,
|
|
||||||
'ims_incident_alert',
|
|
||||||
[incident.reference_no ?? '', input.incident_type, siteName],
|
|
||||||
phoneNumberId,
|
|
||||||
accessToken,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('WhatsApp incident alert error:', err)
|
console.error('WhatsApp incident alert error:', err)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
// Embed description asynchronously for future similarity search
|
// Embed description asynchronously
|
||||||
const supabaseForEmbed = supabase
|
getApiKey('GOOGLE_AI_API_KEY').then(googleAiKey =>
|
||||||
import('@/lib/settings').then(({ getApiKey }) =>
|
import('@/lib/claude/embed').then(({ embedText }) =>
|
||||||
getApiKey('GOOGLE_AI_API_KEY').then(googleAiKey =>
|
embedText(input.description.trim(), googleAiKey).then(embeddingVec => {
|
||||||
import('@/lib/claude/embed').then(({ embedText }) =>
|
const embStr = `[${(embeddingVec as number[]).join(',')}]`
|
||||||
embedText(input.description.trim(), googleAiKey).then(embedding =>
|
return asAdmin(db =>
|
||||||
supabase.from('incidents').update({
|
db.execute(sql`UPDATE incidents SET embedding = ${embStr}::vector(768) WHERE id = ${incidentId}::uuid`)
|
||||||
embedding: `[${embedding.join(',')}]` as unknown as string,
|
|
||||||
}).eq('id', incident.id)
|
|
||||||
)
|
)
|
||||||
)
|
})
|
||||||
)
|
)
|
||||||
).catch(err => console.error('embed error:', err))
|
).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