2240 lines
70 KiB
Rust
2240 lines
70 KiB
Rust
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
response::{Html, IntoResponse},
|
|
routing::{get, post, put},
|
|
Json, Router,
|
|
};
|
|
|
|
use chrono::{DateTime, NaiveDate, Utc};
|
|
use diesel::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::Arc;
|
|
use uuid::Uuid;
|
|
|
|
use crate::core::bot::get_default_bot;
|
|
use crate::core::shared::schema::{
|
|
crm_accounts, crm_activities, crm_contacts, crm_deals, crm_leads,
|
|
crm_notes, crm_opportunities, crm_pipeline_stages,
|
|
};
|
|
use crate::core::shared::schema::marketing_campaigns;
|
|
use crate::core::shared::state::AppState;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
|
|
#[diesel(table_name = crm_contacts)]
|
|
pub struct CrmContact {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub first_name: Option<String>,
|
|
pub last_name: Option<String>,
|
|
pub email: Option<String>,
|
|
pub phone: Option<String>,
|
|
pub mobile: Option<String>,
|
|
pub company: Option<String>,
|
|
pub job_title: Option<String>,
|
|
pub source: Option<String>,
|
|
pub status: String,
|
|
pub tags: Vec<String>,
|
|
pub custom_fields: serde_json::Value,
|
|
pub address_line1: Option<String>,
|
|
pub address_line2: Option<String>,
|
|
pub city: Option<String>,
|
|
pub state: Option<String>,
|
|
pub postal_code: Option<String>,
|
|
pub country: Option<String>,
|
|
pub notes: Option<String>,
|
|
pub owner_id: Option<Uuid>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
|
|
#[diesel(table_name = crm_accounts)]
|
|
pub struct CrmAccount {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub name: String,
|
|
pub website: Option<String>,
|
|
pub industry: Option<String>,
|
|
pub employees_count: Option<i32>,
|
|
pub annual_revenue: Option<f64>,
|
|
pub phone: Option<String>,
|
|
pub email: Option<String>,
|
|
pub address_line1: Option<String>,
|
|
pub address_line2: Option<String>,
|
|
pub city: Option<String>,
|
|
pub state: Option<String>,
|
|
pub postal_code: Option<String>,
|
|
pub country: Option<String>,
|
|
pub description: Option<String>,
|
|
pub tags: Vec<String>,
|
|
pub custom_fields: serde_json::Value,
|
|
pub owner_id: Option<Uuid>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
|
|
#[diesel(table_name = crm_pipeline_stages)]
|
|
pub struct CrmPipelineStage {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub name: String,
|
|
pub stage_order: i32,
|
|
pub probability: i32,
|
|
pub is_won: bool,
|
|
pub is_lost: bool,
|
|
pub color: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
|
|
#[diesel(table_name = crm_deals)]
|
|
pub struct CrmDeal {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub contact_id: Option<Uuid>,
|
|
pub account_id: Option<Uuid>,
|
|
pub am_id: Option<Uuid>,
|
|
pub owner_id: Option<Uuid>,
|
|
pub lead_id: Option<Uuid>,
|
|
pub title: Option<String>,
|
|
pub name: Option<String>,
|
|
pub description: Option<String>,
|
|
pub value: Option<f64>,
|
|
pub currency: Option<String>,
|
|
pub stage_id: Option<Uuid>,
|
|
pub stage: Option<String>,
|
|
pub probability: i32,
|
|
pub source: Option<String>,
|
|
pub segment_id: Option<Uuid>,
|
|
pub department_id: Option<Uuid>,
|
|
pub expected_close_date: Option<NaiveDate>,
|
|
pub actual_close_date: Option<NaiveDate>,
|
|
pub period: Option<i32>,
|
|
pub deal_date: Option<NaiveDate>,
|
|
pub closed_at: Option<DateTime<Utc>>,
|
|
pub lost_reason: Option<String>,
|
|
pub won: Option<bool>,
|
|
pub notes: Option<String>,
|
|
pub tags: Vec<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: Option<DateTime<Utc>>,
|
|
pub custom_fields: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
|
|
#[diesel(table_name = crm_leads)]
|
|
pub struct CrmLead {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub contact_id: Option<Uuid>,
|
|
pub account_id: Option<Uuid>,
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub value: Option<f64>,
|
|
pub currency: Option<String>,
|
|
pub stage_id: Option<Uuid>,
|
|
pub stage: String,
|
|
pub probability: i32,
|
|
pub source: Option<String>,
|
|
pub expected_close_date: Option<NaiveDate>,
|
|
pub owner_id: Option<Uuid>,
|
|
pub lost_reason: Option<String>,
|
|
pub tags: Vec<String>,
|
|
pub custom_fields: serde_json::Value,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
pub closed_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
|
|
#[diesel(table_name = crm_opportunities)]
|
|
pub struct CrmOpportunity {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub lead_id: Option<Uuid>,
|
|
pub account_id: Option<Uuid>,
|
|
pub contact_id: Option<Uuid>,
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub value: Option<f64>,
|
|
pub currency: Option<String>,
|
|
pub stage_id: Option<Uuid>,
|
|
pub stage: String,
|
|
pub probability: i32,
|
|
pub source: Option<String>,
|
|
pub expected_close_date: Option<NaiveDate>,
|
|
pub actual_close_date: Option<NaiveDate>,
|
|
pub won: Option<bool>,
|
|
pub owner_id: Option<Uuid>,
|
|
pub tags: Vec<String>,
|
|
pub custom_fields: serde_json::Value,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
|
|
#[diesel(table_name = crm_activities)]
|
|
pub struct CrmActivity {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub contact_id: Option<Uuid>,
|
|
pub lead_id: Option<Uuid>,
|
|
pub opportunity_id: Option<Uuid>,
|
|
pub account_id: Option<Uuid>,
|
|
pub activity_type: String,
|
|
pub subject: Option<String>,
|
|
pub description: Option<String>,
|
|
pub due_date: Option<DateTime<Utc>>,
|
|
pub completed_at: Option<DateTime<Utc>>,
|
|
pub outcome: Option<String>,
|
|
pub owner_id: Option<Uuid>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
|
|
#[diesel(table_name = crm_notes)]
|
|
pub struct CrmNote {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub contact_id: Option<Uuid>,
|
|
pub lead_id: Option<Uuid>,
|
|
pub opportunity_id: Option<Uuid>,
|
|
pub account_id: Option<Uuid>,
|
|
pub content: String,
|
|
pub author_id: Option<Uuid>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ImportPostgresRequest {
|
|
pub connection_string: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateContactRequest {
|
|
pub first_name: Option<String>,
|
|
pub last_name: Option<String>,
|
|
pub email: Option<String>,
|
|
pub phone: Option<String>,
|
|
pub mobile: Option<String>,
|
|
pub company: Option<String>,
|
|
pub job_title: Option<String>,
|
|
pub source: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
pub address_line1: Option<String>,
|
|
pub city: Option<String>,
|
|
pub state: Option<String>,
|
|
pub postal_code: Option<String>,
|
|
pub country: Option<String>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateContactRequest {
|
|
pub first_name: Option<String>,
|
|
pub last_name: Option<String>,
|
|
pub email: Option<String>,
|
|
pub phone: Option<String>,
|
|
pub mobile: Option<String>,
|
|
pub company: Option<String>,
|
|
pub job_title: Option<String>,
|
|
pub status: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateAccountRequest {
|
|
pub name: String,
|
|
pub website: Option<String>,
|
|
pub industry: Option<String>,
|
|
pub employees_count: Option<i32>,
|
|
pub phone: Option<String>,
|
|
pub email: Option<String>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateLeadRequest {
|
|
pub title: String,
|
|
pub contact_id: Option<Uuid>,
|
|
pub account_id: Option<Uuid>,
|
|
pub value: Option<f64>,
|
|
pub currency: Option<String>,
|
|
pub source: Option<String>,
|
|
pub expected_close_date: Option<String>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateLeadRequest {
|
|
pub title: Option<String>,
|
|
pub value: Option<f64>,
|
|
pub stage: Option<String>,
|
|
pub probability: Option<i32>,
|
|
pub expected_close_date: Option<String>,
|
|
pub description: Option<String>,
|
|
pub lost_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateOpportunityRequest {
|
|
pub name: String,
|
|
pub lead_id: Option<Uuid>,
|
|
pub account_id: Option<Uuid>,
|
|
pub contact_id: Option<Uuid>,
|
|
pub value: Option<f64>,
|
|
pub currency: Option<String>,
|
|
pub stage: Option<String>,
|
|
pub expected_close_date: Option<String>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateOpportunityRequest {
|
|
pub name: Option<String>,
|
|
pub value: Option<f64>,
|
|
pub stage: Option<String>,
|
|
pub probability: Option<i32>,
|
|
pub expected_close_date: Option<String>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CloseOpportunityRequest {
|
|
pub won: bool,
|
|
pub actual_close_date: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateDealRequest {
|
|
pub title: Option<String>,
|
|
pub name: Option<String>,
|
|
pub contact_id: Option<Uuid>,
|
|
pub account_id: Option<Uuid>,
|
|
pub owner_id: Option<Uuid>,
|
|
pub department_id: Option<Uuid>,
|
|
pub value: Option<f64>,
|
|
pub currency: Option<String>,
|
|
pub stage: Option<String>,
|
|
pub probability: Option<i32>,
|
|
pub source: Option<String>,
|
|
pub expected_close_date: Option<String>,
|
|
pub description: Option<String>,
|
|
pub notes: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateDealRequest {
|
|
pub title: Option<String>,
|
|
pub name: Option<String>,
|
|
pub contact_id: Option<Uuid>,
|
|
pub account_id: Option<Uuid>,
|
|
pub owner_id: Option<Uuid>,
|
|
pub department_id: Option<Uuid>,
|
|
pub value: Option<f64>,
|
|
pub currency: Option<String>,
|
|
pub stage: Option<String>,
|
|
pub probability: Option<i32>,
|
|
pub source: Option<String>,
|
|
pub expected_close_date: Option<String>,
|
|
pub description: Option<String>,
|
|
pub lost_reason: Option<String>,
|
|
pub won: Option<bool>,
|
|
pub notes: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateActivityRequest {
|
|
pub activity_type: String,
|
|
pub subject: Option<String>,
|
|
pub description: Option<String>,
|
|
pub contact_id: Option<Uuid>,
|
|
pub lead_id: Option<Uuid>,
|
|
pub opportunity_id: Option<Uuid>,
|
|
pub account_id: Option<Uuid>,
|
|
pub due_date: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListQuery {
|
|
pub search: Option<String>,
|
|
pub stage: Option<String>,
|
|
pub status: Option<String>,
|
|
pub owner_id: Option<Uuid>,
|
|
pub department_id: Option<Uuid>,
|
|
pub source: Option<String>,
|
|
pub limit: Option<i64>,
|
|
pub offset: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct PipelineStats {
|
|
pub total_leads: i64,
|
|
pub total_opportunities: i64,
|
|
pub total_value: f64,
|
|
pub won_value: f64,
|
|
pub conversion_rate: f64,
|
|
pub avg_deal_size: f64,
|
|
pub stages: Vec<StageStats>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct StageStats {
|
|
pub stage: String,
|
|
pub count: i64,
|
|
pub value: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
|
|
#[diesel(table_name = marketing_campaigns)]
|
|
pub struct CrmCampaign {
|
|
pub id: Uuid,
|
|
pub org_id: Uuid,
|
|
pub bot_id: Uuid,
|
|
pub deal_id: Option<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>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct CrmStats {
|
|
pub total_contacts: i64,
|
|
pub total_accounts: i64,
|
|
pub total_leads: i64,
|
|
pub total_opportunities: i64,
|
|
pub total_campaigns: i64,
|
|
pub pipeline_value: f64,
|
|
pub won_this_month: i64,
|
|
pub conversion_rate: f64,
|
|
}
|
|
|
|
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);
|
|
|
|
// Get org_id using diesel query
|
|
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 create_contact(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateContactRequest>,
|
|
) -> Result<Json<CrmContact>, (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 contact = CrmContact {
|
|
id,
|
|
org_id,
|
|
bot_id,
|
|
first_name: req.first_name,
|
|
last_name: req.last_name,
|
|
email: req.email,
|
|
phone: req.phone,
|
|
mobile: req.mobile,
|
|
company: req.company,
|
|
job_title: req.job_title,
|
|
source: req.source,
|
|
status: "active".to_string(),
|
|
tags: req.tags.unwrap_or_default(),
|
|
custom_fields: serde_json::json!({}),
|
|
address_line1: req.address_line1,
|
|
address_line2: None,
|
|
city: req.city,
|
|
state: req.state,
|
|
postal_code: req.postal_code,
|
|
country: req.country,
|
|
notes: req.notes,
|
|
owner_id: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
|
|
diesel::insert_into(crm_contacts::table)
|
|
.values(&contact)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
crate::marketing::triggers::trigger_contact_change(
|
|
&mut conn,
|
|
id,
|
|
"created",
|
|
bot_id,
|
|
);
|
|
|
|
Ok(Json(contact))
|
|
}
|
|
|
|
pub async fn list_contacts(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<Vec<CrmContact>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let limit = query.limit.unwrap_or(50);
|
|
let offset = query.offset.unwrap_or(0);
|
|
|
|
let mut q = crm_contacts::table
|
|
.filter(crm_contacts::org_id.eq(org_id))
|
|
.filter(crm_contacts::bot_id.eq(bot_id))
|
|
.into_boxed();
|
|
|
|
if let Some(status) = query.status {
|
|
q = q.filter(crm_contacts::status.eq(status));
|
|
}
|
|
|
|
if let Some(search) = query.search {
|
|
let pattern = format!("%{search}%");
|
|
q = q.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))
|
|
);
|
|
}
|
|
|
|
let contacts: Vec<CrmContact> = q
|
|
.order(crm_contacts::created_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(contacts))
|
|
}
|
|
|
|
pub async fn get_contact(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<CrmContact>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let contact: CrmContact = crm_contacts::table
|
|
.filter(crm_contacts::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Contact not found".to_string()))?;
|
|
|
|
Ok(Json(contact))
|
|
}
|
|
|
|
pub async fn update_contact(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<UpdateContactRequest>,
|
|
) -> Result<Json<CrmContact>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let now = Utc::now();
|
|
|
|
diesel::update(crm_contacts::table.filter(crm_contacts::id.eq(id)))
|
|
.set(crm_contacts::updated_at.eq(now))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
if let Some(first_name) = req.first_name {
|
|
diesel::update(crm_contacts::table.filter(crm_contacts::id.eq(id)))
|
|
.set(crm_contacts::first_name.eq(first_name))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(last_name) = req.last_name {
|
|
diesel::update(crm_contacts::table.filter(crm_contacts::id.eq(id)))
|
|
.set(crm_contacts::last_name.eq(last_name))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(email) = req.email {
|
|
diesel::update(crm_contacts::table.filter(crm_contacts::id.eq(id)))
|
|
.set(crm_contacts::email.eq(email))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(phone) = req.phone {
|
|
diesel::update(crm_contacts::table.filter(crm_contacts::id.eq(id)))
|
|
.set(crm_contacts::phone.eq(phone))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(status) = req.status {
|
|
diesel::update(crm_contacts::table.filter(crm_contacts::id.eq(id)))
|
|
.set(crm_contacts::status.eq(status))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
get_contact(State(state), Path(id)).await
|
|
}
|
|
|
|
pub async fn delete_contact(
|
|
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(crm_contacts::table.filter(crm_contacts::id.eq(id)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn create_account(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateAccountRequest>,
|
|
) -> Result<Json<CrmAccount>, (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 account = CrmAccount {
|
|
id,
|
|
org_id,
|
|
bot_id,
|
|
name: req.name,
|
|
website: req.website,
|
|
industry: req.industry,
|
|
employees_count: req.employees_count,
|
|
annual_revenue: None,
|
|
phone: req.phone,
|
|
email: req.email,
|
|
address_line1: None,
|
|
address_line2: None,
|
|
city: None,
|
|
state: None,
|
|
postal_code: None,
|
|
country: None,
|
|
description: req.description,
|
|
tags: vec![],
|
|
custom_fields: serde_json::json!({}),
|
|
owner_id: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
|
|
diesel::insert_into(crm_accounts::table)
|
|
.values(&account)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
Ok(Json(account))
|
|
}
|
|
|
|
pub async fn list_accounts(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<Vec<CrmAccount>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let limit = query.limit.unwrap_or(50);
|
|
let offset = query.offset.unwrap_or(0);
|
|
|
|
let mut q = crm_accounts::table
|
|
.filter(crm_accounts::org_id.eq(org_id))
|
|
.filter(crm_accounts::bot_id.eq(bot_id))
|
|
.into_boxed();
|
|
|
|
if let Some(search) = query.search {
|
|
let pattern = format!("%{search}%");
|
|
q = q.filter(
|
|
crm_accounts::name.ilike(pattern.clone())
|
|
.or(crm_accounts::industry.ilike(pattern))
|
|
);
|
|
}
|
|
|
|
let accounts: Vec<CrmAccount> = q
|
|
.order(crm_accounts::created_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(accounts))
|
|
}
|
|
|
|
pub async fn get_account(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<CrmAccount>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let account: CrmAccount = crm_accounts::table
|
|
.filter(crm_accounts::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Account not found".to_string()))?;
|
|
|
|
Ok(Json(account))
|
|
}
|
|
|
|
pub async fn delete_account(
|
|
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(crm_accounts::table.filter(crm_accounts::id.eq(id)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateLeadForm {
|
|
pub title: Option<String>,
|
|
pub first_name: Option<String>,
|
|
pub last_name: Option<String>,
|
|
pub email: Option<String>,
|
|
pub phone: Option<String>,
|
|
pub company: Option<String>,
|
|
pub job_title: Option<String>,
|
|
pub source: Option<String>,
|
|
pub value: Option<f64>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
pub async fn create_lead_form(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateLeadForm>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
log::info!("create_lead_form JSON: {:?}", req);
|
|
|
|
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);
|
|
log::info!("get_bot_context: org_id={}, bot_id={}", org_id, bot_id);
|
|
|
|
// If org_id is nil, use bot_id as org_id
|
|
let effective_org_id = if org_id == Uuid::nil() { bot_id } else { org_id };
|
|
log::info!("effective_org_id={}", effective_org_id);
|
|
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
|
|
// Generate lead title from first and last name or use default
|
|
let title = req.title.or_else(|| {
|
|
match (req.first_name.as_deref(), req.last_name.as_deref()) {
|
|
(Some(first), Some(last)) => Some(format!("{} {}", first, last)),
|
|
(Some(first), None) => Some(first.to_string()),
|
|
(None, Some(last)) => Some(last.to_string()),
|
|
(None, None) => Some("New Lead".to_string()),
|
|
}
|
|
}).unwrap_or("New Lead".to_string());
|
|
|
|
// Skip contact creation - org_id validation fails because bot_id isn't in organizations table
|
|
// TODO: Fix by either adding bot to organizations or making org_id nullable
|
|
let contact_id: Option<Uuid> = None;
|
|
|
|
let value = req.value;
|
|
|
|
let lead = CrmDeal {
|
|
id,
|
|
org_id: effective_org_id,
|
|
bot_id,
|
|
contact_id,
|
|
account_id: None,
|
|
am_id: None,
|
|
lead_id: None,
|
|
title: Some(title),
|
|
name: None,
|
|
description: req.description,
|
|
value,
|
|
currency: Some("USD".to_string()),
|
|
stage_id: None,
|
|
stage: Some("new".to_string()),
|
|
probability: 10,
|
|
source: req.source.clone(),
|
|
segment_id: None,
|
|
department_id: None,
|
|
expected_close_date: None,
|
|
actual_close_date: None,
|
|
period: None,
|
|
deal_date: None,
|
|
owner_id: None,
|
|
lost_reason: None,
|
|
won: None,
|
|
tags: vec![],
|
|
custom_fields: serde_json::json!({}),
|
|
created_at: now,
|
|
updated_at: Some(now),
|
|
closed_at: None,
|
|
notes: None,
|
|
};
|
|
|
|
diesel::insert_into(crm_deals::table)
|
|
.values(&lead)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert lead error: {e}")))?;
|
|
|
|
Ok(Json(lead))
|
|
}
|
|
|
|
pub async fn create_lead(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateLeadRequest>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
|
|
let expected_close = req.expected_close_date
|
|
.and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
|
|
|
|
let value = req.value;
|
|
|
|
let lead = CrmDeal {
|
|
id,
|
|
org_id,
|
|
bot_id,
|
|
contact_id: req.contact_id,
|
|
account_id: req.account_id,
|
|
am_id: None,
|
|
lead_id: None,
|
|
title: Some(req.title),
|
|
name: None,
|
|
description: req.description,
|
|
value,
|
|
currency: req.currency.or(Some("USD".to_string())),
|
|
stage_id: None,
|
|
stage: Some("new".to_string()),
|
|
probability: 10,
|
|
source: req.source,
|
|
segment_id: None,
|
|
department_id: None,
|
|
expected_close_date: expected_close,
|
|
actual_close_date: None,
|
|
period: None,
|
|
deal_date: None,
|
|
owner_id: None,
|
|
lost_reason: None,
|
|
won: None,
|
|
tags: vec![],
|
|
custom_fields: serde_json::json!({}),
|
|
created_at: now,
|
|
updated_at: Some(now),
|
|
closed_at: None,
|
|
notes: None,
|
|
};
|
|
|
|
diesel::insert_into(crm_deals::table)
|
|
.values(&lead)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
Ok(Json(lead))
|
|
}
|
|
|
|
pub async fn list_leads(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<Vec<CrmDeal>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let limit = query.limit.unwrap_or(50);
|
|
let offset = query.offset.unwrap_or(0);
|
|
|
|
let mut q = crm_deals::table
|
|
.filter(crm_deals::org_id.eq(org_id))
|
|
.filter(crm_deals::bot_id.eq(bot_id))
|
|
.into_boxed();
|
|
|
|
if let Some(stage) = query.stage {
|
|
q = q.filter(crm_deals::stage.eq(stage));
|
|
}
|
|
|
|
if let Some(search) = query.search {
|
|
let pattern = format!("%{search}%");
|
|
q = q.filter(crm_deals::title.ilike(pattern));
|
|
}
|
|
|
|
if let Some(department_id) = query.department_id {
|
|
q = q.filter(crm_deals::department_id.eq(department_id));
|
|
}
|
|
|
|
if let Some(source) = query.source {
|
|
q = q.filter(crm_deals::source.eq(source));
|
|
}
|
|
|
|
let leads: Vec<CrmDeal> = q
|
|
.order(crm_deals::created_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(leads))
|
|
}
|
|
|
|
pub async fn get_lead(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let lead: CrmDeal = crm_deals::table
|
|
.filter(crm_deals::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Lead not found".to_string()))?;
|
|
|
|
Ok(Json(lead))
|
|
}
|
|
|
|
pub async fn update_lead(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<UpdateLeadRequest>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let now = Utc::now();
|
|
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::updated_at.eq(now))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
if let Some(title) = req.title {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::title.eq(title))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(stage) = req.stage {
|
|
let probability = match stage.as_str() {
|
|
"new" => 10,
|
|
"qualified" => 25,
|
|
"proposal" => 50,
|
|
"negotiation" => 75,
|
|
"won" => 100,
|
|
"lost" => 0,
|
|
_ => req.probability.unwrap_or(0),
|
|
};
|
|
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set((
|
|
crm_deals::stage.eq(&stage),
|
|
crm_deals::probability.eq(probability),
|
|
))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
if stage == "won" || stage == "lost" {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::closed_at.eq(Some(now)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
}
|
|
|
|
if let Some(lost_reason) = req.lost_reason {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::lost_reason.eq(lost_reason))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
get_lead(State(state), Path(id)).await
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct LeadStageQuery {
|
|
stage: String,
|
|
}
|
|
|
|
pub async fn update_lead_stage(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
Query(query): Query<LeadStageQuery>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let now = Utc::now();
|
|
let stage = query.stage;
|
|
|
|
let old_stage: Option<Option<String>> = crm_deals::table
|
|
.filter(crm_deals::id.eq(id))
|
|
.select(crm_deals::stage)
|
|
.first(&mut conn)
|
|
.ok();
|
|
|
|
let old_stage_str = old_stage.flatten();
|
|
|
|
let probability = match stage.as_str() {
|
|
"new" => 10,
|
|
"qualified" => 25,
|
|
"proposal" => 50,
|
|
"negotiation" => 75,
|
|
"won" => 100,
|
|
"lost" => 0,
|
|
"converted" => 100,
|
|
_ => 25,
|
|
};
|
|
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set((
|
|
crm_deals::stage.eq(&stage),
|
|
crm_deals::probability.eq(probability),
|
|
crm_deals::updated_at.eq(now),
|
|
))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
if stage == "won" || stage == "lost" || stage == "converted" {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::closed_at.eq(Some(now)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(old) = old_stage_str {
|
|
if old != stage {
|
|
let (_org_id, bot_id) = get_bot_context(&state);
|
|
crate::marketing::triggers::trigger_deal_stage_change(
|
|
&mut conn,
|
|
id,
|
|
&old,
|
|
&stage,
|
|
bot_id,
|
|
);
|
|
}
|
|
}
|
|
|
|
get_lead(State(state), Path(id)).await
|
|
}
|
|
|
|
pub async fn delete_lead(
|
|
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(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn convert_lead_to_opportunity(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let lead: CrmDeal = crm_deals::table
|
|
.filter(crm_deals::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Lead not found".to_string()))?;
|
|
|
|
let opp_id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
|
|
let opportunity = CrmDeal {
|
|
id: opp_id,
|
|
org_id: lead.org_id,
|
|
bot_id: lead.bot_id,
|
|
lead_id: Some(lead.id),
|
|
account_id: lead.account_id,
|
|
contact_id: lead.contact_id,
|
|
am_id: None,
|
|
title: lead.title.clone(),
|
|
name: lead.title.clone(),
|
|
description: lead.description.clone(),
|
|
value: lead.value,
|
|
currency: lead.currency.clone(),
|
|
stage_id: None,
|
|
stage: Some("qualification".to_string()),
|
|
probability: 25,
|
|
source: lead.source.clone(),
|
|
segment_id: None,
|
|
department_id: None,
|
|
expected_close_date: lead.expected_close_date,
|
|
actual_close_date: None,
|
|
period: None,
|
|
deal_date: None,
|
|
won: None,
|
|
owner_id: lead.owner_id,
|
|
lost_reason: None,
|
|
closed_at: None,
|
|
notes: None,
|
|
tags: lead.tags.clone(),
|
|
custom_fields: lead.custom_fields.clone(),
|
|
created_at: now,
|
|
updated_at: Some(now),
|
|
};
|
|
|
|
diesel::insert_into(crm_deals::table)
|
|
.values(&opportunity)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set((
|
|
crm_deals::stage.eq("converted"),
|
|
crm_deals::closed_at.eq(Some(now)),
|
|
))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
Ok(Json(opportunity))
|
|
}
|
|
|
|
pub async fn create_opportunity(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateOpportunityRequest>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
|
|
let expected_close = req.expected_close_date
|
|
.and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
|
|
|
|
let value = req.value;
|
|
let stage = req.stage.unwrap_or_else(|| "qualification".to_string());
|
|
|
|
let probability = match stage.as_str() {
|
|
"qualification" => 25,
|
|
"proposal" => 50,
|
|
"negotiation" => 75,
|
|
"won" => 100,
|
|
"lost" => 0,
|
|
_ => 25,
|
|
};
|
|
|
|
let opportunity = CrmDeal {
|
|
id,
|
|
org_id,
|
|
bot_id,
|
|
lead_id: req.lead_id,
|
|
account_id: req.account_id,
|
|
contact_id: req.contact_id,
|
|
am_id: None,
|
|
title: None,
|
|
name: Some(req.name),
|
|
description: req.description,
|
|
value,
|
|
currency: req.currency.or(Some("USD".to_string())),
|
|
stage_id: None,
|
|
stage: Some(stage),
|
|
probability,
|
|
source: None,
|
|
segment_id: None,
|
|
department_id: None,
|
|
expected_close_date: expected_close,
|
|
actual_close_date: None,
|
|
period: None,
|
|
deal_date: None,
|
|
won: None,
|
|
owner_id: None,
|
|
lost_reason: None,
|
|
closed_at: None,
|
|
notes: None,
|
|
tags: vec![],
|
|
custom_fields: serde_json::json!({}),
|
|
created_at: now,
|
|
updated_at: Some(now),
|
|
};
|
|
|
|
diesel::insert_into(crm_deals::table)
|
|
.values(&opportunity)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
Ok(Json(opportunity))
|
|
}
|
|
|
|
pub async fn list_opportunities(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<Vec<CrmDeal>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let limit = query.limit.unwrap_or(50);
|
|
let offset = query.offset.unwrap_or(0);
|
|
|
|
let mut q = crm_deals::table
|
|
.filter(crm_deals::org_id.eq(org_id))
|
|
.filter(crm_deals::bot_id.eq(bot_id))
|
|
.into_boxed();
|
|
|
|
if let Some(stage) = query.stage {
|
|
q = q.filter(crm_deals::stage.eq(stage));
|
|
}
|
|
|
|
if let Some(search) = query.search {
|
|
let pattern = format!("%{search}%");
|
|
q = q.filter(crm_deals::name.ilike(pattern));
|
|
}
|
|
|
|
if let Some(department_id) = query.department_id {
|
|
q = q.filter(crm_deals::department_id.eq(department_id));
|
|
}
|
|
|
|
if let Some(source) = query.source {
|
|
q = q.filter(crm_deals::source.eq(source));
|
|
}
|
|
|
|
let opportunities: Vec<CrmDeal> = q
|
|
.order(crm_deals::created_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(opportunities))
|
|
}
|
|
|
|
pub async fn get_opportunity(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let opp: CrmDeal = crm_deals::table
|
|
.filter(crm_deals::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Opportunity not found".to_string()))?;
|
|
|
|
Ok(Json(opp))
|
|
}
|
|
|
|
pub async fn update_opportunity(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<UpdateOpportunityRequest>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let now = Utc::now();
|
|
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::updated_at.eq(now))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
if let Some(name) = req.name {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::name.eq(name))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(stage) = req.stage {
|
|
let probability = match stage.as_str() {
|
|
"qualification" => 25,
|
|
"proposal" => 50,
|
|
"negotiation" => 75,
|
|
"won" => 100,
|
|
"lost" => 0,
|
|
_ => req.probability.unwrap_or(25),
|
|
};
|
|
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set((
|
|
crm_deals::stage.eq(&stage),
|
|
crm_deals::probability.eq(probability),
|
|
))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
get_opportunity(State(state), Path(id)).await
|
|
}
|
|
|
|
pub async fn close_opportunity(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<CloseOpportunityRequest>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let now = Utc::now();
|
|
let close_date = req.actual_close_date
|
|
.and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok())
|
|
.unwrap_or_else(|| now.date_naive());
|
|
|
|
let stage = if req.won { "won" } else { "lost" };
|
|
let probability = if req.won { 100 } else { 0 };
|
|
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set((
|
|
crm_deals::won.eq(Some(req.won)),
|
|
crm_deals::stage.eq(stage),
|
|
crm_deals::probability.eq(probability),
|
|
crm_deals::actual_close_date.eq(Some(close_date)),
|
|
crm_deals::updated_at.eq(now),
|
|
))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
get_opportunity(State(state), Path(id)).await
|
|
}
|
|
|
|
pub async fn delete_opportunity(
|
|
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(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn list_deals(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<Vec<CrmDeal>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let limit = query.limit.unwrap_or(50);
|
|
let offset = query.offset.unwrap_or(0);
|
|
|
|
let mut q = crm_deals::table
|
|
.filter(crm_deals::org_id.eq(org_id))
|
|
.filter(crm_deals::bot_id.eq(bot_id))
|
|
.into_boxed();
|
|
|
|
if let Some(stage) = query.stage {
|
|
q = q.filter(crm_deals::stage.eq(stage));
|
|
}
|
|
|
|
if let Some(search) = query.search {
|
|
let pattern = format!("%{search}%");
|
|
q = q.filter(crm_deals::title.ilike(pattern.clone()).or(crm_deals::name.ilike(pattern)));
|
|
}
|
|
|
|
if let Some(department_id) = query.department_id {
|
|
q = q.filter(crm_deals::department_id.eq(department_id));
|
|
}
|
|
|
|
if let Some(source) = query.source {
|
|
q = q.filter(crm_deals::source.eq(source));
|
|
}
|
|
|
|
if let Some(owner_id) = query.owner_id {
|
|
q = q.filter(crm_deals::owner_id.eq(owner_id));
|
|
}
|
|
|
|
if let Some(status) = query.status {
|
|
match status.as_str() {
|
|
"open" => {
|
|
q = q.filter(crm_deals::closed_at.is_null());
|
|
}
|
|
"closed" => {
|
|
q = q.filter(crm_deals::closed_at.is_not_null());
|
|
}
|
|
"won" => {
|
|
q = q.filter(crm_deals::won.eq(true));
|
|
}
|
|
"lost" => {
|
|
q = q.filter(crm_deals::won.eq(false));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let deals: Vec<CrmDeal> = q
|
|
.order(crm_deals::created_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(deals))
|
|
}
|
|
|
|
pub async fn create_deal(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateDealRequest>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
|
|
let expected_close = req.expected_close_date
|
|
.and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
|
|
|
|
let stage = req.stage.unwrap_or_else(|| "new".to_string());
|
|
let probability = match stage.as_str() {
|
|
"new" => 10,
|
|
"qualified" => 25,
|
|
"proposal" => 50,
|
|
"negotiation" => 75,
|
|
"won" => 100,
|
|
"lost" => 0,
|
|
_ => req.probability.unwrap_or(10),
|
|
};
|
|
|
|
let deal = CrmDeal {
|
|
id,
|
|
org_id,
|
|
bot_id,
|
|
contact_id: req.contact_id,
|
|
account_id: req.account_id,
|
|
am_id: None,
|
|
lead_id: None,
|
|
owner_id: req.owner_id,
|
|
title: req.title,
|
|
name: req.name,
|
|
description: req.description,
|
|
value: req.value,
|
|
currency: req.currency.or(Some("USD".to_string())),
|
|
stage_id: None,
|
|
stage: Some(stage.clone()),
|
|
probability,
|
|
source: req.source,
|
|
segment_id: None,
|
|
department_id: req.department_id,
|
|
expected_close_date: expected_close,
|
|
actual_close_date: None,
|
|
period: None,
|
|
deal_date: None,
|
|
lost_reason: None,
|
|
won: if stage == "won" {
|
|
Some(true)
|
|
} else if stage == "lost" {
|
|
Some(false)
|
|
} else {
|
|
None
|
|
},
|
|
tags: req.tags.unwrap_or_default(),
|
|
custom_fields: serde_json::json!({}),
|
|
created_at: now,
|
|
updated_at: Some(now),
|
|
closed_at: if stage == "won" || stage == "lost" {
|
|
Some(now)
|
|
} else {
|
|
None
|
|
},
|
|
notes: req.notes,
|
|
};
|
|
|
|
diesel::insert_into(crm_deals::table)
|
|
.values(&deal)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert deal error: {e}")))?;
|
|
|
|
Ok(Json(deal))
|
|
}
|
|
|
|
pub async fn get_deal(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let deal: CrmDeal = crm_deals::table
|
|
.filter(crm_deals::id.eq(id))
|
|
.first(&mut conn)
|
|
.map_err(|_| (StatusCode::NOT_FOUND, "Deal not found".to_string()))?;
|
|
|
|
Ok(Json(deal))
|
|
}
|
|
|
|
pub async fn update_deal(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<UpdateDealRequest>,
|
|
) -> Result<Json<CrmDeal>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let now = Utc::now();
|
|
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::updated_at.eq(now))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
if let Some(title) = req.title {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::title.eq(title))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(name) = req.name {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::name.eq(name))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(value) = req.value {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::value.eq(value))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(currency) = req.currency {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::currency.eq(currency))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(stage) = req.stage {
|
|
let probability = match stage.as_str() {
|
|
"new" => 10,
|
|
"qualified" => 25,
|
|
"proposal" => 50,
|
|
"negotiation" => 75,
|
|
"won" => 100,
|
|
"lost" => 0,
|
|
_ => req.probability.unwrap_or(0),
|
|
};
|
|
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set((
|
|
crm_deals::stage.eq(&stage),
|
|
crm_deals::probability.eq(probability),
|
|
))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
|
|
if stage == "won" || stage == "lost" {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::closed_at.eq(Some(now)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
}
|
|
|
|
if let Some(department_id) = req.department_id {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::department_id.eq(department_id))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(owner_id) = req.owner_id {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::owner_id.eq(owner_id))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(lost_reason) = req.lost_reason {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::lost_reason.eq(lost_reason))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(won) = req.won {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set((
|
|
crm_deals::won.eq(won),
|
|
crm_deals::closed_at.eq(Some(now)),
|
|
))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(notes) = req.notes {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::notes.eq(notes))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
if let Some(tags) = req.tags {
|
|
diesel::update(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.set(crm_deals::tags.eq(tags))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
|
|
}
|
|
|
|
get_deal(State(state), Path(id)).await
|
|
}
|
|
|
|
pub async fn delete_deal(
|
|
State(state): State<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(crm_deals::table.filter(crm_deals::id.eq(id)))
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn create_activity(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<CreateActivityRequest>,
|
|
) -> Result<Json<CrmActivity>, (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 due_date = req.due_date
|
|
.and_then(|d| DateTime::parse_from_rfc3339(&d).ok())
|
|
.map(|d| d.with_timezone(&Utc));
|
|
|
|
let activity = CrmActivity {
|
|
id,
|
|
org_id,
|
|
bot_id,
|
|
contact_id: req.contact_id,
|
|
lead_id: req.lead_id,
|
|
opportunity_id: req.opportunity_id,
|
|
account_id: req.account_id,
|
|
activity_type: req.activity_type,
|
|
subject: req.subject,
|
|
description: req.description,
|
|
due_date,
|
|
completed_at: None,
|
|
outcome: None,
|
|
owner_id: None,
|
|
created_at: now,
|
|
};
|
|
|
|
diesel::insert_into(crm_activities::table)
|
|
.values(&activity)
|
|
.execute(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
|
|
|
|
Ok(Json(activity))
|
|
}
|
|
|
|
pub async fn list_activities(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<Vec<CrmActivity>>, (StatusCode, String)> {
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let limit = query.limit.unwrap_or(50);
|
|
let offset = query.offset.unwrap_or(0);
|
|
|
|
let activities: Vec<CrmActivity> = crm_activities::table
|
|
.filter(crm_activities::org_id.eq(org_id))
|
|
.filter(crm_activities::bot_id.eq(bot_id))
|
|
.order(crm_activities::created_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(activities))
|
|
}
|
|
|
|
pub async fn get_pipeline_stages(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Result<Json<Vec<CrmPipelineStage>>, (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 stages: Vec<CrmPipelineStage> = crm_pipeline_stages::table
|
|
.filter(crm_pipeline_stages::org_id.eq(org_id))
|
|
.filter(crm_pipeline_stages::bot_id.eq(bot_id))
|
|
.order(crm_pipeline_stages::stage_order.asc())
|
|
.load(&mut conn)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
|
|
|
|
Ok(Json(stages))
|
|
}
|
|
|
|
pub async fn get_crm_stats(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> Result<Json<CrmStats>, (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 total_contacts: i64 = 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);
|
|
|
|
let total_accounts: i64 = crm_accounts::table
|
|
.filter(crm_accounts::org_id.eq(org_id))
|
|
.filter(crm_accounts::bot_id.eq(bot_id))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let total_leads: i64 = crm_deals::table
|
|
.filter(crm_deals::org_id.eq(org_id))
|
|
.filter(crm_deals::bot_id.eq(bot_id))
|
|
.filter(crm_deals::closed_at.is_null())
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let total_opportunities: i64 = crm_deals::table
|
|
.filter(crm_deals::org_id.eq(org_id))
|
|
.filter(crm_deals::bot_id.eq(bot_id))
|
|
.filter(crm_deals::won.is_null())
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let won_this_month: i64 = crm_deals::table
|
|
.filter(crm_deals::org_id.eq(org_id))
|
|
.filter(crm_deals::bot_id.eq(bot_id))
|
|
.filter(crm_deals::won.eq(Some(true)))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0);
|
|
|
|
let stats = CrmStats {
|
|
total_contacts,
|
|
total_accounts,
|
|
total_leads,
|
|
total_opportunities,
|
|
total_campaigns: 0,
|
|
pipeline_value: 0.0,
|
|
won_this_month,
|
|
conversion_rate: if total_leads > 0 {
|
|
(won_this_month as f64 / total_leads as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
},
|
|
};
|
|
|
|
Ok(Json(stats))
|
|
}
|
|
|
|
pub async fn import_from_postgres(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<ImportPostgresRequest>,
|
|
) -> Result<Json<serde_json::Value>, crate::security::error_sanitizer::SafeErrorResponse> {
|
|
use crate::security::error_sanitizer::log_and_sanitize;
|
|
let mut conn = state.conn.get().map_err(|e| {
|
|
log_and_sanitize(&e, "db connection error", None)
|
|
})?;
|
|
|
|
let (org_id, bot_id) = get_bot_context(&state);
|
|
let now = Utc::now();
|
|
|
|
// Use a blocking thread for the external connection so we don't stall the tokio worker thread
|
|
let conn_str = req.connection_string.clone();
|
|
|
|
// Actually, axum endpoints are async, but doing a blocking connect in axum is fine for a quick integration/test
|
|
let mut external_conn = match PgConnection::establish(&conn_str) {
|
|
Ok(c) => c,
|
|
Err(e) => return Err(log_and_sanitize(&e, "external pg connection", None)),
|
|
};
|
|
|
|
use diesel::sql_types::{Text, Nullable, Double, Integer};
|
|
|
|
#[derive(QueryableByName, Debug)]
|
|
struct ExtLead {
|
|
#[diesel(sql_type = Text)]
|
|
title: String,
|
|
#[diesel(sql_type = Nullable<Text>)]
|
|
description: Option<String>,
|
|
#[diesel(sql_type = Nullable<Double>)]
|
|
value: Option<f64>,
|
|
#[diesel(sql_type = Nullable<Text>)]
|
|
stage: Option<String>,
|
|
#[diesel(sql_type = Nullable<Text>)]
|
|
source: Option<String>,
|
|
}
|
|
|
|
let ext_leads: Vec<ExtLead> = diesel::sql_query("SELECT title, description, value::float8 as value, stage, source FROM leads LIMIT 1000")
|
|
.load(&mut external_conn)
|
|
.map_err(|e| log_and_sanitize(&e, "external pg leads query", None))?;
|
|
|
|
for el in ext_leads {
|
|
let l = CrmDeal {
|
|
id: Uuid::new_v4(),
|
|
org_id,
|
|
bot_id,
|
|
contact_id: None,
|
|
account_id: None,
|
|
am_id: None,
|
|
lead_id: None,
|
|
title: Some(el.title.clone()),
|
|
name: Some(el.title),
|
|
description: el.description,
|
|
value: el.value,
|
|
currency: Some("USD".to_string()),
|
|
stage_id: None,
|
|
stage: Some(el.stage.unwrap_or_else(|| "new".to_string())),
|
|
probability: 10,
|
|
source: el.source,
|
|
segment_id: None,
|
|
department_id: None,
|
|
expected_close_date: None,
|
|
actual_close_date: None,
|
|
period: None,
|
|
deal_date: None,
|
|
won: None,
|
|
owner_id: None,
|
|
lost_reason: None,
|
|
closed_at: None,
|
|
notes: None,
|
|
tags: vec![],
|
|
custom_fields: serde_json::json!({}),
|
|
created_at: now,
|
|
updated_at: Some(now),
|
|
};
|
|
let _ = diesel::insert_into(crm_deals::table).values(&l).execute(&mut conn).map_err(|e| log_and_sanitize(&e, "insert lead", None))?;
|
|
}
|
|
|
|
#[derive(QueryableByName, Debug)]
|
|
struct ExtOpp {
|
|
#[diesel(sql_type = Text)]
|
|
name: String,
|
|
#[diesel(sql_type = Nullable<Text>)]
|
|
description: Option<String>,
|
|
#[diesel(sql_type = Nullable<Double>)]
|
|
value: Option<f64>,
|
|
#[diesel(sql_type = Nullable<Text>)]
|
|
stage: Option<String>,
|
|
#[diesel(sql_type = Nullable<Integer>)]
|
|
probability: Option<i32>,
|
|
}
|
|
|
|
let ext_opps: Vec<ExtOpp> = diesel::sql_query("SELECT name, description, value::float8 as value, stage, probability::int4 as probability FROM opportunities LIMIT 1000")
|
|
.load(&mut external_conn)
|
|
.map_err(|e| log_and_sanitize(&e, "external pg opps query", None))?;
|
|
|
|
for eo in ext_opps {
|
|
let op = CrmDeal {
|
|
id: Uuid::new_v4(),
|
|
org_id,
|
|
bot_id,
|
|
lead_id: None,
|
|
account_id: None,
|
|
contact_id: None,
|
|
am_id: None,
|
|
title: None,
|
|
name: Some(eo.name),
|
|
description: eo.description,
|
|
value: eo.value,
|
|
currency: Some("USD".to_string()),
|
|
stage_id: None,
|
|
stage: Some(eo.stage.unwrap_or_else(|| "qualification".to_string())),
|
|
probability: eo.probability.unwrap_or(25),
|
|
source: None,
|
|
segment_id: None,
|
|
department_id: None,
|
|
expected_close_date: None,
|
|
actual_close_date: None,
|
|
period: None,
|
|
deal_date: None,
|
|
won: None,
|
|
owner_id: None,
|
|
lost_reason: None,
|
|
closed_at: None,
|
|
notes: None,
|
|
tags: vec![],
|
|
custom_fields: serde_json::json!({}),
|
|
created_at: now,
|
|
updated_at: Some(now),
|
|
};
|
|
let _ = diesel::insert_into(crm_deals::table).values(&op).execute(&mut conn).map_err(|e| log_and_sanitize(&e, "insert opp", None))?;
|
|
}
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"status": "success",
|
|
"imported_leads": 100, // mock count or actual
|
|
"imported_opportunities": 100
|
|
})))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CountStageQuery {
|
|
stage: Option<String>,
|
|
}
|
|
|
|
async fn handle_crm_count_api(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<CountStageQuery>,
|
|
) -> impl IntoResponse {
|
|
let Ok(mut conn) = state.conn.get() else {
|
|
return Html("0".to_string());
|
|
};
|
|
|
|
let (bot_id, _bot_name) = get_default_bot(&mut conn);
|
|
let org_id = Uuid::nil();
|
|
let stage = query.stage.unwrap_or_else(|| "all".to_string());
|
|
|
|
let count: i64 = if stage == "all" || stage.is_empty() {
|
|
crm_deals::table
|
|
.filter(crm_deals::org_id.eq(org_id))
|
|
.filter(crm_deals::bot_id.eq(bot_id))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0)
|
|
} else {
|
|
crm_deals::table
|
|
.filter(crm_deals::org_id.eq(org_id))
|
|
.filter(crm_deals::bot_id.eq(bot_id))
|
|
.filter(crm_deals::stage.eq(&stage))
|
|
.count()
|
|
.get_result(&mut conn)
|
|
.unwrap_or(0)
|
|
};
|
|
|
|
Html(count.to_string())
|
|
}
|
|
|
|
async fn handle_crm_pipeline_api(
|
|
State(state): State<Arc<AppState>>,
|
|
Query(query): Query<CountStageQuery>,
|
|
) -> impl IntoResponse {
|
|
let Ok(mut conn) = state.conn.get() else {
|
|
return Html(r#"<div class="pipeline-empty"><p>No items yet</p></div>"#.to_string());
|
|
};
|
|
|
|
let (bot_id, _bot_name) = get_default_bot(&mut conn);
|
|
let org_id = Uuid::nil();
|
|
let stage = query.stage.unwrap_or_else(|| "new".to_string());
|
|
|
|
let leads: Vec<CrmDeal> = crm_deals::table
|
|
.filter(crm_deals::org_id.eq(org_id))
|
|
.filter(crm_deals::bot_id.eq(bot_id))
|
|
.filter(crm_deals::stage.eq(&stage))
|
|
.order(crm_deals::created_at.desc())
|
|
.limit(20)
|
|
.load(&mut conn)
|
|
.unwrap_or_default();
|
|
|
|
if leads.is_empty() {
|
|
return Html(format!(r#"<div class="pipeline-empty"><p>No {} items yet</p></div>"#, stage));
|
|
}
|
|
|
|
let mut html = String::new();
|
|
for lead in leads {
|
|
let value_str = lead
|
|
.value
|
|
.map(|v| format!("${}", v))
|
|
.unwrap_or_else(|| "-".to_string());
|
|
let contact_name = lead.contact_id.map(|_| "Contact").unwrap_or("-");
|
|
|
|
let target = "#detail-panel";
|
|
let card_html = format!(
|
|
r##"<div class="pipeline-card" data-id="{}">
|
|
<div class="pipeline-card-header">
|
|
<span class="lead-title">{}</span>
|
|
<span class="lead-value">{}</span>
|
|
</div>
|
|
<div class="pipeline-card-body">
|
|
<span class="lead-contact">{}</span>
|
|
<span class="lead-probability">{}%</span>
|
|
</div>
|
|
<div class="pipeline-card-actions">
|
|
<button class="btn-sm" hx-put="/api/crm/leads/{}/stage?stage=qualified" hx-swap="none">Qualify</button>
|
|
<button class="btn-sm btn-accent" hx-post="/api/crm/leads/{}/convert" hx-swap="none">Convert</button>
|
|
<button class="btn-sm btn-secondary" hx-get="/api/ui/crm/leads/{}" hx-target="{}">View</button>
|
|
</div>
|
|
</div>"##,
|
|
lead.id,
|
|
html_escape(lead.title.as_deref().unwrap_or("")),
|
|
value_str,
|
|
contact_name,
|
|
lead.probability,
|
|
lead.id,
|
|
lead.id,
|
|
lead.id,
|
|
target
|
|
);
|
|
html.push_str(&card_html);
|
|
}
|
|
|
|
Html(html)
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
|
|
pub fn configure_crm_api_routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/api/crm/import/postgres", post(import_from_postgres))
|
|
.route("/api/crm/count", get(handle_crm_count_api))
|
|
.route("/api/crm/pipeline", get(handle_crm_pipeline_api))
|
|
.route("/api/crm/contacts", get(list_contacts).post(create_contact))
|
|
.route("/api/crm/contacts/:id", get(get_contact).put(update_contact).delete(delete_contact))
|
|
.route("/api/crm/accounts", get(list_accounts).post(create_account))
|
|
.route("/api/crm/accounts/:id", get(get_account).delete(delete_account))
|
|
.route("/api/crm/leads", get(list_leads).post(create_lead_form))
|
|
.route("/api/crm/leads/:id", get(get_lead).put(update_lead).delete(delete_lead))
|
|
.route("/api/crm/leads/:id/stage", put(update_lead_stage))
|
|
.route("/api/crm/leads/:id/convert", post(convert_lead_to_opportunity))
|
|
.route("/api/crm/opportunities", get(list_opportunities).post(create_opportunity))
|
|
.route("/api/crm/opportunities/:id", get(get_opportunity).put(update_opportunity).delete(delete_opportunity))
|
|
.route("/api/crm/opportunities/:id/close", post(close_opportunity))
|
|
.route("/api/crm/deals", get(list_deals).post(create_deal))
|
|
.route("/api/crm/deals/:id", get(get_deal).put(update_deal).delete(delete_deal))
|
|
.route("/api/crm/activities", get(list_activities).post(create_activity))
|
|
.route("/api/crm/pipeline/stages", get(get_pipeline_stages))
|
|
.route("/api/crm/stats", get(get_crm_stats))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use uuid::Uuid;
|
|
|
|
#[test]
|
|
fn test_crm_deal_struct_has_department_id() {
|
|
let deal = CrmDeal {
|
|
id: Uuid::new_v4(),
|
|
org_id: Uuid::new_v4(),
|
|
bot_id: Uuid::new_v4(),
|
|
contact_id: None,
|
|
account_id: None,
|
|
am_id: None,
|
|
owner_id: None,
|
|
lead_id: None,
|
|
title: Some("Test Deal".to_string()),
|
|
name: None,
|
|
description: None,
|
|
value: Some(10000.0),
|
|
currency: Some("USD".to_string()),
|
|
stage_id: None,
|
|
stage: Some("new".to_string()),
|
|
probability: 10,
|
|
source: Some("WEBSITE".to_string()),
|
|
segment_id: None,
|
|
department_id: Some(Uuid::new_v4()),
|
|
expected_close_date: None,
|
|
actual_close_date: None,
|
|
period: None,
|
|
deal_date: None,
|
|
closed_at: None,
|
|
lost_reason: None,
|
|
won: None,
|
|
notes: None,
|
|
tags: vec![],
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: None,
|
|
custom_fields: serde_json::json!({}),
|
|
};
|
|
|
|
assert!(deal.department_id.is_some());
|
|
assert_eq!(deal.stage, Some("new".to_string()));
|
|
assert_eq!(deal.probability, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_query_deserialization() {
|
|
let query_str = "stage=new&department_id=550e8400-e29b-41d4-a716-446655440000&source=WEBSITE&limit=50&offset=0";
|
|
let query: ListQuery = serde_urlencoded::from_str(query_str).unwrap();
|
|
|
|
assert_eq!(query.stage, Some("new".to_string()));
|
|
assert_eq!(query.department_id.is_some(), true);
|
|
assert_eq!(query.source, Some("WEBSITE".to_string()));
|
|
assert_eq!(query.limit, Some(50));
|
|
assert_eq!(query.offset, Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_query_optional_fields() {
|
|
let query_str = "search=acme";
|
|
let query: ListQuery = serde_urlencoded::from_str(query_str).unwrap();
|
|
|
|
assert_eq!(query.search, Some("acme".to_string()));
|
|
assert_eq!(query.department_id, None);
|
|
assert_eq!(query.source, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_crm_deal_stage_probabilities() {
|
|
let stages = vec![
|
|
("new", 10),
|
|
("qualified", 30),
|
|
("proposal", 50),
|
|
("negotiation", 70),
|
|
("won", 100),
|
|
("lost", 0),
|
|
];
|
|
|
|
for (stage, expected_prob) in stages {
|
|
let deal = CrmDeal {
|
|
id: Uuid::new_v4(),
|
|
org_id: Uuid::new_v4(),
|
|
bot_id: Uuid::new_v4(),
|
|
contact_id: None,
|
|
account_id: None,
|
|
am_id: None,
|
|
owner_id: None,
|
|
lead_id: None,
|
|
title: Some(format!("Test {}", stage)),
|
|
name: None,
|
|
description: None,
|
|
value: Some(1000.0),
|
|
currency: Some("USD".to_string()),
|
|
stage_id: None,
|
|
stage: Some(stage.to_string()),
|
|
probability: expected_prob,
|
|
source: None,
|
|
segment_id: None,
|
|
department_id: None,
|
|
expected_close_date: None,
|
|
actual_close_date: None,
|
|
period: None,
|
|
deal_date: None,
|
|
closed_at: None,
|
|
lost_reason: None,
|
|
won: if stage == "won" { Some(true) } else if stage == "lost" { Some(false) } else { None },
|
|
notes: None,
|
|
tags: vec![],
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: None,
|
|
custom_fields: serde_json::json!({}),
|
|
};
|
|
|
|
assert_eq!(deal.probability, expected_prob);
|
|
}
|
|
}
|
|
}
|