feat: incident report form, API route, middleware shared-route + redirect
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !data?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
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()
|
||||
|
||||
if (error || !incident) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(incident)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { validateIncidentInput } from '@/lib/incidents/validate'
|
||||
import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !data?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const user = data.user
|
||||
|
||||
let body: Record<string, unknown>
|
||||
let files: File[] = []
|
||||
|
||||
const contentType = request.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const form = await request.formData()
|
||||
body = Object.fromEntries(
|
||||
[...form.entries()].filter(([, v]) => typeof v === 'string')
|
||||
) as Record<string, unknown>
|
||||
files = form.getAll('files').filter((v): v is File => v instanceof File)
|
||||
} else {
|
||||
body = await request.json()
|
||||
}
|
||||
|
||||
const input = {
|
||||
zone_token: body.zone_token as string,
|
||||
incident_type: body.incident_type as any,
|
||||
description: body.description as string,
|
||||
injury_involved: body.injury_involved === 'true' || body.injury_involved === true,
|
||||
asset_involved: body.asset_involved === 'true' || body.asset_involved === true,
|
||||
medical_status: body.medical_status as any || undefined,
|
||||
}
|
||||
|
||||
const validation = validateIncidentInput(input)
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: 'Validation failed', details: validation.errors }, { status: 422 })
|
||||
}
|
||||
|
||||
const { data: zone, error: zoneError } = await supabase
|
||||
.from('zones')
|
||||
.select('id, site_id')
|
||||
.eq('qr_code_token', input.zone_token)
|
||||
.single()
|
||||
|
||||
if (zoneError || !zone) {
|
||||
return NextResponse.json({ error: 'Zone not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
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: user.id,
|
||||
description: input.description.trim(),
|
||||
injury_involved: input.injury_involved,
|
||||
asset_involved: input.asset_involved,
|
||||
medical_status: input.injury_involved ? (input.medical_status ?? 'none') : null,
|
||||
})
|
||||
.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
|
||||
}> = []
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incident.id, 'report')
|
||||
evidenceRows.push({
|
||||
incident_id: incident.id,
|
||||
stage: 'report',
|
||||
file_url: publicUrl,
|
||||
file_type: file.type,
|
||||
file_hash: hash,
|
||||
uploaded_by: user.id,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('file upload error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (evidenceRows.length > 0) {
|
||||
await supabase.from('evidence_files').insert(evidenceRows)
|
||||
}
|
||||
|
||||
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: user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ id: incident.id, reference_no: incident.reference_no }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { ReportForm } from '@/components/incidents/report-form'
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ zone?: string }>
|
||||
}
|
||||
|
||||
export default async function ReportPage({ searchParams }: Props) {
|
||||
const { zone } = await searchParams
|
||||
const supabase = await createClient()
|
||||
const { data, error: authError } = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !data?.user) redirect(`/login?redirect=/report${zone ? `?zone=${zone}` : ''}`)
|
||||
|
||||
let zoneData: { id: string; name: string; site_id: string; sites: { name: string } } | null = null
|
||||
|
||||
if (zone) {
|
||||
const { data: zd } = await supabase
|
||||
.from('zones')
|
||||
.select('id, name, site_id, sites (name)')
|
||||
.eq('qr_code_token', zone)
|
||||
.single()
|
||||
zoneData = zd as typeof zoneData
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 py-6 px-4 max-w-lg mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Report an Incident</h1>
|
||||
{zoneData ? (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{(zoneData.sites as any)?.name ?? 'Unknown Site'} — {zoneData.name}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-amber-600 mt-1">No zone detected — zone will not be recorded</p>
|
||||
)}
|
||||
</div>
|
||||
<ReportForm zoneToken={zone ?? null} zoneName={zoneData?.name ?? null} siteName={(zoneData?.sites as any)?.name ?? null} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ ref?: string }>
|
||||
}
|
||||
|
||||
export default async function ReportSuccessPage({ searchParams }: Props) {
|
||||
const { ref } = await searchParams
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="bg-white rounded-xl shadow-sm p-8 max-w-sm w-full text-center">
|
||||
<div className="text-4xl mb-4">✓</div>
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">Report submitted</h1>
|
||||
{ref && (
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
Reference: <span className="font-mono font-semibold">{ref}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
The supervisor and HSE officer have been notified.
|
||||
</p>
|
||||
<Link href="/report" className="text-sm text-blue-600 hover:underline">
|
||||
Submit another report
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user