use models::issue::comment; use models::issue::issue_content_history; use models::issue::issue_dependency; use models::issue::issue_event_line; use models::issue::issue_item; use models::issue::issue_member; use models::issue::issue_pin; use models::issue::issue_reactions; use models::issue::issue_relation_label; use models::issue::issue_relation_pull; use models::issue::issue_relation_repo; use models::issue::issue_user; use models::issue::issue_watch; use models::issue::issues; use models::issue::milestone; use models::issue::stopwatch; use models::issue::tracked_time; use models::users::user_notifications::NotificationType; use sea_orm::{ ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, 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, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum IssueState { Open, Closed, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct IssueDto { pub id: Uuid, pub board_id: Uuid, pub autoincrement_id: i32, pub author: Option, pub title: String, pub description: Option, pub body: String, pub milestone_id: Option, pub state: IssueState, pub created_at: OffsetDateTime, pub updated_at: Option, pub closed_at: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssuePage { pub items: Vec, pub total: i64, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateIssueCmd { pub title: String, #[serde(default)] pub description: Option, #[serde(default)] pub body: String, #[serde(default)] pub milestone_id: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateIssueCmd { #[serde(default)] pub title: Option, #[serde(default)] pub description: Option>, #[serde(default)] pub body: Option, #[serde(default)] pub milestone_id: Option>, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct ListIssueQuery { pub state: Option, pub author_id: Option, pub milestone_id: Option, pub q: Option, pub limit: Option, pub offset: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct IssuePath { pub space_name: String, pub board_id: Uuid, pub issue_id: Uuid, } pub async fn delete_issue_cascade_in_txn( txn: &impl ConnectionTrait, issue_id: Uuid, ) -> Result<(), IssueError> { issue_pin::Entity::delete_many() .filter(issue_pin::Column::IssueId.eq(issue_id)) .exec(txn) .await?; let comment_rows = comment::Entity::find() .filter(comment::Column::IssueId.eq(issue_id)) .all(txn) .await?; let comment_ids: Vec = comment_rows.into_iter().map(|c| c.id).collect(); if !comment_ids.is_empty() { issue_reactions::Entity::delete_many() .filter(issue_reactions::Column::CommentId.is_in(comment_ids)) .exec(txn) .await?; } issue_reactions::Entity::delete_many() .filter(issue_reactions::Column::IssueId.eq(issue_id)) .exec(txn) .await?; comment::Entity::delete_many() .filter(comment::Column::IssueId.eq(issue_id)) .exec(txn) .await?; issue_event_line::Entity::delete_many() .filter(issue_event_line::Column::IssueId.eq(issue_id)) .exec(txn) .await?; issue_member::Entity::delete_many() .filter(issue_member::Column::IssueId.eq(issue_id)) .exec(txn) .await?; issue_relation_label::Entity::delete_many() .filter(issue_relation_label::Column::IssueId.eq(issue_id)) .exec(txn) .await?; issue_relation_pull::Entity::delete_many() .filter(issue_relation_pull::Column::IssueId.eq(issue_id)) .exec(txn) .await?; issue_relation_repo::Entity::delete_many() .filter(issue_relation_repo::Column::IssueId.eq(issue_id)) .exec(txn) .await?; issue_user::Entity::delete_many() .filter(issue_user::Column::IssueId.eq(issue_id)) .exec(txn) .await?; issue_watch::Entity::delete_many() .filter(issue_watch::Column::IssueId.eq(issue_id)) .exec(txn) .await?; issue_dependency::Entity::delete_many() .filter( Condition::any() .add(issue_dependency::Column::IssueId.eq(issue_id)) .add(issue_dependency::Column::DependencyId.eq(issue_id)), ) .exec(txn) .await?; stopwatch::Entity::delete_many() .filter(stopwatch::Column::IssueId.eq(issue_id)) .exec(txn) .await?; tracked_time::Entity::delete_many() .filter(tracked_time::Column::IssueId.eq(issue_id)) .exec(txn) .await?; issue_content_history::Entity::delete_many() .filter(issue_content_history::Column::IssueId.eq(issue_id)) .exec(txn) .await?; issue_item::Entity::delete_by_id(issue_id).exec(txn).await?; Ok(()) } impl IssueService { pub async fn create_issue( &self, caller: Uuid, space_name: &str, board_id: Uuid, cmd: CreateIssueCmd, ) -> Result { 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 title = cmd.title.trim(); if title.is_empty() || title.chars().count() > self.config().issues.max_title_len { return Err(IssueError::BadRequest("issue title is empty or too long".to_string())); } if cmd.body.chars().count() > self.config().issues.max_body_len { return Err(IssueError::BadRequest("issue body is too long".to_string())); } if let Some(mid) = cmd.milestone_id { milestone::Entity::find_by_id(mid) .filter(milestone::Column::CollectionId.eq(board.id)) .one(self.db().read()) .await? .ok_or_else(|| { IssueError::BadRequest("milestone does not belong to this board".to_string()) })?; } let full_text = format!("{} {}", title, cmd.body); let mentioned_uids = self .collect_mentioned_uids(&space, &full_text, caller) .await?; let now = OffsetDateTime::now_utc(); let issue_id = Uuid::now_v7(); let title_str = title.to_string(); let desc_opt = cmd.description.clone(); let body_str = cmd.body.clone(); let milestone_opt = cmd.milestone_id; let space_lower_for_txn = space.lower_name.clone(); let mentioned_for_txn = mentioned_uids.clone(); let this = self.clone(); let inserted = self .db() .primary() .transaction::<_, issue_item::Model, IssueError>(move |txn| { let title_clone = title_str.clone(); let mentioned_clone = mentioned_for_txn; let space_name_clone = space_lower_for_txn; Box::pin(async move { let _ = issues::Entity::find_by_id(board.id) .lock_exclusive() .one(txn) .await?; let max_row = issue_item::Entity::find() .filter(issue_item::Column::CollectionId.eq(board.id)) .order_by_desc(issue_item::Column::AutoincrementId) .one(txn) .await?; let next_num = max_row.map(|m| m.autoincrement_id + 1).unwrap_or(1); let row = issue_item::ActiveModel { id: Set(issue_id), collection_id: Set(board.id), autoincrement_id: Set(next_num), author_id: Set(caller), title: Set(title_clone.clone()), description: Set(desc_opt), body: Set(body_str), milestone_id: Set(milestone_opt), created_at: Set(now), updated_at: Set(None), closed_at: Set(None), }; let ins = row.insert(txn).await?; this.write_event( txn, issue_id, caller, "created", None, None, Some(title_clone.clone()), None, ) .await?; for m_uid in mentioned_clone { 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(models::issue::issue_member::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( models::issue::issue_member::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, "autoincrement_id": next_num, }); this.notify_user( txn, m_uid, NotificationType::Mention, format!("You were mentioned in issue #{next_num}: {title_clone}"), Some(title_clone.clone()), Some(data), Some(caller), ) .await?; } Ok(ins) }) }) .await?; if !mentioned_uids.is_empty() { let emails = self.primary_emails(&mentioned_uids).await?; for (_uid, addr) in emails { let subject = format!( "You were mentioned in issue #{}: {}", inserted.autoincrement_id, inserted.title ); let link = format!( "{}/issues/{}/{}/{}", self.config().web.web_base_url, space.lower_name, board.id, inserted.id ); let text = format!("{}\n\nView issue: {}", subject, link); let html = format!( r#"

{}

View Issue

"#, subject, link ); if let Err(err) = self.send_email(&addr, &subject, &text, &html).await { tracing::warn!(error = %err, "failed to send issue mention email"); } } } let user_map = self.load_user_briefs(&[inserted.author_id]).await?; let author = user_map.get(&inserted.author_id).cloned(); Ok(IssueDto { id: inserted.id, board_id: inserted.collection_id, autoincrement_id: inserted.autoincrement_id, author, title: inserted.title, description: inserted.description, body: inserted.body, milestone_id: inserted.milestone_id, state: IssueState::Open, created_at: inserted.created_at, updated_at: inserted.updated_at, closed_at: inserted.closed_at, }) } pub async fn list_issues( &self, caller: Uuid, space_name: &str, board_id: Uuid, q: ListIssueQuery, ) -> Result { 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 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 = issue_item::Entity::find() .filter(issue_item::Column::CollectionId.eq(board.id)); if let Some(state) = q.state { match state { IssueState::Open => { select = select.filter(issue_item::Column::ClosedAt.is_null()); } IssueState::Closed => { select = select.filter(issue_item::Column::ClosedAt.is_not_null()); } } } if let Some(author) = q.author_id { select = select.filter(issue_item::Column::AuthorId.eq(author)); } if let Some(milestone) = q.milestone_id { select = select.filter(issue_item::Column::MilestoneId.eq(Some(milestone))); } if let Some(ref search) = q.q { let kw = search.trim(); if !kw.is_empty() { select = select.filter(issue_item::Column::Title.contains(kw)); } } select = select.order_by_desc(issue_item::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 author_ids: Vec = rows.iter().map(|i| i.author_id).collect(); let user_map = self.load_user_briefs(&author_ids).await?; let items = rows .into_iter() .map(|i| { let author = user_map.get(&i.author_id).cloned(); let state = if i.closed_at.is_some() { IssueState::Closed } else { IssueState::Open }; IssueDto { id: i.id, board_id: i.collection_id, autoincrement_id: i.autoincrement_id, author, title: i.title, description: i.description, body: i.body, milestone_id: i.milestone_id, state, created_at: i.created_at, updated_at: i.updated_at, closed_at: i.closed_at, } }) .collect(); Ok(IssuePage { items, total }) } pub async fn get_issue( &self, caller: Uuid, space_name: &str, board_id: Uuid, issue_id: Uuid, ) -> Result { 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 user_map = self.load_user_briefs(&[item.author_id]).await?; let author = user_map.get(&item.author_id).cloned(); let state = if item.closed_at.is_some() { IssueState::Closed } else { IssueState::Open }; Ok(IssueDto { id: item.id, board_id: item.collection_id, autoincrement_id: item.autoincrement_id, author, title: item.title, description: item.description, body: item.body, milestone_id: item.milestone_id, state, created_at: item.created_at, updated_at: item.updated_at, closed_at: item.closed_at, }) } pub async fn update_issue( &self, caller: Uuid, space_name: &str, board_id: Uuid, issue_id: Uuid, cmd: UpdateIssueCmd, ) -> Result { 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 old_title = item.title.clone(); let old_milestone = item.milestone_id; let mut title_changed = None; if let Some(new_title) = cmd.title { let t = new_title.trim(); if t.is_empty() || t.chars().count() > self.config().issues.max_title_len { return Err(IssueError::BadRequest("issue title is empty or too long".to_string())); } if t != old_title { title_changed = Some(t.to_string()); } } if cmd.body.as_ref().is_some_and(|b| b.chars().count() > self.config().issues.max_body_len) { return Err(IssueError::BadRequest("issue body is too long".to_string())); } let mut milestone_changed = false; let mut new_milestone_val = old_milestone; if let Some(mid_opt) = cmd.milestone_id { if let Some(mid) = mid_opt { milestone::Entity::find_by_id(mid) .filter(milestone::Column::CollectionId.eq(board.id)) .one(self.db().read()) .await? .ok_or_else(|| { IssueError::BadRequest( "milestone does not belong to this board".to_string(), ) })?; } if mid_opt != old_milestone { milestone_changed = true; new_milestone_val = mid_opt; } } let now = OffsetDateTime::now_utc(); let mut am: issue_item::ActiveModel = item.into(); if let Some(ref t) = title_changed { am.title = Set(t.clone()); } if let Some(desc) = cmd.description { am.description = Set(desc); } if let Some(b) = cmd.body { am.body = Set(b); } if milestone_changed { am.milestone_id = Set(new_milestone_val); } am.updated_at = Set(Some(now)); let this = self.clone(); let updated = self .db() .primary() .transaction::<_, issue_item::Model, IssueError>(move |txn| { let title_change_clone = title_changed.clone(); Box::pin(async move { let u = am.update(txn).await?; if let Some(new_t) = title_change_clone { this.write_event( txn, issue_id, caller, "title_updated", None, Some(old_title), Some(new_t), None, ) .await?; } if milestone_changed { let old_name = if let Some(omid) = old_milestone { let m = milestone::Entity::find_by_id(omid).one(txn).await?; m.map(|m| m.name).unwrap_or_else(|| omid.to_string()) } else { "none".to_string() }; let new_name = if let Some(nmid) = new_milestone_val { let m = milestone::Entity::find_by_id(nmid).one(txn).await?; m.map(|m| m.name).unwrap_or_else(|| nmid.to_string()) } else { "none".to_string() }; let content = format!("{old_name} -> {new_name}"); this.write_event( txn, issue_id, caller, "milestone_updated", None, None, None, Some(content), ) .await?; } Ok(u) }) }) .await?; let user_map = self.load_user_briefs(&[updated.author_id]).await?; let author = user_map.get(&updated.author_id).cloned(); let state = if updated.closed_at.is_some() { IssueState::Closed } else { IssueState::Open }; Ok(IssueDto { id: updated.id, board_id: updated.collection_id, autoincrement_id: updated.autoincrement_id, author, title: updated.title, description: updated.description, body: updated.body, milestone_id: updated.milestone_id, state, created_at: updated.created_at, updated_at: updated.updated_at, closed_at: updated.closed_at, }) } pub async fn delete_issue( &self, caller: Uuid, space_name: &str, board_id: Uuid, issue_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?; self.db() .primary() .transaction::<_, (), IssueError>(|txn| { Box::pin(async move { delete_issue_cascade_in_txn(txn, issue_id).await?; Ok(()) }) }) .await?; Ok(()) } pub async fn close_issue( &self, caller: Uuid, space_name: &str, board_id: Uuid, issue_id: Uuid, closed: bool, ) -> Result { 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 is_currently_closed = item.closed_at.is_some(); if is_currently_closed == closed { let user_map = self.load_user_briefs(&[item.author_id]).await?; let author = user_map.get(&item.author_id).cloned(); let state = if is_currently_closed { IssueState::Closed } else { IssueState::Open }; return Ok(IssueDto { id: item.id, board_id: item.collection_id, autoincrement_id: item.autoincrement_id, author, title: item.title, description: item.description, body: item.body, milestone_id: item.milestone_id, state, created_at: item.created_at, updated_at: item.updated_at, closed_at: item.closed_at, }); } let now = OffsetDateTime::now_utc(); let mut am: issue_item::ActiveModel = item.into(); am.closed_at = Set(closed.then_some(now)); am.updated_at = Set(Some(now)); let event_type = if closed { "closed" } else { "reopened" }; let this = self.clone(); let updated = self .db() .primary() .transaction::<_, issue_item::Model, IssueError>(move |txn| { Box::pin(async move { let u = am.update(txn).await?; this.write_event( txn, issue_id, caller, event_type, None, None, None, None, ) .await?; Ok(u) }) }) .await?; let user_map = self.load_user_briefs(&[updated.author_id]).await?; let author = user_map.get(&updated.author_id).cloned(); let state = if updated.closed_at.is_some() { IssueState::Closed } else { IssueState::Open }; Ok(IssueDto { id: updated.id, board_id: updated.collection_id, autoincrement_id: updated.autoincrement_id, author, title: updated.title, description: updated.description, body: updated.body, milestone_id: updated.milestone_id, state, created_at: updated.created_at, updated_at: updated.updated_at, closed_at: updated.closed_at, }) } }