feat(db): consolidated PostgreSQL schema replacing Supabase (Phase 1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+143
@@ -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')
|
||||||
|
);
|
||||||
+505
@@ -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;
|
||||||
Reference in New Issue
Block a user