feat: add campaigns, attendance SLA, and marketing modules

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-03-14 16:35:42 -03:00
parent 13892b3157
commit 7fb73e683f
46 changed files with 4579 additions and 408 deletions

View file

@ -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"]

View file

@ -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

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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<Arc<AppState>> {
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<Child>,
stdin: Option<Arc<Mutex<ChildStdin>>>,
output_tx: mpsc::Sender<TerminalOutput>,
}
#[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::<String>()
);
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<TerminalOutput> {
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<HashMap<String, TerminalSession>>,
}
impl TerminalManager {
pub fn new() -> Arc<Self> {
Arc::new(Self {
sessions: RwLock::new(HashMap::new()),
})
}
pub async fn create_session(&self, session_id: &str) -> Result<TerminalInfo, String> {
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<TerminalSession> {
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<TerminalInfo> {
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<String>,
}
pub async fn terminal_ws(
State(_state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
// Note: Mock websocket connection upgrade logic
Ok(Json(serde_json::json!({ "status": "Upgrade required" })))
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Query(query): Query<TerminalQuery>,
) -> 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<AppState>,
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::<u16>(),
parts[2].parse::<u16>(),
) {
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<String>,
}
pub async fn create_terminal(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateTerminalRequest>,
) -> 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<Arc<AppState>>,
Json(payload): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
) -> impl IntoResponse {
let terminals = state.terminal_manager.list_sessions().await;
Json(serde_json::json!({
"terminals": terminals
}))
}

View file

@ -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<Arc<AppState>> {
.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

View file

@ -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<String>,
pub channel: Option<String>,
}
pub async fn assign_by_skill(
State(state): State<Arc<AppState>>,
Json(request): Json<SkillBasedAssignRequest>,
) -> 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<AttendantCSV> = 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::<Option<AttendantCSV>, 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<Arc<AppState>>,
Json(request): Json<TransferRequest>,
@ -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<QueueItem>,
}
#[derive(Debug, Serialize)]
pub struct KanbanBoard {
pub columns: Vec<KanbanColumn>,
}
#[derive(Debug, Deserialize)]
pub struct KanbanQuery {
pub bot_id: Option<Uuid>,
pub channel: Option<String>,
}
pub async fn get_kanban(
State(state): State<Arc<AppState>>,
Query(query): Query<KanbanQuery>,
) -> Result<Json<KanbanBoard>, (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<UserSession> = 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 }))
}

144
src/attendance/sla.rs Normal file
View file

@ -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<diesel_async::AsyncPgConnection>,
}
#[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<AppState>) {
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<AppState>) -> 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<chrono::Utc>)>(&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<String>,
pub priority: Option<String>,
pub first_response_minutes: Option<i32>,
pub resolution_minutes: Option<i32>,
pub escalate_on_breach: Option<bool>,
}
#[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<chrono::Utc>,
}

329
src/attendance/webhooks.rs Normal file
View file

@ -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<String>,
pub is_active: bool,
pub secret_key: Option<String>,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: Option<chrono::DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateWebhookRequest {
pub webhook_url: String,
pub events: Vec<String>,
pub secret_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateWebhookRequest {
pub webhook_url: Option<String>,
pub events: Option<Vec<String>>,
pub is_active: Option<bool>,
pub secret_key: Option<String>,
}
#[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::<Option<Uuid>>(&mut conn)
.unwrap_or(None)
.unwrap_or(Uuid::nil());
(org_id, bot_id)
}
pub async fn list_webhooks(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<AttendanceWebhook>>, (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<AttendanceWebhook> = 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<Arc<AppState>>,
Json(req): Json<CreateWebhookRequest>,
) -> Result<Json<AttendanceWebhook>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<AttendanceWebhook>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateWebhookRequest>,
) -> Result<Json<AttendanceWebhook>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (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<String>, Option<String>)> = 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);
}
}

View file

@ -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];

View file

