diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index 2621a11..4277ae6 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -11,4 +11,4 @@ Started: 2026-07-09 - [x] Task 4: Apply migrations to Supabase cloud (user ran supabase login + link + db push) - [x] Task 5: Auth flow (commit 5598594, review clean — LOW: open redirect in callback/route.ts:8 fix before prod) - [x] Task 6: Protected dashboard pages (commit c3ba543, review clean) -- [ ] Task 7: QR code generation +- [x] Task 7: QR code generation (commit 152a660, review clean) diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx index 9bb1fe3..ffc457c 100644 --- a/app/(protected)/layout.tsx +++ b/app/(protected)/layout.tsx @@ -1,4 +1,6 @@ // app/(protected)/layout.tsx +export const dynamic = 'force-dynamic' + import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' diff --git a/app/page.tsx b/app/page.tsx index f688d36..ba24e59 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,6 @@ // app/page.tsx +export const dynamic = 'force-dynamic' + import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { getRoleHome, isValidRole, type UserRole } from '@/lib/auth/roles' diff --git a/components/incidents/file-upload.tsx b/components/incidents/file-upload.tsx new file mode 100644 index 0000000..9d25312 --- /dev/null +++ b/components/incidents/file-upload.tsx @@ -0,0 +1,115 @@ +'use client' + +import { useRef, useState } from 'react' +import type { EvidenceStage } from '@/lib/supabase/storage' + +const ACCEPTED = [ + 'image/jpeg', 'image/png', 'image/heic', 'image/webp', + 'video/mp4', 'video/quicktime', + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +].join(',') + +const MAX_SIZE = { + 'video/mp4': 200 * 1024 * 1024, + 'video/quicktime': 200 * 1024 * 1024, + default: 10 * 1024 * 1024, +} + +interface Props { + stage: EvidenceStage + onFilesChange: (files: File[]) => void + disabled?: boolean +} + +export function FileUpload({ stage, onFilesChange, disabled = false }: Props) { + const inputRef = useRef(null) + const [files, setFiles] = useState([]) + const [errors, setErrors] = useState([]) + const [dragging, setDragging] = useState(false) + + function validate(incoming: File[]): { valid: File[]; errs: string[] } { + const valid: File[] = [] + const errs: string[] = [] + for (const f of incoming) { + const limit = MAX_SIZE[f.type as keyof typeof MAX_SIZE] ?? MAX_SIZE.default + if (f.size > limit) { + errs.push(`${f.name}: exceeds ${limit / 1024 / 1024}MB limit`) + } else { + valid.push(f) + } + } + return { valid, errs } + } + + function addFiles(incoming: File[]) { + const { valid, errs } = validate(incoming) + const next = [...files, ...valid] + setFiles(next) + setErrors(errs) + onFilesChange(next) + } + + function remove(index: number) { + const next = files.filter((_, i) => i !== index) + setFiles(next) + onFilesChange(next) + } + + return ( +
+
inputRef.current?.click()} + onDragOver={e => { e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={e => { + e.preventDefault() + setDragging(false) + addFiles(Array.from(e.dataTransfer.files)) + }} + > +

+ Tap to add photos / videos / documents +

+

+ Photos/docs up to 10MB · Videos up to 200MB +

+
+ + addFiles(Array.from(e.target.files ?? []))} + disabled={disabled} + /> + + {errors.map((err, i) => ( +

{err}

+ ))} + + {files.length > 0 && ( +
    + {files.map((f, i) => ( +
  • + {f.name} + +
  • + ))} +
+ )} +
+ ) +} diff --git a/docs/superpowers/plans/2026-07-10-phase-1-core-reporting.md b/docs/superpowers/plans/2026-07-10-phase-1-core-reporting.md new file mode 100644 index 0000000..2db12b4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-phase-1-core-reporting.md @@ -0,0 +1,2364 @@ +# IMS Phase 1 — MVP Core Reporting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the incident reporting MVP — QR-triggered mobile form, photo/video upload, email notifications, incident inbox, detail page, and basic dashboard — replacing WhatsApp/Excel reporting. + +**Architecture:** Reporter scans zone QR → `/report?zone=` → authenticated form submits to `/api/incidents` (server action) which writes to Supabase DB + uploads files to Supabase Storage + fires Resend email. Supervisor/HSE view incidents in inbox and detail pages. Dashboard shows aggregate counts from DB. + +**Tech Stack:** Next.js 15 App Router, TypeScript, Supabase (Postgres + Storage + RLS), Resend (email), Vitest + React Testing Library + +## Prerequisites (do before Task 1) + +1. Sign up at resend.com → create API key → add to `.env.local`: + ``` + RESEND_API_KEY=re_xxxxxxxxxxxx + RESEND_FROM_EMAIL=onboarding@resend.dev + ``` + (Use `onboarding@resend.dev` until a verified domain is set up — Resend allows this for testing.) + +2. Add `NEXT_PUBLIC_SITE_URL` to `.env.local`: + ``` + NEXT_PUBLIC_SITE_URL=http://localhost:3000 + ``` + +## Global Constraints + +- Next.js 15 App Router; all pages use `export const dynamic = 'force-dynamic'` if they call Supabase +- `@supabase/ssr`: `createBrowserClient` (client) + `createServerClient` + `await cookies()` (server) — import from `@/lib/supabase/client` and `@/lib/supabase/server` +- All Claude API and Resend calls: server-side only (`/app/api/...` routes), never in client components +- RLS enforced at DB level on every table — never use service-role key in app code +- Evidence files: NEVER hard-delete; `deleted BOOLEAN DEFAULT false` already in schema +- Incident records lock on closure (`closed_at IS NOT NULL`) — no further edits +- Every DB mutation writes to `audit_log` via `SELECT public.write_audit_log(...)` (SECURITY DEFINER function from migration 009) +- Incident reference format: `SITE-YYYYMM-####` — auto-generated by Postgres trigger, never set manually +- File upload max: photos/docs 10MB, videos 200MB +- Accepted MIME types: `image/jpeg`, `image/png`, `image/heic`, `video/mp4`, `video/quicktime`, `application/pdf`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document` +- Test runner: `npx vitest run` — uses `vitest.config.ts` with jsdom environment +- Tailwind CSS only for styling — no shadcn/ui, no component libraries +- Commits: `feat:`, `fix:`, `test:` prefixes; one commit per task minimum + +--- + +### Task 1: Supabase Storage + FileUpload component + +**Files:** +- Create: `supabase/migrations/20260710000010_storage.sql` +- Create: `lib/supabase/storage.ts` +- Create: `components/incidents/file-upload.tsx` +- Test: `tests/lib/supabase/storage.test.ts` + +**Interfaces:** +- Produces: + - `uploadEvidenceFile(supabase, file: File, incidentId: string, stage: EvidenceStage): Promise<{ path: string; publicUrl: string; hash: string }>` + - `getEvidenceUrl(supabase, path: string): string` + - `EvidenceStage = 'report' | 'response' | 'investigation' | 'capa' | 'verification'` + - ` void} />` (client component) + +- [ ] **Step 1: Write the storage migration** + +Create `supabase/migrations/20260710000010_storage.sql`: + +```sql +-- Create evidence storage bucket (private) +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'evidence', + 'evidence', + false, + 209715200, -- 200MB + ARRAY[ + 'image/jpeg', 'image/png', 'image/heic', 'image/webp', + 'video/mp4', 'video/quicktime', + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ] +) +ON CONFLICT (id) DO NOTHING; + +-- RLS on storage.objects for the evidence bucket +CREATE POLICY "evidence_upload_authenticated" + ON storage.objects FOR INSERT + TO authenticated + WITH CHECK (bucket_id = 'evidence'); + +CREATE POLICY "evidence_read_uploader" + ON storage.objects FOR SELECT + TO authenticated + USING ( + bucket_id = 'evidence' + AND auth.uid()::text = (storage.foldername(name))[1] + ); + +CREATE POLICY "evidence_read_elevated" + ON storage.objects FOR SELECT + TO authenticated + USING ( + bucket_id = 'evidence' + AND public.auth_user_role() IN ('hse', 'admin', 'management', 'supervisor') + ); +``` + +- [ ] **Step 2: Apply the storage migration via Supabase API** + +```bash +curl -s -X POST \ + "https://api.supabase.com/v1/projects/nkcfjbgappslicotwopl/database/query" \ + -H "Authorization: Bearer sbp_09aac663c6ed68a78559ff40694b82ce8c02d959" \ + -H "Content-Type: application/json" \ + -d "{\"query\": $(cat supabase/migrations/20260710000010_storage.sql | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')}" +``` + +Expected: `[]` (empty array = success) + +- [ ] **Step 3: Write failing tests for storage helpers** + +Create `tests/lib/supabase/storage.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { uploadEvidenceFile, getEvidenceUrl } from '@/lib/supabase/storage' + +const mockUpload = vi.fn() +const mockGetPublicUrl = vi.fn() +const mockSupabase = { + storage: { + from: vi.fn(() => ({ + upload: mockUpload, + getPublicUrl: mockGetPublicUrl, + })), + }, + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-123' } } }) }, +} as any + +beforeEach(() => { + vi.clearAllMocks() + mockUpload.mockResolvedValue({ data: { path: 'user-123/incident-abc/report/photo.jpg' }, error: null }) + mockGetPublicUrl.mockReturnValue({ data: { publicUrl: 'https://example.com/photo.jpg' } }) +}) + +describe('uploadEvidenceFile', () => { + it('uploads to path user-id/incident-id/stage/filename', async () => { + const file = new File(['content'], 'photo.jpg', { type: 'image/jpeg' }) + const result = await uploadEvidenceFile(mockSupabase, file, 'incident-abc', 'report') + expect(mockSupabase.storage.from).toHaveBeenCalledWith('evidence') + expect(mockUpload).toHaveBeenCalledWith( + expect.stringContaining('user-123/incident-abc/report/'), + file, + expect.objectContaining({ contentType: 'image/jpeg', upsert: false }) + ) + expect(result.path).toContain('user-123/incident-abc/report/') + expect(result.hash).toBeTruthy() + }) + + it('throws on upload error', async () => { + mockUpload.mockResolvedValue({ data: null, error: { message: 'Bucket not found' } }) + const file = new File(['x'], 'f.jpg', { type: 'image/jpeg' }) + await expect(uploadEvidenceFile(mockSupabase, file, 'inc', 'report')).rejects.toThrow('Bucket not found') + }) +}) + +describe('getEvidenceUrl', () => { + it('returns signed public URL', () => { + const url = getEvidenceUrl(mockSupabase, 'user-123/incident-abc/report/photo.jpg') + expect(mockSupabase.storage.from).toHaveBeenCalledWith('evidence') + expect(url).toBe('https://example.com/photo.jpg') + }) +}) +``` + +- [ ] **Step 4: Run tests — expect FAIL** + +```bash +npx vitest run tests/lib/supabase/storage.test.ts +``` + +Expected: FAIL — `Cannot find module '@/lib/supabase/storage'` + +- [ ] **Step 5: Implement storage helpers** + +Create `lib/supabase/storage.ts`: + +```typescript +import type { SupabaseClient } from '@supabase/supabase-js' + +export type EvidenceStage = 'report' | 'response' | 'investigation' | 'capa' | 'verification' + +export async function uploadEvidenceFile( + supabase: SupabaseClient, + file: File, + incidentId: string, + stage: EvidenceStage, +): Promise<{ path: string; publicUrl: string; hash: string }> { + const { data: { user } } = await supabase.auth.getUser() + if (!user) throw new Error('Not authenticated') + + const ext = file.name.split('.').pop() ?? 'bin' + const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}` + const path = `${user.id}/${incidentId}/${stage}/${filename}` + + const hash = await computeHash(file) + + const { data, error } = await supabase.storage + .from('evidence') + .upload(path, file, { contentType: file.type, upsert: false }) + + if (error) throw new Error(error.message) + + const { data: urlData } = supabase.storage.from('evidence').getPublicUrl(data.path) + + return { path: data.path, publicUrl: urlData.publicUrl, hash } +} + +export function getEvidenceUrl(supabase: SupabaseClient, path: string): string { + const { data } = supabase.storage.from('evidence').getPublicUrl(path) + return data.publicUrl +} + +async function computeHash(file: File): Promise { + const buffer = await file.arrayBuffer() + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) + return Array.from(new Uint8Array(hashBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} +``` + +- [ ] **Step 6: Run tests — expect PASS** + +```bash +npx vitest run tests/lib/supabase/storage.test.ts +``` + +Expected: PASS — 3 tests pass + +- [ ] **Step 7: Implement FileUpload component** + +Create `components/incidents/file-upload.tsx`: + +```typescript +'use client' + +import { useRef, useState } from 'react' +import type { EvidenceStage } from '@/lib/supabase/storage' + +const ACCEPTED = [ + 'image/jpeg', 'image/png', 'image/heic', 'image/webp', + 'video/mp4', 'video/quicktime', + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +].join(',') + +const MAX_SIZE = { + 'video/mp4': 200 * 1024 * 1024, + 'video/quicktime': 200 * 1024 * 1024, + default: 10 * 1024 * 1024, +} + +interface Props { + stage: EvidenceStage + onFilesChange: (files: File[]) => void + disabled?: boolean +} + +export function FileUpload({ stage, onFilesChange, disabled = false }: Props) { + const inputRef = useRef(null) + const [files, setFiles] = useState([]) + const [errors, setErrors] = useState([]) + const [dragging, setDragging] = useState(false) + + function validate(incoming: File[]): { valid: File[]; errs: string[] } { + const valid: File[] = [] + const errs: string[] = [] + for (const f of incoming) { + const limit = MAX_SIZE[f.type as keyof typeof MAX_SIZE] ?? MAX_SIZE.default + if (f.size > limit) { + errs.push(`${f.name}: exceeds ${limit / 1024 / 1024}MB limit`) + } else { + valid.push(f) + } + } + return { valid, errs } + } + + function addFiles(incoming: File[]) { + const { valid, errs } = validate(incoming) + const next = [...files, ...valid] + setFiles(next) + setErrors(errs) + onFilesChange(next) + } + + function remove(index: number) { + const next = files.filter((_, i) => i !== index) + setFiles(next) + onFilesChange(next) + } + + return ( +
+
inputRef.current?.click()} + onDragOver={e => { e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={e => { + e.preventDefault() + setDragging(false) + addFiles(Array.from(e.dataTransfer.files)) + }} + > +

+ Tap to add photos / videos / documents +

+

+ Photos/docs up to 10MB · Videos up to 200MB +

+
+ + addFiles(Array.from(e.target.files ?? []))} + disabled={disabled} + /> + + {errors.map((err, i) => ( +

{err}

+ ))} + + {files.length > 0 && ( +
    + {files.map((f, i) => ( +
  • + {f.name} + +
  • + ))} +
+ )} +
+ ) +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add supabase/migrations/20260710000010_storage.sql lib/supabase/storage.ts components/incidents/file-upload.tsx tests/lib/supabase/storage.test.ts +git commit -m "feat: Supabase Storage setup, upload helpers, FileUpload component" +``` + +--- + +### Task 2: Incident report form + API route + middleware update + +**Files:** +- Modify: `middleware.ts` (add `/report` shared-route exception + redirect preservation) +- Create: `lib/incidents/validate.ts` +- Create: `app/api/incidents/route.ts` +- Create: `app/api/incidents/[id]/route.ts` +- Create: `app/report/page.tsx` +- Create: `components/incidents/report-form.tsx` +- Test: `tests/lib/incidents/validate.test.ts` +- Test: `tests/api/incidents.test.ts` + +**Interfaces:** +- Consumes: `uploadEvidenceFile` from `@/lib/supabase/storage`, `FileUpload` from `@/components/incidents/file-upload` +- Produces: + - `POST /api/incidents` body: `{ zone_token: string; incident_type: IncidentType; description: string; injury_involved: boolean; medical_status?: MedicalStatus; asset_involved: boolean }` + multipart files + - `POST /api/incidents` response: `{ id: string; reference_no: string }` + - `GET /api/incidents/[id]` response: `IncidentWithEvidence` + - `IncidentType = 'injury' | 'near_miss' | 'hazard' | 'asset_damage' | 'environmental' | 'security' | 'fire'` + - `MedicalStatus = 'none' | 'first_aid' | 'medical_treatment' | 'lti'` + +- [ ] **Step 1: Write failing validation tests** + +Create `tests/lib/incidents/validate.test.ts`: + +```typescript +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') + }) +}) +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +npx vitest run tests/lib/incidents/validate.test.ts +``` + +Expected: FAIL — `Cannot find module '@/lib/incidents/validate'` + +- [ ] **Step 3: Implement validation** + +Create `lib/incidents/validate.ts`: + +```typescript +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 } +} +``` + +- [ ] **Step 4: Run validation tests — expect PASS** + +```bash +npx vitest run tests/lib/incidents/validate.test.ts +``` + +Expected: PASS — 6 tests pass + +- [ ] **Step 5: Implement the incidents API route** + +Create `app/api/incidents/route.ts`: + +```typescript +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 async function POST(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + 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 }) + } + + // Look up zone by token → get site_id + zone_id + 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 }) + } + + // Insert incident + 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 }) + } + + // Upload evidence files + 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) + } + + // Audit log + 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 }) +} +``` + +Create `app/api/incidents/[id]/route.ts`: + +```typescript +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: { user } } = await supabase.auth.getUser() + if (!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) +} +``` + +- [ ] **Step 6: Update middleware — add /report exception + redirect preservation** + +In `middleware.ts`, make these two changes: + +```typescript +// BEFORE (line 33): +const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth') + +// AFTER: +const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth') +const isSharedRoute = pathname.startsWith('/report') // accessible to all logged-in users +``` + +```typescript +// BEFORE (line 36): +if (!user && !isPublicRoute) return NextResponse.redirect(new URL('/login', request.url)) + +// AFTER: +if (!user && !isPublicRoute) { + const redirectUrl = new URL('/login', request.url) + redirectUrl.searchParams.set('redirect', pathname + request.nextUrl.search) + return NextResponse.redirect(redirectUrl) +} +``` + +```typescript +// BEFORE (line 55): +if (user && !isPublicRoute && pathname !== '/') { + +// AFTER: +if (user && !isPublicRoute && !isSharedRoute && pathname !== '/') { +``` + +Full updated `middleware.ts`: + +```typescript +import { createServerClient } from '@supabase/ssr' +import { NextResponse, type NextRequest } from 'next/server' +import { ROLE_HOME, isValidRole, type UserRole } from '@/lib/auth/roles' + +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ request }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { return request.cookies.getAll() }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) + supabaseResponse = NextResponse.next({ request }) + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options), + ) + }, + }, + }, + ) + + const { data: { user } } = await supabase.auth.getUser() + const { pathname } = request.nextUrl + const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth') + const isSharedRoute = pathname.startsWith('/report') + + if (!user && !isPublicRoute) { + const redirectUrl = new URL('/login', request.url) + redirectUrl.searchParams.set('redirect', pathname + request.nextUrl.search) + return NextResponse.redirect(redirectUrl) + } + + if (user && (pathname === '/' || pathname === '/login')) { + const redirect = request.nextUrl.searchParams.get('redirect') + if (redirect && redirect.startsWith('/')) { + return NextResponse.redirect(new URL(redirect, 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)) + } + + if (user && !isPublicRoute && !isSharedRoute && pathname !== '/') { + const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single() + const role = profile?.role + if (isValidRole(role)) { + const allowedPrefix = ROLE_HOME[role as UserRole] + if (!pathname.startsWith(allowedPrefix)) { + return NextResponse.redirect(new URL(allowedPrefix, request.url)) + } + } + } + + return supabaseResponse +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'], +} +``` + +- [ ] **Step 7: Create the report page (server component)** + +Create `app/report/page.tsx`: + +```typescript +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: { user } } = await supabase.auth.getUser() + + if (!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 } = await supabase + .from('zones') + .select('id, name, site_id, sites (name)') + .eq('qr_code_token', zone) + .single() + zoneData = data 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

+ )} +
+ +
+ ) +} +``` + +- [ ] **Step 8: Create the report form component (client)** + +Create `components/incidents/report-form.tsx`: + +```typescript +'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} +
+ )} + +
+ + +
+ +
+ +