Files
ims/docs/superpowers/plans/2026-07-11-phase-2-investigation-capa.md
T
adminandClaude Fable 5 646c94be0c chore: commit supabase config, phase 2/4 plan docs, extend gitignore
- supabase/config.toml + supabase/.gitignore from supabase init (needed
  for supabase db push / local dev)
- phase 2 and phase 4 implementation plans referenced by the SDD
  progress ledger but never committed
- ignore local tooling dirs (node_modules.nosync, graphify-out, .claude)
  and iCloud "name 2.ext" sync-conflict copies

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
2026-07-12 10:30:06 +08:00

90 KiB
Raw Blame History

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 15 + 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

-- 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
npx supabase db push

Expected: Applying migration 20260711000011_phase2_triage_rca.sql... with no errors.

  • Step 3: Verify columns exist
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
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:

    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:

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
npx vitest run tests/lib/incidents/dosh.test.ts

Expected: Cannot find module '@/lib/incidents/dosh'

  • Step 3: Implement lib/incidents/dosh.ts
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
npx vitest run tests/lib/incidents/dosh.test.ts

Expected: 8 passed

  • Step 5: Commit
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 reportedtriaged

  • Produces: <TriageForm incidentId={string} currentSeverity={number|null} /> (client component, calls the API)

  • Step 1: Create triage API route

Create app/api/incidents/[id]/triage/route.ts:

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 15' }, { 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:

'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<number, string> = {
  1: 'Minor',
  2: 'Low',
  3: 'Moderate',
  4: 'Serious',
  5: 'Critical / Fatality',
}

export function TriageForm({ incidentId, currentSeverity }: Props) {
  const router = useRouter()
  const [severity, setSeverity] = useState<number>(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<string | null>(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 (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">
          Severity  {SEVERITY_LABELS[severity]}
        </label>
        <input
          type="range" min={1} max={5} value={severity}
          onChange={e => setSeverity(Number(e.target.value))}
          className="w-full accent-blue-600"
        />
        <div className="flex justify-between text-xs text-gray-400 mt-1">
          <span>1 Minor</span><span>3 Moderate</span><span>5 Critical</span>
        </div>
      </div>

      <fieldset className="space-y-2">
        <legend className="text-sm font-medium text-gray-700 mb-2">DOSH Regulatory Checklist (NADOPOD 2004)</legend>
        {[
          { 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 }) => (
          <label key={id} className="flex items-center gap-2 text-sm text-gray-700">
            <input
              type="checkbox" checked={value}
              onChange={e => set(e.target.checked)}
              className="rounded border-gray-300 text-blue-600"
            />
            {label}
          </label>
        ))}
      </fieldset>

      {dosh.reasons.length > 0 && (
        <div className="rounded-lg bg-red-50 border border-red-200 p-3 space-y-1">
          <p className="text-xs font-semibold text-red-700 uppercase tracking-wide">DOSH Reporting Required</p>
          {dosh.reasons.map(r => (
            <p key={r} className="text-xs text-red-600">{r}</p>
          ))}
          {dosh.requires_immediate_notification && (
            <p className="text-xs font-bold text-red-700 mt-1"> Immediate notification required (within 24h)</p>
          )}
        </div>
      )}

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">Triage notes (optional)</label>
        <textarea
          value={triageNotes}
          onChange={e => setTriageNotes(e.target.value)}
          rows={3}
          className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          placeholder="Initial assessment, immediate actions taken..."
        />
      </div>

      {error && <p className="text-sm text-red-600">{error}</p>}

      <button
        type="submit"
        disabled={saving}
        className="w-full bg-blue-600 text-white rounded-lg py-2 text-sm font-semibold disabled:opacity-50"
      >
        {saving ? 'Saving…' : 'Complete Triage'}
      </button>
    </form>
  )
}
  • Step 3: Create triage page

Create app/(protected)/hse/incidents/[id]/triage/page.tsx:

export const dynamic = 'force-dynamic'

import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { TriageForm } from '@/components/incidents/triage-form'

interface Props {
  params: Promise<{ id: string }>
}

export default async function TriagePage({ params }: Props) {
  const { id } = await params
  const supabase = await createClient()

  const { data, error: authError } = await supabase.auth.getUser()
  if (authError || !data?.user) redirect(`/login?redirect=/hse/incidents/${id}/triage`)

  const { data: profile } = await supabase
    .from('users').select('role').eq('id', data.user.id).single()
  if (!profile || profile.role !== 'hse') redirect('/hse/incidents')

  const { data: incident } = await supabase
    .from('incidents')
    .select('id, reference_no, incident_type, status, severity')
    .eq('id', id)
    .single()

  if (!incident) notFound()
  if (incident.status !== 'reported') redirect(`/hse/incidents/${id}`)

  return (
    <main className="max-w-lg mx-auto px-4 py-6">
      <Link href={`/hse/incidents/${id}`} className="text-sm text-blue-600 hover:underline mb-4 inline-block">
         Back to incident
      </Link>
      <h1 className="text-xl font-bold text-gray-900 mb-1">Triage Incident</h1>
      <p className="text-sm text-gray-500 mb-6">{incident.reference_no ?? id}</p>
      <TriageForm
        incidentId={id}
        currentSeverity={(incident as { severity: number | null }).severity}
      />
    </main>
  )
}
  • Step 4: Add "Triage" action button to incident detail page

Modify app/(protected)/hse/incidents/[id]/page.tsx — find the section after <IncidentDetail .../> and add a status-gated action block:

{/* After <IncidentDetail incident={...} /> */}
{(incident as { status: string }).status === 'reported' && (
  <div className="mt-6">
    <Link
      href={`/hse/incidents/${id}/triage`}
      className="inline-block bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-blue-700"
    >
      Triage Incident
    </Link>
  </div>
)}
{(incident as { status: string }).status === 'triaged' && (
  <div className="mt-6">
    <Link
      href={`/hse/incidents/${id}/investigation`}
      className="inline-block bg-indigo-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-indigo-700"
    >
      Start Investigation
    </Link>
  </div>
)}
{(['investigating', 'capa_pending'] as string[]).includes((incident as { status: string }).status) && (
  <div className="mt-6">
    <Link
      href={`/hse/incidents/${id}/capa/new`}
      className="inline-block bg-amber-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-amber-700"
    >
      Add CAPA Action
    </Link>
  </div>
)}
  • Step 5: Build check
npm run build 2>&1 | tail -20

Expected: ✓ Compiled successfully with no TypeScript or ESLint errors.

  • Step 6: Commit
git add components/incidents/triage-form.tsx \
        app/api/incidents/[id]/triage/route.ts \
        app/\(protected\)/hse/incidents/\[id\]/triage/page.tsx \
        app/\(protected\)/hse/incidents/\[id\]/page.tsx
git commit -m "feat: triage panel — severity + DOSH checklist, transitions reported→triaged"

Task 4: Investigation workspace — component, API route, page

Files:

  • Create: components/incidents/investigation-form.tsx
  • Create: app/api/incidents/[id]/investigation/route.ts
  • Create: app/(protected)/hse/incidents/[id]/investigation/page.tsx

Interfaces:

  • Consumes: Task 1 migration (investigations table with five_why_steps, fishbone_categories columns)
  • Produces: POST /api/incidents/[id]/investigation — creates investigation record, transitions triagedinvestigating
  • Produces: PATCH /api/incidents/[id]/investigation — updates existing investigation, transitions investigatingcapa_pending when completed

Five-Why data shape (stored as JSONB):

type FiveWhyStep = { why: string; answer: string }
// five_why_steps: FiveWhyStep[]  (up to 5 entries)

Fishbone data shape:

type FishboneCategory = {
  category: 'man' | 'machine' | 'method' | 'material' | 'environment' | 'measurement'
  causes: string[]
}
// fishbone_categories: FishboneCategory[]
  • Step 1: Create investigation API route

Create app/api/incidents/[id]/investigation/route.ts:

export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export async function POST(
  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 { 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 !== 'triaged')
    return NextResponse.json({ error: 'Incident must be triaged first' }, { status: 409 })

  const body = await request.json()
  const method: 'five_why' | 'fishbone' | 'other' = body.method ?? 'five_why'

  const { data: inv, error } = await supabase
    .from('investigations')
    .insert({
      incident_id: id,
      investigator_id: user.id,
      method,
      findings_text: body.findings_text ?? null,
      root_cause_summary: body.root_cause_summary ?? null,
      five_why_steps: method === 'five_why' ? (body.five_why_steps ?? []) : null,
      fishbone_categories: method === 'fishbone' ? (body.fishbone_categories ?? []) : null,
    })
    .select('id')
    .single()

  if (error || !inv) return NextResponse.json({ error: 'Insert failed' }, { status: 500 })

  await supabase
    .from('incidents')
    .update({ status: 'investigating' })
    .eq('id', id)

  await supabase.rpc('write_audit_log', {
    p_table_name: 'incidents',
    p_record_id: id,
    p_action: 'investigation_started',
    p_new_value: { status: 'investigating', investigation_id: inv.id },
  })

  return NextResponse.json({ id: inv.id })
}

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 = await request.json()
  const { investigation_id, complete, ...fields } = body

  if (!investigation_id) return NextResponse.json({ error: 'investigation_id required' }, { status: 422 })

  const updateData: Record<string, unknown> = {
    findings_text: fields.findings_text ?? null,
    root_cause_summary: fields.root_cause_summary ?? null,
    five_why_steps: fields.five_why_steps ?? null,
    fishbone_categories: fields.fishbone_categories ?? null,
  }
  if (complete) updateData.completed_at = new Date().toISOString()

  const { error } = await supabase
    .from('investigations')
    .update(updateData)
    .eq('id', investigation_id)
    .eq('incident_id', id)

  if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })

  if (complete) {
    await supabase
      .from('incidents')
      .update({ status: 'capa_pending' })
      .eq('id', id)

    await supabase.rpc('write_audit_log', {
      p_table_name: 'incidents',
      p_record_id: id,
      p_action: 'investigation_completed',
      p_new_value: { status: 'capa_pending' },
    })
  }

  return NextResponse.json({ ok: true })
}
  • Step 2: Create investigation form component

Create components/incidents/investigation-form.tsx:

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'

type FiveWhyStep = { why: string; answer: string }
type FishboneCategory = {
  category: 'man' | 'machine' | 'method' | 'material' | 'environment' | 'measurement'
  causes: string[]
}

const FISHBONE_CATEGORIES: FishboneCategory['category'][] = [
  'man', 'machine', 'method', 'material', 'environment', 'measurement',
]
const CATEGORY_LABELS: Record<FishboneCategory['category'], string> = {
  man: 'Man (People)',
  machine: 'Machine',
  method: 'Method',
  material: 'Material',
  environment: 'Environment',
  measurement: 'Measurement',
}

interface Props {
  incidentId: string
  existingInvestigationId: string | null
}

export function InvestigationForm({ incidentId, existingInvestigationId }: Props) {
  const router = useRouter()
  const [method, setMethod] = useState<'five_why' | 'fishbone' | 'other'>('five_why')
  const [fiveWhy, setFiveWhy] = useState<FiveWhyStep[]>([
    { why: 'Why did the incident happen?', answer: '' },
  ])
  const [fishbone, setFishbone] = useState<FishboneCategory[]>(
    FISHBONE_CATEGORIES.map(c => ({ category: c, causes: [''] }))
  )
  const [findingsText, setFindingsText] = useState('')
  const [rootCause, setRootCause] = useState('')
  const [complete, setComplete] = useState(false)
  const [saving, setSaving] = useState(false)
  const [error, setError] = useState<string | null>(null)

  function addWhyStep() {
    if (fiveWhy.length >= 5) return
    setFiveWhy([...fiveWhy, { why: '', answer: '' }])
  }
  function updateWhyStep(i: number, field: keyof FiveWhyStep, value: string) {
    setFiveWhy(fiveWhy.map((s, idx) => idx === i ? { ...s, [field]: value } : s))
  }
  function updateFishboneCause(catIdx: number, causeIdx: number, value: string) {
    setFishbone(fishbone.map((c, i) =>
      i === catIdx ? { ...c, causes: c.causes.map((cause, j) => j === causeIdx ? value : cause) } : c
    ))
  }
  function addFishboneCause(catIdx: number) {
    setFishbone(fishbone.map((c, i) => i === catIdx ? { ...c, causes: [...c.causes, ''] } : c))
  }

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setSaving(true)
    setError(null)

    const payload = {
      method,
      findings_text: findingsText || null,
      root_cause_summary: rootCause || null,
      five_why_steps: method === 'five_why' ? fiveWhy.filter(s => s.answer) : null,
      fishbone_categories: method === 'fishbone'
        ? fishbone.map(c => ({ ...c, causes: c.causes.filter(Boolean) })).filter(c => c.causes.length > 0)
        : null,
    }

    let res: Response
    if (existingInvestigationId) {
      res = await fetch(`/api/incidents/${incidentId}/investigation`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ...payload, investigation_id: existingInvestigationId, complete }),
      })
    } else {
      res = await fetch(`/api/incidents/${incidentId}/investigation`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      })
    }

    if (!res.ok) {
      const data = await res.json()
      setError(data.error ?? 'Save failed')
      setSaving(false)
      return
    }
    router.push(`/hse/incidents/${incidentId}`)
    router.refresh()
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">RCA Method</label>
        <div className="flex gap-3">
          {(['five_why', 'fishbone', 'other'] as const).map(m => (
            <button
              key={m} type="button"
              onClick={() => setMethod(m)}
              className={`px-3 py-1.5 rounded-lg text-sm font-medium border ${
                method === m
                  ? 'bg-blue-600 text-white border-blue-600'
                  : 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
              }`}
            >
              {m === 'five_why' ? '5-Why' : m === 'fishbone' ? 'Fishbone' : 'Other'}
            </button>
          ))}
        </div>
      </div>

      {method === 'five_why' && (
        <div className="space-y-3">
          <p className="text-sm font-medium text-gray-700">5-Why Analysis</p>
          {fiveWhy.map((step, i) => (
            <div key={i} className="border border-gray-200 rounded-lg p-3 space-y-2">
              <label className="block text-xs text-gray-500">Why #{i + 1}</label>
              <input
                type="text" value={step.why}
                onChange={e => updateWhyStep(i, 'why', e.target.value)}
                placeholder="Why did this happen?"
                className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
              />
              <textarea
                value={step.answer}
                onChange={e => updateWhyStep(i, 'answer', e.target.value)}
                rows={2} placeholder="Answer / finding…"
                className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
              />
            </div>
          ))}
          {fiveWhy.length < 5 && (
            <button type="button" onClick={addWhyStep}
              className="text-sm text-blue-600 hover:underline">
              + Add another Why
            </button>
          )}
        </div>
      )}

      {method === 'fishbone' && (
        <div className="space-y-3">
          <p className="text-sm font-medium text-gray-700">Fishbone (Ishikawa) Analysis</p>
          {fishbone.map((cat, catIdx) => (
            <div key={cat.category} className="border border-gray-200 rounded-lg p-3">
              <p className="text-xs font-semibold text-gray-600 mb-2">{CATEGORY_LABELS[cat.category]}</p>
              {cat.causes.map((cause, causeIdx) => (
                <input
                  key={causeIdx}
                  type="text" value={cause}
                  onChange={e => updateFishboneCause(catIdx, causeIdx, e.target.value)}
                  placeholder="Contributing cause…"
                  className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm mb-1.5"
                />
              ))}
              <button type="button" onClick={() => addFishboneCause(catIdx)}
                className="text-xs text-blue-600 hover:underline">
                + Add cause
              </button>
            </div>
          ))}
        </div>
      )}

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">Key Findings</label>
        <textarea
          value={findingsText}
          onChange={e => setFindingsText(e.target.value)}
          rows={3}
          className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
          placeholder="Describe the sequence of events and contributing factors…"
        />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">Root Cause Summary</label>
        <textarea
          value={rootCause}
          onChange={e => setRootCause(e.target.value)}
          rows={2}
          className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
          placeholder="One-sentence root cause statement…"
        />
      </div>

      {!existingInvestigationId && (
        <label className="flex items-center gap-2 text-sm text-gray-700">
          <input type="checkbox" checked={complete} onChange={e => setComplete(e.target.checked)}
            className="rounded border-gray-300 text-blue-600" />
          Mark investigation complete (transitions incident to CAPA Pending)
        </label>
      )}

      {error && <p className="text-sm text-red-600">{error}</p>}

      <button type="submit" disabled={saving}
        className="w-full bg-indigo-600 text-white rounded-lg py-2 text-sm font-semibold disabled:opacity-50">
        {saving ? 'Saving…' : existingInvestigationId ? 'Update Investigation' : 'Start Investigation'}
      </button>
    </form>
  )
}
  • Step 3: Create investigation page

Create app/(protected)/hse/incidents/[id]/investigation/page.tsx:

export const dynamic = 'force-dynamic'

import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { InvestigationForm } from '@/components/incidents/investigation-form'

interface Props {
  params: Promise<{ id: string }>
}

export default async function InvestigationPage({ params }: Props) {
  const { id } = await params
  const supabase = await createClient()

  const { data, error: authError } = await supabase.auth.getUser()
  if (authError || !data?.user) redirect(`/login?redirect=/hse/incidents/${id}/investigation`)

  const { data: profile } = await supabase
    .from('users').select('role').eq('id', data.user.id).single()
  if (!profile || profile.role !== 'hse') redirect('/hse/incidents')

  const { data: incident } = await supabase
    .from('incidents')
    .select('id, reference_no, status')
    .eq('id', id)
    .single()

  if (!incident) notFound()
  if (!['triaged', 'investigating'].includes((incident as { status: string }).status))
    redirect(`/hse/incidents/${id}`)

  const { data: existing } = await supabase
    .from('investigations')
    .select('id')
    .eq('incident_id', id)
    .order('created_at', { ascending: false })
    .limit(1)
    .maybeSingle()

  return (
    <main className="max-w-2xl mx-auto px-4 py-6">
      <Link href={`/hse/incidents/${id}`} className="text-sm text-blue-600 hover:underline mb-4 inline-block">
         Back to incident
      </Link>
      <h1 className="text-xl font-bold text-gray-900 mb-1">Investigation Workspace</h1>
      <p className="text-sm text-gray-500 mb-6">{(incident as { reference_no: string | null }).reference_no ?? id}</p>
      <InvestigationForm
        incidentId={id}
        existingInvestigationId={(existing as { id: string } | null)?.id ?? null}
      />
    </main>
  )
}
  • Step 4: Build check
npm run build 2>&1 | tail -20

Expected: zero errors.

  • Step 5: Commit
git add components/incidents/investigation-form.tsx \
        app/api/incidents/\[id\]/investigation/route.ts \
        app/\(protected\)/hse/incidents/\[id\]/investigation/page.tsx
git commit -m "feat: investigation workspace — 5-Why/fishbone RCA, transitions triaged→investigating→capa_pending"

Task 5: CAPA board + form + detail

Files:

  • Create: components/capa/capa-board.tsx
  • Create: components/capa/capa-form.tsx
  • Create: components/capa/capa-detail.tsx
  • Create: app/api/capa/route.ts
  • Create: app/api/capa/[id]/route.ts
  • Create: app/(protected)/hse/capa/page.tsx
  • Create: app/(protected)/hse/capa/[id]/page.tsx
  • Create: app/(protected)/hse/incidents/[id]/capa/new/page.tsx

Interfaces:

  • Consumes: capa_actions table from Task 1 migration
  • Produces:
    • GET /api/capa — returns CapaAction[] for current user's scope
    • POST /api/capa — creates new capa_action, transitions incident investigatingcapa_pending if needed
    • GET /api/capa/[id] — returns single CapaAction
    • PATCH /api/capa/[id] — updates status/fields
type CapaAction = {
  id: string
  incident_id: string
  root_cause_ref: string | null
  description: string
  owner_user_id: string
  department: string
  due_date: string      // ISO date string
  priority: 'low' | 'med' | 'high'
  status: 'open' | 'in_progress' | 'overdue' | 'pending_verification' | 'verified' | 'reopened' | 'closed'
  completed_at: string | null
  verified_by: string | null
  verified_at: string | null
  created_at: string
  incidents: { reference_no: string | null; incident_type: string } | null
  owner: { name: string; email: string } | null
}
  • Step 1: Create CAPA list + create API routes

Create app/api/capa/route.ts:

export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export async function GET() {
  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) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  let query = supabase
    .from('capa_actions')
    .select(`
      id, incident_id, root_cause_ref, description, owner_user_id, department,
      due_date, priority, status, completed_at, verified_by, verified_at, created_at,
      incidents (reference_no, incident_type),
      owner:users!owner_user_id (name, email)
    `)
    .order('due_date', { ascending: true })

  if (profile.role === 'supervisor' || profile.role === 'worker') {
    query = query.eq('owner_user_id', user.id)
  }

  const { data, error } = await query
  if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
  return NextResponse.json(data ?? [])
}

export async function POST(request: NextRequest) {
  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 || !['hse', 'admin'].includes(profile.role))
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })

  const body = await request.json()
  const { incident_id, description, owner_user_id, department, due_date, priority, root_cause_ref } = body

  if (!incident_id || !description || !owner_user_id || !department || !due_date)
    return NextResponse.json({ error: 'Missing required fields' }, { status: 422 })

  const { data: capa, error } = await supabase
    .from('capa_actions')
    .insert({
      incident_id,
      description,
      owner_user_id,
      department,
      due_date,
      priority: priority ?? 'med',
      root_cause_ref: root_cause_ref ?? null,
    })
    .select('id')
    .single()

  if (error || !capa) return NextResponse.json({ error: 'Insert failed' }, { status: 500 })

  await supabase.rpc('write_audit_log', {
    p_table_name: 'capa_actions',
    p_record_id: capa.id,
    p_action: 'created',
    p_new_value: { incident_id, description, owner_user_id, department, due_date },
  })

  return NextResponse.json({ id: capa.id }, { status: 201 })
}
  • Step 2: Create CAPA detail/update API route

Create app/api/capa/[id]/route.ts:

export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export async function GET(
  _: 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, error } = await supabase
    .from('capa_actions')
    .select(`
      id, incident_id, root_cause_ref, description, owner_user_id, department,
      due_date, priority, status, completed_at, verified_by, verified_at, created_at,
      incidents (reference_no, incident_type),
      owner:users!owner_user_id (name, email)
    `)
    .eq('id', id)
    .single()

  if (error || !data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
  return NextResponse.json(data)
}

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 body = await request.json()
  const allowed = ['description', 'due_date', 'priority', 'status', 'department', 'root_cause_ref']
  const update: Record<string, unknown> = {}
  for (const key of allowed) {
    if (key in body) update[key] = body[key]
  }

  if (body.status === 'pending_verification') {
    update.completed_at = new Date().toISOString()
  }

  const { error } = await supabase
    .from('capa_actions')
    .update(update)
    .eq('id', id)

  if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })

  await supabase.rpc('write_audit_log', {
    p_table_name: 'capa_actions',
    p_record_id: id,
    p_action: 'updated',
    p_new_value: update as Record<string, unknown>,
  })

  return NextResponse.json({ ok: true })
}
  • Step 3: Create CAPA form component

Create components/capa/capa-form.tsx:

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'

interface User {
  id: string
  name: string
  department: string
}

interface Props {
  incidentId: string
  users: User[]
}

export function CapaForm({ incidentId, users }: Props) {
  const router = useRouter()
  const [description, setDescription] = useState('')
  const [ownerId, setOwnerId] = useState('')
  const [department, setDepartment] = useState('')
  const [dueDate, setDueDate] = useState('')
  const [priority, setPriority] = useState<'low' | 'med' | 'high'>('med')
  const [rootCauseRef, setRootCauseRef] = useState('')
  const [saving, setSaving] = useState(false)
  const [error, setError] = useState<string | null>(null)

  function handleOwnerChange(id: string) {
    setOwnerId(id)
    const u = users.find(u => u.id === id)
    if (u) setDepartment(u.department)
  }

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!ownerId || !description || !dueDate) {
      setError('Description, owner, and due date are required')
      return
    }
    setSaving(true)
    setError(null)
    const res = await fetch('/api/capa', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        incident_id: incidentId,
        description,
        owner_user_id: ownerId,
        department,
        due_date: dueDate,
        priority,
        root_cause_ref: rootCauseRef || null,
      }),
    })
    if (!res.ok) {
      const data = await res.json()
      setError(data.error ?? 'Create failed')
      setSaving(false)
      return
    }
    router.push(`/hse/incidents/${incidentId}`)
    router.refresh()
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">Action Description *</label>
        <textarea
          value={description}
          onChange={e => setDescription(e.target.value)}
          rows={3} required
          className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
          placeholder="What corrective action will be taken?"
        />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">Root Cause Reference</label>
        <input type="text" value={rootCauseRef}
          onChange={e => setRootCauseRef(e.target.value)}
          className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
          placeholder="e.g. Why #3 — inadequate training" />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">Assigned To *</label>
        <select value={ownerId} onChange={e => handleOwnerChange(e.target.value)} required
          className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
          <option value="">Select owner</option>
          {users.map(u => (
            <option key={u.id} value={u.id}>{u.name} ({u.department})</option>
          ))}
        </select>
      </div>

      <div className="grid grid-cols-2 gap-3">
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Due Date *</label>
          <input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} required
            className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" />
        </div>
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
          <select value={priority} onChange={e => setPriority(e.target.value as 'low' | 'med' | 'high')}
            className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
            <option value="low">Low</option>
            <option value="med">Medium</option>
            <option value="high">High</option>
          </select>
        </div>
      </div>

      {error && <p className="text-sm text-red-600">{error}</p>}

      <button type="submit" disabled={saving}
        className="w-full bg-amber-600 text-white rounded-lg py-2 text-sm font-semibold disabled:opacity-50">
        {saving ? 'Creating…' : 'Create CAPA Action'}
      </button>
    </form>
  )
}
  • Step 4: Create CAPA board component

Create components/capa/capa-board.tsx:

'use client'

import { useState } from 'react'
import Link from 'next/link'

type CapaStatus = 'open' | 'in_progress' | 'overdue' | 'pending_verification' | 'verified' | 'reopened' | 'closed'

type CapaAction = {
  id: string
  incident_id: string
  description: string
  department: string
  due_date: string
  priority: 'low' | 'med' | 'high'
  status: CapaStatus
  incidents: { reference_no: string | null } | null
  owner: { name: string } | null
}

const STATUS_ORDER: CapaStatus[] = ['open', 'in_progress', 'overdue', 'pending_verification', 'verified', 'closed']
const STATUS_LABELS: Record<CapaStatus, string> = {
  open: 'Open',
  in_progress: 'In Progress',
  overdue: 'Overdue',
  pending_verification: 'Pending Verification',
  verified: 'Verified',
  reopened: 'Reopened',
  closed: 'Closed',
}
const STATUS_COLORS: Record<CapaStatus, string> = {
  open: 'bg-blue-50 border-blue-200',
  in_progress: 'bg-yellow-50 border-yellow-200',
  overdue: 'bg-red-50 border-red-200',
  pending_verification: 'bg-purple-50 border-purple-200',
  verified: 'bg-green-50 border-green-200',
  reopened: 'bg-orange-50 border-orange-200',
  closed: 'bg-gray-50 border-gray-200',
}
const PRIORITY_BADGE: Record<'low' | 'med' | 'high', string> = {
  low: 'bg-gray-100 text-gray-600',
  med: 'bg-yellow-100 text-yellow-700',
  high: 'bg-red-100 text-red-700',
}

interface Props {
  capas: CapaAction[]
}

export function CapaBoard({ capas }: Props) {
  const [view, setView] = useState<'board' | 'table'>('board')

  const byStatus = STATUS_ORDER.reduce<Record<CapaStatus, CapaAction[]>>((acc, s) => {
    acc[s] = capas.filter(c => c.status === s)
    return acc
  }, {} as Record<CapaStatus, CapaAction[]>)

  if (view === 'table') {
    return (
      <div>
        <div className="flex justify-end mb-3">
          <button onClick={() => setView('board')} className="text-sm text-blue-600 hover:underline">Board view</button>
        </div>
        <div className="overflow-x-auto">
          <table className="w-full text-sm">
            <thead>
              <tr className="text-left text-xs text-gray-500 uppercase border-b">
                <th className="pb-2 pr-4">Ref</th>
                <th className="pb-2 pr-4">Action</th>
                <th className="pb-2 pr-4">Owner</th>
                <th className="pb-2 pr-4">Due</th>
                <th className="pb-2 pr-4">Priority</th>
                <th className="pb-2">Status</th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-100">
              {capas.map(c => (
                <tr key={c.id}>
                  <td className="py-2 pr-4 text-gray-500 text-xs">
                    {(c.incidents as { reference_no: string | null } | null)?.reference_no ?? '—'}
                  </td>
                  <td className="py-2 pr-4">
                    <Link href={`/hse/capa/${c.id}`} className="text-blue-600 hover:underline line-clamp-2">
                      {c.description}
                    </Link>
                  </td>
                  <td className="py-2 pr-4 text-gray-600">{(c.owner as { name: string } | null)?.name ?? '—'}</td>
                  <td className="py-2 pr-4 text-gray-600">{c.due_date}</td>
                  <td className="py-2 pr-4">
                    <span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_BADGE[c.priority]}`}>
                      {c.priority}
                    </span>
                  </td>
                  <td className="py-2 text-gray-600 text-xs">{STATUS_LABELS[c.status]}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    )
  }

  return (
    <div>
      <div className="flex justify-end mb-3">
        <button onClick={() => setView('table')} className="text-sm text-blue-600 hover:underline">Table view</button>
      </div>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
        {STATUS_ORDER.filter(s => s !== 'reopened').map(status => (
          <div key={status} className={`rounded-xl border p-4 ${STATUS_COLORS[status]}`}>
            <h3 className="text-xs font-semibold uppercase tracking-wide text-gray-600 mb-3">
              {STATUS_LABELS[status]} ({byStatus[status].length})
            </h3>
            <div className="space-y-2">
              {byStatus[status].map(c => (
                <Link key={c.id} href={`/hse/capa/${c.id}`}
                  className="block bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow border border-gray-100">
                  <p className="text-sm text-gray-800 line-clamp-2 mb-1">{c.description}</p>
                  <div className="flex items-center justify-between text-xs text-gray-500">
                    <span>{(c.owner as { name: string } | null)?.name ?? '—'}</span>
                    <span className={`px-1.5 py-0.5 rounded ${PRIORITY_BADGE[c.priority]}`}>{c.priority}</span>
                  </div>
                  <p className="text-xs text-gray-400 mt-1">Due {c.due_date}</p>
                </Link>
              ))}
              {byStatus[status].length === 0 && (
                <p className="text-xs text-gray-400 text-center py-3">Empty</p>
              )}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}
  • Step 5: Create CAPA board page

Create app/(protected)/hse/capa/page.tsx:

export const dynamic = 'force-dynamic'

import { redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { CapaBoard } from '@/components/capa/capa-board'

export default async function CapaListPage() {
  const supabase = await createClient()
  const { data, error: authError } = await supabase.auth.getUser()
  if (authError || !data?.user) redirect('/login?redirect=/hse/capa')

  const { data: capas } = await supabase
    .from('capa_actions')
    .select(`
      id, incident_id, description, department, due_date, priority, status,
      incidents (reference_no, incident_type),
      owner:users!owner_user_id (name, email)
    `)
    .order('due_date', { ascending: true })

  return (
    <main className="max-w-6xl mx-auto px-4 py-6">
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold text-gray-900">CAPA Board</h1>
        <Link href="/hse/incidents" className="text-sm text-blue-600 hover:underline">
           Incidents
        </Link>
      </div>
      <CapaBoard capas={(capas ?? []) as Parameters<typeof CapaBoard>[0]['capas']} />
    </main>
  )
}
  • Step 6: Create "new CAPA" page (from incident)

Create app/(protected)/hse/incidents/[id]/capa/new/page.tsx:

export const dynamic = 'force-dynamic'

import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { CapaForm } from '@/components/capa/capa-form'

interface Props {
  params: Promise<{ id: string }>
}

export default async function NewCapaPage({ params }: Props) {
  const { id } = await params
  const supabase = await createClient()
  const { data, error: authError } = await supabase.auth.getUser()
  if (authError || !data?.user) redirect(`/login?redirect=/hse/incidents/${id}/capa/new`)

  const { data: profile } = await supabase
    .from('users').select('role').eq('id', data.user.id).single()
  if (!profile || !['hse', 'admin'].includes(profile.role)) redirect('/hse/incidents')

  const { data: incident } = await supabase
    .from('incidents').select('id, reference_no, status').eq('id', id).single()
  if (!incident) notFound()

  const { data: users } = await supabase
    .from('users')
    .select('id, name, department')
    .not('department', 'is', null)
    .order('name')

  return (
    <main className="max-w-lg mx-auto px-4 py-6">
      <Link href={`/hse/incidents/${id}`} className="text-sm text-blue-600 hover:underline mb-4 inline-block">
         Back to incident
      </Link>
      <h1 className="text-xl font-bold text-gray-900 mb-1">Add CAPA Action</h1>
      <p className="text-sm text-gray-500 mb-6">
        {(incident as { reference_no: string | null }).reference_no ?? id}
      </p>
      <CapaForm
        incidentId={id}
        users={(users ?? []) as Array<{ id: string; name: string; department: string }>}
      />
    </main>
  )
}
  • Step 7: Build check
npm run build 2>&1 | tail -20

Expected: zero errors.

  • Step 8: Commit
git add components/capa/ \
        app/api/capa/ \
        app/\(protected\)/hse/capa/ \
        app/\(protected\)/hse/incidents/\[id\]/capa/
git commit -m "feat: CAPA board — kanban/table view, create CAPA from incident, status tracking"

Task 6: Verification flow

Files:

  • Create: components/capa/verify-form.tsx
  • Create: app/api/capa/[id]/verify/route.ts
  • Create: app/(protected)/hse/capa/[id]/page.tsx

Interfaces:

  • Consumes: CapaAction type from Task 5

  • Produces: POST /api/capa/[id]/verify — HSE marks CAPA verified or reopens with reason

  • Produces: <VerifyForm capaId={string} /> (client component)

  • Produces: CAPA detail page showing full action + verify form when status = pending_verification

  • Step 1: Create verify API route

Create app/api/capa/[id]/verify/route.ts:

export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export async function POST(
  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 || !['hse', 'admin'].includes(profile.role))
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })

  const { data: capa } = await supabase
    .from('capa_actions').select('status, incident_id').eq('id', id).single()
  if (!capa) return NextResponse.json({ error: 'Not found' }, { status: 404 })
  if (capa.status !== 'pending_verification')
    return NextResponse.json({ error: 'CAPA must be pending_verification' }, { status: 409 })

  const body: { verdict: 'verified' | 'reopened'; reopen_reason?: string } = await request.json()
  if (body.verdict !== 'verified' && body.verdict !== 'reopened')
    return NextResponse.json({ error: 'verdict must be verified or reopened' }, { status: 422 })

  const update: Record<string, unknown> = {
    status: body.verdict,
    verified_by: user.id,
    verified_at: new Date().toISOString(),
  }

  const { error } = await supabase
    .from('capa_actions').update(update).eq('id', id)

  if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })

  await supabase.rpc('write_audit_log', {
    p_table_name: 'capa_actions',
    p_record_id: id,
    p_action: body.verdict === 'verified' ? 'verified' : 'reopened',
    p_new_value: { ...update, reopen_reason: body.reopen_reason ?? null },
  })

  // Check if all CAPAs for this incident are verified — if so, transition incident to verification
  const { data: openCapas } = await supabase
    .from('capa_actions')
    .select('id')
    .eq('incident_id', capa.incident_id)
    .not('status', 'in', '(verified,closed)')

  if (!openCapas || openCapas.length === 0) {
    await supabase
      .from('incidents')
      .update({ status: 'verification' })
      .eq('id', capa.incident_id)
  }

  return NextResponse.json({ ok: true })
}
  • Step 2: Create verify form component

Create components/capa/verify-form.tsx:

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'

interface Props {
  capaId: string
}

export function VerifyForm({ capaId }: Props) {
  const router = useRouter()
  const [verdict, setVerdict] = useState<'verified' | 'reopened' | null>(null)
  const [reopenReason, setReopenReason] = useState('')
  const [saving, setSaving] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!verdict) return
    if (verdict === 'reopened' && !reopenReason.trim()) {
      setError('Reopen reason is required')
      return
    }
    setSaving(true)
    setError(null)
    const res = await fetch(`/api/capa/${capaId}/verify`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ verdict, reopen_reason: reopenReason || null }),
    })
    if (!res.ok) {
      const data = await res.json()
      setError(data.error ?? 'Verification failed')
      setSaving(false)
      return
    }
    router.push('/hse/capa')
    router.refresh()
  }

  return (
    <form onSubmit={handleSubmit} className="border border-gray-200 rounded-xl p-5 space-y-4 bg-purple-50">
      <h3 className="text-sm font-semibold text-gray-800">HSE Verification</h3>
      <div className="flex gap-3">
        <button
          type="button"
          onClick={() => setVerdict('verified')}
          className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
            verdict === 'verified'
              ? 'bg-green-600 text-white border-green-600'
              : 'bg-white text-gray-700 border-gray-300 hover:border-green-500'
          }`}
        >
          Verified  Effective
        </button>
        <button
          type="button"
          onClick={() => setVerdict('reopened')}
          className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
            verdict === 'reopened'
              ? 'bg-orange-600 text-white border-orange-600'
              : 'bg-white text-gray-700 border-gray-300 hover:border-orange-500'
          }`}
        >
          Reopen  Ineffective
        </button>
      </div>
      {verdict === 'reopened' && (
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">Why is the action ineffective? *</label>
          <textarea
            value={reopenReason}
            onChange={e => setReopenReason(e.target.value)}
            rows={2} required
            className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
            placeholder="Describe why the corrective action did not resolve the root cause…"
          />
        </div>
      )}
      {error && <p className="text-sm text-red-600">{error}</p>}
      {verdict && (
        <button type="submit" disabled={saving}
          className="w-full bg-gray-900 text-white rounded-lg py-2 text-sm font-semibold disabled:opacity-50">
          {saving ? 'Saving…' : 'Submit Verification'}
        </button>
      )}
    </form>
  )
}
  • Step 3: Create CAPA detail page

Create app/(protected)/hse/capa/[id]/page.tsx:

export const dynamic = 'force-dynamic'

import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { VerifyForm } from '@/components/capa/verify-form'

interface Props {
  params: Promise<{ id: string }>
}

const PRIORITY_BADGE: Record<string, string> = {
  low: 'bg-gray-100 text-gray-600',
  med: 'bg-yellow-100 text-yellow-700',
  high: 'bg-red-100 text-red-700',
}

export default async function CapaDetailPage({ params }: Props) {
  const { id } = await params
  const supabase = await createClient()
  const { data, error: authError } = await supabase.auth.getUser()
  if (authError || !data?.user) redirect(`/login?redirect=/hse/capa/${id}`)

  const { data: profile } = await supabase
    .from('users').select('role').eq('id', data.user.id).single()

  const { data: capa } = await supabase
    .from('capa_actions')
    .select(`
      id, incident_id, root_cause_ref, description, department,
      due_date, priority, status, completed_at, verified_by, verified_at, created_at,
      incidents (reference_no, incident_type),
      owner:users!owner_user_id (name, email)
    `)
    .eq('id', id)
    .single()

  if (!capa) notFound()

  const isHse = profile && ['hse', 'admin'].includes(profile.role)
  const status = (capa as { status: string }).status

  return (
    <main className="max-w-2xl mx-auto px-4 py-6">
      <Link href="/hse/capa" className="text-sm text-blue-600 hover:underline mb-4 inline-block">
         CAPA Board
      </Link>

      <div className="bg-white rounded-xl shadow-sm p-5 mb-4 space-y-4">
        <div className="flex items-start justify-between">
          <div>
            <p className="text-xs text-gray-500 mb-1">
              Incident: {(capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no ?? '—'}
            </p>
            <p className="text-gray-900 font-medium">{(capa as { description: string }).description}</p>
          </div>
          <span className={`px-2 py-1 rounded text-xs font-semibold ${PRIORITY_BADGE[(capa as { priority: string }).priority] ?? ''}`}>
            {(capa as { priority: string }).priority}
          </span>
        </div>

        <div className="grid grid-cols-2 gap-3 text-sm">
          <div>
            <p className="text-xs text-gray-500">Owner</p>
            <p className="text-gray-800">{(capa.owner as unknown as { name: string } | null)?.name ?? '—'}</p>
          </div>
          <div>
            <p className="text-xs text-gray-500">Department</p>
            <p className="text-gray-800">{(capa as { department: string }).department}</p>
          </div>
          <div>
            <p className="text-xs text-gray-500">Due Date</p>
            <p className="text-gray-800">{(capa as { due_date: string }).due_date}</p>
          </div>
          <div>
            <p className="text-xs text-gray-500">Status</p>
            <p className="text-gray-800 capitalize">{status.replace(/_/g, ' ')}</p>
          </div>
        </div>

        {(capa as { root_cause_ref: string | null }).root_cause_ref && (
          <div>
            <p className="text-xs text-gray-500">Root Cause Reference</p>
            <p className="text-sm text-gray-800">{(capa as { root_cause_ref: string }).root_cause_ref}</p>
          </div>
        )}

        {(capa as { completed_at: string | null }).completed_at && (
          <p className="text-xs text-gray-500">
            Completed: {new Date((capa as { completed_at: string }).completed_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })}
          </p>
        )}
      </div>

      {isHse && status === 'pending_verification' && (
        <VerifyForm capaId={id} />
      )}
    </main>
  )
}
  • Step 4: Build check
npm run build 2>&1 | tail -20

Expected: zero errors.

  • Step 5: Commit
git add components/capa/verify-form.tsx \
        app/api/capa/\[id\]/verify/route.ts \
        app/\(protected\)/hse/capa/\[id\]/page.tsx
git commit -m "feat: verification flow — HSE verify/reopen CAPA, auto-transition incident to verification"

Task 7: CAPA overdue escalation cron

Files:

  • Create: lib/notifications/capa-escalation.ts
  • Create: app/api/cron/capa-escalation/route.ts
  • Create: tests/lib/notifications/capa-escalation.test.ts

Interfaces:

  • Consumes: sendEscalationEmail(to: string[], subject: string, html: string, text: string): Promise<void> — uses Resend under the hood, same pattern as lib/notifications/email.ts
  • Produces: escalateOverdueCapa(supabase: SupabaseClient): Promise<{ notified: number }> — pure business logic, testable without HTTP
  • Produces: GET /api/cron/capa-escalation — protected by Authorization: Bearer <CRON_SECRET> header

Escalation thresholds (DUEDATE = capa.due_date):

  • 3 days before due: warning_3d — send to owner
  • On due date: due_today — send to owner + HSE
  • 3 days overdue: overdue_3d — send to owner + HSE + supervisor
  • 7 days overdue: overdue_7d — send to owner + HSE + supervisor + management

Each threshold fires once (tracked in notifications_log). If notifications_log has a row for (capa_id, channel='email', status='sent') matching the threshold pattern, skip.

  • Step 1: Write failing tests

Create tests/lib/notifications/capa-escalation.test.ts:

import { describe, it, expect } from 'vitest'
import { getEscalationThreshold } from '@/lib/notifications/capa-escalation'

describe('getEscalationThreshold', () => {
  function daysFromNow(n: number): string {
    const d = new Date()
    d.setDate(d.getDate() + n)
    return d.toISOString().split('T')[0]
  }

  it('returns warning_3d when due in 3 days', () => {
    expect(getEscalationThreshold(daysFromNow(3))).toBe('warning_3d')
  })

  it('returns due_today when due today', () => {
    expect(getEscalationThreshold(daysFromNow(0))).toBe('due_today')
  })

  it('returns overdue_3d when 3 days past due', () => {
    expect(getEscalationThreshold(daysFromNow(-3))).toBe('overdue_3d')
  })

  it('returns overdue_7d when 7 days past due', () => {
    expect(getEscalationThreshold(daysFromNow(-7))).toBe('overdue_7d')
  })

  it('returns null for 2 days before due (no threshold)', () => {
    expect(getEscalationThreshold(daysFromNow(2))).toBeNull()
  })

  it('returns null for 4 days before due', () => {
    expect(getEscalationThreshold(daysFromNow(4))).toBeNull()
  })
})
  • Step 2: Run tests to confirm they fail
npx vitest run tests/lib/notifications/capa-escalation.test.ts

Expected: Cannot find module '@/lib/notifications/capa-escalation'

  • Step 3: Implement lib/notifications/capa-escalation.ts
import { Resend } from 'resend'
import type { SupabaseClient } from '@supabase/supabase-js'

export type EscalationThreshold = 'warning_3d' | 'due_today' | 'overdue_3d' | 'overdue_7d'

export function getEscalationThreshold(dueDateIso: string): EscalationThreshold | null {
  const today = new Date()
  today.setHours(0, 0, 0, 0)
  const due = new Date(dueDateIso)
  due.setHours(0, 0, 0, 0)
  const diffDays = Math.round((due.getTime() - today.getTime()) / 86_400_000)

  if (diffDays === 3) return 'warning_3d'
  if (diffDays === 0) return 'due_today'
  if (diffDays === -3) return 'overdue_3d'
  if (diffDays === -7) return 'overdue_7d'
  return null
}

const THRESHOLD_SUBJECT: Record<EscalationThreshold, string> = {
  warning_3d: '[IMS] CAPA action due in 3 days',
  due_today: '[IMS] CAPA action due TODAY',
  overdue_3d: '[IMS] CAPA action 3 days OVERDUE',
  overdue_7d: '[IMS] URGENT: CAPA action 7 days OVERDUE',
}

export async function escalateOverdueCapa(
  supabase: SupabaseClient
): Promise<{ notified: number }> {
  const { data: capas } = await supabase
    .from('capa_actions')
    .select(`
      id, description, due_date, incident_id,
      incidents (reference_no, site_id),
      owner:users!owner_user_id (email, name)
    `)
    .not('status', 'in', '(verified,closed)')

  if (!capas || capas.length === 0) return { notified: 0 }

  const resend = new Resend(process.env.RESEND_API_KEY)
  const from = process.env.RESEND_FROM_EMAIL ?? 'onboarding@resend.dev'
  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
  let notified = 0

  for (const capa of capas) {
    const threshold = getEscalationThreshold((capa as { due_date: string }).due_date)
    if (!threshold) continue

    const { data: alreadySent } = await supabase
      .from('notifications_log')
      .select('id')
      .eq('capa_id', capa.id)
      .eq('channel', 'email')
      .eq('status', threshold)
      .limit(1)
      .maybeSingle()

    if (alreadySent) continue

    const ownerEmail = (capa.owner as unknown as { email: string } | null)?.email
    const ownerName = (capa.owner as unknown as { name: string } | null)?.name ?? 'Owner'
    if (!ownerEmail) continue

    const incidentRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no ?? capa.incident_id
    const capaUrl = `${siteUrl}/ims/hse/capa/${capa.id}`
    const subject = THRESHOLD_SUBJECT[threshold]
    const html = `
      <p>Hi ${ownerName},</p>
      <p>CAPA action for incident <strong>${incidentRef}</strong> requires attention.</p>
      <p><strong>Action:</strong> ${(capa as { description: string }).description}</p>
      <p><strong>Due date:</strong> ${(capa as { due_date: string }).due_date}</p>
      <p><a href="${capaUrl}">View CAPA</a></p>
    `
    const text = `CAPA ${incidentRef}: ${(capa as { description: string }).description}\nDue: ${(capa as { due_date: string }).due_date}\n${capaUrl}`

    const to = [ownerEmail]
    const siteId = (capa.incidents as unknown as { site_id: string } | null)?.site_id
    if (siteId && ['due_today', 'overdue_3d', 'overdue_7d'].includes(threshold)) {
      const { data: hseUsers } = await supabase
        .from('users')
        .select('email')
        .eq('site_id', siteId)
        .in('role', ['hse', 'supervisor', 'management'])
      if (hseUsers) to.push(...hseUsers.map((u: { email: string }) => u.email))
    }

    const { error } = await resend.emails.send({ from, to: [...new Set(to)], subject, html, text })
    if (error) {
      console.error('Escalation email error:', error)
      continue
    }

    await supabase.from('notifications_log').insert({
      capa_id: capa.id,
      channel: 'email',
      recipient: to.join(','),
      status: threshold,
    })

    notified++
  }

  return { notified }
}
  • Step 4: Run tests to confirm they pass
npx vitest run tests/lib/notifications/capa-escalation.test.ts

Expected: 6 passed

  • Step 5: Create cron API route

Create app/api/cron/capa-escalation/route.ts:

export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { escalateOverdueCapa } from '@/lib/notifications/capa-escalation'

export async function GET(request: NextRequest) {
  const auth = request.headers.get('authorization')
  const expected = `Bearer ${process.env.CRON_SECRET}`
  if (!auth || auth !== expected) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const supabase = await createClient()
  const { notified } = await escalateOverdueCapa(supabase)
  return NextResponse.json({ ok: true, notified })
}
  • Step 6: Add CRON_SECRET to .env.local.example

Open .env.local.example and add at the bottom:

CRON_SECRET=<generate a random secret, e.g. openssl rand -hex 32>
  • Step 7: Register VPS cron job

On the VPS (run via SSH or note for manual setup):

# Add to root crontab — runs daily at 08:00 MYT (00:00 UTC)
crontab -e
# Add this line:
0 0 * * * curl -s -H "Authorization: Bearer <CRON_SECRET>" http://localhost:3000/ims/api/cron/capa-escalation >> /var/log/ims-cron.log 2>&1

Document in docs/vps-cron.md:

CAPA escalation: runs daily at 00:00 UTC (08:00 MYT)
Command: curl -s -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/ims/api/cron/capa-escalation
Log: /var/log/ims-cron.log
  • Step 8: Build check
npm run build 2>&1 | tail -20

Expected: zero errors.

  • Step 9: Commit
git add lib/notifications/capa-escalation.ts \
        app/api/cron/capa-escalation/route.ts \
        tests/lib/notifications/capa-escalation.test.ts \
        .env.local.example \
        docs/vps-cron.md
git commit -m "feat: CAPA overdue escalation cron — 4 thresholds, once-per-threshold via notifications_log"

Task 8: JKKP 6/7 PDF generation

Files:

  • Create: lib/pdf/jkkp.ts
  • Create: app/api/incidents/[id]/jkkp-pdf/route.ts
  • Create: tests/lib/pdf/jkkp.test.ts
  • Modify: package.json — add pdf-lib
  • Modify: app/(protected)/hse/incidents/[id]/page.tsx — add PDF download buttons for DOSH-reportable incidents

Interfaces:

  • Consumes: computeDoshObligation from @/lib/incidents/dosh

  • Produces:

    async function buildJkkp6Pdf(incident: JkkpIncident): Promise<Uint8Array>
    async function buildJkkp7Pdf(incident: JkkpIncident): Promise<Uint8Array>
    
    type JkkpIncident = {
      reference_no: string | null
      incident_type: string
      description: string
      reported_at: string
      severity: number | null
      is_fatality: boolean
      is_serious_bodily_injury: boolean
      is_dangerous_occurrence: boolean
      lost_days: number | null
      site_name: string
      reporter_name: string
    }
    
  • Produces: GET /api/incidents/[id]/jkkp-pdf?form=jkkp6 or ?form=jkkp7 — streams PDF bytes

  • Step 1: Install pdf-lib

npm install pdf-lib

Expected: pdf-lib added to package.json dependencies.

  • Step 2: Write failing tests

Create tests/lib/pdf/jkkp.test.ts:

import { describe, it, expect } from 'vitest'
import { buildJkkp6Pdf, buildJkkp7Pdf } from '@/lib/pdf/jkkp'

const mockIncident = {
  reference_no: 'TEST-202607-0001',
  incident_type: 'injury',
  description: 'Worker fell from platform',
  reported_at: new Date('2026-07-01T08:00:00Z').toISOString(),
  severity: 4,
  is_fatality: false,
  is_serious_bodily_injury: true,
  is_dangerous_occurrence: false,
  lost_days: 5,
  site_name: 'Setia Corporation Warehouse 1',
  reporter_name: 'John Doe',
}

describe('buildJkkp6Pdf', () => {
  it('returns a non-empty Uint8Array', async () => {
    const pdf = await buildJkkp6Pdf(mockIncident)
    expect(pdf).toBeInstanceOf(Uint8Array)
    expect(pdf.length).toBeGreaterThan(100)
  })

  it('PDF bytes start with %PDF', async () => {
    const pdf = await buildJkkp6Pdf(mockIncident)
    const header = new TextDecoder().decode(pdf.slice(0, 4))
    expect(header).toBe('%PDF')
  })
})

describe('buildJkkp7Pdf', () => {
  it('returns a non-empty Uint8Array', async () => {
    const pdf = await buildJkkp7Pdf(mockIncident)
    expect(pdf).toBeInstanceOf(Uint8Array)
    expect(pdf.length).toBeGreaterThan(100)
  })

  it('PDF bytes start with %PDF', async () => {
    const pdf = await buildJkkp7Pdf(mockIncident)
    const header = new TextDecoder().decode(pdf.slice(0, 4))
    expect(header).toBe('%PDF')
  })
})
  • Step 3: Run tests to confirm they fail
npx vitest run tests/lib/pdf/jkkp.test.ts

Expected: Cannot find module '@/lib/pdf/jkkp'

  • Step 4: Implement lib/pdf/jkkp.ts
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'

export type JkkpIncident = {
  reference_no: string | null
  incident_type: string
  description: string
  reported_at: string
  severity: number | null
  is_fatality: boolean
  is_serious_bodily_injury: boolean
  is_dangerous_occurrence: boolean
  lost_days: number | null
  site_name: string
  reporter_name: string
}

async function createBaseDoc(title: string): Promise<{ doc: PDFDocument; page: ReturnType<PDFDocument['addPage']>; font: Awaited<ReturnType<PDFDocument['embedFont']>>; boldFont: Awaited<ReturnType<PDFDocument['embedFont']>>; y: { value: number } }> {
  const doc = await PDFDocument.create()
  const page = doc.addPage([595, 842]) // A4
  const font = await doc.embedFont(StandardFonts.Helvetica)
  const boldFont = await doc.embedFont(StandardFonts.HelveticaBold)
  const y = { value: 800 }

  page.drawText(title, { x: 50, y: y.value, size: 14, font: boldFont, color: rgb(0, 0, 0) })
  y.value -= 8
  page.drawLine({ start: { x: 50, y: y.value }, end: { x: 545, y: y.value }, thickness: 1, color: rgb(0, 0, 0) })
  y.value -= 20

  return { doc, page, font, boldFont, y }
}

function drawField(
  page: ReturnType<PDFDocument['addPage']>,
  label: string, value: string,
  font: Awaited<ReturnType<PDFDocument['embedFont']>>,
  boldFont: Awaited<ReturnType<PDFDocument['embedFont']>>,
  y: { value: number }
) {
  page.drawText(label + ':', { x: 50, y: y.value, size: 9, font: boldFont, color: rgb(0.3, 0.3, 0.3) })
  const lines = value.length > 80 ? [value.slice(0, 80), value.slice(80, 160)] : [value]
  for (const line of lines) {
    y.value -= 14
    page.drawText(line || '—', { x: 50, y: y.value, size: 10, font, color: rgb(0, 0, 0) })
  }
  y.value -= 8
}

export async function buildJkkp6Pdf(incident: JkkpIncident): Promise<Uint8Array> {
  const { doc, page, font, boldFont, y } = await createBaseDoc('BORANG JKKP 6 — Notis Kemalangan / Kejadian Berbahaya')

  const reportedDate = new Date(incident.reported_at).toLocaleDateString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })

  drawField(page, 'Rujukan / Reference', incident.reference_no ?? '—', font, boldFont, y)
  drawField(page, 'Tapak / Site', incident.site_name, font, boldFont, y)
  drawField(page, 'Tarikh Laporan / Date Reported', reportedDate, font, boldFont, y)
  drawField(page, 'Jenis Insiden / Incident Type', incident.incident_type.replace(/_/g, ' '), font, boldFont, y)
  drawField(page, 'Dilaporkan Oleh / Reported By', incident.reporter_name, font, boldFont, y)
  drawField(page, 'Penerangan / Description', incident.description, font, boldFont, y)
  drawField(page, 'Kematian / Fatality', incident.is_fatality ? 'Yes' : 'No', font, boldFont, y)
  drawField(page, 'Kecederaan Serius / Serious Bodily Injury', incident.is_serious_bodily_injury ? 'Yes' : 'No', font, boldFont, y)
  drawField(page, 'Kejadian Berbahaya / Dangerous Occurrence', incident.is_dangerous_occurrence ? 'Yes' : 'No', font, boldFont, y)
  drawField(page, 'Hari Hilang Kerja / Lost Days', incident.lost_days !== null ? String(incident.lost_days) : '—', font, boldFont, y)

  y.value -= 20
  page.drawText('Nota: Borang ini adalah draf yang dijana secara automatik. Sila semak sebelum dikemukakan ke DOSH.',
    { x: 50, y: y.value, size: 8, font, color: rgb(0.5, 0.5, 0.5) })

  return doc.save()
}

export async function buildJkkp7Pdf(incident: JkkpIncident): Promise<Uint8Array> {
  const { doc, page, font, boldFont, y } = await createBaseDoc('BORANG JKKP 7 — Laporan Siasatan Kemalangan')

  const reportedDate = new Date(incident.reported_at).toLocaleDateString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })

  drawField(page, 'Rujukan / Reference', incident.reference_no ?? '—', font, boldFont, y)
  drawField(page, 'Tapak / Site', incident.site_name, font, boldFont, y)
  drawField(page, 'Tarikh Kemalangan / Incident Date', reportedDate, font, boldFont, y)
  drawField(page, 'Jenis Insiden / Incident Type', incident.incident_type.replace(/_/g, ' '), font, boldFont, y)
  drawField(page, 'Penerangan Kemalangan / Incident Description', incident.description, font, boldFont, y)
  drawField(page, 'Keterukan / Severity', incident.severity !== null ? String(incident.severity) + ' / 5' : '—', font, boldFont, y)
  drawField(page, 'Hari Hilang Kerja / Lost Days', incident.lost_days !== null ? String(incident.lost_days) : '—', font, boldFont, y)

  y.value -= 20
  page.drawText('Rumusan Punca Asas / Root Cause Summary:', { x: 50, y: y.value, size: 9, font: boldFont, color: rgb(0.3, 0.3, 0.3) })
  y.value -= 14
  page.drawText('[To be completed by HSE investigator]', { x: 50, y: y.value, size: 10, font, color: rgb(0.6, 0.6, 0.6) })

  y.value -= 30
  page.drawText('Nota: Borang ini adalah draf yang dijana secara automatik. Sila semak sebelum dikemukakan ke DOSH.',
    { x: 50, y: y.value, size: 8, font, color: rgb(0.5, 0.5, 0.5) })

  return doc.save()
}
  • Step 5: Run tests to confirm they pass
npx vitest run tests/lib/pdf/jkkp.test.ts

Expected: 4 passed

  • Step 6: Create JKKP PDF API route

Create app/api/incidents/[id]/jkkp-pdf/route.ts:

export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { buildJkkp6Pdf, buildJkkp7Pdf, type JkkpIncident } from '@/lib/pdf/jkkp'
import { computeDoshObligation } from '@/lib/incidents/dosh'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const form = request.nextUrl.searchParams.get('form')
  if (form !== 'jkkp6' && form !== 'jkkp7')
    return NextResponse.json({ error: 'form must be jkkp6 or jkkp7' }, { status: 422 })

  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 || !['hse', 'admin'].includes(profile.role))
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })

  const { data: incident } = await supabase
    .from('incidents')
    .select(`
      reference_no, incident_type, description, reported_at, severity, lost_days,
      is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
      sites (name),
      reporter:users!reported_by (name)
    `)
    .eq('id', id)
    .single()

  if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })

  const dosh = computeDoshObligation({
    is_fatality: (incident as { is_fatality: boolean }).is_fatality,
    is_serious_bodily_injury: (incident as { is_serious_bodily_injury: boolean }).is_serious_bodily_injury,
    is_dangerous_occurrence: (incident as { is_dangerous_occurrence: boolean }).is_dangerous_occurrence,
    is_occupational_disease: (incident as { is_occupational_disease: boolean }).is_occupational_disease,
    lost_days: (incident as { lost_days: number | null }).lost_days,
  })

  const required = form === 'jkkp6' ? dosh.requires_jkkp6 : dosh.requires_jkkp7
  if (!required) return NextResponse.json({ error: 'This form is not required for this incident' }, { status: 400 })

  const jkkpIncident: JkkpIncident = {
    reference_no: (incident as { reference_no: string | null }).reference_no,
    incident_type: (incident as { incident_type: string }).incident_type,
    description: (incident as { description: string }).description,
    reported_at: (incident as { reported_at: string }).reported_at,
    severity: (incident as { severity: number | null }).severity,
    is_fatality: (incident as { is_fatality: boolean }).is_fatality,
    is_serious_bodily_injury: (incident as { is_serious_bodily_injury: boolean }).is_serious_bodily_injury,
    is_dangerous_occurrence: (incident as { is_dangerous_occurrence: boolean }).is_dangerous_occurrence,
    lost_days: (incident as { lost_days: number | null }).lost_days,
    site_name: (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown',
    reporter_name: (incident.reporter as unknown as { name: string } | null)?.name ?? 'Unknown',
  }

  const pdfBytes = form === 'jkkp6'
    ? await buildJkkp6Pdf(jkkpIncident)
    : await buildJkkp7Pdf(jkkpIncident)

  const ref = jkkpIncident.reference_no ?? id
  return new NextResponse(pdfBytes, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="${form}-${ref}.pdf"`,
    },
  })
}
  • Step 7: Add PDF download buttons to incident detail page

Modify app/(protected)/hse/incidents/[id]/page.tsx — in the HSE-only action section, add:

{/* After existing action buttons, still inside isHse check */}
{/* DOSH PDF links — only render if fields exist (post-triage) */}
{Boolean((incident as { is_fatality?: boolean }).is_fatality ||
         (incident as { is_serious_bodily_injury?: boolean }).is_serious_bodily_injury ||
         (incident as { is_dangerous_occurrence?: boolean }).is_dangerous_occurrence ||
         ((incident as { lost_days?: number | null }).lost_days ?? 0) >= 4) && (
  <div className="mt-4 flex gap-3">
    <a
      href={`/api/incidents/${id}/jkkp-pdf?form=jkkp6`}
      target="_blank"
      className="inline-block bg-gray-800 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-900"
    >
      Download JKKP 6 (PDF)
    </a>
    <a
      href={`/api/incidents/${id}/jkkp-pdf?form=jkkp7`}
      target="_blank"
      className="inline-block bg-gray-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-700"
    >
      Download JKKP 7 (PDF)
    </a>
  </div>
)}
  • Step 8: Build check
npm run build 2>&1 | tail -20

Expected: zero errors.

  • Step 9: Run all tests
npx vitest run

Expected: all tests pass (dosh + capa-escalation + jkkp).

  • Step 10: Commit
