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

2629 lines
90 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```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: `<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`:
```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 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`:
```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<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`:
```typescript
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:
```typescript
{/* 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**
```bash
npm run build 2>&1 | tail -20
```
Expected: `✓ Compiled successfully` with no TypeScript or ESLint errors.
- [ ] **Step 6: Commit**
```bash
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 `triaged` → `investigating`
- Produces: PATCH `/api/incidents/[id]/investigation` — updates existing investigation, transitions `investigating` → `capa_pending` when completed
Five-Why data shape (stored as JSONB):
```typescript
type FiveWhyStep = { why: string; answer: string }
// five_why_steps: FiveWhyStep[] (up to 5 entries)
```
Fishbone data shape:
```typescript
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`:
```typescript
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`:
```typescript
'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`:
```typescript
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**
```bash
npm run build 2>&1 | tail -20
```
Expected: zero errors.
- [ ] **Step 5: Commit**
```bash
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 `investigating` → `capa_pending` if needed
- GET `/api/capa/[id]` — returns single `CapaAction`
- PATCH `/api/capa/[id]` — updates status/fields
```typescript
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`:
```typescript
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`:
```typescript
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`:
```typescript
'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`:
```typescript
'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`:
```typescript
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`:
```typescript
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**
```bash
npm run build 2>&1 | tail -20
```
Expected: zero errors.
- [ ] **Step 8: Commit**
```bash
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`:
```typescript
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`:
```typescript
'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`:
```typescript
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**
```bash
npm run build 2>&1 | tail -20
```
Expected: zero errors.
- [ ] **Step 5: Commit**
```bash
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`:
```typescript
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**
```bash
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`**
```typescript
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**
```bash
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`:
```typescript
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):
```bash
# 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**
```bash
npm run build 2>&1 | tail -20
```
Expected: zero errors.
- [ ] **Step 9: Commit**
```bash
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:
```typescript
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**
```bash
npm install pdf-lib
```
Expected: `pdf-lib` added to `package.json` dependencies.
- [ ] **Step 2: Write failing tests**
Create `tests/lib/pdf/jkkp.test.ts`:
```typescript
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**
```bash
npx vitest run tests/lib/pdf/jkkp.test.ts
```
Expected: `Cannot find module '@/lib/pdf/jkkp'`
- [ ] **Step 4: Implement `lib/pdf/jkkp.ts`**
```typescript
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**
```bash
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`:
```typescript
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:
```typescript
{/* 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**
```bash
npm run build 2>&1 | tail -20
```
Expected: zero errors.
- [ ] **Step 9: Run all tests**
```bash
npx vitest run
```
Expected: all tests pass (dosh + capa-escalation + jkkp).
- [ ] **Step 10: Commit**
```bash
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:
```bash
# 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:
```bash
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 -"
```