From e5fd2436fa6830acfc702634bd34802ea8075a96 Mon Sep 17 00:00:00 2001 From: weeihan Date: Thu, 23 Jul 2026 15:43:09 +0800 Subject: [PATCH] feat(db): consolidated PostgreSQL schema replacing Supabase (Phase 1) Co-Authored-By: Claude Sonnet 4.6 --- db/rls.sql | 143 ++++++++++++++ db/schema.sql | 505 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 648 insertions(+) create mode 100644 db/rls.sql create mode 100644 db/schema.sql diff --git a/db/rls.sql b/db/rls.sql new file mode 100644 index 0000000..68232b1 --- /dev/null +++ b/db/rls.sql @@ -0,0 +1,143 @@ +-- ============================================================================= +-- IMS HSE Incident Management System +-- Row Level Security Policies (Phase 1) +-- Run AFTER schema.sql (functions used in policies must exist). +-- All auth.uid() replaced with app_current_user_id() (GUC-based). +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- Enable RLS + FORCE ROW LEVEL SECURITY on all 13 tables +-- --------------------------------------------------------------------------- +ALTER TABLE sites ENABLE ROW LEVEL SECURITY; +ALTER TABLE zones ENABLE ROW LEVEL SECURITY; +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +ALTER TABLE trucks 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; +ALTER TABLE app_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE incident_addenda ENABLE ROW LEVEL SECURITY; + +-- FORCE RLS so that even the table owner (postgres superuser running as app_user) cannot bypass +ALTER TABLE sites FORCE ROW LEVEL SECURITY; +ALTER TABLE zones FORCE ROW LEVEL SECURITY; +ALTER TABLE users FORCE ROW LEVEL SECURITY; +ALTER TABLE trucks FORCE ROW LEVEL SECURITY; +ALTER TABLE incidents FORCE ROW LEVEL SECURITY; +ALTER TABLE evidence_files FORCE ROW LEVEL SECURITY; +ALTER TABLE investigations FORCE ROW LEVEL SECURITY; +ALTER TABLE capa_actions FORCE ROW LEVEL SECURITY; +ALTER TABLE dosh_reports FORCE ROW LEVEL SECURITY; +ALTER TABLE notifications_log FORCE ROW LEVEL SECURITY; +ALTER TABLE audit_log FORCE ROW LEVEL SECURITY; +ALTER TABLE app_settings FORCE ROW LEVEL SECURITY; +ALTER TABLE incident_addenda FORCE ROW LEVEL SECURITY; + +-- --------------------------------------------------------------------------- +-- Policies — final state (all auth.uid() replaced with app_current_user_id()) +-- --------------------------------------------------------------------------- + +-- sites +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 +CREATE POLICY "zones_read" ON zones FOR SELECT USING (true); +CREATE POLICY "zones_admin_all" ON zones FOR ALL USING (auth_user_role() = 'admin'); + +-- trucks +CREATE POLICY "trucks_read" ON trucks FOR SELECT USING (true); +CREATE POLICY "trucks_admin_all" ON trucks FOR ALL USING (auth_user_role() = 'admin'); + +-- users +CREATE POLICY "users_read_own" ON users FOR SELECT USING (id = app_current_user_id()); +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 = app_current_user_id()) + WITH CHECK ( + id = app_current_user_id() + AND role = (SELECT role FROM public.users WHERE id = app_current_user_id()) + ); +CREATE POLICY "users_admin_all" ON users FOR ALL USING (auth_user_role() = 'admin'); + +-- incidents +CREATE POLICY "incidents_insert" ON incidents FOR INSERT + WITH CHECK (reported_by = app_current_user_id()); +CREATE POLICY "incidents_read_reporter" ON incidents FOR SELECT + USING (reported_by = app_current_user_id()); +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 +CREATE POLICY "evidence_insert" ON evidence_files FOR INSERT + WITH CHECK (uploaded_by = app_current_user_id()); +CREATE POLICY "evidence_read_uploader" ON evidence_files FOR SELECT + USING (uploaded_by = app_current_user_id()); +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 +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 (capa_update_owner is tightened — no department clause) +CREATE POLICY "capa_read_owner" ON capa_actions FOR SELECT + USING (owner_user_id = app_current_user_id() OR department = auth_user_department()); +CREATE POLICY "capa_update_owner" ON capa_actions FOR UPDATE + USING (owner_user_id = app_current_user_id()); +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 +CREATE POLICY "dosh_hse_admin" ON dosh_reports FOR ALL + USING (auth_user_role() IN ('hse', 'admin')); + +-- notifications_log (final state after migration 19 fix) +CREATE POLICY "notifications_insert_elevated" ON notifications_log FOR INSERT + WITH CHECK (auth_user_role() IN ('hse', 'admin', 'supervisor')); +CREATE POLICY "notifications_read_own" ON notifications_log FOR SELECT + USING (recipient_user_id = app_current_user_id()); +CREATE POLICY "notifications_read_admin" ON notifications_log FOR SELECT + USING (auth_user_role() = 'admin'); +CREATE POLICY "notifications_update_own" ON notifications_log FOR UPDATE + USING (recipient_user_id = app_current_user_id()) + WITH CHECK (recipient_user_id = app_current_user_id()); + +-- audit_log +CREATE POLICY "audit_read_elevated" ON audit_log FOR SELECT + USING (auth_user_role() IN ('hse', 'admin')); + +-- app_settings +CREATE POLICY "admin_select_settings" ON app_settings FOR SELECT + USING (auth_user_role() = 'admin'); +CREATE POLICY "admin_update_settings" ON app_settings FOR ALL + USING (auth_user_role() = 'admin'); + +-- incident_addenda +CREATE POLICY "addenda_read" ON incident_addenda FOR SELECT + USING ( + auth_user_role() IN ('hse', 'admin', 'supervisor', 'management') + OR EXISTS ( + SELECT 1 FROM incidents i + WHERE i.id = incident_id AND i.reported_by = app_current_user_id() + ) + ); +CREATE POLICY "addenda_insert" ON incident_addenda FOR INSERT + WITH CHECK ( + author = app_current_user_id() + AND auth_user_role() IN ('hse', 'admin', 'supervisor') + ); diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..7b0f253 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,505 @@ +-- ============================================================================= +-- 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;