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]
|
[features]
|
||||||
# ===== DEFAULT =====
|
# ===== 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"]
|
browser = ["automation", "drive", "cache"]
|
||||||
terminal = ["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"]
|
mail = ["automation", "drive", "cache", "dep:lettre", "dep:mailparse", "dep:imap"]
|
||||||
meet = ["automation", "drive", "cache"]
|
meet = ["automation", "drive", "cache"]
|
||||||
social = ["automation", "drive", "cache"]
|
social = ["automation", "drive", "cache"]
|
||||||
|
marketing = ["people", "automation", "drive", "cache"]
|
||||||
|
|
||||||
# Productivity
|
# Productivity
|
||||||
calendar = ["automation", "drive", "cache"]
|
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::{
|
use axum::{
|
||||||
extract::State,
|
extract::{
|
||||||
response::Json,
|
query::Query,
|
||||||
routing::get,
|
State,
|
||||||
Router,
|
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::shared::state::AppState;
|
||||||
|
use crate::core::urls::ApiUrls;
|
||||||
|
|
||||||
pub fn configure_terminal_routes() -> Router<Arc<AppState>> {
|
pub fn configure_terminal_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
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(
|
pub async fn terminal_ws(
|
||||||
State(_state): State<Arc<AppState>>,
|
ws: WebSocketUpgrade,
|
||||||
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
|
State(state): State<Arc<AppState>>,
|
||||||
// Note: Mock websocket connection upgrade logic
|
Query(query): Query<TerminalQuery>,
|
||||||
Ok(Json(serde_json::json!({ "status": "Upgrade required" })))
|
) -> 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 drive;
|
||||||
pub mod keyword_services;
|
pub mod keyword_services;
|
||||||
|
pub mod sla;
|
||||||
|
pub mod webhooks;
|
||||||
#[cfg(feature = "llm")]
|
#[cfg(feature = "llm")]
|
||||||
pub mod llm_types;
|
pub mod llm_types;
|
||||||
#[cfg(feature = "llm")]
|
#[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_QUEUE, get(queue::list_queue))
|
||||||
.route(ApiUrls::ATTENDANCE_ATTENDANTS, get(queue::list_attendants))
|
.route(ApiUrls::ATTENDANCE_ATTENDANTS, get(queue::list_attendants))
|
||||||
.route(ApiUrls::ATTENDANCE_ASSIGN, post(queue::assign_conversation))
|
.route(ApiUrls::ATTENDANCE_ASSIGN, post(queue::assign_conversation))
|
||||||
|
.route(ApiUrls::ATTENDANCE_ASSIGN_BY_SKILL, post(queue::assign_by_skill))
|
||||||
.route(
|
.route(
|
||||||
ApiUrls::ATTENDANCE_TRANSFER,
|
ApiUrls::ATTENDANCE_TRANSFER,
|
||||||
post(queue::transfer_conversation),
|
post(queue::transfer_conversation),
|
||||||
)
|
)
|
||||||
.route(ApiUrls::ATTENDANCE_RESOLVE, post(queue::resolve_conversation))
|
.route(ApiUrls::ATTENDANCE_RESOLVE, post(queue::resolve_conversation))
|
||||||
.route(ApiUrls::ATTENDANCE_INSIGHTS, get(queue::get_insights))
|
.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::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")]
|
#[cfg(feature = "llm")]
|
||||||
let router = router
|
let router = router
|
||||||
|
|
|
||||||
|
|
@ -461,14 +461,16 @@ pub async fn assign_conversation(
|
||||||
move || {
|
move || {
|
||||||
let mut db_conn = conn
|
let mut db_conn = conn
|
||||||
.get()
|
.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;
|
use crate::core::shared::models::schema::user_sessions;
|
||||||
|
|
||||||
let session: UserSession = user_sessions::table
|
let session: UserSession = user_sessions::table
|
||||||
.filter(user_sessions::id.eq(session_id))
|
.filter(user_sessions::id.eq(session_id))
|
||||||
.first(&mut db_conn)
|
.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;
|
let mut ctx = session.context_data;
|
||||||
ctx["assigned_to"] = serde_json::json!(attendant_id.to_string());
|
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)))
|
diesel::update(user_sessions::table.filter(user_sessions::id.eq(session_id)))
|
||||||
.set(user_sessions::context_data.eq(&ctx))
|
.set(user_sessions::context_data.eq(&ctx))
|
||||||
.execute(&mut db_conn)
|
.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>(())
|
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(
|
pub async fn transfer_conversation(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(request): Json<TransferRequest>,
|
Json(request): Json<TransferRequest>,
|
||||||
|
|
@ -557,7 +760,22 @@ pub async fn transfer_conversation(
|
||||||
user_sessions::updated_at.eq(Utc::now()),
|
user_sessions::updated_at.eq(Utc::now()),
|
||||||
))
|
))
|
||||||
.execute(&mut db_conn)
|
.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>(())
|
Ok::<(), String>(())
|
||||||
}
|
}
|
||||||
|
|
@ -636,7 +854,19 @@ pub async fn resolve_conversation(
|
||||||
user_sessions::updated_at.eq(Utc::now()),
|
user_sessions::updated_at.eq(Utc::now()),
|
||||||
))
|
))
|
||||||
.execute(&mut db_conn)
|
.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>(())
|
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 source = source.as_str();
|
||||||
let mut has_schedule = false;
|
let mut has_schedule = false;
|
||||||
let mut _has_webhook = false;
|
|
||||||
let script_name = Path::new(source_path)
|
let script_name = Path::new(source_path)
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
|
|
@ -482,9 +481,7 @@ impl BasicCompiler {
|
||||||
if parts.len() >= 3 {
|
if parts.len() >= 3 {
|
||||||
#[cfg(feature = "tasks")]
|
#[cfg(feature = "tasks")]
|
||||||
{
|
{
|
||||||
#[allow(unused_variables, unused_mut)]
|
|
||||||
let cron = parts[1];
|
let cron = parts[1];
|
||||||
#[allow(unused_variables, unused_mut)]
|
|
||||||
let mut conn = self
|
let mut conn = self
|
||||||
.state
|
.state
|
||||||
.conn
|
.conn
|
||||||
|
|
@ -506,7 +503,6 @@ impl BasicCompiler {
|
||||||
}
|
}
|
||||||
|
|
||||||
if normalized.starts_with("WEBHOOK") {
|
if normalized.starts_with("WEBHOOK") {
|
||||||
_has_webhook = true;
|
|
||||||
let parts: Vec<&str> = normalized.split('"').collect();
|
let parts: Vec<&str> = normalized.split('"').collect();
|
||||||
if parts.len() >= 2 {
|
if parts.len() >= 2 {
|
||||||
let endpoint = parts[1];
|
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 _ = self; // silence unused self warning - kept for API consistency
|
||||||
let script = preprocess_switch(script);
|
let script = preprocess_switch(script);
|
||||||
|
|
||||||
|
|
@ -346,10 +346,9 @@ impl ScriptService {
|
||||||
}
|
}
|
||||||
if trimmed.starts_with("NEXT") {
|
if trimmed.starts_with("NEXT") {
|
||||||
if let Some(expected_indent) = for_stack.pop() {
|
if let Some(expected_indent) = for_stack.pop() {
|
||||||
assert!(
|
if (current_indent - 4) != expected_indent {
|
||||||
(current_indent - 4) == expected_indent,
|
return Err("NEXT without matching FOR EACH (indentation mismatch)".to_string());
|
||||||
"NEXT without matching FOR EACH"
|
}
|
||||||
);
|
|
||||||
current_indent -= 4;
|
current_indent -= 4;
|
||||||
result.push_str(&" ".repeat(current_indent));
|
result.push_str(&" ".repeat(current_indent));
|
||||||
result.push_str("}\n");
|
result.push_str("}\n");
|
||||||
|
|
@ -360,7 +359,7 @@ impl ScriptService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
log::error!("NEXT without matching FOR EACH");
|
log::error!("NEXT without matching FOR EACH");
|
||||||
return result;
|
return Err("NEXT without matching FOR EACH".to_string());
|
||||||
}
|
}
|
||||||
if trimmed == "EXIT FOR" {
|
if trimmed == "EXIT FOR" {
|
||||||
result.push_str(&" ".repeat(current_indent));
|
result.push_str(&" ".repeat(current_indent));
|
||||||
|
|
@ -555,11 +554,16 @@ impl ScriptService {
|
||||||
}
|
}
|
||||||
result.push('\n');
|
result.push('\n');
|
||||||
}
|
}
|
||||||
assert!(for_stack.is_empty(), "Unclosed FOR EACH loop");
|
if !for_stack.is_empty() {
|
||||||
result
|
return Err("Unclosed FOR EACH loop".to_string());
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
pub fn compile(&self, script: &str) -> Result<rhai::AST, Box<EvalAltResult>> {
|
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);
|
trace!("Processed Script:\n{}", processed_script);
|
||||||
match self.engine.compile(&processed_script) {
|
match self.engine.compile(&processed_script) {
|
||||||
Ok(ast) => Ok(ast),
|
Ok(ast) => Ok(ast),
|
||||||
|
|
@ -711,73 +715,7 @@ impl ScriptService {
|
||||||
Ok(())
|
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
|
/// Convert a single TALK line with ${variable} substitution to proper TALK syntax
|
||||||
/// Handles: "Hello ${name}" → TALK "Hello " + name
|
/// Handles: "Hello ${name}" → TALK "Hello " + name
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,10 @@ pub struct Contact {
|
||||||
pub updated_at: DateTime<Utc>,
|
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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ContactStatus {
|
pub enum ContactStatus {
|
||||||
|
#[default]
|
||||||
Active,
|
Active,
|
||||||
Inactive,
|
Inactive,
|
||||||
Lead,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ContactSource {
|
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 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TaskContactsQuery {
|
pub struct TaskContactsQuery {
|
||||||
pub role: Option<TaskContactRole>,
|
pub role: Option<TaskContactRole>,
|
||||||
|
|
@ -138,6 +148,23 @@ pub struct ContactTasksQuery {
|
||||||
pub sort_order: Option<SortOrder>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub enum TaskSortField {
|
pub enum TaskSortField {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -364,15 +391,15 @@ impl TasksIntegrationService {
|
||||||
let role = request.role.clone().unwrap_or_default();
|
let role = request.role.clone().unwrap_or_default();
|
||||||
|
|
||||||
// Create assignment in database
|
// Create assignment in database
|
||||||
self.create_task_contact_assignment(
|
self.create_task_contact_assignment(TaskAssignmentParams {
|
||||||
id,
|
id,
|
||||||
task_id,
|
task_id,
|
||||||
request.contact_id,
|
contact_id: request.contact_id,
|
||||||
&role,
|
role: &role,
|
||||||
assigned_by,
|
assigned_by,
|
||||||
request.notes.as_deref(),
|
notes: request.notes.as_deref(),
|
||||||
now,
|
assigned_at: now,
|
||||||
)
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Send notification if requested
|
// Send notification if requested
|
||||||
|
|
@ -388,7 +415,7 @@ impl TasksIntegrationService {
|
||||||
self.log_contact_activity(
|
self.log_contact_activity(
|
||||||
request.contact_id,
|
request.contact_id,
|
||||||
TaskActivityType::Assigned,
|
TaskActivityType::Assigned,
|
||||||
&format!("Assigned to task"),
|
"Assigned to task",
|
||||||
task_id,
|
task_id,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -760,13 +787,7 @@ impl TasksIntegrationService {
|
||||||
|
|
||||||
async fn create_task_contact_assignment(
|
async fn create_task_contact_assignment(
|
||||||
&self,
|
&self,
|
||||||
_id: Uuid,
|
_params: TaskAssignmentParams<'_>,
|
||||||
_task_id: Uuid,
|
|
||||||
_contact_id: Uuid,
|
|
||||||
_role: &TaskContactRole,
|
|
||||||
_assigned_by: Uuid,
|
|
||||||
_notes: Option<&str>,
|
|
||||||
_assigned_at: DateTime<Utc>,
|
|
||||||
) -> Result<(), TasksIntegrationError> {
|
) -> Result<(), TasksIntegrationError> {
|
||||||
// Insert into task_contacts table
|
// Insert into task_contacts table
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -865,8 +886,7 @@ impl TasksIntegrationService {
|
||||||
|
|
||||||
let mut task_contacts = Vec::new();
|
let mut task_contacts = Vec::new();
|
||||||
|
|
||||||
if let Ok((tid, assigned_to, created_at)) = task_row {
|
if let Ok((tid, Some(assignee_id), created_at)) = task_row {
|
||||||
if let Some(assignee_id) = assigned_to {
|
|
||||||
// Look up person -> email -> contact
|
// Look up person -> email -> contact
|
||||||
let person_email: Result<Option<String>, _> = people_table::table
|
let person_email: Result<Option<String>, _> = people_table::table
|
||||||
.filter(people_table::id.eq(assignee_id))
|
.filter(people_table::id.eq(assignee_id))
|
||||||
|
|
@ -895,7 +915,6 @@ impl TasksIntegrationService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(task_contacts)
|
Ok(task_contacts)
|
||||||
})
|
})
|
||||||
|
|
@ -922,7 +941,21 @@ impl TasksIntegrationService {
|
||||||
db_query = db_query.filter(tasks_table::status.eq(status));
|
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())
|
.order(tasks_table::created_at.desc())
|
||||||
.select((
|
.select((
|
||||||
tasks_table::id,
|
tasks_table::id,
|
||||||
|
|
@ -944,7 +977,7 @@ impl TasksIntegrationService {
|
||||||
ContactTaskWithDetails {
|
ContactTaskWithDetails {
|
||||||
task_contact: TaskContact {
|
task_contact: TaskContact {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
task_id: row.0,
|
task_id: row.id,
|
||||||
contact_id,
|
contact_id,
|
||||||
role: TaskContactRole::Assignee,
|
role: TaskContactRole::Assignee,
|
||||||
assigned_at: Utc::now(),
|
assigned_at: Utc::now(),
|
||||||
|
|
@ -954,17 +987,17 @@ impl TasksIntegrationService {
|
||||||
notes: None,
|
notes: None,
|
||||||
},
|
},
|
||||||
task: TaskSummary {
|
task: TaskSummary {
|
||||||
id: row.0,
|
id: row.id,
|
||||||
title: row.1,
|
title: row.title,
|
||||||
description: row.2,
|
description: row.description,
|
||||||
status: row.3,
|
status: row.status,
|
||||||
priority: row.4,
|
priority: row.priority,
|
||||||
due_date: row.5,
|
due_date: row.due_date,
|
||||||
project_id: row.6,
|
project_id: row.project_id,
|
||||||
project_name: None,
|
project_name: None,
|
||||||
progress: row.7 as u8,
|
progress: row.progress as u8,
|
||||||
created_at: row.8,
|
created_at: row.created_at,
|
||||||
updated_at: row.9,
|
updated_at: row.updated_at,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
@ -1104,7 +1137,7 @@ impl TasksIntegrationService {
|
||||||
query = query.filter(crm_contacts_table::id.ne(*exc));
|
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((
|
.select((
|
||||||
crm_contacts_table::id,
|
crm_contacts_table::id,
|
||||||
crm_contacts_table::first_name,
|
crm_contacts_table::first_name,
|
||||||
|
|
@ -1119,13 +1152,13 @@ impl TasksIntegrationService {
|
||||||
|
|
||||||
let contacts = rows.into_iter().map(|row| {
|
let contacts = rows.into_iter().map(|row| {
|
||||||
let summary = ContactSummary {
|
let summary = ContactSummary {
|
||||||
id: row.0,
|
id: row.id,
|
||||||
first_name: row.1.unwrap_or_default(),
|
first_name: row.first_name.unwrap_or_default(),
|
||||||
last_name: row.2.unwrap_or_default(),
|
last_name: row.last_name.unwrap_or_default(),
|
||||||
email: row.3,
|
email: row.email,
|
||||||
phone: None,
|
phone: None,
|
||||||
company: row.4,
|
company: row.company,
|
||||||
job_title: row.5,
|
job_title: row.job_title,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
};
|
};
|
||||||
let workload = ContactWorkload {
|
let workload = ContactWorkload {
|
||||||
|
|
@ -1164,7 +1197,9 @@ impl TasksIntegrationService {
|
||||||
query = query.filter(crm_contacts_table::id.ne(*exc));
|
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((
|
.select((
|
||||||
crm_contacts_table::id,
|
crm_contacts_table::id,
|
||||||
crm_contacts_table::first_name,
|
crm_contacts_table::first_name,
|
||||||
|
|
@ -1179,13 +1214,13 @@ impl TasksIntegrationService {
|
||||||
|
|
||||||
let contacts = rows.into_iter().map(|row| {
|
let contacts = rows.into_iter().map(|row| {
|
||||||
let summary = ContactSummary {
|
let summary = ContactSummary {
|
||||||
id: row.0,
|
id: row.id,
|
||||||
first_name: row.1.unwrap_or_default(),
|
first_name: row.first_name.unwrap_or_default(),
|
||||||
last_name: row.2.unwrap_or_default(),
|
last_name: row.last_name.unwrap_or_default(),
|
||||||
email: row.3,
|
email: row.email,
|
||||||
phone: None,
|
phone: None,
|
||||||
company: row.4,
|
company: row.company,
|
||||||
job_title: row.5,
|
job_title: row.job_title,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
};
|
};
|
||||||
let workload = ContactWorkload {
|
let workload = ContactWorkload {
|
||||||
|
|
@ -1224,7 +1259,9 @@ impl TasksIntegrationService {
|
||||||
query = query.filter(crm_contacts_table::id.ne(*exc));
|
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((
|
.select((
|
||||||
crm_contacts_table::id,
|
crm_contacts_table::id,
|
||||||
crm_contacts_table::first_name,
|
crm_contacts_table::first_name,
|
||||||
|
|
@ -1239,13 +1276,13 @@ impl TasksIntegrationService {
|
||||||
|
|
||||||
let contacts = rows.into_iter().map(|row| {
|
let contacts = rows.into_iter().map(|row| {
|
||||||
let summary = ContactSummary {
|
let summary = ContactSummary {
|
||||||
id: row.0,
|
id: row.id,
|
||||||
first_name: row.1.unwrap_or_default(),
|
first_name: row.first_name.unwrap_or_default(),
|
||||||
last_name: row.2.unwrap_or_default(),
|
last_name: row.last_name.unwrap_or_default(),
|
||||||
email: row.3,
|
email: row.email,
|
||||||
phone: None,
|
phone: None,
|
||||||
company: row.4,
|
company: row.company,
|
||||||
job_title: row.5,
|
job_title: row.job_title,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
};
|
};
|
||||||
let workload = ContactWorkload {
|
let workload = ContactWorkload {
|
||||||
|
|
@ -1283,9 +1320,9 @@ impl TasksIntegrationService {
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_task_type_display() {
|
fn test_task_type_display() {
|
||||||
assert_eq!(format!("{:?}", ContactTaskType::FollowUp), "FollowUp");
|
assert_eq!(format!("{:?}", TaskActivityType::Assigned), "Assigned");
|
||||||
assert_eq!(format!("{:?}", ContactTaskType::Meeting), "Meeting");
|
assert_eq!(format!("{:?}", TaskActivityType::Completed), "Completed");
|
||||||
assert_eq!(format!("{:?}", ContactTaskType::Call), "Call");
|
assert_eq!(format!("{:?}", TaskActivityType::Updated), "Updated");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use crate::core::shared::utils::DbPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Global WhatsApp message queue (shared across all adapters)
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WhatsAppAdapter {
|
pub struct WhatsAppAdapter {
|
||||||
|
|
@ -20,8 +20,8 @@ pub struct WhatsAppAdapter {
|
||||||
webhook_verify_token: String,
|
webhook_verify_token: String,
|
||||||
_business_account_id: String,
|
_business_account_id: String,
|
||||||
api_version: String,
|
api_version: String,
|
||||||
voice_response: bool,
|
_voice_response: bool,
|
||||||
queue: &'static Arc<WhatsAppMessageQueue>,
|
queue: Option<&'static Arc<WhatsAppMessageQueue>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WhatsAppAdapter {
|
impl WhatsAppAdapter {
|
||||||
|
|
@ -65,20 +65,24 @@ impl WhatsAppAdapter {
|
||||||
webhook_verify_token: verify_token,
|
webhook_verify_token: verify_token,
|
||||||
_business_account_id: business_account_id,
|
_business_account_id: business_account_id,
|
||||||
api_version,
|
api_version,
|
||||||
voice_response,
|
_voice_response: voice_response,
|
||||||
queue: WHATSAPP_QUEUE.get_or_init(|| {
|
queue: WHATSAPP_QUEUE.get_or_init(|| {
|
||||||
let queue = WhatsAppMessageQueue::new(&redis_url)
|
let queue_res = WhatsAppMessageQueue::new(&redis_url);
|
||||||
.unwrap_or_else(|e| {
|
match queue_res {
|
||||||
error!("Failed to create WhatsApp queue: {}", e);
|
Ok(q) => {
|
||||||
panic!("WhatsApp queue initialization failed");
|
let q_arc = Arc::new(q);
|
||||||
});
|
let worker_queue = Arc::clone(&q_arc);
|
||||||
let queue = Arc::new(queue);
|
|
||||||
let worker_queue = Arc::clone(&queue);
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
worker_queue.start_worker().await;
|
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(),
|
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))?;
|
.map_err(|e| format!("Failed to enqueue WhatsApp message: {}", e))?;
|
||||||
|
|
||||||
info!("WhatsApp message enqueued for {}: {}", to, &message.chars().take(50).collect::<String>());
|
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 media_info: serde_json::Value = response.json().await?;
|
||||||
let download_url = media_info["url"]
|
let download_url = media_info["url"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| "Media URL not found in response")?;
|
.ok_or("Media URL not found in response")?;
|
||||||
|
|
||||||
// 2. Download the binary
|
// 2. Download the binary
|
||||||
let download_response = client
|
let download_response = client
|
||||||
|
|
|
||||||
|
|
@ -387,7 +387,7 @@ impl BotOrchestrator {
|
||||||
|
|
||||||
// Ensure default tenant exists (use fixed ID for consistency)
|
// Ensure default tenant exists (use fixed ID for consistency)
|
||||||
let default_tenant_id = "00000000-0000-0000-0000-000000000001";
|
let default_tenant_id = "00000000-0000-0000-0000-000000000001";
|
||||||
sql_query(&format!(
|
sql_query(format!(
|
||||||
"INSERT INTO tenants (id, name, slug, created_at) \
|
"INSERT INTO tenants (id, name, slug, created_at) \
|
||||||
VALUES ('{}', 'Default Tenant', 'default', NOW()) \
|
VALUES ('{}', 'Default Tenant', 'default', NOW()) \
|
||||||
ON CONFLICT (slug) DO NOTHING",
|
ON CONFLICT (slug) DO NOTHING",
|
||||||
|
|
@ -398,7 +398,7 @@ impl BotOrchestrator {
|
||||||
|
|
||||||
// Ensure default organization exists (use fixed ID for consistency)
|
// Ensure default organization exists (use fixed ID for consistency)
|
||||||
let default_org_id = "00000000-0000-0000-0000-000000000001";
|
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) \
|
"INSERT INTO organizations (org_id, tenant_id, name, slug, created_at) \
|
||||||
VALUES ('{}', '{}', 'Default Org', 'default', NOW()) \
|
VALUES ('{}', '{}', 'Default Org', 'default', NOW()) \
|
||||||
ON CONFLICT (org_id) DO NOTHING",
|
ON CONFLICT (org_id) DO NOTHING",
|
||||||
|
|
|
||||||
|
|
@ -213,12 +213,6 @@ struct ScalewayEmbeddingResponse {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ScalewayEmbeddingData {
|
struct ScalewayEmbeddingData {
|
||||||
embedding: Vec<f32>,
|
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)
|
// Generic embedding service format (object with embeddings key)
|
||||||
|
|
@ -254,9 +248,6 @@ struct CloudflareResult {
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct CloudflareMeta {
|
struct CloudflareMeta {
|
||||||
#[serde(default)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
cost_metric_name_1: Option<String>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
cost_metric_value_1: Option<f64>,
|
cost_metric_value_1: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ use crate::core::package_manager::{InstallMode, OsType};
|
||||||
use crate::security::command_guard::SafeCommand;
|
use crate::security::command_guard::SafeCommand;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use log::{error, info, trace, warn};
|
use log::{error, info, trace, warn};
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -22,15 +21,25 @@ struct ThirdPartyConfig {
|
||||||
components: HashMap<String, ComponentEntry>,
|
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");
|
let toml_str = include_str!("../../../3rdparty.toml");
|
||||||
toml::from_str(toml_str).unwrap_or_else(|e| {
|
match toml::from_str::<ThirdPartyConfig>(toml_str) {
|
||||||
panic!("Failed to parse embedded 3rdparty.toml: {e}")
|
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> {
|
fn get_component_url(name: &str) -> Option<String> {
|
||||||
THIRDPARTY_CONFIG
|
get_thirdparty_config()
|
||||||
.components
|
.components
|
||||||
.get(name)
|
.get(name)
|
||||||
.map(|c| c.url.clone())
|
.map(|c| c.url.clone())
|
||||||
|
|
@ -1366,8 +1375,8 @@ EOF"#.to_string(),
|
||||||
info!("Waiting for Vault to start...");
|
info!("Waiting for Vault to start...");
|
||||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||||
|
|
||||||
let vault_addr = std::env::var("VAULT_ADDR")
|
let vault_addr =
|
||||||
.unwrap_or_else(|_| "https://localhost:8200".to_string());
|
std::env::var("VAULT_ADDR").unwrap_or_else(|_| "https://localhost:8200".to_string());
|
||||||
let ca_cert = conf_path.join("system/certificates/ca/ca.crt");
|
let ca_cert = conf_path.join("system/certificates/ca/ca.crt");
|
||||||
|
|
||||||
// Initialize Vault
|
// Initialize Vault
|
||||||
|
|
@ -1391,8 +1400,8 @@ EOF"#.to_string(),
|
||||||
}
|
}
|
||||||
|
|
||||||
let init_output = String::from_utf8_lossy(&output.stdout);
|
let init_output = String::from_utf8_lossy(&output.stdout);
|
||||||
let init_json_val: serde_json::Value = serde_json::from_str(&init_output)
|
let init_json_val: serde_json::Value =
|
||||||
.context("Failed to parse Vault init output")?;
|
serde_json::from_str(&init_output).context("Failed to parse Vault init output")?;
|
||||||
|
|
||||||
let unseal_keys = init_json_val["unseal_keys_b64"]
|
let unseal_keys = init_json_val["unseal_keys_b64"]
|
||||||
.as_array()
|
.as_array()
|
||||||
|
|
@ -1402,10 +1411,7 @@ EOF"#.to_string(),
|
||||||
.context("No root token in output")?;
|
.context("No root token in output")?;
|
||||||
|
|
||||||
// Save init.json
|
// Save init.json
|
||||||
std::fs::write(
|
std::fs::write(&init_json, serde_json::to_string_pretty(&init_json_val)?)?;
|
||||||
&init_json,
|
|
||||||
serde_json::to_string_pretty(&init_json_val)?
|
|
||||||
)?;
|
|
||||||
info!("Created {}", init_json.display());
|
info!("Created {}", init_json.display());
|
||||||
|
|
||||||
// Create .env file with Vault credentials
|
// Create .env file with Vault credentials
|
||||||
|
|
@ -1427,9 +1433,7 @@ VAULT_CACERT={}
|
||||||
if existing.contains("VAULT_ADDR=") {
|
if existing.contains("VAULT_ADDR=") {
|
||||||
warn!(".env already contains VAULT_ADDR, not overwriting");
|
warn!(".env already contains VAULT_ADDR, not overwriting");
|
||||||
} else {
|
} else {
|
||||||
let mut file = std::fs::OpenOptions::new()
|
let mut file = std::fs::OpenOptions::new().append(true).open(&env_file)?;
|
||||||
.append(true)
|
|
||||||
.open(&env_file)?;
|
|
||||||
file.write_all(env_content.as_bytes())?;
|
file.write_all(env_content.as_bytes())?;
|
||||||
info!("Appended Vault config to .env");
|
info!("Appended Vault config to .env");
|
||||||
}
|
}
|
||||||
|
|
@ -1508,8 +1512,8 @@ VAULT_CACERT={}
|
||||||
|
|
||||||
let conf_path = self.base_path.join("conf");
|
let conf_path = self.base_path.join("conf");
|
||||||
let ca_cert = conf_path.join("system/certificates/ca/ca.crt");
|
let ca_cert = conf_path.join("system/certificates/ca/ca.crt");
|
||||||
let vault_addr = std::env::var("VAULT_ADDR")
|
let vault_addr =
|
||||||
.unwrap_or_else(|_| "https://localhost:8200".to_string());
|
std::env::var("VAULT_ADDR").unwrap_or_else(|_| "https://localhost:8200".to_string());
|
||||||
|
|
||||||
let env_content = format!(
|
let env_content = format!(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -1528,9 +1532,7 @@ VAULT_CACERT={}
|
||||||
if existing.contains("VAULT_ADDR=") {
|
if existing.contains("VAULT_ADDR=") {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut file = std::fs::OpenOptions::new()
|
let mut file = std::fs::OpenOptions::new().append(true).open(&env_file)?;
|
||||||
.append(true)
|
|
||||||
.open(&env_file)?;
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
file.write_all(env_content.as_bytes())?;
|
file.write_all(env_content.as_bytes())?;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
#![cfg_attr(feature = "mail", allow(unused_imports))]
|
|
||||||
use axum::{Router, routing::{get, post}};
|
use axum::{Router, routing::{get, post}};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ pub enum TriggerKind {
|
||||||
Webhook = 4,
|
Webhook = 4,
|
||||||
EmailReceived = 5,
|
EmailReceived = 5,
|
||||||
FolderChange = 6,
|
FolderChange = 6,
|
||||||
|
DealStageChange = 7,
|
||||||
|
ContactChange = 8,
|
||||||
|
EmailOpened = 9,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TriggerKind {
|
impl TriggerKind {
|
||||||
|
|
@ -29,6 +32,9 @@ impl TriggerKind {
|
||||||
4 => Some(Self::Webhook),
|
4 => Some(Self::Webhook),
|
||||||
5 => Some(Self::EmailReceived),
|
5 => Some(Self::EmailReceived),
|
||||||
6 => Some(Self::FolderChange),
|
6 => Some(Self::FolderChange),
|
||||||
|
7 => Some(Self::DealStageChange),
|
||||||
|
8 => Some(Self::ContactChange),
|
||||||
|
9 => Some(Self::EmailOpened),
|
||||||
_ => None,
|
_ => 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_person_skills -> people_skills (skill_id));
|
||||||
diesel::joinable!(people_time_off -> organizations (org_id));
|
diesel::joinable!(people_time_off -> organizations (org_id));
|
||||||
diesel::joinable!(people_time_off -> bots (bot_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 task_progress_broadcast: Option<broadcast::Sender<TaskProgressEvent>>,
|
||||||
pub billing_alert_broadcast: Option<broadcast::Sender<BillingAlertNotification>>,
|
pub billing_alert_broadcast: Option<broadcast::Sender<BillingAlertNotification>>,
|
||||||
pub task_manifests: Arc<std::sync::RwLock<HashMap<String, TaskManifest>>>,
|
pub task_manifests: Arc<std::sync::RwLock<HashMap<String, TaskManifest>>>,
|
||||||
|
#[cfg(feature = "terminal")]
|
||||||
|
pub terminal_manager: Arc<crate::api::terminal::TerminalManager>,
|
||||||
#[cfg(feature = "project")]
|
#[cfg(feature = "project")]
|
||||||
pub project_service: Arc<RwLock<ProjectService>>,
|
pub project_service: Arc<RwLock<ProjectService>>,
|
||||||
#[cfg(feature = "compliance")]
|
#[cfg(feature = "compliance")]
|
||||||
|
|
@ -431,6 +433,8 @@ impl Clone for AppState {
|
||||||
task_progress_broadcast: self.task_progress_broadcast.clone(),
|
task_progress_broadcast: self.task_progress_broadcast.clone(),
|
||||||
billing_alert_broadcast: self.billing_alert_broadcast.clone(),
|
billing_alert_broadcast: self.billing_alert_broadcast.clone(),
|
||||||
task_manifests: Arc::clone(&self.task_manifests),
|
task_manifests: Arc::clone(&self.task_manifests),
|
||||||
|
#[cfg(feature = "terminal")]
|
||||||
|
terminal_manager: Arc::clone(&self.terminal_manager),
|
||||||
#[cfg(feature = "project")]
|
#[cfg(feature = "project")]
|
||||||
project_service: Arc::clone(&self.project_service),
|
project_service: Arc::clone(&self.project_service),
|
||||||
#[cfg(feature = "compliance")]
|
#[cfg(feature = "compliance")]
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,8 @@ impl ApiUrls {
|
||||||
pub const ATTENDANCE_RESOLVE: &'static str = "/api/attendance/resolve/:session_id";
|
pub const ATTENDANCE_RESOLVE: &'static str = "/api/attendance/resolve/:session_id";
|
||||||
pub const ATTENDANCE_INSIGHTS: &'static str = "/api/attendance/insights";
|
pub const ATTENDANCE_INSIGHTS: &'static str = "/api/attendance/insights";
|
||||||
pub const ATTENDANCE_RESPOND: &'static str = "/api/attendance/respond";
|
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_TIPS: &'static str = "/api/attendance/llm/tips";
|
||||||
pub const ATTENDANCE_LLM_POLISH: &'static str = "/api/attendance/llm/polish";
|
pub const ATTENDANCE_LLM_POLISH: &'static str = "/api/attendance/llm/polish";
|
||||||
pub const ATTENDANCE_LLM_SMART_REPLIES: &'static str = "/api/attendance/llm/smart-replies";
|
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_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_SESSIONS: &'static str = "/api/ui/monitoring/metric/sessions";
|
||||||
pub const MONITORING_METRIC_MESSAGES: &'static str = "/api/ui/monitoring/metric/messages";
|
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_TREND_SESSIONS: &'static str = "/api/ui/monitoring/trend/sessions";
|
||||||
pub const MONITORING_RATE_MESSAGES: &'static str = "/api/ui/monitoring/rate/messages";
|
pub const MONITORING_RATE_MESSAGES: &'static str = "/api/ui/monitoring/rate/messages";
|
||||||
pub const MONITORING_SESSIONS_PANEL: &'static str = "/api/ui/monitoring/sessions";
|
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_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_SCAN: &'static str = "/api/ui/sources/mcp/scan";
|
||||||
pub const SOURCES_MCP_EXAMPLES: &'static str = "/api/ui/sources/mcp/examples";
|
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_MENTIONS: &'static str = "/api/ui/sources/mentions";
|
||||||
pub const SOURCES_TOOLS: &'static str = "/api/ui/sources/tools";
|
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_CHAT: &'static str = "/ws/chat";
|
||||||
pub const WS_NOTIFICATIONS: &'static str = "/ws/notifications";
|
pub const WS_NOTIFICATIONS: &'static str = "/ws/notifications";
|
||||||
pub const WS_ATTENDANT: &'static str = "/ws/attendant";
|
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)]
|
#[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 diesel::sql_types::{Bool, Double, Integer, Nullable, Text, Timestamptz, Uuid as DieselUuid};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::designer::canvas_api::types::{Canvas, CanvasTemplate, Layer, CanvasElement};
|
use crate::designer::canvas_api::types::{Canvas, CanvasElement, Layer};
|
||||||
|
|
||||||
#[derive(QueryableByName)]
|
#[derive(QueryableByName)]
|
||||||
pub struct CanvasRow {
|
pub struct CanvasRow {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
routing::{get, post, put, delete},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::Utc;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::sql_types::{Text, Timestamptz, Uuid as DieselUuid};
|
use diesel::sql_types::{Text, Uuid as DieselUuid};
|
||||||
use log::error;
|
use log::error;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
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> {
|
pub async fn get_asset_library(&self, asset_type: Option<AssetType>) -> Result<Vec<AssetLibraryItem>, CanvasError> {
|
||||||
let icons = vec![
|
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: "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: "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: "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: "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: "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: "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: "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: "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 {
|
let filtered = match asset_type {
|
||||||
|
|
|
||||||
|
|
@ -330,9 +330,10 @@ pub struct Layer {
|
||||||
pub z_index: i32,
|
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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum BlendMode {
|
pub enum BlendMode {
|
||||||
|
#[default]
|
||||||
Normal,
|
Normal,
|
||||||
Multiply,
|
Multiply,
|
||||||
Screen,
|
Screen,
|
||||||
|
|
@ -347,12 +348,6 @@ pub enum BlendMode {
|
||||||
Exclusion,
|
Exclusion,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for BlendMode {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Normal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CanvasTemplate {
|
pub struct CanvasTemplate {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
use super::types::*;
|
use super::types::*;
|
||||||
use super::utils::*;
|
use super::utils::*;
|
||||||
use super::validators::validate_basic_code;
|
use super::validators::validate_basic_code;
|
||||||
use crate::auto_task::get_designer_error_context;
|
|
||||||
use crate::core::urls::ApiUrls;
|
use crate::core::urls::ApiUrls;
|
||||||
use crate::core::shared::state::AppState;
|
use crate::core::shared::state::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
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::state::AppState;
|
||||||
use crate::core::shared::get_content_type;
|
use crate::core::shared::get_content_type;
|
||||||
use axum::{extract::State, response::IntoResponse, Json};
|
use axum::{extract::State, response::IntoResponse, Json};
|
||||||
|
use std::fmt::Write;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
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>> {
|
) -> 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::schema::bots::dsl::*;
|
||||||
use crate::core::shared::models::UserSession;
|
use crate::core::shared::models::UserSession;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
let mut conn = state.conn.get()?;
|
let mut conn = state.conn.get()?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ pub mod maintenance;
|
||||||
#[cfg(feature = "monitoring")]
|
#[cfg(feature = "monitoring")]
|
||||||
pub mod monitoring;
|
pub mod monitoring;
|
||||||
pub mod multimodal;
|
pub mod multimodal;
|
||||||
|
#[cfg(feature = "marketing")]
|
||||||
|
pub mod marketing;
|
||||||
#[cfg(feature = "paper")]
|
#[cfg(feature = "paper")]
|
||||||
pub mod paper;
|
pub mod paper;
|
||||||
#[cfg(feature = "people")]
|
#[cfg(feature = "people")]
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,8 @@ pub async fn create_app_state(
|
||||||
task_progress_broadcast: Some(task_progress_tx),
|
task_progress_broadcast: Some(task_progress_tx),
|
||||||
billing_alert_broadcast: None,
|
billing_alert_broadcast: None,
|
||||||
task_manifests: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
task_manifests: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||||
|
#[cfg(feature = "terminal")]
|
||||||
|
terminal_manager: crate::api::terminal::TerminalManager::new(),
|
||||||
#[cfg(feature = "project")]
|
#[cfg(feature = "project")]
|
||||||
project_service: Arc::new(tokio::sync::RwLock::new(
|
project_service: Arc::new(tokio::sync::RwLock::new(
|
||||||
crate::project::ProjectService::new(),
|
crate::project::ProjectService::new(),
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,11 @@ pub async fn run_axum_server(
|
||||||
api_router = api_router.merge(crate::whatsapp::configure());
|
api_router = api_router.merge(crate::whatsapp::configure());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "marketing")]
|
||||||
|
{
|
||||||
|
api_router = api_router.merge(crate::marketing::configure_marketing_routes());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "telegram")]
|
#[cfg(feature = "telegram")]
|
||||||
{
|
{
|
||||||
api_router = api_router.merge(crate::telegram::configure());
|
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::schema::{people_departments, people_teams, people_time_off};
|
||||||
use crate::core::shared::state::AppState;
|
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)]
|
#[derive(Debug, Deserialize, Default)]
|
||||||
pub struct PeopleQuery {
|
pub struct PeopleQuery {
|
||||||
pub department: Option<String>,
|
pub department: Option<String>,
|
||||||
|
|
@ -57,7 +109,7 @@ async fn handle_people_list(
|
||||||
) -> Html<String> {
|
) -> Html<String> {
|
||||||
let pool = state.conn.clone();
|
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 mut conn = pool.get().ok()?;
|
||||||
let (bot_id, _) = get_default_bot(&mut conn);
|
let (bot_id, _) = get_default_bot(&mut conn);
|
||||||
|
|
||||||
|
|
@ -100,7 +152,7 @@ async fn handle_people_list(
|
||||||
people_table::avatar_url,
|
people_table::avatar_url,
|
||||||
people_table::is_active,
|
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()
|
.ok()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
@ -124,12 +176,14 @@ async fn handle_people_list(
|
||||||
<tbody>"##
|
<tbody>"##
|
||||||
);
|
);
|
||||||
|
|
||||||
for (id, first_name, last_name, email, job_title, department, avatar_url, is_active) in persons {
|
for row in persons {
|
||||||
let full_name = format!("{} {}", first_name, last_name.unwrap_or_default());
|
let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default());
|
||||||
let email_str = email.unwrap_or_else(|| "-".to_string());
|
let email_str = row.email.unwrap_or_else(|| "-".to_string());
|
||||||
let title_str = job_title.unwrap_or_else(|| "-".to_string());
|
let title_str = row.job_title.unwrap_or_else(|| "-".to_string());
|
||||||
let dept_str = department.unwrap_or_else(|| "-".to_string());
|
let dept_str = row.department.unwrap_or_else(|| "-".to_string());
|
||||||
let avatar = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".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_class = if is_active { "status-active" } else { "status-inactive" };
|
||||||
let status_text = if is_active { "Active" } else { "Inactive" };
|
let status_text = if is_active { "Active" } else { "Inactive" };
|
||||||
|
|
||||||
|
|
@ -178,7 +232,7 @@ async fn handle_people_cards(
|
||||||
) -> Html<String> {
|
) -> Html<String> {
|
||||||
let pool = state.conn.clone();
|
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 mut conn = pool.get().ok()?;
|
||||||
let (bot_id, _) = get_default_bot(&mut conn);
|
let (bot_id, _) = get_default_bot(&mut conn);
|
||||||
|
|
||||||
|
|
@ -207,7 +261,7 @@ async fn handle_people_cards(
|
||||||
people_table::avatar_url,
|
people_table::avatar_url,
|
||||||
people_table::phone,
|
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()
|
.ok()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
@ -218,13 +272,14 @@ async fn handle_people_cards(
|
||||||
Some(persons) if !persons.is_empty() => {
|
Some(persons) if !persons.is_empty() => {
|
||||||
let mut html = String::from(r##"<div class="people-cards-grid">"##);
|
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 {
|
for row in persons {
|
||||||
let full_name = format!("{} {}", first_name, last_name.unwrap_or_default());
|
let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default());
|
||||||
let email_str = email.unwrap_or_default();
|
let email_str = row.email.unwrap_or_default();
|
||||||
let title_str = job_title.unwrap_or_else(|| "Team Member".to_string());
|
let title_str = row.job_title.unwrap_or_else(|| "Team Member".to_string());
|
||||||
let dept_str = department.unwrap_or_default();
|
let dept_str = row.department.unwrap_or_default();
|
||||||
let avatar = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
|
let avatar = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
|
||||||
let phone_str = phone.unwrap_or_default();
|
let phone_str = row.phone.unwrap_or_default();
|
||||||
|
let id = row.id;
|
||||||
|
|
||||||
html.push_str(&format!(
|
html.push_str(&format!(
|
||||||
r##"<div class="person-card" data-id="{id}" hx-get="/api/ui/people/{id}" hx-target="#person-detail">
|
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> {
|
) -> Html<String> {
|
||||||
let pool = state.conn.clone();
|
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()?;
|
let mut conn = pool.get().ok()?;
|
||||||
|
|
||||||
people_table::table
|
people_table::table
|
||||||
|
|
@ -331,7 +386,7 @@ async fn handle_person_detail(
|
||||||
people_table::is_active,
|
people_table::is_active,
|
||||||
people_table::last_seen_at,
|
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()
|
.ok()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
@ -339,18 +394,20 @@ async fn handle_person_detail(
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Some((id, first_name, last_name, email, phone, mobile, job_title, department, office, avatar_url, bio, hire_date, is_active, last_seen)) => {
|
Some(row) => {
|
||||||
let full_name = format!("{} {}", first_name, last_name.unwrap_or_default());
|
let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default());
|
||||||
let email_str = email.unwrap_or_else(|| "-".to_string());
|
let email_str = row.email.unwrap_or_else(|| "-".to_string());
|
||||||
let phone_str = phone.unwrap_or_else(|| "-".to_string());
|
let phone_str = row.phone.unwrap_or_else(|| "-".to_string());
|
||||||
let mobile_str = mobile.unwrap_or_else(|| "-".to_string());
|
let mobile_str = row.mobile.unwrap_or_else(|| "-".to_string());
|
||||||
let title_str = job_title.unwrap_or_else(|| "-".to_string());
|
let title_str = row.job_title.unwrap_or_else(|| "-".to_string());
|
||||||
let dept_str = department.unwrap_or_else(|| "-".to_string());
|
let dept_str = row.department.unwrap_or_else(|| "-".to_string());
|
||||||
let office_str = office.unwrap_or_else(|| "-".to_string());
|
let office_str = row.office_location.unwrap_or_else(|| "-".to_string());
|
||||||
let avatar = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
|
let avatar = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
|
||||||
let bio_str = bio.unwrap_or_else(|| "No bio available".to_string());
|
let bio_str = row.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 hire_str = row.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());
|
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_class = if is_active { "status-active" } else { "status-inactive" };
|
||||||
let status_text = if is_active { "Active" } else { "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 pool = state.conn.clone();
|
||||||
let search_term = format!("%{q}%");
|
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 mut conn = pool.get().ok()?;
|
||||||
let (bot_id, _) = get_default_bot(&mut conn);
|
let (bot_id, _) = get_default_bot(&mut conn);
|
||||||
|
|
||||||
|
|
@ -660,7 +717,7 @@ async fn handle_people_search(
|
||||||
people_table::job_title,
|
people_table::job_title,
|
||||||
people_table::avatar_url,
|
people_table::avatar_url,
|
||||||
))
|
))
|
||||||
.load::<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>)>(&mut conn)
|
.load::<PersonSearchRow>(&mut conn)
|
||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
@ -671,11 +728,12 @@ async fn handle_people_search(
|
||||||
Some(persons) if !persons.is_empty() => {
|
Some(persons) if !persons.is_empty() => {
|
||||||
let mut html = String::from(r##"<div class="search-results">"##);
|
let mut html = String::from(r##"<div class="search-results">"##);
|
||||||
|
|
||||||
for (id, first_name, last_name, email, job_title, avatar_url) in persons {
|
for row in persons {
|
||||||
let full_name = format!("{} {}", first_name, last_name.unwrap_or_default());
|
let full_name = format!("{} {}", row.first_name, row.last_name.unwrap_or_default());
|
||||||
let email_str: String = email.unwrap_or_default();
|
let email_str: String = row.email.unwrap_or_default();
|
||||||
let title_str: String = job_title.unwrap_or_default();
|
let title_str: String = row.job_title.unwrap_or_default();
|
||||||
let avatar: String = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
|
let avatar: String = row.avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
|
||||||
|
let id = row.id;
|
||||||
|
|
||||||
html.push_str(&format!(
|
html.push_str(&format!(
|
||||||
r##"<div class="search-result-item" hx-get="/api/ui/people/{id}" hx-target="#person-detail">
|
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 block_executables: bool,
|
||||||
pub check_magic_bytes: bool,
|
pub check_magic_bytes: bool,
|
||||||
defang_pdf: bool,
|
defang_pdf: bool,
|
||||||
#[allow(dead_code)]
|
|
||||||
scan_for_malware: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FileValidationConfig {
|
impl Default for FileValidationConfig {
|
||||||
|
|
@ -67,7 +65,6 @@ impl Default for FileValidationConfig {
|
||||||
block_executables: true,
|
block_executables: true,
|
||||||
check_magic_bytes: true,
|
check_magic_bytes: true,
|
||||||
defang_pdf: 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/**", "PUT", ""),
|
||||||
RoutePermission::new("/api/contacts/**", "DELETE", ""),
|
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
|
// Billing / Products
|
||||||
RoutePermission::new("/api/billing/**", "GET", ""),
|
RoutePermission::new("/api/billing/**", "GET", ""),
|
||||||
RoutePermission::new("/api/billing/**", "POST", ""),
|
RoutePermission::new("/api/billing/**", "POST", ""),
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ impl RedisCsrfStore {
|
||||||
|
|
||||||
pub struct RedisCsrfManager {
|
pub struct RedisCsrfManager {
|
||||||
store: RedisCsrfStore,
|
store: RedisCsrfStore,
|
||||||
#[allow(dead_code)]
|
|
||||||
secret: Vec<u8>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RedisCsrfManager {
|
impl RedisCsrfManager {
|
||||||
|
|
@ -45,10 +43,7 @@ impl RedisCsrfManager {
|
||||||
|
|
||||||
let store = RedisCsrfStore::new(redis_url, config).await?;
|
let store = RedisCsrfStore::new(redis_url, config).await?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self { store })
|
||||||
store,
|
|
||||||
secret: secret.to_vec(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate_token(&self) -> Result<CsrfToken> {
|
pub async fn generate_token(&self) -> Result<CsrfToken> {
|
||||||
|
|
|
||||||
|
|
@ -520,6 +520,227 @@ pub async fn handle_mentions_autocomplete(
|
||||||
Json(mentions)
|
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>> {
|
pub fn configure_sources_routes() -> axum::Router<Arc<AppState>> {
|
||||||
use crate::core::urls::ApiUrls;
|
use crate::core::urls::ApiUrls;
|
||||||
use super::mcp_handlers::*;
|
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_TEST, post(handle_test_mcp_server))
|
||||||
.route(ApiUrls::SOURCES_MCP_SCAN, post(handle_scan_mcp_directory))
|
.route(ApiUrls::SOURCES_MCP_SCAN, post(handle_scan_mcp_directory))
|
||||||
.route(ApiUrls::SOURCES_MCP_EXAMPLES, get(handle_get_mcp_examples))
|
.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_MENTIONS, get(handle_mentions_autocomplete))
|
||||||
.route(ApiUrls::SOURCES_TOOLS, get(handle_list_all_tools))
|
.route(ApiUrls::SOURCES_TOOLS, get(handle_list_all_tools))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue