Skip to content
Clone
main
360 lines 11.8 KB Rust
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<UserDto>,
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<i64>,
}
#[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<issue_reactions::Model>) -> Vec<ReactionAggDto> {
let mut groups: HashMap<String, Vec<i64>> = 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<String, IssueError> {
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<ReactionDto, IssueError> {
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<ReactionDto, IssueError> {
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<Vec<ReactionAggDto>, 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<Vec<ReactionAggDto>, 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(())
}
}