506 lines
17 KiB
PL/PgSQL
506 lines
17 KiB
PL/PgSQL
-- =============================================================================
|
|
-- 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;
|