diff --git a/ddl/02-enums.sql b/ddl/02-enums.sql index 9027c06..d772724 100644 --- a/ddl/02-enums.sql +++ b/ddl/02-enums.sql @@ -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_type AS ENUM ('text', 'template', 'image', 'document', 'audio', 'video', 'location', 'contacts', 'interactive'); 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'); diff --git a/ddl/schemas/sales/00-schema.sql b/ddl/schemas/sales/00-schema.sql new file mode 100644 index 0000000..974e5a1 --- /dev/null +++ b/ddl/schemas/sales/00-schema.sql @@ -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; diff --git a/ddl/schemas/sales/01-enums.sql b/ddl/schemas/sales/01-enums.sql new file mode 100644 index 0000000..3766397 --- /dev/null +++ b/ddl/schemas/sales/01-enums.sql @@ -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' +); diff --git a/ddl/schemas/sales/02-tables.sql b/ddl/schemas/sales/02-tables.sql new file mode 100644 index 0000000..751f5e6 --- /dev/null +++ b/ddl/schemas/sales/02-tables.sql @@ -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(); diff --git a/ddl/schemas/sales/03-functions.sql b/ddl/schemas/sales/03-functions.sql new file mode 100644 index 0000000..108c16a --- /dev/null +++ b/ddl/schemas/sales/03-functions.sql @@ -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'; diff --git a/ddl/schemas/sales/04-rls.sql b/ddl/schemas/sales/04-rls.sql new file mode 100644 index 0000000..8c85053 --- /dev/null +++ b/ddl/schemas/sales/04-rls.sql @@ -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); diff --git a/ddl/schemas/sales/05-indexes.sql b/ddl/schemas/sales/05-indexes.sql new file mode 100644 index 0000000..deabeb9 --- /dev/null +++ b/ddl/schemas/sales/05-indexes.sql @@ -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;