diff --git a/database/migrations/025_lead_scoring.sql b/database/migrations/025_lead_scoring.sql new file mode 100644 index 0000000..28f813a --- /dev/null +++ b/database/migrations/025_lead_scoring.sql @@ -0,0 +1,35 @@ +-- ============================================================================ +-- Lead Scoring ML — Configurable scoring engine with historical training +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS lead_scoring_config ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + feature_name VARCHAR(100) NOT NULL UNIQUE, + feature_type VARCHAR(50) NOT NULL DEFAULT 'keyword', + field_ref VARCHAR(100) NOT NULL, + weight DECIMAL(5,2) NOT NULL DEFAULT 1.00, + value_map JSONB, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_lead_scoring_active ON lead_scoring_config(is_active); + +ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score INT DEFAULT 0; +ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score_details JSONB; + +-- Seed default scoring features +INSERT INTO lead_scoring_config (feature_name, feature_type, field_ref, weight, value_map) VALUES + ('Has Email', 'boolean', 'email', 5, '{"true": 5, "false": 0}'), + ('Has Phone', 'boolean', 'phone', 5, '{"true": 5, "false": 0}'), + ('Source: Referral', 'keyword', 'source', 15, '{"referral": 15, "friend": 15, "word_of_mouth": 15}'), + ('Source: Website', 'keyword', 'source', 10, '{"website": 10, "organic": 8, "direct": 8}'), + ('Source: Social', 'keyword', 'source', 8, '{"facebook": 8, "linkedin": 8, "twitter": 5, "instagram": 5}'), + ('Source: Paid', 'keyword', 'source', 6, '{"google_ads": 6, "paid": 6, "ppc": 6}'), + ('Company Name Present', 'boolean', 'company_name', 10, '{"true": 10, "false": 0}'), + ('Description Length', 'range', 'description', 10, '{"0": 0, "50": 5, "200": 10, "500": 15}') +ON CONFLICT DO NOTHING; + +-- Update ml_score for all existing leads +UPDATE leads SET ml_score = 0 WHERE ml_score IS NULL; diff --git a/database/migrations/026_email_campaigns.sql b/database/migrations/026_email_campaigns.sql new file mode 100644 index 0000000..e0b6bca --- /dev/null +++ b/database/migrations/026_email_campaigns.sql @@ -0,0 +1,62 @@ +-- ============================================================================ +-- Email Sequences / Drip Campaigns — Automated follow-up sequences +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS email_campaigns ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'draft', + trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual', + trigger_config 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 TABLE IF NOT EXISTS email_campaign_steps ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE, + step_order INT NOT NULL DEFAULT 0, + delay_days INT NOT NULL DEFAULT 0, + subject VARCHAR(500) NOT NULL, + body_text TEXT, + body_html TEXT, + variables JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS email_campaign_subscribers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE, + lead_id UUID REFERENCES leads(id) ON DELETE SET NULL, + email VARCHAR(255) NOT NULL, + contact_name VARCHAR(255), + subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + unsubscribed_at TIMESTAMPTZ, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + current_step INT NOT NULL DEFAULT 0, + last_sent_at TIMESTAMPTZ, + CONSTRAINT uq_campaign_lead UNIQUE (campaign_id, lead_id) +); + +CREATE TABLE IF NOT EXISTS email_campaign_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE, + subscriber_id UUID REFERENCES email_campaign_subscribers(id) ON DELETE CASCADE, + step_id UUID REFERENCES email_campaign_steps(id) ON DELETE SET NULL, + recipient VARCHAR(255) NOT NULL, + subject VARCHAR(500), + body_text TEXT, + sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status VARCHAR(50) NOT NULL DEFAULT 'sent', + error_message TEXT +); + +ALTER TABLE email_campaign_subscribers ADD CONSTRAINT IF NOT EXISTS uq_campaign_lead UNIQUE (campaign_id, lead_id); + +CREATE INDEX idx_campaign_steps_order ON email_campaign_steps(campaign_id, step_order); +CREATE INDEX idx_campaign_subscribers ON email_campaign_subscribers(campaign_id, status); +CREATE INDEX idx_campaign_logs ON email_campaign_logs(campaign_id, sent_at); diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql index 9aa2729..0486a0f 100644 --- a/database/migrations/run_all.sql +++ b/database/migrations/run_all.sql @@ -91,5 +91,11 @@ BEGIN; \echo '=== Running 024_resource_planning.sql (Resource Planning) ===' \i 024_resource_planning.sql +\echo '=== Running 025_lead_scoring.sql (Lead Scoring ML) ===' +\i 025_lead_scoring.sql + +\echo '=== Running 026_email_campaigns.sql (Email Campaigns) ===' +\i 026_email_campaigns.sql + \echo '=== Migration Complete ===' COMMIT; diff --git a/src/app/(dashboard)/campaigns/[id]/page.tsx b/src/app/(dashboard)/campaigns/[id]/page.tsx new file mode 100644 index 0000000..0cea105 --- /dev/null +++ b/src/app/(dashboard)/campaigns/[id]/page.tsx @@ -0,0 +1,258 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { use } from "react" +import { PageHeader } from "@/components/shared/page-header" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + ArrowLeft, Plus, Trash2, Send, Play, Pause, Users, Mail, Clock, +} from "lucide-react" + +export default function CampaignDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const router = useRouter() + const [campaign, setCampaign] = useState(null) + const [loading, setLoading] = useState(true) + const [newStep, setNewStep] = useState({ subject: "", bodyText: "", delayDays: 3 }) + const [addingStep, setAddingStep] = useState(false) + const [sending, setSending] = useState(false) + const [leadSearch, setLeadSearch] = useState("") + const [searchResults, setSearchResults] = useState([]) + const [subscribing, setSubscribing] = useState(false) + + const fetchCampaign = async () => { + try { + const res = await fetch(`/api/campaigns/${id}`) + if (res.ok) setCampaign(await res.json()) + } catch { /* ignore */ } + setLoading(false) + } + + useEffect(() => { fetchCampaign() }, [id]) + + const searchLeads = async (q: string) => { + setLeadSearch(q) + if (q.length < 2) { setSearchResults([]); return } + const res = await fetch(`/api/leads?search=${encodeURIComponent(q)}&limit=10`) + if (res.ok) setSearchResults(await res.json()) + } + + const addStep = async () => { + if (!newStep.subject.trim()) return + setAddingStep(true) + try { + await fetch(`/api/campaigns/${id}/steps`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newStep), + }) + setNewStep({ subject: "", bodyText: "", delayDays: 3 }) + fetchCampaign() + } catch { /* ignore */ } + setAddingStep(false) + } + + const subscribeLeads = async (leadIds: string[]) => { + setSubscribing(true) + try { + await fetch(`/api/campaigns/${id}/subscribe`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ leadIds }), + }) + setLeadSearch("") + setSearchResults([]) + fetchCampaign() + } catch { /* ignore */ } + setSubscribing(false) + } + + const sendCampaign = async () => { + setSending(true) + try { + const res = await fetch(`/api/campaigns/${id}/send`, { method: "POST" }) + const data = await res.json() + if (data.sent > 0) fetchCampaign() + } catch { /* ignore */ } + setSending(false) + } + + const updateStatus = async (status: string) => { + await fetch(`/api/campaigns/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }) + fetchCampaign() + } + + if (loading) return
Loading...
+ if (!campaign) return
Campaign not found
+ + return ( +
+ + + + +
+ +

Status

+
+ {campaign.status} + {campaign.status === "draft" && } + {campaign.status === "active" && } + {campaign.status === "paused" && } +
+
+ +

Subscribers

+

{campaign.subscribers?.length || 0}

+
+ +

Steps

+

{campaign.steps?.length || 0}

+
+ +

Created

+

{new Date(campaign.created_at).toLocaleDateString()}

+
+
+ + + + Steps ({campaign.steps?.length || 0}) + Subscribers ({campaign.subscribers?.length || 0}) + Send + + + + +

Add Step

+
+
+ + setNewStep(p => ({ ...p, subject: e.target.value }))} placeholder="Follow-up: {{name}}" /> +
+
+ + setNewStep(p => ({ ...p, delayDays: parseInt(e.target.value) || 0 }))} /> +
+
+ +
+
+
+ +