@ -316,7 +316,7 @@ impl ScriptService {
}
}
}
fn preprocess_basic_script(&self, script: &str) -> String {
fn preprocess_basic_script(&self, script: &str) -> Result<String, String> {
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<rhai::AST, Box<EvalAltResult>> {
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

View file

@ -36,9 +36,10 @@ pub struct Contact {
pub updated_at: DateTime<Utc>,
}
#[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 {

File diff suppressed because it is too large Load diff

View file

@ -118,6 +118,16 @@ pub struct UpdateTaskContactRequest {
pub notes: Option<String>,
}
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<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskContactsQuery {
pub role: Option<TaskContactRole>,
@ -138,6 +148,23 @@ pub struct ContactTasksQuery {
pub sort_order: Option<SortOrder>,
}
#[derive(Queryable)]
pub struct ContactRow {
pub id: Uuid,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub email: Option<String>,
pub company: Option<String>,
pub job_title: Option<String>,
}
#[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<Utc>,
_params: TaskAssignmentParams<'_>,
) -> Result<(), TasksIntegrationError> {
// Insert into task_contacts table
Ok(())
@ -865,8 +886,7 @@ 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 {
if let Ok((tid, Some(assignee_id), created_at)) = task_row {
// Look up person -> email -> contact
let person_email: Result<Option<String>, _> = people_table::table
.filter(people_table::id.eq(assignee_id))
@ -895,7 +915,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, String, Option<DateTime<Utc>>, Option<Uuid>, i32, DateTime<Utc>, DateTime<Utc>)> = db_query
#[derive(Queryable)]
struct TaskRow {
id: Uuid,
title: String,
description: Option<String>,
status: String,
priority: String,
due_date: Option<DateTime<Utc>>,
project_id: Option<Uuid>,
progress: i32,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
let rows: Vec<TaskRow> = 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<String>, Option<String>, Option<String>, Option<String>, Option<String>)> = query
let rows: Vec<ContactRow> = 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<String>, Option<String>, Option<String>, Option<String>, Option<String>)> = query
let rows: Vec<ContactRow> = 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<String>, Option<String>, Option<String>, Option<String>, Option<String>)> = query
let rows: Vec<ContactRow> = 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]

View file

@ -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<Arc<WhatsAppMessageQueue>> = std::sync::OnceLock::new();
static WHATSAPP_QUEUE: std::sync::OnceLock<Option<Arc<WhatsAppMessageQueue>>> = 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<WhatsAppMessageQueue>,
_voice_response: bool,
queue: Option<&'static Arc<WhatsAppMessageQueue>>,
}
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);
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;
});
queue
}),
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::<String>());
@ -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

View file

@ -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",

View file

@ -213,12 +213,6 @@ struct ScalewayEmbeddingResponse {
#[derive(Debug, Deserialize)]
struct ScalewayEmbeddingData {
embedding: Vec<f32>,
#[serde(default)]
#[allow(dead_code)]
index: usize,
#[serde(default)]
#[allow(dead_code)]
object: Option<String>,
}
// 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<String>,
#[serde(default)]
cost_metric_value_1: Option<f64>,
}

View file

