1360 lines
40 KiB
Markdown
1360 lines
40 KiB
Markdown
# 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<UserRole, string>), `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<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**
|
|
|
|
```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://<ref>.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<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**
|
|
|
|
```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**
|
|
|
|
```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 (
|
|
<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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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.
|