From 3f8da5bd9129da38afdab8e2ae566ee4c2d5d20a Mon Sep 17 00:00:00 2001 From: weeihan Date: Fri, 10 Jul 2026 13:19:45 +0800 Subject: [PATCH] feat: incident report form, API route, middleware shared-route + redirect --- app/api/incidents/[id]/route.ts | 32 +++++ app/api/incidents/route.ts | 109 +++++++++++++++++ app/report/page.tsx | 44 +++++++ app/report/success/page.tsx | 30 +++++ components/incidents/report-form.tsx | 176 +++++++++++++++++++++++++++ lib/incidents/validate.ts | 26 ++++ middleware.ts | 47 +++---- tests/api/incidents.test.ts | 28 +++++ tests/lib/incidents/validate.test.ts | 45 +++++++ 9 files changed, 505 insertions(+), 32 deletions(-) create mode 100644 app/api/incidents/[id]/route.ts create mode 100644 app/api/incidents/route.ts create mode 100644 app/report/page.tsx create mode 100644 app/report/success/page.tsx create mode 100644 components/incidents/report-form.tsx create mode 100644 lib/incidents/validate.ts create mode 100644 tests/api/incidents.test.ts create mode 100644 tests/lib/incidents/validate.test.ts diff --git a/app/api/incidents/[id]/route.ts b/app/api/incidents/[id]/route.ts new file mode 100644 index 0000000..78083f3 --- /dev/null +++ b/app/api/incidents/[id]/route.ts @@ -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) +} diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts new file mode 100644 index 0000000..ea476ac --- /dev/null +++ b/app/api/incidents/route.ts @@ -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 + 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 + 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 }) +} diff --git a/app/report/page.tsx b/app/report/page.tsx new file mode 100644 index 0000000..755d079 --- /dev/null +++ b/app/report/page.tsx @@ -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 ( +
+
+

Report an Incident

+ {zoneData ? ( +

+ {(zoneData.sites as any)?.name ?? 'Unknown Site'} — {zoneData.name} +

+ ) : ( +

No zone detected — zone will not be recorded

+ )} +
+ +
+ ) +} diff --git a/app/report/success/page.tsx b/app/report/success/page.tsx new file mode 100644 index 0000000..43f3a8b --- /dev/null +++ b/app/report/success/page.tsx @@ -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 ( +
+
+
+

Report submitted

+ {ref && ( +

+ Reference: {ref} +

+ )} +

+ The supervisor and HSE officer have been notified. +

+ + Submit another report + +
+
+ ) +} diff --git a/components/incidents/report-form.tsx b/components/incidents/report-form.tsx new file mode 100644 index 0000000..62e4d66 --- /dev/null +++ b/components/incidents/report-form.tsx @@ -0,0 +1,176 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { FileUpload } from '@/components/incidents/file-upload' +import type { IncidentType, MedicalStatus } from '@/lib/incidents/validate' + +const INCIDENT_TYPE_LABELS: Record = { + injury: 'Injury / Medical', + near_miss: 'Near Miss', + hazard: 'Hazard / Unsafe Condition', + asset_damage: 'Asset / Equipment Damage', + environmental: 'Environmental Incident', + security: 'Security Incident', + fire: 'Fire / Emergency', +} + +const MEDICAL_STATUS_LABELS: Record = { + none: 'No treatment needed', + first_aid: 'First aid only', + medical_treatment: 'Medical treatment (non-LTI)', + lti: 'Lost Time Injury (LTI)', +} + +interface Props { + zoneToken: string | null + zoneName: string | null + siteName: string | null +} + +export function ReportForm({ zoneToken }: Props) { + const router = useRouter() + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [files, setFiles] = useState([]) + + const [form, setForm] = useState({ + incident_type: '' as IncidentType | '', + description: '', + injury_involved: false, + medical_status: '' as MedicalStatus | '', + asset_involved: false, + }) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + setSubmitting(true) + + try { + const fd = new FormData() + if (zoneToken) fd.append('zone_token', zoneToken) + fd.append('incident_type', form.incident_type) + fd.append('description', form.description) + fd.append('injury_involved', String(form.injury_involved)) + fd.append('asset_involved', String(form.asset_involved)) + if (form.injury_involved && form.medical_status) { + fd.append('medical_status', form.medical_status) + } + files.forEach(f => fd.append('files', f)) + + const res = await fetch('/api/incidents', { method: 'POST', body: fd }) + const data = await res.json() + + if (!res.ok) { + setError(data.details ? data.details.join('. ') : data.error) + return + } + + router.push(`/report/success?ref=${data.reference_no}`) + } catch { + setError('Something went wrong. Please try again.') + } finally { + setSubmitting(false) + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+ +