@ -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<String, ComponentEntry>,
}
static THIRDPARTY_CONFIG: Lazy<ThirdPartyConfig> = Lazy::new(|| {
static THIRDPARTY_CONFIG: std::sync::OnceLock<ThirdPartyConfig> = std::sync::OnceLock::new();
fn get_thirdparty_config() -> &'static ThirdPartyConfig {
THIRDPARTY_CONFIG.get_or_init(|| {
let toml_str = include_str!("../../../3rdparty.toml");
toml::from_str(toml_str).unwrap_or_else(|e| {
panic!("Failed to parse embedded 3rdparty.toml: {e}")
match toml::from_str::<ThirdPartyConfig>(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<String> {
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 {

View file

@ -1,4 +1,4 @@
#![cfg_attr(feature = "mail", allow(unused_imports))]
use axum::{Router, routing::{get, post}};
use std::sync::Arc;

View file

@ -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,
}
}

View file

@ -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<Uuid>,
account_id -> Nullable<Uuid>,
am_id -> Nullable<Uuid>,
owner_id -> Nullable<Uuid>,
lead_id -> Nullable<Uuid>,
title -> Nullable<Varchar>,
name -> Nullable<Varchar>,
description -> Nullable<Text>,
value -> Nullable<Float8>,
currency -> Nullable<Varchar>,
stage_id -> Nullable<Uuid>,
stage -> Nullable<Varchar>,
probability -> Int4,
source -> Nullable<Varchar>,
segment_id -> Nullable<Uuid>,
department_id -> Nullable<Uuid>,
expected_close_date -> Nullable<Date>,
actual_close_date -> Nullable<Date>,
period -> Nullable<Int4>,
deal_date -> Nullable<Date>,
closed_at -> Nullable<Timestamptz>,
lost_reason -> Nullable<Varchar>,
won -> Nullable<Bool>,
notes -> Nullable<Text>,
tags -> Array<Text>,
created_at -> Timestamptz,
updated_at -> Nullable<Timestamptz>,
custom_fields -> Jsonb,
}
}
diesel::table! {
crm_deal_segments (id) {
id -> Uuid,
org_id -> Uuid,
bot_id -> Uuid,
name -> Varchar,
description -> Nullable<Varchar>,
created_at -> Timestamptz,
}
}
diesel::table! {
marketing_campaigns (id) {
id -> Uuid,
org_id -> Uuid,
bot_id -> Uuid,
deal_id -> Nullable<Uuid>,
name -> Varchar,
status -> Varchar,
channel -> Varchar,
content_template -> Jsonb,
scheduled_at -> Nullable<Timestamptz>,
sent_at -> Nullable<Timestamptz>,
completed_at -> Nullable<Timestamptz>,
metrics -> Jsonb,
budget -> Nullable<Float8>,
created_at -> Timestamptz,
updated_at -> Nullable<Timestamptz>,
}
}
diesel::table! {
marketing_lists (id) {
id -> Uuid,
org_id -> Uuid,
bot_id -> Uuid,
name -> Varchar,
list_type -> Varchar,
query_text -> Nullable<Text>,
contact_count -> Nullable<Int4>,
created_at -> Timestamptz,
updated_at -> Nullable<Timestamptz>,
}
}
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<Uuid>,
contact_id -> Nullable<Uuid>,
deal_id -> Nullable<Uuid>,
channel -> Varchar,
status -> Varchar,
sent_at -> Nullable<Timestamptz>,
delivered_at -> Nullable<Timestamptz>,
failed_at -> Nullable<Timestamptz>,
error_message -> Nullable<Text>,
response -> Nullable<Jsonb>,
created_at -> Timestamptz,
}
}
diesel::table! {
marketing_templates (id) {
id -> Uuid,
org_id -> Uuid,
bot_id -> Uuid,
name -> Varchar,
channel -> Varchar,
subject -> Nullable<Varchar>,
body -> Nullable<Text>,
media_url -> Nullable<Varchar>,
ai_prompt -> Nullable<Text>,
variables -> Jsonb,
approved -> Nullable<Bool>,
meta_template_id -> Nullable<Varchar>,
created_at -> Timestamptz,
updated_at -> Nullable<Timestamptz>,
}
}
diesel::table! {
email_tracking (id) {
id -> Uuid,
recipient_id -> Nullable<Uuid>,
campaign_id -> Nullable<Uuid>,
message_id -> Nullable<Varchar>,
open_token -> Nullable<Uuid>,
open_tracking_enabled -> Nullable<Bool>,
opened -> Nullable<Bool>,
opened_at -> Nullable<Timestamptz>,
clicked -> Nullable<Bool>,
clicked_at -> Nullable<Timestamptz>,
ip_address -> Nullable<Varchar>,
user_agent -> Nullable<Text>,
created_at -> Timestamptz,
}
}
diesel::table! {
whatsapp_business (id) {
id -> Uuid,
bot_id -> Uuid,
phone_number_id -> Nullable<Varchar>,
business_account_id -> Nullable<Varchar>,
access_token -> Nullable<Text>,
webhooks_verified -> Nullable<Bool>,
created_at -> Timestamptz,
updated_at -> Nullable<Timestamptz>,
}
}
diesel::table! {
attendance_sla_policies (id) {
id -> Uuid,
org_id -> Uuid,
bot_id -> Uuid,
name -> Varchar,
channel -> Nullable<Varchar>,
priority -> Nullable<Varchar>,
first_response_minutes -> Nullable<Int4>,
resolution_minutes -> Nullable<Int4>,
escalate_on_breach -> Nullable<Bool>,
is_active -> Nullable<Bool>,
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<Timestamptz>,
breached_at -> Nullable<Timestamptz>,
status -> Nullable<Varchar>,
created_at -> Timestamptz,
}
}
diesel::table! {
attendance_webhooks (id) {
id -> Uuid,
org_id -> Uuid,
bot_id -> Uuid,
webhook_url -> Varchar,
events -> Nullable<Array<Text>>,
is_active -> Nullable<Bool>,
secret_key -> Nullable<Text>,
created_at -> Timestamptz,
updated_at -> Nullable<Timestamptz>,
}
}

View file

@ -387,6 +387,8 @@ pub struct AppState {
pub task_progress_broadcast: Option<broadcast::Sender<TaskProgressEvent>>,
pub billing_alert_broadcast: Option<broadcast::Sender<BillingAlertNotification>>,
pub task_manifests: Arc<std::sync::RwLock<HashMap<String, TaskManifest>>>,
#[cfg(feature = "terminal")]
pub terminal_manager: Arc<crate::api::terminal::TerminalManager>,
#[cfg(feature = "project")]
pub project_service: Arc<RwLock<ProjectService>>,
#[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")]

View file

@ -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)]

View file

@ -1,2 +1 @@
pub use canvas_api::*;
pub use super::canvas_api::*;

View file

@ -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 {

View file

@ -1,7 +1,7 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post, put, delete},
Json,
};
use serde::Deserialize;

View file

@ -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<AssetType>) -> Result<Vec<AssetLibraryItem>, 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 {

View file

@ -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,

View file

@ -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;

View file

@ -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<crate::core::shared::models::UserSession, Box<dyn std::error::Error + Send + Sync>> {
use crate::core::shared::models::schema::bots::dsl::*;
use crate::core::shared::models::UserSession;
use diesel::prelude::*;
let mut conn = state.conn.get()?;

View file

@ -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")]

View file

@ -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(),

View file

@ -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());

495
src/marketing/campaigns.rs Normal file
View file

@ -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<Uuid>,
pub name: String,
pub status: String,
pub channel: String,
pub content_template: serde_json::Value,
pub scheduled_at: Option<DateTime<Utc>>,
pub sent_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub metrics: serde_json::Value,
pub budget: Option<f64>,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCampaignRequest {
pub name: String,
pub channel: String,
pub deal_id: Option<Uuid>,
pub content_template: Option<serde_json::Value>,
pub scheduled_at: Option<String>,
pub budget: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateCampaignRequest {
pub name: Option<String>,
pub status: Option<String>,
pub channel: Option<String>,
pub content_template: Option<serde_json::Value>,
pub scheduled_at: Option<String>,
pub budget: Option<f64>,
}
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::<Option<Uuid>>(&mut conn)
.unwrap_or(None)
.unwrap_or(Uuid::nil());
(org_id, bot_id)
}
pub async fn list_campaigns(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<CrmCampaign>>, (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<CrmCampaign> = 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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<CrmCampaign>, (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<Arc<AppState>>,
Json(req): Json<CreateCampaignRequest>,
) -> Result<Json<CrmCampaign>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateCampaignRequest>,
) -> Result<Json<CrmCampaign>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (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<Uuid>,
pub contact_ids: Option<Vec<Uuid>>,
pub template_id: Option<Uuid>,
}
#[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<String, String> {
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<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<SendCampaignRequest>,
) -> Result<Json<CampaignSendResult>, (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<Uuid> = Vec::new();
if let Some(list_id) = req.list_id {
use crate::core::shared::schema::crm_contacts;
let contacts: Vec<Uuid> = 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<String>,
}
let template_id = req.template_id.unwrap_or(Uuid::nil());
let template: Option<TemplateData> = if template_id != Uuid::nil() {
let result: Result<(Option<String>, Option<String>, Option<String>), _> =
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<String>, Option<String>)> = 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,
}))
}

