[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:
parent
27de049441
commit
ea4f8b18a0
@ -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');
|
||||||
|
|||||||
19
ddl/schemas/sales/00-schema.sql
Normal file
19
ddl/schemas/sales/00-schema.sql
Normal 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;
|
||||||
51
ddl/schemas/sales/01-enums.sql
Normal file
51
ddl/schemas/sales/01-enums.sql
Normal 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'
|
||||||
|
);
|
||||||
243
ddl/schemas/sales/02-tables.sql
Normal file
243
ddl/schemas/sales/02-tables.sql
Normal 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();
|
||||||
252
ddl/schemas/sales/03-functions.sql
Normal file
252
ddl/schemas/sales/03-functions.sql
Normal 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';
|
||||||
111
ddl/schemas/sales/04-rls.sql
Normal file
111
ddl/schemas/sales/04-rls.sql
Normal 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);
|
||||||
120
ddl/schemas/sales/05-indexes.sql
Normal file
120
ddl/schemas/sales/05-indexes.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user