use crate::error::AppError; use models::ai::{AiConversation, ai_conversation}; use models::projects::MemberRole; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use uuid::Uuid; use crate::AppService; impl AppService { pub async fn find_conversation( &self, conversation_id: Uuid, ) -> Result { AiConversation::find_by_id(conversation_id) .one(self.db.reader()) .await? .ok_or_else(|| AppError::NotFound("conversation".into())) } pub async fn find_conversation_owned( &self, conversation_id: Uuid, user_id: Uuid, ) -> Result { let c = self.find_conversation(conversation_id).await?; if c.user_id != user_id { // For project conversations, check access control if c.project_id.is_some() { let access = super::access::check_conversation_access(&self.db, &c, user_id).await?; if access != super::AccessLevel::Denied { return Ok(c); } } return Err(AppError::PermissionDenied); } Ok(c) } pub async fn find_conversation_accessible( &self, conversation_id: Uuid, user_id: Uuid, ) -> Result { let c = self.find_conversation(conversation_id).await?; if c.user_id == user_id { return Ok(c); } if c.project_id.is_some() { let access = super::access::check_conversation_access(&self.db, &c, user_id).await?; if access != super::AccessLevel::Denied { return Ok(c); } } Err(AppError::PermissionDenied) } pub async fn find_conversation_full_access( &self, conversation_id: Uuid, user_id: Uuid, ) -> Result { let c = self.find_conversation(conversation_id).await?; if c.user_id == user_id { return Ok(c); } if c.project_id.is_some() { let access = super::access::check_conversation_access(&self.db, &c, user_id).await?; if access == super::AccessLevel::Full { return Ok(c); } } Err(AppError::PermissionDenied) } pub async fn find_conversation_creator( &self, conversation_id: Uuid, user_id: Uuid, ) -> Result { let c = self.find_conversation(conversation_id).await?; if c.user_id == user_id { Ok(c) } else { Err(AppError::PermissionDenied) } } pub async fn create_conversation( &self, user_id: Uuid, project_id: Option, title: Option, model: String, model_config: Option, access_visibility: Option, can_ask: Option, model_uid: Option, model_name: Option, ) -> Result { let scope = if project_id.is_some() { // For project chats: check that user can create (owner or admin) if let Some(pid) = project_id { let role = super::access::resolve_project_role(&self.db, pid, user_id).await?; match role { Some(r) if super::access::can_create(r) => {} _ => return Err(AppError::PermissionDenied), } // Auto-increment project_uid let next_uid = self.next_project_chat_uid(pid).await?; let now = chrono::Utc::now(); let conv = ai_conversation::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_id), project_id: Set(Some(pid)), scope: Set("project".to_string()), title: Set(title), model: Set(model), model_config: Set(model_config), status: Set("active".to_string()), root_message_id: Set(None), fork_count: Set(0), is_shared: Set(false), message_count: Set(0), token_usage_total: Set(None), access_visibility: Set( access_visibility.unwrap_or_else(|| "owner".to_string()) ), can_ask: Set(can_ask.unwrap_or_else(|| "owner".to_string())), project_uid: Set(Some(next_uid)), model_uid: Set(model_uid), model_name: Set(model_name), created_at: Set(now), updated_at: Set(now), } .insert(self.db.writer()) .await?; observability::incr!(observability::AI_CHAT_CONVERSATIONS_CREATED); return Ok(conv); } "project".to_string() } else { "user".to_string() }; let now = chrono::Utc::now(); let conv = ai_conversation::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_id), project_id: Set(project_id), scope: Set(scope), title: Set(title), model: Set(model), model_config: Set(model_config), status: Set("active".to_string()), root_message_id: Set(None), fork_count: Set(0), is_shared: Set(false), message_count: Set(0), token_usage_total: Set(None), access_visibility: Set(access_visibility.unwrap_or_else(|| "owner".to_string())), can_ask: Set(can_ask.unwrap_or_else(|| "owner".to_string())), project_uid: Set(None), model_uid: Set(model_uid), model_name: Set(model_name), created_at: Set(now), updated_at: Set(now), } .insert(self.db.writer()) .await?; observability::incr!(observability::AI_CHAT_CONVERSATIONS_CREATED); Ok(conv) } /// Get the next project-unique sequential number for chat conversations. async fn next_project_chat_uid(&self, project_id: Uuid) -> Result { use sea_orm::ExprTrait; let max_uid: Option> = AiConversation::find() .filter(ai_conversation::Column::ProjectId.eq(project_id)) .filter(ai_conversation::Column::ProjectUid.is_not_null()) .select_only() .column_as( sea_orm::sea_query::Expr::col(ai_conversation::Column::ProjectUid).max(), "max_uid", ) .into_tuple::>() .one(self.db.reader()) .await?; Ok(max_uid.flatten().unwrap_or(0) + 1) } pub async fn list_conversations( &self, user_id: Uuid, project_id: Option, page_size: u64, search_query: Option, ) -> Result, AppError> { let mut query = AiConversation::find().order_by_desc(ai_conversation::Column::UpdatedAt); if let Some(pid) = project_id { // For project chats, apply visibility rules let role = super::access::resolve_project_role(&self.db, pid, user_id).await?; match role { Some(r) => { query = query.filter(ai_conversation::Column::ProjectId.eq(pid)); let convs = query .paginate(self.db.reader(), page_size.saturating_mul(4).max(page_size)) .fetch_page(0) .await?; let mut visible = Vec::new(); for conv in convs { if conv.user_id == user_id || matches!(r, MemberRole::Owner) { visible.push(conv); } else if super::access::check_conversation_access(&self.db, &conv, user_id) .await? != super::AccessLevel::Denied { visible.push(conv); } if visible.len() >= page_size as usize { break; } } return Ok(visible); } None => { // Not a project member — only show own chats query = query .filter(ai_conversation::Column::ProjectId.eq(pid)) .filter(ai_conversation::Column::UserId.eq(user_id)); } } } else { // Personal scope — only own chats without a project query = query .filter(ai_conversation::Column::UserId.eq(user_id)) .filter(ai_conversation::Column::ProjectId.is_null()); } // Apply search filter if provided if let Some(ref q) = search_query { if !q.is_empty() { query = query.filter(ai_conversation::Column::Title.contains(q)); } } let convs = query .paginate(self.db.reader(), page_size) .fetch_page(0) .await?; Ok(convs) } pub async fn update_conversation( &self, conversation_id: Uuid, user_id: Uuid, title: Option, model: Option, model_config: Option, status: Option, access_visibility: Option, can_ask: Option, model_uid: Option, model_name: Option, ) -> Result<(), AppError> { let c = self .find_conversation_creator(conversation_id, user_id) .await?; let mut active: ai_conversation::ActiveModel = c.into(); if let Some(t) = title { active.title = Set(Some(t)); } if let Some(m) = model { active.model = Set(m); } if let Some(mc) = model_config { active.model_config = Set(Some(mc)); } if let Some(s) = status { active.status = Set(s); } if let Some(av) = access_visibility { active.access_visibility = Set(av); } if let Some(ca) = can_ask { active.can_ask = Set(ca); } if let Some(mu) = model_uid { active.model_uid = Set(Some(mu)); } if let Some(mn) = model_name { active.model_name = Set(Some(mn)); } active.updated_at = Set(chrono::Utc::now()); active.update(self.db.writer()).await?; Ok(()) } pub async fn delete_conversation( &self, conversation_id: Uuid, user_id: Uuid, ) -> Result<(), AppError> { self.find_conversation_creator(conversation_id, user_id) .await?; AiConversation::delete_by_id(conversation_id) .exec(self.db.writer()) .await?; Ok(()) } }