use std::collections::HashMap; use api::users::UserDto; use models::issue::{comment, issue_reactions}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use serde::{Deserialize, Serialize}; use space::SpaceJoinTrigger; use space::services::join_rules; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::IssueError; use crate::services::service::IssueService; use crate::share::lookup::{issue_context, require_issue_access}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum ReactionScope { Issue, Comment, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ReactionDto { pub id: Uuid, pub scope: ReactionScope, pub target_id: Uuid, pub user: Option, pub emoji: String, pub created_at: OffsetDateTime, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ReactionAggDto { pub emoji: String, pub count: i64, pub user_ids: Vec, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateReactionCmd { pub emoji: String, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct ReactionPath { pub space_name: String, pub board_id: Uuid, pub issue_id: String, pub reaction_id: Uuid, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct EmojiQuery { pub emoji: String, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct CommentReactionPath { pub space_name: String, pub board_id: Uuid, pub issue_id: String, pub comment_id: Uuid, } fn aggregate_reactions(rows: Vec) -> Vec { let mut groups: HashMap> = HashMap::new(); for r in rows { groups.entry(r.emoji).or_default().push(r.user_id); } let mut result = Vec::new(); for (emoji, mut uids) in groups { uids.sort(); uids.dedup(); result.push(ReactionAggDto { count: uids.len() as i64, emoji, user_ids: uids, }); } result.sort_by(|a, b| a.emoji.cmp(&b.emoji)); result } impl IssueService { fn validate_emoji(&self, emoji: &str) -> Result { let clean = emoji.trim(); if clean.is_empty() || clean.chars().count() > self.settings.issues.max_reaction_emoji_len { return Err(IssueError::BadRequest( "emoji is empty or too long".to_string(), )); } Ok(clean.to_string()) } #[tracing::instrument(name = "issues.create_reaction", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id))] pub async fn create_reaction( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, cmd: CreateReactionCmd, ) -> Result { let (space, _, _) = issue_context(self, caller, space_name, board_id, issue_id).await?; let emoji = self.validate_emoji(&cmd.emoji)?; let existing = issue_reactions::Entity::find() .filter(issue_reactions::Column::IssueId.eq(Some(issue_id))) .filter(issue_reactions::Column::UserId.eq(caller)) .filter(issue_reactions::Column::Emoji.eq(&emoji)) .one(self.database.read()) .await?; if existing.is_some() { return Err(IssueError::Conflict("reaction already exists".to_string())); } let now = OffsetDateTime::now_utc(); let row = issue_reactions::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(Some(issue_id)), comment_id: Set(None), project_id: Set(None), user_id: Set(caller), emoji: Set(emoji.clone()), created_at: Set(now), }; let inserted = row.insert(self.database.write()).await?; if let Err(e) = join_rules::apply_space_join_rules( &self.access, space.id(), caller, SpaceJoinTrigger::IssueReaction, &emoji, ) .await { tracing::warn!(%e, "space join rule application failed"); } let user_map = self.audience.load_user_briefs(&[caller]).await?; let user = user_map.get(&caller).cloned(); Ok(ReactionDto { id: inserted.id, scope: ReactionScope::Issue, target_id: issue_id, user, emoji, created_at: inserted.created_at, }) } #[tracing::instrument(name = "issues.create_comment_reaction", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, comment_id = %comment_id))] pub async fn create_comment_reaction( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, comment_id: Uuid, cmd: CreateReactionCmd, ) -> Result { let (space, _, _) = issue_context(self, caller, space_name, board_id, issue_id).await?; let c = comment::Entity::find_by_id(comment_id) .filter(comment::Column::IssueId.eq(Some(issue_id))) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; let emoji = self.validate_emoji(&cmd.emoji)?; let existing = issue_reactions::Entity::find() .filter(issue_reactions::Column::CommentId.eq(Some(c.id))) .filter(issue_reactions::Column::UserId.eq(caller)) .filter(issue_reactions::Column::Emoji.eq(&emoji)) .one(self.database.read()) .await?; if existing.is_some() { return Err(IssueError::Conflict("reaction already exists".to_string())); } let now = OffsetDateTime::now_utc(); let row = issue_reactions::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(None), comment_id: Set(Some(c.id)), project_id: Set(None), user_id: Set(caller), emoji: Set(emoji.clone()), created_at: Set(now), }; let inserted = row.insert(self.database.write()).await?; if let Err(e) = join_rules::apply_space_join_rules( &self.access, space.id(), caller, SpaceJoinTrigger::IssueCommentReaction, &emoji, ) .await { tracing::warn!(%e, "space join rule application failed"); } let user_map = self.audience.load_user_briefs(&[caller]).await?; let user = user_map.get(&caller).cloned(); Ok(ReactionDto { id: inserted.id, scope: ReactionScope::Comment, target_id: c.id, user, emoji, created_at: inserted.created_at, }) } pub async fn list_reactions( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, ) -> Result, IssueError> { require_issue_access(self, caller, space_name, board_id, issue_id).await?; let rows = issue_reactions::Entity::find() .filter(issue_reactions::Column::IssueId.eq(Some(issue_id))) .all(self.database.read()) .await?; Ok(aggregate_reactions(rows)) } pub async fn list_comment_reactions( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, comment_id: Uuid, ) -> Result, IssueError> { require_issue_access(self, caller, space_name, board_id, issue_id).await?; let _ = comment::Entity::find_by_id(comment_id) .filter(comment::Column::IssueId.eq(Some(issue_id))) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; let rows = issue_reactions::Entity::find() .filter(issue_reactions::Column::CommentId.eq(Some(comment_id))) .all(self.database.read()) .await?; Ok(aggregate_reactions(rows)) } #[tracing::instrument(name = "issues.remove_reaction_by_emoji", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id))] pub async fn remove_reaction_by_emoji( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, emoji: &str, ) -> Result<(), IssueError> { require_issue_access(self, caller, space_name, board_id, issue_id).await?; let emoji = self.validate_emoji(emoji)?; let row = issue_reactions::Entity::find() .filter(issue_reactions::Column::IssueId.eq(Some(issue_id))) .filter(issue_reactions::Column::CommentId.is_null()) .filter(issue_reactions::Column::UserId.eq(caller)) .filter(issue_reactions::Column::Emoji.eq(&emoji)) .one(self.database.write()) .await? .ok_or(IssueError::NotFound)?; issue_reactions::Entity::delete_by_id(row.id) .exec(self.database.write()) .await?; Ok(()) } #[tracing::instrument(name = "issues.remove_comment_reaction_by_emoji", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, comment_id = %comment_id))] pub async fn remove_comment_reaction_by_emoji( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, comment_id: Uuid, emoji: &str, ) -> Result<(), IssueError> { require_issue_access(self, caller, space_name, board_id, issue_id).await?; let _ = comment::Entity::find_by_id(comment_id) .filter(comment::Column::IssueId.eq(Some(issue_id))) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; let emoji = self.validate_emoji(emoji)?; let row = issue_reactions::Entity::find() .filter(issue_reactions::Column::CommentId.eq(Some(comment_id))) .filter(issue_reactions::Column::UserId.eq(caller)) .filter(issue_reactions::Column::Emoji.eq(&emoji)) .one(self.database.write()) .await? .ok_or(IssueError::NotFound)?; issue_reactions::Entity::delete_by_id(row.id) .exec(self.database.write()) .await?; Ok(()) } #[tracing::instrument(name = "issues.delete_reaction", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, reaction_id = %reaction_id))] pub async fn delete_reaction( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, reaction_id: Uuid, ) -> Result<(), IssueError> { require_issue_access(self, caller, space_name, board_id, issue_id).await?; let r = issue_reactions::Entity::find_by_id(reaction_id) .filter(issue_reactions::Column::UserId.eq(caller)) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; let allowed = match r.issue_id { Some(iid) => iid == issue_id, None => match r.comment_id { Some(cid) => comment::Entity::find_by_id(cid) .filter(comment::Column::IssueId.eq(Some(issue_id))) .one(self.database.read()) .await? .is_some(), None => false, }, }; if !allowed { return Err(IssueError::NotFound); } issue_reactions::Entity::delete_by_id(r.id) .exec(self.database.write()) .await?; Ok(()) } }