mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 03:05:43 +02:00
1172 lines
48 KiB
PL/PgSQL
1172 lines
48 KiB
PL/PgSQL
-- ============================================================================
|
|
-- CRM Database Schema — Enterprise RBAC + CRM
|
|
-- PostgreSQL 15+ | UUID PKs | 3NF | Hierarchical RBAC | Full Audit Trail
|
|
-- ============================================================================
|
|
|
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
|
|
-- ============================================================================
|
|
-- HELPER: update updated_at automatically
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = NOW();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ============================================================================
|
|
-- 1. AUTHENTICATION & ROLE MANAGEMENT
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS roles (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
name VARCHAR(50) NOT NULL,
|
|
display_name VARCHAR(100),
|
|
description TEXT,
|
|
hierarchy_level INT NOT NULL CHECK (hierarchy_level BETWEEN 1 AND 100),
|
|
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_roles_name ON roles(name);
|
|
|
|
CREATE TABLE IF NOT EXISTS permissions (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
resource VARCHAR(100) NOT NULL,
|
|
action VARCHAR(50) NOT NULL,
|
|
description TEXT,
|
|
category VARCHAR(50),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_permissions ON permissions(resource, action);
|
|
|
|
CREATE TABLE IF NOT EXISTS role_permissions (
|
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
|
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
|
granted_by UUID,
|
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
PRIMARY KEY (role_id, permission_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
username VARCHAR(100) NOT NULL,
|
|
email VARCHAR(255) NOT NULL,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
first_name VARCHAR(100) NOT NULL,
|
|
last_name VARCHAR(100) NOT NULL,
|
|
phone VARCHAR(50),
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
is_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
|
lock_reason TEXT,
|
|
password_change_required BOOLEAN NOT NULL DEFAULT TRUE,
|
|
email_verified_at TIMESTAMPTZ,
|
|
last_login_at TIMESTAMPTZ,
|
|
failed_login_attempts INT NOT NULL DEFAULT 0,
|
|
locked_until TIMESTAMPTZ,
|
|
preferences JSONB DEFAULT '{}'::jsonb,
|
|
created_by UUID REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_users_username ON users(username) WHERE deleted_at IS NULL;
|
|
CREATE UNIQUE INDEX uq_users_email ON users(email) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_users_is_active ON users(is_active) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_users_is_locked ON users(is_locked) WHERE is_locked = TRUE AND deleted_at IS NULL;
|
|
CREATE INDEX idx_users_created_by ON users(created_by);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_roles (
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
|
assigned_by UUID REFERENCES users(id),
|
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
PRIMARY KEY (user_id, role_id)
|
|
);
|
|
|
|
CREATE INDEX idx_user_roles_user ON user_roles(user_id);
|
|
CREATE INDEX idx_user_roles_role ON user_roles(role_id);
|
|
|
|
-- ============================================================================
|
|
-- 2. BAN & SUSPENSION SYSTEM
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS banned_users (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
banned_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
banned_by UUID NOT NULL REFERENCES users(id),
|
|
reason TEXT NOT NULL,
|
|
banned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ,
|
|
permanent_ban BOOLEAN NOT NULL DEFAULT FALSE,
|
|
is_reversed BOOLEAN NOT NULL DEFAULT FALSE,
|
|
reversed_by UUID REFERENCES users(id),
|
|
reversed_at TIMESTAMPTZ,
|
|
reversal_reason TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT chk_ban_expiry CHECK (
|
|
(permanent_ban = TRUE AND expires_at IS NULL)
|
|
OR (permanent_ban = FALSE AND expires_at IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX idx_banned_users_user ON banned_users(banned_user_id);
|
|
CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id)
|
|
WHERE is_reversed = FALSE;
|
|
CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by);
|
|
|
|
CREATE TABLE IF NOT EXISTS suspended_users (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
suspended_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
suspended_by UUID NOT NULL REFERENCES users(id),
|
|
reason TEXT NOT NULL,
|
|
suspended_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
lifted_by UUID REFERENCES users(id),
|
|
lifted_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT chk_suspension_expiry CHECK (expires_at > suspended_at)
|
|
);
|
|
|
|
CREATE INDEX idx_suspended_users_user ON suspended_users(suspended_user_id);
|
|
CREATE INDEX idx_suspended_users_active ON suspended_users(suspended_user_id)
|
|
WHERE is_active = TRUE;
|
|
CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by);
|
|
|
|
-- ============================================================================
|
|
-- 3. SESSION & LOGIN MANAGEMENT
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_hash VARCHAR(255) NOT NULL,
|
|
ip_address INET,
|
|
user_agent TEXT,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_sessions_user ON sessions(user_id) WHERE is_active = TRUE;
|
|
CREATE UNIQUE INDEX uq_sessions_token ON sessions(token_hash);
|
|
CREATE INDEX idx_sessions_expires ON sessions(expires_at) WHERE is_active = TRUE;
|
|
|
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
user_id UUID REFERENCES users(id),
|
|
username_attempted VARCHAR(100) NOT NULL,
|
|
ip_address INET NOT NULL,
|
|
user_agent TEXT,
|
|
was_successful BOOLEAN NOT NULL DEFAULT FALSE,
|
|
failure_reason VARCHAR(100),
|
|
attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_login_attempts_user ON login_attempts(user_id);
|
|
CREATE INDEX idx_login_attempts_username ON login_attempts(username_attempted);
|
|
CREATE INDEX idx_login_attempts_ip ON login_attempts(ip_address);
|
|
CREATE INDEX idx_login_attempts_time ON login_attempts(attempted_at DESC);
|
|
|
|
-- ============================================================================
|
|
-- 4. CUSTOMER MANAGEMENT
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS customer_statuses (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
name VARCHAR(100) NOT NULL,
|
|
description TEXT,
|
|
color VARCHAR(7),
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_customer_statuses_name ON customer_statuses(name) WHERE deleted_at IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS customers (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
customer_type VARCHAR(20) NOT NULL,
|
|
status_id UUID NOT NULL REFERENCES customer_statuses(id),
|
|
owner_id UUID REFERENCES users(id),
|
|
source VARCHAR(100),
|
|
notes TEXT,
|
|
tags TEXT[],
|
|
score INT NOT NULL DEFAULT 0 CHECK (score >= 0 AND score <= 100),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ,
|
|
CONSTRAINT chk_customer_type CHECK (customer_type IN ('individual', 'company'))
|
|
);
|
|
|
|
CREATE INDEX idx_customers_status ON customers(status_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_customers_owner ON customers(owner_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_customers_type ON customers(customer_type) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_customers_score ON customers(score DESC) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_customers_tags ON customers USING GIN(tags) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_customers_created ON customers(created_at DESC) WHERE deleted_at IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS individual_customers (
|
|
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
|
|
first_name VARCHAR(100) NOT NULL,
|
|
last_name VARCHAR(100) NOT NULL,
|
|
middle_name VARCHAR(100),
|
|
date_of_birth DATE,
|
|
gender VARCHAR(20),
|
|
job_title VARCHAR(200),
|
|
company_name VARCHAR(200),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_individual_names ON individual_customers(last_name, first_name);
|
|
|
|
CREATE TABLE IF NOT EXISTS company_customers (
|
|
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE CASCADE,
|
|
company_name VARCHAR(255) NOT NULL,
|
|
registration_number VARCHAR(100),
|
|
tax_id VARCHAR(100),
|
|
website VARCHAR(500),
|
|
industry VARCHAR(200),
|
|
company_size VARCHAR(50),
|
|
annual_revenue DECIMAL(15,2),
|
|
founded_date DATE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_company_name ON company_customers(company_name);
|
|
CREATE INDEX idx_company_industry ON company_customers(industry);
|
|
|
|
CREATE TABLE IF NOT EXISTS contact_information (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
type VARCHAR(20) NOT NULL,
|
|
value VARCHAR(500) NOT NULL,
|
|
label VARCHAR(100),
|
|
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
|
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
|
verified_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ,
|
|
CONSTRAINT chk_contact_type CHECK (type IN ('email','phone','mobile','fax','website','linkedin','twitter','other'))
|
|
);
|
|
|
|
CREATE INDEX idx_contacts_customer ON contact_information(customer_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_contacts_type ON contact_information(type) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_contacts_value ON contact_information(value) WHERE deleted_at IS NULL;
|
|
CREATE UNIQUE INDEX uq_contacts_primary ON contact_information(customer_id, type) WHERE is_primary = TRUE AND deleted_at IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS addresses (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
address_type VARCHAR(20) NOT NULL,
|
|
address_line1 VARCHAR(255) NOT NULL,
|
|
address_line2 VARCHAR(255),
|
|
city VARCHAR(100) NOT NULL,
|
|
state VARCHAR(100),
|
|
postal_code VARCHAR(20),
|
|
country VARCHAR(100) NOT NULL,
|
|
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
|
latitude DECIMAL(10,7),
|
|
longitude DECIMAL(10,7),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ,
|
|
CONSTRAINT chk_address_type CHECK (address_type IN ('billing','shipping','physical','postal','other'))
|
|
);
|
|
|
|
CREATE INDEX idx_addresses_customer ON addresses(customer_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_addresses_country ON addresses(country) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_addresses_city ON addresses(city) WHERE deleted_at IS NULL;
|
|
CREATE UNIQUE INDEX uq_addresses_primary ON addresses(customer_id, address_type) WHERE is_primary = TRUE AND deleted_at IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS customer_notes (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
author_id UUID NOT NULL REFERENCES users(id),
|
|
content TEXT NOT NULL,
|
|
type VARCHAR(50) NOT NULL DEFAULT 'general',
|
|
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ,
|
|
CONSTRAINT chk_note_type CHECK (type IN ('general','call_summary','meeting_notes','complaint','follow_up','other'))
|
|
);
|
|
|
|
CREATE INDEX idx_notes_customer ON customer_notes(customer_id, created_at DESC) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_notes_author ON customer_notes(author_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_notes_pinned ON customer_notes(is_pinned) WHERE is_pinned = TRUE AND deleted_at IS NULL;
|
|
|
|
-- ============================================================================
|
|
-- 5. CUSTOMER & INCIDENT REPORTS
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS customer_reports (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
reported_by UUID NOT NULL REFERENCES users(id),
|
|
report_type VARCHAR(50) NOT NULL,
|
|
title VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
severity VARCHAR(20) NOT NULL DEFAULT 'low',
|
|
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
|
resolution_notes TEXT,
|
|
resolved_by UUID REFERENCES users(id),
|
|
resolved_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT chk_report_type CHECK (report_type IN ('complaint','issue','feedback','escalation','other')),
|
|
CONSTRAINT chk_report_severity CHECK (severity IN ('low','medium','high','critical')),
|
|
CONSTRAINT chk_report_status CHECK (status IN ('open','investigating','resolved','closed'))
|
|
);
|
|
|
|
CREATE INDEX idx_customer_reports_customer ON customer_reports(customer_id);
|
|
CREATE INDEX idx_customer_reports_reported_by ON customer_reports(reported_by);
|
|
CREATE INDEX idx_customer_reports_status ON customer_reports(status);
|
|
CREATE INDEX idx_customer_reports_severity ON customer_reports(severity);
|
|
|
|
CREATE TABLE IF NOT EXISTS incident_reports (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
reported_by UUID NOT NULL REFERENCES users(id),
|
|
target_user_id UUID REFERENCES users(id),
|
|
incident_type VARCHAR(50) NOT NULL,
|
|
title VARCHAR(255) NOT NULL,
|
|
description TEXT NOT NULL,
|
|
severity VARCHAR(20) NOT NULL DEFAULT 'medium',
|
|
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
|
assigned_to UUID REFERENCES users(id),
|
|
resolution_notes TEXT,
|
|
resolved_by UUID REFERENCES users(id),
|
|
resolved_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT chk_incident_type CHECK (incident_type IN ('suspicious_activity','policy_violation','security_breach','harassment','spam','other')),
|
|
CONSTRAINT chk_incident_severity CHECK (severity IN ('low','medium','high','critical')),
|
|
CONSTRAINT chk_incident_status CHECK (status IN ('open','investigating','resolved','dismissed'))
|
|
);
|
|
|
|
CREATE INDEX idx_incident_reports_reported_by ON incident_reports(reported_by);
|
|
CREATE INDEX idx_incident_reports_target ON incident_reports(target_user_id);
|
|
CREATE INDEX idx_incident_reports_status ON incident_reports(status);
|
|
CREATE INDEX idx_incident_reports_severity ON incident_reports(severity);
|
|
|
|
-- ============================================================================
|
|
-- 6. LEAD MANAGEMENT
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS lead_sources (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
name VARCHAR(100) NOT NULL,
|
|
description TEXT,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_lead_sources_name ON lead_sources(name) WHERE deleted_at IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS lead_stages (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
name VARCHAR(100) NOT NULL,
|
|
description TEXT,
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
probability INT NOT NULL DEFAULT 0 CHECK (probability >= 0 AND probability <= 100),
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_lead_stages_name ON lead_stages(name) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_lead_stages_sort ON lead_stages(sort_order);
|
|
|
|
CREATE TABLE IF NOT EXISTS leads (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
source_id UUID REFERENCES lead_sources(id),
|
|
stage_id UUID NOT NULL REFERENCES lead_stages(id),
|
|
assigned_to UUID REFERENCES users(id),
|
|
company_name VARCHAR(255),
|
|
contact_name VARCHAR(255) NOT NULL,
|
|
email VARCHAR(255),
|
|
phone VARCHAR(50),
|
|
job_title VARCHAR(200),
|
|
budget_range VARCHAR(100),
|
|
interest_level VARCHAR(20),
|
|
notes TEXT,
|
|
score INT NOT NULL DEFAULT 0 CHECK (score >= 0 AND score <= 100),
|
|
converted_customer_id UUID REFERENCES customers(id),
|
|
converted_at TIMESTAMPTZ,
|
|
converted_by UUID REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ,
|
|
CONSTRAINT chk_interest_level CHECK (interest_level IN ('low','medium','high')),
|
|
CONSTRAINT chk_conversion_consistency CHECK (
|
|
(converted_customer_id IS NULL AND converted_at IS NULL AND converted_by IS NULL)
|
|
OR (converted_customer_id IS NOT NULL AND converted_at IS NOT NULL AND converted_by IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX idx_leads_stage ON leads(stage_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_leads_assigned ON leads(assigned_to) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_leads_source ON leads(source_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_leads_score ON leads(score DESC) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_leads_created ON leads(created_at DESC) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_leads_converted ON leads(converted_customer_id) WHERE converted_customer_id IS NOT NULL;
|
|
CREATE INDEX idx_leads_email ON leads(email) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_leads_active ON leads(stage_id, assigned_to) WHERE deleted_at IS NULL AND converted_customer_id IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS lead_conversions (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
converted_by UUID NOT NULL REFERENCES users(id),
|
|
conversion_notes TEXT,
|
|
converted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_lead_conversions_lead ON lead_conversions(lead_id);
|
|
CREATE INDEX idx_lead_conversions_customer ON lead_conversions(customer_id);
|
|
|
|
-- ============================================================================
|
|
-- 7. PRODUCT CATALOG
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS product_categories (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
parent_id UUID REFERENCES product_categories(id),
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_product_categories_name ON product_categories(name) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_product_categories_parent ON product_categories(parent_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS products (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
category_id UUID REFERENCES product_categories(id),
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
sku VARCHAR(100),
|
|
unit_price DECIMAL(15,2) NOT NULL CHECK (unit_price >= 0),
|
|
cost_price DECIMAL(15,2) CHECK (cost_price >= 0),
|
|
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
is_service BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_products_sku ON products(sku) WHERE sku IS NOT NULL AND deleted_at IS NULL;
|
|
CREATE INDEX idx_products_category ON products(category_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_products_name ON products(name) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_products_active ON products(is_active) WHERE deleted_at IS NULL;
|
|
|
|
-- ============================================================================
|
|
-- 8. SALES PIPELINE
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS deal_stages (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
name VARCHAR(100) NOT NULL,
|
|
description TEXT,
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
probability INT NOT NULL DEFAULT 0 CHECK (probability >= 0 AND probability <= 100),
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_deal_stages_name ON deal_stages(name) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_deal_stages_sort ON deal_stages(sort_order);
|
|
|
|
CREATE TABLE IF NOT EXISTS opportunities (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
lead_id UUID REFERENCES leads(id),
|
|
stage_id UUID NOT NULL REFERENCES deal_stages(id),
|
|
owner_id UUID NOT NULL REFERENCES users(id),
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
estimated_revenue DECIMAL(15,2),
|
|
probability INT CHECK (probability >= 0 AND probability <= 100),
|
|
expected_close_date DATE,
|
|
actual_close_date DATE,
|
|
loss_reason TEXT,
|
|
is_won BOOLEAN,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE INDEX idx_opportunities_customer ON opportunities(customer_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_opportunities_stage ON opportunities(stage_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_opportunities_owner ON opportunities(owner_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_opportunities_close ON opportunities(expected_close_date) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_opportunities_revenue ON opportunities(estimated_revenue DESC) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_opportunities_won ON opportunities(is_won) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_opportunities_created ON opportunities(created_at DESC) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_opportunities_owner_stage ON opportunities(owner_id, stage_id) WHERE deleted_at IS NULL AND is_won IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS opportunity_products (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE,
|
|
product_id UUID NOT NULL REFERENCES products(id),
|
|
quantity INT NOT NULL CHECK (quantity > 0),
|
|
unit_price DECIMAL(15,2) NOT NULL,
|
|
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0 CHECK (discount_percent >= 0 AND discount_percent <= 100),
|
|
total_price DECIMAL(15,2),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_opp_products_opportunity ON opportunity_products(opportunity_id);
|
|
CREATE INDEX idx_opp_products_product ON opportunity_products(product_id);
|
|
|
|
-- ============================================================================
|
|
-- 9. COMMUNICATION TRACKING
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS communication_types (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
name VARCHAR(50) NOT NULL,
|
|
description TEXT,
|
|
icon VARCHAR(50),
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_comm_types_name ON communication_types(name);
|
|
|
|
CREATE TABLE IF NOT EXISTS communications (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
opportunity_id UUID REFERENCES opportunities(id),
|
|
type_id UUID NOT NULL REFERENCES communication_types(id),
|
|
subject VARCHAR(500),
|
|
body TEXT,
|
|
direction VARCHAR(10) NOT NULL,
|
|
created_by UUID NOT NULL REFERENCES users(id),
|
|
started_at TIMESTAMPTZ,
|
|
duration_minutes INT CHECK (duration_minutes >= 0),
|
|
outcome TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ,
|
|
CONSTRAINT chk_comm_direction CHECK (direction IN ('inbound','outbound'))
|
|
);
|
|
|
|
CREATE INDEX idx_communications_customer ON communications(customer_id, created_at DESC) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_communications_opportunity ON communications(opportunity_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_communications_type ON communications(type_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_communications_created_by ON communications(created_by) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_communications_started ON communications(started_at) WHERE deleted_at IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS communication_participants (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
communication_id UUID NOT NULL REFERENCES communications(id) ON DELETE CASCADE,
|
|
user_id UUID REFERENCES users(id),
|
|
email VARCHAR(255),
|
|
name VARCHAR(255),
|
|
role VARCHAR(50),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_comm_participants_comm ON communication_participants(communication_id);
|
|
CREATE INDEX idx_comm_participants_user ON communication_participants(user_id);
|
|
|
|
-- ============================================================================
|
|
-- 10. TASKS AND ACTIVITIES
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS task_priorities (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
name VARCHAR(50) NOT NULL,
|
|
color VARCHAR(7),
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_task_priorities_name ON task_priorities(name);
|
|
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
customer_id UUID REFERENCES customers(id),
|
|
opportunity_id UUID REFERENCES opportunities(id),
|
|
communication_id UUID REFERENCES communications(id),
|
|
assigned_to UUID REFERENCES users(id),
|
|
assigned_by UUID REFERENCES users(id),
|
|
title VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
priority_id UUID REFERENCES task_priorities(id),
|
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|
due_date TIMESTAMPTZ,
|
|
completed_at TIMESTAMPTZ,
|
|
reminder_at TIMESTAMPTZ,
|
|
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ,
|
|
CONSTRAINT chk_task_status CHECK (status IN ('pending','in_progress','completed','cancelled'))
|
|
);
|
|
|
|
CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to, status) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_tasks_customer ON tasks(customer_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_tasks_opportunity ON tasks(opportunity_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_tasks_due_date ON tasks(due_date) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_tasks_status ON tasks(status) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_tasks_reminder ON tasks(reminder_at) WHERE reminder_sent = FALSE AND deleted_at IS NULL;
|
|
|
|
-- ============================================================================
|
|
-- 11. INVOICE SUPPORT
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS invoice_statuses (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
name VARCHAR(50) NOT NULL,
|
|
description TEXT,
|
|
color VARCHAR(7),
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_invoice_statuses_name ON invoice_statuses(name);
|
|
|
|
CREATE TABLE IF NOT EXISTS invoices (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
|
opportunity_id UUID REFERENCES opportunities(id),
|
|
status_id UUID NOT NULL REFERENCES invoice_statuses(id),
|
|
invoice_number VARCHAR(50) NOT NULL,
|
|
subtotal DECIMAL(15,2) NOT NULL CHECK (subtotal >= 0),
|
|
tax_percent DECIMAL(5,2) CHECK (tax_percent >= 0 AND tax_percent <= 100),
|
|
tax_amount DECIMAL(15,2) CHECK (tax_amount >= 0),
|
|
discount_percent DECIMAL(5,2) CHECK (discount_percent >= 0 AND discount_percent <= 100),
|
|
discount_amount DECIMAL(15,2) CHECK (discount_amount >= 0),
|
|
total_amount DECIMAL(15,2) NOT NULL CHECK (total_amount >= 0),
|
|
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
|
|
issued_date DATE NOT NULL,
|
|
due_date DATE NOT NULL,
|
|
paid_at TIMESTAMPTZ,
|
|
notes TEXT,
|
|
created_by UUID NOT NULL REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ,
|
|
CONSTRAINT chk_dates CHECK (due_date >= issued_date),
|
|
CONSTRAINT chk_amount_consistency CHECK (total_amount = subtotal + COALESCE(tax_amount,0) - COALESCE(discount_amount,0))
|
|
);
|
|
|
|
CREATE UNIQUE INDEX uq_invoice_number ON invoices(invoice_number) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_invoices_customer ON invoices(customer_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_invoices_status ON invoices(status_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_invoices_issued ON invoices(issued_date DESC) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL;
|
|
-- Skipped: cannot use NOW() in partial index predicate (not IMMUTABLE)
|
|
|
|
CREATE TABLE IF NOT EXISTS invoice_items (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
|
product_id UUID REFERENCES products(id),
|
|
description TEXT NOT NULL,
|
|
quantity INT NOT NULL CHECK (quantity > 0),
|
|
unit_price DECIMAL(15,2) NOT NULL CHECK (unit_price >= 0),
|
|
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0 CHECK (discount_percent >= 0 AND discount_percent <= 100),
|
|
total_price DECIMAL(15,2) NOT NULL CHECK (total_price >= 0),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
|
|
CREATE INDEX idx_invoice_items_product ON invoice_items(product_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS payments (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
invoice_id UUID NOT NULL REFERENCES invoices(id),
|
|
amount DECIMAL(15,2) NOT NULL CHECK (amount > 0),
|
|
payment_method VARCHAR(50),
|
|
transaction_id VARCHAR(255),
|
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|
paid_at TIMESTAMPTZ,
|
|
notes TEXT,
|
|
created_by UUID NOT NULL REFERENCES users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ,
|
|
CONSTRAINT chk_payment_status CHECK (status IN ('pending','completed','failed','refunded'))
|
|
);
|
|
|
|
CREATE INDEX idx_payments_invoice ON payments(invoice_id) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_payments_status ON payments(status) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_payments_transaction ON payments(transaction_id) WHERE transaction_id IS NOT NULL;
|
|
|
|
-- ============================================================================
|
|
-- 12. AUDIT LOGGING
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
table_name VARCHAR(100) NOT NULL,
|
|
record_id UUID NOT NULL,
|
|
action VARCHAR(20) NOT NULL,
|
|
old_data JSONB,
|
|
new_data JSONB,
|
|
changed_by UUID REFERENCES users(id),
|
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
ip_address INET,
|
|
user_agent TEXT,
|
|
session_id UUID,
|
|
CONSTRAINT chk_audit_action CHECK (action IN ('CREATE','UPDATE','DELETE'))
|
|
);
|
|
|
|
CREATE INDEX idx_audit_table_record ON audit_logs(table_name, record_id);
|
|
CREATE INDEX idx_audit_changed_by ON audit_logs(changed_by);
|
|
CREATE INDEX idx_audit_changed_at ON audit_logs(changed_at DESC);
|
|
CREATE INDEX idx_audit_action ON audit_logs(action);
|
|
CREATE INDEX idx_audit_table ON audit_logs(table_name);
|
|
CREATE INDEX idx_audit_session ON audit_logs(session_id);
|
|
|
|
-- ============================================================================
|
|
-- 13. DUPLICATE DETECTION
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS customer_duplicates (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
duplicate_customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
match_type VARCHAR(50) NOT NULL,
|
|
match_score DECIMAL(5,2) NOT NULL CHECK (match_score >= 0 AND match_score <= 100),
|
|
is_resolved BOOLEAN NOT NULL DEFAULT FALSE,
|
|
resolved_by UUID REFERENCES users(id),
|
|
resolved_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT chk_no_self_duplicate CHECK (customer_id <> duplicate_customer_id)
|
|
);
|
|
|
|
CREATE INDEX idx_duplicates_customer ON customer_duplicates(customer_id);
|
|
CREATE INDEX idx_duplicates_duplicate ON customer_duplicates(duplicate_customer_id);
|
|
CREATE INDEX idx_duplicates_unresolved ON customer_duplicates(is_resolved) WHERE is_resolved = FALSE;
|
|
|
|
-- ============================================================================
|
|
-- FULL-TEXT SEARCH INDEXES
|
|
-- ============================================================================
|
|
|
|
CREATE INDEX idx_fts_customers ON customers
|
|
USING GIN (to_tsvector('english', COALESCE(notes, '')));
|
|
|
|
CREATE INDEX idx_fts_leads ON leads
|
|
USING GIN (to_tsvector('english', COALESCE(contact_name,'') || ' ' || COALESCE(company_name,'') || ' ' || COALESCE(notes,'')));
|
|
|
|
CREATE INDEX idx_fts_products ON products
|
|
USING GIN (to_tsvector('english', COALESCE(name,'') || ' ' || COALESCE(description,'')));
|
|
|
|
CREATE INDEX idx_fts_communications ON communications
|
|
USING GIN (to_tsvector('english', COALESCE(subject,'') || ' ' || COALESCE(body,'')));
|
|
|
|
CREATE INDEX idx_fts_tasks ON tasks
|
|
USING GIN (to_tsvector('english', COALESCE(title,'') || ' ' || COALESCE(description,'')));
|
|
|
|
-- ============================================================================
|
|
-- RBAC TRIGGER: Auto-update updated_at
|
|
-- ============================================================================
|
|
|
|
DO $$
|
|
DECLARE
|
|
t TEXT;
|
|
BEGIN
|
|
FOR t IN
|
|
SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = 'public'
|
|
AND table_type = 'BASE TABLE'
|
|
AND table_name IN (
|
|
SELECT table_name FROM information_schema.columns
|
|
WHERE column_name = 'updated_at'
|
|
AND table_schema = 'public'
|
|
)
|
|
LOOP
|
|
EXECUTE format(
|
|
'DROP TRIGGER IF EXISTS trg_%I_updated_at ON %I; CREATE TRIGGER trg_%I_updated_at
|
|
BEFORE UPDATE ON %I
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION update_updated_at_column()',
|
|
t, t, t, t
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ============================================================================
|
|
-- RBAC FUNCTION: Check if user has a specific permission
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION has_permission(
|
|
p_user_id UUID,
|
|
p_resource VARCHAR(100),
|
|
p_action VARCHAR(50)
|
|
)
|
|
RETURNS BOOLEAN AS $$
|
|
BEGIN
|
|
RETURN EXISTS (
|
|
SELECT 1
|
|
FROM user_roles ur
|
|
JOIN role_permissions rp ON rp.role_id = ur.role_id
|
|
JOIN permissions p ON p.id = rp.permission_id
|
|
WHERE ur.user_id = p_user_id
|
|
AND p.resource = p_resource
|
|
AND p.action = p_action
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
|
|
-- ============================================================================
|
|
-- RBAC FUNCTION: Get user's hierarchy level (lower number = higher privilege)
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION user_hierarchy_level(p_user_id UUID)
|
|
RETURNS INT AS $$
|
|
SELECT MIN(r.hierarchy_level)
|
|
FROM user_roles ur
|
|
JOIN roles r ON r.id = ur.role_id
|
|
WHERE ur.user_id = p_user_id;
|
|
$$ LANGUAGE sql STABLE SECURITY DEFINER;
|
|
|
|
-- ============================================================================
|
|
-- RBAC FUNCTION: Enforce that only SUPER_ADMIN can create users
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION enforce_create_user()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
creator_level INT;
|
|
super_admin_level INT := 1;
|
|
BEGIN
|
|
-- Allow bootstrap: first user or system migrations with NULL created_by
|
|
IF NEW.created_by IS NULL THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
-- Get the creating user's hierarchy level
|
|
SELECT MIN(r.hierarchy_level) INTO creator_level
|
|
FROM user_roles ur
|
|
JOIN roles r ON r.id = ur.role_id
|
|
WHERE ur.user_id = NEW.created_by;
|
|
|
|
IF creator_level IS NULL OR creator_level != super_admin_level THEN
|
|
RAISE EXCEPTION 'ACCESS DENIED: Only SUPER_ADMIN (hierarchy_level=1) can create user accounts. Your level: %',
|
|
COALESCE(creator_level::TEXT, 'NONE');
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
DROP TRIGGER IF EXISTS trg_enforce_create_user ON users;
|
|
CREATE TRIGGER trg_enforce_create_user
|
|
BEFORE INSERT ON users
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION enforce_create_user();
|
|
|
|
-- ============================================================================
|
|
-- RBAC FUNCTION: Prevent privilege escalation on role assignment
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION enforce_role_assignment()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
assigner_level INT;
|
|
target_role_level INT;
|
|
BEGIN
|
|
-- Allow bootstrap: first role assignment with NULL assigned_by
|
|
IF NEW.assigned_by IS NULL THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
-- Level of the user doing the assignment
|
|
SELECT MIN(r.hierarchy_level) INTO assigner_level
|
|
FROM user_roles ur
|
|
JOIN roles r ON r.id = ur.role_id
|
|
WHERE ur.user_id = NEW.assigned_by;
|
|
|
|
-- Level of the role being assigned
|
|
SELECT hierarchy_level INTO target_role_level
|
|
FROM roles
|
|
WHERE id = NEW.role_id;
|
|
|
|
IF assigner_level IS NULL THEN
|
|
RAISE EXCEPTION 'ACCESS DENIED: Cannot assign roles. User has no role.';
|
|
END IF;
|
|
|
|
-- Only SUPER_ADMIN (level 1) can assign roles
|
|
IF assigner_level != 1 THEN
|
|
RAISE EXCEPTION 'ACCESS DENIED: Only SUPER_ADMIN can assign roles. Your hierarchy level: %', assigner_level;
|
|
END IF;
|
|
|
|
-- Prevent privilege escalation: cannot assign SUPER_ADMIN role unless you are SUPER_ADMIN
|
|
IF target_role_level < assigner_level THEN
|
|
RAISE EXCEPTION 'ACCESS DENIED: Privilege escalation prevented. Cannot assign a role (level %) higher privileged than your own (level %).',
|
|
target_role_level, assigner_level;
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
DROP TRIGGER IF EXISTS trg_enforce_role_assignment ON user_roles;
|
|
CREATE TRIGGER trg_enforce_role_assignment
|
|
BEFORE INSERT ON user_roles
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION enforce_role_assignment();
|
|
|
|
-- ============================================================================
|
|
-- AUDIT TRIGGER FUNCTION
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION audit_trigger_function()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
audit_action VARCHAR(20);
|
|
old_row JSONB;
|
|
new_row JSONB;
|
|
excluded_cols TEXT[] := ARRAY['updated_at', 'last_login_at'];
|
|
BEGIN
|
|
IF TG_OP = 'INSERT' THEN
|
|
audit_action := 'CREATE';
|
|
old_row := NULL;
|
|
new_row := to_jsonb(NEW);
|
|
ELSIF TG_OP = 'UPDATE' THEN
|
|
audit_action := 'UPDATE';
|
|
old_row := to_jsonb(OLD);
|
|
new_row := to_jsonb(NEW);
|
|
ELSIF TG_OP = 'DELETE' THEN
|
|
audit_action := 'DELETE';
|
|
old_row := to_jsonb(OLD);
|
|
new_row := NULL;
|
|
END IF;
|
|
|
|
INSERT INTO audit_logs (table_name, record_id, action, old_data, new_data, changed_by)
|
|
VALUES (
|
|
TG_TABLE_NAME,
|
|
COALESCE(NEW.id, OLD.id),
|
|
audit_action,
|
|
CASE WHEN audit_action IN ('UPDATE', 'DELETE') THEN old_row ELSE NULL END,
|
|
CASE WHEN audit_action IN ('CREATE', 'UPDATE') THEN new_row ELSE NULL END,
|
|
NULLIF(current_setting('app.current_user_id', true), '')
|
|
);
|
|
|
|
RETURN COALESCE(NEW, OLD);
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- ============================================================================
|
|
-- AUDIT TRIGGER: Ban actions
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION audit_ban_action()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF TG_OP = 'INSERT' THEN
|
|
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
|
|
VALUES ('banned_users', NEW.id, 'CREATE', to_jsonb(NEW), NEW.banned_by);
|
|
ELSIF TG_OP = 'UPDATE' AND NEW.is_reversed = TRUE AND OLD.is_reversed = FALSE THEN
|
|
INSERT INTO audit_logs (table_name, record_id, action, old_data, new_data, changed_by)
|
|
VALUES ('banned_users', NEW.id, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), NEW.reversed_by);
|
|
END IF;
|
|
RETURN COALESCE(NEW, OLD);
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
DROP TRIGGER IF EXISTS trg_audit_ban ON banned_users;
|
|
CREATE TRIGGER trg_audit_ban
|
|
AFTER INSERT OR UPDATE ON banned_users
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION audit_ban_action();
|
|
|
|
-- ============================================================================
|
|
-- AUDIT TRIGGER: Suspension actions
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION audit_suspension_action()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF TG_OP = 'INSERT' THEN
|
|
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
|
|
VALUES ('suspended_users', NEW.id, 'CREATE', to_jsonb(NEW), NEW.suspended_by);
|
|
ELSIF TG_OP = 'UPDATE' AND OLD.is_active = TRUE AND NEW.is_active = FALSE THEN
|
|
INSERT INTO audit_logs (table_name, record_id, action, old_data, new_data, changed_by)
|
|
VALUES ('suspended_users', NEW.id, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), NEW.lifted_by);
|
|
END IF;
|
|
RETURN COALESCE(NEW, OLD);
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
DROP TRIGGER IF EXISTS trg_audit_suspension ON suspended_users;
|
|
CREATE TRIGGER trg_audit_suspension
|
|
AFTER INSERT OR UPDATE ON suspended_users
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION audit_suspension_action();
|
|
|
|
-- ============================================================================
|
|
-- AUDIT TRIGGER: Login/logout actions
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION audit_login()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF NEW.was_successful = TRUE THEN
|
|
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
|
|
VALUES (
|
|
'login_attempts',
|
|
NEW.id,
|
|
'CREATE',
|
|
jsonb_build_object(
|
|
'username', NEW.username_attempted,
|
|
'successful', TRUE,
|
|
'ip', NEW.ip_address::TEXT
|
|
),
|
|
NEW.user_id
|
|
);
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
|
|
CREATE TRIGGER trg_audit_login
|
|
AFTER INSERT ON login_attempts
|
|
FOR EACH ROW
|
|
WHEN (NEW.was_successful = TRUE)
|
|
EXECUTE FUNCTION audit_login();
|
|
|
|
-- ============================================================================
|
|
-- VIEW: Active bans with user details
|
|
-- ============================================================================
|
|
|
|
CREATE VIEW v_active_bans AS
|
|
SELECT
|
|
b.id AS ban_id,
|
|
bu.id AS banned_user_id,
|
|
bu.username AS banned_username,
|
|
bu.email AS banned_email,
|
|
ba.id AS banned_by_id,
|
|
ba.username AS banned_by_username,
|
|
b.reason,
|
|
b.banned_at,
|
|
b.expires_at,
|
|
b.permanent_ban,
|
|
CASE
|
|
WHEN b.permanent_ban THEN 'PERMANENT'
|
|
WHEN b.expires_at > NOW() THEN 'ACTIVE'
|
|
ELSE 'EXPIRED'
|
|
END AS ban_status
|
|
FROM banned_users b
|
|
JOIN users bu ON bu.id = b.banned_user_id
|
|
JOIN users ba ON ba.id = b.banned_by
|
|
WHERE b.is_reversed = FALSE
|
|
AND (b.permanent_ban = TRUE OR b.expires_at > NOW());
|
|
|
|
-- ============================================================================
|
|
-- VIEW: Active suspensions
|
|
-- ============================================================================
|
|
|
|
CREATE VIEW v_active_suspensions AS
|
|
SELECT
|
|
s.id AS suspension_id,
|
|
su.id AS suspended_user_id,
|
|
su.username AS suspended_username,
|
|
su.email AS suspended_email,
|
|
sa.id AS suspended_by_id,
|
|
sa.username AS suspended_by_username,
|
|
s.reason,
|
|
s.suspended_at,
|
|
s.expires_at
|
|
FROM suspended_users s
|
|
JOIN users su ON su.id = s.suspended_user_id
|
|
JOIN users sa ON sa.id = s.suspended_by
|
|
WHERE s.is_active = TRUE AND s.expires_at > NOW();
|
|
|
|
-- ============================================================================
|
|
-- VIEW: User permission report
|
|
-- ============================================================================
|
|
|
|
CREATE VIEW v_user_permissions AS
|
|
SELECT
|
|
u.id AS user_id,
|
|
u.username,
|
|
STRING_AGG(DISTINCT r.name, ', ') AS roles,
|
|
JSONB_AGG(DISTINCT jsonb_build_object('resource', p.resource, 'action', p.action))
|
|
FILTER (WHERE p.id IS NOT NULL) AS permissions
|
|
FROM users u
|
|
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
|
LEFT JOIN roles r ON r.id = ur.role_id
|
|
LEFT JOIN role_permissions rp ON rp.role_id = r.id
|
|
LEFT JOIN permissions p ON p.id = rp.permission_id
|
|
WHERE u.deleted_at IS NULL
|
|
GROUP BY u.id, u.username;
|
|
|
|
-- ============================================================================
|
|
-- VIEW: Revenue Analytics
|
|
-- ============================================================================
|
|
|
|
CREATE VIEW v_revenue_analytics AS
|
|
SELECT
|
|
DATE_TRUNC('month', i.issued_date) AS month,
|
|
i.currency,
|
|
COUNT(DISTINCT i.id) AS invoice_count,
|
|
SUM(i.total_amount) AS total_revenue,
|
|
SUM(CASE WHEN s.name = 'Paid' THEN i.total_amount ELSE 0 END) AS collected_revenue,
|
|
COUNT(DISTINCT i.customer_id) AS paying_customers,
|
|
AVG(i.total_amount) AS avg_invoice_value
|
|
FROM invoices i
|
|
JOIN invoice_statuses s ON s.id = i.status_id
|
|
WHERE i.deleted_at IS NULL
|
|
GROUP BY DATE_TRUNC('month', i.issued_date), i.currency
|
|
ORDER BY month DESC;
|
|
|
|
-- ============================================================================
|
|
-- VIEW: Sales Performance
|
|
-- ============================================================================
|
|
|
|
CREATE VIEW v_sales_performance AS
|
|
SELECT
|
|
u.id AS user_id,
|
|
u.first_name || ' ' || u.last_name AS user_name,
|
|
COUNT(DISTINCT o.id) AS total_opportunities,
|
|
COUNT(DISTINCT CASE WHEN o.is_won = TRUE THEN o.id END) AS won_opportunities,
|
|
COUNT(DISTINCT CASE WHEN o.is_won = FALSE AND o.actual_close_date IS NOT NULL THEN o.id END) AS lost_opportunities,
|
|
COALESCE(SUM(CASE WHEN o.is_won = TRUE THEN o.estimated_revenue ELSE 0 END), 0) AS won_revenue,
|
|
COALESCE(AVG(CASE WHEN o.is_won = TRUE THEN o.estimated_revenue ELSE NULL END), 0) AS avg_deal_size,
|
|
COUNT(DISTINCT l.id) AS total_leads,
|
|
COUNT(DISTINCT l.converted_customer_id) AS converted_leads,
|
|
COUNT(DISTINCT t.id) AS completed_tasks
|
|
FROM users u
|
|
LEFT JOIN opportunities o ON o.owner_id = u.id AND o.deleted_at IS NULL
|
|
LEFT JOIN leads l ON l.assigned_to = u.id AND l.deleted_at IS NULL
|
|
LEFT JOIN tasks t ON t.assigned_to = u.id AND t.status = 'completed' AND t.deleted_at IS NULL
|
|
WHERE u.deleted_at IS NULL
|
|
GROUP BY u.id, u.first_name, u.last_name;
|