[SAAS-018] feat: Add sales schema DDL

- Create sales schema (00-schema.sql)
- Add sales enums (01-enums.sql): lead_status, lead_source, opportunity_stage, activity_type, activity_status
- Add tables (02-tables.sql): pipeline_stages, leads, opportunities, activities
- Add functions (03-functions.sql): convert_lead_to_opportunity, update_opportunity_stage, calculate_lead_score, get_pipeline_summary, initialize_default_stages
- Add RLS policies (04-rls.sql) for tenant isolation
- Add indexes (05-indexes.sql) for performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-24 20:49:35 -06:00
parent 27de049441
commit ea4f8b18a0
7 changed files with 803 additions and 0 deletions

View File

@ -62,3 +62,10 @@ CREATE TYPE webhooks.event_type AS ENUM (
CREATE TYPE whatsapp.message_status AS ENUM ('pending', 'sent', 'delivered', 'read', 'failed'); CREATE TYPE whatsapp.message_status AS ENUM ('pending', 'sent', 'delivered', 'read', 'failed');
CREATE TYPE whatsapp.message_type AS ENUM ('text', 'template', 'image', 'document', 'audio', 'video', 'location', 'contacts', 'interactive'); CREATE TYPE whatsapp.message_type AS ENUM ('text', 'template', 'image', 'document', 'audio', 'video', 'location', 'contacts', 'interactive');
CREATE TYPE whatsapp.message_direction AS ENUM ('outbound', 'inbound'); CREATE TYPE whatsapp.message_direction AS ENUM ('outbound', 'inbound');
-- Sales enums (SAAS-018)
CREATE TYPE sales.lead_status AS ENUM ('new', 'contacted', 'qualified', 'unqualified', 'converted');
CREATE TYPE sales.lead_source AS ENUM ('website', 'referral', 'cold_call', 'event', 'advertisement', 'social_media', 'other');
CREATE TYPE sales.opportunity_stage AS ENUM ('prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won', 'closed_lost');
CREATE TYPE sales.activity_type AS ENUM ('call', 'meeting', 'task', 'email', 'note');
CREATE TYPE sales.activity_status AS ENUM ('pending', 'completed', 'cancelled');

View File

@ -0,0 +1,19 @@
-- ============================================
-- TEMPLATE-SAAS: Sales Schema
-- Version: 1.0.0
-- Module: SAAS-018
-- ============================================
-- Create schema
CREATE SCHEMA IF NOT EXISTS sales;
-- Grant permissions
GRANT USAGE ON SCHEMA sales TO template_saas_app;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA sales TO template_saas_app;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA sales TO template_saas_app;
-- Default privileges for future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA sales
GRANT ALL PRIVILEGES ON TABLES TO template_saas_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA sales
GRANT ALL PRIVILEGES ON SEQUENCES TO template_saas_app;

View File

@ -0,0 +1,51 @@
-- ============================================
-- TEMPLATE-SAAS: Sales Enums
-- Version: 1.0.0
-- Module: SAAS-018
-- ============================================
-- Lead status enum
CREATE TYPE sales.lead_status AS ENUM (
'new',
'contacted',
'qualified',
'unqualified',
'converted'
);
-- Lead source enum
CREATE TYPE sales.lead_source AS ENUM (
'website',
'referral',
'cold_call',
'event',
'advertisement',
'social_media',
'other'
);
-- Opportunity stage enum
CREATE TYPE sales.opportunity_stage AS ENUM (
'prospecting',
'qualification',
'proposal',
'negotiation',
'closed_won',
'closed_lost'
);
-- Activity type enum
CREATE TYPE sales.activity_type AS ENUM (
'call',
'meeting',
'task',
'email',
'note'
);
-- Activity status enum
CREATE TYPE sales.activity_status AS ENUM (
'pending',
'completed',
'cancelled'
);

View File

@ -0,0 +1,243 @@
-- ============================================
-- TEMPLATE-SAAS: Sales Tables
-- Version: 1.0.0
-- Module: SAAS-018
-- ============================================
-- ============================================
-- Pipeline Stages (configurable stages per tenant)
-- ============================================
CREATE TABLE sales.pipeline_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Stage info
name VARCHAR(100) NOT NULL,
position INT NOT NULL DEFAULT 0,
color VARCHAR(7) DEFAULT '#3B82F6',
-- Stage type flags
is_won BOOLEAN DEFAULT FALSE,
is_lost BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
-- Constraints
CONSTRAINT unique_stage_position UNIQUE (tenant_id, position),
CONSTRAINT check_win_lost_exclusive CHECK (NOT (is_won AND is_lost))
);
COMMENT ON TABLE sales.pipeline_stages IS 'Configurable pipeline stages per tenant';
-- ============================================
-- Leads (prospects)
-- ============================================
CREATE TABLE sales.leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Contact info
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255),
phone VARCHAR(50),
-- Company info
company VARCHAR(200),
job_title VARCHAR(150),
website VARCHAR(255),
-- Lead classification
source sales.lead_source DEFAULT 'other',
status sales.lead_status DEFAULT 'new' NOT NULL,
score INT DEFAULT 0 CHECK (score >= 0 AND score <= 100),
-- Assignment
assigned_to UUID REFERENCES users.users(id) ON DELETE SET NULL,
-- Notes
notes TEXT,
-- Conversion tracking
converted_at TIMESTAMPTZ,
converted_to_opportunity_id UUID,
-- Address
address_line1 VARCHAR(255),
address_line2 VARCHAR(255),
city VARCHAR(100),
state VARCHAR(100),
postal_code VARCHAR(20),
country VARCHAR(100),
-- Custom fields (JSON)
custom_fields JSONB DEFAULT '{}'::jsonb,
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
created_by UUID REFERENCES users.users(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ
);
COMMENT ON TABLE sales.leads IS 'Sales leads/prospects';
COMMENT ON COLUMN sales.leads.score IS 'Lead score from 0-100';
COMMENT ON COLUMN sales.leads.custom_fields IS 'Flexible custom fields per tenant';
-- ============================================
-- Opportunities (deals)
-- ============================================
CREATE TABLE sales.opportunities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Basic info
name VARCHAR(255) NOT NULL,
description TEXT,
-- Lead reference (optional)
lead_id UUID REFERENCES sales.leads(id) ON DELETE SET NULL,
-- Pipeline stage
stage sales.opportunity_stage DEFAULT 'prospecting' NOT NULL,
stage_id UUID REFERENCES sales.pipeline_stages(id) ON DELETE SET NULL,
-- Value
amount DECIMAL(15, 2) DEFAULT 0,
currency VARCHAR(3) DEFAULT 'USD',
probability INT DEFAULT 0 CHECK (probability >= 0 AND probability <= 100),
-- Timeline
expected_close_date DATE,
actual_close_date DATE,
-- Assignment
assigned_to UUID REFERENCES users.users(id) ON DELETE SET NULL,
-- Win/Loss tracking
won_at TIMESTAMPTZ,
lost_at TIMESTAMPTZ,
lost_reason VARCHAR(500),
-- Contact info (can be different from lead)
contact_name VARCHAR(200),
contact_email VARCHAR(255),
contact_phone VARCHAR(50),
company_name VARCHAR(200),
-- Notes
notes TEXT,
-- Custom fields
custom_fields JSONB DEFAULT '{}'::jsonb,
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
created_by UUID REFERENCES users.users(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT check_close_status CHECK (
(won_at IS NULL OR lost_at IS NULL) -- Cannot be both won and lost
)
);
COMMENT ON TABLE sales.opportunities IS 'Sales opportunities/deals';
COMMENT ON COLUMN sales.opportunities.probability IS 'Win probability percentage (0-100)';
-- ============================================
-- Activities (calls, meetings, tasks, etc.)
-- ============================================
CREATE TABLE sales.activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Activity type
type sales.activity_type NOT NULL,
status sales.activity_status DEFAULT 'pending' NOT NULL,
-- Content
subject VARCHAR(255) NOT NULL,
description TEXT,
-- Related records (at least one should be set)
lead_id UUID REFERENCES sales.leads(id) ON DELETE CASCADE,
opportunity_id UUID REFERENCES sales.opportunities(id) ON DELETE CASCADE,
-- Scheduling
due_date TIMESTAMPTZ,
due_time TIME,
duration_minutes INT,
-- Completion
completed_at TIMESTAMPTZ,
outcome TEXT,
-- Assignment
assigned_to UUID REFERENCES users.users(id) ON DELETE SET NULL,
created_by UUID REFERENCES users.users(id) ON DELETE SET NULL,
-- For calls
call_direction VARCHAR(10), -- 'inbound' or 'outbound'
call_recording_url VARCHAR(500),
-- For meetings
location VARCHAR(255),
meeting_url VARCHAR(500),
attendees JSONB DEFAULT '[]'::jsonb,
-- Reminder
reminder_at TIMESTAMPTZ,
reminder_sent BOOLEAN DEFAULT FALSE,
-- Custom fields
custom_fields JSONB DEFAULT '{}'::jsonb,
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
deleted_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT check_related_record CHECK (
lead_id IS NOT NULL OR opportunity_id IS NOT NULL
)
);
COMMENT ON TABLE sales.activities IS 'Sales activities: calls, meetings, tasks, emails, notes';
COMMENT ON COLUMN sales.activities.attendees IS 'JSON array of meeting attendees';
-- ============================================
-- Triggers for updated_at
-- ============================================
CREATE OR REPLACE FUNCTION sales.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_pipeline_stages_updated_at
BEFORE UPDATE ON sales.pipeline_stages
FOR EACH ROW
EXECUTE FUNCTION sales.update_updated_at();
CREATE TRIGGER trg_leads_updated_at
BEFORE UPDATE ON sales.leads
FOR EACH ROW
EXECUTE FUNCTION sales.update_updated_at();
CREATE TRIGGER trg_opportunities_updated_at
BEFORE UPDATE ON sales.opportunities
FOR EACH ROW
EXECUTE FUNCTION sales.update_updated_at();
CREATE TRIGGER trg_activities_updated_at
BEFORE UPDATE ON sales.activities
FOR EACH ROW
EXECUTE FUNCTION sales.update_updated_at();

