40 KiB
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 startlocal dev - TypeScript strict mode throughout — no
anywithout comment - No
.env.localcommitted — use.env.local.exampleas 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
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
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
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
// 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":
"test": "vitest run",
"test:watch": "vitest",
"generate-qr": "tsx scripts/generate-qr.ts"
- Step 6: Create .env.local.example
# .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):
cp .env.local.example .env.local
- Step 7: Create lib/supabase/client.ts
// 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
// 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
npm run build
Expected: Build succeeds with no TypeScript errors.
- Step 10: Commit
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<UserRole, string>),getRoleHome(role: UserRole): string -
Consumes:
createClient()from@/lib/supabase/server(in middleware only) -
Step 1: Write the failing test
// __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
npm test
Expected: FAIL — Cannot find module '@/lib/auth/roles'
- Step 3: Implement lib/auth/roles.ts
// 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<UserRole, string> = {
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
npm test
Expected: PASS — 8 tests passing.
- Step 5: Create middleware.ts
// 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
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
mkdir -p supabase/migrations
- Step 2: Write 20260709000001_sites_zones.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
-- 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
-- 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
-- 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
-- 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
-- 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
-- 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
-- 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
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:
- Go to https://supabase.com → New project
- Name it
ims-hse, note the Project URL and anon key from Settings → API - Paste both into
.env.local
Files: No new files — applies existing migrations.
- Step 1: Install Supabase CLI
brew install supabase/tap/supabase
supabase --version
Expected: supabase version X.X.X
- Step 2: Log in and link to your cloud project
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://<ref>.supabase.co
- Step 3: Push migrations
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:
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
mkdir -p app/'(auth)'/login
mkdir -p app/api/auth/callback
mkdir -p components/auth
- Step 2: Create components/auth/login-form.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<string | null>(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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full max-w-sm">
<div className="text-center">
<h1 className="text-2xl font-bold">IMS</h1>
<p className="text-sm text-gray-500 mt-1">HSE Incident Management</p>
</div>
{error && (
<p className="bg-red-50 border border-red-200 text-red-700 text-sm rounded px-3 py-2">
{error}
</p>
)}
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => 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"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 rounded-md disabled:opacity-50 transition-colors"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
)
}
- Step 3: Create app/(auth)/login/page.tsx
// app/(auth)/login/page.tsx
import { LoginForm } from '@/components/auth/login-form'
export default function LoginPage() {
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="bg-white rounded-xl shadow-sm border p-8 w-full max-w-sm">
<LoginForm />
</div>
</main>
)
}
- Step 4: Create app/api/auth/callback/route.ts
// 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
// 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
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
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
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
// 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
// app/(protected)/reporter/page.tsx
export default function ReporterHome() {
return (
<main className="p-8 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold">Reporter Dashboard</h1>
<p className="mt-2 text-gray-500">Phase 1: Incident report form coming here.</p>
</main>
)
}
- Step 4: Create app/(protected)/supervisor/page.tsx
// app/(protected)/supervisor/page.tsx
export default function SupervisorHome() {
return (
<main className="p-8 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold">Supervisor Dashboard</h1>
<p className="mt-2 text-gray-500">Phase 1: Incident inbox coming here.</p>
</main>
)
}
- Step 5: Create app/(protected)/hse/page.tsx
// app/(protected)/hse/page.tsx
export default function HSEHome() {
return (
<main className="p-8 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold">HSE Officer Dashboard</h1>
<p className="mt-2 text-gray-500">Phase 1: Incident inbox + triage coming here.</p>
</main>
)
}
- Step 6: Create app/(protected)/capa-owner/page.tsx
// app/(protected)/capa-owner/page.tsx
export default function CapaOwnerHome() {
return (
<main className="p-8 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold">CAPA Owner Dashboard</h1>
<p className="mt-2 text-gray-500">Phase 2: CAPA board coming here.</p>
</main>
)
}
- Step 7: Create app/(protected)/management/page.tsx
// app/(protected)/management/page.tsx
export default function ManagementHome() {
return (
<main className="p-8 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold">Management Dashboard</h1>
<p className="mt-2 text-gray-500">Phase 1: KPI dashboard coming here.</p>
</main>
)
}
- Step 8: Create app/(protected)/admin/page.tsx
// app/(protected)/admin/page.tsx
export default function AdminHome() {
return (
<main className="p-8 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<p className="mt-2 text-gray-500">Phase 0: User management and site config coming here.</p>
</main>
)
}
- 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.
| Role | |
|---|---|
| reporter@ims-test.com | reporter |
| supervisor@ims-test.com | supervisor |
| hse@ims-test.com | hse |
- Step 10: Commit
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_URLenv var, seeded zone tokens from20260709000008_seed.sql -
Produces: PNG files in
public/qr/(one per zone) -
Step 1: Create scripts/generate-qr.ts
// 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
mkdir -p public/qr
touch public/qr/.gitkeep
echo "public/qr/*.png" >> .gitignore
- Step 3: Run QR generation
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
npm test
Expected: All 8 unit tests pass.
- Step 6: Final commit
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.