275
src/marketing/lists.rs Normal file
View file

@ -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<String>,
pub contact_count: Option<i32>,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct CreateListRequest {
pub name: String,
pub list_type: String,
pub query_text: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateListRequest {
pub name: Option<String>,
pub list_type: Option<String>,
pub query_text: Option<String>,
}
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::<Option<Uuid>>(&mut conn)
.unwrap_or(None)
.unwrap_or(Uuid::nil());
(org_id, bot_id)
}
pub async fn list_lists(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<MarketingList>>, (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<MarketingList> = 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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<MarketingList>, (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<Arc<AppState>>,
Json(req): Json<CreateListRequest>,
) -> Result<Json<MarketingList>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateListRequest>,
) -> Result<Json<MarketingList>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (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
})))
}

159
src/marketing/mod.rs Normal file
View file

@ -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<Vec<u8>> {
let chars: Vec<u8> = 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<Arc<AppState>>,
Path(token): Path<String>,
) -> 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<Arc<AppState>>,
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<Arc<AppState>> {
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))
}

230
src/marketing/templates.rs Normal file
View file

@ -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<String>,
pub body: Option<String>,
pub media_url: Option<String>,
pub ai_prompt: Option<String>,
pub variables: serde_json::Value,
pub approved: Option<bool>,
pub meta_template_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct CreateTemplateRequest {
pub name: String,
pub channel: String,
pub subject: Option<String>,
pub body: Option<String>,
pub media_url: Option<String>,
pub ai_prompt: Option<String>,
pub variables: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTemplateRequest {
pub name: Option<String>,
pub channel: Option<String>,
pub subject: Option<String>,
pub body: Option<String>,
pub media_url: Option<String>,
pub ai_prompt: Option<String>,
pub variables: Option<serde_json::Value>,
pub approved: Option<bool>,
}
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::<Option<Uuid>>(&mut conn)
.unwrap_or(None)
.unwrap_or(Uuid::nil());
(org_id, bot_id)
}
pub async fn list_templates(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<MarketingTemplate>>, (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<MarketingTemplate> = 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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<MarketingTemplate>, (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<Arc<AppState>>,
Json(req): Json<CreateTemplateRequest>,
) -> Result<Json<MarketingTemplate>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateTemplateRequest>,
) -> Result<Json<MarketingTemplate>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (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" })))
}

201
src/marketing/triggers.rs Normal file
View file

@ -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<crate::core::shared::models::Automation> = 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<crate::core::shared::models::Automation> = 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<crate::core::shared::models::Automation> = 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<String>,
pub token: Option<String>,
}
pub async fn track_email_open(
State(state): State<Arc<AppState>>,
Json(req): Json<EmailOpenRequest>,
) -> Result<Json<serde_json::Value>, (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<Uuid>)> = 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" })))
}

