use crate::AppService; use crate::error::AppError; use chrono::Utc; use models::issues::{ReactionType, issue, issue_comment, issue_comment_reaction, issue_reaction}; use models::projects::project_members; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use std::str::FromStr; use utoipa::ToSchema; use uuid::Uuid; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct ReactionAddRequest { pub reaction: String, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ReactionResponse { pub user: Uuid, pub reaction: String, pub created_at: chrono::DateTime, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ReactionSummary { pub reaction: String, pub count: i64, pub users: Vec, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ReactionListResponse { pub reactions: Vec, } impl AppService { /// List reactions summary on an issue. pub async fn issue_reaction_list( &self, project_name: String, issue_number: 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 reactions = issue_reaction::Entity::find() .filter(issue_reaction::Column::Issue.eq(issue.id)) .all(&self.db) .await?; let summaries = self.aggregate_reactions(reactions); Ok(ReactionListResponse { reactions: summaries, }) } /// Add a reaction to an issue. pub async fn issue_reaction_add( &self, project_name: String, issue_number: i64, request: ReactionAddRequest, 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? .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()))?; let _ = ReactionType::from_str(&request.reaction) .map_err(|_| AppError::BadRequest("Unknown reaction type".to_string()))?; let existing = issue_reaction::Entity::find() .filter(issue_reaction::Column::Issue.eq(issue.id)) .filter(issue_reaction::Column::User.eq(user_uid)) .filter(issue_reaction::Column::Reaction.eq(&request.reaction)) .one(&self.db) .await?; if let Some(e) = existing { return Ok(ReactionResponse { user: e.user, reaction: e.reaction, created_at: e.created_at, }); } let now = Utc::now(); let active = issue_reaction::ActiveModel { issue: Set(issue.id), user: Set(user_uid), reaction: Set(request.reaction), created_at: Set(now), ..Default::default() }; let model = active.insert(&self.db).await?; Ok(ReactionResponse { user: model.user, reaction: model.reaction, created_at: model.created_at, }) } pub async fn issue_reaction_remove( &self, project_name: String, issue_number: i64, reaction: String, 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()))?; issue_reaction::Entity::delete_many() .filter(issue_reaction::Column::Issue.eq(issue.id)) .filter(issue_reaction::Column::User.eq(user_uid)) .filter(issue_reaction::Column::Reaction.eq(reaction)) .exec(&self.db) .await?; Ok(()) } /// List reactions on a comment. pub async fn issue_comment_reaction_list( &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 reactions = issue_comment_reaction::Entity::find() .filter(issue_comment_reaction::Column::Comment.eq(comment.id)) .all(&self.db) .await?; let summaries = self.aggregate_comment_reactions(reactions); Ok(ReactionListResponse { reactions: summaries, }) } /// Add a reaction to a comment. pub async fn issue_comment_reaction_add( &self, project_name: String, issue_number: i64, comment_id: i64, request: ReactionAddRequest, 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? .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()))?; 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 _ = ReactionType::from_str(&request.reaction) .map_err(|_| AppError::BadRequest("Unknown reaction type".to_string()))?; let existing = issue_comment_reaction::Entity::find() .filter(issue_comment_reaction::Column::Comment.eq(comment.id)) .filter(issue_comment_reaction::Column::User.eq(user_uid)) .filter(issue_comment_reaction::Column::Reaction.eq(&request.reaction)) .one(&self.db) .await?; if let Some(e) = existing { return Ok(ReactionResponse { user: e.user, reaction: e.reaction, created_at: e.created_at, }); } let now = Utc::now(); let active = issue_comment_reaction::ActiveModel { comment: Set(comment.id), user: Set(user_uid), reaction: Set(request.reaction), created_at: Set(now), ..Default::default() }; let model = active.insert(&self.db).await?; Ok(ReactionResponse { user: model.user, reaction: model.reaction, created_at: model.created_at, }) } pub async fn issue_comment_reaction_remove( &self, project_name: String, issue_number: i64, comment_id: i64, reaction: String, 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()))?; 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()))?; issue_comment_reaction::Entity::delete_many() .filter(issue_comment_reaction::Column::Comment.eq(comment.id)) .filter(issue_comment_reaction::Column::User.eq(user_uid)) .filter(issue_comment_reaction::Column::Reaction.eq(reaction)) .exec(&self.db) .await?; Ok(()) } fn aggregate_reactions(&self, reactions: Vec) -> Vec { use std::collections::HashMap; let mut map: HashMap)> = HashMap::new(); for r in reactions { let entry = map.entry(r.reaction.clone()).or_insert_with(|| (0, vec![])); entry.0 += 1; entry.1.push(r.user); } map.into_iter() .map(|(reaction, (count, users))| ReactionSummary { reaction, count, users, }) .collect() } fn aggregate_comment_reactions( &self, reactions: Vec, ) -> Vec { use std::collections::HashMap; let mut map: HashMap)> = HashMap::new(); for r in reactions { let entry = map.entry(r.reaction.clone()).or_insert_with(|| (0, vec![])); entry.0 += 1; entry.1.push(r.user); } map.into_iter() .map(|(reaction, (count, users))| ReactionSummary { reaction, count, users, }) .collect() } }