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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<IncidentType, string> = {
|
||||||
|
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<MedicalStatus, string> = {
|
||||||
|
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<string | null>(null)
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5 bg-white rounded-xl shadow-sm p-5">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Incident type <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={form.incident_type}
|
||||||
|
onChange={e => setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))}
|
||||||
|
>
|
||||||
|
<option value="">Select type…</option>
|
||||||
|
{Object.entries(INCIDENT_TYPE_LABELS).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>{l}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
What happened? <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Describe what happened, where, and any immediate actions taken…"
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
value={form.description}
|
||||||
|
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-4 h-4 text-blue-600"
|
||||||
|
checked={form.injury_involved}
|
||||||
|
onChange={e => setForm(f => ({ ...f, injury_involved: e.target.checked, medical_status: '' }))}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Person was injured</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{form.injury_involved && (
|
||||||
|
<div className="ml-7">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Treatment level <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
required={form.injury_involved}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={form.medical_status}
|
||||||
|
onChange={e => setForm(f => ({ ...f, medical_status: e.target.value as MedicalStatus }))}
|
||||||
|
>
|
||||||
|
<option value="">Select treatment…</option>
|
||||||
|
{Object.entries(MEDICAL_STATUS_LABELS).map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>{l}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-4 h-4 text-blue-600"
|
||||||
|
checked={form.asset_involved}
|
||||||
|
onChange={e => setForm(f => ({ ...f, asset_involved: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Equipment / asset was damaged</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Photos / Videos / Documents
|
||||||
|
</label>
|
||||||
|
<FileUpload onFilesChange={setFiles} disabled={submitting} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !form.incident_type}
|
||||||
|
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium text-sm
|
||||||
|
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting…' : 'Submit Incident Report'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const INCIDENT_TYPES = ['injury', 'near_miss', 'hazard', 'asset_damage', 'environmental', 'security', 'fire'] as const
|
||||||
|
export const MEDICAL_STATUSES = ['none', 'first_aid', 'medical_treatment', 'lti'] as const
|
||||||
|
|
||||||
|
export type IncidentType = typeof INCIDENT_TYPES[number]
|
||||||
|
export type MedicalStatus = typeof MEDICAL_STATUSES[number]
|
||||||
|
|
||||||
|
export interface IncidentInput {
|
||||||
|
zone_token: string
|
||||||
|
incident_type: IncidentType
|
||||||
|
description: string
|
||||||
|
injury_involved: boolean
|
||||||
|
medical_status?: MedicalStatus
|
||||||
|
asset_involved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateIncidentInput(input: IncidentInput): { ok: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if (!input.zone_token?.trim()) errors.push('zone_token is required')
|
||||||
|
if (input.description.trim().length < 10) errors.push('description must be at least 10 characters')
|
||||||
|
if (!INCIDENT_TYPES.includes(input.incident_type)) errors.push('incident_type is invalid')
|
||||||
|
if (input.injury_involved && !input.medical_status) errors.push('medical_status is required when injury is involved')
|
||||||
|
if (input.medical_status && !MEDICAL_STATUSES.includes(input.medical_status)) errors.push('medical_status is invalid')
|
||||||
|
|
||||||
|
return { ok: errors.length === 0, errors }
|
||||||
|
}
|
||||||
+15
-32
@@ -1,4 +1,3 @@
|
|||||||
// middleware.ts
|
|
||||||
import { createServerClient } from '@supabase/ssr'
|
import { createServerClient } from '@supabase/ssr'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { ROLE_HOME, isValidRole, type UserRole } from '@/lib/auth/roles'
|
import { ROLE_HOME, isValidRole, type UserRole } from '@/lib/auth/roles'
|
||||||
@@ -11,9 +10,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
{
|
{
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() {
|
getAll() { return request.cookies.getAll() },
|
||||||
return request.cookies.getAll()
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
setAll(cookiesToSet) {
|
||||||
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
|
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
|
||||||
supabaseResponse = NextResponse.next({ request })
|
supabaseResponse = NextResponse.next({ request })
|
||||||
@@ -25,44 +22,32 @@ export async function middleware(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser()
|
|
||||||
|
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth')
|
const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth')
|
||||||
|
const isSharedRoute = pathname.startsWith('/report')
|
||||||
|
|
||||||
// Unauthenticated → force login
|
|
||||||
if (!user && !isPublicRoute) {
|
if (!user && !isPublicRoute) {
|
||||||
return NextResponse.redirect(new URL('/login', request.url))
|
const redirectUrl = new URL('/login', request.url)
|
||||||
|
redirectUrl.searchParams.set('redirect', pathname + request.nextUrl.search)
|
||||||
|
return NextResponse.redirect(redirectUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticated + hitting root or login → redirect to role home
|
|
||||||
if (user && (pathname === '/' || pathname === '/login')) {
|
if (user && (pathname === '/' || pathname === '/login')) {
|
||||||
const { data: profile } = await supabase
|
const redirect = request.nextUrl.searchParams.get('redirect')
|
||||||
.from('users')
|
if (redirect && redirect.startsWith('/')) {
|
||||||
.select('role')
|
return NextResponse.redirect(new URL(redirect, request.url))
|
||||||
.eq('id', user.id)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
const role = profile?.role
|
|
||||||
if (isValidRole(role)) {
|
|
||||||
return NextResponse.redirect(new URL(ROLE_HOME[role as UserRole], request.url))
|
|
||||||
}
|
}
|
||||||
|
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
|
||||||
|
const role = profile?.role
|
||||||
|
if (isValidRole(role)) return NextResponse.redirect(new URL(ROLE_HOME[role as UserRole], request.url))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role-based route enforcement for protected paths
|
if (user && !isPublicRoute && !isSharedRoute && pathname !== '/') {
|
||||||
if (user && !isPublicRoute && pathname !== '/') {
|
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
|
||||||
const { data: profile } = await supabase
|
|
||||||
.from('users')
|
|
||||||
.select('role')
|
|
||||||
.eq('id', user.id)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
const role = profile?.role
|
const role = profile?.role
|
||||||
if (isValidRole(role)) {
|
if (isValidRole(role)) {
|
||||||
const allowedPrefix = ROLE_HOME[role as UserRole]
|
const allowedPrefix = ROLE_HOME[role as UserRole]
|
||||||
// Block access to routes that don't belong to this role
|
|
||||||
if (!pathname.startsWith(allowedPrefix)) {
|
if (!pathname.startsWith(allowedPrefix)) {
|
||||||
return NextResponse.redirect(new URL(allowedPrefix, request.url))
|
return NextResponse.redirect(new URL(allowedPrefix, request.url))
|
||||||
}
|
}
|
||||||
@@ -73,7 +58,5 @@ export async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
|
||||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { validateIncidentInput } from '@/lib/incidents/validate'
|
||||||
|
|
||||||
|
describe('incident report validation', () => {
|
||||||
|
it('rejects empty zone_token', () => {
|
||||||
|
const result = validateIncidentInput({
|
||||||
|
zone_token: '',
|
||||||
|
incident_type: 'near_miss',
|
||||||
|
description: 'Forklift nearly hit a worker',
|
||||||
|
injury_involved: false,
|
||||||
|
asset_involved: false,
|
||||||
|
})
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
expect(result.errors).toContain('zone_token is required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires medical_status for injury', () => {
|
||||||
|
const result = validateIncidentInput({
|
||||||
|
zone_token: 'scw1-dock-a-qr-2026',
|
||||||
|
incident_type: 'injury',
|
||||||
|
description: 'Worker slipped on wet floor near cold storage entrance',
|
||||||
|
injury_involved: true,
|
||||||
|
asset_involved: false,
|
||||||
|
})
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
expect(result.errors).toContain('medical_status is required when injury is involved')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { validateIncidentInput, type IncidentInput } from '@/lib/incidents/validate'
|
||||||
|
|
||||||
|
const valid: IncidentInput = {
|
||||||
|
zone_token: 'scw1-dock-a-qr-2026',
|
||||||
|
incident_type: 'near_miss',
|
||||||
|
description: 'Forklift nearly hit a pedestrian in Dock A aisle.',
|
||||||
|
injury_involved: false,
|
||||||
|
asset_involved: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('validateIncidentInput', () => {
|
||||||
|
it('accepts valid input', () => {
|
||||||
|
expect(validateIncidentInput(valid)).toEqual({ ok: true, errors: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing zone_token', () => {
|
||||||
|
const result = validateIncidentInput({ ...valid, zone_token: '' })
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
expect(result.errors).toContain('zone_token is required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects description under 10 chars', () => {
|
||||||
|
const result = validateIncidentInput({ ...valid, description: 'Short' })
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
expect(result.errors).toContain('description must be at least 10 characters')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires medical_status when injury_involved is true', () => {
|
||||||
|
const result = validateIncidentInput({ ...valid, injury_involved: true })
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
expect(result.errors).toContain('medical_status is required when injury is involved')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts injury with medical_status', () => {
|
||||||
|
const result = validateIncidentInput({ ...valid, injury_involved: true, medical_status: 'first_aid' })
|
||||||
|
expect(result.ok).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid incident_type', () => {
|
||||||
|
const result = validateIncidentInput({ ...valid, incident_type: 'explosion' as any })
|
||||||
|
expect(result.ok).toBe(false)
|
||||||
|
expect(result.errors).toContain('incident_type is invalid')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user