1299 lines
42 KiB
Rust
1299 lines
42 KiB
Rust
use axum::{response::IntoResponse, Json};
|
|
use chrono::{DateTime, Utc};
|
|
use diesel::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use uuid::Uuid;
|
|
|
|
use crate::core::shared::schema::people::{crm_contacts as crm_contacts_table, people as people_table};
|
|
use crate::core::shared::schema::tasks::tasks as tasks_table;
|
|
use crate::shared::utils::DbPool;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum TasksIntegrationError {
|
|
DatabaseError(String),
|
|
ContactNotFound,
|
|
TaskNotFound,
|
|
AlreadyAssigned,
|
|
NotAssigned,
|
|
Unauthorized,
|
|
InvalidInput(String),
|
|
}
|
|
|
|
impl std::fmt::Display for TasksIntegrationError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::DatabaseError(e) => write!(f, "Database error: {e}"),
|
|
Self::ContactNotFound => write!(f, "Contact not found"),
|
|
Self::TaskNotFound => write!(f, "Task not found"),
|
|
Self::AlreadyAssigned => write!(f, "Contact already assigned"),
|
|
Self::NotAssigned => write!(f, "Contact not assigned"),
|
|
Self::Unauthorized => write!(f, "Unauthorized"),
|
|
Self::InvalidInput(e) => write!(f, "Invalid input: {e}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for TasksIntegrationError {}
|
|
|
|
impl IntoResponse for TasksIntegrationError {
|
|
fn into_response(self) -> axum::response::Response {
|
|
use axum::http::StatusCode;
|
|
let status = match &self {
|
|
Self::ContactNotFound | Self::TaskNotFound => StatusCode::NOT_FOUND,
|
|
Self::AlreadyAssigned | Self::NotAssigned => StatusCode::CONFLICT,
|
|
Self::Unauthorized => StatusCode::UNAUTHORIZED,
|
|
Self::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
|
Self::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
};
|
|
(status, Json(serde_json::json!({ "error": self.to_string() }))).into_response()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TaskContact {
|
|
pub id: Uuid,
|
|
pub task_id: Uuid,
|
|
pub contact_id: Uuid,
|
|
pub role: TaskContactRole,
|
|
pub assigned_at: DateTime<Utc>,
|
|
pub assigned_by: Uuid,
|
|
pub notified: bool,
|
|
pub notified_at: Option<DateTime<Utc>>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
pub enum TaskContactRole {
|
|
#[default]
|
|
Assignee,
|
|
Reviewer,
|
|
Stakeholder,
|
|
Collaborator,
|
|
Client,
|
|
Vendor,
|
|
Consultant,
|
|
Approver,
|
|
}
|
|
|
|
impl std::fmt::Display for TaskContactRole {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
TaskContactRole::Assignee => write!(f, "assignee"),
|
|
TaskContactRole::Reviewer => write!(f, "reviewer"),
|
|
TaskContactRole::Stakeholder => write!(f, "stakeholder"),
|
|
TaskContactRole::Collaborator => write!(f, "collaborator"),
|
|
TaskContactRole::Client => write!(f, "client"),
|
|
TaskContactRole::Vendor => write!(f, "vendor"),
|
|
TaskContactRole::Consultant => write!(f, "consultant"),
|
|
TaskContactRole::Approver => write!(f, "approver"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AssignContactRequest {
|
|
pub contact_id: Uuid,
|
|
pub role: Option<TaskContactRole>,
|
|
pub send_notification: Option<bool>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BulkAssignContactsRequest {
|
|
pub assignments: Vec<ContactAssignment>,
|
|
pub send_notification: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContactAssignment {
|
|
pub contact_id: Uuid,
|
|
pub role: Option<TaskContactRole>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UpdateTaskContactRequest {
|
|
pub role: Option<TaskContactRole>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TaskContactsQuery {
|
|
pub role: Option<TaskContactRole>,
|
|
pub include_contact_details: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContactTasksQuery {
|
|
pub status: Option<String>,
|
|
pub priority: Option<String>,
|
|
pub role: Option<TaskContactRole>,
|
|
pub due_before: Option<DateTime<Utc>>,
|
|
pub due_after: Option<DateTime<Utc>>,
|
|
pub project_id: Option<Uuid>,
|
|
pub limit: Option<u32>,
|
|
pub offset: Option<u32>,
|
|
pub sort_by: Option<TaskSortField>,
|
|
pub sort_order: Option<SortOrder>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub enum TaskSortField {
|
|
#[default]
|
|
DueDate,
|
|
Priority,
|
|
CreatedAt,
|
|
UpdatedAt,
|
|
Title,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub enum SortOrder {
|
|
#[default]
|
|
Asc,
|
|
Desc,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TaskContactWithDetails {
|
|
pub task_contact: TaskContact,
|
|
pub contact: ContactSummary,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContactSummary {
|
|
pub id: Uuid,
|
|
pub first_name: String,
|
|
pub last_name: String,
|
|
pub email: Option<String>,
|
|
pub phone: Option<String>,
|
|
pub company: Option<String>,
|
|
pub job_title: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
}
|
|
|
|
impl ContactSummary {
|
|
pub fn full_name(&self) -> String {
|
|
format!("{} {}", self.first_name, self.last_name).trim().to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TaskSummary {
|
|
pub id: Uuid,
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub status: String,
|
|
pub priority: String,
|
|
pub due_date: Option<DateTime<Utc>>,
|
|
pub project_id: Option<Uuid>,
|
|
pub project_name: Option<String>,
|
|
pub progress: u8,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContactTaskWithDetails {
|
|
pub task_contact: TaskContact,
|
|
pub task: TaskSummary,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContactTasksResponse {
|
|
pub tasks: Vec<ContactTaskWithDetails>,
|
|
pub total_count: u32,
|
|
pub by_status: HashMap<String, u32>,
|
|
pub by_priority: HashMap<String, u32>,
|
|
pub overdue_count: u32,
|
|
pub due_today_count: u32,
|
|
pub due_this_week_count: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContactTaskStats {
|
|
pub contact_id: Uuid,
|
|
pub total_tasks: u32,
|
|
pub completed_tasks: u32,
|
|
pub in_progress_tasks: u32,
|
|
pub overdue_tasks: u32,
|
|
pub completion_rate: f32,
|
|
pub average_completion_time_days: Option<f32>,
|
|
pub tasks_by_role: HashMap<String, u32>,
|
|
pub recent_activity: Vec<TaskActivity>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TaskActivity {
|
|
pub id: Uuid,
|
|
pub task_id: Uuid,
|
|
pub task_title: String,
|
|
pub activity_type: TaskActivityType,
|
|
pub description: String,
|
|
pub occurred_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum TaskActivityType {
|
|
Assigned,
|
|
Unassigned,
|
|
StatusChanged,
|
|
Completed,
|
|
Commented,
|
|
Updated,
|
|
DueDateChanged,
|
|
}
|
|
|
|
impl std::fmt::Display for TaskActivityType {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
TaskActivityType::Assigned => write!(f, "assigned"),
|
|
TaskActivityType::Unassigned => write!(f, "unassigned"),
|
|
TaskActivityType::StatusChanged => write!(f, "status_changed"),
|
|
TaskActivityType::Completed => write!(f, "completed"),
|
|
TaskActivityType::Commented => write!(f, "commented"),
|
|
TaskActivityType::Updated => write!(f, "updated"),
|
|
TaskActivityType::DueDateChanged => write!(f, "due_date_changed"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SuggestedTaskContact {
|
|
pub contact: ContactSummary,
|
|
pub reason: TaskSuggestionReason,
|
|
pub score: f32,
|
|
pub workload: ContactWorkload,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum TaskSuggestionReason {
|
|
PreviouslyAssigned,
|
|
SameProject,
|
|
SimilarTasks,
|
|
TeamMember,
|
|
ExpertInArea,
|
|
LowWorkload,
|
|
ClientContact,
|
|
}
|
|
|
|
impl std::fmt::Display for TaskSuggestionReason {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
TaskSuggestionReason::PreviouslyAssigned => write!(f, "Previously assigned to similar tasks"),
|
|
TaskSuggestionReason::SameProject => write!(f, "Assigned to same project"),
|
|
TaskSuggestionReason::SimilarTasks => write!(f, "Completed similar tasks"),
|
|
TaskSuggestionReason::TeamMember => write!(f, "Team member"),
|
|
TaskSuggestionReason::ExpertInArea => write!(f, "Expert in this area"),
|
|
TaskSuggestionReason::LowWorkload => write!(f, "Has capacity"),
|
|
TaskSuggestionReason::ClientContact => write!(f, "Client contact"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContactWorkload {
|
|
pub active_tasks: u32,
|
|
pub high_priority_tasks: u32,
|
|
pub overdue_tasks: u32,
|
|
pub due_this_week: u32,
|
|
pub workload_level: WorkloadLevel,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum WorkloadLevel {
|
|
Low,
|
|
Medium,
|
|
High,
|
|
Overloaded,
|
|
}
|
|
|
|
impl std::fmt::Display for WorkloadLevel {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
WorkloadLevel::Low => write!(f, "low"),
|
|
WorkloadLevel::Medium => write!(f, "medium"),
|
|
WorkloadLevel::High => write!(f, "high"),
|
|
WorkloadLevel::Overloaded => write!(f, "overloaded"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CreateTaskForContactRequest {
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub priority: Option<String>,
|
|
pub due_date: Option<DateTime<Utc>>,
|
|
pub project_id: Option<Uuid>,
|
|
pub tags: Option<Vec<String>>,
|
|
pub role: Option<TaskContactRole>,
|
|
pub send_notification: Option<bool>,
|
|
}
|
|
|
|
pub struct TasksIntegrationService {
|
|
db_pool: DbPool,
|
|
}
|
|
|
|
impl TasksIntegrationService {
|
|
pub fn new(pool: DbPool) -> Self {
|
|
Self { db_pool: pool }
|
|
}
|
|
|
|
pub async fn assign_contact_to_task(
|
|
&self,
|
|
organization_id: Uuid,
|
|
task_id: Uuid,
|
|
request: &AssignContactRequest,
|
|
assigned_by: Uuid,
|
|
) -> Result<TaskContact, TasksIntegrationError> {
|
|
// Verify contact exists and belongs to organization
|
|
self.verify_contact(organization_id, request.contact_id).await?;
|
|
|
|
// Verify task exists
|
|
self.verify_task(organization_id, task_id).await?;
|
|
|
|
// Check if already assigned
|
|
if self.is_contact_assigned(task_id, request.contact_id).await? {
|
|
return Err(TasksIntegrationError::AlreadyAssigned);
|
|
}
|
|
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
let role = request.role.clone().unwrap_or_default();
|
|
|
|
// Create assignment in database
|
|
self.create_task_contact_assignment(
|
|
id,
|
|
task_id,
|
|
request.contact_id,
|
|
&role,
|
|
assigned_by,
|
|
request.notes.as_deref(),
|
|
now,
|
|
)
|
|
.await?;
|
|
|
|
// Send notification if requested
|
|
let notified = if request.send_notification.unwrap_or(true) {
|
|
self.send_task_assignment_notification(task_id, request.contact_id)
|
|
.await
|
|
.is_ok()
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Log activity
|
|
self.log_contact_activity(
|
|
request.contact_id,
|
|
TaskActivityType::Assigned,
|
|
&format!("Assigned to task"),
|
|
task_id,
|
|
)
|
|
.await?;
|
|
|
|
Ok(TaskContact {
|
|
id,
|
|
task_id,
|
|
contact_id: request.contact_id,
|
|
role,
|
|
assigned_at: now,
|
|
assigned_by,
|
|
notified,
|
|
notified_at: if notified { Some(now) } else { None },
|
|
notes: request.notes.clone(),
|
|
})
|
|
}
|
|
|
|
pub async fn bulk_assign_contacts(
|
|
&self,
|
|
organization_id: Uuid,
|
|
task_id: Uuid,
|
|
request: &BulkAssignContactsRequest,
|
|
assigned_by: Uuid,
|
|
) -> Result<Vec<TaskContact>, TasksIntegrationError> {
|
|
let mut results = Vec::new();
|
|
|
|
for assignment in &request.assignments {
|
|
let assign_request = AssignContactRequest {
|
|
contact_id: assignment.contact_id,
|
|
role: assignment.role.clone(),
|
|
send_notification: request.send_notification,
|
|
notes: assignment.notes.clone(),
|
|
};
|
|
|
|
match self
|
|
.assign_contact_to_task(organization_id, task_id, &assign_request, assigned_by)
|
|
.await
|
|
{
|
|
Ok(task_contact) => results.push(task_contact),
|
|
Err(TasksIntegrationError::AlreadyAssigned) => continue,
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
pub async fn unassign_contact_from_task(
|
|
&self,
|
|
organization_id: Uuid,
|
|
task_id: Uuid,
|
|
contact_id: Uuid,
|
|
) -> Result<(), TasksIntegrationError> {
|
|
self.verify_contact(organization_id, contact_id).await?;
|
|
self.verify_task(organization_id, task_id).await?;
|
|
|
|
self.delete_task_contact_assignment(task_id, contact_id).await?;
|
|
|
|
self.log_contact_activity(
|
|
contact_id,
|
|
TaskActivityType::Unassigned,
|
|
"Unassigned from task",
|
|
task_id,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn update_task_contact(
|
|
&self,
|
|
organization_id: Uuid,
|
|
task_id: Uuid,
|
|
contact_id: Uuid,
|
|
request: &UpdateTaskContactRequest,
|
|
) -> Result<TaskContact, TasksIntegrationError> {
|
|
self.verify_contact(organization_id, contact_id).await?;
|
|
self.verify_task(organization_id, task_id).await?;
|
|
|
|
let mut task_contact = self.get_task_contact(task_id, contact_id).await?;
|
|
|
|
if let Some(role) = &request.role {
|
|
task_contact.role = role.clone();
|
|
}
|
|
|
|
if let Some(notes) = &request.notes {
|
|
task_contact.notes = Some(notes.clone());
|
|
}
|
|
|
|
self.update_task_contact_in_db(&task_contact).await?;
|
|
|
|
Ok(task_contact)
|
|
}
|
|
|
|
pub async fn get_task_contacts(
|
|
&self,
|
|
organization_id: Uuid,
|
|
task_id: Uuid,
|
|
query: &TaskContactsQuery,
|
|
) -> Result<Vec<TaskContactWithDetails>, TasksIntegrationError> {
|
|
self.verify_task(organization_id, task_id).await?;
|
|
|
|
let contacts = self.fetch_task_contacts(task_id, query).await?;
|
|
|
|
if query.include_contact_details.unwrap_or(true) {
|
|
let mut results = Vec::new();
|
|
for task_contact in contacts {
|
|
if let Ok(contact) = self.get_contact_summary(task_contact.contact_id).await {
|
|
results.push(TaskContactWithDetails {
|
|
task_contact,
|
|
contact,
|
|
});
|
|
}
|
|
}
|
|
Ok(results)
|
|
} else {
|
|
Ok(contacts
|
|
.into_iter()
|
|
.map(|tc| TaskContactWithDetails {
|
|
contact: ContactSummary {
|
|
id: tc.contact_id,
|
|
first_name: String::new(),
|
|
last_name: String::new(),
|
|
email: None,
|
|
phone: None,
|
|
company: None,
|
|
job_title: None,
|
|
avatar_url: None,
|
|
},
|
|
task_contact: tc,
|
|
})
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
pub async fn get_contact_tasks(
|
|
&self,
|
|
organization_id: Uuid,
|
|
contact_id: Uuid,
|
|
query: &ContactTasksQuery,
|
|
) -> Result<ContactTasksResponse, TasksIntegrationError> {
|
|
self.verify_contact(organization_id, contact_id).await?;
|
|
|
|
let tasks = self.fetch_contact_tasks(contact_id, query).await?;
|
|
let total_count = tasks.len() as u32;
|
|
let now = Utc::now();
|
|
|
|
let week_end = now + chrono::Duration::days(7);
|
|
|
|
let mut by_status: HashMap<String, u32> = HashMap::new();
|
|
let mut by_priority: HashMap<String, u32> = HashMap::new();
|
|
let mut overdue_count = 0;
|
|
let mut due_today_count = 0;
|
|
let mut due_this_week_count = 0;
|
|
|
|
for task in &tasks {
|
|
*by_status.entry(task.task.status.clone()).or_insert(0) += 1;
|
|
*by_priority.entry(task.task.priority.clone()).or_insert(0) += 1;
|
|
|
|
if let Some(due_date) = task.task.due_date {
|
|
if due_date < now && task.task.status != "completed" {
|
|
overdue_count += 1;
|
|
} else if due_date.date_naive() == now.date_naive() {
|
|
due_today_count += 1;
|
|
} else if due_date < week_end {
|
|
due_this_week_count += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(ContactTasksResponse {
|
|
tasks,
|
|
total_count,
|
|
by_status,
|
|
by_priority,
|
|
overdue_count,
|
|
due_today_count,
|
|
due_this_week_count,
|
|
})
|
|
}
|
|
|
|
pub async fn get_contact_task_stats(
|
|
&self,
|
|
organization_id: Uuid,
|
|
contact_id: Uuid,
|
|
) -> Result<ContactTaskStats, TasksIntegrationError> {
|
|
self.verify_contact(organization_id, contact_id).await?;
|
|
|
|
let stats = self.calculate_contact_task_stats(contact_id).await?;
|
|
|
|
Ok(stats)
|
|
}
|
|
|
|
pub async fn get_suggested_contacts(
|
|
&self,
|
|
organization_id: Uuid,
|
|
task_id: Uuid,
|
|
limit: Option<u32>,
|
|
) -> Result<Vec<SuggestedTaskContact>, TasksIntegrationError> {
|
|
self.verify_task(organization_id, task_id).await?;
|
|
|
|
let limit = limit.unwrap_or(10);
|
|
let mut suggestions: Vec<SuggestedTaskContact> = Vec::new();
|
|
|
|
// Get task details for context
|
|
let task = self.get_task_details(task_id).await?;
|
|
|
|
// Get already assigned contacts to exclude
|
|
let assigned_contacts = self.get_assigned_contact_ids(task_id).await?;
|
|
|
|
// Find contacts previously assigned to similar tasks
|
|
let previous_assignees = self
|
|
.find_similar_task_assignees(&task, &assigned_contacts, 5)
|
|
.await?;
|
|
for (contact, workload) in previous_assignees {
|
|
suggestions.push(SuggestedTaskContact {
|
|
contact,
|
|
reason: TaskSuggestionReason::PreviouslyAssigned,
|
|
score: 0.9,
|
|
workload,
|
|
});
|
|
}
|
|
|
|
// Find contacts assigned to same project
|
|
if let Some(project_id) = task.project_id {
|
|
let project_contacts = self
|
|
.find_project_contacts(project_id, &assigned_contacts, 5)
|
|
.await?;
|
|
for (contact, workload) in project_contacts {
|
|
suggestions.push(SuggestedTaskContact {
|
|
contact,
|
|
reason: TaskSuggestionReason::SameProject,
|
|
score: 0.8,
|
|
workload,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Find contacts with low workload
|
|
let available_contacts = self
|
|
.find_low_workload_contacts(organization_id, &assigned_contacts, 5)
|
|
.await?;
|
|
for (contact, workload) in available_contacts {
|
|
if workload.workload_level == WorkloadLevel::Low {
|
|
suggestions.push(SuggestedTaskContact {
|
|
contact,
|
|
reason: TaskSuggestionReason::LowWorkload,
|
|
score: 0.6,
|
|
workload,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by score and limit
|
|
suggestions.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
|
suggestions.truncate(limit as usize);
|
|
|
|
Ok(suggestions)
|
|
}
|
|
|
|
pub async fn get_contact_workload(
|
|
&self,
|
|
organization_id: Uuid,
|
|
contact_id: Uuid,
|
|
) -> Result<ContactWorkload, TasksIntegrationError> {
|
|
self.verify_contact(organization_id, contact_id).await?;
|
|
|
|
let workload = self.calculate_contact_workload(contact_id).await?;
|
|
|
|
Ok(workload)
|
|
}
|
|
|
|
pub async fn create_task_for_contact(
|
|
&self,
|
|
organization_id: Uuid,
|
|
contact_id: Uuid,
|
|
request: &CreateTaskForContactRequest,
|
|
created_by: Uuid,
|
|
) -> Result<ContactTaskWithDetails, TasksIntegrationError> {
|
|
self.verify_contact(organization_id, contact_id).await?;
|
|
|
|
// Create task
|
|
let task_id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
|
|
self.create_task_in_db(
|
|
task_id,
|
|
organization_id,
|
|
&request.title,
|
|
request.description.as_deref(),
|
|
Some(created_by),
|
|
request.due_date,
|
|
)
|
|
.await?;
|
|
|
|
// Assign contact
|
|
let assign_request = AssignContactRequest {
|
|
contact_id,
|
|
role: request.role.clone(),
|
|
send_notification: request.send_notification,
|
|
notes: None,
|
|
};
|
|
|
|
let task_contact = self
|
|
.assign_contact_to_task(organization_id, task_id, &assign_request, created_by)
|
|
.await?;
|
|
|
|
let task = TaskSummary {
|
|
id: task_id,
|
|
title: request.title.clone(),
|
|
description: request.description.clone(),
|
|
status: "todo".to_string(),
|
|
priority: request.priority.clone().unwrap_or_else(|| "medium".to_string()),
|
|
due_date: request.due_date,
|
|
project_id: request.project_id,
|
|
project_name: None,
|
|
progress: 0,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
|
|
Ok(ContactTaskWithDetails { task_contact, task })
|
|
}
|
|
|
|
async fn send_task_assignment_notification(
|
|
&self,
|
|
_task_id: Uuid,
|
|
_contact_id: Uuid,
|
|
) -> Result<(), TasksIntegrationError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn log_contact_activity(
|
|
&self,
|
|
_contact_id: Uuid,
|
|
_activity_type: TaskActivityType,
|
|
_description: &str,
|
|
_task_id: Uuid,
|
|
) -> Result<(), TasksIntegrationError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn verify_contact(
|
|
&self,
|
|
_organization_id: Uuid,
|
|
_contact_id: Uuid,
|
|
) -> Result<(), TasksIntegrationError> {
|
|
// Verify contact exists and belongs to organization
|
|
Ok(())
|
|
}
|
|
|
|
async fn verify_task(
|
|
&self,
|
|
_organization_id: Uuid,
|
|
_task_id: Uuid,
|
|
) -> Result<(), TasksIntegrationError> {
|
|
// Verify task exists and belongs to organization
|
|
Ok(())
|
|
}
|
|
|
|
async fn is_contact_assigned(
|
|
&self,
|
|
_task_id: Uuid,
|
|
_contact_id: Uuid,
|
|
) -> Result<bool, TasksIntegrationError> {
|
|
// Check if contact is already assigned to task
|
|
Ok(false)
|
|
}
|
|
|
|
async fn create_task_contact_assignment(
|
|
&self,
|
|
_id: Uuid,
|
|
_task_id: Uuid,
|
|
_contact_id: Uuid,
|
|
_role: &TaskContactRole,
|
|
_assigned_by: Uuid,
|
|
_notes: Option<&str>,
|
|
_assigned_at: DateTime<Utc>,
|
|
) -> Result<(), TasksIntegrationError> {
|
|
// Insert into task_contacts table
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete_task_contact_assignment(
|
|
&self,
|
|
_task_id: Uuid,
|
|
_contact_id: Uuid,
|
|
) -> Result<(), TasksIntegrationError> {
|
|
// Delete from task_contacts table
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_task_contact(
|
|
&self,
|
|
task_id: Uuid,
|
|
contact_id: Uuid,
|
|
) -> Result<TaskContact, TasksIntegrationError> {
|
|
// Query task_contacts table
|
|
Ok(TaskContact {
|
|
id: Uuid::new_v4(),
|
|
task_id,
|
|
contact_id,
|
|
role: TaskContactRole::Assignee,
|
|
assigned_at: Utc::now(),
|
|
assigned_by: Uuid::new_v4(),
|
|
notified: false,
|
|
notified_at: None,
|
|
notes: None,
|
|
})
|
|
}
|
|
|
|
async fn update_task_contact_in_db(
|
|
&self,
|
|
task_contact: &TaskContact,
|
|
) -> Result<(), TasksIntegrationError> {
|
|
let pool = self.db_pool.clone();
|
|
let task_id = task_contact.task_id;
|
|
let contact_id = task_contact.contact_id;
|
|
let role = task_contact.role.to_string();
|
|
let _notes = task_contact.notes.clone();
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut conn = pool.get().map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
// Get the contact's email to find the corresponding person
|
|
let contact_email: Option<String> = crm_contacts_table::table
|
|
.filter(crm_contacts_table::id.eq(contact_id))
|
|
.select(crm_contacts_table::email)
|
|
.first(&mut conn)
|
|
.map_err(|e: diesel::result::Error| TasksIntegrationError::DatabaseError(format!("Contact not found: {}", e)))?;
|
|
|
|
let contact_email = match contact_email {
|
|
Some(email) => email,
|
|
None => return Ok(()), // No email, can't link to person
|
|
};
|
|
|
|
// Find the person with this email
|
|
let person_id: Result<uuid::Uuid, _> = people_table::table
|
|
.filter(people_table::email.eq(&contact_email))
|
|
.select(people_table::id)
|
|
.first(&mut conn);
|
|
|
|
if let Ok(pid) = person_id {
|
|
// Update the task's assigned_to field if this is an assignee
|
|
if role == "assignee" {
|
|
diesel::update(tasks_table::table.filter(tasks_table::id.eq(task_id)))
|
|
.set(tasks_table::assignee_id.eq(Some(pid)))
|
|
.execute(&mut conn)
|
|
.map_err(|e: diesel::result::Error| TasksIntegrationError::DatabaseError(format!("Failed to update task: {}", e)))?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
.await
|
|
.map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?
|
|
}
|
|
|
|
async fn fetch_task_contacts(
|
|
&self,
|
|
task_id: Uuid,
|
|
_query: &TaskContactsQuery,
|
|
) -> Result<Vec<TaskContact>, TasksIntegrationError> {
|
|
let pool = self.db_pool.clone();
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut conn = pool.get().map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
// Get task assignees from tasks table and look up corresponding contacts
|
|
let task_row: Result<(Uuid, Option<Uuid>, DateTime<Utc>), _> = tasks_table::table
|
|
.filter(tasks_table::id.eq(task_id))
|
|
.select((tasks_table::id, tasks_table::assignee_id, tasks_table::created_at))
|
|
.first(&mut conn);
|
|
|
|
let mut task_contacts = Vec::new();
|
|
|
|
if let Ok((tid, assigned_to, created_at)) = task_row {
|
|
if let Some(assignee_id) = assigned_to {
|
|
// Look up person -> email -> contact
|
|
let person_email: Result<Option<String>, _> = people_table::table
|
|
.filter(people_table::id.eq(assignee_id))
|
|
.select(people_table::email)
|
|
.first(&mut conn);
|
|
|
|
if let Ok(Some(email)) = person_email {
|
|
// Find contact with this email
|
|
let contact_result: Result<Uuid, _> = crm_contacts_table::table
|
|
.filter(crm_contacts_table::email.eq(&email))
|
|
.select(crm_contacts_table::id)
|
|
.first(&mut conn);
|
|
|
|
if let Ok(contact_id) = contact_result {
|
|
task_contacts.push(TaskContact {
|
|
id: Uuid::new_v4(),
|
|
task_id: tid,
|
|
contact_id,
|
|
role: TaskContactRole::Assignee,
|
|
assigned_at: created_at,
|
|
assigned_by: Uuid::nil(),
|
|
notified: false,
|
|
notified_at: None,
|
|
notes: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(task_contacts)
|
|
})
|
|
.await
|
|
.map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?
|
|
}
|
|
|
|
async fn fetch_contact_tasks(
|
|
&self,
|
|
contact_id: Uuid,
|
|
query: &ContactTasksQuery,
|
|
) -> Result<Vec<ContactTaskWithDetails>, TasksIntegrationError> {
|
|
let pool = self.db_pool.clone();
|
|
let status_filter = query.status.clone();
|
|
|
|
tokio::task::spawn_blocking(move || -> Result<Vec<ContactTaskWithDetails>, TasksIntegrationError> {
|
|
let mut conn = pool.get().map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
let mut db_query = tasks_table::table
|
|
.filter(tasks_table::status.ne("deleted"))
|
|
.into_boxed();
|
|
|
|
if let Some(status) = status_filter {
|
|
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
|
|
.order(tasks_table::created_at.desc())
|
|
.select((
|
|
tasks_table::id,
|
|
tasks_table::title,
|
|
tasks_table::description,
|
|
tasks_table::status,
|
|
tasks_table::priority,
|
|
tasks_table::due_date,
|
|
tasks_table::project_id,
|
|
tasks_table::progress,
|
|
tasks_table::created_at,
|
|
tasks_table::updated_at,
|
|
))
|
|
.limit(50)
|
|
.load(&mut conn)
|
|
.map_err(|e: diesel::result::Error| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
let tasks_list = rows.into_iter().map(|row| {
|
|
ContactTaskWithDetails {
|
|
task_contact: TaskContact {
|
|
id: Uuid::new_v4(),
|
|
task_id: row.0,
|
|
contact_id,
|
|
role: TaskContactRole::Assignee,
|
|
assigned_at: Utc::now(),
|
|
assigned_by: Uuid::nil(),
|
|
notified: false,
|
|
notified_at: None,
|
|
notes: None,
|
|
},
|
|
task: TaskSummary {
|
|
id: row.0,
|
|
title: row.1,
|
|
description: row.2,
|
|
status: row.3,
|
|
priority: row.4,
|
|
due_date: row.5,
|
|
project_id: row.6,
|
|
project_name: None,
|
|
progress: row.7 as u8,
|
|
created_at: row.8,
|
|
updated_at: row.9,
|
|
},
|
|
}
|
|
}).collect();
|
|
|
|
Ok(tasks_list)
|
|
})
|
|
.await
|
|
.map_err(|e: tokio::task::JoinError| TasksIntegrationError::DatabaseError(e.to_string()))?
|
|
}
|
|
|
|
async fn get_contact_summary(
|
|
&self,
|
|
contact_id: Uuid,
|
|
) -> Result<ContactSummary, TasksIntegrationError> {
|
|
// Query contacts table for summary
|
|
Ok(ContactSummary {
|
|
id: contact_id,
|
|
first_name: "John".to_string(),
|
|
last_name: "Doe".to_string(),
|
|
email: Some("john@example.com".to_string()),
|
|
phone: None,
|
|
company: None,
|
|
job_title: None,
|
|
avatar_url: None,
|
|
})
|
|
}
|
|
|
|
async fn get_task_details(&self, task_id: Uuid) -> Result<TaskSummary, TasksIntegrationError> {
|
|
// Query tasks table
|
|
Ok(TaskSummary {
|
|
id: task_id,
|
|
title: "Task".to_string(),
|
|
description: None,
|
|
status: "todo".to_string(),
|
|
priority: "medium".to_string(),
|
|
due_date: None,
|
|
project_id: None,
|
|
project_name: None,
|
|
progress: 0,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
})
|
|
}
|
|
|
|
async fn get_assigned_contact_ids(
|
|
&self,
|
|
task_id: Uuid,
|
|
) -> Result<Vec<Uuid>, TasksIntegrationError> {
|
|
let pool = self.db_pool.clone();
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut conn = pool.get().map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
let assignee_id: Option<Uuid> = tasks_table::table
|
|
.filter(tasks_table::id.eq(task_id))
|
|
.select(tasks_table::assignee_id)
|
|
.first(&mut conn)
|
|
.optional()
|
|
.map_err(|e: diesel::result::Error| TasksIntegrationError::DatabaseError(e.to_string()))?
|
|
.flatten();
|
|
|
|
if let Some(user_id) = assignee_id {
|
|
let person_email: Option<String> = people_table::table
|
|
.filter(people_table::user_id.eq(user_id))
|
|
.select(people_table::email)
|
|
.first(&mut conn)
|
|
.optional()
|
|
.map_err(|e: diesel::result::Error| TasksIntegrationError::DatabaseError(e.to_string()))?
|
|
.flatten();
|
|
|
|
if let Some(email) = person_email {
|
|
let contact_ids: Vec<Uuid> = crm_contacts_table::table
|
|
.filter(crm_contacts_table::email.eq(&email))
|
|
.select(crm_contacts_table::id)
|
|
.load(&mut conn)
|
|
.unwrap_or_default();
|
|
|
|
return Ok(contact_ids);
|
|
}
|
|
}
|
|
|
|
Ok(vec![])
|
|
})
|
|
.await
|
|
.map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?
|
|
}
|
|
|
|
async fn calculate_contact_task_stats(
|
|
&self,
|
|
contact_id: Uuid,
|
|
) -> Result<ContactTaskStats, TasksIntegrationError> {
|
|
// Calculate task statistics for contact
|
|
Ok(ContactTaskStats {
|
|
contact_id,
|
|
total_tasks: 0,
|
|
completed_tasks: 0,
|
|
in_progress_tasks: 0,
|
|
overdue_tasks: 0,
|
|
completion_rate: 0.0,
|
|
average_completion_time_days: None,
|
|
tasks_by_role: HashMap::new(),
|
|
recent_activity: vec![],
|
|
})
|
|
}
|
|
|
|
async fn calculate_contact_workload(
|
|
&self,
|
|
_contact_id: Uuid,
|
|
) -> Result<ContactWorkload, TasksIntegrationError> {
|
|
// Calculate current workload for contact
|
|
Ok(ContactWorkload {
|
|
active_tasks: 0,
|
|
high_priority_tasks: 0,
|
|
overdue_tasks: 0,
|
|
due_this_week: 0,
|
|
workload_level: WorkloadLevel::Low,
|
|
})
|
|
}
|
|
|
|
async fn find_similar_task_assignees(
|
|
&self,
|
|
_task: &TaskSummary,
|
|
exclude: &[Uuid],
|
|
limit: usize,
|
|
) -> Result<Vec<(ContactSummary, ContactWorkload)>, TasksIntegrationError> {
|
|
let pool = self.db_pool.clone();
|
|
let exclude = exclude.to_vec();
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut conn = pool.get().map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
let mut query = crm_contacts_table::table
|
|
.filter(crm_contacts_table::status.eq("active"))
|
|
.into_boxed();
|
|
|
|
for exc in &exclude {
|
|
query = query.filter(crm_contacts_table::id.ne(*exc));
|
|
}
|
|
|
|
let rows: Vec<(Uuid, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)> = query
|
|
.select((
|
|
crm_contacts_table::id,
|
|
crm_contacts_table::first_name,
|
|
crm_contacts_table::last_name,
|
|
crm_contacts_table::email,
|
|
crm_contacts_table::company,
|
|
crm_contacts_table::job_title,
|
|
))
|
|
.limit(limit as i64)
|
|
.load(&mut conn)
|
|
.map_err(|e: diesel::result::Error| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
let contacts = rows.into_iter().map(|row| {
|
|
let summary = ContactSummary {
|
|
id: row.0,
|
|
first_name: row.1.unwrap_or_default(),
|
|
last_name: row.2.unwrap_or_default(),
|
|
email: row.3,
|
|
phone: None,
|
|
company: row.4,
|
|
job_title: row.5,
|
|
avatar_url: None,
|
|
};
|
|
let workload = ContactWorkload {
|
|
active_tasks: 0,
|
|
high_priority_tasks: 0,
|
|
overdue_tasks: 0,
|
|
due_this_week: 0,
|
|
workload_level: WorkloadLevel::Low,
|
|
};
|
|
(summary, workload)
|
|
}).collect();
|
|
|
|
Ok(contacts)
|
|
})
|
|
.await
|
|
.map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?
|
|
}
|
|
|
|
async fn find_project_contacts(
|
|
&self,
|
|
_project_id: Uuid,
|
|
exclude: &[Uuid],
|
|
limit: usize,
|
|
) -> Result<Vec<(ContactSummary, ContactWorkload)>, TasksIntegrationError> {
|
|
let pool = self.db_pool.clone();
|
|
let exclude = exclude.to_vec();
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut conn = pool.get().map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
let mut query = crm_contacts_table::table
|
|
.filter(crm_contacts_table::status.eq("active"))
|
|
.into_boxed();
|
|
|
|
for exc in &exclude {
|
|
query = query.filter(crm_contacts_table::id.ne(*exc));
|
|
}
|
|
|
|
let rows: Vec<(Uuid, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)> = query
|
|
.select((
|
|
crm_contacts_table::id,
|
|
crm_contacts_table::first_name,
|
|
crm_contacts_table::last_name,
|
|
crm_contacts_table::email,
|
|
crm_contacts_table::company,
|
|
crm_contacts_table::job_title,
|
|
))
|
|
.limit(limit as i64)
|
|
.load(&mut conn)
|
|
.map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
let contacts = rows.into_iter().map(|row| {
|
|
let summary = ContactSummary {
|
|
id: row.0,
|
|
first_name: row.1.unwrap_or_default(),
|
|
last_name: row.2.unwrap_or_default(),
|
|
email: row.3,
|
|
phone: None,
|
|
company: row.4,
|
|
job_title: row.5,
|
|
avatar_url: None,
|
|
};
|
|
let workload = ContactWorkload {
|
|
active_tasks: 0,
|
|
high_priority_tasks: 0,
|
|
overdue_tasks: 0,
|
|
due_this_week: 0,
|
|
workload_level: WorkloadLevel::Low,
|
|
};
|
|
(summary, workload)
|
|
}).collect();
|
|
|
|
Ok(contacts)
|
|
})
|
|
.await
|
|
.map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?
|
|
}
|
|
|
|
async fn find_low_workload_contacts(
|
|
&self,
|
|
_organization_id: Uuid,
|
|
exclude: &[Uuid],
|
|
limit: usize,
|
|
) -> Result<Vec<(ContactSummary, ContactWorkload)>, TasksIntegrationError> {
|
|
let pool = self.db_pool.clone();
|
|
let exclude = exclude.to_vec();
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut conn = pool.get().map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
let mut query = crm_contacts_table::table
|
|
.filter(crm_contacts_table::status.eq("active"))
|
|
.into_boxed();
|
|
|
|
for exc in &exclude {
|
|
query = query.filter(crm_contacts_table::id.ne(*exc));
|
|
}
|
|
|
|
let rows: Vec<(Uuid, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)> = query
|
|
.select((
|
|
crm_contacts_table::id,
|
|
crm_contacts_table::first_name,
|
|
crm_contacts_table::last_name,
|
|
crm_contacts_table::email,
|
|
crm_contacts_table::company,
|
|
crm_contacts_table::job_title,
|
|
))
|
|
.limit(limit as i64)
|
|
.load(&mut conn)
|
|
.map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?;
|
|
|
|
let contacts = rows.into_iter().map(|row| {
|
|
let summary = ContactSummary {
|
|
id: row.0,
|
|
first_name: row.1.unwrap_or_default(),
|
|
last_name: row.2.unwrap_or_default(),
|
|
email: row.3,
|
|
phone: None,
|
|
company: row.4,
|
|
job_title: row.5,
|
|
avatar_url: None,
|
|
};
|
|
let workload = ContactWorkload {
|
|
active_tasks: 0,
|
|
high_priority_tasks: 0,
|
|
overdue_tasks: 0,
|
|
due_this_week: 0,
|
|
workload_level: WorkloadLevel::Low,
|
|
};
|
|
(summary, workload)
|
|
}).collect();
|
|
|
|
Ok(contacts)
|
|
})
|
|
.await
|
|
.map_err(|e| TasksIntegrationError::DatabaseError(e.to_string()))?
|
|
}
|
|
|
|
async fn create_task_in_db(
|
|
&self,
|
|
_task_id: Uuid,
|
|
_organization_id: Uuid,
|
|
_title: &str,
|
|
_description: Option<&str>,
|
|
_assignee_id: Option<Uuid>,
|
|
_due_date: Option<chrono::DateTime<chrono::Utc>>,
|
|
) -> Result<(), TasksIntegrationError> {
|
|
// Implementation would insert task into database
|
|
// For now, this is a placeholder
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_task_type_display() {
|
|
assert_eq!(format!("{:?}", ContactTaskType::FollowUp), "FollowUp");
|
|
assert_eq!(format!("{:?}", ContactTaskType::Meeting), "Meeting");
|
|
assert_eq!(format!("{:?}", ContactTaskType::Call), "Call");
|
|
}
|
|
|
|
#[test]
|
|
fn test_task_priority_display() {
|
|
assert_eq!(format!("{:?}", ContactTaskPriority::Low), "Low");
|
|
assert_eq!(format!("{:?}", ContactTaskPriority::Normal), "Normal");
|
|
assert_eq!(format!("{:?}", ContactTaskPriority::High), "High");
|
|
}
|
|
}
|