diff --git a/supabase/migrations/20260709000001_sites_zones.sql b/supabase/migrations/20260709000001_sites_zones.sql new file mode 100644 index 0000000..8c37248 --- /dev/null +++ b/supabase/migrations/20260709000001_sites_zones.sql @@ -0,0 +1,19 @@ +-- 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); diff --git a/supabase/migrations/20260709000002_users.sql b/supabase/migrations/20260709000002_users.sql new file mode 100644 index 0000000..b309010 --- /dev/null +++ b/supabase/migrations/20260709000002_users.sql @@ -0,0 +1,40 @@ +-- 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(); diff --git a/supabase/migrations/20260709000003_incidents.sql b/supabase/migrations/20260709000003_incidents.sql new file mode 100644 index 0000000..3671933 --- /dev/null +++ b/supabase/migrations/20260709000003_incidents.sql @@ -0,0 +1,69 @@ +-- 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); diff --git a/supabase/migrations/20260709000004_evidence_investigations.sql b/supabase/migrations/20260709000004_evidence_investigations.sql new file mode 100644 index 0000000..d76f3ca --- /dev/null +++ b/supabase/migrations/20260709000004_evidence_investigations.sql @@ -0,0 +1,34 @@ +-- 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); diff --git a/supabase/migrations/20260709000005_capa_dosh.sql b/supabase/migrations/20260709000005_capa_dosh.sql new file mode 100644 index 0000000..4c3debf --- /dev/null +++ b/supabase/migrations/20260709000005_capa_dosh.sql @@ -0,0 +1,43 @@ +-- 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); diff --git a/supabase/migrations/20260709000006_notifications_audit.sql b/supabase/migrations/20260709000006_notifications_audit.sql new file mode 100644 index 0000000..76a7754 --- /dev/null +++ b/supabase/migrations/20260709000006_notifications_audit.sql @@ -0,0 +1,26 @@ +-- 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); diff --git a/supabase/migrations/20260709000007_rls_policies.sql b/supabase/migrations/20260709000007_rls_policies.sql new file mode 100644 index 0000000..936f666 --- /dev/null +++ b/supabase/migrations/20260709000007_rls_policies.sql @@ -0,0 +1,99 @@ +-- 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')); diff --git a/supabase/migrations/20260709000008_seed.sql b/supabase/migrations/20260709000008_seed.sql new file mode 100644 index 0000000..280afe9 --- /dev/null +++ b/supabase/migrations/20260709000008_seed.sql @@ -0,0 +1,34 @@ +-- 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;