-- ============================================================================= -- IMS HSE Incident Management System -- Consolidated PostgreSQL Schema (Phase 1) -- Replaces 28 Supabase migrations. -- No auth.uid(), no auth.users, no storage.* — plain PostgreSQL only. -- Authorization: app.user_id GUC set by application transaction wrapper. -- ============================================================================= -- --------------------------------------------------------------------------- -- Extensions -- --------------------------------------------------------------------------- CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pgcrypto; -- --------------------------------------------------------------------------- -- Enums -- --------------------------------------------------------------------------- CREATE TYPE user_role AS ENUM ( 'reporter', 'supervisor', 'hse', 'capa_owner', 'management', 'admin' ); CREATE TYPE incident_type AS ENUM ( 'injury', 'near_miss', 'hazard', 'asset_damage', 'environmental', 'security', 'fire', 'transport' ); 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 TYPE evidence_stage AS ENUM ( 'report', 'response', 'investigation', 'capa', 'verification' ); CREATE TYPE rca_method AS ENUM ('five_why', 'fishbone', 'other'); 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 TYPE dosh_form_type AS ENUM ('jkkp6', 'jkkp7', 'jkkp8'); CREATE TYPE dosh_status AS ENUM ('not_required', 'pending', 'submitted'); CREATE TYPE notification_channel AS ENUM ('email', 'whatsapp', 'in_app'); -- --------------------------------------------------------------------------- -- Tables (dependency order) -- --------------------------------------------------------------------------- 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, active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX zones_site_id_idx ON zones(site_id); -- users: standalone (no auth.users FK), includes auth columns for self-hosted CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '' UNIQUE, phone TEXT, role user_role NOT NULL DEFAULT 'reporter', department TEXT, site_id UUID REFERENCES sites(id), active BOOLEAN NOT NULL DEFAULT true, password_hash TEXT NOT NULL DEFAULT '', email_verified_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Note: password_hash NOT NULL DEFAULT '' — allows schema load before data migration. -- Phase 8 sets real hashes; Phase 3 prevents login when hash is empty. CREATE TABLE trucks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), truck_no TEXT NOT NULL UNIQUE, carrier TEXT, active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- incidents: final column set includes all columns added in later migrations 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), truck_id UUID REFERENCES trucks(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), is_fatality BOOLEAN NOT NULL DEFAULT false, is_serious_bodily_injury BOOLEAN NOT NULL DEFAULT false, is_dangerous_occurrence BOOLEAN NOT NULL DEFAULT false, is_occupational_disease BOOLEAN NOT NULL DEFAULT false, triage_notes TEXT, triaged_by UUID REFERENCES users(id), triaged_at TIMESTAMPTZ, type_details JSONB, embedding vector(768), closed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); 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); CREATE INDEX incidents_truck_id_idx ON incidents(truck_id); CREATE INDEX incidents_embedding_idx ON incidents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 10); 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, uploaded_by UUID NOT NULL REFERENCES users(id), uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT false ); CREATE INDEX evidence_files_incident_idx ON evidence_files(incident_id); -- investigations: final column set includes RCA JSONB + urine_test_result 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, urine_test_result TEXT CHECK (urine_test_result IN ('negative','positive','refused','pending')), witness_statement_refs TEXT[], five_why_steps JSONB, fishbone_categories JSONB, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX investigations_incident_idx ON investigations(incident_id); -- capa_actions: final column set includes effectiveness_recheck_round + owner_notes 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, effectiveness_recheck_round INT NOT NULL DEFAULT 0, owner_notes 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); 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(), CONSTRAINT dosh_reports_incident_form_uq UNIQUE (incident_id, form_type) ); -- notifications_log: final column set includes in-app columns 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, recipient_user_id UUID REFERENCES users(id), read_at TIMESTAMPTZ, title TEXT, link TEXT, sent_at TIMESTAMPTZ NOT NULL DEFAULT now(), status TEXT NOT NULL DEFAULT 'sent' ); CREATE INDEX notifications_in_app_unread_idx ON notifications_log (recipient_user_id, sent_at DESC) WHERE channel = 'in_app' AND read_at IS NULL; 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); CREATE TABLE app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_by UUID REFERENCES users(id) ); CREATE TABLE incident_addenda ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), incident_id UUID NOT NULL REFERENCES incidents(id), author UUID NOT NULL REFERENCES users(id), body TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX incident_addenda_incident_idx ON incident_addenda(incident_id); -- --------------------------------------------------------------------------- -- Functions -- --------------------------------------------------------------------------- -- app_current_user_id(): GUC accessor — no SECURITY DEFINER (public convenience) CREATE OR REPLACE FUNCTION public.app_current_user_id() RETURNS uuid LANGUAGE sql STABLE AS $$ SELECT NULLIF(current_setting('app.user_id', true), '')::uuid $$; -- auth_user_role(): role of the current app user CREATE OR REPLACE FUNCTION public.auth_user_role() RETURNS user_role LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public AS $$ SELECT role FROM public.users WHERE id = app_current_user_id() $$; -- auth_user_site_id(): site of the current app user CREATE OR REPLACE FUNCTION public.auth_user_site_id() RETURNS UUID LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public AS $$ SELECT site_id FROM public.users WHERE id = app_current_user_id() $$; -- auth_user_department(): department of the current app user CREATE OR REPLACE FUNCTION public.auth_user_department() RETURNS TEXT LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public AS $$ SELECT department FROM public.users WHERE id = app_current_user_id() $$; -- write_audit_log(): signature unchanged — 26 call sites depend on this exact signature CREATE OR REPLACE FUNCTION public.write_audit_log( p_table_name TEXT, p_record_id UUID, p_action TEXT, p_new_value JSONB DEFAULT NULL, p_old_value JSONB DEFAULT NULL ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ BEGIN INSERT INTO audit_log (table_name, record_id, action, changed_by, new_value, old_value) VALUES (p_table_name, p_record_id, p_action, app_current_user_id(), p_new_value, p_old_value); END; $$; -- create_in_app_notification(): creates an in-app notification record CREATE OR REPLACE FUNCTION public.create_in_app_notification( p_recipient UUID, p_title TEXT, p_link TEXT DEFAULT NULL, p_incident_id UUID DEFAULT NULL, p_capa_id UUID DEFAULT NULL ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ BEGIN IF app_current_user_id() IS NULL THEN RAISE EXCEPTION 'authentication required'; END IF; INSERT INTO public.notifications_log (channel, recipient, recipient_user_id, title, link, incident_id, capa_id) VALUES ('in_app', p_recipient::text, p_recipient, p_title, p_link, p_incident_id, p_capa_id); END; $$; -- generate_incident_reference(): trigger function for auto reference generation CREATE OR REPLACE FUNCTION public.generate_incident_reference() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public 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 := COALESCE(v_site_code, 'UNK') || '-' || 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(); -- prevent_closed_incident_change(): locks closed incidents CREATE OR REPLACE FUNCTION public.prevent_closed_incident_change() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ BEGIN RAISE EXCEPTION 'incident % is closed and locked — add an addendum instead', OLD.id; END; $$; CREATE TRIGGER incidents_closed_lock_update BEFORE UPDATE ON incidents FOR EACH ROW WHEN (OLD.status = 'closed') EXECUTE FUNCTION prevent_closed_incident_change(); CREATE TRIGGER incidents_closed_lock_delete BEFORE DELETE ON incidents FOR EACH ROW WHEN (OLD.status = 'closed') EXECUTE FUNCTION prevent_closed_incident_change(); -- match_incidents(): 768-dim semantic similarity search with hse/admin auth guard CREATE OR REPLACE FUNCTION match_incidents( query_embedding vector(768), exclude_id uuid, match_count int DEFAULT 5 ) RETURNS TABLE ( id uuid, reference_no text, incident_type text, description text, severity int, similarity float ) LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM public.users WHERE id = app_current_user_id() AND role IN ('hse', 'admin') ) THEN RAISE EXCEPTION 'Forbidden' USING ERRCODE = 'PGRST301'; END IF; RETURN QUERY SELECT i.id, i.reference_no, i.incident_type::text, i.description, i.severity::int, 1 - (i.embedding <=> query_embedding) AS similarity FROM incidents i WHERE i.id != exclude_id AND i.embedding IS NOT NULL ORDER BY i.embedding <=> query_embedding LIMIT match_count; END; $$; -- Note: handle_new_auth_user() and on_auth_user_created trigger are NOT included. -- Users are now created directly by the admin API route. -- --------------------------------------------------------------------------- -- Seed data -- --------------------------------------------------------------------------- -- Seed: initial site 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; -- Seed: 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; -- Seed: app_settings (final state after all migrations) INSERT INTO app_settings (key, value) VALUES ('DEEPSEEK_API_KEY', ''), ('GOOGLE_AI_API_KEY', ''), ('META_WHATSAPP_PHONE_NUMBER_ID', ''), ('META_WHATSAPP_ACCESS_TOKEN', '') ON CONFLICT (key) DO NOTHING; -- --------------------------------------------------------------------------- -- Role grants -- --------------------------------------------------------------------------- -- Grant to app_user (RLS enforced — all queries from normal sessions) GRANT USAGE ON SCHEMA public TO app_user; GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO app_user; -- Revoke DELETE on evidence_files (soft-delete only — DOSH JKKP 8 requirement) REVOKE DELETE ON evidence_files FROM app_user; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO app_user; -- Grant to app_admin (BYPASSRLS — admin operations and service role) GRANT USAGE ON SCHEMA public TO app_admin; GRANT ALL ON ALL TABLES IN SCHEMA public TO app_admin; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_admin; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO app_admin;