git add lib/pdf/jkkp.ts \
        app/api/incidents/\[id\]/jkkp-pdf/route.ts \
        tests/lib/pdf/jkkp.test.ts \
        app/\(protected\)/hse/incidents/\[id\]/page.tsx \
        package.json package-lock.json
git commit -m "feat: JKKP 6/7 PDF generation via pdf-lib, download from incident detail"

Post-Implementation: Deploy to VPS

After all tasks complete and npm run build passes:

# From local machine
rsync -avz --exclude='.git' --exclude='node_modules' --exclude='.next' --exclude='.env.local' \
  /Users/yapweeihan/Desktop/Projects/IMS.nosync/ root@64.176.82.100:/ims/

ssh root@64.176.82.100 "cd /ims && npm ci && npm run build && \
  cp -r public .next/standalone/ && \
  cp -r .next/static .next/standalone/.next/ && \
  pm2 restart ims && pm2 status"

Add CRON_SECRET to VPS .env.local, then install cron:

ssh root@64.176.82.100 "crontab -l | { cat; echo '0 0 * * * curl -s -H \"Authorization: Bearer \$(grep CRON_SECRET /ims/.env.local | cut -d= -f2)\" http://localhost:3000/ims/api/cron/capa-escalation >> /var/log/ims-cron.log 2>&1'; } | crontab -"