Files
ims/docs/superpowers/plans/2026-07-09-phase-0-foundation.md

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

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:

  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
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.

Email 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_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

// 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.