-- ============================================================================ -- 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;