Skip to content
Clone
4c83001
477 lines 16.5 KB Rust
use models::issue::comment;
use models::issue::issue_member::{self, IssueMemberKind};
use models::users::user_notifications::NotificationType;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use space::services::spaces::UserBriefDto;
use time::OffsetDateTime;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::errors::IssueError;
use crate::services::service::IssueService;
use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CommentDto {
pub id: Uuid,
pub issue_id: Option<Uuid>,
pub parent_id: Option<Uuid>,
pub poster: Option<UserBriefDto>,
pub content: String,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct CommentPage {
pub items: Vec<CommentDto>,
pub total: i64,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateCommentCmd {
pub content: String,
#[serde(default)]
pub parent_id: Option<Uuid>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateCommentCmd {
pub content: String,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct ListCommentQuery {
pub parent_id: Option<Uuid>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct CommentPath {
pub space_name: String,
pub board_id: Uuid,
pub issue_id: Uuid,
pub comment_id: Uuid,
}
impl IssueService {
pub async fn create_comment(
&self,
caller: Uuid,
space_name: &str,
board_id: Uuid,
issue_id: Uuid,
cmd: CreateCommentCmd,
) -> Result<CommentDto, IssueError> {
let space = lookup_space_by_name(self, space_name).await?;
require_member(self, &space, caller).await?;
let board = lookup_board(self, &space, board_id).await?;
let item = lookup_issue(self, &board, issue_id).await?;
let content = cmd.content.trim();
if content.is_empty() || content.chars().count() > self.config().issues.max_comment_len {
return Err(IssueError::BadRequest("comment content is empty or too long".to_string()));
}
let mut parent_poster_id: Option<Uuid> = None;
if let Some(pid) = cmd.parent_id {
let parent_comment = comment::Entity::find_by_id(pid)
.one(self.db().read())
.await?
.ok_or_else(|| IssueError::BadRequest("parent comment not found".to_string()))?;
if parent_comment.issue_id != Some(issue_id) {
return Err(IssueError::BadRequest(
"parent comment must belong to the same issue".to_string(),
));
}
if parent_comment.poster_id != caller {
parent_poster_id = Some(parent_comment.poster_id);
}
}
let mentioned_uids = self
.collect_mentioned_uids(&space, content, caller)
.await?;
let now = OffsetDateTime::now_utc();
let comment_id = Uuid::now_v7();
let content_str = content.to_string();
let parent_opt = cmd.parent_id;
let this = self.clone();
let space_lower_for_txn = space.lower_name.clone();
let item_title_for_txn = item.title.clone();
let mentioned_for_txn = mentioned_uids.clone();
let inserted = self
.db()
.primary()
.transaction::<_, comment::Model, IssueError>(move |txn| {
let content_clone = content_str.clone();
let mentioned_clone = mentioned_for_txn;
let space_name_clone = space_lower_for_txn;
let issue_num = item.autoincrement_id;
let issue_title_clone = item_title_for_txn;
Box::pin(async move {
let row = comment::ActiveModel {
id: Set(comment_id),
issue_id: Set(Some(issue_id)),
project_id: Set(None),
parent_id: Set(parent_opt),
poster_id: Set(caller),
content: Set(content_clone.clone()),
created_at: Set(now),
updated_at: Set(None),
};
let ins = row.insert(txn).await?;
this.write_event(
txn,
issue_id,
caller,
"comment_created",
Some(comment_id),
None,
None,
None,
)
.await?;
if let Some(pp_uid) = parent_poster_id {
let data = serde_json::json!({
"space_name": space_name_clone,
"board_id": board.id,
"issue_id": issue_id,
"comment_id": comment_id,
"autoincrement_id": issue_num,
});
this.notify_user(
txn,
pp_uid,
NotificationType::Reply,
format!("New reply on issue #{issue_num}: {issue_title_clone}"),
Some(content_clone.clone()),
Some(data),
Some(caller),
)
.await?;
}
for m_uid in mentioned_clone {
if Some(m_uid) == parent_poster_id {
continue;
}
let existing_member = issue_member::Entity::find()
.filter(issue_member::Column::IssueId.eq(issue_id))
.filter(issue_member::Column::UserId.eq(m_uid))
.filter(issue_member::Column::Kind.eq(IssueMemberKind::Mentioned))
.one(txn)
.await?;
if existing_member.is_none() {
let m_row = issue_member::ActiveModel {
id: Set(Uuid::now_v7()),
issue_id: Set(issue_id),
user_id: Set(m_uid),
kind: Set(IssueMemberKind::Mentioned),
created_at: Set(now),
updated_at: Set(None),
};
m_row.insert(txn).await?;
}
let data = serde_json::json!({
"space_name": space_name_clone,
"board_id": board.id,
"issue_id": issue_id,
"comment_id": comment_id,
"autoincrement_id": issue_num,
});
this.notify_user(
txn,
m_uid,
NotificationType::Mention,
format!("You were mentioned in issue #{issue_num}: {issue_title_clone}"),
Some(content_clone.clone()),
Some(data),
Some(caller),
)
.await?;
}
Ok(ins)
})
})
.await?;
let mut email_recipients = Vec::new();
if let Some(pp_uid) = parent_poster_id {
email_recipients.push(pp_uid);
}
for m_uid in &mentioned_uids {
if !email_recipients.contains(m_uid) {
email_recipients.push(*m_uid);
}
}
if !email_recipients.is_empty() {
let emails = self.primary_emails(&email_recipients).await?;
for (uid, addr) in emails {
let is_reply = Some(uid) == parent_poster_id;
let subject = if is_reply {
format!(
"New reply on issue #{}: {}",
item.autoincrement_id, item.title
)
} else {
format!(
"You were mentioned in issue #{}: {}",
item.autoincrement_id, item.title
)
};
let link = format!(
"{}/issues/{}/{}/{}",
self.config().web.web_base_url,
space.lower_name,
board.id,
item.id
);
let text = format!("{}\n\nView issue: {}", subject, link);
let html = format!(
r#"<p>{}</p><p><a href="{}">View Issue</a></p>"#,
subject, link
);
if let Err(err) = self.send_email(&addr, &subject, &text, &html).await {
tracing::warn!(error = %err, "failed to send comment notification email");
}
}
}
let user_map = self.load_user_briefs(&[inserted.poster_id]).await?;
let poster = user_map.get(&inserted.poster_id).cloned();
Ok(CommentDto {
id: inserted.id,
issue_id: inserted.issue_id,
parent_id: inserted.parent_id,
poster,
content: inserted.content,
created_at: inserted.created_at,
updated_at: inserted.updated_at,
})
}
pub async fn list_comments(
&self,
caller: Uuid,
space_name: &str,
board_id: Uuid,
issue_id: Uuid,
q: ListCommentQuery,
) -> Result<CommentPage, IssueError> {
let space = lookup_space_by_name(self, space_name).await?;
require_member(self, &space, caller).await?;
let board = lookup_board(self, &space, board_id).await?;
let _ = lookup_issue(self, &board, issue_id).await?;
let max_pagination = self.config().issues.max_pagination;
let default_pagination = self.config().issues.default_pagination;
let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
let offset = q.offset.unwrap_or(0);
let mut select = comment::Entity::find()
.filter(comment::Column::IssueId.eq(Some(issue_id)));
if let Some(pid) = q.parent_id {
select = select.filter(comment::Column::ParentId.eq(Some(pid)));
}
select = select.order_by_asc(comment::Column::CreatedAt);
let total = select.clone().count(self.db().read()).await? as i64;
let rows = select
.limit(limit)
.offset(offset)
.all(self.db().read())
.await?;
let poster_ids: Vec<Uuid> = rows.iter().map(|c| c.poster_id).collect();
let user_map = self.load_user_briefs(&poster_ids).await?;
let items = rows
.into_iter()
.map(|c| {
let poster = user_map.get(&c.poster_id).cloned();
CommentDto {
id: c.id,
issue_id: c.issue_id,
parent_id: c.parent_id,
poster,
content: c.content,
created_at: c.created_at,
updated_at: c.updated_at,
}
})
.collect();
Ok(CommentPage { items, total })
}
pub async fn get_comment(
&self,
caller: Uuid,
space_name: &str,
board_id: Uuid,
issue_id: Uuid,
comment_id: Uuid,
) -> Result<CommentDto, IssueError> {
let space = lookup_space_by_name(self, space_name).await?;
require_member(self, &space, caller).await?;
let board = lookup_board(self, &space, board_id).await?;
let _ = lookup_issue(self, &board, issue_id).await?;
let c = comment::Entity::find_by_id(comment_id)
.filter(comment::Column::IssueId.eq(Some(issue_id)))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
let user_map = self.load_user_briefs(&[c.poster_id]).await?;
let poster = user_map.get(&c.poster_id).cloned();
Ok(CommentDto {
id: c.id,
issue_id: c.issue_id,
parent_id: c.parent_id,
poster,
content: c.content,
created_at: c.created_at,
updated_at: c.updated_at,
})
}
pub async fn update_comment(
&self,
caller: Uuid,
space_name: &str,
board_id: Uuid,
issue_id: Uuid,
comment_id: Uuid,
cmd: UpdateCommentCmd,
) -> Result<CommentDto, IssueError> {
let space = lookup_space_by_name(self, space_name).await?;
require_member(self, &space, caller).await?;
let board = lookup_board(self, &space, board_id).await?;
let _ = lookup_issue(self, &board, issue_id).await?;
let c = comment::Entity::find_by_id(comment_id)
.filter(comment::Column::IssueId.eq(Some(issue_id)))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
if c.poster_id != caller {
return Err(IssueError::Forbidden);
}
let content = cmd.content.trim();
if content.is_empty() || content.chars().count() > self.config().issues.max_comment_len {
return Err(IssueError::BadRequest("comment content is empty or too long".to_string()));
}
let now = OffsetDateTime::now_utc();
let mut am: comment::ActiveModel = c.into();
am.content = Set(content.to_string());
am.updated_at = Set(Some(now));
let this = self.clone();
let updated = self
.db()
.primary()
.transaction::<_, comment::Model, IssueError>(move |txn| {
Box::pin(async move {
let u = am.update(txn).await?;
this.write_event(
txn,
issue_id,
caller,
"comment_updated",
Some(comment_id),
None,
None,
None,
)
.await?;
Ok(u)
})
})
.await?;
let user_map = self.load_user_briefs(&[updated.poster_id]).await?;
let poster = user_map.get(&updated.poster_id).cloned();
Ok(CommentDto {
id: updated.id,
issue_id: updated.issue_id,
parent_id: updated.parent_id,
poster,
content: updated.content,
created_at: updated.created_at,
updated_at: updated.updated_at,
})
}
pub async fn delete_comment(
&self,
caller: Uuid,
space_name: &str,
board_id: Uuid,
issue_id: Uuid,
comment_id: Uuid,
) -> Result<(), IssueError> {
let space = lookup_space_by_name(self, space_name).await?;
require_member(self, &space, caller).await?;
let board = lookup_board(self, &space, board_id).await?;
let _ = lookup_issue(self, &board, issue_id).await?;
let c = comment::Entity::find_by_id(comment_id)
.filter(comment::Column::IssueId.eq(Some(issue_id)))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
if c.poster_id != caller {
return Err(IssueError::Forbidden);
}
let this = self.clone();
self.db()
.primary()
.transaction::<_, (), IssueError>(move |txn| {
Box::pin(async move {
comment::Entity::delete_by_id(comment_id).exec(txn).await?;
this.write_event(
txn,
issue_id,
caller,
"comment_deleted",
Some(comment_id),
None,
None,
None,
)
.await?;
Ok(())
})
})
.await?;
Ok(())
}
}