Files
CRM_backup/database/migrations/001_schema.sql
T
Ace 1adc4806fa Files Changed
Security Fixes
File	Change
.env.local	Placeholder JWT secret (rotate to random value: openssl rand -base64 64)
src/middleware.ts	Removed /api/auth/me bypass; API routes return JSON 401 (not 302 redirect)
src/lib/auth.ts	sameSite: "lax" → "strict" (create + destroy cookie); SVG name chars are pre-computed (no injection)
src/lib/avatar.ts	Added sanitizeName() — strips non-alphanumeric/safe chars, max 50 chars
src/app/api/users/route.ts	POST restricted to super_admin; validates role against whitelist ["sales","admin","super_admin","dev"]; validates active defaults to true
src/app/api/users/[id]/route.ts	DELETE restricted to super_admin; blocks self-deletion
src/app/api/leads/[id]/route.ts	GET: non-admin users filtered by assigned_to; PATCH: ownership check + score validated 0-100 range
src/app/api/leads/route.ts	DELETE: ownership + admin check; GET: ownership filter for non-admin users
src/app/api/conversations/[id]/messages/route.ts	GET/POST: verify user is a conversation_participant before access
rust-ai/src/main.rs	CORS: AllowOrigin::list(["localhost:3006", "127.0.0.1:3006"]) (was Any)
Performance Fixes
File	Change
src/app/api/leads/route.ts	Added limit (default 50), offset query params; ownership filter in SQL
src/app/api/users/route.ts	Added limit (default 50), offset query params
src/app/api/conversations/route.ts	Added LIMIT 50
src/app/api/leads/[id]/notes/route.ts	Added limit (default 50), offset query params
src/app/api/conversations/[id]/messages/route.ts	Added limit (default 100), offset, before query params
src/app/(dashboard)/chats/page.tsx	Pruned to 5 cached conversations; useMemo wraps messages/conversation/filteredConversations; otherParticipant moved outside component; added 10MB file size limit; recordingChunksRef cleared on discard
src/app/(dashboard)/leads/[id]/page.tsx	Optimistic status update rolls back on fetch failure
database/migrations/003_chat.sql	Added composite index idx_messages_conversation_created on (conversation_id, created_at DESC)
Code Quality Fixes
File	Change
src/lib/ai.ts	Removed dead getInstructions()/updateInstructions() (called nonexistent /ai/instructions)
src/lib/auth.ts	bcrypt.hash(password, 12) — kept (acceptable, OWASP-recommended)
src/app/login/page.tsx	"Forgot password?" button now shows alert to contact admin (was no-op)
src/components/users/user-form-dialog.tsx	Password min 6 → 8 chars
26 silent catch {} blocks across 16 files (see below)	Added console.warn("...") context messages
2026-06-23 09:45:30 +02:00

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;