# Phase 0 — Foundation 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:** Working Next.js + Supabase skeleton with login, role-based routing, full DB schema, and one seeded test site with QR codes — no incident forms yet. **Architecture:** Single Next.js 15 App Router application with server-side Supabase clients. Auth handled by Supabase (email/password). Role stored in `public.users.role`; middleware reads it on every request and enforces redirects. Migrations define all 10 tables + RLS policies in SQL files, applied via Supabase CLI. **Tech Stack:** Next.js 15 (App Router, TypeScript, Tailwind), Supabase (Postgres + Auth + RLS), Vitest (unit tests), `@supabase/ssr` (cookie-based session), `qrcode` + `tsx` (QR generation script), Supabase CLI (migrations). ## Global Constraints - Node.js v26.3.0, npm 11.16.0 - No Docker available — use Supabase cloud project, not `supabase start` local dev - TypeScript strict mode throughout — no `any` without comment - No `.env.local` committed — use `.env.local.example` as template - All Claude API calls must be server-side only — never in client components - RLS must be enabled on every table — never bypass with service-role key in app code - Incident reference format: `SITE-YYYYMM-####` (generated by Postgres trigger) - Evidence files: never hard-delete — soft delete only - Import alias `@/*` maps to project root --- ## File Map ``` IMS/ ├── app/ │ ├── (auth)/login/page.tsx # Login page (public) │ ├── (protected)/ │ │ ├── layout.tsx # Auth guard — redirect to /login if no session │ │ ├── reporter/page.tsx # Placeholder: Phase 1 adds incident form │ │ ├── supervisor/page.tsx │ │ ├── hse/page.tsx │ │ ├── capa-owner/page.tsx │ │ ├── management/page.tsx │ │ └── admin/page.tsx │ ├── api/auth/callback/route.ts # Supabase OAuth callback (email magic link support) │ ├── layout.tsx # Root layout │ └── page.tsx # Root redirect → role home or /login ├── components/auth/login-form.tsx # Client component: email/password form ├── lib/ │ ├── supabase/ │ │ ├── client.ts # Browser Supabase client (createBrowserClient) │ │ └── server.ts # Server Supabase client (createServerClient + cookies) │ └── auth/ │ └── roles.ts # UserRole type + ROLE_HOME map (pure, unit-testable) ├── middleware.ts # Protect all routes; redirect unauthenticated to /login ├── supabase/migrations/ │ ├── 20260709000001_sites_zones.sql │ ├── 20260709000002_users.sql │ ├── 20260709000003_incidents.sql │ ├── 20260709000004_evidence_investigations.sql │ ├── 20260709000005_capa_dosh.sql │ ├── 20260709000006_notifications_audit.sql │ ├── 20260709000007_rls_policies.sql │ └── 20260709000008_seed.sql ├── scripts/generate-qr.ts # Generates QR PNG for each seeded zone ├── public/qr/ # QR output dir (git-ignored images, checked-in .gitkeep) ├── __tests__/lib/auth/roles.test.ts # Unit tests for roles.ts ├── vitest.config.ts ├── .env.local.example └── .gitignore # Extended by create-next-app ``` --- ## Task 1: Scaffold Project + Supabase Clients **Files:** - Create: all Next.js scaffold files (via `create-next-app`) - Create: `lib/supabase/client.ts` - Create: `lib/supabase/server.ts` - Create: `.env.local.example` - Create: `vitest.config.ts` - Modify: `package.json` (add test + generate-qr scripts) **Interfaces:** - Produces: `createClient()` from `@/lib/supabase/client` (browser), `createClient()` from `@/lib/supabase/server` (async, server) - [ ] **Step 1: Initialize git repo inside IMS** ```bash cd /Users/yapweeihan/Desktop/Projects/IMS git init echo "node_modules/\n.next/\n.env.local\npublic/qr/*.png" >> .gitignore ``` - [ ] **Step 2: Scaffold Next.js project** ```bash npx create-next-app@latest . \ --typescript \ --tailwind \ --app \ --no-src-dir \ --turbopack \ --import-alias "@/*" \ --yes ``` Expected: Next.js 15 project created in current directory. `npm run dev` should start on port 3000. - [ ] **Step 3: Install Supabase + test packages** ```bash npm install @supabase/supabase-js @supabase/ssr npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom tsx qrcode @types/qrcode ``` - [ ] **Step 4: Write vitest.config.ts** ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import path from 'path' export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, setupFiles: [], }, resolve: { alias: { '@': path.resolve(__dirname, '.'), }, }, }) ``` - [ ] **Step 5: Add scripts to package.json** Open `package.json` and add to `"scripts"`: ```json "test": "vitest run", "test:watch": "vitest", "generate-qr": "tsx scripts/generate-qr.ts" ``` - [ ] **Step 6: Create .env.local.example** ```bash # .env.local.example NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here NEXT_PUBLIC_APP_URL=http://localhost:3000 ``` Then copy to actual env file (you fill in real values after creating Supabase project): ```bash cp .env.local.example .env.local ``` - [ ] **Step 7: Create lib/supabase/client.ts** ```typescript // lib/supabase/client.ts import { createBrowserClient } from '@supabase/ssr' export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, ) } ``` - [ ] **Step 8: Create lib/supabase/server.ts** ```typescript // lib/supabase/server.ts import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' export async function createClient() { const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll() }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options), ) } catch { // Server Component — cookie setting handled by middleware } }, }, }, ) } ``` - [ ] **Step 9: Verify build compiles** ```bash npm run build ``` Expected: Build succeeds with no TypeScript errors. - [ ] **Step 10: Commit** ```bash git add -A git commit -m "chore: scaffold Next.js 15 + Supabase clients + vitest" ``` --- ## Task 2: Role Types + Middleware **Files:** - Create: `lib/auth/roles.ts` - Create: `__tests__/lib/auth/roles.test.ts` - Create: `middleware.ts` **Interfaces:** - Produces: `UserRole` (union type), `ROLE_HOME` (Record), `getRoleHome(role: UserRole): string` - Consumes: `createClient()` from `@/lib/supabase/server` (in middleware only) - [ ] **Step 1: Write the failing test** ```typescript // __tests__/lib/auth/roles.test.ts import { describe, it, expect } from 'vitest' import { getRoleHome, ROLE_HOME, ALL_ROLES } from '@/lib/auth/roles' describe('getRoleHome', () => { it('returns /reporter for reporter role', () => { expect(getRoleHome('reporter')).toBe('/reporter') }) it('returns /supervisor for supervisor role', () => { expect(getRoleHome('supervisor')).toBe('/supervisor') }) it('returns /hse for hse role', () => { expect(getRoleHome('hse')).toBe('/hse') }) it('returns /capa-owner for capa_owner role', () => { expect(getRoleHome('capa_owner')).toBe('/capa-owner') }) it('returns /management for management role', () => { expect(getRoleHome('management')).toBe('/management') }) it('returns /admin for admin role', () => { expect(getRoleHome('admin')).toBe('/admin') }) it('ROLE_HOME covers exactly 6 roles', () => { expect(Object.keys(ROLE_HOME)).toHaveLength(6) }) it('ALL_ROLES lists exactly 6 roles', () => { expect(ALL_ROLES).toHaveLength(6) }) }) ``` - [ ] **Step 2: Run test to verify it fails** ```bash npm test ``` Expected: FAIL — `Cannot find module '@/lib/auth/roles'` - [ ] **Step 3: Implement lib/auth/roles.ts** ```typescript // lib/auth/roles.ts export type UserRole = | 'reporter' | 'supervisor' | 'hse' | 'capa_owner' | 'management' | 'admin' export const ALL_ROLES: UserRole[] = [ 'reporter', 'supervisor', 'hse', 'capa_owner', 'management', 'admin', ] export const ROLE_HOME: Record = { reporter: '/reporter', supervisor: '/supervisor', hse: '/hse', capa_owner: '/capa-owner', management: '/management', admin: '/admin', } export function getRoleHome(role: UserRole): string { return ROLE_HOME[role] } export function isValidRole(value: unknown): value is UserRole { return ALL_ROLES.includes(value as UserRole) } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash npm test ``` Expected: PASS — 8 tests passing. - [ ] **Step 5: Create middleware.ts** ```typescript // middleware.ts import { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' import { ROLE_HOME, isValidRole, type UserRole } from '@/lib/auth/roles' export async function middleware(request: NextRequest) { let supabaseResponse = NextResponse.next({ request }) const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return request.cookies.getAll() }, setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) supabaseResponse = NextResponse.next({ request }) cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options), ) }, }, }, ) const { data: { user }, } = await supabase.auth.getUser() const { pathname } = request.nextUrl const isPublicRoute = pathname.startsWith('/login') || pathname.startsWith('/auth') // Unauthenticated → force login if (!user && !isPublicRoute) { return NextResponse.redirect(new URL('/login', request.url)) } // Authenticated + hitting root or login → redirect to role home if (user && (pathname === '/' || pathname === '/login')) { const { data: profile } = await supabase .from('users') .select('role') .eq('id', user.id) .single() const role = profile?.role if (isValidRole(role)) { return NextResponse.redirect(new URL(ROLE_HOME[role as UserRole], request.url)) } } return supabaseResponse } export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], } ``` - [ ] **Step 6: Commit** ```bash git add lib/auth/roles.ts __tests__/lib/auth/roles.test.ts middleware.ts git commit -m "feat: add user role types and auth middleware" ``` --- ## Task 3: Database Migrations **Files:** - Create: `supabase/migrations/20260709000001_sites_zones.sql` - Create: `supabase/migrations/20260709000002_users.sql` - Create: `supabase/migrations/20260709000003_incidents.sql` - Create: `supabase/migrations/20260709000004_evidence_investigations.sql` - Create: `supabase/migrations/20260709000005_capa_dosh.sql` - Create: `supabase/migrations/20260709000006_notifications_audit.sql` - Create: `supabase/migrations/20260709000007_rls_policies.sql` - Create: `supabase/migrations/20260709000008_seed.sql` **Interfaces:** - Produces: All 10 DB tables, 3 helper SQL functions, RLS policies, seed data - [ ] **Step 1: Create migrations directory** ```bash mkdir -p supabase/migrations ``` - [ ] **Step 2: Write 20260709000001_sites_zones.sql** ```sql -- supabase/migrations/20260709000001_sites_zones.sql CREATE TABLE sites ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, address TEXT, region TEXT, active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE zones ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE, name TEXT NOT NULL, qr_code_token TEXT NOT NULL UNIQUE DEFAULT gen_random_uuid()::TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX zones_site_id_idx ON zones(site_id); ``` - [ ] **Step 3: Write 20260709000002_users.sql** ```sql -- supabase/migrations/20260709000002_users.sql CREATE TYPE user_role AS ENUM ( 'reporter', 'supervisor', 'hse', 'capa_owner', 'management', 'admin' ); CREATE TABLE users ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', phone TEXT, role user_role NOT NULL DEFAULT 'reporter', department TEXT, site_id UUID REFERENCES sites(id), active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Auto-create users row when auth user signs up CREATE OR REPLACE FUNCTION public.handle_new_auth_user() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ BEGIN INSERT INTO public.users (id, email, name) VALUES ( NEW.id, NEW.email, COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.email) ) ON CONFLICT (id) DO NOTHING; RETURN NEW; END; $$; CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_auth_user(); ``` - [ ] **Step 4: Write 20260709000003_incidents.sql** ```sql -- supabase/migrations/20260709000003_incidents.sql CREATE TYPE incident_type AS ENUM ( 'injury', 'near_miss', 'hazard', 'asset_damage', 'environmental', 'security', 'fire' ); CREATE TYPE incident_status AS ENUM ( 'reported', 'triaged', 'investigating', 'capa_pending', 'verification', 'closed' ); CREATE TYPE medical_status AS ENUM ( 'none', 'first_aid', 'medical_treatment', 'lti' ); CREATE TABLE incidents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), reference_no TEXT UNIQUE, incident_type incident_type NOT NULL, site_id UUID NOT NULL REFERENCES sites(id), zone_id UUID REFERENCES zones(id), reported_by UUID NOT NULL REFERENCES users(id), reported_at TIMESTAMPTZ NOT NULL DEFAULT now(), description TEXT NOT NULL, severity SMALLINT CHECK (severity BETWEEN 1 AND 5), status incident_status NOT NULL DEFAULT 'reported', injury_involved BOOLEAN NOT NULL DEFAULT false, asset_involved BOOLEAN NOT NULL DEFAULT false, medical_status medical_status, lost_days INT CHECK (lost_days >= 0), closed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Auto-generate reference_no: SITECODE-YYYYMM-#### (e.g. SCW1-202607-0001) CREATE OR REPLACE FUNCTION public.generate_incident_reference() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE v_site_code TEXT; v_month TEXT; v_seq INT; BEGIN SELECT UPPER(REGEXP_REPLACE(SUBSTRING(name, 1, 6), '[^A-Za-z0-9]', '', 'g')) INTO v_site_code FROM sites WHERE id = NEW.site_id; v_month := TO_CHAR(NEW.reported_at, 'YYYYMM'); SELECT COUNT(*) + 1 INTO v_seq FROM incidents WHERE site_id = NEW.site_id AND TO_CHAR(reported_at, 'YYYYMM') = v_month; NEW.reference_no := v_site_code || '-' || v_month || '-' || LPAD(v_seq::TEXT, 4, '0'); RETURN NEW; END; $$; CREATE TRIGGER set_incident_reference BEFORE INSERT ON incidents FOR EACH ROW WHEN (NEW.reference_no IS NULL) EXECUTE FUNCTION public.generate_incident_reference(); CREATE INDEX incidents_site_id_idx ON incidents(site_id); CREATE INDEX incidents_reported_by_idx ON incidents(reported_by); CREATE INDEX incidents_status_idx ON incidents(status); ``` - [ ] **Step 5: Write 20260709000004_evidence_investigations.sql** ```sql -- supabase/migrations/20260709000004_evidence_investigations.sql CREATE TYPE evidence_stage AS ENUM ( 'report', 'response', 'investigation', 'capa', 'verification' ); CREATE TABLE evidence_files ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE RESTRICT, stage evidence_stage NOT NULL, file_url TEXT NOT NULL, file_type TEXT NOT NULL, file_hash TEXT NOT NULL, -- immutable after upload (DOSH audit integrity) uploaded_by UUID NOT NULL REFERENCES users(id), uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT false -- NEVER hard-delete; use this flag ); CREATE TYPE rca_method AS ENUM ('five_why', 'fishbone', 'other'); CREATE TABLE investigations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE RESTRICT, investigator_id UUID NOT NULL REFERENCES users(id), method rca_method NOT NULL DEFAULT 'five_why', findings_text TEXT, root_cause_summary TEXT, alcohol_test_result TEXT, witness_statement_refs TEXT[], completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX evidence_files_incident_idx ON evidence_files(incident_id); CREATE INDEX investigations_incident_idx ON investigations(incident_id); ``` - [ ] **Step 6: Write 20260709000005_capa_dosh.sql** ```sql -- supabase/migrations/20260709000005_capa_dosh.sql CREATE TYPE capa_priority AS ENUM ('low', 'med', 'high'); CREATE TYPE capa_status AS ENUM ( 'open', 'in_progress', 'overdue', 'pending_verification', 'verified', 'reopened', 'closed' ); CREATE TABLE capa_actions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE RESTRICT, root_cause_ref TEXT, description TEXT NOT NULL, owner_user_id UUID NOT NULL REFERENCES users(id), department TEXT NOT NULL, due_date DATE NOT NULL, priority capa_priority NOT NULL DEFAULT 'med', status capa_status NOT NULL DEFAULT 'open', completed_at TIMESTAMPTZ, verified_by UUID REFERENCES users(id), verified_at TIMESTAMPTZ, effectiveness_recheck_date DATE, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TYPE dosh_form_type AS ENUM ('jkkp6', 'jkkp7', 'jkkp8'); CREATE TYPE dosh_status AS ENUM ('not_required', 'pending', 'submitted'); CREATE TABLE dosh_reports ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE RESTRICT, form_type dosh_form_type NOT NULL, status dosh_status NOT NULL DEFAULT 'not_required', submitted_at TIMESTAMPTZ, submitted_by UUID REFERENCES users(id), file_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX capa_actions_incident_idx ON capa_actions(incident_id); CREATE INDEX capa_actions_owner_idx ON capa_actions(owner_user_id); CREATE INDEX capa_actions_status_idx ON capa_actions(status); CREATE INDEX capa_actions_due_date_idx ON capa_actions(due_date); ``` - [ ] **Step 7: Write 20260709000006_notifications_audit.sql** ```sql -- supabase/migrations/20260709000006_notifications_audit.sql CREATE TYPE notification_channel AS ENUM ('email', 'whatsapp', 'in_app'); CREATE TABLE notifications_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), incident_id UUID REFERENCES incidents(id), capa_id UUID REFERENCES capa_actions(id), channel notification_channel NOT NULL, recipient TEXT NOT NULL, sent_at TIMESTAMPTZ NOT NULL DEFAULT now(), status TEXT NOT NULL DEFAULT 'sent' ); CREATE TABLE audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), table_name TEXT NOT NULL, record_id UUID NOT NULL, action TEXT NOT NULL, changed_by UUID REFERENCES users(id), changed_at TIMESTAMPTZ NOT NULL DEFAULT now(), old_value JSONB, new_value JSONB ); CREATE INDEX audit_log_table_record_idx ON audit_log(table_name, record_id); CREATE INDEX audit_log_changed_by_idx ON audit_log(changed_by); ``` - [ ] **Step 8: Write 20260709000007_rls_policies.sql** ```sql -- supabase/migrations/20260709000007_rls_policies.sql -- Enable RLS on all application tables ALTER TABLE sites ENABLE ROW LEVEL SECURITY; ALTER TABLE zones ENABLE ROW LEVEL SECURITY; ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE incidents ENABLE ROW LEVEL SECURITY; ALTER TABLE evidence_files ENABLE ROW LEVEL SECURITY; ALTER TABLE investigations ENABLE ROW LEVEL SECURITY; ALTER TABLE capa_actions ENABLE ROW LEVEL SECURITY; ALTER TABLE dosh_reports ENABLE ROW LEVEL SECURITY; ALTER TABLE notifications_log ENABLE ROW LEVEL SECURITY; ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY; -- Helper functions (SECURITY DEFINER avoids recursion through RLS) CREATE OR REPLACE FUNCTION public.auth_user_role() RETURNS user_role LANGUAGE sql SECURITY DEFINER STABLE AS $$ SELECT role FROM public.users WHERE id = auth.uid() $$; CREATE OR REPLACE FUNCTION public.auth_user_site_id() RETURNS UUID LANGUAGE sql SECURITY DEFINER STABLE AS $$ SELECT site_id FROM public.users WHERE id = auth.uid() $$; CREATE OR REPLACE FUNCTION public.auth_user_department() RETURNS TEXT LANGUAGE sql SECURITY DEFINER STABLE AS $$ SELECT department FROM public.users WHERE id = auth.uid() $$; -- sites: everyone reads active sites; only admin manages CREATE POLICY "sites_read" ON sites FOR SELECT USING (active = true); CREATE POLICY "sites_admin_all" ON sites FOR ALL USING (auth_user_role() = 'admin'); -- zones: everyone reads; only admin manages CREATE POLICY "zones_read" ON zones FOR SELECT USING (true); CREATE POLICY "zones_admin_all" ON zones FOR ALL USING (auth_user_role() = 'admin'); -- users: own profile always readable; elevated roles see all; admin manages CREATE POLICY "users_read_own" ON users FOR SELECT USING (id = auth.uid()); CREATE POLICY "users_read_elevated" ON users FOR SELECT USING (auth_user_role() IN ('hse', 'admin', 'management', 'supervisor')); CREATE POLICY "users_update_own" ON users FOR UPDATE USING (id = auth.uid()); CREATE POLICY "users_admin_all" ON users FOR ALL USING (auth_user_role() = 'admin'); -- incidents: reporter sees own; supervisor sees own site; elevated sees all CREATE POLICY "incidents_insert" ON incidents FOR INSERT WITH CHECK (reported_by = auth.uid()); CREATE POLICY "incidents_read_reporter" ON incidents FOR SELECT USING (reported_by = auth.uid()); CREATE POLICY "incidents_read_supervisor" ON incidents FOR SELECT USING (auth_user_role() = 'supervisor' AND site_id = auth_user_site_id()); CREATE POLICY "incidents_read_elevated" ON incidents FOR SELECT USING (auth_user_role() IN ('hse', 'admin', 'management')); CREATE POLICY "incidents_update_elevated" ON incidents FOR UPDATE USING (auth_user_role() IN ('hse', 'admin', 'supervisor')); -- evidence_files: inserter or incident owner sees own; elevated sees all; no delete CREATE POLICY "evidence_insert" ON evidence_files FOR INSERT WITH CHECK (uploaded_by = auth.uid()); CREATE POLICY "evidence_read_uploader" ON evidence_files FOR SELECT USING (uploaded_by = auth.uid()); CREATE POLICY "evidence_read_elevated" ON evidence_files FOR SELECT USING (auth_user_role() IN ('hse', 'admin', 'management', 'supervisor')); -- No DELETE policy — files are never deleted (soft-delete only via `deleted` flag) -- investigations: hse/admin full access; supervisor read-only CREATE POLICY "investigations_hse_admin" ON investigations FOR ALL USING (auth_user_role() IN ('hse', 'admin')); CREATE POLICY "investigations_read_supervisor" ON investigations FOR SELECT USING (auth_user_role() = 'supervisor'); -- capa_actions: owner/dept reads and updates own; elevated reads all; hse/admin manages CREATE POLICY "capa_read_owner" ON capa_actions FOR SELECT USING (owner_user_id = auth.uid() OR department = auth_user_department()); CREATE POLICY "capa_update_owner" ON capa_actions FOR UPDATE USING (owner_user_id = auth.uid() OR department = auth_user_department()); CREATE POLICY "capa_read_elevated" ON capa_actions FOR SELECT USING (auth_user_role() IN ('hse', 'admin', 'management', 'supervisor')); CREATE POLICY "capa_hse_admin_all" ON capa_actions FOR ALL USING (auth_user_role() IN ('hse', 'admin')); -- dosh_reports: hse/admin only CREATE POLICY "dosh_hse_admin" ON dosh_reports FOR ALL USING (auth_user_role() IN ('hse', 'admin')); -- notifications_log: hse/admin read CREATE POLICY "notifications_read_elevated" ON notifications_log FOR SELECT USING (auth_user_role() IN ('hse', 'admin')); -- audit_log: hse/admin read only — no writes from app (writes via SECURITY DEFINER functions) CREATE POLICY "audit_read_elevated" ON audit_log FOR SELECT USING (auth_user_role() IN ('hse', 'admin')); ``` - [ ] **Step 9: Write 20260709000008_seed.sql** ```sql -- supabase/migrations/20260709000008_seed.sql -- Test site: Setia Corp Warehouse 1 INSERT INTO sites (id, name, address, region, active) VALUES ( '00000000-0000-0000-0000-000000000001'::UUID, 'SCW1', 'No. 1, Jalan Industri 1, Shah Alam, Selangor', 'Central', true ) ON CONFLICT (id) DO NOTHING; -- Zones for SCW1 INSERT INTO zones (id, site_id, name, qr_code_token) VALUES ( '00000000-0000-0000-0000-000000000010'::UUID, '00000000-0000-0000-0000-000000000001'::UUID, 'Dock A', 'scw1-dock-a-qr-2026' ), ( '00000000-0000-0000-0000-000000000011'::UUID, '00000000-0000-0000-0000-000000000001'::UUID, 'Cold Storage', 'scw1-cold-storage-qr-2026' ), ( '00000000-0000-0000-0000-000000000012'::UUID, '00000000-0000-0000-0000-000000000001'::UUID, 'Loading Bay', 'scw1-loading-bay-qr-2026' ) ON CONFLICT (id) DO NOTHING; ``` - [ ] **Step 10: Commit migration files** ```bash git add supabase/migrations/ git commit -m "feat: add database schema migrations and seed data" ``` --- ## Task 4: Apply Migrations to Supabase Cloud **Prerequisite:** You need a free Supabase project. If you don't have one: 1. Go to https://supabase.com → New project 2. Name it `ims-hse`, note the **Project URL** and **anon key** from Settings → API 3. Paste both into `.env.local` **Files:** No new files — applies existing migrations. - [ ] **Step 1: Install Supabase CLI** ```bash brew install supabase/tap/supabase supabase --version ``` Expected: `supabase version X.X.X` - [ ] **Step 2: Log in and link to your cloud project** ```bash supabase login # Opens browser — authenticate with your Supabase account supabase link --project-ref YOUR_PROJECT_REF # YOUR_PROJECT_REF is the string in your project URL: https://.supabase.co ``` - [ ] **Step 3: Push migrations** ```bash supabase db push ``` Expected output includes: `Applying migration 20260709000001_sites_zones.sql` through `...000008_seed.sql`. No errors. **Fallback (no CLI):** If `supabase db push` fails, go to Supabase Dashboard → SQL Editor → paste each migration file's SQL in order (001 through 008), run each. This works identically. - [ ] **Step 4: Verify in Supabase Dashboard** Open Supabase Dashboard → Table Editor. Confirm these tables exist: `sites`, `zones`, `users`, `incidents`, `evidence_files`, `investigations`, `capa_actions`, `dosh_reports`, `notifications_log`, `audit_log` Open Table Editor → `sites`. Confirm 1 row: `SCW1`. Open Table Editor → `zones`. Confirm 3 rows: `Dock A`, `Cold Storage`, `Loading Bay`. - [ ] **Step 5: Create test admin user in Supabase** Supabase Dashboard → Authentication → Users → Add user: - Email: `admin@ims-test.com` - Password: `TestAdmin123!` Then in SQL Editor, set their role to admin: ```sql UPDATE public.users SET role = 'admin', name = 'Test Admin', site_id = '00000000-0000-0000-0000-000000000001' WHERE email = 'admin@ims-test.com'; ``` --- ## Task 5: Auth Flow (Login + Redirect) **Files:** - Create: `app/(auth)/login/page.tsx` - Create: `components/auth/login-form.tsx` - Create: `app/api/auth/callback/route.ts` - Modify: `app/page.tsx` (replace default Next.js home) - Modify: `app/layout.tsx` (remove default styling if needed) **Interfaces:** - Consumes: `createClient()` from `@/lib/supabase/client` (login form), `@/lib/supabase/server` (page), `getRoleHome()` from `@/lib/auth/roles` - [ ] **Step 1: Create directory structure** ```bash mkdir -p app/'(auth)'/login mkdir -p app/api/auth/callback mkdir -p components/auth ``` - [ ] **Step 2: Create components/auth/login-form.tsx** ```tsx // components/auth/login-form.tsx 'use client' import { useState } from 'react' import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' export function LoginForm() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const router = useRouter() async function handleSubmit(e: React.FormEvent) { e.preventDefault() setLoading(true) setError(null) const supabase = createClient() const { error } = await supabase.auth.signInWithPassword({ email, password }) if (error) { setError(error.message) setLoading(false) return } router.refresh() } return (

