feat: add campaigns, attendance SLA, and marketing modules
This commit is contained in:
parent
13892b3157
commit
7fb73e683f
46 changed files with 4579 additions and 408 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
23
migrations/6.2.3-crm-deals/down.sql
Normal file
23
migrations/6.2.3-crm-deals/down.sql
Normal 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
|
||||
81
migrations/6.2.3-crm-deals/up.sql
Normal file
81
migrations/6.2.3-crm-deals/up.sql
Normal 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);
|
||||
8
migrations/6.2.4-campaigns/down.sql
Normal file
8
migrations/6.2.4-campaigns/down.sql
Normal 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;
|
||||
119
migrations/6.2.4-campaigns/up.sql
Normal file
119
migrations/6.2.4-campaigns/up.sql
Normal 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);
|
||||
15
migrations/6.2.5-crm-department-sla/down.sql
Normal file
15
migrations/6.2.5-crm-department-sla/down.sql
Normal 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;
|
||||
81
migrations/6.2.5-crm-department-sla/up.sql
Normal file
81
migrations/6.2.5-crm-department-sla/up.sql
Normal 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);
|
||||
|
|
@ -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
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
144
src/attendance/sla.rs
Normal 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
329
src/attendance/webhooks.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
#![cfg_attr(feature = "mail", allow(unused_imports))]
|
||||
|
||||
use axum::{Router, routing::{get, post}};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
pub use canvas_api::*;
|
||||
|
||||
pub use super::canvas_api::*;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post, put, delete},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
495
src/marketing/campaigns.rs
Normal 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
275
src/marketing/lists.rs
Normal 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
159
src/marketing/mod.rs
Normal 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
230
src/marketing/templates.rs
Normal 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
201
src/marketing/triggers.rs
Normal 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" })))
|
||||
}
|
||||
134
src/people/ui.rs
134
src/people/ui.rs
|
|
@ -15,6 +15,58 @@ use crate::core::shared::schema::people::people as people_table;
|
|||
use crate::core::shared::schema::{people_departments, people_teams, people_time_off};
|
||||
use crate::core::shared::state::AppState;
|
||||
|
||||
#[derive(Queryable)]
|
||||
struct PersonListRow {
|
||||
id: Uuid,
|
||||
first_name: String,
|
||||
last_name: Option<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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue