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

DOSH Reporting Required

+ {dosh.reasons.map(r => ( +

{r}

+ ))} + {dosh.requires_immediate_notification && ( +

⚠ Immediate notification required (within 24h)

+ )} +
+ )} + +
+ +