IMS

HSE Incident Management

{error && (

{error}

)} setEmail(e.target.value)} required autoComplete="email" className="border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> setPassword(e.target.value)} required autoComplete="current-password" className="border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
) } ``` - [ ] **Step 3: Create app/(auth)/login/page.tsx** ```tsx // app/(auth)/login/page.tsx import { LoginForm } from '@/components/auth/login-form' export default function LoginPage() { return (
) } ``` - [ ] **Step 4: Create app/api/auth/callback/route.ts** ```typescript // app/api/auth/callback/route.ts import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url) const code = searchParams.get('code') const next = searchParams.get('next') ?? '/' if (code) { const supabase = await createClient() const { error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { return NextResponse.redirect(`${origin}${next}`) } } return NextResponse.redirect(`${origin}/login?error=auth_callback_failed`) } ``` - [ ] **Step 5: Replace app/page.tsx** ```tsx // app/page.tsx import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { getRoleHome, isValidRole, type UserRole } from '@/lib/auth/roles' export default async function RootPage() { const supabase = await createClient() const { data: { user }, } = await supabase.auth.getUser() if (!user) redirect('/login') const { data: profile } = await supabase .from('users') .select('role') .eq('id', user.id) .single() if (profile?.role && isValidRole(profile.role)) { redirect(getRoleHome(profile.role as UserRole)) } redirect('/login') } ``` - [ ] **Step 6: Update Supabase Auth redirect URL** In Supabase Dashboard → Authentication → URL Configuration: - Site URL: `http://localhost:3000` - Redirect URLs: add `http://localhost:3000/auth/callback` - [ ] **Step 7: Manual test** ```bash npm run dev ``` Open http://localhost:3000 → should redirect to `/login`. Sign in with `admin@ims-test.com` / `TestAdmin123!` → should redirect to `/admin`. - [ ] **Step 8: Commit** ```bash git add app/ components/ git commit -m "feat: add login page and auth callback route" ``` --- ## Task 6: Protected Dashboard Pages **Files:** - Create: `app/(protected)/layout.tsx` - Create: `app/(protected)/reporter/page.tsx` - Create: `app/(protected)/supervisor/page.tsx` - Create: `app/(protected)/hse/page.tsx` - Create: `app/(protected)/capa-owner/page.tsx` - Create: `app/(protected)/management/page.tsx` - Create: `app/(protected)/admin/page.tsx` **Interfaces:** - Consumes: `createClient()` from `@/lib/supabase/server` - [ ] **Step 1: Create directory structure** ```bash mkdir -p "app/(protected)/reporter" mkdir -p "app/(protected)/supervisor" mkdir -p "app/(protected)/hse" mkdir -p "app/(protected)/capa-owner" mkdir -p "app/(protected)/management" mkdir -p "app/(protected)/admin" ``` - [ ] **Step 2: Create app/(protected)/layout.tsx** ```tsx // app/(protected)/layout.tsx import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' export default async function ProtectedLayout({ children, }: { children: React.ReactNode }) { const supabase = await createClient() const { data: { user }, } = await supabase.auth.getUser() if (!user) redirect('/login') return <>{children} } ``` - [ ] **Step 3: Create app/(protected)/reporter/page.tsx** ```tsx // app/(protected)/reporter/page.tsx export default function ReporterHome() { return (