View File

@ -0,0 +1,252 @@
-- ============================================
-- TEMPLATE-SAAS: Sales Functions
-- Version: 1.0.0
-- Module: SAAS-018
-- ============================================
-- ============================================
-- Function: Convert Lead to Opportunity
-- ============================================
CREATE OR REPLACE FUNCTION sales.convert_lead_to_opportunity(
p_lead_id UUID,
p_opportunity_name VARCHAR(255) DEFAULT NULL,
p_amount DECIMAL(15, 2) DEFAULT 0,
p_expected_close_date DATE DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_lead RECORD;
v_opportunity_id UUID;
v_tenant_id UUID;
BEGIN
-- Get lead data
SELECT * INTO v_lead
FROM sales.leads
WHERE id = p_lead_id
AND deleted_at IS NULL
AND status != 'converted';
IF NOT FOUND THEN
RAISE EXCEPTION 'Lead not found or already converted';
END IF;
v_tenant_id := v_lead.tenant_id;
-- Create opportunity
INSERT INTO sales.opportunities (
tenant_id,
name,
lead_id,
stage,
amount,
currency,
expected_close_date,
assigned_to,
contact_name,
contact_email,
contact_phone,
company_name,
notes,
created_by
) VALUES (
v_tenant_id,
COALESCE(p_opportunity_name, v_lead.company || ' - ' || v_lead.first_name || ' ' || v_lead.last_name),
p_lead_id,
'prospecting',
COALESCE(p_amount, 0),
'USD',
COALESCE(p_expected_close_date, CURRENT_DATE + INTERVAL '30 days'),
v_lead.assigned_to,
v_lead.first_name || ' ' || v_lead.last_name,
v_lead.email,
v_lead.phone,
v_lead.company,
v_lead.notes,
v_lead.created_by
)
RETURNING id INTO v_opportunity_id;
-- Update lead as converted
UPDATE sales.leads
SET status = 'converted',
converted_at = NOW(),
converted_to_opportunity_id = v_opportunity_id
WHERE id = p_lead_id;
RETURN v_opportunity_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION sales.convert_lead_to_opportunity IS 'Converts a lead to an opportunity, copying relevant data';
-- ============================================
-- Function: Update Opportunity Stage
-- ============================================
CREATE OR REPLACE FUNCTION sales.update_opportunity_stage(
p_opportunity_id UUID,
p_new_stage sales.opportunity_stage,
p_notes TEXT DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_opportunity RECORD;
BEGIN
-- Get current opportunity
SELECT * INTO v_opportunity
FROM sales.opportunities
WHERE id = p_opportunity_id
AND deleted_at IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'Opportunity not found';
END IF;
-- Update stage
UPDATE sales.opportunities
SET stage = p_new_stage,
notes = CASE
WHEN p_notes IS NOT NULL THEN COALESCE(notes, '') || E'\n\n[Stage Change] ' || p_notes
ELSE notes
END,
won_at = CASE WHEN p_new_stage = 'closed_won' THEN NOW() ELSE won_at END,
lost_at = CASE WHEN p_new_stage = 'closed_lost' THEN NOW() ELSE lost_at END,
actual_close_date = CASE
WHEN p_new_stage IN ('closed_won', 'closed_lost') THEN CURRENT_DATE
ELSE actual_close_date
END
WHERE id = p_opportunity_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION sales.update_opportunity_stage IS 'Updates opportunity stage with automatic win/loss tracking';
-- ============================================
-- Function: Calculate Lead Score
-- ============================================
CREATE OR REPLACE FUNCTION sales.calculate_lead_score(
p_lead_id UUID
)
RETURNS INT AS $$
DECLARE
v_lead RECORD;
v_score INT := 0;
v_activity_count INT;
BEGIN
-- Get lead data
SELECT * INTO v_lead
FROM sales.leads
WHERE id = p_lead_id
AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN 0;
END IF;
-- Base score for having email
IF v_lead.email IS NOT NULL THEN
v_score := v_score + 10;
END IF;
-- Score for phone
IF v_lead.phone IS NOT NULL THEN
v_score := v_score + 10;
END IF;
-- Score for company
IF v_lead.company IS NOT NULL THEN
v_score := v_score + 15;
END IF;
-- Score based on source
CASE v_lead.source
WHEN 'referral' THEN v_score := v_score + 20;
WHEN 'website' THEN v_score := v_score + 15;
WHEN 'event' THEN v_score := v_score + 10;
ELSE v_score := v_score + 5;
END CASE;
-- Score based on status
CASE v_lead.status
WHEN 'qualified' THEN v_score := v_score + 25;
WHEN 'contacted' THEN v_score := v_score + 15;
WHEN 'new' THEN v_score := v_score + 5;
ELSE NULL;
END CASE;
-- Score for activities
SELECT COUNT(*) INTO v_activity_count
FROM sales.activities
WHERE lead_id = p_lead_id
AND deleted_at IS NULL;
v_score := v_score + LEAST(v_activity_count * 5, 20);
-- Cap at 100
RETURN LEAST(v_score, 100);
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION sales.calculate_lead_score IS 'Calculates a lead score based on completeness and engagement';
-- ============================================
-- Function: Get Pipeline Summary
-- ============================================
CREATE OR REPLACE FUNCTION sales.get_pipeline_summary(
p_tenant_id UUID
)
RETURNS TABLE (
stage sales.opportunity_stage,
count BIGINT,
total_amount DECIMAL(15, 2),
avg_probability NUMERIC
) AS $$
BEGIN
RETURN QUERY
SELECT
o.stage,
COUNT(*)::BIGINT,
COALESCE(SUM(o.amount), 0)::DECIMAL(15, 2),
COALESCE(AVG(o.probability), 0)::NUMERIC
FROM sales.opportunities o
WHERE o.tenant_id = p_tenant_id
AND o.deleted_at IS NULL
GROUP BY o.stage
ORDER BY
CASE o.stage
WHEN 'prospecting' THEN 1
WHEN 'qualification' THEN 2
WHEN 'proposal' THEN 3
WHEN 'negotiation' THEN 4
WHEN 'closed_won' THEN 5
WHEN 'closed_lost' THEN 6
END;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION sales.get_pipeline_summary IS 'Returns summary of opportunities by stage for a tenant';
-- ============================================
-- Function: Initialize Default Pipeline Stages
-- ============================================
CREATE OR REPLACE FUNCTION sales.initialize_default_stages(
p_tenant_id UUID
)
RETURNS VOID AS $$
BEGIN
-- Insert default stages if none exist
IF NOT EXISTS (SELECT 1 FROM sales.pipeline_stages WHERE tenant_id = p_tenant_id) THEN
INSERT INTO sales.pipeline_stages (tenant_id, name, position, color, is_won, is_lost)
VALUES
(p_tenant_id, 'Prospecting', 1, '#94A3B8', FALSE, FALSE),
(p_tenant_id, 'Qualification', 2, '#3B82F6', FALSE, FALSE),
(p_tenant_id, 'Proposal', 3, '#8B5CF6', FALSE, FALSE),
(p_tenant_id, 'Negotiation', 4, '#F59E0B', FALSE, FALSE),
(p_tenant_id, 'Closed Won', 5, '#10B981', TRUE, FALSE),
(p_tenant_id, 'Closed Lost', 6, '#EF4444', FALSE, TRUE);
END IF;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION sales.initialize_default_stages IS 'Creates default pipeline stages for a new tenant';

View File

@ -0,0 +1,111 @@
-- ============================================
-- TEMPLATE-SAAS: Sales Row Level Security
-- Version: 1.0.0
-- Module: SAAS-018
-- ============================================
-- ============================================
-- Enable RLS on all tables
-- ============================================
ALTER TABLE sales.pipeline_stages ENABLE ROW LEVEL SECURITY;
ALTER TABLE sales.leads ENABLE ROW LEVEL SECURITY;
ALTER TABLE sales.opportunities ENABLE ROW LEVEL SECURITY;
ALTER TABLE sales.activities ENABLE ROW LEVEL SECURITY;
-- ============================================
-- Pipeline Stages Policies
-- ============================================
CREATE POLICY pipeline_stages_tenant_isolation ON sales.pipeline_stages
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY pipeline_stages_insert ON sales.pipeline_stages
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY pipeline_stages_update ON sales.pipeline_stages
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY pipeline_stages_delete ON sales.pipeline_stages
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ============================================
-- Leads Policies
-- ============================================
CREATE POLICY leads_tenant_isolation ON sales.leads
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY leads_insert ON sales.leads
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY leads_update ON sales.leads
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY leads_delete ON sales.leads
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ============================================
-- Opportunities Policies
-- ============================================
CREATE POLICY opportunities_tenant_isolation ON sales.opportunities
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY opportunities_insert ON sales.opportunities
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY opportunities_update ON sales.opportunities
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY opportunities_delete ON sales.opportunities
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ============================================
-- Activities Policies
-- ============================================
CREATE POLICY activities_tenant_isolation ON sales.activities
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY activities_insert ON sales.activities
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY activities_update ON sales.activities
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY activities_delete ON sales.activities
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ============================================
-- Bypass policies for service role (optional)
-- ============================================
-- These allow backend services with elevated privileges to bypass RLS
-- when needed (e.g., for admin operations, reporting, etc.)
-- CREATE POLICY pipeline_stages_service_bypass ON sales.pipeline_stages
-- FOR ALL
-- TO template_saas_service
-- USING (true);
-- CREATE POLICY leads_service_bypass ON sales.leads
-- FOR ALL
-- TO template_saas_service
-- USING (true);
-- CREATE POLICY opportunities_service_bypass ON sales.opportunities
-- FOR ALL
-- TO template_saas_service
-- USING (true);
-- CREATE POLICY activities_service_bypass ON sales.activities
-- FOR ALL
-- TO template_saas_service
-- USING (true);

View File

@ -0,0 +1,120 @@
-- ============================================
-- TEMPLATE-SAAS: Sales Indexes
-- Version: 1.0.0
-- Module: SAAS-018
-- ============================================
-- ============================================
-- Pipeline Stages Indexes
-- ============================================
CREATE INDEX idx_pipeline_stages_tenant ON sales.pipeline_stages(tenant_id);
CREATE INDEX idx_pipeline_stages_position ON sales.pipeline_stages(tenant_id, position);
CREATE INDEX idx_pipeline_stages_active ON sales.pipeline_stages(tenant_id, is_active) WHERE is_active = TRUE;
-- ============================================
-- Leads Indexes
-- ============================================
-- Primary indexes
CREATE INDEX idx_leads_tenant ON sales.leads(tenant_id);
CREATE INDEX idx_leads_status ON sales.leads(tenant_id, status);
CREATE INDEX idx_leads_source ON sales.leads(tenant_id, source);
CREATE INDEX idx_leads_assigned ON sales.leads(assigned_to) WHERE assigned_to IS NOT NULL;
-- Search indexes
CREATE INDEX idx_leads_email ON sales.leads(tenant_id, email) WHERE email IS NOT NULL;
CREATE INDEX idx_leads_company ON sales.leads(tenant_id, company) WHERE company IS NOT NULL;
-- Full-text search on name
CREATE INDEX idx_leads_name_search ON sales.leads
USING gin(to_tsvector('simple', first_name || ' ' || last_name));
-- Score for prioritization
CREATE INDEX idx_leads_score ON sales.leads(tenant_id, score DESC) WHERE deleted_at IS NULL;
-- Soft delete filter
CREATE INDEX idx_leads_active ON sales.leads(tenant_id)
WHERE deleted_at IS NULL;
-- Conversion tracking
CREATE INDEX idx_leads_converted ON sales.leads(tenant_id, converted_at)
WHERE converted_at IS NOT NULL;
-- ============================================
-- Opportunities Indexes
-- ============================================
-- Primary indexes
CREATE INDEX idx_opportunities_tenant ON sales.opportunities(tenant_id);
CREATE INDEX idx_opportunities_stage ON sales.opportunities(tenant_id, stage);
CREATE INDEX idx_opportunities_stage_id ON sales.opportunities(tenant_id, stage_id) WHERE stage_id IS NOT NULL;
CREATE INDEX idx_opportunities_assigned ON sales.opportunities(assigned_to) WHERE assigned_to IS NOT NULL;
-- Value-based queries
CREATE INDEX idx_opportunities_amount ON sales.opportunities(tenant_id, amount DESC);
CREATE INDEX idx_opportunities_probability ON sales.opportunities(tenant_id, probability DESC);
-- Date-based queries
CREATE INDEX idx_opportunities_expected_close ON sales.opportunities(tenant_id, expected_close_date);
CREATE INDEX idx_opportunities_won ON sales.opportunities(tenant_id, won_at) WHERE won_at IS NOT NULL;
CREATE INDEX idx_opportunities_lost ON sales.opportunities(tenant_id, lost_at) WHERE lost_at IS NOT NULL;
-- Lead reference
CREATE INDEX idx_opportunities_lead ON sales.opportunities(lead_id) WHERE lead_id IS NOT NULL;
-- Soft delete filter
CREATE INDEX idx_opportunities_active ON sales.opportunities(tenant_id)
WHERE deleted_at IS NULL;
-- Pipeline view (common query pattern)
CREATE INDEX idx_opportunities_pipeline ON sales.opportunities(tenant_id, stage, amount DESC)
WHERE deleted_at IS NULL AND won_at IS NULL AND lost_at IS NULL;
-- ============================================
-- Activities Indexes
-- ============================================
-- Primary indexes
CREATE INDEX idx_activities_tenant ON sales.activities(tenant_id);
CREATE INDEX idx_activities_type ON sales.activities(tenant_id, type);
CREATE INDEX idx_activities_status ON sales.activities(tenant_id, status);
-- Related records
CREATE INDEX idx_activities_lead ON sales.activities(lead_id) WHERE lead_id IS NOT NULL;
CREATE INDEX idx_activities_opportunity ON sales.activities(opportunity_id) WHERE opportunity_id IS NOT NULL;
-- Assignment
CREATE INDEX idx_activities_assigned ON sales.activities(assigned_to) WHERE assigned_to IS NOT NULL;
-- Scheduling
CREATE INDEX idx_activities_due_date ON sales.activities(tenant_id, due_date)
WHERE status = 'pending' AND deleted_at IS NULL;
-- Upcoming activities (common query)
CREATE INDEX idx_activities_upcoming ON sales.activities(tenant_id, due_date, type)
WHERE status = 'pending' AND deleted_at IS NULL AND due_date IS NOT NULL;
-- Reminders
CREATE INDEX idx_activities_reminder ON sales.activities(reminder_at)
WHERE reminder_sent = FALSE AND reminder_at IS NOT NULL AND status = 'pending';
-- Soft delete filter
CREATE INDEX idx_activities_active ON sales.activities(tenant_id)
WHERE deleted_at IS NULL;
-- ============================================
-- Composite indexes for common queries
-- ============================================
-- Dashboard: leads by status with score
CREATE INDEX idx_leads_dashboard ON sales.leads(tenant_id, status, score DESC)
WHERE deleted_at IS NULL;
-- Dashboard: opportunities pipeline
CREATE INDEX idx_opportunities_dashboard ON sales.opportunities(tenant_id, stage, expected_close_date)
WHERE deleted_at IS NULL AND won_at IS NULL AND lost_at IS NULL;
-- Activity feed for a lead
CREATE INDEX idx_activities_lead_feed ON sales.activities(lead_id, created_at DESC)
WHERE deleted_at IS NULL;
-- Activity feed for an opportunity
CREATE INDEX idx_activities_opportunity_feed ON sales.activities(opportunity_id, created_at DESC)
WHERE deleted_at IS NULL;