use crate::AppService; use crate::error::AppError; use crate::project::activity::ActivityLogParams; use chrono::Utc; use models::issues::{issue, issue_assignee}; use models::projects::project_members; use models::users::user; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::ToSchema; use uuid::Uuid; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct IssueAssignUserRequest { pub user_id: Uuid, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueAssigneeResponse { pub issue: Uuid, pub user_id: Uuid, pub username: String, pub assigned_at: chrono::DateTime, } impl AppService { /// List assignees for an issue. pub async fn issue_assignee_list( &self, project_name: String, issue_number: i64, ctx: &Session, ) -> Result, AppError> { let project = self.utils_find_project_by_name(project_name).await?; if let Some(uid) = ctx.user() { self.check_project_access(project.id, uid).await?; } let issue = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Number.eq(issue_number)) .one(&self.db) .await? .ok_or(AppError::NotFound("Issue not found".to_string()))?; let assignees = issue_assignee::Entity::find() .filter(issue_assignee::Column::Issue.eq(issue.id)) .all(&self.db) .await?; let user_ids: Vec = assignees.iter().map(|a| a.user).collect(); let users = if user_ids.is_empty() { vec![] } else { user::Entity::find() .filter(user::Column::Uid.is_in(user_ids)) .all(&self.db) .await? }; let responses: Vec = assignees .into_iter() .filter_map(|a| { let username = users.iter().find(|u| u.uid == a.user)?.username.clone(); Some(IssueAssigneeResponse { issue: a.issue, user_id: a.user, username, assigned_at: a.assigned_at, }) }) .collect(); Ok(responses) } /// Assign a user to an issue. pub async fn issue_assignee_add( &self, project_name: String, issue_number: i64, request: IssueAssignUserRequest, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; // Must be project member let _member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NoPower)?; let issue = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Number.eq(issue_number)) .one(&self.db) .await? .ok_or(AppError::NotFound("Issue not found".to_string()))?; // Check if assignee is also a member let assignee_member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(request.user_id)) .one(&self.db) .await?; if assignee_member.is_none() { return Err(AppError::NotFound( "User is not a project member".to_string(), )); } let now = Utc::now(); let active = issue_assignee::ActiveModel { issue: Set(issue.id), user: Set(request.user_id), assigned_at: Set(now), ..Default::default() }; let model = active.insert(&self.db).await?; self.invalidate_issue_cache(project.id, issue_number).await; let username = user::Entity::find_by_id(request.user_id) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_default(); let response = Ok(IssueAssigneeResponse { issue: model.issue, user_id: model.user, username: username.clone(), assigned_at: model.assigned_at, }); let _ = self .project_log_activity( project.id, None, user_uid, ActivityLogParams { event_type: "issue_assignee_add".to_string(), title: format!( "{} assigned {} to issue #{}", user_uid, username.clone(), issue_number ), repo_id: None, content: None, event_id: Some(model.issue), event_sub_id: Some(issue_number), metadata: Some(serde_json::json!({ "assignee_uid": request.user_id, "assignee_username": username.clone(), })), is_private: false, }, ) .await; response } /// Remove an assignee from an issue. pub async fn issue_assignee_remove( &self, project_name: String, issue_number: i64, assignee_id: Uuid, ctx: &Session, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; let member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NoPower)?; let issue = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Number.eq(issue_number)) .one(&self.db) .await? .ok_or(AppError::NotFound("Issue not found".to_string()))?; // Allow: the assignee themselves, or any admin/owner, or the issue author let role = member.scope_role().map_err(|_| AppError::RoleParseError)?; let is_self = assignee_id == user_uid; let is_author = issue.author == user_uid; let is_admin = role == models::projects::MemberRole::Admin || role == models::projects::MemberRole::Owner; if !is_self && !is_author && !is_admin { return Err(AppError::NoPower); } issue_assignee::Entity::delete_many() .filter(issue_assignee::Column::Issue.eq(issue.id)) .filter(issue_assignee::Column::User.eq(assignee_id)) .exec(&self.db) .await?; self.invalidate_issue_cache(project.id, issue_number).await; let assignee_username = user::Entity::find_by_id(assignee_id) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_else(|| assignee_id.to_string()); let _ = self .project_log_activity( project.id, None, user_uid, ActivityLogParams { event_type: "issue_assignee_remove".to_string(), title: format!( "{} removed {} from issue #{}", user_uid, assignee_username, issue_number ), repo_id: None, content: None, event_id: Some(issue.id), event_sub_id: Some(issue_number), metadata: Some(serde_json::json!({ "removed_assignee_uid": assignee_id, "removed_assignee_username": assignee_username, })), is_private: false, }, ) .await; Ok(()) } }