# IMS Phase 2 — Investigation, CAPA & Compliance 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 triage panel, investigation workspace, CAPA board with overdue escalation, verification flow, and JKKP 6/7 PDF generation — closing the loop from incident report to verified corrective action. **Architecture:** HSE triages incidents (severity 1–5 + DOSH regulatory checklist), leads investigation (5-Why or fishbone RCA, creates `investigations` record), creates CAPA actions with owners/due dates, CAPA owners upload completion evidence, HSE verifies effectiveness. System auto-escalates overdue CAPAs via email. DOSH-reportable incidents generate JKKP 6/7 draft PDFs for download. **Tech Stack:** Next.js 15 App Router, TypeScript, Supabase (Postgres + RLS), Resend (email), pdf-lib (JKKP PDF), Vitest + React Testing Library ## Global Constraints - Never commit `.env.local` — only `.env.local.example` as template - All Claude API calls server-side only (`/app/api/...`) - RLS enforced on every table — never bypass with service-role key in app code - Evidence files: never hard-delete — soft delete only via `deleted` flag - Incident records lock on closure — no further edits, only addenda - `export const dynamic = 'force-dynamic'` on every server page/route calling Supabase - Safe auth pattern: `const { data, error: authError } = await supabase.auth.getUser(); if (authError || !data?.user)` - Supabase join casts: always `as unknown as SpecificType | null` — never bare `as any` (ESLint blocks it) and never `as DirectType` without `unknown` intermediary (TSC rejects non-overlapping) - Audit every DB mutation: `await supabase.rpc('write_audit_log', { p_table_name, p_record_id, p_action, p_new_value })` - Incident reference format: `SITE-YYYYMM-####` - `basePath: '/ims'` — all internal `href` values are relative (no `/ims` prefix in code) - `npm run build` must pass with zero TypeScript and ESLint errors before deploy - Vitest 4.1.10 + jsdom; `@/` alias resolves to project root --- ## File Map ### New files — migrations - `supabase/migrations/20260711000011_phase2_triage_rca.sql` — triage fields on incidents, RCA structured data on investigations, write_audit_log RPC ### New files — lib - `lib/incidents/dosh.ts` — pure function `computeDoshObligation` - `lib/pdf/jkkp.ts` — pdf-lib JKKP 6/7 PDF builder - `lib/notifications/capa-escalation.ts` — overdue CAPA escalation email logic ### New files — components - `components/incidents/triage-form.tsx` — severity slider + DOSH checklist + notes - `components/incidents/investigation-form.tsx` — method picker + 5-Why / fishbone RCA form - `components/capa/capa-board.tsx` — Kanban + table toggle, groups by status - `components/capa/capa-form.tsx` — create CAPA form - `components/capa/capa-detail.tsx` — single CAPA view + verify/reopen actions - `components/capa/verify-form.tsx` — HSE verification + evidence upload ### New files — API routes - `app/api/incidents/[id]/triage/route.ts` — PATCH: set triage fields, transition reported→triaged - `app/api/incidents/[id]/investigation/route.ts` — POST: create investigation; PATCH: update - `app/api/capa/route.ts` — GET: list CAPAs; POST: create - `app/api/capa/[id]/route.ts` — GET: single CAPA; PATCH: update status/fields - `app/api/capa/[id]/verify/route.ts` — POST: HSE verification - `app/api/incidents/[id]/jkkp-pdf/route.ts` — GET: generate + stream JKKP 6 or 7 PDF - `app/api/cron/capa-escalation/route.ts` — GET: protected by `CRON_SECRET` header ### New files — pages - `app/(protected)/hse/incidents/[id]/triage/page.tsx` - `app/(protected)/hse/incidents/[id]/investigation/page.tsx` - `app/(protected)/hse/incidents/[id]/capa/new/page.tsx` - `app/(protected)/hse/capa/page.tsx` - `app/(protected)/hse/capa/[id]/page.tsx` ### New files — tests - `tests/lib/incidents/dosh.test.ts` - `tests/lib/pdf/jkkp.test.ts` - `tests/lib/notifications/capa-escalation.test.ts` - `tests/api/triage.test.ts` - `tests/api/capa.test.ts` ### Modified files - `app/(protected)/hse/incidents/[id]/page.tsx` — add "Triage" / "Investigate" / "Add CAPA" action buttons based on current status - `components/incidents/incident-detail.tsx` — add triage fields display + DOSH obligations badge --- ## Task 1: DB Migration — triage fields, RCA structured data, write_audit_log RPC **Files:** - Create: `supabase/migrations/20260711000011_phase2_triage_rca.sql` **Interfaces:** - Produces: `incidents.is_fatality`, `incidents.is_serious_bodily_injury`, `incidents.is_dangerous_occurrence`, `incidents.is_occupational_disease`, `incidents.triage_notes`, `incidents.triaged_by`, `incidents.triaged_at` - Produces: `investigations.five_why_steps JSONB`, `investigations.fishbone_categories JSONB` - Produces: RPC `public.write_audit_log(p_table_name TEXT, p_record_id UUID, p_action TEXT, p_new_value JSONB DEFAULT NULL, p_old_value JSONB DEFAULT NULL)` - [ ] **Step 1: Write migration SQL** ```sql -- supabase/migrations/20260711000011_phase2_triage_rca.sql -- Triage classification fields on incidents ALTER TABLE incidents ADD COLUMN IF NOT EXISTS is_fatality BOOLEAN NOT NULL DEFAULT false, ADD COLUMN IF NOT EXISTS is_serious_bodily_injury BOOLEAN NOT NULL DEFAULT false, ADD COLUMN IF NOT EXISTS is_dangerous_occurrence BOOLEAN NOT NULL DEFAULT false, ADD COLUMN IF NOT EXISTS is_occupational_disease BOOLEAN NOT NULL DEFAULT false, ADD COLUMN IF NOT EXISTS triage_notes TEXT, ADD COLUMN IF NOT EXISTS triaged_by UUID REFERENCES users(id), ADD COLUMN IF NOT EXISTS triaged_at TIMESTAMPTZ; -- Structured RCA data on investigations ALTER TABLE investigations ADD COLUMN IF NOT EXISTS five_why_steps JSONB, ADD COLUMN IF NOT EXISTS fishbone_categories JSONB; -- Audit log write helper — SECURITY DEFINER so callers cannot bypass RLS CREATE OR REPLACE FUNCTION public.write_audit_log( p_table_name TEXT, p_record_id UUID, p_action TEXT, p_new_value JSONB DEFAULT NULL, p_old_value JSONB DEFAULT NULL ) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN INSERT INTO audit_log (table_name, record_id, action, changed_by, new_value, old_value) VALUES (p_table_name, p_record_id, p_action, auth.uid(), p_new_value, p_old_value); END; $$; ``` - [ ] **Step 2: Apply migration to Supabase cloud** ```bash npx supabase db push ``` Expected: `Applying migration 20260711000011_phase2_triage_rca.sql...` with no errors. - [ ] **Step 3: Verify columns exist** ```bash npx supabase db diff --schema public 2>/dev/null | grep -E "is_fatality|five_why|write_audit" ``` Expected: shows no pending diff (columns already applied). - [ ] **Step 4: Commit** ```bash git add supabase/migrations/20260711000011_phase2_triage_rca.sql git commit -m "feat: phase2 migration — triage fields, RCA structured data, write_audit_log RPC" ``` --- ## Task 2: DOSH obligation pure function + tests **Files:** - Create: `lib/incidents/dosh.ts` - Create: `tests/lib/incidents/dosh.test.ts` **Interfaces:** - Consumes: nothing (pure function, no imports) - Produces: ```typescript type DoshObligation = { requires_jkkp6: boolean // notify DOSH within 7 days requires_jkkp7: boolean // investigation report within 30 days requires_jkkp8: boolean // occupational disease annual return requires_immediate_notification: boolean // notify within 24h (fatality) reasons: string[] } function computeDoshObligation(incident: { is_fatality: boolean is_serious_bodily_injury: boolean is_dangerous_occurrence: boolean is_occupational_disease: boolean lost_days: number | null }): DoshObligation ``` - [ ] **Step 1: Write failing tests** Create `tests/lib/incidents/dosh.test.ts`: ```typescript import { describe, it, expect } from 'vitest' import { computeDoshObligation } from '@/lib/incidents/dosh' const base = { is_fatality: false, is_serious_bodily_injury: false, is_dangerous_occurrence: false, is_occupational_disease: false, lost_days: null, } describe('computeDoshObligation', () => { it('returns no obligations for minor incident', () => { const result = computeDoshObligation(base) expect(result.requires_jkkp6).toBe(false) expect(result.requires_jkkp7).toBe(false) expect(result.requires_jkkp8).toBe(false) expect(result.requires_immediate_notification).toBe(false) expect(result.reasons).toHaveLength(0) }) it('fatality triggers jkkp6, jkkp7, immediate notification', () => { const result = computeDoshObligation({ ...base, is_fatality: true }) expect(result.requires_jkkp6).toBe(true) expect(result.requires_jkkp7).toBe(true) expect(result.requires_immediate_notification).toBe(true) expect(result.requires_jkkp8).toBe(false) expect(result.reasons).toContain('Fatality — NADOPOD 2004 s.9(1)(a)') }) it('serious bodily injury triggers jkkp6 + jkkp7, not immediate', () => { const result = computeDoshObligation({ ...base, is_serious_bodily_injury: true }) expect(result.requires_jkkp6).toBe(true) expect(result.requires_jkkp7).toBe(true) expect(result.requires_immediate_notification).toBe(false) expect(result.reasons).toContain('Serious bodily injury — NADOPOD 2004 s.9(1)(b)') }) it('dangerous occurrence triggers jkkp6 + jkkp7', () => { const result = computeDoshObligation({ ...base, is_dangerous_occurrence: true }) expect(result.requires_jkkp6).toBe(true) expect(result.requires_jkkp7).toBe(true) expect(result.requires_immediate_notification).toBe(false) expect(result.reasons).toContain('Dangerous occurrence — NADOPOD 2004 s.9(1)(c)') }) it('occupational disease triggers jkkp8 only', () => { const result = computeDoshObligation({ ...base, is_occupational_disease: true }) expect(result.requires_jkkp8).toBe(true) expect(result.requires_jkkp6).toBe(false) expect(result.requires_jkkp7).toBe(false) expect(result.reasons).toContain('Occupational disease — NADOPOD 2004 s.11') }) it('4+ lost days triggers jkkp6 + jkkp7', () => { const result = computeDoshObligation({ ...base, lost_days: 4 }) expect(result.requires_jkkp6).toBe(true) expect(result.requires_jkkp7).toBe(true) expect(result.reasons).toContain('Lost-time injury ≥4 days — NADOPOD 2004 s.9(1)(d)') }) it('3 lost days does NOT trigger JKKP reporting', () => { const result = computeDoshObligation({ ...base, lost_days: 3 }) expect(result.requires_jkkp6).toBe(false) expect(result.requires_jkkp7).toBe(false) }) it('multiple flags accumulate reasons', () => { const result = computeDoshObligation({ ...base, is_fatality: true, is_occupational_disease: true, }) expect(result.requires_jkkp6).toBe(true) expect(result.requires_jkkp8).toBe(true) expect(result.reasons).toHaveLength(2) }) }) ``` - [ ] **Step 2: Run tests to confirm they fail** ```bash npx vitest run tests/lib/incidents/dosh.test.ts ``` Expected: `Cannot find module '@/lib/incidents/dosh'` - [ ] **Step 3: Implement `lib/incidents/dosh.ts`** ```typescript export type DoshObligation = { requires_jkkp6: boolean requires_jkkp7: boolean requires_jkkp8: boolean requires_immediate_notification: boolean reasons: string[] } export function computeDoshObligation(incident: { is_fatality: boolean is_serious_bodily_injury: boolean is_dangerous_occurrence: boolean is_occupational_disease: boolean lost_days: number | null }): DoshObligation { const reasons: string[] = [] let requires_jkkp6 = false let requires_jkkp7 = false let requires_jkkp8 = false let requires_immediate_notification = false if (incident.is_fatality) { reasons.push('Fatality — NADOPOD 2004 s.9(1)(a)') requires_jkkp6 = true requires_jkkp7 = true requires_immediate_notification = true } if (incident.is_serious_bodily_injury) { reasons.push('Serious bodily injury — NADOPOD 2004 s.9(1)(b)') requires_jkkp6 = true requires_jkkp7 = true } if (incident.is_dangerous_occurrence) { reasons.push('Dangerous occurrence — NADOPOD 2004 s.9(1)(c)') requires_jkkp6 = true requires_jkkp7 = true } if ((incident.lost_days ?? 0) >= 4) { reasons.push('Lost-time injury ≥4 days — NADOPOD 2004 s.9(1)(d)') requires_jkkp6 = true requires_jkkp7 = true } if (incident.is_occupational_disease) { reasons.push('Occupational disease — NADOPOD 2004 s.11') requires_jkkp8 = true } return { requires_jkkp6, requires_jkkp7, requires_jkkp8, requires_immediate_notification, reasons } } ``` - [ ] **Step 4: Run tests to confirm they pass** ```bash npx vitest run tests/lib/incidents/dosh.test.ts ``` Expected: `8 passed` - [ ] **Step 5: Commit** ```bash git add lib/incidents/dosh.ts tests/lib/incidents/dosh.test.ts git commit -m "feat: DOSH obligation pure function with NADOPOD 2004 rules" ``` --- ## Task 3: Triage panel — component, API route, page **Files:** - Create: `components/incidents/triage-form.tsx` - Create: `app/api/incidents/[id]/triage/route.ts` - Create: `app/(protected)/hse/incidents/[id]/triage/page.tsx` - Modify: `app/(protected)/hse/incidents/[id]/page.tsx` — add "Triage incident" button when status = `reported` **Interfaces:** - Consumes: `computeDoshObligation` from `@/lib/incidents/dosh` - Produces: PATCH `/api/incidents/[id]/triage` — updates triage fields, transitions `reported` → `triaged` - Produces: `` (client component, calls the API) - [ ] **Step 1: Create triage API route** Create `app/api/incidents/[id]/triage/route.ts`: ```typescript export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' interface TriageBody { severity: number is_fatality: boolean is_serious_bodily_injury: boolean is_dangerous_occurrence: boolean is_occupational_disease: boolean triage_notes: string | null } export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params const supabase = await createClient() const { data: { user }, error: authError } = await supabase.auth.getUser() if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { data: profile } = await supabase .from('users').select('role').eq('id', user.id).single() if (!profile || profile.role !== 'hse') return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) const body: TriageBody = await request.json() if (body.severity < 1 || body.severity > 5) return NextResponse.json({ error: 'severity must be 1–5' }, { status: 422 }) 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.status !== 'reported') return NextResponse.json({ error: 'Incident is not in reported status' }, { status: 409 }) const { error } = await supabase .from('incidents') .update({ severity: body.severity, is_fatality: body.is_fatality, is_serious_bodily_injury: body.is_serious_bodily_injury, is_dangerous_occurrence: body.is_dangerous_occurrence, is_occupational_disease: body.is_occupational_disease, triage_notes: body.triage_notes ?? null, triaged_by: user.id, triaged_at: new Date().toISOString(), status: 'triaged', }) .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: user.id }, }) return NextResponse.json({ ok: true }) } ``` - [ ] **Step 2: Create triage form component** Create `components/incidents/triage-form.tsx`: ```typescript 'use client' import { useState } from 'react' import { useRouter } from 'next/navigation' import { computeDoshObligation } from '@/lib/incidents/dosh' interface Props { incidentId: string currentSeverity: number | null } const SEVERITY_LABELS: Record = { 1: 'Minor', 2: 'Low', 3: 'Moderate', 4: 'Serious', 5: 'Critical / Fatality', } export function TriageForm({ incidentId, currentSeverity }: Props) { const router = useRouter() const [severity, setSeverity] = useState(currentSeverity ?? 1) const [isFatality, setIsFatality] = useState(false) const [isSBI, setIsSBI] = useState(false) const [isDO, setIsDO] = useState(false) const [isOD, setIsOD] = useState(false) const [triageNotes, setTriageNotes] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const dosh = computeDoshObligation({ is_fatality: isFatality, is_serious_bodily_injury: isSBI, is_dangerous_occurrence: isDO, is_occupational_disease: isOD, lost_days: null, }) async function handleSubmit(e: React.FormEvent) { e.preventDefault() setSaving(true) setError(null) const res = await fetch(`/api/incidents/${incidentId}/triage`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ severity, is_fatality: isFatality, is_serious_bodily_injury: isSBI, is_dangerous_occurrence: isDO, is_occupational_disease: isOD, triage_notes: triageNotes || null, }), }) if (!res.ok) { const data = await res.json() setError(data.error ?? 'Triage failed') setSaving(false) return } router.push(`/hse/incidents/${incidentId}`) router.refresh() } return (
setSeverity(Number(e.target.value))} className="w-full accent-blue-600" />
1 Minor3 Moderate5 Critical
DOSH Regulatory Checklist (NADOPOD 2004) {[ { id: 'fatality', label: 'Fatality', value: isFatality, set: setIsFatality }, { id: 'sbi', label: 'Serious bodily injury', value: isSBI, set: setIsSBI }, { id: 'do', label: 'Dangerous occurrence', value: isDO, set: setIsDO }, { id: 'od', label: 'Occupational disease', value: isOD, set: setIsOD }, ].map(({ id, label, value, set }) => ( ))}
{dosh.reasons.length > 0 && (

DOSH Reporting Required

{dosh.reasons.map(r => (

{r}

))} {dosh.requires_immediate_notification && (

⚠ Immediate notification required (within 24h)

)}
)}