Reporter Dashboard

Phase 1: Incident report form coming here.

) } ``` - [ ] **Step 4: Create app/(protected)/supervisor/page.tsx** ```tsx // app/(protected)/supervisor/page.tsx export default function SupervisorHome() { return (

Supervisor Dashboard

Phase 1: Incident inbox coming here.

) } ``` - [ ] **Step 5: Create app/(protected)/hse/page.tsx** ```tsx // app/(protected)/hse/page.tsx export default function HSEHome() { return (

HSE Officer Dashboard

Phase 1: Incident inbox + triage coming here.

) } ``` - [ ] **Step 6: Create app/(protected)/capa-owner/page.tsx** ```tsx // app/(protected)/capa-owner/page.tsx export default function CapaOwnerHome() { return (

CAPA Owner Dashboard

Phase 2: CAPA board coming here.

) } ``` - [ ] **Step 7: Create app/(protected)/management/page.tsx** ```tsx // app/(protected)/management/page.tsx export default function ManagementHome() { return (

Management Dashboard

Phase 1: KPI dashboard coming here.

) } ``` - [ ] **Step 8: Create app/(protected)/admin/page.tsx** ```tsx // app/(protected)/admin/page.tsx export default function AdminHome() { return (

Admin Dashboard

Phase 0: User management and site config coming here.

) } ``` - [ ] **Step 9: Manual test all roles** In Supabase Dashboard → Authentication, create one test user per remaining role and update their `users.role`. Test each login → verify landing page matches role. | Email | Role | |---|---| | reporter@ims-test.com | reporter | | supervisor@ims-test.com | supervisor | | hse@ims-test.com | hse | - [ ] **Step 10: Commit** ```bash git add "app/(protected)/" git commit -m "feat: add protected role dashboard placeholders" ``` --- ## Task 7: QR Code Generation **Files:** - Create: `scripts/generate-qr.ts` - Create: `public/qr/.gitkeep` **Interfaces:** - Consumes: `NEXT_PUBLIC_APP_URL` env var, seeded zone tokens from `20260709000008_seed.sql` - Produces: PNG files in `public/qr/` (one per zone) - [ ] **Step 1: Create scripts/generate-qr.ts** ```typescript // scripts/generate-qr.ts import QRCode from 'qrcode' import { mkdirSync } from 'fs' import path from 'path' const BASE_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000' // Matches seeded zone qr_code_token values in 20260709000008_seed.sql const ZONES = [ { token: 'scw1-dock-a-qr-2026', name: 'SCW1 — Dock A' }, { token: 'scw1-cold-storage-qr-2026', name: 'SCW1 — Cold Storage' }, { token: 'scw1-loading-bay-qr-2026', name: 'SCW1 — Loading Bay' }, ] async function main() { const outDir = path.join(process.cwd(), 'public', 'qr') mkdirSync(outDir, { recursive: true }) for (const zone of ZONES) { const url = `${BASE_URL}/report?zone=${zone.token}` const outputPath = path.join(outDir, `${zone.token}.png`) await QRCode.toFile(outputPath, url, { width: 400, margin: 2, color: { dark: '#000000', light: '#FFFFFF' }, }) console.log(`✓ ${zone.name}`) console.log(` → ${url}`) console.log(` → ${outputPath}`) } } main().catch((err) => { console.error(err) process.exit(1) }) ``` - [ ] **Step 2: Create public/qr/.gitkeep and update .gitignore** ```bash mkdir -p public/qr touch public/qr/.gitkeep echo "public/qr/*.png" >> .gitignore ``` - [ ] **Step 3: Run QR generation** ```bash npm run generate-qr ``` Expected output: ``` ✓ SCW1 — Dock A → http://localhost:3000/report?zone=scw1-dock-a-qr-2026 → .../public/qr/scw1-dock-a-qr-2026.png ✓ SCW1 — Cold Storage → ... ✓ SCW1 — Loading Bay → ... ``` - [ ] **Step 4: Verify QR codes** Open `public/qr/scw1-dock-a-qr-2026.png` with Preview (or phone camera). Scanning should resolve to `http://localhost:3000/report?zone=scw1-dock-a-qr-2026`. - [ ] **Step 5: Run full test suite one final time** ```bash npm test ``` Expected: All 8 unit tests pass. - [ ] **Step 6: Final commit** ```bash git add scripts/ public/qr/.gitkeep .gitignore git commit -m "feat: add QR code generation script for seeded zones" ``` --- ## Self-Review **Spec coverage check:** | PRD/Spec Requirement | Covered in Task | |---|---| | Next.js + Supabase project | Task 1 | | DB schema §3 (all 10 tables) | Task 3 | | RLS at DB level | Task 3 (migration 007) | | Supabase Auth | Task 4 (Supabase setup) | | Login + role-based routing | Task 2 (middleware) + Task 5 | | 6 roles | Task 2 (roles.ts) + Task 6 (pages) | | One test site + zone + QR | Task 3 (migration 008) + Task 7 | | reference_no format SITE-YYYYMM-#### | Task 3 (migration 003 trigger) | | Audit trail (audit_log) | Task 3 (migration 006 table) | | Evidence never deleted | Task 3 (evidence_files.deleted flag) | **No placeholders found.** All steps contain actual code. **Type consistency:** `UserRole` defined in Task 2 → used in Task 5 (app/page.tsx) and Task 2 (middleware.ts). `isValidRole()` defined in Task 2 → used in Task 5. Consistent.