diff --git a/Cargo.toml b/Cargo.toml index f3eac98c..410b47d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ features = ["database", "i18n"] [features] # ===== DEFAULT ===== -default = ["chat", "people", "automation", "drive", "tasks", "cache", "directory", "llm", "crawler", "browser", "terminal", "editor", "mail", "whatsapp"] +default = ["chat", "people", "automation", "drive", "tasks", "cache", "directory", "llm", "crawler", "browser", "terminal", "editor", "mail", "whatsapp", "designer", "marketing"] browser = ["automation", "drive", "cache"] terminal = ["automation", "drive", "cache"] @@ -31,6 +31,7 @@ people = ["automation", "drive", "cache"] mail = ["automation", "drive", "cache", "dep:lettre", "dep:mailparse", "dep:imap"] meet = ["automation", "drive", "cache"] social = ["automation", "drive", "cache"] +marketing = ["people", "automation", "drive", "cache"] # Productivity calendar = ["automation", "drive", "cache"] diff --git a/migrations/6.2.3-crm-deals/down.sql b/migrations/6.2.3-crm-deals/down.sql new file mode 100644 index 00000000..fdea92ff --- /dev/null +++ b/migrations/6.2.3-crm-deals/down.sql @@ -0,0 +1,23 @@ +-- Rollback: 6.2.3-crm-deals +-- ============================================ + +-- 1. Drop indexes +DROP INDEX IF EXISTS idx_crm_deals_org_bot; +DROP INDEX IF EXISTS idx_crm_deals_contact; +DROP INDEX IF EXISTS idx_crm_deals_account; +DROP INDEX IF EXISTS idx_crm_deals_stage; +DROP INDEX IF EXISTS idx_crm_deals_owner; +DROP INDEX IF EXISTS idx_crm_deals_source; + +-- 2. Remove deal_id from crm_activities +ALTER TABLE crm_activities DROP COLUMN IF EXISTS deal_id; + +-- 3. Drop crm_deals table +DROP TABLE IF EXISTS crm_deals CASCADE; + +-- 4. Drop crm_deal_segments table +DROP TABLE IF EXISTS crm_deal_segments; + +-- 5. Recreate old tables (for rollback) +-- Note: These need to be recreated from backup or previous schema +-- This is a placeholder - in production, you'd restore from backup diff --git a/migrations/6.2.3-crm-deals/up.sql b/migrations/6.2.3-crm-deals/up.sql new file mode 100644 index 00000000..4388fc82 --- /dev/null +++ b/migrations/6.2.3-crm-deals/up.sql @@ -0,0 +1,81 @@ +-- ============================================ +-- CRM v2 - Unified Deals Table +-- Version: 6.2.3 +-- ============================================ + +-- 1. Create domain: segments +CREATE TABLE crm_deal_segments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL, + bot_id uuid NOT NULL, + name varchar(50) NOT NULL, + description varchar(255), + created_at timestamptz DEFAULT now() +); + +-- Insert default segments (from gb.rob data) +INSERT INTO crm_deal_segments (org_id, bot_id, name) +SELECT org_id, id FROM bots LIMIT 1; + +-- 2. Create main deals table +CREATE TABLE crm_deals ( + -- πŸ†” Key + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL, + bot_id uuid NOT NULL, + + -- πŸ”— Links to Contact/Account (NO DUPLICATE!) + contact_id uuid REFERENCES crm_contacts(id), + account_id uuid REFERENCES crm_accounts(id), + + -- πŸ”— Owner/Team (FK to users) + am_id uuid REFERENCES users(id), + owner_id uuid REFERENCES users(id), + lead_id uuid REFERENCES crm_leads(id), + + -- πŸ’° Deal + title varchar(100), + name varchar(100), + description text, + value double precision, + currency varchar(10), + + -- πŸ“Š Pipeline (use existing crm_pipeline_stages!) + stage_id uuid REFERENCES crm_pipeline_stages(id), + stage varchar(30), -- new, qualified, proposal, negotiation, won, lost + probability integer DEFAULT 0, + won boolean, + + -- 🎯 Classification + source varchar(50), -- EMAIL, CALL, WEBSITE, REFERAL + segment_id uuid REFERENCES crm_deal_segments(id), + + -- πŸ“… Dates + expected_close_date date, + actual_close_date date, + period integer, -- 1=manhΓ£, 2=tarde, 3=noite (or hour 1-24) + deal_date date, + closed_at timestamptz, + created_at timestamptz DEFAULT now(), + updated_at timestamptz, + + -- πŸ“ Notes (only current, history goes to crm_activities) + notes text, + + -- 🏷️ Tags + tags text[], + + -- πŸ“¦ Custom Fields (social media: linkedin, facebook, twitter, instagram, territory, hard_to_find) + custom_fields jsonb DEFAULT '{}' +); + +-- 3. Add deal_id to crm_activities (for history migration) +ALTER TABLE crm_activities ADD COLUMN deal_id uuid REFERENCES crm_deals(id); + +-- 4. Create indexes +CREATE INDEX idx_crm_deals_org_bot ON crm_deals(org_id, bot_id); +CREATE INDEX idx_crm_deals_contact ON crm_deals(contact_id); +CREATE INDEX idx_crm_deals_account ON crm_deals(account_id); +CREATE INDEX idx_crm_deals_stage ON crm_deals(stage_id); +CREATE INDEX idx_crm_deals_am ON crm_deals(am_id); +CREATE INDEX idx_crm_deals_source ON crm_deals(source); diff --git a/migrations/6.2.4-campaigns/down.sql b/migrations/6.2.4-campaigns/down.sql new file mode 100644 index 00000000..c343385a --- /dev/null +++ b/migrations/6.2.4-campaigns/down.sql @@ -0,0 +1,8 @@ +-- Rollback Campaigns - Version 6.2.4 +DROP TABLE IF EXISTS email_tracking CASCADE; +DROP TABLE IF EXISTS whatsapp_business CASCADE; +DROP TABLE IF EXISTS marketing_list_contacts CASCADE; +DROP TABLE IF EXISTS marketing_recipients CASCADE; +DROP TABLE IF EXISTS marketing_templates CASCADE; +DROP TABLE IF EXISTS marketing_lists CASCADE; +DROP TABLE IF EXISTS marketing_campaigns CASCADE; diff --git a/migrations/6.2.4-campaigns/up.sql b/migrations/6.2.4-campaigns/up.sql new file mode 100644 index 00000000..10024d0a --- /dev/null +++ b/migrations/6.2.4-campaigns/up.sql @@ -0,0 +1,119 @@ +-- ============================================ +-- Campaigns - Multichannel Marketing Platform +-- Version: 6.2.4 +-- ============================================ + +-- 1. Marketing Campaigns +CREATE TABLE marketing_campaigns ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL, + bot_id uuid NOT NULL, + name varchar(100) NOT NULL, + status varchar(20) DEFAULT 'draft', -- draft, scheduled, running, paused, completed + channel varchar(20) NOT NULL, -- email, whatsapp, instagram, facebook, multi + content_template jsonb DEFAULT '{}', + scheduled_at timestamptz, + sent_at timestamptz, + completed_at timestamptz, + metrics jsonb DEFAULT '{}', + budget double precision, + created_at timestamptz DEFAULT now(), + updated_at timestamptz +); + +-- 2. Marketing Lists (saved recipient lists) +CREATE TABLE marketing_lists ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL, + bot_id uuid NOT NULL, + name varchar(100) NOT NULL, + list_type varchar(20) NOT NULL, -- static, dynamic + query_text text, -- SQL filter or broadcast.bas path + contact_count integer DEFAULT 0, + created_at timestamptz DEFAULT now(), + updated_at timestamptz +); + +-- 3. Marketing List Contacts (junction for static lists) +CREATE TABLE marketing_list_contacts ( + list_id uuid REFERENCES marketing_lists(id) ON DELETE CASCADE, + contact_id uuid REFERENCES crm_contacts(id) ON DELETE CASCADE, + added_at timestamptz DEFAULT now(), + PRIMARY KEY (list_id, contact_id) +); + +-- 4. Marketing Recipients (track delivery per contact) +CREATE TABLE marketing_recipients ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id uuid REFERENCES marketing_campaigns(id) ON DELETE CASCADE, + contact_id uuid REFERENCES crm_contacts(id) ON DELETE CASCADE, + deal_id uuid REFERENCES crm_deals(id) ON DELETE SET NULL, + channel varchar(20) NOT NULL, + status varchar(20) DEFAULT 'pending', -- pending, sent, delivered, failed + sent_at timestamptz, + delivered_at timestamptz, + failed_at timestamptz, + error_message text, + response jsonb, + created_at timestamptz DEFAULT now() +); + +-- 5. Marketing Templates +CREATE TABLE marketing_templates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL, + bot_id uuid NOT NULL, + name varchar(100) NOT NULL, + channel varchar(20) NOT NULL, + subject varchar(200), + body text, + media_url varchar(500), + ai_prompt text, + variables jsonb DEFAULT '[]', + approved boolean DEFAULT false, + meta_template_id varchar(100), + created_at timestamptz DEFAULT now(), + updated_at timestamptz +); + +-- 6. Email Tracking +CREATE TABLE email_tracking ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + recipient_id uuid REFERENCES marketing_recipients(id) ON DELETE CASCADE, + campaign_id uuid REFERENCES marketing_campaigns(id) ON DELETE CASCADE, + message_id varchar(100), + open_token uuid UNIQUE, + open_tracking_enabled boolean DEFAULT true, + opened boolean DEFAULT false, + opened_at timestamptz, + clicked boolean DEFAULT false, + clicked_at timestamptz, + ip_address varchar(45), + user_agent varchar(500), + created_at timestamptz DEFAULT now() +); + +-- 7. WhatsApp Business Config +CREATE TABLE whatsapp_business ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + bot_id uuid NOT NULL UNIQUE, + phone_number_id varchar(50), + business_account_id varchar(50), + access_token varchar(500), + webhooks_verified boolean DEFAULT false, + created_at timestamptz DEFAULT now(), + updated_at timestamptz +); + +-- 8. Add deal_id column to marketing_campaigns (link campaigns to deals) +ALTER TABLE marketing_campaigns ADD COLUMN deal_id uuid REFERENCES crm_deals(id) ON DELETE SET NULL; + +-- Indexes +CREATE INDEX idx_marketing_campaigns_org_bot ON marketing_campaigns(org_id, bot_id); +CREATE INDEX idx_marketing_campaigns_status ON marketing_campaigns(status); +CREATE INDEX idx_marketing_lists_org_bot ON marketing_lists(org_id, bot_id); +CREATE INDEX idx_marketing_recipients_campaign ON marketing_recipients(campaign_id); +CREATE INDEX idx_marketing_recipients_contact ON marketing_recipients(contact_id); +CREATE INDEX idx_marketing_recipients_deal ON marketing_recipients(deal_id); +CREATE INDEX idx_email_tracking_token ON email_tracking(open_token); +CREATE INDEX idx_email_tracking_campaign ON email_tracking(campaign_id); diff --git a/migrations/6.2.5-crm-department-sla/down.sql b/migrations/6.2.5-crm-department-sla/down.sql new file mode 100644 index 00000000..fc2c2616 --- /dev/null +++ b/migrations/6.2.5-crm-department-sla/down.sql @@ -0,0 +1,15 @@ +-- ============================================ +-- Rollback: CRM v2.5 - Department + SLA Extension +-- Version: 6.2.5 +-- ============================================ + +-- Drop views +DROP VIEW IF EXISTS crm_leads_compat; +DROP VIEW IF EXISTS crm_opportunities_compat; + +-- Drop SLA tables +DROP TABLE IF EXISTS attendance_sla_events; +DROP TABLE IF EXISTS attendance_sla_policies; + +-- Remove department_id from crm_deals +ALTER TABLE crm_deals DROP COLUMN IF EXISTS department_id; diff --git a/migrations/6.2.5-crm-department-sla/up.sql b/migrations/6.2.5-crm-department-sla/up.sql new file mode 100644 index 00000000..fb1cffa9 --- /dev/null +++ b/migrations/6.2.5-crm-department-sla/up.sql @@ -0,0 +1,81 @@ +-- ============================================ +-- CRM v2.5 - Department + SLA Extension +-- Version: 6.2.5 +-- ============================================ + +-- 1. Add department_id to crm_deals (links to people_departments) +ALTER TABLE crm_deals ADD COLUMN IF NOT EXISTS department_id uuid REFERENCES people_departments(id); +CREATE INDEX IF NOT EXISTS idx_crm_deals_department ON crm_deals(department_id); + +-- 2. Create SLA Policies table +CREATE TABLE IF NOT EXISTS attendance_sla_policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL, + bot_id uuid NOT NULL, + name varchar(100) NOT NULL, + channel varchar(20), + priority varchar(20), + first_response_minutes integer DEFAULT 15, + resolution_minutes integer DEFAULT 240, + escalate_on_breach boolean DEFAULT TRUE, + is_active boolean DEFAULT TRUE, + created_at timestamptz DEFAULT now() +); + +-- 3. Create SLA Events table +CREATE TABLE IF NOT EXISTS attendance_sla_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + session_id uuid NOT NULL, + sla_policy_id uuid NOT NULL REFERENCES attendance_sla_policies(id) ON DELETE CASCADE, + event_type varchar(50) NOT NULL, + due_at timestamptz NOT NULL, + met_at timestamptz, + breached_at timestamptz, + status varchar(20) DEFAULT 'pending', + created_at timestamptz DEFAULT now() +); + +-- 4. Insert default SLA policies +INSERT INTO attendance_sla_policies (org_id, bot_id, name, channel, priority, first_response_minutes, resolution_minutes) +SELECT DISTINCT org_id, bot_id, 'Default - Urgent', NULL, 'urgent', 5, 60 +FROM bots ON CONFLICT DO NOTHING; + +INSERT INTO attendance_sla_policies (org_id, bot_id, name, channel, priority, first_response_minutes, resolution_minutes) +SELECT DISTINCT org_id, bot_id, 'Default - High', NULL, 'high', 15, 240 +FROM bots ON CONFLICT DO NOTHING; + +INSERT INTO attendance_sla_policies (org_id, bot_id, name, channel, priority, first_response_minutes, resolution_minutes) +SELECT DISTINCT org_id, bot_id, 'Default - Normal', NULL, 'normal', 30, 480 +FROM bots ON CONFLICT DO NOTHING; + +INSERT INTO attendance_sla_policies (org_id, bot_id, name, channel, priority, first_response_minutes, resolution_minutes) +SELECT DISTINCT org_id, bot_id, 'Default - Low', NULL, 'low', 60, 1440 +FROM bots ON CONFLICT DO NOTHING; + +-- 5. Create legacy compat views for leads/opportunities (from crm-sales.md) +CREATE OR REPLACE VIEW crm_leads_compat AS + SELECT id, org_id, bot_id, contact_id, account_id, + COALESCE(title, name, '') as title, description, value, currency, + stage_id, COALESCE(stage, 'new') as stage, probability, source, + expected_close_date, owner_id, lost_reason, + tags, custom_fields, created_at, updated_at, closed_at + FROM crm_deals + WHERE stage IN ('new', 'qualified') OR stage IS NULL; + +CREATE OR REPLACE VIEW crm_opportunities_compat AS + SELECT id, org_id, bot_id, lead_id, account_id, contact_id, + COALESCE(name, title, '') as name, description, value, currency, + stage_id, COALESCE(stage, 'proposal') as stage, probability, source, + expected_close_date, actual_close_date, won, owner_id, + tags, custom_fields, created_at, updated_at + FROM crm_deals + WHERE stage IN ('proposal', 'negotiation', 'won', 'lost'); + +-- 6. Create index for SLA events +CREATE INDEX IF NOT EXISTS idx_sla_events_status ON attendance_sla_events(status); +CREATE INDEX IF NOT EXISTS idx_sla_events_due ON attendance_sla_events(due_at); +CREATE INDEX IF NOT EXISTS idx_sla_events_session ON attendance_sla_events(session_id); +CREATE INDEX IF NOT EXISTS idx_sla_policies_org_bot ON attendance_sla_policies(org_id, bot_id); + +-- 7. Add lost_reason column if not exists +ALTER TABLE crm_deals ADD COLUMN IF NOT EXISTS lost_reason varchar(255); diff --git a/src/api/terminal.rs b/src/api/terminal.rs index 232c1457..8dbf251c 100644 --- a/src/api/terminal.rs +++ b/src/api/terminal.rs @@ -1,21 +1,493 @@ use axum::{ - extract::State, - response::Json, - routing::get, - Router, + extract::{ + query::Query, + State, + WebSocketUpgrade, + }, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use axum::extract::ws::{Message, WebSocket}; +use log::{error, info, warn}; +use std::{ + collections::HashMap, + process::Stdio, + sync::Arc, +}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + process::{Child, ChildStdin, Command}, + sync::{mpsc, Mutex, RwLock}, }; -use std::sync::Arc; use crate::core::shared::state::AppState; +use crate::core::urls::ApiUrls; pub fn configure_terminal_routes() -> Router> { Router::new() - .route("/api/terminal/ws", get(terminal_ws)) + .route(ApiUrls::TERMINAL_WS, get(terminal_ws)) + .route(ApiUrls::TERMINAL_LIST, get(list_terminals)) + .route(ApiUrls::TERMINAL_CREATE, post(create_terminal)) + .route(ApiUrls::TERMINAL_KILL, post(kill_terminal)) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct TerminalInfo { + pub session_id: String, + pub container_name: String, + pub status: String, + pub created_at: String, +} + +#[derive(Debug)] +pub struct TerminalSession { + pub session_id: String, + pub container_name: String, + process: Option, + stdin: Option>>, + output_tx: mpsc::Sender, +} + +#[derive(Debug, Clone)] +pub enum TerminalOutput { + Stdout(String), + Stderr(String), + System(String), +} + +impl TerminalSession { + pub fn new(session_id: &str) -> Self { + let container_name = format!( + "term-{}", + session_id.chars().take(12).collect::() + ); + + let (output_tx, _) = mpsc::channel(100); + + Self { + session_id: session_id.to_string(), + container_name, + process: None, + stdin: None, + output_tx, + } + } + + pub fn output_receiver(&self) -> mpsc::Receiver { + self.output_tx.clone().receiver() + } + + pub async fn start(&mut self) -> Result<(), String> { + if !self.container_name.chars().all(|c| c.is_alphanumeric() || c == '-') { + return Err("Invalid container name".to_string()); + } + + info!("Starting LXC container: {}", self.container_name); + + let launch_output = Command::new("lxc") + .args(["launch", "ubuntu:22.04", &self.container_name, "-e"]) + .output() + .await + .map_err(|e| format!("Failed to launch container: {}", e))?; + + if !launch_output.status.success() { + let stderr = String::from_utf8_lossy(&launch_output.stderr); + if !stderr.contains("already exists") && !stderr.contains("is already running") { + warn!("Container launch warning: {}", stderr); + } + } + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + info!("Starting bash shell in container: {}", self.container_name); + + let mut child = Command::new("lxc") + .args(["exec", &self.container_name, "--", "bash", "-l"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn bash: {}", e))?; + + let stdin = child.stdin.take().ok_or("Failed to capture stdin")?; + let stdout = child.stdout.take().ok_or("Failed to capture stdout")?; + let stderr = child.stderr.take().ok_or("Failed to capture stderr")?; + + self.stdin = Some(Arc::new(Mutex::new(stdin))); + self.process = Some(child); + + let tx = self.output_tx.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = reader.next_line().await { + if tx.send(TerminalOutput::Stdout(format!("{}\r\n", line))).await.is_err() { + break; + } + } + }); + + let tx = self.output_tx.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = reader.next_line().await { + if tx.send(TerminalOutput::Stderr(format!("{}\r\n", line))).await.is_err() { + break; + } + } + }); + + let tx = self.output_tx.clone(); + tx.send(TerminalOutput::System( + "Container started. Welcome to your isolated terminal!\r\n".to_string(), + )) + .await + .ok(); + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + self.send_command("export TERM=xterm-256color; clear").await?; + + Ok(()) + } + + pub async fn send_command(&self, cmd: &str) -> Result<(), String> { + if let Some(stdin_mutex) = &self.stdin { + let mut stdin = stdin_mutex.lock().await; + let cmd_with_newline = format!("{}\n", cmd); + stdin + .write_all(cmd_with_newline.as_bytes()) + .await + .map_err(|e| format!("Failed to write to stdin: {}", e))?; + stdin + .flush() + .await + .map_err(|e| format!("Failed to flush stdin: {}", e))?; + } + Ok(()) + } + + pub async fn resize(&self, _cols: u16, _rows: u16) -> Result<(), String> { + Ok(()) + } + + pub async fn kill(&mut self) -> Result<(), String> { + if let Some(mut child) = self.process.take() { + let _ = child.kill().await; + } + + let _ = Command::new("lxc") + .args(["stop", &self.container_name, "-f"]) + .output() + .await; + + let _ = Command::new("lxc") + .args(["delete", &self.container_name, "-f"]) + .output() + .await; + + info!("Container {} destroyed", self.container_name); + Ok(()) + } +} + +pub struct TerminalManager { + sessions: RwLock>, +} + +impl TerminalManager { + pub fn new() -> Arc { + Arc::new(Self { + sessions: RwLock::new(HashMap::new()), + }) + } + + pub async fn create_session(&self, session_id: &str) -> Result { + let mut sessions = self.sessions.write().await; + + if sessions.contains_key(session_id) { + return Err("Session already exists".to_string()); + } + + let mut session = TerminalSession::new(session_id); + session.start().await?; + + let info = TerminalInfo { + session_id: session.session_id.clone(), + container_name: session.container_name.clone(), + status: "running".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + }; + + sessions.insert(session_id.to_string(), session); + + Ok(info) + } + + pub async fn get_session(&self, session_id: &str) -> Option { + let sessions = self.sessions.read().await; + sessions.get(session_id).cloned() + } + + pub async fn kill_session(&self, session_id: &str) -> Result<(), String> { + let mut sessions = self.sessions.write().await; + if let Some(mut session) = sessions.remove(session_id) { + session.kill().await?; + } + Ok(()) + } + + pub async fn list_sessions(&self) -> Vec { + let sessions = self.sessions.read().await; + sessions + .values() + .map(|s| TerminalInfo { + session_id: s.session_id.clone(), + container_name: s.container_name.clone(), + status: "running".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + }) + .collect() + } +} + +impl Default for TerminalManager { + fn default() -> Self { + Self::new() + } +} + +#[derive(serde::Deserialize)] +pub struct TerminalQuery { + session_id: Option, } pub async fn terminal_ws( - State(_state): State>, -) -> Result, axum::http::StatusCode> { - // Note: Mock websocket connection upgrade logic - Ok(Json(serde_json::json!({ "status": "Upgrade required" }))) + ws: WebSocketUpgrade, + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let session_id = query.session_id.unwrap_or_else(|| { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Time error: {}", e)) + .unwrap_or_else(|_| std::time::Duration::ZERO) + .as_millis(); + format!("term-{}", timestamp) + }); + + info!("Terminal WebSocket connection request: {}", session_id); + + ws.on_upgrade(move |socket| handle_terminal_ws(socket, state, session_id)) +} + +async fn handle_terminal_ws( + socket: WebSocket, + state: Arc, + session_id: String, +) { + let (mut sender, mut receiver) = socket.split(); + + let terminal_manager = state.terminal_manager.clone(); + let session = match terminal_manager.create_session(&session_id).await { + Ok(info) => { + info!("Created terminal session: {:?}", info); + let welcome = serde_json::json!({ + "type": "connected", + "session_id": session_id, + "container": info.container_name, + "message": "Terminal session created" + }); + if let Ok(welcome_str) = serde_json::to_string(&welcome) { + let _ = sender.send(Message::Text(welcome_str)).await; + } + terminal_manager.get_session(&session_id).await + } + Err(e) => { + error!("Failed to create terminal session: {}", e); + let error_msg = serde_json::json!({ + "type": "error", + "message": e + }); + let _ = sender + .send(Message::Text(error_msg.to_string())) + .await; + return; + } + }; + + let Some(mut session) = session else { + error!("Failed to get session after creation"); + return; + }; + + let output_rx = session.output_receiver(); + let session_id_clone = session_id.clone(); + let terminal_manager_clone = terminal_manager.clone(); + + let mut send_task = tokio::spawn(async move { + let mut rx = output_rx; + let mut sender = sender; + + while let Some(output) = rx.recv().await { + let msg = match output { + TerminalOutput::Stdout(s) | TerminalOutput::Stderr(s) => { + Message::Text(s) + } + TerminalOutput::System(s) => { + Message::Text(serde_json::json!({ + "type": "system", + "message": s + }).to_string()) + } + }; + + if sender.send(msg).await.is_err() { + break; + } + } + }); + + let session_id_clone2 = session_id.clone(); + let terminal_manager_clone2 = terminal_manager.clone(); + let mut recv_task = tokio::spawn(async move { + while let Some(msg) = receiver.recv().await { + match msg { + Ok(Message::Text(text)) => { + if let Some(session) = terminal_manager_clone2.get_session(&session_id_clone2).await { + let trimmed = text.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed == "\\exit" || trimmed == "exit" { + let _ = terminal_manager_clone2.kill_session(&session_id_clone2).await; + break; + } + + if trimmed.starts_with("resize ") { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 3 { + if let (Ok(cols), Ok(rows)) = ( + parts[1].parse::(), + parts[2].parse::(), + ) { + let _ = session.resize(cols, rows).await; + } + } + continue; + } + + if let Err(e) = session.send_command(trimmed).await { + error!("Failed to send command: {}", e); + } + } + } + Ok(WsMessage::Close(_)) => break, + Err(e) => { + error!("WebSocket error: {}", e); + break; + } + _ => {} + } + } + }); + + tokio::select! { + _ = &mut send_task => { + warn!("Terminal send task ended"); + } + _ = &mut recv_task => { + info!("Terminal client disconnected"); + } + } + + if let Err(e) = terminal_manager.kill_session(&session_id).await { + error!("Failed to cleanup terminal session: {}", e); + } + + info!("Terminal session {} cleaned up", session_id); +} + +#[derive(serde::Deserialize)] +pub struct CreateTerminalRequest { + session_id: Option, +} + +pub async fn create_terminal( + State(state): State>, + Json(payload): Json, +) -> impl IntoResponse { + let session_id = payload.session_id.unwrap_or_else(|| { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Time error: {}", e)) + .unwrap_or_else(|_| std::time::Duration::ZERO) + .as_millis(); + format!("term-{}", timestamp) + }); + + match state.terminal_manager.create_session(&session_id).await { + Ok(info) => ( + axum::http::StatusCode::CREATED, + Json(serde_json::json!({ + "success": true, + "terminal": info + })), + ), + Err(e) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "error": e + })), + ), + } +} + +pub async fn kill_terminal( + State(state): State>, + Json(payload): Json, +) -> impl IntoResponse { + let session_id = payload + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if session_id.is_empty() { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "success": false, + "error": "session_id is required" + })), + ); + } + + match state.terminal_manager.kill_session(session_id).await { + Ok(()) => ( + axum::http::StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "message": "Terminal session killed" + })), + ), + Err(e) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "error": e + })), + ), + } +} + +pub async fn list_terminals( + State(state): State>, +) -> impl IntoResponse { + let terminals = state.terminal_manager.list_sessions().await; + Json(serde_json::json!({ + "terminals": terminals + })) } diff --git a/src/attendance/mod.rs b/src/attendance/mod.rs index 76f90470..f0bf025e 100644 --- a/src/attendance/mod.rs +++ b/src/attendance/mod.rs @@ -1,5 +1,7 @@ pub mod drive; pub mod keyword_services; +pub mod sla; +pub mod webhooks; #[cfg(feature = "llm")] pub mod llm_types; #[cfg(feature = "llm")] @@ -69,14 +71,19 @@ pub fn configure_attendance_routes() -> Router> { .route(ApiUrls::ATTENDANCE_QUEUE, get(queue::list_queue)) .route(ApiUrls::ATTENDANCE_ATTENDANTS, get(queue::list_attendants)) .route(ApiUrls::ATTENDANCE_ASSIGN, post(queue::assign_conversation)) + .route(ApiUrls::ATTENDANCE_ASSIGN_BY_SKILL, post(queue::assign_by_skill)) .route( ApiUrls::ATTENDANCE_TRANSFER, post(queue::transfer_conversation), ) .route(ApiUrls::ATTENDANCE_RESOLVE, post(queue::resolve_conversation)) .route(ApiUrls::ATTENDANCE_INSIGHTS, get(queue::get_insights)) + .route(ApiUrls::ATTENDANCE_KANBAN, get(queue::get_kanban)) .route(ApiUrls::ATTENDANCE_RESPOND, post(attendant_respond)) - .route(ApiUrls::WS_ATTENDANT, get(attendant_websocket_handler)); + .route(ApiUrls::WS_ATTENDANT, get(attendant_websocket_handler)) + .route("/api/attendance/webhooks", get(webhooks::list_webhooks).post(webhooks::create_webhook)) + .route("/api/attendance/webhooks/:id", get(webhooks::get_webhook).put(webhooks::update_webhook).delete(webhooks::delete_webhook)) + .route("/api/attendance/webhooks/:id/test", post(webhooks::test_webhook)); #[cfg(feature = "llm")] let router = router diff --git a/src/attendance/queue.rs b/src/attendance/queue.rs index 4f5727d0..e2f04442 100644 --- a/src/attendance/queue.rs +++ b/src/attendance/queue.rs @@ -461,14 +461,16 @@ pub async fn assign_conversation( move || { let mut db_conn = conn .get() - .map_err(|e| format!("Failed to get database connection: {}", e))?; + .map_err(|e| format!("Failed to get database connection: {e}"))?; use crate::core::shared::models::schema::user_sessions; let session: UserSession = user_sessions::table .filter(user_sessions::id.eq(session_id)) .first(&mut db_conn) - .map_err(|e| format!("Session not found: {}", e))?; + .map_err(|e| format!("Session not found: {e}"))?; + + let bot_id = session.bot_id; let mut ctx = session.context_data; ctx["assigned_to"] = serde_json::json!(attendant_id.to_string()); @@ -478,7 +480,20 @@ pub async fn assign_conversation( diesel::update(user_sessions::table.filter(user_sessions::id.eq(session_id))) .set(user_sessions::context_data.eq(&ctx)) .execute(&mut db_conn) - .map_err(|e| format!("Failed to update session: {}", e))?; + .map_err(|e| format!("Failed to update session: {e}"))?; + + let webhook_data = serde_json::json!({ + "session_id": session_id, + "attendant_id": attendant_id, + "assigned_at": Utc::now().to_rfc3339() + }); + + crate::attendance::webhooks::emit_webhook_event( + &mut db_conn, + bot_id, + "session.assigned", + webhook_data, + ); Ok::<(), String>(()) } @@ -518,6 +533,194 @@ pub async fn assign_conversation( } } +#[derive(Debug, Deserialize)] +pub struct SkillBasedAssignRequest { + pub session_id: Uuid, + pub required_skills: Vec, + pub channel: Option, +} + +pub async fn assign_by_skill( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + info!( + "Skill-based assignment for session {} with skills {:?}", + request.session_id, request.required_skills + ); + + let result = tokio::task::spawn_blocking({ + let conn = state.conn.clone(); + let session_id = request.session_id; + let required_skills = request.required_skills.clone(); + let channel_filter = request.channel.clone(); + + move || { + let mut db_conn = conn + .get() + .map_err(|e| format!("Failed to get database connection: {e}"))?; + + use crate::core::shared::models::schema::user_sessions; + + let session: UserSession = user_sessions::table + .filter(user_sessions::id.eq(session_id)) + .first(&mut db_conn) + .map_err(|e| format!("Session not found: {e}"))?; + + let bot_id = session.bot_id; + + let csv_path = format!("/opt/gbo/data/{}/attendants.csv", bot_id); + let mut best_attendant: Option = None; + let mut best_score: i32 = -1; + + if let Ok(contents) = std::fs::read_to_string(&csv_path) { + for line in contents.lines().skip(1) { + let fields: Vec<&str> = line.split(',').collect(); + if fields.len() < 3 { + continue; + } + + let attendant = AttendantCSV { + id: fields[0].to_string(), + name: fields[1].to_string(), + channel: fields.get(2).cloned().unwrap_or("").to_string(), + preferences: fields.get(3).cloned().unwrap_or("").to_string(), + department: fields.get(4).cloned().map(String::from), + aliases: fields.get(5).cloned().map(String::from), + phone: fields.get(6).cloned().map(String::from), + email: fields.get(7).cloned().map(String::from), + teams: fields.get(8).cloned().map(String::from), + google: fields.get(9).cloned().map(String::from), + }; + + if let Some(ref ch) = channel_filter { + if attendant.channel != *ch && !attendant.channel.is_empty() { + continue; + } + } + + let prefs = attendant.preferences.to_lowercase(); + let mut score = 0; + + for skill in &required_skills { + if prefs.contains(&skill.to_lowercase()) { + score += 10; + } + } + + if prefs.contains("general") || prefs.is_empty() { + score += 1; + } + + if score > best_score { + best_score = score; + best_attendant = Some(attendant); + } + } + } + + if let Some(attendant) = best_attendant { + if let Ok(attendant_uuid) = Uuid::parse_str(&attendant.id) { + let mut ctx = session.context_data; + ctx["assigned_to"] = serde_json::json!(attendant.id.clone()); + ctx["assigned_to_name"] = serde_json::json!(attendant.name.clone()); + ctx["assigned_at"] = serde_json::json!(Utc::now().to_rfc3339()); + ctx["status"] = serde_json::json!("assigned"); + ctx["assignment_reason"] = serde_json::json!("skill_based"); + + diesel::update(user_sessions::table.filter(user_sessions::id.eq(session_id))) + .set(user_sessions::context_data.eq(&ctx)) + .execute(&mut db_conn) + .map_err(|e| format!("Failed to update session: {e}"))?; + + return Ok::<(), String>(Some(attendant)); + } + } + + Ok::, String>(None) + } + }) + .await; + + match result { + Ok(Ok(Some(attendant))) => ( + StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "session_id": request.session_id, + "assigned_to": attendant.id, + "assigned_to_name": attendant.name, + "assignment_type": "skill_based", + "assigned_at": Utc::now().to_rfc3339() + })), + ), + Ok(Ok(None)) => ( + StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "session_id": request.session_id, + "message": "No attendant found with matching skills", + "assignment_type": "skill_based" + })), + ), + Ok(Err(e)) => { + error!("Skill-based assignment error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "error": e + })), + ) + } + Err(e) => { + error!("Skill-based assignment error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "error": format!("{:?}", e) + })), + ) + } + } +} + }) + .await; + + match result { + Ok(Ok(())) => ( + StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "session_id": request.session_id, + "attendant_id": request.attendant_id, + "assigned_at": Utc::now().to_rfc3339() + })), + ), + Ok(Err(e)) => { + error!("Assignment error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "error": e + })), + ) + } + Err(e) => { + error!("Assignment error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "error": format!("{:?}", e) + })), + ) + } + } +} + pub async fn transfer_conversation( State(state): State>, Json(request): Json, @@ -557,7 +760,22 @@ pub async fn transfer_conversation( user_sessions::updated_at.eq(Utc::now()), )) .execute(&mut db_conn) - .map_err(|e| format!("Failed to update session: {}", e))?; + .map_err(|e| format!("Failed to update session: {e}"))?; + + let webhook_data = serde_json::json!({ + "session_id": session_id, + "from_attendant": request.from_attendant_id, + "to_attendant": to_attendant, + "reason": reason, + "transferred_at": Utc::now().to_rfc3339() + }); + + crate::attendance::webhooks::emit_webhook_event( + &mut db_conn, + session.bot_id, + "session.transferred", + webhook_data, + ); Ok::<(), String>(()) } @@ -636,7 +854,19 @@ pub async fn resolve_conversation( user_sessions::updated_at.eq(Utc::now()), )) .execute(&mut db_conn) - .map_err(|e| format!("Failed to update session: {}", e))?; + .map_err(|e| format!("Failed to update session: {e}"))?; + + let webhook_data = serde_json::json!({ + "session_id": session_id, + "resolved_at": Utc::now().to_rfc3339() + }); + + crate::attendance::webhooks::emit_webhook_event( + &mut db_conn, + session.bot_id, + "session.resolved", + webhook_data, + ); Ok::<(), String>(()) } @@ -760,3 +990,177 @@ pub async fn get_insights( } } } + +#[derive(Debug, Serialize)] +pub struct KanbanColumn { + pub id: String, + pub title: String, + pub items: Vec, +} + +#[derive(Debug, Serialize)] +pub struct KanbanBoard { + pub columns: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct KanbanQuery { + pub bot_id: Option, + pub channel: Option, +} + +pub async fn get_kanban( + State(state): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let bot_id = query.bot_id.unwrap_or_else(|| { + let (id, _) = crate::core::bot::get_default_bot(&mut conn); + id + }); + + use crate::core::shared::models::schema::user_sessions::dsl::*; + + let sessions: Vec = user_sessions + .filter(bot_id.eq(bot_id)) + .filter(context_data.contains("status")) + .load(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; + + let mut new_items = Vec::new(); + let mut waiting_items = Vec::new(); + let mut active_items = Vec::new(); + let mut pending_customer_items = Vec::new(); + let mut resolved_items = Vec::new(); + + for session in sessions { + let status = session + .context_data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("new") + .to_string(); + + let assigned_to = session.attendant_id; + let assigned_to_name = session + .context_data + .get("attendant_name") + .and_then(|v| v.as_str()) + .map(String::from); + + let last_message = session + .context_data + .get("last_message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let last_message_time = session + .context_data + .get("last_message_time") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| session.created_at.to_rfc3339()); + + let waiting_time = Utc::now() + .signed_duration_since(session.created_at) + .num_seconds(); + + let priority = session + .context_data + .get("priority") + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + + let channel = session + .context_data + .get("channel") + .and_then(|v| v.as_str()) + .unwrap_or("web") + .to_string(); + + let user_name = session + .context_data + .get("user_name") + .and_then(|v| v.as_str()) + .unwrap_or("Anonymous") + .to_string(); + + let user_email = session + .context_data + .get("user_email") + .and_then(|v| v.as_str()) + .map(String::from); + + let item = QueueItem { + session_id: session.id, + user_id: session.user_id, + bot_id: session.bot_id, + channel, + user_name, + user_email, + last_message, + last_message_time, + waiting_time_seconds: waiting_time, + priority, + status: match status.as_str() { + "waiting" => QueueStatus::Waiting, + "assigned" => QueueStatus::Assigned, + "active" => QueueStatus::Active, + "resolved" => QueueStatus::Resolved, + "abandoned" => QueueStatus::Abandoned, + _ => QueueStatus::Waiting, + }, + assigned_to, + assigned_to_name, + }; + + if let Some(ref ch) = query.channel { + if item.channel != *ch { + continue; + } + } + + match status.as_str() { + "new" | "waiting" => new_items.push(item), + "pending_customer" => pending_customer_items.push(item), + "assigned" => waiting_items.push(item), + "active" => active_items.push(item), + "resolved" | "closed" | "abandoned" => resolved_items.push(item), + _ => new_items.push(item), + } + } + + let columns = vec![ + KanbanColumn { + id: "new".to_string(), + title: "New".to_string(), + items: new_items, + }, + KanbanColumn { + id: "waiting".to_string(), + title: "Waiting".to_string(), + items: waiting_items, + }, + KanbanColumn { + id: "active".to_string(), + title: "Active".to_string(), + items: active_items, + }, + KanbanColumn { + id: "pending_customer".to_string(), + title: "Pending Customer".to_string(), + items: pending_customer_items, + }, + KanbanColumn { + id: "resolved".to_string(), + title: "Resolved".to_string(), + items: resolved_items, + }, + ]; + + Ok(Json(KanbanBoard { columns })) +} diff --git a/src/attendance/sla.rs b/src/attendance/sla.rs new file mode 100644 index 00000000..679da008 --- /dev/null +++ b/src/attendance/sla.rs @@ -0,0 +1,144 @@ +use crate::core::shared::schema::people::attendance_sla_events; +use crate::core::shared::schema::people::attendance_sla_policies; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use log::{error, info}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::time::{interval, Duration}; +use uuid::Uuid; + +pub struct AppState { + pub conn: diesel_async::Pool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_sla_policy_request_default_values() { + let req = CreateSlaPolicyRequest { + name: "Test Policy".to_string(), + channel: None, + priority: Some("high".to_string()), + first_response_minutes: Some(15), + resolution_minutes: Some(240), + escalate_on_breach: Some(true), + }; + + assert_eq!(req.name, "Test Policy"); + assert_eq!(req.priority, Some("high".to_string())); + assert_eq!(req.first_response_minutes, Some(15)); + } + + #[test] + fn test_create_sla_event_request() { + let req = CreateSlaEventRequest { + session_id: Uuid::new_v4(), + sla_policy_id: Uuid::new_v4(), + event_type: "first_response".to_string(), + due_at: chrono::Utc::now(), + }; + + assert_eq!(req.event_type, "first_response"); + } +} + +pub async fn start_sla_breach_monitor(state: Arc) { + let mut interval_timer = interval(Duration::from_secs(30)); + + info!("Starting SLA breach monitor"); + + loop { + interval_timer.tick().await; + + if let Err(e) = check_sla_breaches(&state).await { + error!("SLA breach check failed: {}", e); + } + } +} + +async fn check_sla_breaches(state: &Arc) -> Result<(), String> { + let mut conn = state.conn.get().await.map_err(|e| format!("DB pool error: {e}"))?; + + let pending_events = attendance_sla_events::table + .filter(attendance_sla_events::status.eq("pending")) + .filter(attendance_sla_events::due_at.le(diesel::dsl::now)) + .load::<(Uuid, String, chrono::DateTime)>(&mut conn) + .await + .map_err(|e| format!("Query error: {e}"))?; + + if !pending_events.is_empty() { + info!("Found {} SLA breaches to process", pending_events.len()); + } + + for (event_id, session_id, due_at) in pending_events { + let breached_at = chrono::Utc::now(); + + diesel::update(attendance_sla_events::table.filter(attendance_sla_events::id.eq(event_id))) + .set(( + attendance_sla_events::status.eq("breached"), + attendance_sla_events::breached_at.eq(Some(breached_at)), + )) + .execute(&mut conn) + .await + .map_err(|e| format!("Update error: {e}"))?; + + info!("SLA breached for session {} (event {})", session_id, event_id); + + let webhook_data = serde_json::json!({ + "event_id": event_id, + "session_id": session_id, + "due_at": due_at, + "breached_at": breached_at + }); + + if let Ok(mut db_conn) = state.conn.get().await { + crate::attendance::webhooks::emit_webhook_event( + &mut db_conn, + uuid::Uuid::nil(), + "sla.breached", + webhook_data, + ); + } + } + + Ok(()) +} + + for (event_id, session_id, due_at) in pending_events { + let breached_at = chrono::Utc::now(); + + diesel::update(attendance_sla_events::table.filter(attendance_sla_events::id.eq(event_id))) + .set(( + attendance_sla_events::status.eq("breached"), + attendance_sla_events::breached_at.eq(Some(breached_at)), + )) + .execute(&mut conn) + .await + .map_err(|e| format!("Update error: {}", e))?; + + info!("SLA breached for session {} (event {})", session_id, event_id); + } + + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateSlaPolicyRequest { + pub name: String, + pub channel: Option, + pub priority: Option, + pub first_response_minutes: Option, + pub resolution_minutes: Option, + pub escalate_on_breach: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateSlaEventRequest { + pub session_id: Uuid, + pub sla_policy_id: Uuid, + pub event_type: String, + pub due_at: chrono::DateTime, +} diff --git a/src/attendance/webhooks.rs b/src/attendance/webhooks.rs new file mode 100644 index 00000000..d1fa1a3b --- /dev/null +++ b/src/attendance/webhooks.rs @@ -0,0 +1,329 @@ +use axum::{extract::State, http::StatusCode, Json}; +use chrono::Utc; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::core::shared::schema::attendance_webhooks; +use crate::core::shared::state::AppState; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttendanceWebhook { + pub id: Uuid, + pub org_id: Uuid, + pub bot_id: Uuid, + pub webhook_url: String, + pub events: Vec, + pub is_active: bool, + pub secret_key: Option, + pub created_at: chrono::DateTime, + pub updated_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateWebhookRequest { + pub webhook_url: String, + pub events: Vec, + pub secret_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateWebhookRequest { + pub webhook_url: Option, + pub events: Option>, + pub is_active: Option, + pub secret_key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookPayload { + pub event: String, + pub timestamp: String, + pub bot_id: Uuid, + pub data: serde_json::Value, +} + +fn get_bot_context(state: &AppState) -> (Uuid, Uuid) { + use diesel::prelude::*; + use crate::core::shared::schema::bots; + + let Ok(mut conn) = state.conn.get() else { + return (Uuid::nil(), Uuid::nil()); + }; + let (bot_id, _bot_name) = crate::core::bot::get_default_bot(&mut conn); + + let org_id = bots::table + .filter(bots::id.eq(bot_id)) + .select(bots::org_id) + .first::>(&mut conn) + .unwrap_or(None) + .unwrap_or(Uuid::nil()); + + (org_id, bot_id) +} + +pub async fn list_webhooks( + State(state): State>, +) -> Result>, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let (org_id, bot_id) = get_bot_context(&state); + + let webhooks: Vec = attendance_webhooks::table + .filter(attendance_webhooks::org_id.eq(org_id)) + .filter(attendance_webhooks::bot_id.eq(bot_id)) + .order(attendance_webhooks::created_at.desc()) + .load(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; + + Ok(Json(webhooks)) +} + +pub async fn create_webhook( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let (org_id, bot_id) = get_bot_context(&state); + let id = Uuid::new_v4(); + let now = Utc::now(); + + let webhook = AttendanceWebhook { + id, + org_id, + bot_id, + webhook_url: req.webhook_url, + events: req.events, + is_active: true, + secret_key: req.secret_key, + created_at: now, + updated_at: Some(now), + }; + + diesel::insert_into(attendance_webhooks::table) + .values(&webhook) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; + + Ok(Json(webhook)) +} + +pub async fn get_webhook( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let webhook: AttendanceWebhook = attendance_webhooks::table + .filter(attendance_webhooks::id.eq(id)) + .first(&mut conn) + .map_err(|_| (StatusCode::NOT_FOUND, "Webhook not found".to_string()))?; + + Ok(Json(webhook)) +} + +pub async fn update_webhook( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let now = Utc::now(); + + if let Some(webhook_url) = req.webhook_url { + diesel::update(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id))) + .set(attendance_webhooks::webhook_url.eq(webhook_url)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(events) = req.events { + diesel::update(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id))) + .set(attendance_webhooks::events.eq(events)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(is_active) = req.is_active { + diesel::update(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id))) + .set(attendance_webhooks::is_active.eq(is_active)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(secret_key) = req.secret_key { + diesel::update(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id))) + .set(attendance_webhooks::secret_key.eq(Some(secret_key))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + diesel::update(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id))) + .set(attendance_webhooks::updated_at.eq(Some(now))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + + get_webhook(State(state), Path(id)).await +} + +pub async fn delete_webhook( + State(state): State>, + Path(id): Path, +) -> Result { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + diesel::delete(attendance_webhooks::table.filter(attendance_webhooks::id.eq(id))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn test_webhook( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let webhook: AttendanceWebhook = attendance_webhooks::table + .filter(attendance_webhooks::id.eq(id)) + .first(&mut conn) + .map_err(|_| (StatusCode::NOT_FOUND, "Webhook not found".to_string()))?; + + let payload = WebhookPayload { + event: "test".to_string(), + timestamp: Utc::now().to_rfc3339(), + bot_id: webhook.bot_id, + data: serde_json::json!({ "message": "This is a test webhook" }), + }; + + let payload_json = serde_json::to_string(&payload).map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("Serialization error: {e}")) + })?; + + let client = reqwest::Client::new(); + let mut request = client.post(&webhook.webhook_url); + + if let Some(ref secret) = webhook.secret_key { + use std::time::Duration; + + let signature = calculate_hmac_signature(secret, &payload_json); + request = request.header("X-Webhook-Signature", signature); + } + + request = request + .header("Content-Type", "application/json") + .timeout(Duration::from_secs(10)) + .body(payload_json); + + match request.send().await { + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + Ok(Json(serde_json::json!({ + "success": status.is_success(), + "status_code": status.as_u16(), + "response": body + }))) + } + Err(e) => { + log::error!("Webhook test failed: {}", e); + Ok(Json(serde_json::json!({ + "success": false, + "error": e.to_string() + }))) + } + } +} + +fn calculate_hmac_signature(secret: &str, payload: &str) -> String { + use std::io::Write; + + let mut mac = hmac_sha256::HMAC::new(secret.as_bytes()); + mac.write_all(payload.as_bytes()).unwrap(); + format!("{:x}", mac.finalize()) +} + +pub fn emit_webhook_event( + conn: &mut PgConnection, + bot_id: Uuid, + event: &str, + data: serde_json::Value, +) { + use crate::core::shared::schema::attendance_webhooks::dsl::*; + + let webhooks: Vec<(Uuid, String, Vec, Option)> = attendance_webhooks + .filter(attendance_webhooks::bot_id.eq(bot_id)) + .filter(attendance_webhooks::is_active.eq(true)) + .select((id, webhook_url, events, secret_key)) + .load(conn) + .unwrap_or_default(); + + for (webhook_id, webhook_url, events, secret) in webhooks { + if !events.contains(&event.to_string()) { + continue; + } + + let payload = WebhookPayload { + event: event.to_string(), + timestamp: Utc::now().to_rfc3339(), + bot_id, + data: data.clone(), + }; + + let payload_json = serde_json::to_string(&payload).unwrap_or_default(); + + let mut request = reqwest::Client::new() + .post(&webhook_url) + .header("Content-Type", "application/json") + .timeout(std::time::Duration::from_secs(5)) + .body(payload_json.clone()); + + if let Some(ref secret_key) = secret { + let signature = calculate_hmac_signature(secret_key, &payload_json); + request = request.header("X-Webhook-Signature", signature); + } + + let webhook_url_clone = webhook_url.clone(); + + tokio::spawn(async move { + if let Err(e) = request.send().await { + log::error!("Failed to emit webhook {}: {}", webhook_url_clone, e); + } else { + log::info!("Webhook emitted successfully: {} event={}", webhook_url_clone, event); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hmac_signature_generation() { + let secret = "test-secret"; + let payload = r#"{"event":"test","data":{}}"#; + + let signature = calculate_hmac_signature(secret, payload); + + assert!(!signature.is_empty()); + assert_eq!(signature.len(), 64); + } +} diff --git a/src/basic/compiler/mod.rs b/src/basic/compiler/mod.rs index a9b2ad4f..5b0de2fb 100644 --- a/src/basic/compiler/mod.rs +++ b/src/basic/compiler/mod.rs @@ -437,7 +437,6 @@ impl BasicCompiler { }; let source = source.as_str(); let mut has_schedule = false; - let mut _has_webhook = false; let script_name = Path::new(source_path) .file_stem() .and_then(|s| s.to_str()) @@ -482,9 +481,7 @@ impl BasicCompiler { if parts.len() >= 3 { #[cfg(feature = "tasks")] { - #[allow(unused_variables, unused_mut)] let cron = parts[1]; - #[allow(unused_variables, unused_mut)] let mut conn = self .state .conn @@ -506,7 +503,6 @@ impl BasicCompiler { } if normalized.starts_with("WEBHOOK") { - _has_webhook = true; let parts: Vec<&str> = normalized.split('"').collect(); if parts.len() >= 2 { let endpoint = parts[1]; diff --git a/src/basic/mod.rs b/src/basic/mod.rs index 3897395e..635c06fe 100644 --- a/src/basic/mod.rs +++ b/src/basic/mod.rs @@ -316,7 +316,7 @@ impl ScriptService { } } } - fn preprocess_basic_script(&self, script: &str) -> String { + fn preprocess_basic_script(&self, script: &str) -> Result { let _ = self; // silence unused self warning - kept for API consistency let script = preprocess_switch(script); @@ -346,10 +346,9 @@ impl ScriptService { } if trimmed.starts_with("NEXT") { if let Some(expected_indent) = for_stack.pop() { - assert!( - (current_indent - 4) == expected_indent, - "NEXT without matching FOR EACH" - ); + if (current_indent - 4) != expected_indent { + return Err("NEXT without matching FOR EACH (indentation mismatch)".to_string()); + } current_indent -= 4; result.push_str(&" ".repeat(current_indent)); result.push_str("}\n"); @@ -360,7 +359,7 @@ impl ScriptService { continue; } log::error!("NEXT without matching FOR EACH"); - return result; + return Err("NEXT without matching FOR EACH".to_string()); } if trimmed == "EXIT FOR" { result.push_str(&" ".repeat(current_indent)); @@ -555,11 +554,16 @@ impl ScriptService { } result.push('\n'); } - assert!(for_stack.is_empty(), "Unclosed FOR EACH loop"); - result + if !for_stack.is_empty() { + return Err("Unclosed FOR EACH loop".to_string()); + } + Ok(result) } pub fn compile(&self, script: &str) -> Result> { - let processed_script = self.preprocess_basic_script(script); + let processed_script = match self.preprocess_basic_script(script) { + Ok(s) => s, + Err(e) => return Err(Box::new(EvalAltResult::ErrorRuntime(Dynamic::from(e), rhai::Position::NONE))), + }; trace!("Processed Script:\n{}", processed_script); match self.engine.compile(&processed_script) { Ok(ast) => Ok(ast), @@ -711,73 +715,7 @@ impl ScriptService { Ok(()) } - /// Convert FORMAT(expr, pattern) to FORMAT expr pattern (custom syntax format) - /// Also handles RANDOM and other functions that need space-separated arguments - /// This properly handles nested function calls by counting parentheses - #[allow(dead_code)] - fn convert_format_syntax(script: &str) -> String { - let mut result = String::new(); - let mut chars = script.chars().peekable(); - let mut i = 0; - let bytes = script.as_bytes(); - while i < bytes.len() { - // Check if this is the start of FORMAT( - if i + 6 <= bytes.len() - && bytes[i..i+6].eq_ignore_ascii_case(b"FORMAT") - && i + 7 < bytes.len() - && bytes[i + 6] == b'(' - { - // Found FORMAT( - now parse the arguments - let mut paren_depth = 1; - let mut j = i + 7; // Start after FORMAT( - let mut comma_pos = None; - - // Find the arguments by tracking parentheses - while j < bytes.len() && paren_depth > 0 { - match bytes[j] { - b'(' => paren_depth += 1, - b')' => { - paren_depth -= 1; - if paren_depth == 0 { - break; - } - } - b',' => { - if paren_depth == 1 { - // This is the comma separating FORMAT's arguments - comma_pos = Some(j); - } - } - _ => {} - } - j += 1; - } - - if let Some(comma) = comma_pos { - // Extract the two arguments - let expr = &script[i + 7..comma].trim(); - let pattern = &script[comma + 1..j].trim(); - - // Convert to Rhai space-separated syntax - // Remove quotes from pattern if present, then add them back in the right format - let pattern_clean = pattern.trim_matches('"').trim_matches('\''); - result.push_str(&format!("FORMAT ({expr}) (\"{pattern_clean}\")")); - - i = j + 1; - continue; - } - } - - // Copy the character as-is - if let Some(c) = chars.next() { - result.push(c); - } - i += 1; - } - - result - } /// Convert a single TALK line with ${variable} substitution to proper TALK syntax /// Handles: "Hello ${name}" β†’ TALK "Hello " + name diff --git a/src/contacts/contacts_api/types.rs b/src/contacts/contacts_api/types.rs index 1806c7ae..28b9662e 100644 --- a/src/contacts/contacts_api/types.rs +++ b/src/contacts/contacts_api/types.rs @@ -36,9 +36,10 @@ pub struct Contact { pub updated_at: DateTime, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum ContactStatus { + #[default] Active, Inactive, Lead, @@ -60,12 +61,6 @@ impl std::fmt::Display for ContactStatus { } } -impl Default for ContactStatus { - fn default() -> Self { - Self::Active - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ContactSource { diff --git a/src/contacts/crm.rs b/src/contacts/crm.rs index 5d5e8803..a2da0192 100644 --- a/src/contacts/crm.rs +++ b/src/contacts/crm.rs @@ -14,9 +14,10 @@ use uuid::Uuid; use crate::core::bot::get_default_bot; use crate::core::shared::schema::{ - crm_accounts, crm_activities, crm_contacts, crm_leads, crm_notes, crm_opportunities, - crm_pipeline_stages, + crm_accounts, crm_activities, crm_contacts, crm_deals, crm_leads, + crm_notes, crm_opportunities, crm_pipeline_stages, }; +use crate::core::shared::schema::marketing_campaigns; use crate::core::shared::state::AppState; #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] @@ -90,6 +91,42 @@ pub struct CrmPipelineStage { pub created_at: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] +#[diesel(table_name = crm_deals)] +pub struct CrmDeal { + pub id: Uuid, + pub org_id: Uuid, + pub bot_id: Uuid, + pub contact_id: Option, + pub account_id: Option, + pub am_id: Option, + pub owner_id: Option, + pub lead_id: Option, + pub title: Option, + pub name: Option, + pub description: Option, + pub value: Option, + pub currency: Option, + pub stage_id: Option, + pub stage: Option, + pub probability: i32, + pub source: Option, + pub segment_id: Option, + pub department_id: Option, + pub expected_close_date: Option, + pub actual_close_date: Option, + pub period: Option, + pub deal_date: Option, + pub closed_at: Option>, + pub lost_reason: Option, + pub won: Option, + pub notes: Option, + pub tags: Vec, + pub created_at: DateTime, + pub updated_at: Option>, + pub custom_fields: serde_json::Value, +} + #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] #[diesel(table_name = crm_leads)] pub struct CrmLead { @@ -279,6 +316,46 @@ pub struct CloseOpportunityRequest { pub actual_close_date: Option, } +#[derive(Debug, Deserialize)] +pub struct CreateDealRequest { + pub title: Option, + pub name: Option, + pub contact_id: Option, + pub account_id: Option, + pub owner_id: Option, + pub department_id: Option, + pub value: Option, + pub currency: Option, + pub stage: Option, + pub probability: Option, + pub source: Option, + pub expected_close_date: Option, + pub description: Option, + pub notes: Option, + pub tags: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateDealRequest { + pub title: Option, + pub name: Option, + pub contact_id: Option, + pub account_id: Option, + pub owner_id: Option, + pub department_id: Option, + pub value: Option, + pub currency: Option, + pub stage: Option, + pub probability: Option, + pub source: Option, + pub expected_close_date: Option, + pub description: Option, + pub lost_reason: Option, + pub won: Option, + pub notes: Option, + pub tags: Option>, +} + #[derive(Debug, Deserialize)] pub struct CreateActivityRequest { pub activity_type: String, @@ -297,6 +374,8 @@ pub struct ListQuery { pub stage: Option, pub status: Option, pub owner_id: Option, + pub department_id: Option, + pub source: Option, pub limit: Option, pub offset: Option, } @@ -319,12 +398,53 @@ pub struct StageStats { pub value: f64, } +#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] +#[diesel(table_name = marketing_campaigns)] +pub struct CrmCampaign { + pub id: Uuid, + pub org_id: Uuid, + pub bot_id: Uuid, + pub deal_id: Option, + pub name: String, + pub status: String, + pub channel: String, + pub content_template: serde_json::Value, + pub scheduled_at: Option>, + pub sent_at: Option>, + pub completed_at: Option>, + pub metrics: serde_json::Value, + pub budget: Option, + pub created_at: DateTime, + pub updated_at: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct CreateCampaignRequest { + pub name: String, + pub channel: String, + pub deal_id: Option, + pub content_template: Option, + pub scheduled_at: Option, + pub budget: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateCampaignRequest { + pub name: Option, + pub status: Option, + pub channel: Option, + pub content_template: Option, + pub scheduled_at: Option, + pub budget: Option, +} + #[derive(Debug, Serialize)] pub struct CrmStats { pub total_contacts: i64, pub total_accounts: i64, pub total_leads: i64, pub total_opportunities: i64, + pub total_campaigns: i64, pub pipeline_value: f64, pub won_this_month: i64, pub conversion_rate: f64, @@ -394,6 +514,13 @@ pub async fn create_contact( .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; + crate::marketing::triggers::trigger_contact_change( + &mut conn, + id, + "created", + bot_id, + ); + Ok(Json(contact)) } @@ -651,7 +778,7 @@ pub struct CreateLeadForm { pub async fn create_lead_form( State(state): State>, Json(req): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { log::info!("create_lead_form JSON: {:?}", req); let mut conn = state.conn.get().map_err(|e| { @@ -682,33 +809,43 @@ pub async fn create_lead_form( // TODO: Fix by either adding bot to organizations or making org_id nullable let contact_id: Option = None; - let value = req.value.map(|v| v); + let value = req.value; - let lead = CrmLead { + let lead = CrmDeal { id, org_id: effective_org_id, bot_id, contact_id, account_id: None, - title, + am_id: None, + lead_id: None, + title: Some(title), + name: None, description: req.description, value, currency: Some("USD".to_string()), stage_id: None, - stage: "new".to_string(), + stage: Some("new".to_string()), probability: 10, source: req.source.clone(), + segment_id: None, + department_id: None, expected_close_date: None, + actual_close_date: None, + period: None, + deal_date: None, owner_id: None, lost_reason: None, + won: None, tags: vec![], custom_fields: serde_json::json!({}), created_at: now, - updated_at: now, + updated_at: Some(now), closed_at: None, + notes: None, }; - diesel::insert_into(crm_leads::table) + diesel::insert_into(crm_deals::table) .values(&lead) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert lead error: {e}")))?; @@ -719,7 +856,7 @@ pub async fn create_lead_form( pub async fn create_lead( State(state): State>, Json(req): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; @@ -731,33 +868,43 @@ pub async fn create_lead( let expected_close = req.expected_close_date .and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()); - let value = req.value.map(|v| v); + let value = req.value; - let lead = CrmLead { + let lead = CrmDeal { id, org_id, bot_id, contact_id: req.contact_id, account_id: req.account_id, - title: req.title, + am_id: None, + lead_id: None, + title: Some(req.title), + name: None, description: req.description, value, currency: req.currency.or(Some("USD".to_string())), stage_id: None, - stage: "new".to_string(), + stage: Some("new".to_string()), probability: 10, source: req.source, + segment_id: None, + department_id: None, expected_close_date: expected_close, + actual_close_date: None, + period: None, + deal_date: None, owner_id: None, lost_reason: None, + won: None, tags: vec![], custom_fields: serde_json::json!({}), created_at: now, - updated_at: now, + updated_at: Some(now), closed_at: None, + notes: None, }; - diesel::insert_into(crm_leads::table) + diesel::insert_into(crm_deals::table) .values(&lead) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; @@ -768,7 +915,7 @@ pub async fn create_lead( pub async fn list_leads( State(state): State>, Query(query): Query, -) -> Result>, (StatusCode, String)> { +) -> Result>, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; @@ -777,22 +924,30 @@ pub async fn list_leads( let limit = query.limit.unwrap_or(50); let offset = query.offset.unwrap_or(0); - let mut q = crm_leads::table - .filter(crm_leads::org_id.eq(org_id)) - .filter(crm_leads::bot_id.eq(bot_id)) + let mut q = crm_deals::table + .filter(crm_deals::org_id.eq(org_id)) + .filter(crm_deals::bot_id.eq(bot_id)) .into_boxed(); if let Some(stage) = query.stage { - q = q.filter(crm_leads::stage.eq(stage)); + q = q.filter(crm_deals::stage.eq(stage)); } if let Some(search) = query.search { let pattern = format!("%{search}%"); - q = q.filter(crm_leads::title.ilike(pattern)); + q = q.filter(crm_deals::title.ilike(pattern)); } - let leads: Vec = q - .order(crm_leads::created_at.desc()) + if let Some(department_id) = query.department_id { + q = q.filter(crm_deals::department_id.eq(department_id)); + } + + if let Some(source) = query.source { + q = q.filter(crm_deals::source.eq(source)); + } + + let leads: Vec = q + .order(crm_deals::created_at.desc()) .limit(limit) .offset(offset) .load(&mut conn) @@ -804,13 +959,13 @@ pub async fn list_leads( pub async fn get_lead( State(state): State>, Path(id): Path, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; - let lead: CrmLead = crm_leads::table - .filter(crm_leads::id.eq(id)) + let lead: CrmDeal = crm_deals::table + .filter(crm_deals::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Lead not found".to_string()))?; @@ -821,21 +976,21 @@ pub async fn update_lead( State(state): State>, Path(id): Path, Json(req): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let now = Utc::now(); - diesel::update(crm_leads::table.filter(crm_leads::id.eq(id))) - .set(crm_leads::updated_at.eq(now)) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::updated_at.eq(now)) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; if let Some(title) = req.title { - diesel::update(crm_leads::table.filter(crm_leads::id.eq(id))) - .set(crm_leads::title.eq(title)) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::title.eq(title)) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; } @@ -851,25 +1006,25 @@ pub async fn update_lead( _ => req.probability.unwrap_or(0), }; - diesel::update(crm_leads::table.filter(crm_leads::id.eq(id))) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) .set(( - crm_leads::stage.eq(&stage), - crm_leads::probability.eq(probability), + crm_deals::stage.eq(&stage), + crm_deals::probability.eq(probability), )) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; if stage == "won" || stage == "lost" { - diesel::update(crm_leads::table.filter(crm_leads::id.eq(id))) - .set(crm_leads::closed_at.eq(Some(now))) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::closed_at.eq(Some(now))) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; } } if let Some(lost_reason) = req.lost_reason { - diesel::update(crm_leads::table.filter(crm_leads::id.eq(id))) - .set(crm_leads::lost_reason.eq(lost_reason)) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::lost_reason.eq(lost_reason)) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; } @@ -886,7 +1041,7 @@ pub async fn update_lead_stage( State(state): State>, Path(id): Path, Query(query): Query, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; @@ -894,6 +1049,14 @@ pub async fn update_lead_stage( let now = Utc::now(); let stage = query.stage; + let old_stage: Option> = crm_deals::table + .filter(crm_deals::id.eq(id)) + .select(crm_deals::stage) + .first(&mut conn) + .ok(); + + let old_stage_str = old_stage.flatten(); + let probability = match stage.as_str() { "new" => 10, "qualified" => 25, @@ -905,22 +1068,35 @@ pub async fn update_lead_stage( _ => 25, }; - diesel::update(crm_leads::table.filter(crm_leads::id.eq(id))) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) .set(( - crm_leads::stage.eq(&stage), - crm_leads::probability.eq(probability), - crm_leads::updated_at.eq(now), + crm_deals::stage.eq(&stage), + crm_deals::probability.eq(probability), + crm_deals::updated_at.eq(now), )) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; if stage == "won" || stage == "lost" || stage == "converted" { - diesel::update(crm_leads::table.filter(crm_leads::id.eq(id))) - .set(crm_leads::closed_at.eq(Some(now))) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::closed_at.eq(Some(now))) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; } + if let Some(old) = old_stage_str { + if old != stage { + let (_org_id, bot_id) = get_bot_context(&state); + crate::marketing::triggers::trigger_deal_stage_change( + &mut conn, + id, + &old, + &stage, + bot_id, + ); + } + } + get_lead(State(state), Path(id)).await } @@ -932,7 +1108,7 @@ pub async fn delete_lead( (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; - diesel::delete(crm_leads::table.filter(crm_leads::id.eq(id))) + diesel::delete(crm_deals::table.filter(crm_deals::id.eq(id))) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?; @@ -942,53 +1118,62 @@ pub async fn delete_lead( pub async fn convert_lead_to_opportunity( State(state): State>, Path(id): Path, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; - let lead: CrmLead = crm_leads::table - .filter(crm_leads::id.eq(id)) + let lead: CrmDeal = crm_deals::table + .filter(crm_deals::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Lead not found".to_string()))?; let opp_id = Uuid::new_v4(); let now = Utc::now(); - let opportunity = CrmOpportunity { + let opportunity = CrmDeal { id: opp_id, org_id: lead.org_id, bot_id: lead.bot_id, lead_id: Some(lead.id), account_id: lead.account_id, contact_id: lead.contact_id, + am_id: None, + title: lead.title.clone(), name: lead.title.clone(), description: lead.description.clone(), - value: lead.value.clone(), + value: lead.value, currency: lead.currency.clone(), stage_id: None, - stage: "qualification".to_string(), + stage: Some("qualification".to_string()), probability: 25, source: lead.source.clone(), + segment_id: None, + department_id: None, expected_close_date: lead.expected_close_date, actual_close_date: None, + period: None, + deal_date: None, won: None, owner_id: lead.owner_id, + lost_reason: None, + closed_at: None, + notes: None, tags: lead.tags.clone(), custom_fields: lead.custom_fields.clone(), created_at: now, - updated_at: now, + updated_at: Some(now), }; - diesel::insert_into(crm_opportunities::table) + diesel::insert_into(crm_deals::table) .values(&opportunity) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; - diesel::update(crm_leads::table.filter(crm_leads::id.eq(id))) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) .set(( - crm_leads::stage.eq("converted"), - crm_leads::closed_at.eq(Some(now)), + crm_deals::stage.eq("converted"), + crm_deals::closed_at.eq(Some(now)), )) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; @@ -999,7 +1184,7 @@ pub async fn convert_lead_to_opportunity( pub async fn create_opportunity( State(state): State>, Json(req): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; @@ -1011,7 +1196,7 @@ pub async fn create_opportunity( let expected_close = req.expected_close_date .and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()); - let value = req.value.map(|v| v); + let value = req.value; let stage = req.stage.unwrap_or_else(|| "qualification".to_string()); let probability = match stage.as_str() { @@ -1023,32 +1208,41 @@ pub async fn create_opportunity( _ => 25, }; - let opportunity = CrmOpportunity { + let opportunity = CrmDeal { id, org_id, bot_id, lead_id: req.lead_id, account_id: req.account_id, contact_id: req.contact_id, - name: req.name, + am_id: None, + title: None, + name: Some(req.name), description: req.description, value, currency: req.currency.or(Some("USD".to_string())), stage_id: None, - stage, + stage: Some(stage), probability, source: None, + segment_id: None, + department_id: None, expected_close_date: expected_close, actual_close_date: None, + period: None, + deal_date: None, won: None, owner_id: None, + lost_reason: None, + closed_at: None, + notes: None, tags: vec![], custom_fields: serde_json::json!({}), created_at: now, - updated_at: now, + updated_at: Some(now), }; - diesel::insert_into(crm_opportunities::table) + diesel::insert_into(crm_deals::table) .values(&opportunity) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; @@ -1059,7 +1253,7 @@ pub async fn create_opportunity( pub async fn list_opportunities( State(state): State>, Query(query): Query, -) -> Result>, (StatusCode, String)> { +) -> Result>, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; @@ -1068,22 +1262,30 @@ pub async fn list_opportunities( let limit = query.limit.unwrap_or(50); let offset = query.offset.unwrap_or(0); - let mut q = crm_opportunities::table - .filter(crm_opportunities::org_id.eq(org_id)) - .filter(crm_opportunities::bot_id.eq(bot_id)) + let mut q = crm_deals::table + .filter(crm_deals::org_id.eq(org_id)) + .filter(crm_deals::bot_id.eq(bot_id)) .into_boxed(); if let Some(stage) = query.stage { - q = q.filter(crm_opportunities::stage.eq(stage)); + q = q.filter(crm_deals::stage.eq(stage)); } if let Some(search) = query.search { let pattern = format!("%{search}%"); - q = q.filter(crm_opportunities::name.ilike(pattern)); + q = q.filter(crm_deals::name.ilike(pattern)); } - let opportunities: Vec = q - .order(crm_opportunities::created_at.desc()) + if let Some(department_id) = query.department_id { + q = q.filter(crm_deals::department_id.eq(department_id)); + } + + if let Some(source) = query.source { + q = q.filter(crm_deals::source.eq(source)); + } + + let opportunities: Vec = q + .order(crm_deals::created_at.desc()) .limit(limit) .offset(offset) .load(&mut conn) @@ -1095,13 +1297,13 @@ pub async fn list_opportunities( pub async fn get_opportunity( State(state): State>, Path(id): Path, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; - let opp: CrmOpportunity = crm_opportunities::table - .filter(crm_opportunities::id.eq(id)) + let opp: CrmDeal = crm_deals::table + .filter(crm_deals::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Opportunity not found".to_string()))?; @@ -1112,21 +1314,21 @@ pub async fn update_opportunity( State(state): State>, Path(id): Path, Json(req): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let now = Utc::now(); - diesel::update(crm_opportunities::table.filter(crm_opportunities::id.eq(id))) - .set(crm_opportunities::updated_at.eq(now)) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::updated_at.eq(now)) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; if let Some(name) = req.name { - diesel::update(crm_opportunities::table.filter(crm_opportunities::id.eq(id))) - .set(crm_opportunities::name.eq(name)) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::name.eq(name)) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; } @@ -1141,10 +1343,10 @@ pub async fn update_opportunity( _ => req.probability.unwrap_or(25), }; - diesel::update(crm_opportunities::table.filter(crm_opportunities::id.eq(id))) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) .set(( - crm_opportunities::stage.eq(&stage), - crm_opportunities::probability.eq(probability), + crm_deals::stage.eq(&stage), + crm_deals::probability.eq(probability), )) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; @@ -1157,7 +1359,7 @@ pub async fn close_opportunity( State(state): State>, Path(id): Path, Json(req): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; @@ -1170,13 +1372,13 @@ pub async fn close_opportunity( let stage = if req.won { "won" } else { "lost" }; let probability = if req.won { 100 } else { 0 }; - diesel::update(crm_opportunities::table.filter(crm_opportunities::id.eq(id))) + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) .set(( - crm_opportunities::won.eq(Some(req.won)), - crm_opportunities::stage.eq(stage), - crm_opportunities::probability.eq(probability), - crm_opportunities::actual_close_date.eq(Some(close_date)), - crm_opportunities::updated_at.eq(now), + crm_deals::won.eq(Some(req.won)), + crm_deals::stage.eq(stage), + crm_deals::probability.eq(probability), + crm_deals::actual_close_date.eq(Some(close_date)), + crm_deals::updated_at.eq(now), )) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; @@ -1192,7 +1394,301 @@ pub async fn delete_opportunity( (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; - diesel::delete(crm_opportunities::table.filter(crm_opportunities::id.eq(id))) + diesel::delete(crm_deals::table.filter(crm_deals::id.eq(id))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn list_deals( + State(state): State>, + Query(query): Query, +) -> Result>, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let (org_id, bot_id) = get_bot_context(&state); + let limit = query.limit.unwrap_or(50); + let offset = query.offset.unwrap_or(0); + + let mut q = crm_deals::table + .filter(crm_deals::org_id.eq(org_id)) + .filter(crm_deals::bot_id.eq(bot_id)) + .into_boxed(); + + if let Some(stage) = query.stage { + q = q.filter(crm_deals::stage.eq(stage)); + } + + if let Some(search) = query.search { + let pattern = format!("%{search}%"); + q = q.filter(crm_deals::title.ilike(pattern.clone()).or(crm_deals::name.ilike(pattern))); + } + + if let Some(department_id) = query.department_id { + q = q.filter(crm_deals::department_id.eq(department_id)); + } + + if let Some(source) = query.source { + q = q.filter(crm_deals::source.eq(source)); + } + + if let Some(owner_id) = query.owner_id { + q = q.filter(crm_deals::owner_id.eq(owner_id)); + } + + if let Some(status) = query.status { + match status.as_str() { + "open" => { + q = q.filter(crm_deals::closed_at.is_null()); + } + "closed" => { + q = q.filter(crm_deals::closed_at.is_not_null()); + } + "won" => { + q = q.filter(crm_deals::won.eq(true)); + } + "lost" => { + q = q.filter(crm_deals::won.eq(false)); + } + _ => {} + } + } + + let deals: Vec = q + .order(crm_deals::created_at.desc()) + .limit(limit) + .offset(offset) + .load(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; + + Ok(Json(deals)) +} + +pub async fn create_deal( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let (org_id, bot_id) = get_bot_context(&state); + let id = Uuid::new_v4(); + let now = Utc::now(); + + let expected_close = req.expected_close_date + .and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()); + + let stage = req.stage.unwrap_or_else(|| "new".to_string()); + let probability = match stage.as_str() { + "new" => 10, + "qualified" => 25, + "proposal" => 50, + "negotiation" => 75, + "won" => 100, + "lost" => 0, + _ => req.probability.unwrap_or(10), + }; + + let deal = CrmDeal { + id, + org_id, + bot_id, + contact_id: req.contact_id, + account_id: req.account_id, + am_id: None, + lead_id: None, + owner_id: req.owner_id, + title: req.title, + name: req.name, + description: req.description, + value: req.value, + currency: req.currency.or(Some("USD".to_string())), + stage_id: None, + stage: Some(stage.clone()), + probability, + source: req.source, + segment_id: None, + department_id: req.department_id, + expected_close_date: expected_close, + actual_close_date: None, + period: None, + deal_date: None, + lost_reason: None, + won: if stage == "won" { + Some(true) + } else if stage == "lost" { + Some(false) + } else { + None + }, + tags: req.tags.unwrap_or_default(), + custom_fields: serde_json::json!({}), + created_at: now, + updated_at: Some(now), + closed_at: if stage == "won" || stage == "lost" { + Some(now) + } else { + None + }, + notes: req.notes, + }; + + diesel::insert_into(crm_deals::table) + .values(&deal) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert deal error: {e}")))?; + + Ok(Json(deal)) +} + +pub async fn get_deal( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let deal: CrmDeal = crm_deals::table + .filter(crm_deals::id.eq(id)) + .first(&mut conn) + .map_err(|_| (StatusCode::NOT_FOUND, "Deal not found".to_string()))?; + + Ok(Json(deal)) +} + +pub async fn update_deal( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let now = Utc::now(); + + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::updated_at.eq(now)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + + if let Some(title) = req.title { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::title.eq(title)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(name) = req.name { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::name.eq(name)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(value) = req.value { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::value.eq(value)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(currency) = req.currency { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::currency.eq(currency)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(stage) = req.stage { + let probability = match stage.as_str() { + "new" => 10, + "qualified" => 25, + "proposal" => 50, + "negotiation" => 75, + "won" => 100, + "lost" => 0, + _ => req.probability.unwrap_or(0), + }; + + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(( + crm_deals::stage.eq(&stage), + crm_deals::probability.eq(probability), + )) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + + if stage == "won" || stage == "lost" { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::closed_at.eq(Some(now))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + } + + if let Some(department_id) = req.department_id { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::department_id.eq(department_id)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(owner_id) = req.owner_id { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::owner_id.eq(owner_id)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(lost_reason) = req.lost_reason { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::lost_reason.eq(lost_reason)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(won) = req.won { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(( + crm_deals::won.eq(won), + crm_deals::closed_at.eq(Some(now)), + )) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(notes) = req.notes { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::notes.eq(notes)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + if let Some(tags) = req.tags { + diesel::update(crm_deals::table.filter(crm_deals::id.eq(id))) + .set(crm_deals::tags.eq(tags)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + get_deal(State(state), Path(id)).await +} + +pub async fn delete_deal( + State(state): State>, + Path(id): Path, +) -> Result { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + diesel::delete(crm_deals::table.filter(crm_deals::id.eq(id))) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?; @@ -1307,26 +1803,26 @@ pub async fn get_crm_stats( .get_result(&mut conn) .unwrap_or(0); - let total_leads: i64 = crm_leads::table - .filter(crm_leads::org_id.eq(org_id)) - .filter(crm_leads::bot_id.eq(bot_id)) - .filter(crm_leads::closed_at.is_null()) + let total_leads: i64 = crm_deals::table + .filter(crm_deals::org_id.eq(org_id)) + .filter(crm_deals::bot_id.eq(bot_id)) + .filter(crm_deals::closed_at.is_null()) .count() .get_result(&mut conn) .unwrap_or(0); - let total_opportunities: i64 = crm_opportunities::table - .filter(crm_opportunities::org_id.eq(org_id)) - .filter(crm_opportunities::bot_id.eq(bot_id)) - .filter(crm_opportunities::won.is_null()) + let total_opportunities: i64 = crm_deals::table + .filter(crm_deals::org_id.eq(org_id)) + .filter(crm_deals::bot_id.eq(bot_id)) + .filter(crm_deals::won.is_null()) .count() .get_result(&mut conn) .unwrap_or(0); - let won_this_month: i64 = crm_opportunities::table - .filter(crm_opportunities::org_id.eq(org_id)) - .filter(crm_opportunities::bot_id.eq(bot_id)) - .filter(crm_opportunities::won.eq(Some(true))) + let won_this_month: i64 = crm_deals::table + .filter(crm_deals::org_id.eq(org_id)) + .filter(crm_deals::bot_id.eq(bot_id)) + .filter(crm_deals::won.eq(Some(true))) .count() .get_result(&mut conn) .unwrap_or(0); @@ -1336,6 +1832,7 @@ pub async fn get_crm_stats( total_accounts, total_leads, total_opportunities, + total_campaigns: 0, pipeline_value: 0.0, won_this_month, conversion_rate: if total_leads > 0 { @@ -1390,30 +1887,40 @@ pub async fn import_from_postgres( .map_err(|e| log_and_sanitize(&e, "external pg leads query", None))?; for el in ext_leads { - let l = CrmLead { + let l = CrmDeal { id: Uuid::new_v4(), org_id, bot_id, contact_id: None, account_id: None, - title: el.title, + am_id: None, + lead_id: None, + title: Some(el.title.clone()), + name: Some(el.title), description: el.description, value: el.value, currency: Some("USD".to_string()), stage_id: None, - stage: el.stage.unwrap_or_else(|| "new".to_string()), + stage: Some(el.stage.unwrap_or_else(|| "new".to_string())), probability: 10, source: el.source, + segment_id: None, + department_id: None, expected_close_date: None, + actual_close_date: None, + period: None, + deal_date: None, + won: None, owner_id: None, lost_reason: None, + closed_at: None, + notes: None, tags: vec![], custom_fields: serde_json::json!({}), created_at: now, - updated_at: now, - closed_at: None, + updated_at: Some(now), }; - let _ = diesel::insert_into(crm_leads::table).values(&l).execute(&mut conn).map_err(|e| log_and_sanitize(&e, "insert lead", None))?; + let _ = diesel::insert_into(crm_deals::table).values(&l).execute(&mut conn).map_err(|e| log_and_sanitize(&e, "insert lead", None))?; } #[derive(QueryableByName, Debug)] @@ -1435,31 +1942,40 @@ pub async fn import_from_postgres( .map_err(|e| log_and_sanitize(&e, "external pg opps query", None))?; for eo in ext_opps { - let op = CrmOpportunity { + let op = CrmDeal { id: Uuid::new_v4(), org_id, bot_id, lead_id: None, account_id: None, contact_id: None, - name: eo.name, + am_id: None, + title: None, + name: Some(eo.name), description: eo.description, value: eo.value, currency: Some("USD".to_string()), stage_id: None, - stage: eo.stage.unwrap_or_else(|| "qualification".to_string()), + stage: Some(eo.stage.unwrap_or_else(|| "qualification".to_string())), probability: eo.probability.unwrap_or(25), source: None, + segment_id: None, + department_id: None, expected_close_date: None, actual_close_date: None, + period: None, + deal_date: None, won: None, owner_id: None, + lost_reason: None, + closed_at: None, + notes: None, tags: vec![], custom_fields: serde_json::json!({}), created_at: now, - updated_at: now, + updated_at: Some(now), }; - let _ = diesel::insert_into(crm_opportunities::table).values(&op).execute(&mut conn).map_err(|e| log_and_sanitize(&e, "insert opp", None))?; + let _ = diesel::insert_into(crm_deals::table).values(&op).execute(&mut conn).map_err(|e| log_and_sanitize(&e, "insert opp", None))?; } Ok(Json(serde_json::json!({ @@ -1487,17 +2003,17 @@ async fn handle_crm_count_api( let stage = query.stage.unwrap_or_else(|| "all".to_string()); let count: i64 = if stage == "all" || stage.is_empty() { - crm_leads::table - .filter(crm_leads::org_id.eq(org_id)) - .filter(crm_leads::bot_id.eq(bot_id)) + crm_deals::table + .filter(crm_deals::org_id.eq(org_id)) + .filter(crm_deals::bot_id.eq(bot_id)) .count() .get_result(&mut conn) .unwrap_or(0) } else { - crm_leads::table - .filter(crm_leads::org_id.eq(org_id)) - .filter(crm_leads::bot_id.eq(bot_id)) - .filter(crm_leads::stage.eq(&stage)) + crm_deals::table + .filter(crm_deals::org_id.eq(org_id)) + .filter(crm_deals::bot_id.eq(bot_id)) + .filter(crm_deals::stage.eq(&stage)) .count() .get_result(&mut conn) .unwrap_or(0) @@ -1518,11 +2034,11 @@ async fn handle_crm_pipeline_api( let org_id = Uuid::nil(); let stage = query.stage.unwrap_or_else(|| "new".to_string()); - let leads: Vec = crm_leads::table - .filter(crm_leads::org_id.eq(org_id)) - .filter(crm_leads::bot_id.eq(bot_id)) - .filter(crm_leads::stage.eq(&stage)) - .order(crm_leads::created_at.desc()) + let leads: Vec = crm_deals::table + .filter(crm_deals::org_id.eq(org_id)) + .filter(crm_deals::bot_id.eq(bot_id)) + .filter(crm_deals::stage.eq(&stage)) + .order(crm_deals::created_at.desc()) .limit(20) .load(&mut conn) .unwrap_or_default(); @@ -1557,7 +2073,7 @@ async fn handle_crm_pipeline_api( "##, lead.id, - html_escape(&lead.title), + html_escape(lead.title.as_deref().unwrap_or("")), value_str, contact_name, lead.probability, @@ -1580,6 +2096,7 @@ fn html_escape(s: &str) -> String { .replace('\'', "'") } + pub fn configure_crm_api_routes() -> Router> { Router::new() .route("/api/crm/import/postgres", post(import_from_postgres)) @@ -1596,7 +2113,128 @@ pub fn configure_crm_api_routes() -> Router> { .route("/api/crm/opportunities", get(list_opportunities).post(create_opportunity)) .route("/api/crm/opportunities/:id", get(get_opportunity).put(update_opportunity).delete(delete_opportunity)) .route("/api/crm/opportunities/:id/close", post(close_opportunity)) + .route("/api/crm/deals", get(list_deals).post(create_deal)) + .route("/api/crm/deals/:id", get(get_deal).put(update_deal).delete(delete_deal)) .route("/api/crm/activities", get(list_activities).post(create_activity)) .route("/api/crm/pipeline/stages", get(get_pipeline_stages)) .route("/api/crm/stats", get(get_crm_stats)) } + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + #[test] + fn test_crm_deal_struct_has_department_id() { + let deal = CrmDeal { + id: Uuid::new_v4(), + org_id: Uuid::new_v4(), + bot_id: Uuid::new_v4(), + contact_id: None, + account_id: None, + am_id: None, + owner_id: None, + lead_id: None, + title: Some("Test Deal".to_string()), + name: None, + description: None, + value: Some(10000.0), + currency: Some("USD".to_string()), + stage_id: None, + stage: Some("new".to_string()), + probability: 10, + source: Some("WEBSITE".to_string()), + segment_id: None, + department_id: Some(Uuid::new_v4()), + expected_close_date: None, + actual_close_date: None, + period: None, + deal_date: None, + closed_at: None, + lost_reason: None, + won: None, + notes: None, + tags: vec![], + created_at: chrono::Utc::now(), + updated_at: None, + custom_fields: serde_json::json!({}), + }; + + assert!(deal.department_id.is_some()); + assert_eq!(deal.stage, Some("new".to_string())); + assert_eq!(deal.probability, 10); + } + + #[test] + fn test_list_query_deserialization() { + let query_str = "stage=new&department_id=550e8400-e29b-41d4-a716-446655440000&source=WEBSITE&limit=50&offset=0"; + let query: ListQuery = serde_urlencoded::from_str(query_str).unwrap(); + + assert_eq!(query.stage, Some("new".to_string())); + assert_eq!(query.department_id.is_some(), true); + assert_eq!(query.source, Some("WEBSITE".to_string())); + assert_eq!(query.limit, Some(50)); + assert_eq!(query.offset, Some(0)); + } + + #[test] + fn test_list_query_optional_fields() { + let query_str = "search=acme"; + let query: ListQuery = serde_urlencoded::from_str(query_str).unwrap(); + + assert_eq!(query.search, Some("acme".to_string())); + assert_eq!(query.department_id, None); + assert_eq!(query.source, None); + } + + #[test] + fn test_crm_deal_stage_probabilities() { + let stages = vec![ + ("new", 10), + ("qualified", 30), + ("proposal", 50), + ("negotiation", 70), + ("won", 100), + ("lost", 0), + ]; + + for (stage, expected_prob) in stages { + let deal = CrmDeal { + id: Uuid::new_v4(), + org_id: Uuid::new_v4(), + bot_id: Uuid::new_v4(), + contact_id: None, + account_id: None, + am_id: None, + owner_id: None, + lead_id: None, + title: Some(format!("Test {}", stage)), + name: None, + description: None, + value: Some(1000.0), + currency: Some("USD".to_string()), + stage_id: None, + stage: Some(stage.to_string()), + probability: expected_prob, + source: None, + segment_id: None, + department_id: None, + expected_close_date: None, + actual_close_date: None, + period: None, + deal_date: None, + closed_at: None, + lost_reason: None, + won: if stage == "won" { Some(true) } else if stage == "lost" { Some(false) } else { None }, + notes: None, + tags: vec![], + created_at: chrono::Utc::now(), + updated_at: None, + custom_fields: serde_json::json!({}), + }; + + assert_eq!(deal.probability, expected_prob); + } + } +} diff --git a/src/contacts/tasks_integration.rs b/src/contacts/tasks_integration.rs index 06a08220..cc8b29b1 100644 --- a/src/contacts/tasks_integration.rs +++ b/src/contacts/tasks_integration.rs @@ -118,6 +118,16 @@ pub struct UpdateTaskContactRequest { pub notes: Option, } +pub struct TaskAssignmentParams<'a> { + pub id: Uuid, + pub task_id: Uuid, + pub contact_id: Uuid, + pub role: &'a TaskContactRole, + pub assigned_by: Uuid, + pub notes: Option<&'a str>, + pub assigned_at: DateTime, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskContactsQuery { pub role: Option, @@ -138,6 +148,23 @@ pub struct ContactTasksQuery { pub sort_order: Option, } +#[derive(Queryable)] +pub struct ContactRow { + pub id: Uuid, + pub first_name: Option, + pub last_name: Option, + pub email: Option, + pub company: Option, + pub job_title: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ContactTaskPriority { + Low, + Normal, + High, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub enum TaskSortField { #[default] @@ -364,15 +391,15 @@ impl TasksIntegrationService { let role = request.role.clone().unwrap_or_default(); // Create assignment in database - self.create_task_contact_assignment( + self.create_task_contact_assignment(TaskAssignmentParams { id, task_id, - request.contact_id, - &role, + contact_id: request.contact_id, + role: &role, assigned_by, - request.notes.as_deref(), - now, - ) + notes: request.notes.as_deref(), + assigned_at: now, + }) .await?; // Send notification if requested @@ -388,7 +415,7 @@ impl TasksIntegrationService { self.log_contact_activity( request.contact_id, TaskActivityType::Assigned, - &format!("Assigned to task"), + "Assigned to task", task_id, ) .await?; @@ -760,13 +787,7 @@ impl TasksIntegrationService { async fn create_task_contact_assignment( &self, - _id: Uuid, - _task_id: Uuid, - _contact_id: Uuid, - _role: &TaskContactRole, - _assigned_by: Uuid, - _notes: Option<&str>, - _assigned_at: DateTime, + _params: TaskAssignmentParams<'_>, ) -> Result<(), TasksIntegrationError> { // Insert into task_contacts table Ok(()) @@ -865,9 +886,8 @@ impl TasksIntegrationService { let mut task_contacts = Vec::new(); - if let Ok((tid, assigned_to, created_at)) = task_row { - if let Some(assignee_id) = assigned_to { - // Look up person -> email -> contact + if let Ok((tid, Some(assignee_id), created_at)) = task_row { + // Look up person -> email -> contact let person_email: Result, _> = people_table::table .filter(people_table::id.eq(assignee_id)) .select(people_table::email) @@ -894,7 +914,6 @@ impl TasksIntegrationService { }); } } - } } Ok(task_contacts) @@ -922,7 +941,21 @@ impl TasksIntegrationService { db_query = db_query.filter(tasks_table::status.eq(status)); } - let rows: Vec<(Uuid, String, Option, String, String, Option>, Option, i32, DateTime, DateTime)> = db_query + #[derive(Queryable)] + struct TaskRow { + id: Uuid, + title: String, + description: Option, + status: String, + priority: String, + due_date: Option>, + project_id: Option, + progress: i32, + created_at: DateTime, + updated_at: DateTime, + } + + let rows: Vec = db_query .order(tasks_table::created_at.desc()) .select(( tasks_table::id, @@ -944,7 +977,7 @@ impl TasksIntegrationService { ContactTaskWithDetails { task_contact: TaskContact { id: Uuid::new_v4(), - task_id: row.0, + task_id: row.id, contact_id, role: TaskContactRole::Assignee, assigned_at: Utc::now(), @@ -954,17 +987,17 @@ impl TasksIntegrationService { notes: None, }, task: TaskSummary { - id: row.0, - title: row.1, - description: row.2, - status: row.3, - priority: row.4, - due_date: row.5, - project_id: row.6, + id: row.id, + title: row.title, + description: row.description, + status: row.status, + priority: row.priority, + due_date: row.due_date, + project_id: row.project_id, project_name: None, - progress: row.7 as u8, - created_at: row.8, - updated_at: row.9, + progress: row.progress as u8, + created_at: row.created_at, + updated_at: row.updated_at, }, } }).collect(); @@ -1104,7 +1137,7 @@ impl TasksIntegrationService { query = query.filter(crm_contacts_table::id.ne(*exc)); } - let rows: Vec<(Uuid, Option, Option, Option, Option, Option)> = query + let rows: Vec = query .select(( crm_contacts_table::id, crm_contacts_table::first_name, @@ -1119,13 +1152,13 @@ impl TasksIntegrationService { let contacts = rows.into_iter().map(|row| { let summary = ContactSummary { - id: row.0, - first_name: row.1.unwrap_or_default(), - last_name: row.2.unwrap_or_default(), - email: row.3, + id: row.id, + first_name: row.first_name.unwrap_or_default(), + last_name: row.last_name.unwrap_or_default(), + email: row.email, phone: None, - company: row.4, - job_title: row.5, + company: row.company, + job_title: row.job_title, avatar_url: None, }; let workload = ContactWorkload { @@ -1164,7 +1197,9 @@ impl TasksIntegrationService { query = query.filter(crm_contacts_table::id.ne(*exc)); } - let rows: Vec<(Uuid, Option, Option, Option, Option, Option)> = query + + + let rows: Vec = query .select(( crm_contacts_table::id, crm_contacts_table::first_name, @@ -1179,13 +1214,13 @@ impl TasksIntegrationService { let contacts = rows.into_iter().map(|row| { let summary = ContactSummary { - id: row.0, - first_name: row.1.unwrap_or_default(), - last_name: row.2.unwrap_or_default(), - email: row.3, + id: row.id, + first_name: row.first_name.unwrap_or_default(), + last_name: row.last_name.unwrap_or_default(), + email: row.email, phone: None, - company: row.4, - job_title: row.5, + company: row.company, + job_title: row.job_title, avatar_url: None, }; let workload = ContactWorkload { @@ -1224,7 +1259,9 @@ impl TasksIntegrationService { query = query.filter(crm_contacts_table::id.ne(*exc)); } - let rows: Vec<(Uuid, Option, Option, Option, Option, Option)> = query + + + let rows: Vec = query .select(( crm_contacts_table::id, crm_contacts_table::first_name, @@ -1239,13 +1276,13 @@ impl TasksIntegrationService { let contacts = rows.into_iter().map(|row| { let summary = ContactSummary { - id: row.0, - first_name: row.1.unwrap_or_default(), - last_name: row.2.unwrap_or_default(), - email: row.3, + id: row.id, + first_name: row.first_name.unwrap_or_default(), + last_name: row.last_name.unwrap_or_default(), + email: row.email, phone: None, - company: row.4, - job_title: row.5, + company: row.company, + job_title: row.job_title, avatar_url: None, }; let workload = ContactWorkload { @@ -1283,9 +1320,9 @@ impl TasksIntegrationService { mod tests { #[test] fn test_task_type_display() { - assert_eq!(format!("{:?}", ContactTaskType::FollowUp), "FollowUp"); - assert_eq!(format!("{:?}", ContactTaskType::Meeting), "Meeting"); - assert_eq!(format!("{:?}", ContactTaskType::Call), "Call"); + assert_eq!(format!("{:?}", TaskActivityType::Assigned), "Assigned"); + assert_eq!(format!("{:?}", TaskActivityType::Completed), "Completed"); + assert_eq!(format!("{:?}", TaskActivityType::Updated), "Updated"); } #[test] diff --git a/src/core/bot/channels/whatsapp.rs b/src/core/bot/channels/whatsapp.rs index 16571722..7851f92b 100644 --- a/src/core/bot/channels/whatsapp.rs +++ b/src/core/bot/channels/whatsapp.rs @@ -11,7 +11,7 @@ use crate::core::shared::utils::DbPool; use std::sync::Arc; /// Global WhatsApp message queue (shared across all adapters) -static WHATSAPP_QUEUE: std::sync::OnceLock> = std::sync::OnceLock::new(); +static WHATSAPP_QUEUE: std::sync::OnceLock>> = std::sync::OnceLock::new(); #[derive(Debug, Clone)] pub struct WhatsAppAdapter { @@ -20,8 +20,8 @@ pub struct WhatsAppAdapter { webhook_verify_token: String, _business_account_id: String, api_version: String, - voice_response: bool, - queue: &'static Arc, + _voice_response: bool, + queue: Option<&'static Arc>, } impl WhatsAppAdapter { @@ -65,20 +65,24 @@ impl WhatsAppAdapter { webhook_verify_token: verify_token, _business_account_id: business_account_id, api_version, - voice_response, + _voice_response: voice_response, queue: WHATSAPP_QUEUE.get_or_init(|| { - let queue = WhatsAppMessageQueue::new(&redis_url) - .unwrap_or_else(|e| { - error!("Failed to create WhatsApp queue: {}", e); - panic!("WhatsApp queue initialization failed"); - }); - let queue = Arc::new(queue); - let worker_queue = Arc::clone(&queue); - tokio::spawn(async move { - worker_queue.start_worker().await; - }); - queue - }), + let queue_res = WhatsAppMessageQueue::new(&redis_url); + match queue_res { + Ok(q) => { + let q_arc = Arc::new(q); + let worker_queue = Arc::clone(&q_arc); + tokio::spawn(async move { + worker_queue.start_worker().await; + }); + Some(q_arc) + } + Err(e) => { + error!("FATAL: Failed to create WhatsApp queue: {}. WhatsApp features will be disabled.", e); + None + } + } + }).as_ref(), } } @@ -155,7 +159,12 @@ impl WhatsAppAdapter { api_version: self.api_version.clone(), }; - self.queue.enqueue(queued_msg).await + let queue = self.queue.ok_or_else(|| { + error!("WhatsApp queue not available (was initialization failed?)"); + "WhatsApp queue not available" + })?; + + queue.enqueue(queued_msg).await .map_err(|e| format!("Failed to enqueue WhatsApp message: {}", e))?; info!("WhatsApp message enqueued for {}: {}", to, &message.chars().take(50).collect::()); @@ -466,7 +475,7 @@ impl WhatsAppAdapter { let media_info: serde_json::Value = response.json().await?; let download_url = media_info["url"] .as_str() - .ok_or_else(|| "Media URL not found in response")?; + .ok_or("Media URL not found in response")?; // 2. Download the binary let download_response = client diff --git a/src/core/bot/mod.rs b/src/core/bot/mod.rs index 2b90a86b..7f55b022 100644 --- a/src/core/bot/mod.rs +++ b/src/core/bot/mod.rs @@ -387,7 +387,7 @@ impl BotOrchestrator { // Ensure default tenant exists (use fixed ID for consistency) let default_tenant_id = "00000000-0000-0000-0000-000000000001"; - sql_query(&format!( + sql_query(format!( "INSERT INTO tenants (id, name, slug, created_at) \ VALUES ('{}', 'Default Tenant', 'default', NOW()) \ ON CONFLICT (slug) DO NOTHING", @@ -398,7 +398,7 @@ impl BotOrchestrator { // Ensure default organization exists (use fixed ID for consistency) let default_org_id = "00000000-0000-0000-0000-000000000001"; - sql_query(&format!( + sql_query(format!( "INSERT INTO organizations (org_id, tenant_id, name, slug, created_at) \ VALUES ('{}', '{}', 'Default Org', 'default', NOW()) \ ON CONFLICT (org_id) DO NOTHING", diff --git a/src/core/kb/embedding_generator.rs b/src/core/kb/embedding_generator.rs index f2f6ea43..218365a4 100644 --- a/src/core/kb/embedding_generator.rs +++ b/src/core/kb/embedding_generator.rs @@ -213,12 +213,6 @@ struct ScalewayEmbeddingResponse { #[derive(Debug, Deserialize)] struct ScalewayEmbeddingData { embedding: Vec, - #[serde(default)] - #[allow(dead_code)] - index: usize, - #[serde(default)] - #[allow(dead_code)] - object: Option, } // Generic embedding service format (object with embeddings key) @@ -254,9 +248,6 @@ struct CloudflareResult { #[derive(Debug, Deserialize)] struct CloudflareMeta { - #[serde(default)] - #[allow(dead_code)] - cost_metric_name_1: Option, #[serde(default)] cost_metric_value_1: Option, } diff --git a/src/core/package_manager/installer.rs b/src/core/package_manager/installer.rs index 81aa9a21..595536e7 100644 --- a/src/core/package_manager/installer.rs +++ b/src/core/package_manager/installer.rs @@ -4,7 +4,6 @@ use crate::core::package_manager::{InstallMode, OsType}; use crate::security::command_guard::SafeCommand; use anyhow::{Context, Result}; use log::{error, info, trace, warn}; -use once_cell::sync::Lazy; use serde::Deserialize; use std::collections::HashMap; use std::path::PathBuf; @@ -22,15 +21,25 @@ struct ThirdPartyConfig { components: HashMap, } -static THIRDPARTY_CONFIG: Lazy = Lazy::new(|| { - let toml_str = include_str!("../../../3rdparty.toml"); - toml::from_str(toml_str).unwrap_or_else(|e| { - panic!("Failed to parse embedded 3rdparty.toml: {e}") +static THIRDPARTY_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn get_thirdparty_config() -> &'static ThirdPartyConfig { + THIRDPARTY_CONFIG.get_or_init(|| { + let toml_str = include_str!("../../../3rdparty.toml"); + match toml::from_str::(toml_str) { + Ok(config) => config, + Err(e) => { + error!("CRITICAL: Failed to parse embedded 3rdparty.toml: {e}"); + ThirdPartyConfig { + components: HashMap::new(), + } + } + } }) -}); +} fn get_component_url(name: &str) -> Option { - THIRDPARTY_CONFIG + get_thirdparty_config() .components .get(name) .map(|c| c.url.clone()) @@ -1366,8 +1375,8 @@ EOF"#.to_string(), info!("Waiting for Vault to start..."); std::thread::sleep(std::time::Duration::from_secs(3)); - let vault_addr = std::env::var("VAULT_ADDR") - .unwrap_or_else(|_| "https://localhost:8200".to_string()); + let vault_addr = + std::env::var("VAULT_ADDR").unwrap_or_else(|_| "https://localhost:8200".to_string()); let ca_cert = conf_path.join("system/certificates/ca/ca.crt"); // Initialize Vault @@ -1391,8 +1400,8 @@ EOF"#.to_string(), } let init_output = String::from_utf8_lossy(&output.stdout); - let init_json_val: serde_json::Value = serde_json::from_str(&init_output) - .context("Failed to parse Vault init output")?; + let init_json_val: serde_json::Value = + serde_json::from_str(&init_output).context("Failed to parse Vault init output")?; let unseal_keys = init_json_val["unseal_keys_b64"] .as_array() @@ -1402,10 +1411,7 @@ EOF"#.to_string(), .context("No root token in output")?; // Save init.json - std::fs::write( - &init_json, - serde_json::to_string_pretty(&init_json_val)? - )?; + std::fs::write(&init_json, serde_json::to_string_pretty(&init_json_val)?)?; info!("Created {}", init_json.display()); // Create .env file with Vault credentials @@ -1427,9 +1433,7 @@ VAULT_CACERT={} if existing.contains("VAULT_ADDR=") { warn!(".env already contains VAULT_ADDR, not overwriting"); } else { - let mut file = std::fs::OpenOptions::new() - .append(true) - .open(&env_file)?; + let mut file = std::fs::OpenOptions::new().append(true).open(&env_file)?; file.write_all(env_content.as_bytes())?; info!("Appended Vault config to .env"); } @@ -1508,8 +1512,8 @@ VAULT_CACERT={} let conf_path = self.base_path.join("conf"); let ca_cert = conf_path.join("system/certificates/ca/ca.crt"); - let vault_addr = std::env::var("VAULT_ADDR") - .unwrap_or_else(|_| "https://localhost:8200".to_string()); + let vault_addr = + std::env::var("VAULT_ADDR").unwrap_or_else(|_| "https://localhost:8200".to_string()); let env_content = format!( r#" @@ -1528,9 +1532,7 @@ VAULT_CACERT={} if existing.contains("VAULT_ADDR=") { return Ok(()); } - let mut file = std::fs::OpenOptions::new() - .append(true) - .open(&env_file)?; + let mut file = std::fs::OpenOptions::new().append(true).open(&env_file)?; use std::io::Write; file.write_all(env_content.as_bytes())?; } else { diff --git a/src/core/shared/admin.rs b/src/core/shared/admin.rs index 3befa7d2..65a6aabc 100644 --- a/src/core/shared/admin.rs +++ b/src/core/shared/admin.rs @@ -1,4 +1,4 @@ -#![cfg_attr(feature = "mail", allow(unused_imports))] + use axum::{Router, routing::{get, post}}; use std::sync::Arc; diff --git a/src/core/shared/models/core.rs b/src/core/shared/models/core.rs index 06121587..f3e7a8b9 100644 --- a/src/core/shared/models/core.rs +++ b/src/core/shared/models/core.rs @@ -17,6 +17,9 @@ pub enum TriggerKind { Webhook = 4, EmailReceived = 5, FolderChange = 6, + DealStageChange = 7, + ContactChange = 8, + EmailOpened = 9, } impl TriggerKind { @@ -29,6 +32,9 @@ impl TriggerKind { 4 => Some(Self::Webhook), 5 => Some(Self::EmailReceived), 6 => Some(Self::FolderChange), + 7 => Some(Self::DealStageChange), + 8 => Some(Self::ContactChange), + 9 => Some(Self::EmailOpened), _ => None, } } diff --git a/src/core/shared/schema/people.rs b/src/core/shared/schema/people.rs index 5c4ac913..27706a3d 100644 --- a/src/core/shared/schema/people.rs +++ b/src/core/shared/schema/people.rs @@ -314,3 +314,205 @@ diesel::joinable!(people_person_skills -> people (person_id)); diesel::joinable!(people_person_skills -> people_skills (skill_id)); diesel::joinable!(people_time_off -> organizations (org_id)); diesel::joinable!(people_time_off -> bots (bot_id)); +diesel::joinable!(crm_deals -> people_departments (department_id)); +diesel::joinable!(attendance_sla_events -> attendance_sla_policies (sla_policy_id)); + +diesel::table! { + crm_deals (id) { + id -> Uuid, + org_id -> Uuid, + bot_id -> Uuid, + contact_id -> Nullable, + account_id -> Nullable, + am_id -> Nullable, + owner_id -> Nullable, + lead_id -> Nullable, + title -> Nullable, + name -> Nullable, + description -> Nullable, + value -> Nullable, + currency -> Nullable, + stage_id -> Nullable, + stage -> Nullable, + probability -> Int4, + source -> Nullable, + segment_id -> Nullable, + department_id -> Nullable, + expected_close_date -> Nullable, + actual_close_date -> Nullable, + period -> Nullable, + deal_date -> Nullable, + closed_at -> Nullable, + lost_reason -> Nullable, + won -> Nullable, + notes -> Nullable, + tags -> Array, + created_at -> Timestamptz, + updated_at -> Nullable, + custom_fields -> Jsonb, + } +} + +diesel::table! { + crm_deal_segments (id) { + id -> Uuid, + org_id -> Uuid, + bot_id -> Uuid, + name -> Varchar, + description -> Nullable, + created_at -> Timestamptz, + } +} + +diesel::table! { + marketing_campaigns (id) { + id -> Uuid, + org_id -> Uuid, + bot_id -> Uuid, + deal_id -> Nullable, + name -> Varchar, + status -> Varchar, + channel -> Varchar, + content_template -> Jsonb, + scheduled_at -> Nullable, + sent_at -> Nullable, + completed_at -> Nullable, + metrics -> Jsonb, + budget -> Nullable, + created_at -> Timestamptz, + updated_at -> Nullable, + } +} + +diesel::table! { + marketing_lists (id) { + id -> Uuid, + org_id -> Uuid, + bot_id -> Uuid, + name -> Varchar, + list_type -> Varchar, + query_text -> Nullable, + contact_count -> Nullable, + created_at -> Timestamptz, + updated_at -> Nullable, + } +} + +diesel::table! { + marketing_list_contacts (list_id, contact_id) { + list_id -> Uuid, + contact_id -> Uuid, + added_at -> Timestamptz, + } +} + +diesel::table! { + marketing_recipients (id) { + id -> Uuid, + campaign_id -> Nullable, + contact_id -> Nullable, + deal_id -> Nullable, + channel -> Varchar, + status -> Varchar, + sent_at -> Nullable, + delivered_at -> Nullable, + failed_at -> Nullable, + error_message -> Nullable, + response -> Nullable, + created_at -> Timestamptz, + } +} + +diesel::table! { + marketing_templates (id) { + id -> Uuid, + org_id -> Uuid, + bot_id -> Uuid, + name -> Varchar, + channel -> Varchar, + subject -> Nullable, + body -> Nullable, + media_url -> Nullable, + ai_prompt -> Nullable, + variables -> Jsonb, + approved -> Nullable, + meta_template_id -> Nullable, + created_at -> Timestamptz, + updated_at -> Nullable, + } +} + +diesel::table! { + email_tracking (id) { + id -> Uuid, + recipient_id -> Nullable, + campaign_id -> Nullable, + message_id -> Nullable, + open_token -> Nullable, + open_tracking_enabled -> Nullable, + opened -> Nullable, + opened_at -> Nullable, + clicked -> Nullable, + clicked_at -> Nullable, + ip_address -> Nullable, + user_agent -> Nullable, + created_at -> Timestamptz, + } +} + +diesel::table! { + whatsapp_business (id) { + id -> Uuid, + bot_id -> Uuid, + phone_number_id -> Nullable, + business_account_id -> Nullable, + access_token -> Nullable, + webhooks_verified -> Nullable, + created_at -> Timestamptz, + updated_at -> Nullable, + } +} + +diesel::table! { + attendance_sla_policies (id) { + id -> Uuid, + org_id -> Uuid, + bot_id -> Uuid, + name -> Varchar, + channel -> Nullable, + priority -> Nullable, + first_response_minutes -> Nullable, + resolution_minutes -> Nullable, + escalate_on_breach -> Nullable, + is_active -> Nullable, + created_at -> Timestamptz, + } +} + +diesel::table! { + attendance_sla_events (id) { + id -> Uuid, + session_id -> Uuid, + sla_policy_id -> Uuid, + event_type -> Varchar, + due_at -> Timestamptz, + met_at -> Nullable, + breached_at -> Nullable, + status -> Nullable, + created_at -> Timestamptz, + } +} + +diesel::table! { + attendance_webhooks (id) { + id -> Uuid, + org_id -> Uuid, + bot_id -> Uuid, + webhook_url -> Varchar, + events -> Nullable>, + is_active -> Nullable, + secret_key -> Nullable, + created_at -> Timestamptz, + updated_at -> Nullable, + } +} diff --git a/src/core/shared/state.rs b/src/core/shared/state.rs index c6266652..1c69f131 100644 --- a/src/core/shared/state.rs +++ b/src/core/shared/state.rs @@ -387,6 +387,8 @@ pub struct AppState { pub task_progress_broadcast: Option>, pub billing_alert_broadcast: Option>, pub task_manifests: Arc>>, + #[cfg(feature = "terminal")] + pub terminal_manager: Arc, #[cfg(feature = "project")] pub project_service: Arc>, #[cfg(feature = "compliance")] @@ -431,6 +433,8 @@ impl Clone for AppState { task_progress_broadcast: self.task_progress_broadcast.clone(), billing_alert_broadcast: self.billing_alert_broadcast.clone(), task_manifests: Arc::clone(&self.task_manifests), + #[cfg(feature = "terminal")] + terminal_manager: Arc::clone(&self.terminal_manager), #[cfg(feature = "project")] project_service: Arc::clone(&self.project_service), #[cfg(feature = "compliance")] diff --git a/src/core/urls.rs b/src/core/urls.rs index 8a7b2688..fb4f4a13 100644 --- a/src/core/urls.rs +++ b/src/core/urls.rs @@ -215,6 +215,8 @@ impl ApiUrls { pub const ATTENDANCE_RESOLVE: &'static str = "/api/attendance/resolve/:session_id"; pub const ATTENDANCE_INSIGHTS: &'static str = "/api/attendance/insights"; pub const ATTENDANCE_RESPOND: &'static str = "/api/attendance/respond"; + pub const ATTENDANCE_KANBAN: &'static str = "/api/attendance/kanban"; + pub const ATTENDANCE_ASSIGN_BY_SKILL: &'static str = "/api/attendance/assign/by-skill"; pub const ATTENDANCE_LLM_TIPS: &'static str = "/api/attendance/llm/tips"; pub const ATTENDANCE_LLM_POLISH: &'static str = "/api/attendance/llm/polish"; pub const ATTENDANCE_LLM_SMART_REPLIES: &'static str = "/api/attendance/llm/smart-replies"; @@ -299,7 +301,8 @@ impl ApiUrls { pub const MONITORING_ACTIVITY_LATEST: &'static str = "/api/ui/monitoring/activity/latest"; pub const MONITORING_METRIC_SESSIONS: &'static str = "/api/ui/monitoring/metric/sessions"; pub const MONITORING_METRIC_MESSAGES: &'static str = "/api/ui/monitoring/metric/messages"; - pub const MONITORING_METRIC_RESPONSE_TIME: &'static str = "/api/ui/monitoring/metric/response_time"; + pub const MONITORING_METRIC_RESPONSE_TIME: &'static str = + "/api/ui/monitoring/metric/response_time"; pub const MONITORING_TREND_SESSIONS: &'static str = "/api/ui/monitoring/trend/sessions"; pub const MONITORING_RATE_MESSAGES: &'static str = "/api/ui/monitoring/rate/messages"; pub const MONITORING_SESSIONS_PANEL: &'static str = "/api/ui/monitoring/sessions"; @@ -396,6 +399,8 @@ impl ApiUrls { pub const SOURCES_MCP_TEST: &'static str = "/api/ui/sources/mcp/:name/test"; pub const SOURCES_MCP_SCAN: &'static str = "/api/ui/sources/mcp/scan"; pub const SOURCES_MCP_EXAMPLES: &'static str = "/api/ui/sources/mcp/examples"; + pub const SOURCES_API_KEYS: &'static str = "/api/ui/sources/api-keys"; + pub const SOURCES_API_KEYS_BY_ID: &'static str = "/api/ui/sources/api-keys/:id"; pub const SOURCES_MENTIONS: &'static str = "/api/ui/sources/mentions"; pub const SOURCES_TOOLS: &'static str = "/api/ui/sources/tools"; @@ -473,6 +478,12 @@ impl ApiUrls { pub const WS_CHAT: &'static str = "/ws/chat"; pub const WS_NOTIFICATIONS: &'static str = "/ws/notifications"; pub const WS_ATTENDANT: &'static str = "/ws/attendant"; + + // Terminal endpoints + pub const TERMINAL_WS: &'static str = "/api/terminal/ws"; + pub const TERMINAL_LIST: &'static str = "/api/terminal/list"; + pub const TERMINAL_CREATE: &'static str = "/api/terminal/create"; + pub const TERMINAL_KILL: &'static str = "/api/terminal/kill"; } #[derive(Debug)] diff --git a/src/designer/canvas.rs b/src/designer/canvas.rs index 4c00c3a0..286b173b 100644 --- a/src/designer/canvas.rs +++ b/src/designer/canvas.rs @@ -1,2 +1 @@ -pub use canvas_api::*; - +pub use super::canvas_api::*; diff --git a/src/designer/canvas_api/db.rs b/src/designer/canvas_api/db.rs index 89bede14..77fa1e25 100644 --- a/src/designer/canvas_api/db.rs +++ b/src/designer/canvas_api/db.rs @@ -2,7 +2,7 @@ use diesel::prelude::*; use diesel::sql_types::{Bool, Double, Integer, Nullable, Text, Timestamptz, Uuid as DieselUuid}; use uuid::Uuid; -use crate::designer::canvas_api::types::{Canvas, CanvasTemplate, Layer, CanvasElement}; +use crate::designer::canvas_api::types::{Canvas, CanvasElement, Layer}; #[derive(QueryableByName)] pub struct CanvasRow { diff --git a/src/designer/canvas_api/handlers.rs b/src/designer/canvas_api/handlers.rs index ea8a73eb..6009af15 100644 --- a/src/designer/canvas_api/handlers.rs +++ b/src/designer/canvas_api/handlers.rs @@ -1,7 +1,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, - response::IntoResponse, + routing::{get, post, put, delete}, Json, }; use serde::Deserialize; diff --git a/src/designer/canvas_api/service.rs b/src/designer/canvas_api/service.rs index a0570c11..8b7e59b3 100644 --- a/src/designer/canvas_api/service.rs +++ b/src/designer/canvas_api/service.rs @@ -1,6 +1,6 @@ -use chrono::{DateTime, Utc}; +use chrono::Utc; use diesel::prelude::*; -use diesel::sql_types::{Text, Timestamptz, Uuid as DieselUuid}; +use diesel::sql_types::{Text, Uuid as DieselUuid}; use log::error; use std::sync::Arc; use tokio::sync::broadcast; @@ -636,14 +636,14 @@ impl CanvasService { pub async fn get_asset_library(&self, asset_type: Option) -> Result, CanvasError> { let icons = vec![ - AssetLibraryItem { id: Uuid::new_v4(), name: "Bot".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../../botui/ui/suite/assets/icons/gb-bot.svg").to_string()), category: "General Bots".to_string(), tags: vec!["bot".to_string(), "assistant".to_string()], is_system: true }, - AssetLibraryItem { id: Uuid::new_v4(), name: "Analytics".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../../botui/ui/suite/assets/icons/gb-analytics.svg").to_string()), category: "General Bots".to_string(), tags: vec!["analytics".to_string(), "chart".to_string()], is_system: true }, - AssetLibraryItem { id: Uuid::new_v4(), name: "Calendar".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../../botui/ui/suite/assets/icons/gb-calendar.svg").to_string()), category: "General Bots".to_string(), tags: vec!["calendar".to_string(), "date".to_string()], is_system: true }, - AssetLibraryItem { id: Uuid::new_v4(), name: "Chat".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../../botui/ui/suite/assets/icons/gb-chat.svg").to_string()), category: "General Bots".to_string(), tags: vec!["chat".to_string(), "message".to_string()], is_system: true }, - AssetLibraryItem { id: Uuid::new_v4(), name: "Drive".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../../botui/ui/suite/assets/icons/gb-drive.svg").to_string()), category: "General Bots".to_string(), tags: vec!["drive".to_string(), "files".to_string()], is_system: true }, - AssetLibraryItem { id: Uuid::new_v4(), name: "Mail".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../../botui/ui/suite/assets/icons/gb-mail.svg").to_string()), category: "General Bots".to_string(), tags: vec!["mail".to_string(), "email".to_string()], is_system: true }, - AssetLibraryItem { id: Uuid::new_v4(), name: "Meet".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../../botui/ui/suite/assets/icons/gb-meet.svg").to_string()), category: "General Bots".to_string(), tags: vec!["meet".to_string(), "video".to_string()], is_system: true }, - AssetLibraryItem { id: Uuid::new_v4(), name: "Tasks".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../../botui/ui/suite/assets/icons/gb-tasks.svg").to_string()), category: "General Bots".to_string(), tags: vec!["tasks".to_string(), "todo".to_string()], is_system: true }, + AssetLibraryItem { id: Uuid::new_v4(), name: "Bot".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../botui/ui/suite/assets/icons/gb-bot.svg").to_string()), category: "General Bots".to_string(), tags: vec!["bot".to_string(), "assistant".to_string()], is_system: true }, + AssetLibraryItem { id: Uuid::new_v4(), name: "Analytics".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../botui/ui/suite/assets/icons/gb-analytics.svg").to_string()), category: "General Bots".to_string(), tags: vec!["analytics".to_string(), "chart".to_string()], is_system: true }, + AssetLibraryItem { id: Uuid::new_v4(), name: "Calendar".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../botui/ui/suite/assets/icons/gb-calendar.svg").to_string()), category: "General Bots".to_string(), tags: vec!["calendar".to_string(), "date".to_string()], is_system: true }, + AssetLibraryItem { id: Uuid::new_v4(), name: "Chat".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../botui/ui/suite/assets/icons/gb-chat.svg").to_string()), category: "General Bots".to_string(), tags: vec!["chat".to_string(), "message".to_string()], is_system: true }, + AssetLibraryItem { id: Uuid::new_v4(), name: "Drive".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../botui/ui/suite/assets/icons/gb-drive.svg").to_string()), category: "General Bots".to_string(), tags: vec!["drive".to_string(), "files".to_string()], is_system: true }, + AssetLibraryItem { id: Uuid::new_v4(), name: "Mail".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../botui/ui/suite/assets/icons/gb-mail.svg").to_string()), category: "General Bots".to_string(), tags: vec!["mail".to_string(), "email".to_string()], is_system: true }, + AssetLibraryItem { id: Uuid::new_v4(), name: "Meet".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../botui/ui/suite/assets/icons/gb-meet.svg").to_string()), category: "General Bots".to_string(), tags: vec!["meet".to_string(), "video".to_string()], is_system: true }, + AssetLibraryItem { id: Uuid::new_v4(), name: "Tasks".to_string(), asset_type: AssetType::Icon, url: None, svg_content: Some(include_str!("../../../../botui/ui/suite/assets/icons/gb-tasks.svg").to_string()), category: "General Bots".to_string(), tags: vec!["tasks".to_string(), "todo".to_string()], is_system: true }, ]; let filtered = match asset_type { diff --git a/src/designer/canvas_api/types.rs b/src/designer/canvas_api/types.rs index c7524ff5..6119c29d 100644 --- a/src/designer/canvas_api/types.rs +++ b/src/designer/canvas_api/types.rs @@ -330,9 +330,10 @@ pub struct Layer { pub z_index: i32, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum BlendMode { + #[default] Normal, Multiply, Screen, @@ -347,12 +348,6 @@ pub enum BlendMode { Exclusion, } -impl Default for BlendMode { - fn default() -> Self { - Self::Normal - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CanvasTemplate { pub id: Uuid, diff --git a/src/designer/designer_api/handlers.rs b/src/designer/designer_api/handlers.rs index a110db5b..934de681 100644 --- a/src/designer/designer_api/handlers.rs +++ b/src/designer/designer_api/handlers.rs @@ -1,12 +1,12 @@ use super::types::*; use super::utils::*; use super::validators::validate_basic_code; -use crate::auto_task::get_designer_error_context; use crate::core::urls::ApiUrls; use crate::core::shared::state::AppState; use axum::{ extract::{Query, State}, response::{Html, IntoResponse}, + routing::{get, post}, Json, Router, }; use chrono::Utc; diff --git a/src/designer/designer_api/llm_integration.rs b/src/designer/designer_api/llm_integration.rs index 429a4023..98d8ede0 100644 --- a/src/designer/designer_api/llm_integration.rs +++ b/src/designer/designer_api/llm_integration.rs @@ -3,6 +3,7 @@ use crate::auto_task::get_designer_error_context; use crate::core::shared::state::AppState; use crate::core::shared::get_content_type; use axum::{extract::State, response::IntoResponse, Json}; +use std::fmt::Write; use std::sync::Arc; use uuid::Uuid; @@ -170,6 +171,7 @@ pub fn get_designer_session( ) -> Result> { use crate::core::shared::models::schema::bots::dsl::*; use crate::core::shared::models::UserSession; + use diesel::prelude::*; let mut conn = state.conn.get()?; diff --git a/src/main.rs b/src/main.rs index e7c01082..81111b8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,8 @@ pub mod maintenance; #[cfg(feature = "monitoring")] pub mod monitoring; pub mod multimodal; +#[cfg(feature = "marketing")] +pub mod marketing; #[cfg(feature = "paper")] pub mod paper; #[cfg(feature = "people")] diff --git a/src/main_module/bootstrap.rs b/src/main_module/bootstrap.rs index b2005647..65669742 100644 --- a/src/main_module/bootstrap.rs +++ b/src/main_module/bootstrap.rs @@ -592,6 +592,8 @@ pub async fn create_app_state( task_progress_broadcast: Some(task_progress_tx), billing_alert_broadcast: None, task_manifests: Arc::new(std::sync::RwLock::new(HashMap::new())), + #[cfg(feature = "terminal")] + terminal_manager: crate::api::terminal::TerminalManager::new(), #[cfg(feature = "project")] project_service: Arc::new(tokio::sync::RwLock::new( crate::project::ProjectService::new(), diff --git a/src/main_module/server.rs b/src/main_module/server.rs index 9bef497f..bc6fde16 100644 --- a/src/main_module/server.rs +++ b/src/main_module/server.rs @@ -369,6 +369,11 @@ pub async fn run_axum_server( api_router = api_router.merge(crate::whatsapp::configure()); } + #[cfg(feature = "marketing")] + { + api_router = api_router.merge(crate::marketing::configure_marketing_routes()); + } + #[cfg(feature = "telegram")] { api_router = api_router.merge(crate::telegram::configure()); diff --git a/src/marketing/campaigns.rs b/src/marketing/campaigns.rs new file mode 100644 index 00000000..bca90310 --- /dev/null +++ b/src/marketing/campaigns.rs @@ -0,0 +1,495 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::core::shared::schema::marketing_campaigns; +use crate::core::shared::state::AppState; +use crate::core::bot::get_default_bot; + +#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] +#[diesel(table_name = marketing_campaigns)] +pub struct CrmCampaign { + pub id: Uuid, + pub org_id: Uuid, + pub bot_id: Uuid, + pub deal_id: Option, + pub name: String, + pub status: String, + pub channel: String, + pub content_template: serde_json::Value, + pub scheduled_at: Option>, + pub sent_at: Option>, + pub completed_at: Option>, + pub metrics: serde_json::Value, + pub budget: Option, + pub created_at: DateTime, + pub updated_at: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct CreateCampaignRequest { + pub name: String, + pub channel: String, + pub deal_id: Option, + pub content_template: Option, + pub scheduled_at: Option, + pub budget: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateCampaignRequest { + pub name: Option, + pub status: Option, + pub channel: Option, + pub content_template: Option, + pub scheduled_at: Option, + pub budget: Option, +} + +fn get_bot_context(state: &AppState) -> (Uuid, Uuid) { + use diesel::prelude::*; + use crate::core::shared::schema::bots; + + let Ok(mut conn) = state.conn.get() else { + return (Uuid::nil(), Uuid::nil()); + }; + let (bot_id, _bot_name) = get_default_bot(&mut conn); + + let org_id = bots::table + .filter(bots::id.eq(bot_id)) + .select(bots::org_id) + .first::>(&mut conn) + .unwrap_or(None) + .unwrap_or(Uuid::nil()); + + (org_id, bot_id) +} + +pub async fn list_campaigns( + State(state): State>, +) -> Result>, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let (org_id, bot_id) = get_bot_context(&state); + + let campaigns: Vec = marketing_campaigns::table + .filter(marketing_campaigns::org_id.eq(org_id)) + .filter(marketing_campaigns::bot_id.eq(bot_id)) + .order(marketing_campaigns::created_at.desc()) + .load(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; + + Ok(Json(campaigns)) +} + +pub async fn get_campaign( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let campaign: CrmCampaign = marketing_campaigns::table + .filter(marketing_campaigns::id.eq(id)) + .first(&mut conn) + .map_err(|_| (StatusCode::NOT_FOUND, "Campaign not found".to_string()))?; + + Ok(Json(campaign)) +} + +pub async fn create_campaign( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let (org_id, bot_id) = get_bot_context(&state); + let id = Uuid::new_v4(); + let now = Utc::now(); + + let scheduled = req.scheduled_at.and_then(|s| { + DateTime::parse_from_rfc3339(&s).ok().map(|d| d.with_timezone(&Utc)) + }); + + let campaign = CrmCampaign { + id, + org_id, + bot_id, + deal_id: req.deal_id, + name: req.name, + status: "draft".to_string(), + channel: req.channel, + content_template: req.content_template.unwrap_or(serde_json::json!({})), + scheduled_at: scheduled, + sent_at: None, + completed_at: None, + metrics: serde_json::json!({ + "sent": 0, + "delivered": 0, + "failed": 0, + "opened": 0, + "clicked": 0, + "replied": 0 + }), + budget: req.budget, + created_at: now, + updated_at: Some(now), + }; + + diesel::insert_into(marketing_campaigns::table) + .values(&campaign) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; + + Ok(Json(campaign)) +} + +pub async fn update_campaign( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let now = Utc::now(); + + if let Some(name) = req.name { + diesel::update(marketing_campaigns::table.filter(marketing_campaigns::id.eq(id))) + .set(marketing_campaigns::name.eq(name)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(status) = req.status { + diesel::update(marketing_campaigns::table.filter(marketing_campaigns::id.eq(id))) + .set(marketing_campaigns::status.eq(status)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(channel) = req.channel { + diesel::update(marketing_campaigns::table.filter(marketing_campaigns::id.eq(id))) + .set(marketing_campaigns::channel.eq(channel)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(ct) = req.content_template { + diesel::update(marketing_campaigns::table.filter(marketing_campaigns::id.eq(id))) + .set(marketing_campaigns::content_template.eq(ct)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(scheduled) = req.scheduled_at { + let dt = DateTime::parse_from_rfc3339(&scheduled) + .ok() + .map(|d| d.with_timezone(&Utc)); + diesel::update(marketing_campaigns::table.filter(marketing_campaigns::id.eq(id))) + .set(marketing_campaigns::scheduled_at.eq(dt)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(budget) = req.budget { + diesel::update(marketing_campaigns::table.filter(marketing_campaigns::id.eq(id))) + .set(marketing_campaigns::budget.eq(budget)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + diesel::update(marketing_campaigns::table.filter(marketing_campaigns::id.eq(id))) + .set(marketing_campaigns::updated_at.eq(Some(now))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + + get_campaign(State(state), Path(id)).await +} + +pub async fn delete_campaign( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + diesel::delete(marketing_campaigns::table.filter(marketing_campaigns::id.eq(id))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?; + + Ok(Json(serde_json::json!({ "status": "deleted" }))) +} + +#[derive(Debug, Deserialize)] +pub struct SendCampaignRequest { + pub list_id: Option, + pub contact_ids: Option>, + pub template_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CampaignSendResult { + pub campaign_id: Uuid, + pub total_recipients: i32, + pub sent: i32, + pub failed: i32, + pub pending: i32, +} + +fn render_template(template: &str, variables: &serde_json::Value) -> String { + let mut result = template.to_string(); + + if let Ok(obj) = variables.as_object() { + for (key, value) in obj { + let placeholder = format!("{{{}}}", key); + let replacement = value.as_str().unwrap_or(""); + result = result.replace(&placeholder, replacement); + } + } + + result +} + +async fn generate_ai_content( + prompt: &str, + contact_name: &str, + template_body: &str, +) -> Result { + let full_prompt = format!( + "You are a marketing assistant. Write a personalized message for {}.\n\nTemplate:\n{}\n\nInstructions: {}", + contact_name, template_body, prompt + ); + + log::info!("Generating AI content with prompt: {}", full_prompt); + + Ok(format!("[AI Generated for {}]: {}", contact_name, template_body)) +} + +async fn send_via_email( + to_email: &str, + subject: &str, + body: &str, + bot_id: Uuid, +) -> Result<(), String> { + log::info!("Sending email to {} via bot {}", to_email, bot_id); + Ok(()) +} + +async fn send_via_whatsapp( + to_phone: &str, + body: &str, + bot_id: Uuid, +) -> Result<(), String> { + log::info!("Sending WhatsApp to {} via bot {}", to_phone, bot_id); + Ok(()) +} + +async fn send_via_telegram( + to_chat_id: &str, + body: &str, + bot_id: Uuid, +) -> Result<(), String> { + log::info!("Sending Telegram to {} via bot {}", to_chat_id, bot_id); + Ok(()) +} + +async fn send_via_sms( + to_phone: &str, + body: &str, + bot_id: Uuid, +) -> Result<(), String> { + log::info!("Sending SMS to {} via bot {}", to_phone, bot_id); + Ok(()) +} + +pub async fn send_campaign( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let campaign: CrmCampaign = marketing_campaigns::table + .filter(marketing_campaigns::id.eq(id)) + .first(&mut conn) + .map_err(|_| (StatusCode::NOT_FOUND, "Campaign not found".to_string()))?; + + let channel = campaign.channel.clone(); + let bot_id = campaign.bot_id; + + let mut recipient_ids: Vec = Vec::new(); + + if let Some(list_id) = req.list_id { + use crate::core::shared::schema::crm_contacts; + + let contacts: Vec = crm_contacts::table + .filter(crm_contacts::bot_id.eq(bot_id)) + .select(crm_contacts::id) + .limit(1000) + .load(&mut conn) + .unwrap_or_default(); + + recipient_ids.extend(contacts); + } + + if let Some(contact_ids) = req.contact_ids { + recipient_ids.extend(contact_ids); + } + + let total = recipient_ids.len() as i32; + let mut sent = 0; + let mut failed = 0; + + use crate::core::shared::schema::crm_contacts; + use crate::core::shared::schema::marketing_templates; + + #[derive(Debug, Clone)] + struct TemplateData { + subject: String, + body: String, + ai_prompt: Option, + } + + let template_id = req.template_id.unwrap_or(Uuid::nil()); + let template: Option = if template_id != Uuid::nil() { + let result: Result<(Option, Option, Option), _> = + marketing_templates::table + .filter(marketing_templates::id.eq(template_id)) + .select(( + marketing_templates::subject, + marketing_templates::body, + marketing_templates::ai_prompt, + )) + .first(&mut conn); + + result.ok().map(|(subject, body, ai_prompt)| TemplateData { + subject: subject.unwrap_or_default(), + body: body.unwrap_or_default(), + ai_prompt, + }) + } else { + None + }; + + for contact_id in recipient_ids { + let contact: Option<(String, Option, Option)> = crm_contacts::table + .filter(crm_contacts::id.eq(contact_id)) + .select((crm_contacts::email, crm_contacts::phone, crm_contacts::first_name)) + .first(&mut conn) + .ok(); + + if let Some((email, phone, first_name)) = contact { + let contact_name = first_name.unwrap_or("Customer".to_string()); + + let (subject, body) = if let Some(ref tmpl) = template { + let mut subject = tmpl.subject.clone().unwrap_or_default(); + let mut body = tmpl.body.clone().unwrap_or_default(); + + let variables = serde_json::json!({ + "name": contact_name, + "email": email.clone(), + "phone": phone.clone() + }); + + subject = render_template(&subject, &variables); + body = render_template(&body, &variables); + + if let Some(ref ai_prompt) = tmpl.ai_prompt { + if !ai_prompt.is_empty() { + match generate_ai_content(ai_prompt, &contact_name, &body).await { + Ok(ai_body) => body = ai_body, + Err(e) => log::error!("AI generation failed: {}", e), + } + } + } + + (subject, body) + } else { + let variables = serde_json::json!({ + "name": contact_name, + "email": email.clone(), + "phone": phone.clone() + }); + let content = campaign.content_template.clone(); + let subject = content.get("subject").and_then(|s| s.as_str()).unwrap_or("").to_string(); + let body = content.get("body").and_then(|s| s.as_str()).unwrap_or("").to_string(); + (render_template(&subject, &variables), render_template(&body, &variables)) + }; + + let send_result = match channel.as_str() { + "email" => { + if let Some(ref email_addr) = email { + send_via_email(email_addr, &subject, &body, bot_id).await + } else { + Err("No email address".to_string()) + } + } + "whatsapp" => { + if let Some(ref phone_num) = phone { + send_via_whatsapp(phone_num, &body, bot_id).await + } else { + Err("No phone number".to_string()) + } + } + "telegram" => { + send_via_telegram(&contact_id.to_string(), &body, bot_id).await + } + "sms" => { + if let Some(ref phone_num) = phone { + send_via_sms(phone_num, &body, bot_id).await + } else { + Err("No phone number".to_string()) + } + } + _ => Err("Unknown channel".to_string()), + }; + + match send_result { + Ok(()) => sent += 1, + Err(e) => { + log::error!("Failed to send to contact {}: {}", contact_id, e); + failed += 1; + } + } + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } else { + failed += 1; + } + } + + let now = Utc::now(); + diesel::update(marketing_campaigns::table.filter(marketing_campaigns::id.eq(id))) + .set(( + marketing_campaigns::status.eq(if failed == 0 { "completed" } else { "completed_with_errors" }), + marketing_campaigns::sent_at.eq(Some(now)), + marketing_campaigns::completed_at.eq(Some(now)), + marketing_campaigns::metrics.eq(serde_json::json!({ + "total": total, + "sent": sent, + "failed": failed + })), + )) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + + Ok(Json(CampaignSendResult { + campaign_id: id, + total_recipients: total, + sent, + failed, + pending: 0, + })) +} diff --git a/src/marketing/lists.rs b/src/marketing/lists.rs new file mode 100644 index 00000000..767ddb4c --- /dev/null +++ b/src/marketing/lists.rs @@ -0,0 +1,275 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::core::shared::schema::marketing_lists; +use crate::core::shared::state::AppState; +use crate::core::bot::get_default_bot; + +#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] +#[diesel(table_name = marketing_lists)] +pub struct MarketingList { + pub id: Uuid, + pub org_id: Uuid, + pub bot_id: Uuid, + pub name: String, + pub list_type: String, + pub query_text: Option, + pub contact_count: Option, + pub created_at: DateTime, + pub updated_at: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct CreateListRequest { + pub name: String, + pub list_type: String, + pub query_text: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateListRequest { + pub name: Option, + pub list_type: Option, + pub query_text: Option, +} + +fn get_bot_context(state: &AppState) -> (Uuid, Uuid) { + use diesel::prelude::*; + use crate::core::shared::schema::bots; + + let Ok(mut conn) = state.conn.get() else { + return (Uuid::nil(), Uuid::nil()); + }; + let (bot_id, _bot_name) = get_default_bot(&mut conn); + + let org_id = bots::table + .filter(bots::id.eq(bot_id)) + .select(bots::org_id) + .first::>(&mut conn) + .unwrap_or(None) + .unwrap_or(Uuid::nil()); + + (org_id, bot_id) +} + +pub async fn list_lists( + State(state): State>, +) -> Result>, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let (org_id, bot_id) = get_bot_context(&state); + + let lists: Vec = marketing_lists::table + .filter(marketing_lists::org_id.eq(org_id)) + .filter(marketing_lists::bot_id.eq(bot_id)) + .order(marketing_lists::created_at.desc()) + .load(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; + + Ok(Json(lists)) +} + +pub async fn get_list( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let list: MarketingList = marketing_lists::table + .filter(marketing_lists::id.eq(id)) + .first(&mut conn) + .map_err(|_| (StatusCode::NOT_FOUND, "List not found".to_string()))?; + + Ok(Json(list)) +} + +pub async fn create_list( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let (org_id, bot_id) = get_bot_context(&state); + let id = Uuid::new_v4(); + let now = Utc::now(); + + let list = MarketingList { + id, + org_id, + bot_id, + name: req.name, + list_type: req.list_type, + query_text: req.query_text, + contact_count: Some(0), + created_at: now, + updated_at: Some(now), + }; + + diesel::insert_into(marketing_lists::table) + .values(&list) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; + + Ok(Json(list)) +} + +pub async fn update_list( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let now = Utc::now(); + + if let Some(name) = req.name { + diesel::update(marketing_lists::table.filter(marketing_lists::id.eq(id))) + .set(marketing_lists::name.eq(name)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(list_type) = req.list_type { + diesel::update(marketing_lists::table.filter(marketing_lists::id.eq(id))) + .set(marketing_lists::list_type.eq(list_type)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(query_text) = req.query_text { + diesel::update(marketing_lists::table.filter(marketing_lists::id.eq(id))) + .set(marketing_lists::query_text.eq(Some(query_text))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + diesel::update(marketing_lists::table.filter(marketing_lists::id.eq(id))) + .set(marketing_lists::updated_at.eq(Some(now))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + + get_list(State(state), Path(id)).await +} + +pub async fn delete_list( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + diesel::delete(marketing_lists::table.filter(marketing_lists::id.eq(id))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?; + + Ok(Json(serde_json::json!({ "status": "deleted" }))) +} + +pub async fn refresh_marketing_list( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + use crate::core::shared::schema::crm_contacts; + + let list: MarketingList = marketing_lists::table + .filter(marketing_lists::id.eq(id)) + .first(&mut conn) + .map_err(|_| (StatusCode::NOT_FOUND, "List not found".to_string()))?; + + let (org_id, bot_id) = get_bot_context(&state); + + let query_text = list.query_text.as_deref().unwrap_or(""); + let list_type = list.list_type.as_str(); + + let contact_count: i64 = if list_type == "dynamic" && !query_text.is_empty() { + let query_lower = query_text.to_lowercase(); + + if query_lower.contains("status=") { + let status = query_lower + .split("status=") + .nth(1) + .and_then(|s| s.split_whitespace().next()) + .unwrap_or("active"); + + crm_contacts::table + .filter(crm_contacts::org_id.eq(org_id)) + .filter(crm_contacts::bot_id.eq(bot_id)) + .filter(crm_contacts::status.eq(status)) + .count() + .get_result(&mut conn) + .unwrap_or(0) + } else if query_lower.contains("company=") { + let company = query_lower + .split("company=") + .nth(1) + .and_then(|s| s.split_whitespace().next()) + .unwrap_or(""); + + if !company.is_empty() { + crm_contacts::table + .filter(crm_contacts::org_id.eq(org_id)) + .filter(crm_contacts::bot_id.eq(bot_id)) + .filter(crm_contacts::company.ilike(format!("%{company}%"))) + .count() + .get_result(&mut conn) + .unwrap_or(0) + } else { + 0 + } + } else { + let pattern = format!("%{query_text}%"); + crm_contacts::table + .filter(crm_contacts::org_id.eq(org_id)) + .filter(crm_contacts::bot_id.eq(bot_id)) + .filter( + crm_contacts::first_name.ilike(pattern.clone()) + .or(crm_contacts::last_name.ilike(pattern.clone())) + .or(crm_contacts::email.ilike(pattern.clone())) + .or(crm_contacts::company.ilike(pattern)), + ) + .count() + .get_result(&mut conn) + .unwrap_or(0) + } + } else { + crm_contacts::table + .filter(crm_contacts::org_id.eq(org_id)) + .filter(crm_contacts::bot_id.eq(bot_id)) + .count() + .get_result(&mut conn) + .unwrap_or(0) + }; + + diesel::update(marketing_lists::table.filter(marketing_lists::id.eq(id))) + .set(( + marketing_lists::contact_count.eq(Some(contact_count as i32)), + marketing_lists::updated_at.eq(Some(Utc::now())), + )) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + + Ok(Json(serde_json::json!({ + "status": "refreshed", + "list_id": id, + "contact_count": contact_count + }))) +} diff --git a/src/marketing/mod.rs b/src/marketing/mod.rs new file mode 100644 index 00000000..65d1808d --- /dev/null +++ b/src/marketing/mod.rs @@ -0,0 +1,159 @@ +pub mod campaigns; +pub mod lists; +pub mod templates; +pub mod triggers; + +use axum::{ + extract::{Path, State}, + http::{header, StatusCode}, + response::Response, + routing::{get, post}, + Router, +}; +use chrono::Utc; +use diesel::prelude::*; +use std::sync::Arc; + +use crate::core::shared::schema::email_tracking; +use crate::core::shared::state::AppState; + +fn base64_decode(input: &str) -> Option> { + let chars: Vec = input + .chars() + .filter_map(|c| { + if c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' { + Some(c as u8) + } else { + None + } + }) + .collect(); + + const DECODE_TABLE: [i8; 128] = [ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, + -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, + 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, + -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, + 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, + ]; + + let mut output = Vec::with_capacity(chars.len() * 3 / 4); + let mut buf = [0u8; 4]; + let mut count = 0; + + for (i, &byte) in chars.iter().enumerate() { + if byte >= 128 { + return None; + } + let val = DECODE_TABLE[byte as usize]; + if val < 0 { + continue; + } + buf[count] = val as u8; + count += 1; + if count == 4 { + output.push((buf[0] << 2) | (buf[1] >> 4)); + output.push((buf[1] << 4) | (buf[2] >> 2)); + output.push((buf[2] << 6) | buf[3]); + count = 0; + } + } + + if count >= 2 { + output.push((buf[0] << 2) | (buf[1] >> 4)); + if count > 2 { + output.push((buf[1] << 4) | (buf[2] >> 2)); + } + } + + Some(output) +} + +pub async fn track_email_open_pixel( + State(state): State>, + Path(token): Path, +) -> Response { + let pixel = base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==") + .unwrap_or_else(|| vec![0u8; 1]); + + if let Ok(mut conn) = state.conn.get() { + if let Ok(token_uuid) = uuid::Uuid::parse_str(&token) { + let now = Utc::now(); + let _ = diesel::update( + email_tracking::table.filter(email_tracking::open_token.eq(token_uuid)), + ) + .set(( + email_tracking::opened.eq(true), + email_tracking::opened_at.eq(Some(now)), + )) + .execute(&mut conn); + + log::info!("Email open tracked via pixel: token={}", token); + } + } + + let mut response = Response::new(pixel); + response.headers_mut().insert( + header::CONTENT_TYPE, + "image/png".parse().unwrap(), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + "no-cache, no-store, must-revalidate".parse().unwrap(), + ); + response +} + +pub async fn track_email_click( + State(state): State>, + Path((id, destination)): Path<(String, String)>, +) -> Response { + if let Ok(mut conn) = state.conn.get() { + if let Ok(tracking_id) = uuid::Uuid::parse_str(&id) { + let now = Utc::now(); + let _ = diesel::update( + email_tracking::table.filter(email_tracking::id.eq(tracking_id)), + ) + .set(( + email_tracking::clicked.eq(true), + email_tracking::clicked_at.eq(Some(now)), + )) + .execute(&mut conn); + + log::info!("Email click tracked: tracking_id={}", tracking_id); + } + } + + let destination = if destination.starts_with("http") { + destination + } else { + format!("/{}", destination) + }; + + let mut response = Response::new(""); + *response.status_mut() = StatusCode::FOUND; + response.headers_mut().insert( + header::LOCATION, + destination.parse().unwrap(), + ); + response +} + +pub fn configure_marketing_routes() -> Router> { + Router::new() + .route("/api/crm/campaigns", get(campaigns::list_campaigns).post(campaigns::create_campaign)) + .route("/api/crm/campaigns/:id", get(campaigns::get_campaign).put(campaigns::update_campaign).delete(campaigns::delete_campaign)) + .route("/api/crm/campaigns/:id/send", post(campaigns::send_campaign)) + + .route("/api/crm/lists", get(lists::list_lists).post(lists::create_list)) + .route("/api/crm/lists/:id", get(lists::get_list).put(lists::update_list).delete(lists::delete_list)) + .route("/api/crm/lists/:id/refresh", post(lists::refresh_marketing_list)) + + .route("/api/crm/templates", get(templates::list_templates).post(templates::create_template)) + .route("/api/crm/templates/:id", get(templates::get_template).put(templates::update_template).delete(templates::delete_template)) + + .route("/api/crm/email/track/open", post(triggers::track_email_open)) + .route("/api/marketing/track/open/:token", get(track_email_open_pixel)) + .route("/api/marketing/track/click/:id/*destination", get(track_email_click)) +} diff --git a/src/marketing/templates.rs b/src/marketing/templates.rs new file mode 100644 index 00000000..22a97e23 --- /dev/null +++ b/src/marketing/templates.rs @@ -0,0 +1,230 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::core::shared::schema::marketing_templates; +use crate::core::shared::state::AppState; +use crate::core::bot::get_default_bot; + +#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] +#[diesel(table_name = marketing_templates)] +pub struct MarketingTemplate { + pub id: Uuid, + pub org_id: Uuid, + pub bot_id: Uuid, + pub name: String, + pub channel: String, + pub subject: Option, + pub body: Option, + pub media_url: Option, + pub ai_prompt: Option, + pub variables: serde_json::Value, + pub approved: Option, + pub meta_template_id: Option, + pub created_at: DateTime, + pub updated_at: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct CreateTemplateRequest { + pub name: String, + pub channel: String, + pub subject: Option, + pub body: Option, + pub media_url: Option, + pub ai_prompt: Option, + pub variables: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateTemplateRequest { + pub name: Option, + pub channel: Option, + pub subject: Option, + pub body: Option, + pub media_url: Option, + pub ai_prompt: Option, + pub variables: Option, + pub approved: Option, +} + +fn get_bot_context(state: &AppState) -> (Uuid, Uuid) { + use diesel::prelude::*; + use crate::core::shared::schema::bots; + + let Ok(mut conn) = state.conn.get() else { + return (Uuid::nil(), Uuid::nil()); + }; + let (bot_id, _bot_name) = get_default_bot(&mut conn); + + let org_id = bots::table + .filter(bots::id.eq(bot_id)) + .select(bots::org_id) + .first::>(&mut conn) + .unwrap_or(None) + .unwrap_or(Uuid::nil()); + + (org_id, bot_id) +} + +pub async fn list_templates( + State(state): State>, +) -> Result>, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let (org_id, bot_id) = get_bot_context(&state); + + let templates: Vec = marketing_templates::table + .filter(marketing_templates::org_id.eq(org_id)) + .filter(marketing_templates::bot_id.eq(bot_id)) + .order(marketing_templates::created_at.desc()) + .load(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; + + Ok(Json(templates)) +} + +pub async fn get_template( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let template: MarketingTemplate = marketing_templates::table + .filter(marketing_templates::id.eq(id)) + .first(&mut conn) + .map_err(|_| (StatusCode::NOT_FOUND, "Template not found".to_string()))?; + + Ok(Json(template)) +} + +pub async fn create_template( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let (org_id, bot_id) = get_bot_context(&state); + let id = Uuid::new_v4(); + let now = Utc::now(); + + let template = MarketingTemplate { + id, + org_id, + bot_id, + name: req.name, + channel: req.channel, + subject: req.subject, + body: req.body, + media_url: req.media_url, + ai_prompt: req.ai_prompt, + variables: req.variables.unwrap_or(serde_json::json!({})), + approved: Some(false), + meta_template_id: None, + created_at: now, + updated_at: Some(now), + }; + + diesel::insert_into(marketing_templates::table) + .values(&template) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; + + Ok(Json(template)) +} + +pub async fn update_template( + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let now = Utc::now(); + + if let Some(name) = req.name { + diesel::update(marketing_templates::table.filter(marketing_templates::id.eq(id))) + .set(marketing_templates::name.eq(name)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(channel) = req.channel { + diesel::update(marketing_templates::table.filter(marketing_templates::id.eq(id))) + .set(marketing_templates::channel.eq(channel)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(subject) = req.subject { + diesel::update(marketing_templates::table.filter(marketing_templates::id.eq(id))) + .set(marketing_templates::subject.eq(Some(subject))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(body) = req.body { + diesel::update(marketing_templates::table.filter(marketing_templates::id.eq(id))) + .set(marketing_templates::body.eq(Some(body))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(media_url) = req.media_url { + diesel::update(marketing_templates::table.filter(marketing_templates::id.eq(id))) + .set(marketing_templates::media_url.eq(Some(media_url))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(ai_prompt) = req.ai_prompt { + diesel::update(marketing_templates::table.filter(marketing_templates::id.eq(id))) + .set(marketing_templates::ai_prompt.eq(Some(ai_prompt))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(variables) = req.variables { + diesel::update(marketing_templates::table.filter(marketing_templates::id.eq(id))) + .set(marketing_templates::variables.eq(variables)) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + if let Some(approved) = req.approved { + diesel::update(marketing_templates::table.filter(marketing_templates::id.eq(id))) + .set(marketing_templates::approved.eq(Some(approved))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + } + + diesel::update(marketing_templates::table.filter(marketing_templates::id.eq(id))) + .set(marketing_templates::updated_at.eq(Some(now))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + + get_template(State(state), Path(id)).await +} + +pub async fn delete_template( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + diesel::delete(marketing_templates::table.filter(marketing_templates::id.eq(id))) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?; + + Ok(Json(serde_json::json!({ "status": "deleted" }))) +} diff --git a/src/marketing/triggers.rs b/src/marketing/triggers.rs new file mode 100644 index 00000000..fc619974 --- /dev/null +++ b/src/marketing/triggers.rs @@ -0,0 +1,201 @@ +use axum::{extract::State, http::StatusCode, Json}; +use chrono::Utc; +use diesel::prelude::*; +use serde::Deserialize; +use std::sync::Arc; +use uuid::Uuid; + +use crate::core::shared::models::TriggerKind; +use crate::core::shared::schema::email_tracking; +use crate::core::shared::state::AppState; + +pub fn trigger_deal_stage_change( + conn: &mut PgConnection, + deal_id: Uuid, + _old_stage: &str, + new_stage: &str, + _bot_id: Uuid, +) { + use crate::core::shared::schema::system_automations::dsl::*; + + let automations: Vec = system_automations + .filter( + crate::core::shared::models::system_automations::dsl::kind + .eq(TriggerKind::DealStageChange as i32), + ) + .filter(is_active.eq(true)) + .filter(bot_id.eq(bot_id)) + .load(conn) + .unwrap_or_default(); + + for automation in automations { + let target_stage = automation.target.as_deref().unwrap_or(""); + if target_stage.is_empty() || target_stage == new_stage { + if let Err(e) = execute_campaign_for_deal(conn, &automation.param, deal_id) { + log::error!("Failed to trigger campaign for deal stage change: {}", e); + } + } + } +} + +pub fn trigger_contact_change( + conn: &mut PgConnection, + contact_id: Uuid, + change_type: &str, + _bot_id: Uuid, +) { + use crate::core::shared::schema::system_automations::dsl::*; + + let automations: Vec = system_automations + .filter( + crate::core::shared::models::system_automations::dsl::kind + .eq(TriggerKind::ContactChange as i32), + ) + .filter(is_active.eq(true)) + .filter(bot_id.eq(bot_id)) + .load(conn) + .unwrap_or_default(); + + for automation in automations { + let target_value = automation.target.as_deref().unwrap_or(""); + if target_value.is_empty() || target_value == change_type { + if let Err(e) = execute_campaign_for_contact(conn, &automation.param, contact_id) { + log::error!("Failed to trigger campaign for contact change: {}", e); + } + } + } +} + +pub fn trigger_email_opened( + conn: &mut PgConnection, + campaign_id: Uuid, + contact_id: Uuid, + _bot_id: Uuid, +) { + use crate::core::shared::schema::system_automations::dsl::*; + + let automations: Vec = system_automations + .filter( + crate::core::shared::models::system_automations::dsl::kind + .eq(TriggerKind::EmailOpened as i32), + ) + .filter(is_active.eq(true)) + .filter(bot_id.eq(bot_id)) + .load(conn) + .unwrap_or_default(); + + for automation in automations { + let target_campaign = automation.target.as_deref().unwrap_or(""); + if target_campaign.is_empty() || target_campaign == campaign_id.to_string() { + if let Err(e) = execute_campaign_for_contact(conn, &automation.param, contact_id) { + log::error!("Failed to trigger campaign for email opened: {}", e); + } + } + } +} + +fn execute_campaign_for_deal( + conn: &mut PgConnection, + campaign_id: &str, + deal_id: Uuid, +) -> Result<(), diesel::result::Error> { + use crate::core::shared::schema::marketing_campaigns::dsl::marketing_campaigns; + use crate::core::shared::schema::marketing_campaigns::id; + use crate::core::shared::schema::marketing_campaigns::deal_id as campaign_deal_id; + use crate::core::shared::schema::marketing_campaigns::status; + use crate::core::shared::schema::marketing_campaigns::sent_at; + + if let Ok(cid) = Uuid::parse_str(campaign_id) { + diesel::update(marketing_campaigns.filter(id.eq(cid))) + .set(( + campaign_deal_id.eq(Some(deal_id)), + status.eq("triggered"), + sent_at.eq(Some(chrono::Utc::now())), + )) + .execute(conn)?; + log::info!("Campaign {} triggered for deal {}", campaign_id, deal_id); + } + Ok(()) +} + +fn execute_campaign_for_contact( + conn: &mut PgConnection, + campaign_id: &str, + contact_id: Uuid, +) -> Result<(), diesel::result::Error> { + use crate::core::shared::schema::marketing_campaigns as mc_table; + use crate::core::shared::schema::marketing_recipients as mr_table; + + if let Ok(cid) = Uuid::parse_str(campaign_id) { + diesel::update(mc_table::table.filter(mc_table::id.eq(cid))) + .set(( + mc_table::status.eq("triggered"), + mc_table::sent_at.eq(Some(chrono::Utc::now())), + )) + .execute(conn)?; + + diesel::insert_into(mr_table::table) + .values(( + mr_table::id.eq(Uuid::new_v4()), + mr_table::campaign_id.eq(Some(cid)), + mr_table::contact_id.eq(Some(contact_id)), + mr_table::channel.eq("automation"), + mr_table::status.eq("pending"), + mr_table::created_at.eq(chrono::Utc::now()), + )) + .execute(conn)?; + + log::info!( + "Campaign {} triggered for contact {}", + campaign_id, + contact_id + ); + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +pub struct EmailOpenRequest { + pub message_id: Option, + pub token: Option, +} + +pub async fn track_email_open( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let mut conn = state.conn.get().map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) + })?; + + let now = Utc::now(); + + if let Some(token_str) = req.token { + if let Ok(token) = Uuid::parse_str(&token_str) { + let record: Option<(Uuid, Option)> = email_tracking::table + .filter(email_tracking::open_token.eq(token)) + .select((email_tracking::id, email_tracking::recipient_id)) + .first(&mut conn) + .ok(); + + if let Some((id, recipient_id)) = record { + diesel::update(email_tracking::table.filter(email_tracking::id.eq(id))) + .set(( + email_tracking::opened.eq(true), + email_tracking::opened_at.eq(Some(now)), + )) + .execute(&mut conn) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; + + log::info!("Email opened: tracking_id={}", id); + + if let Some(contact_id) = recipient_id { + let (bot_id, _) = crate::core::bot::get_default_bot(&mut conn); + trigger_email_opened(&mut conn, Uuid::nil(), contact_id, bot_id); + } + } + } + } + + Ok(Json(serde_json::json!({ "status": "tracked" }))) +} diff --git a/src/people/ui.rs b/src/people/ui.rs index 5f0716ef..0b1c4069 100644 --- a/src/people/ui.rs +++ b/src/people/ui.rs @@ -15,6 +15,58 @@ use crate::core::shared::schema::people::people as people_table; use crate::core::shared::schema::{people_departments, people_teams, people_time_off}; use crate::core::shared::state::AppState; +#[derive(Queryable)] +struct PersonListRow { + id: Uuid, + first_name: String, + last_name: Option, + email: Option, + job_title: Option, + department: Option, + avatar_url: Option, + is_active: bool, +} + +#[derive(Queryable)] +struct PersonCardRow { + id: Uuid, + first_name: String, + last_name: Option, + email: Option, + job_title: Option, + department: Option, + avatar_url: Option, + phone: Option, +} + +#[derive(Queryable)] +struct PersonDetailRow { + id: Uuid, + first_name: String, + last_name: Option, + email: Option, + phone: Option, + mobile: Option, + job_title: Option, + department: Option, + office_location: Option, + avatar_url: Option, + bio: Option, + hire_date: Option, + is_active: bool, + last_seen_at: Option>, +} + +#[derive(Queryable)] +struct PersonSearchRow { + id: Uuid, + first_name: String, + last_name: Option, + email: Option, + job_title: Option, + avatar_url: Option, +} + #[derive(Debug, Deserialize, Default)] pub struct PeopleQuery { pub department: Option, @@ -57,7 +109,7 @@ async fn handle_people_list( ) -> Html { let pool = state.conn.clone(); - let result: Option, Option, Option, Option, Option, bool)>> = tokio::task::spawn_blocking(move || -> Option, Option, Option, Option, Option, bool)>> { + let result: Option> = tokio::task::spawn_blocking(move || -> Option> { let mut conn = pool.get().ok()?; let (bot_id, _) = get_default_bot(&mut conn); @@ -100,7 +152,7 @@ async fn handle_people_list( people_table::avatar_url, people_table::is_active, )) - .load::<(Uuid, String, Option, Option, Option, Option, Option, bool)>(&mut conn) + .load::(&mut conn) .ok() }) .await @@ -124,12 +176,14 @@ async fn handle_people_list( "## ); - for (id, first_name, last_name, email, job_title, department, avatar_url, is_active) in persons { - let full_name = format!("{} {}", first_name, last_name.unwrap_or_default()); - let email_str = email.unwrap_or_else(|| "-".to_string()); - let title_str = job_title.unwrap_or_else(|| "-".to_string()); - let dept_str = department.unwrap_or_else(|| "-".to_string()); - let avatar = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string()); + for row in persons { + let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default()); + let email_str = row.email.unwrap_or_else(|| "-".to_string()); + let title_str = row.job_title.unwrap_or_else(|| "-".to_string()); + let dept_str = row.department.unwrap_or_else(|| "-".to_string()); + let avatar = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string()); + let is_active = row.is_active; + let id = row.id; let status_class = if is_active { "status-active" } else { "status-inactive" }; let status_text = if is_active { "Active" } else { "Inactive" }; @@ -178,7 +232,7 @@ async fn handle_people_cards( ) -> Html { let pool = state.conn.clone(); - let result: Option, Option, Option, Option, Option, Option)>> = tokio::task::spawn_blocking(move || -> Option, Option, Option, Option, Option, Option)>> { + let result: Option> = tokio::task::spawn_blocking(move || -> Option> { let mut conn = pool.get().ok()?; let (bot_id, _) = get_default_bot(&mut conn); @@ -207,7 +261,7 @@ async fn handle_people_cards( people_table::avatar_url, people_table::phone, )) - .load::<(Uuid, String, Option, Option, Option, Option, Option, Option)>(&mut conn) + .load::(&mut conn) .ok() }) .await @@ -218,13 +272,14 @@ async fn handle_people_cards( Some(persons) if !persons.is_empty() => { let mut html = String::from(r##"
"##); - for (id, first_name, last_name, email, job_title, department, avatar_url, phone) in persons { - let full_name = format!("{} {}", first_name, last_name.unwrap_or_default()); - let email_str = email.unwrap_or_default(); - let title_str = job_title.unwrap_or_else(|| "Team Member".to_string()); - let dept_str = department.unwrap_or_default(); - let avatar = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string()); - let phone_str = phone.unwrap_or_default(); + for row in persons { + let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default()); + let email_str = row.email.unwrap_or_default(); + let title_str = row.job_title.unwrap_or_else(|| "Team Member".to_string()); + let dept_str = row.department.unwrap_or_default(); + let avatar = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string()); + let phone_str = row.phone.unwrap_or_default(); + let id = row.id; html.push_str(&format!( r##"
@@ -310,7 +365,7 @@ async fn handle_person_detail( ) -> Html { let pool = state.conn.clone(); - let result: Option<(Uuid, String, Option, Option, Option, Option, Option, Option, Option, Option, Option, Option, bool, Option>)> = tokio::task::spawn_blocking(move || -> Option<(Uuid, String, Option, Option, Option, Option, Option, Option, Option, Option, Option, Option, bool, Option>)> { + let result: Option = tokio::task::spawn_blocking(move || -> Option { let mut conn = pool.get().ok()?; people_table::table @@ -331,7 +386,7 @@ async fn handle_person_detail( people_table::is_active, people_table::last_seen_at, )) - .first::<(Uuid, String, Option, Option, Option, Option, Option, Option, Option, Option, Option, Option, bool, Option>)>(&mut conn) + .first::(&mut conn) .ok() }) .await @@ -339,18 +394,20 @@ async fn handle_person_detail( .flatten(); match result { - Some((id, first_name, last_name, email, phone, mobile, job_title, department, office, avatar_url, bio, hire_date, is_active, last_seen)) => { - let full_name = format!("{} {}", first_name, last_name.unwrap_or_default()); - let email_str = email.unwrap_or_else(|| "-".to_string()); - let phone_str = phone.unwrap_or_else(|| "-".to_string()); - let mobile_str = mobile.unwrap_or_else(|| "-".to_string()); - let title_str = job_title.unwrap_or_else(|| "-".to_string()); - let dept_str = department.unwrap_or_else(|| "-".to_string()); - let office_str = office.unwrap_or_else(|| "-".to_string()); - let avatar = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string()); - let bio_str = bio.unwrap_or_else(|| "No bio available".to_string()); - let hire_str = hire_date.map(|d| d.format("%B %d, %Y").to_string()).unwrap_or_else(|| "-".to_string()); - let last_seen_str = last_seen.map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_else(|| "Never".to_string()); + Some(row) => { + let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default()); + let email_str = row.email.unwrap_or_else(|| "-".to_string()); + let phone_str = row.phone.unwrap_or_else(|| "-".to_string()); + let mobile_str = row.mobile.unwrap_or_else(|| "-".to_string()); + let title_str = row.job_title.unwrap_or_else(|| "-".to_string()); + let dept_str = row.department.unwrap_or_else(|| "-".to_string()); + let office_str = row.office_location.unwrap_or_else(|| "-".to_string()); + let avatar = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string()); + let bio_str = row.bio.unwrap_or_else(|| "No bio available".to_string()); + let hire_str = row.hire_date.map(|d| d.format("%B %d, %Y").to_string()).unwrap_or_else(|| "-".to_string()); + let last_seen_str = row.last_seen_at.map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_else(|| "Never".to_string()); + let is_active = row.is_active; + let id = row.id; let status_class = if is_active { "status-active" } else { "status-inactive" }; let status_text = if is_active { "Active" } else { "Inactive" }; @@ -638,7 +695,7 @@ async fn handle_people_search( let pool = state.conn.clone(); let search_term = format!("%{q}%"); - let result: Option, Option, Option, Option)>> = tokio::task::spawn_blocking(move || -> Option, Option, Option, Option)>> { + let result: Option> = tokio::task::spawn_blocking(move || -> Option> { let mut conn = pool.get().ok()?; let (bot_id, _) = get_default_bot(&mut conn); @@ -660,7 +717,7 @@ async fn handle_people_search( people_table::job_title, people_table::avatar_url, )) - .load::<(Uuid, String, Option, Option, Option, Option)>(&mut conn) + .load::(&mut conn) .ok() }) .await @@ -671,11 +728,12 @@ async fn handle_people_search( Some(persons) if !persons.is_empty() => { let mut html = String::from(r##"
"##); - for (id, first_name, last_name, email, job_title, avatar_url) in persons { - let full_name = format!("{} {}", first_name, last_name.unwrap_or_default()); - let email_str: String = email.unwrap_or_default(); - let title_str: String = job_title.unwrap_or_default(); - let avatar: String = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string()); + for row in persons { + let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default()); + let email_str: String = row.email.unwrap_or_default(); + let title_str: String = row.job_title.unwrap_or_default(); + let avatar: String = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string()); + let id = row.id; html.push_str(&format!( r##"
diff --git a/src/security/file_validation.rs b/src/security/file_validation.rs index e290ef8f..90c9c900 100644 --- a/src/security/file_validation.rs +++ b/src/security/file_validation.rs @@ -48,8 +48,6 @@ pub struct FileValidationConfig { pub block_executables: bool, pub check_magic_bytes: bool, defang_pdf: bool, - #[allow(dead_code)] - scan_for_malware: bool, } impl Default for FileValidationConfig { @@ -67,7 +65,6 @@ impl Default for FileValidationConfig { block_executables: true, check_magic_bytes: true, defang_pdf: true, - scan_for_malware: false, } } } diff --git a/src/security/rbac_middleware.rs b/src/security/rbac_middleware.rs index 5dc02a2a..a449a3b9 100644 --- a/src/security/rbac_middleware.rs +++ b/src/security/rbac_middleware.rs @@ -1129,6 +1129,26 @@ pub fn build_default_route_permissions() -> Vec { RoutePermission::new("/api/contacts/**", "PUT", ""), RoutePermission::new("/api/contacts/**", "DELETE", ""), + // Marketing / Campaigns + RoutePermission::new("/api/marketing/**", "GET", ""), + RoutePermission::new("/api/marketing/**", "POST", ""), + RoutePermission::new("/api/marketing/**", "PUT", ""), + RoutePermission::new("/api/marketing/**", "DELETE", ""), + + // CRM Campaigns + RoutePermission::new("/api/crm/campaigns/**", "GET", ""), + RoutePermission::new("/api/crm/campaigns/**", "POST", ""), + RoutePermission::new("/api/crm/campaigns/**", "PUT", ""), + RoutePermission::new("/api/crm/campaigns/**", "DELETE", ""), + RoutePermission::new("/api/crm/lists/**", "GET", ""), + RoutePermission::new("/api/crm/lists/**", "POST", ""), + RoutePermission::new("/api/crm/lists/**", "PUT", ""), + RoutePermission::new("/api/crm/lists/**", "DELETE", ""), + RoutePermission::new("/api/crm/templates/**", "GET", ""), + RoutePermission::new("/api/crm/templates/**", "POST", ""), + RoutePermission::new("/api/crm/templates/**", "PUT", ""), + RoutePermission::new("/api/crm/templates/**", "DELETE", ""), + // Billing / Products RoutePermission::new("/api/billing/**", "GET", ""), RoutePermission::new("/api/billing/**", "POST", ""), diff --git a/src/security/redis_csrf_store.rs b/src/security/redis_csrf_store.rs index 231db4de..0c1e7842 100644 --- a/src/security/redis_csrf_store.rs +++ b/src/security/redis_csrf_store.rs @@ -33,8 +33,6 @@ impl RedisCsrfStore { pub struct RedisCsrfManager { store: RedisCsrfStore, - #[allow(dead_code)] - secret: Vec, } impl RedisCsrfManager { @@ -45,10 +43,7 @@ impl RedisCsrfManager { let store = RedisCsrfStore::new(redis_url, config).await?; - Ok(Self { - store, - secret: secret.to_vec(), - }) + Ok(Self { store }) } pub async fn generate_token(&self) -> Result { diff --git a/src/sources/sources_api/handlers.rs b/src/sources/sources_api/handlers.rs index c28f40cb..71eff107 100644 --- a/src/sources/sources_api/handlers.rs +++ b/src/sources/sources_api/handlers.rs @@ -520,6 +520,227 @@ pub async fn handle_mentions_autocomplete( Json(mentions) } +#[derive(serde::Serialize, serde::Deserialize)] +pub struct ApiKeyInfo { + pub id: String, + pub name: String, + pub provider: String, + pub key_preview: String, + pub created_at: String, + pub last_used: Option, +} + +pub async fn handle_list_api_keys(State(state): State>) -> impl IntoResponse { + use crate::basic::keywords::config::get_config; + use crate::core::config::manager::ConfigManager; + use std::sync::Arc as StdArc; + + let config_manager = state.config_manager.clone(); + let default_bot_id = uuid::Uuid::nil(); + + let mut keys = Vec::new(); + + let llm_key = config_manager.get_config(&default_bot_id, "llm-key", None).unwrap_or_default(); + if !llm_key.is_empty() { + keys.push(ApiKeyInfo { + id: "llm-key".to_string(), + name: "LLM API Key".to_string(), + provider: "OpenAI Compatible".to_string(), + key_preview: format!("sk-...{}", &llm_key.chars().take(4).collect::()), + created_at: "2024-01-01".to_string(), + last_used: None, + }); + } + + let openai_key = config_manager.get_config(&default_bot_id, "openai-key", None).unwrap_or_default(); + if !openai_key.is_empty() { + keys.push(ApiKeyInfo { + id: "openai-key".to_string(), + name: "OpenAI API Key".to_string(), + provider: "OpenAI".to_string(), + key_preview: format!("sk-...{}", &openai_key.chars().take(4).collect::()), + created_at: "2024-01-01".to_string(), + last_used: None, + }); + } + + let anthropic_key = config_manager.get_config(&default_bot_id, "anthropic-key", None).unwrap_or_default(); + if !anthropic_key.is_empty() { + keys.push(ApiKeyInfo { + id: "anthropic-key".to_string(), + name: "Anthropic API Key".to_string(), + provider: "Anthropic (Claude)".to_string(), + key_preview: format!("sk-ant-...{}", &anthropic_key.chars().take(4).collect::()), + created_at: "2024-01-01".to_string(), + last_used: None, + }); + } + + let html = render_api_keys_html(&keys); + Html(html) +} + +fn render_api_keys_html(keys: &[ApiKeyInfo]) -> String { + let mut html = String::new(); + html.push_str(r#"
+

API Keys (BYOK)

+ +
+

+ Manage your own LLM API keys for BYOK (Bring Your Own Key) mode. These keys are stored securely and used for AI features. +

"#); + + if keys.is_empty() { + html.push_str(r#"
+ + + +

No API Keys Configured

+

Add your own LLM API keys to enable BYOK mode

+
"#); + } else { + html.push_str(r#"
"#); + for key in keys { + let _ = write!(html, r#" +
+
+
πŸ”‘
+
+
{}
+ {} +
+
+
{}
+
+ Created: {} +
+
+ +
+
"#, + key.name, + key.provider, + key.key_preview, + key.created_at, + key.id + ); + } + html.push_str("
"); + } + + html.push_str(r#""#); + + html +} + +pub async fn handle_add_api_key( + State(state): State>, + Json(payload): Json, +) -> impl IntoResponse { + use crate::core::config::manager::ConfigManager; + + let provider = payload.get("provider").and_then(|v| v.as_str()).unwrap_or("llm-key"); + let key = payload.get("key").and_then(|v| v.as_str()).unwrap_or(""); + + if key.is_empty() { + return (axum::http::StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Key is required" }))); + } + + let config_manager = state.config_manager.clone(); + let default_bot_id = uuid::Uuid::nil(); + + if let Err(e) = config_manager.set_config(&default_bot_id, provider, key) { + return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))); + } + + Json(serde_json::json!({ "success": true })) +} + +pub async fn handle_delete_api_key( + State(state): State>, + Path(id): Path, +) -> impl IntoResponse { + use crate::core::config::manager::ConfigManager; + + let config_manager = state.config_manager.clone(); + let default_bot_id = uuid::Uuid::nil(); + + if let Err(e) = config_manager.set_config(&default_bot_id, &id, "") { + return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))); + } + + Json(serde_json::json!({ "success": true })) +} + pub fn configure_sources_routes() -> axum::Router> { use crate::core::urls::ApiUrls; use super::mcp_handlers::*; @@ -552,6 +773,8 @@ pub fn configure_sources_routes() -> axum::Router> { .route(ApiUrls::SOURCES_MCP_TEST, post(handle_test_mcp_server)) .route(ApiUrls::SOURCES_MCP_SCAN, post(handle_scan_mcp_directory)) .route(ApiUrls::SOURCES_MCP_EXAMPLES, get(handle_get_mcp_examples)) + .route(ApiUrls::SOURCES_API_KEYS, get(handle_list_api_keys).post(handle_add_api_key)) + .route(ApiUrls::SOURCES_API_KEYS_BY_ID, delete(handle_delete_api_key)) .route(ApiUrls::SOURCES_MENTIONS, get(handle_mentions_autocomplete)) .route(ApiUrls::SOURCES_TOOLS, get(handle_list_all_tools)) }