use crate::AppService; use crate::error::AppError; use crate::project::activity::ActivityLogParams; use chrono::Utc; use models::issues::{issue, issue_comment}; 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 IssueCommentCreateRequest { pub body: String, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct IssueCommentUpdateRequest { pub body: String, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueCommentResponse { pub id: i64, pub issue: Uuid, pub author: Uuid, pub author_username: String, pub body: String, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } impl From for IssueCommentResponse { fn from(c: issue_comment::Model) -> Self { Self { id: c.id, issue: c.issue, author: c.author, author_username: String::new(), body: c.body, created_at: c.created_at, updated_at: c.updated_at, } } } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueCommentListResponse { pub comments: Vec, pub total: i64, pub page: i64, pub per_page: i64, } impl AppService { /// List comments on an issue. pub async fn issue_comment_list( &self, project_name: String, issue_number: i64, page: Option, per_page: Option, ctx: &Session, ) -> Result { 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 page = page.unwrap_or(1); let per_page = per_page.unwrap_or(20); let offset = (page - 1) * per_page; let total: u64 = issue_comment::Entity::find() .filter(issue_comment::Column::Issue.eq(issue.id)) .count(&self.db) .await?; let comments = issue_comment::Entity::find() .filter(issue_comment::Column::Issue.eq(issue.id)) .order_by_asc(issue_comment::Column::CreatedAt) .offset(offset as u64) .limit(per_page as u64) .all(&self.db) .await?; let author_ids: Vec = comments.iter().map(|c| c.author).collect(); let authors = if author_ids.is_empty() { vec![] } else { user::Entity::find() .filter(user::Column::Uid.is_in(author_ids)) .all(&self.db) .await? }; let responses: Vec = comments .into_iter() .map(|c| { let username = authors .iter() .find(|u| u.uid == c.author) .map(|u| u.username.clone()) .unwrap_or_default(); IssueCommentResponse { author_username: username, ..IssueCommentResponse::from(c) } }) .collect(); Ok(IssueCommentListResponse { comments: responses, total: total as i64, page, per_page, }) } /// Get a single comment by ID. pub async fn issue_comment_get( &self, project_name: String, issue_number: i64, comment_id: i64, ctx: &Session, ) -> Result { 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 comment = issue_comment::Entity::find_by_id(comment_id) .filter(issue_comment::Column::Issue.eq(issue.id)) .one(&self.db) .await? .ok_or(AppError::NotFound("Comment not found".to_string()))?; let author = user::Entity::find_by_id(comment.author) .one(&self.db) .await .ok() .flatten(); let username = author.map(|u| u.username).unwrap_or_default(); Ok(IssueCommentResponse { author_username: username, ..IssueCommentResponse::from(comment) }) } /// Create a comment on an issue. pub async fn issue_comment_create( &self, project_name: String, issue_number: i64, request: IssueCommentCreateRequest, ctx: &Session, ) -> Result { 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?; if member.is_none() { return Err(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()))?; let now = Utc::now(); let active = issue_comment::ActiveModel { issue: Set(issue.id), author: Set(user_uid), body: Set(request.body), created_at: Set(now), updated_at: Set(now), ..Default::default() }; let model = active.insert(&self.db).await?; self.invalidate_issue_cache(project.id, issue_number).await; let actor_username = user::Entity::find_by_id(user_uid) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_default(); let _ = self .project_log_activity( project.id, None, user_uid, crate::project::activity::ActivityLogParams { event_type: "issue_comment".to_string(), title: format!("{} commented on issue #{}", actor_username, issue_number), repo_id: None, content: Some(model.body.clone()), event_id: None, event_sub_id: Some(issue_number), metadata: None, is_private: false, }, ) .await; Ok(IssueCommentResponse { author_username: actor_username, ..IssueCommentResponse::from(model) }) } /// Update a comment body. pub async fn issue_comment_update( &self, project_name: String, issue_number: i64, comment_id: i64, request: IssueCommentUpdateRequest, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).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 comment = issue_comment::Entity::find_by_id(comment_id) .filter(issue_comment::Column::Issue.eq(issue.id)) .one(&self.db) .await? .ok_or(AppError::NotFound("Comment not found".to_string()))?; // Only author can edit their comment if comment.author != user_uid { return Err(AppError::NoPower); } let mut active: issue_comment::ActiveModel = comment.clone().into(); active.body = Set(request.body); active.updated_at = Set(Utc::now()); let model = active.update(&self.db).await?; let username = user::Entity::find_by_id(model.author) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_default(); let _ = self .project_log_activity( project.id, None, user_uid, ActivityLogParams { event_type: "issue_comment_update".to_string(), title: format!("{} updated a comment on issue #{}", user_uid, issue_number), repo_id: None, content: None, event_id: None, event_sub_id: Some(issue_number), metadata: None, is_private: false, }, ) .await; Ok(IssueCommentResponse { author_username: username, ..IssueCommentResponse::from(model) }) } /// Delete a comment. pub async fn issue_comment_delete( &self, project_name: String, issue_number: i64, comment_id: i64, 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 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 comment = issue_comment::Entity::find_by_id(comment_id) .filter(issue_comment::Column::Issue.eq(issue.id)) .one(&self.db) .await? .ok_or(AppError::NotFound("Comment not found".to_string()))?; // Allow: author of comment, or issue author, or admin/owner 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 role = member.scope_role().map_err(|_| AppError::RoleParseError)?; let is_comment_author = comment.author == user_uid; let is_issue_author = issue.author == user_uid; let is_admin = role == models::projects::MemberRole::Admin || role == models::projects::MemberRole::Owner; if !is_comment_author && !is_issue_author && !is_admin { return Err(AppError::NoPower); } issue_comment::Entity::delete_by_id(comment_id) .exec(&self.db) .await?; self.invalidate_issue_cache(project.id, issue_number).await; let _ = self .project_log_activity( project.id, None, user_uid, ActivityLogParams { event_type: "issue_comment_delete".to_string(), title: format!("{} deleted a comment on issue #{}", user_uid, issue_number), repo_id: None, content: None, event_id: None, event_sub_id: Some(issue_number), metadata: None, is_private: false, }, ) .await; Ok(()) } }