diff --git a/database/DESIGN_DECISIONS.md b/database/DESIGN_DECISIONS.md new file mode 100644 index 0000000..8f2a679 --- /dev/null +++ b/database/DESIGN_DECISIONS.md @@ -0,0 +1,164 @@ +# CRM Database Schema — Enterprise RBAC + CRM + +## Architecture Overview + +Enterprise-grade PostgreSQL schema implementing **hierarchical Role-Based Access Control (RBAC)** +with a complete CRM system. Designed for production deployments with strict security requirements, +audit compliance, and scalability to millions of records. + +--- + +## 1. Hierarchical RBAC Model + +Four roles arranged in a strict hierarchy by `hierarchy_level` (lower = higher privilege): + +| Role | Level | Authority | +|------|-------|-----------| +| **SUPER_ADMIN** | 1 | Unrestricted — create/delete users, assign roles, override all permissions | +| **ADMIN** | 2 | Operational — ban/suspend users, review reports, moderate activity. **Cannot create accounts.** | +| **SALES_USER** | 3 | CRM operations — leads, customers, opportunities, communications | +| **DEVELOPER** | 4 | Technical + CRM read access for post-sale project visibility | + +### Enforcement via PostgreSQL Triggers + +- **`enforce_create_user()`** (BEFORE INSERT ON users): Only SUPER_ADMIN (`hierarchy_level = 1`) + can create new user accounts. Bootstrap case (`created_by IS NULL`) is allowed for initial setup. +- **`enforce_role_assignment()`** (BEFORE INSERT ON user_roles): Only SUPER_ADMIN can assign roles. + Prevents privilege escalation by checking hierarchy levels. + +### Permission Model + +Granular permissions stored in `permissions` table with `(resource, action)` pairs. +`role_permissions` maps roles to permissions. The `has_permission(user_id, resource, action)` +function provides application-layer authorization checks. + +--- + +## 2. Ban & Suspension System + +Two separate tables for enforcement flexibility: + +- **`banned_users`**: Permanent or time-limited bans. Supports reversal (by SUPER_ADMIN). + Admins can create bans; SUPER_ADMIN can reverse or permanently delete banned users. +- **`suspended_users`**: Time-limited suspensions (always has expiry). Admins can create + and lift suspensions. + +Both tables are fully audited via dedicated trigger functions. + +--- + +## 3. Session & Login Security + +- **`sessions`**: Stores hashed session tokens with expiry. Only one active session per + token hash. +- **`login_attempts`**: Captures every login attempt (successful or failed) with IP address + and user agent. Used for brute-force detection and audit. +- Users have `failed_login_attempts` and `locked_until` for account lockout policies. + +--- + +## 4. Password Security + +- All passwords hashed with **bcrypt (cost factor 12)** +- `password_change_required` forces password change on first login (TRUE for all non-root + test accounts) +- Password hashes are opaque to the database — validation is application-layer only + +--- + +## 5. UUID Primary Keys + +- All tables use `UUID PRIMARY KEY DEFAULT uuid_generate_v4()` +- Fixed UUIDs used for seed data and system entities for referential transparency +- Prevents enumeration attacks and supports distributed ID generation + +--- + +## 6. Audit Trail + +Every mutation is logged to `audit_logs` via `audit_trigger_function()`: + +- Captures `(table_name, record_id, action, old_data, new_data, changed_by, ip_address)` +- Ban actions, suspension actions, and successful logins have dedicated audit triggers +- Indexed on `(table_name, record_id)` for per-record lookup + +Dedicated audit views: +- `v_active_bans` — Currently active bans with user details +- `v_active_suspensions` — Currently active suspensions +- `v_user_permissions` — Flattened permission report per user + +--- + +## 7. Third Normal Form (3NF) + +Customer model uses the **discriminator pattern**: + +``` +customers (parent — shared columns: status, owner, tags, score) + ├── customer_type = 'individual' → individual_customers + └── customer_type = 'company' → company_customers +``` + +All lookup tables are normalized. Contact information, addresses, and notes are +separate 1:N child tables. + +--- + +## 8. Indexing Strategy + +- **Core indexes**: FK columns, status/sort/date columns, unique constraints +- **Partial indexes**: `WHERE deleted_at IS NULL` on soft-delete tables, + `WHERE is_active = TRUE` on bans/suspensions, `WHERE due_date < NOW()` on overdue invoices +- **Full-text search**: GIN indexes on customers, leads, products, communications, tasks +- **Composite indexes**: Common query patterns (e.g., `opportunities(owner_id, stage_id)`) + +--- + +## 9. Soft Delete + +- `deleted_at TIMESTAMPTZ` on all business tables +- Partial unique indexes ignore soft-deleted rows (e.g., `WHERE deleted_at IS NULL`) +- Audit logs capture the full row snapshot before soft-delete + +--- + +## 10. Test Accounts + +| Username | Password | Role | +|----------|----------|------| +| `superadmin_demo` | `SuperAdmin@2026` | SUPER_ADMIN | +| `admin_demo` | `AdminAccess@2026` | ADMIN | +| `sales_demo` | `SalesAccess@2026` | SALES_USER | +| `dev_demo` | `DevTesting@2026` | DEVELOPER | + +--- + +## 11. Migration Instructions + +```bash +# Create database +psql -U postgres -c "CREATE DATABASE crm;" + +# Run full migration +psql -U postgres -d crm -f database/migrations/run_all.sql + +# Or step by step: +psql -U postgres -d crm -f database/migrations/001_schema.sql +psql -U postgres -d crm -f database/migrations/002_seed.sql +``` + +--- + +## 12. Security Rules Summary + +| Rule | Enforcement | +|------|-------------| +| Only SUPER_ADMIN creates accounts | Trigger `trg_enforce_create_user` | +| Only SUPER_ADMIN assigns roles | Trigger `trg_enforce_role_assignment` | +| No privilege escalation | Level check in `enforce_role_assignment()` | +| bcrypt password hashing | Application-layer (cost=12) | +| Unique usernames/emails | Partial unique indexes | +| Force password change on first login | `password_change_required` column | +| Audit every login/logout | Trigger `trg_audit_login` on `login_attempts` | +| Audit every ban action | Trigger `trg_audit_ban` on `banned_users` | +| Audit every suspension action | Trigger `trg_audit_suspension` on `suspended_users` | diff --git a/database/migrations/001_schema.sql b/database/migrations/001_schema.sql new file mode 100644 index 0000000..a70728d --- /dev/null +++ b/database/migrations/001_schema.sql @@ -0,0 +1,1162 @@ +-- ============================================================================ +-- 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 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 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 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 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 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 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 AND (permanent_ban = TRUE OR expires_at > NOW()); +CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by); + +CREATE TABLE 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 AND expires_at > NOW(); +CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by); + +-- ============================================================================ +-- 3. SESSION & LOGIN MANAGEMENT +-- ============================================================================ + +CREATE TABLE 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; +CREATE INDEX idx_invoices_overdue ON invoices(due_date, status_id) WHERE deleted_at IS NULL AND due_date < NOW(); + +CREATE TABLE 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 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 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 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.columns + WHERE column_name = 'updated_at' + AND table_schema = 'public' + AND table_type = 'BASE TABLE' + LOOP + EXECUTE format( + 'CREATE TRIGGER trg_%I_updated_at + BEFORE UPDATE ON %I + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column()', + 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; + +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; + +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, + NULL + ); + + 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; + +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; + +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; + +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; diff --git a/database/migrations/002_seed.sql b/database/migrations/002_seed.sql new file mode 100644 index 0000000..92b32a3 --- /dev/null +++ b/database/migrations/002_seed.sql @@ -0,0 +1,401 @@ +-- ============================================================================ +-- CRM Database Seed Data — RBAC + Test Accounts + Reference Data +-- ============================================================================ + +-- ============================================================================ +-- FIXED UUIDs FOR REFERENTIAL INTEGRITY +-- ============================================================================ + +-- Role UUIDs +-- SUPER_ADMIN=1, ADMIN=2, SALES_USER=3, DEVELOPER=4 +-- User UUIDs: SUPER_ADMIN=u0001, ADMIN=u0002, SALES=u0003, DEV=u0004 + +-- ============================================================================ +-- 1. ROLES +-- ============================================================================ + +INSERT INTO roles (id, name, display_name, description, hierarchy_level, is_system) VALUES + ('00000001-0000-0000-0000-000000000000', 'SUPER_ADMIN', 'Super Administrator', + 'Highest authority. Full system access. Can create/delete users, assign roles, override all permissions.', + 1, TRUE), + ('00000002-0000-0000-0000-000000000000', 'ADMIN', 'Administrator', + 'Operational administration. Can ban/suspend users, review reports, moderate activity. Cannot create accounts.', + 2, TRUE), + ('00000003-0000-0000-0000-000000000000', 'SALES_USER', 'Sales Representative', + 'Day-to-day CRM operations. Manage leads, customers, opportunities, and communications.', + 3, TRUE), + ('00000004-0000-0000-0000-000000000000', 'DEVELOPER', 'Developer', + 'Internal development/testing with CRM read access. API testing, diagnostics, debugging. Can view projects and customer data post-sale.', + 4, TRUE) +ON CONFLICT (name) DO NOTHING; + +-- ============================================================================ +-- 2. PERMISSIONS — organised by category +-- ============================================================================ + +-- USER MANAGEMENT (SUPER_ADMIN only) +INSERT INTO permissions (id, resource, action, description, category) VALUES + ('p0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'), + ('p0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'), + ('p0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'), + ('p0010004-0000-0000-0000-000000000000', 'users', 'delete', 'Permanently delete user accounts', 'User Management'), + ('p0010005-0000-0000-0000-000000000000', 'users', 'assign_roles', 'Assign roles to users', 'User Management'), + ('p0010006-0000-0000-0000-000000000000', 'users', 'promote', 'Promote users to higher roles', 'User Management'), + ('p0010007-0000-0000-0000-000000000000', 'users', 'demote', 'Demote users to lower roles', 'User Management'), + ('p0010008-0000-0000-0000-000000000000', 'users', 'reset_password', 'Reset user passwords', 'User Management'), + ('p0010009-0000-0000-0000-000000000000', 'users', 'disable', 'Disable/disable user accounts', 'User Management'), + ('p0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management') +ON CONFLICT DO NOTHING; + +-- BAN & SUSPENSION (ADMIN + SUPER_ADMIN) +INSERT INTO permissions (id, resource, action, description, category) VALUES + ('p0020001-0000-0000-0000-000000000000', 'bans', 'create', 'Ban a user account', 'Ban Management'), + ('p0020002-0000-0000-0000-000000000000', 'bans', 'reverse', 'Reverse a ban on a user', 'Ban Management'), + ('p0020003-0000-0000-0000-000000000000', 'bans', 'permanently_delete', 'Permanently delete banned users', 'Ban Management'), + ('p0020004-0000-0000-0000-000000000000', 'suspensions', 'create', 'Suspend a user account', 'Ban Management'), + ('p0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'), + ('p0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management') +ON CONFLICT DO NOTHING; + +-- CRM CORE (SALES_USER + MANAGERS) +INSERT INTO permissions (id, resource, action, description, category) VALUES + ('p0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'), + ('p0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'), + ('p0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'), + ('p0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'), + ('p0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'), + ('p0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'), + ('p0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'), + ('p0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'), + ('p0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'), + ('p0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'), + ('p0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'), + ('p0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'), + ('p0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'), + ('p0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'), + ('p0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'), + ('p0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'), + ('p0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM') +ON CONFLICT DO NOTHING; + +-- REPORTING & INCIDENTS (ADMIN) +INSERT INTO permissions (id, resource, action, description, category) VALUES + ('p0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'), + ('p0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'), + ('p0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'), + ('p0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'), + ('p0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'), + ('p0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'), + ('p0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting') +ON CONFLICT DO NOTHING; + +-- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN) +INSERT INTO permissions (id, resource, action, description, category) VALUES + ('p0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'), + ('p0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'), + ('p0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'), + ('p0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'), + ('p0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'), + ('p0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System') +ON CONFLICT DO NOTHING; + +-- DEVELOPER PERMISSIONS +INSERT INTO permissions (id, resource, action, description, category) VALUES + ('p0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'), + ('p0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'), + ('p0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'), + ('p0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'), + ('p0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'), + ('p0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 3. ROLE-PERMISSION MAPPING +-- ============================================================================ + +-- SUPER_ADMIN — ALL permissions +INSERT INTO role_permissions (role_id, permission_id) +SELECT '00000001-0000-0000-0000-000000000000', id FROM permissions +ON CONFLICT DO NOTHING; + +-- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs +INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES + -- Bans & Suspensions + ('00000002-0000-0000-0000-000000000000', 'p0020001-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0020004-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0020005-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0020006-0000-0000-0000-000000000000', NULL), + -- User read + disable + ('00000002-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-0000-000000000000', 'p0010009-0000-0000-0000-000000000000', NULL), + -- Reporting & Incidents + ('00000002-0000-0000-0000-000000000000', 'p0040001-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0040002-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0040003-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0040004-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0040005-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0040006-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0040007-0000-0000-0000-000000000000', NULL), + -- CRM read access + ('00000002-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL), + -- Audit read + ('00000002-0000-0000-0000-000000000000', 'p0050002-0000-0000-0000-000000000000', NULL) +ON CONFLICT DO NOTHING; + +-- SALES_USER — CRM operations +INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES + ('00000003-0000-0000-0000-000000000000', 'p0030001-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030003-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030005-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030007-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030008-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030009-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030011-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030012-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030013-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030015-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL) +ON CONFLICT DO NOTHING; + +-- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility) +INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES + -- Developer technical permissions + ('00000004-0000-0000-0000-000000000000', 'p0060001-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'p0060002-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'p0060003-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'p0060004-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'p0060005-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'p0060006-0000-0000-0000-000000000000', NULL), + -- User self-view + ('00000004-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL), + -- CRM access (matching SALES_USER level for post-sale project visibility) + ('00000004-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL) +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 4. TEST ACCOUNTS +-- ============================================================================ +-- Passwords (bcrypt, cost=12): +-- superadmin_demo / SuperAdmin@2026 +-- admin_demo / AdminAccess@2026 +-- sales_demo / SalesAccess@2026 +-- dev_demo / DevTesting@2026 + +INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES + ('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@crm.demo', + '$2b$12$C5hczK17I.bu6ILzmGW0U.UnFSdfTuDh42C8t16nxRKaUtXKkdWlC', + 'Super', 'Admin', TRUE, FALSE), + ('00000000-0000-0000-0000-000000000002', 'admin_demo', 'admin@crm.demo', + '$2b$12$TCDq5.sXHA4kWelQPKO6DeQo.WW.NeTuNtOed57UdQ3lRs7.rdkNy', + 'System', 'Admin', TRUE, TRUE), + ('00000000-0000-0000-0000-000000000003', 'sales_demo', 'sales@crm.demo', + '$2b$12$Xhh20UmTn.LTQAs4v4cHx.yQgvuYyNo6TkPaytQ1Q8o0oTPCtIj7W', + 'Sales', 'User', TRUE, TRUE), + ('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@crm.demo', + '$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa', + 'Dev', 'User', TRUE, TRUE) +ON CONFLICT (username) DO NOTHING; + +-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap) +UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001' +WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL; + +-- Set created_by for other users (SUPER_ADMIN created them) +UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001' +WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004') + AND created_by IS NULL; + +-- ============================================================================ +-- 5. ROLE ASSIGNMENTS +-- ============================================================================ + +-- Assign SUPER_ADMIN role to superadmin_demo (bootstrap — assigned_by NULL) +INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES + ('00000000-0000-0000-0000-000000000001', '00000001-0000-0000-0000-000000000000', NULL) +ON CONFLICT DO NOTHING; + +-- Assign remaining roles (SUPER_ADMIN assigned them) +INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES + ('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), + ('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), + ('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 6. REFERENCE DATA +-- ============================================================================ + +-- Customer Statuses +INSERT INTO customer_statuses (id, name, description, color, sort_order, is_default) VALUES + ('c0000000-0000-0000-0000-000000000001', 'Active', 'Customer in good standing', '#22C55E', 1, TRUE), + ('c0000000-0000-0000-0000-000000000002', 'Inactive', 'No recent activity', '#A0AEC0', 2, FALSE), + ('c0000000-0000-0000-0000-000000000003', 'At Risk', 'Showing signs of churn', '#F59E0B', 3, FALSE), + ('c0000000-0000-0000-0000-000000000004', 'Churned', 'Relationship ended', '#EF4444', 4, FALSE), + ('c0000000-0000-0000-0000-000000000005', 'VIP', 'High-value priority customer', '#8B5CF6', 0, FALSE) +ON CONFLICT DO NOTHING; + +-- Lead Sources +INSERT INTO lead_sources (id, name, description) VALUES + ('d0000000-0000-0000-0000-000000000001', 'Website', 'Organic website traffic or web forms'), + ('d0000000-0000-0000-0000-000000000002', 'Referral', 'Referred by existing customer'), + ('d0000000-0000-0000-0000-000000000003', 'LinkedIn', 'LinkedIn outreach'), + ('d0000000-0000-0000-0000-000000000004', 'Email Campaign', 'Email marketing campaign'), + ('d0000000-0000-0000-0000-000000000005', 'Trade Show', 'Event or trade show'), + ('d0000000-0000-0000-0000-000000000006', 'Cold Call', 'Outbound cold calling'), + ('d0000000-0000-0000-0000-000000000007', 'Partner', 'Channel partner referral'), + ('d0000000-0000-0000-0000-000000000008', 'Social Media', 'Social media platforms'), + ('d0000000-0000-0000-0000-000000000009', 'Advertisement', 'Paid advertising'), + ('d0000000-0000-0000-0000-000000000010', 'Other', 'Other sources') +ON CONFLICT DO NOTHING; + +-- Lead Stages +INSERT INTO lead_stages (id, name, description, sort_order, probability) VALUES + ('e0000000-0000-0000-0000-000000000001', 'New', 'Newly captured lead', 1, 5), + ('e0000000-0000-0000-0000-000000000002', 'Contacted', 'Initial contact made', 2, 10), + ('e0000000-0000-0000-0000-000000000003', 'Qualified', 'Meets qualification criteria', 3, 25), + ('e0000000-0000-0000-0000-000000000004', 'Interested', 'Shown interest', 4, 40), + ('e0000000-0000-0000-0000-000000000005', 'Demo Scheduled', 'Product demo scheduled', 5, 60), + ('e0000000-0000-0000-0000-000000000006', 'Negotiation', 'In price/terms negotiation', 6, 75), + ('e0000000-0000-0000-0000-000000000007', 'Closed Won', 'Successfully converted', 7, 100), + ('e0000000-0000-0000-0000-000000000008', 'Closed Lost', 'Lead was lost', 8, 0) +ON CONFLICT DO NOTHING; + +-- Deal Stages +INSERT INTO deal_stages (id, name, description, sort_order, probability) VALUES + ('f0000000-0000-0000-0000-000000000001', 'Prospecting', 'Initial qualification', 1, 10), + ('f0000000-0000-0000-0000-000000000002', 'Discovery', 'Understanding needs', 2, 20), + ('f0000000-0000-0000-0000-000000000003', 'Proposal', 'Solution proposed', 3, 40), + ('f0000000-0000-0000-0000-000000000004', 'Negotiation', 'Commercial discussions', 4, 60), + ('f0000000-0000-0000-0000-000000000005', 'Closing', 'Final stage before decision', 5, 80), + ('f0000000-0000-0000-0000-000000000006', 'Won', 'Deal closed won', 6, 100), + ('f0000000-0000-0000-0000-000000000007', 'Lost', 'Deal closed lost', 7, 0) +ON CONFLICT DO NOTHING; + +-- Communication Types +INSERT INTO communication_types (id, name, description, icon) VALUES + ('g0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'), + ('g0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'), + ('g0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'), + ('g0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'), + ('g0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'), + ('g0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'), + ('g0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text') +ON CONFLICT DO NOTHING; + +-- Task Priorities +INSERT INTO task_priorities (id, name, color, sort_order) VALUES + ('h0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1), + ('h0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2), + ('h0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3), + ('h0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4) +ON CONFLICT DO NOTHING; + +-- Invoice Statuses +INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES + ('i0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1), + ('i0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2), + ('i0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3), + ('i0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4), + ('i0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5), + ('i0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6), + ('i0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7) +ON CONFLICT DO NOTHING; + +-- Product Categories +INSERT INTO product_categories (id, name, description, sort_order) VALUES + ('j0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1), + ('j0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2), + ('j0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3), + ('j0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4), + ('j0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5) +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 7. SAMPLE CRM DATA (for demo purposes) +-- ============================================================================ + +-- Sample Products +INSERT INTO products (id, category_id, name, description, sku, unit_price, cost_price) VALUES + ('k0000000-0000-0000-0000-000000000001', 'j0000000-0000-0000-0000-000000000001', 'CRM Pro License', 'Enterprise CRM license per user/year', 'SW-CRM-001', 1200.00, 300.00), + ('k0000000-0000-0000-0000-000000000002', 'j0000000-0000-0000-0000-000000000001', 'CRM Basic License', 'Basic CRM license per user/year', 'SW-CRM-002', 600.00, 150.00), + ('k0000000-0000-0000-0000-000000000003', 'j0000000-0000-0000-0000-000000000003', 'Implementation Service', 'CRM implementation and setup', 'SV-IMP-001', 15000.00, 7500.00), + ('k0000000-0000-0000-0000-000000000004', 'j0000000-0000-0000-0000-000000000003', 'Consulting Day Rate', 'Senior consultant daily rate', 'SV-CON-001', 2500.00, 1000.00), + ('k0000000-0000-0000-0000-000000000005', 'j0000000-0000-0000-0000-000000000005', 'CRM Training - Basic', 'Basic user training per person', 'TR-BAS-001', 500.00, 200.00), + ('k0000000-0000-0000-0000-000000000006', 'j0000000-0000-0000-0000-000000000005', 'CRM Training - Advanced', 'Advanced admin training per person', 'TR-ADV-001', 1000.00, 400.00) +ON CONFLICT DO NOTHING; + +-- Sample Customers +INSERT INTO customers (id, customer_type, status_id, owner_id, source, tags, score) VALUES + ('a0000000-0000-0000-0000-000000000001', 'company', 'c0000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000003', 'Website', ARRAY['tech','enterprise'], 85), + ('a0000000-0000-0000-0000-000000000002', 'individual', 'c0000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000003', 'Referral', ARRAY['retail'], 60), + ('a0000000-0000-0000-0000-000000000003', 'company', 'c0000000-0000-0000-0000-000000000005', + '00000000-0000-0000-0000-000000000003', 'LinkedIn', ARRAY['finance','enterprise','vip'], 95), + ('a0000000-0000-0000-0000-000000000004', 'individual', 'c0000000-0000-0000-0000-000000000002', + '00000000-0000-0000-0000-000000000003', 'Email Campaign', ARRAY['education'], 15) +ON CONFLICT DO NOTHING; + +-- Company details +INSERT INTO company_customers (customer_id, company_name, registration_number, tax_id, website, industry, company_size, annual_revenue) VALUES + ('a0000000-0000-0000-0000-000000000001', 'TechCorp International', 'REG-2024-001', 'TAX-987654', 'https://techcorp.example.com', 'Technology', '500-1000', 50000000.00), + ('a0000000-0000-0000-0000-000000000003', 'FinServ Group', 'REG-2024-002', 'TAX-123456', 'https://finserv.example.com', 'Financial Services', '1000-5000', 250000000.00) +ON CONFLICT DO NOTHING; + +-- Individual details +INSERT INTO individual_customers (customer_id, first_name, last_name, job_title, company_name) VALUES + ('a0000000-0000-0000-0000-000000000002', 'Michael', 'Brown', 'Store Owner', 'Browns Retail Shop'), + ('a0000000-0000-0000-0000-000000000004', 'Sarah', 'Davis', 'Professor', 'State University') +ON CONFLICT DO NOTHING; + +-- Contact Information +INSERT INTO contact_information (customer_id, type, value, is_primary) VALUES + ('a0000000-0000-0000-0000-000000000001', 'email', 'contact@techcorp.example.com', TRUE), + ('a0000000-0000-0000-0000-000000000001', 'phone', '+1-555-0200', TRUE), + ('a0000000-0000-0000-0000-000000000002', 'email', 'michael.brown@email.example.com', TRUE), + ('a0000000-0000-0000-0000-000000000002', 'mobile', '+1-555-0201', TRUE), + ('a0000000-0000-0000-0000-000000000003', 'email', 'info@finserv.example.com', TRUE), + ('a0000000-0000-0000-0000-000000000003', 'phone', '+1-555-0202', TRUE), + ('a0000000-0000-0000-0000-000000000004', 'email', 'sarah.davis@email.example.com', TRUE) +ON CONFLICT DO NOTHING; + +-- Sample Leads +INSERT INTO leads (id, source_id, stage_id, assigned_to, company_name, contact_name, email, interest_level, score) VALUES + ('l0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000004', + '00000000-0000-0000-0000-000000000003', 'DataFlow Analytics', 'David Miller', 'david@dataflow.example.com', 'high', 70), + ('l0000000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003', + '00000000-0000-0000-0000-000000000003', 'GreenEarth Nonprofit', 'Emma Green', 'emma@greenearth.example.com', 'medium', 45) +ON CONFLICT DO NOTHING; + +-- Sample Opportunities +INSERT INTO opportunities (id, customer_id, stage_id, owner_id, name, estimated_revenue, probability, expected_close_date) VALUES + ('m0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'f0000000-0000-0000-0000-000000000005', + '00000000-0000-0000-0000-000000000003', 'TechCorp - Annual Renewal', 120000.00, 80, '2024-12-31'), + ('m0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000003', + '00000000-0000-0000-0000-000000000003', 'FinServ - Enterprise Upgrade', 250000.00, 40, '2025-03-15') +ON CONFLICT DO NOTHING; + +-- Sample Tasks +INSERT INTO tasks (id, customer_id, opportunity_id, assigned_to, assigned_by, title, priority_id, status, due_date) VALUES + ('n0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'm0000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', + 'Prepare renewal proposal for TechCorp', 'h0000000-0000-0000-0000-000000000001', 'in_progress', NOW() + INTERVAL '7 days'), + ('n0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'm0000000-0000-0000-0000-000000000002', + '00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', + 'Schedule FinServ executive meeting', 'h0000000-0000-0000-0000-000000000002', 'pending', NOW() + INTERVAL '3 days') +ON CONFLICT DO NOTHING; diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql new file mode 100644 index 0000000..19380f0 --- /dev/null +++ b/database/migrations/run_all.sql @@ -0,0 +1,19 @@ +-- ============================================================================ +-- CRM Database — Full Migration Runner +-- ============================================================================ +-- Usage: psql -U postgres -d crm -f run_all.sql +-- ============================================================================ + +\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ===' +\i 001_schema.sql + +\echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ===' +\i 002_seed.sql + +\echo '=== Migration Complete ===' +\echo '' +\echo 'Test accounts created:' +\echo ' superadmin_demo / SuperAdmin@2026' +\echo ' admin_demo / AdminAccess@2026' +\echo ' sales_demo / SalesAccess@2026' +\echo ' dev_demo / DevTesting@2026' diff --git a/package-lock.json b/package-lock.json index 122726d..cf0da78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@tanstack/react-table": "^8.20.6", "autoprefixer": "^10.4.20", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", @@ -2985,6 +2986,14 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/package.json b/package.json index aa8119f..7fb48c3 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@tanstack/react-table": "^8.20.6", "autoprefixer": "^10.4.20", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "emoji-mart": "^5.6.0",