View file

@ -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<String>,
email: Option<String>,
job_title: Option<String>,
department: Option<String>,
avatar_url: Option<String>,
is_active: bool,
}
#[derive(Queryable)]
struct PersonCardRow {
id: Uuid,
first_name: String,
last_name: Option<String>,
email: Option<String>,
job_title: Option<String>,
department: Option<String>,
avatar_url: Option<String>,
phone: Option<String>,
}
#[derive(Queryable)]
struct PersonDetailRow {
id: Uuid,
first_name: String,
last_name: Option<String>,
email: Option<String>,
phone: Option<String>,
mobile: Option<String>,
job_title: Option<String>,
department: Option<String>,
office_location: Option<String>,
avatar_url: Option<String>,
bio: Option<String>,
hire_date: Option<chrono::NaiveDate>,
is_active: bool,
last_seen_at: Option<DateTime<Utc>>,
}
#[derive(Queryable)]
struct PersonSearchRow {
id: Uuid,
first_name: String,
last_name: Option<String>,
email: Option<String>,
job_title: Option<String>,
avatar_url: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct PeopleQuery {
pub department: Option<String>,
@ -57,7 +109,7 @@ async fn handle_people_list(
) -> Html<String> {
let pool = state.conn.clone();
let result: Option<Vec<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, bool)>> = tokio::task::spawn_blocking(move || -> Option<Vec<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, bool)>> {
let result: Option<Vec<PersonListRow>> = tokio::task::spawn_blocking(move || -> Option<Vec<PersonListRow>> {
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<String>, Option<String>, Option<String>, Option<String>, Option<String>, bool)>(&mut conn)
.load::<PersonListRow>(&mut conn)
.ok()
})
.await
@ -124,12 +176,14 @@ async fn handle_people_list(
<tbody>"##
);
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<String> {
let pool = state.conn.clone();
let result: Option<Vec<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>> = tokio::task::spawn_blocking(move || -> Option<Vec<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>> {
let result: Option<Vec<PersonCardRow>> = tokio::task::spawn_blocking(move || -> Option<Vec<PersonCardRow>> {
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<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(&mut conn)
.load::<PersonCardRow>(&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##"<div class="people-cards-grid">"##);
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##"<div class="person-card" data-id="{id}" hx-get="/api/ui/people/{id}" hx-target="#person-detail">
@ -310,7 +365,7 @@ async fn handle_person_detail(
) -> Html<String> {
let pool = state.conn.clone();
let result: Option<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<chrono::NaiveDate>, bool, Option<DateTime<Utc>>)> = tokio::task::spawn_blocking(move || -> Option<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<chrono::NaiveDate>, bool, Option<DateTime<Utc>>)> {
let result: Option<PersonDetailRow> = tokio::task::spawn_blocking(move || -> Option<PersonDetailRow> {
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<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<chrono::NaiveDate>, bool, Option<DateTime<Utc>>)>(&mut conn)
.first::<PersonDetailRow>(&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<Vec<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>)>> = tokio::task::spawn_blocking(move || -> Option<Vec<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>)>> {
let result: Option<Vec<PersonSearchRow>> = tokio::task::spawn_blocking(move || -> Option<Vec<PersonSearchRow>> {
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<String>, Option<String>, Option<String>, Option<String>)>(&mut conn)
.load::<PersonSearchRow>(&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##"<div class="search-results">"##);
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##"<div class="search-result-item" hx-get="/api/ui/people/{id}" hx-target="#person-detail">

View file

@ -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,
}
}
}

View file

@ -1129,6 +1129,26 @@ pub fn build_default_route_permissions() -> Vec<RoutePermission> {
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", ""),

View file

@ -33,8 +33,6 @@ impl RedisCsrfStore {
pub struct RedisCsrfManager {
store: RedisCsrfStore,
#[allow(dead_code)]
secret: Vec<u8>,
}
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<CsrfToken> {

View file

@ -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<String>,
}
pub async fn handle_list_api_keys(State(state): State<Arc<AppState>>) -> 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::<String>()),
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::<String>()),
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::<String>()),
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#"<div class="section-header">
<h2>API Keys (BYOK)</h2>
<button class="btn btn-primary" onclick="showAddKeyModal()">+ Add API Key</button>
</div>
<p style="margin-bottom: 24px; color: var(--text-secondary);">
Manage your own LLM API keys for BYOK (Bring Your Own Key) mode. These keys are stored securely and used for AI features.
</p>"#);
if keys.is_empty() {
html.push_str(r#"<div class="empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path>
</svg>
<h3>No API Keys Configured</h3>
<p>Add your own LLM API keys to enable BYOK mode</p>
</div>"#);
} else {
html.push_str(r#"<div class="servers-grid">"#);
for key in keys {
let _ = write!(html, r#"
<div class="server-card">
<div class="server-header">
<div class="server-icon">🔑</div>
<div class="server-info">
<div class="server-name">{}</div>
<span class="server-type">{}</span>
</div>
</div>
<div class="server-description">{}</div>
<div class="server-meta">
<span class="server-meta-item">Created: {}</span>
</div>
<div class="server-actions" style="margin-top: 12px; display: flex; gap: 8px;">
<button class="btn btn-sm btn-outline" onclick="deleteApiKey('{}')">Delete</button>
</div>
</div>"#,
key.name,
key.provider,
key.key_preview,
key.created_at,
key.id
);
}
html.push_str("</div>");
}
html.push_str(r#"<script>
function showAddKeyModal() {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content" style="background: white; padding: 24px; border-radius: 12px; max-width: 500px; margin: 100px auto;">
<h3 style="margin-bottom: 16px;">Add API Key</h3>
<div class="form-group" style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Name</label>
<input type="text" id="keyName" placeholder="e.g., My OpenAI Key" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 8px;">
</div>
<div class="form-group" style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">Provider</label>
<select id="keyProvider" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 8px;">
<option value="llm-key">OpenAI Compatible</option>
<option value="openai-key">OpenAI</option>
<option value="anthropic-key">Anthropic (Claude)</option>
<option value="google-key">Google (Gemini)</option>
<option value="azure-key">Azure OpenAI</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500;">API Key</label>
<input type="password" id="keyValue" placeholder="sk-..." style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 8px;">
</div>
<div style="display: flex; gap: 12px;">
<button class="btn btn-primary" onclick="saveApiKey()">Save</button>
<button class="btn btn-outline" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
async function saveApiKey() {
const provider = document.getElementById('keyProvider').value;
const keyValue = document.getElementById('keyValue').value;
if (!keyValue) {
alert('Please enter an API key');
return;
}
try {
const response = await fetch('/api/ui/sources/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, key: keyValue })
});
if (response.ok) {
location.reload();
} else {
alert('Failed to save API key');
}
} catch (e) {
alert('Error: ' + e.message);
}
}
async function deleteApiKey(id) {
if (confirm('Delete this API key?')) {
try {
const response = await fetch('/api/ui/sources/api-keys/' + id, { method: 'DELETE' });
if (response.ok) {
location.reload();
}
} catch (e) {
alert('Error: ' + e.message);
}
}
}
</script>"#);
html
}
pub async fn handle_add_api_key(
State(state): State<Arc<AppState>>,
Json(payload): Json<serde_json::Value>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<Arc<AppState>> {
use crate::core::urls::ApiUrls;
use super::mcp_handlers::*;
@ -552,6 +773,8 @@ pub fn configure_sources_routes() -> axum::Router<Arc<AppState>> {
.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))
}