feat(db): consolidated PostgreSQL schema replacing Supabase (Phase 1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:43:09 +08:00
co-authored by Claude Sonnet 4.6
parent ba3d345531
commit e5fd2436fa
2 changed files with 648 additions and 0 deletions
+505
View File
@@ -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;