use models::issue::comment; use models::issue::issue_attachment; use models::issue::issue_content_history; use models::issue::issue_dependency; use models::issue::issue_event_line; use models::issue::issue_event_line::IssueEventType; use models::issue::issue_item; use models::issue::issue_item::IssuePriority; use models::issue::issue_label; 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::space::space_members::{self, MemberStatus}; 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::UserDto; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::IssueError; use crate::services::labels::LabelDto; use crate::services::service::IssueEventDetail; use crate::services::service::IssueService; use crate::share::authz::is_board_manager; use crate::share::lookup::{board_context, issue_context, lookup_issue_by_ref}; #[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 number: i32, pub author: Option, pub title: String, pub description: Option, pub content: String, pub milestone_id: Option, pub priority: IssuePriority, pub due_at: Option, pub assignees: Vec, pub viewer_watch: Option, pub state: IssueState, pub created_at: OffsetDateTime, pub updated_at: Option, pub closed_at: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueListItemDto { pub id: Uuid, pub board_id: Uuid, pub number: i32, pub author: Option, pub title: String, /// Chips the list rows render; the detail view fetches these separately. pub labels: Vec, pub milestone_id: Option, pub priority: IssuePriority, pub due_at: Option, pub assignees: Vec, pub state: IssueState, pub created_at: OffsetDateTime, pub updated_at: Option, pub closed_at: Option, } pub type IssuePage = export::pagination::Page; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateIssueCmd { pub title: String, #[serde(default)] pub description: Option, #[serde(default)] pub content: String, #[serde(default)] pub milestone_id: Option, #[serde(default)] pub priority: IssuePriority, #[serde( default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime" )] pub due_at: Option, #[serde(default)] pub assignee_ids: Vec, #[serde(default)] pub label_ids: Vec, /// Draft uploads (`POST /issues/attachments`) to link to the new issue. #[serde(default)] pub attachment_ids: Vec, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateIssueCmd { #[serde(default)] pub title: Option, #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub description: Option>, #[serde(default)] pub content: Option, #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub milestone_id: Option>, #[serde(default)] pub priority: Option, #[serde( default, deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime" )] pub due_at: Option>, #[serde(default)] pub assignee_ids: Option>, #[serde(default)] pub label_ids: Option>, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct ListIssueQuery { pub state: Option, pub author_id: Option, /// One id or a comma-separated id list (`a,b`); `none` matches issues /// without a milestone. Any of the listed values matches. pub milestone_id: Option, /// One id or a comma-separated id list (`a,b`); matches any of them. pub label_id: Option, /// One id or a comma-separated id list (`a,b`); matches any of them. pub assignee_id: Option, /// One priority or a comma-separated list (`high,urgent`). pub priority: Option, pub sort: Option, pub q: Option, pub limit: Option, pub offset: Option, } /// `a,b` / `a` / absent → parsed ids, rejecting anything malformed so a bad /// filter link answers 400 instead of silently listing everything. fn parse_filter_ids(raw: Option<&str>) -> Result, IssueError> { let Some(raw) = raw else { return Ok(Vec::new()); }; raw.split(',') .map(str::trim) .filter(|part| !part.is_empty()) .map(|part| { Uuid::parse_str(part) .map_err(|_| IssueError::BadRequest("invalid id in filter".to_string())) }) .collect() } fn parse_filter_priorities(raw: Option<&str>) -> Result, IssueError> { let Some(raw) = raw else { return Ok(Vec::new()); }; raw.split(',') .map(str::trim) .filter(|part| !part.is_empty()) .map(|part| { PRIORITY_KEYS .iter() .copied() .find(|(key, _)| *key == part) .map(|(_, priority)| priority) .ok_or_else(|| IssueError::BadRequest("invalid priority in filter".to_string())) }) .collect() } /// Milestones accept the extra `none` token, so "未设里程碑" is a server-side /// filter instead of a per-page client-side hack. fn parse_milestone_filter(raw: Option<&str>) -> Result<(Vec, bool), IssueError> { let Some(raw) = raw else { return Ok((Vec::new(), false)); }; let parts: Vec<&str> = raw .split(',') .map(str::trim) .filter(|part| !part.is_empty()) .collect(); let include_none = parts.contains(&"none"); let ids = parts .into_iter() .filter(|part| *part != "none") .map(|part| { Uuid::parse_str(part) .map_err(|_| IssueError::BadRequest("invalid id in filter".to_string())) }) .collect::, IssueError>>()?; Ok((ids, include_none)) } /// snake_case query keys, in the enum's own order — the one place the wire /// names for `?priority=` live. const PRIORITY_KEYS: [(&str, IssuePriority); 5] = [ ("none", IssuePriority::None), ("low", IssuePriority::Low), ("medium", IssuePriority::Medium), ("high", IssuePriority::High), ("urgent", IssuePriority::Urgent), ]; /// Inverse of {@link PRIORITY_KEYS}: the query string a caller should send. pub fn priority_query_key(priority: IssuePriority) -> &'static str { PRIORITY_KEYS .iter() .find(|(_, value)| *value == priority) .map(|(key, _)| *key) .unwrap_or("none") } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct IssuePath { pub space_name: String, pub board_id: Uuid, /// issue uuid or board-scoped number (e.g. "123") pub issue_id: String, } 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?; issue_attachment::Entity::delete_many() .filter(issue_attachment::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(crate) async fn load_assignee_map( &self, issue_ids: &[Uuid], ) -> Result>, IssueError> { let mut map = std::collections::HashMap::new(); if issue_ids.is_empty() { return Ok(map); } let rows = issue_member::Entity::find() .filter(issue_member::Column::IssueId.is_in(issue_ids.to_vec())) .filter(issue_member::Column::Kind.eq(issue_member::IssueMemberKind::Assignee)) .all(self.db().read()) .await?; let uids: Vec = rows.iter().map(|r| r.user_id).collect(); let user_map = self.load_user_briefs(&uids).await?; for r in rows { if let Some(brief) = user_map.get(&r.user_id) { map.entry(r.issue_id) .or_insert_with(Vec::new) .push(brief.clone()); } } Ok(map) } /// Labels per issue for one page of rows, in the board's own label order. pub(crate) async fn load_label_map( &self, issue_ids: &[Uuid], ) -> Result>, IssueError> { let mut map: std::collections::HashMap> = std::collections::HashMap::new(); if issue_ids.is_empty() { return Ok(map); } let rels = issue_relation_label::Entity::find() .filter(issue_relation_label::Column::IssueId.is_in(issue_ids.to_vec())) .all(self.db().read()) .await?; if rels.is_empty() { return Ok(map); } let label_ids: Vec = rels.iter().map(|r| r.label_id).collect(); let labels = issue_label::Entity::find() .filter(issue_label::Column::Id.is_in(label_ids)) .all(self.db().read()) .await?; let by_id: std::collections::HashMap = labels .into_iter() .map(|label| (label.id, LabelDto::from(label))) .collect(); for rel in rels { if let Some(label) = by_id.get(&rel.label_id) { map.entry(rel.issue_id).or_default().push(label.clone()); } } for rows in map.values_mut() { rows.sort_by(|a, b| a.name.cmp(&b.name)); } Ok(map) } pub(crate) async fn build_issue_dto( &self, caller: i64, item: issue_item::Model, ) -> Result { let user_map = self.load_user_briefs(&[item.author_id]).await?; let author = user_map.get(&item.author_id).cloned(); let assignees = self .load_assignee_map(&[item.id]) .await? .remove(&item.id) .unwrap_or_default(); let state = if item.closed_at.is_some() { IssueState::Closed } else { IssueState::Open }; let viewer_watch = issue_watch::Entity::find() .filter(issue_watch::Column::IssueId.eq(item.id)) .filter(issue_watch::Column::UserId.eq(caller)) .one(self.db().read()) .await? .map(|w| w.mode); Ok(IssueDto { viewer_watch, id: item.id, board_id: item.collection_id, number: item.autoincrement_id, author, title: item.title, description: item.description, content: item.body, milestone_id: item.milestone_id, priority: item.priority, due_at: item.due_at, assignees, state, created_at: item.created_at, updated_at: item.updated_at, closed_at: item.closed_at, }) } #[tracing::instrument(name = "issues.create_issue", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id))] pub async fn create_issue( &self, caller: i64, space_name: &str, board_id: Uuid, cmd: CreateIssueCmd, ) -> Result { if !self .state .redis .quota_allow( &format!("quota:issue_create:{caller}"), self.config().quota.issue_create_per_day, 86_400, ) .await { return Err(IssueError::RateLimited( "issue creation quota exceeded, retry tomorrow".to_string(), )); } let (space, board) = board_context(self, caller, space_name, 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.content.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()) })?; } for a_uid in &cmd.assignee_ids { let is_member = *a_uid == space.owner_id || space_members::Entity::find_by_id((space.id, *a_uid)) .one(self.db().read()) .await? .is_some_and(|m| m.status == MemberStatus::Active); if !is_member { return Err(IssueError::BadRequest(format!( "assignee {a_uid} is not a member of this space" ))); } } if !cmd.label_ids.is_empty() { let count = issue_label::Entity::find() .filter(issue_label::Column::Id.is_in(cmd.label_ids.clone())) .filter(issue_label::Column::DirId.eq(board.dir_id)) .count(self.db().read()) .await?; if count != cmd.label_ids.len() as u64 { return Err(IssueError::BadRequest( "one or more labels do not belong to this board".to_string(), )); } } let full_text = format!("{} {}", title, cmd.content); 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.content.clone(); let milestone_opt = cmd.milestone_id; let priority_val = cmd.priority; let due_val = cmd.due_at; let assignee_ids = cmd.assignee_ids.clone(); let label_ids = cmd.label_ids.clone(); let attachment_ids = cmd.attachment_ids.clone(); let space_lower_for_txn = space.lower_name.clone(); let mentioned_for_txn = mentioned_uids.clone(); let this = self.clone(); let pending = export::notify::PendingNotifications::default(); let pending_txn = pending.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), priority: Set(priority_val), due_at: Set(due_val), created_at: Set(now), updated_at: Set(None), closed_at: Set(None), }; let ins = row.insert(txn).await?; for a_uid in assignee_ids.clone() { if a_uid == caller { continue; } let member_row = issue_member::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(issue_id), user_id: Set(a_uid), kind: Set(issue_member::IssueMemberKind::Assignee), created_at: Set(now), updated_at: Set(Some(now)), }; member_row.insert(txn).await?; } for l_id in label_ids.clone() { let link = issue_relation_label::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(issue_id), label_id: Set(l_id), created_at: Set(now), }; link.insert(txn).await?; } IssueService::link_issue_attachments( txn, caller, issue_id, &attachment_ids, now, ) .await?; this.write_event( txn, issue_id, caller, IssueEventType::Created, IssueEventDetail { comment_id: None, old_title: None, new_title: Some(title_clone.clone()), content: 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, &pending_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?; pending.flush(&self.state.redis).await; if !mentioned_uids.is_empty() { let emails = self.primary_emails(&mentioned_uids).await?; for (uid, addr) in emails { let locale = self.recipient_locale(uid).await; let subject = i18n::catalog::issue_mention_subject( locale, 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!("{subject}\n\n{}: {link}", i18n::catalog::view_issue(locale)); let html = format!( r#"

{subject}

{}

"#, i18n::catalog::view_issue(locale) ); if let Err(err) = self.send_email(&addr, &subject, &text, &html).await { tracing::warn!(error = %err, "failed to send issue mention email"); } } } self.build_issue_dto(caller, inserted).await } pub async fn list_issues( &self, caller: i64, space_name: &str, board_id: Uuid, q: ListIssueQuery, ) -> Result { let (_, board) = board_context(self, caller, space_name, board_id).await?; let (limit, offset) = self.paginate(q.limit, q.offset); 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)); } let (milestone_ids, include_no_milestone) = parse_milestone_filter(q.milestone_id.as_deref())?; if include_no_milestone && !milestone_ids.is_empty() { select = select.filter( Condition::any() .add(issue_item::Column::MilestoneId.is_null()) .add(issue_item::Column::MilestoneId.is_in(milestone_ids)), ); } else if include_no_milestone { select = select.filter(issue_item::Column::MilestoneId.is_null()); } else if !milestone_ids.is_empty() { select = select.filter(issue_item::Column::MilestoneId.is_in(milestone_ids)); } if let Some(ref search) = q.q { let kw = search.trim(); if !kw.is_empty() { select = select.filter(issue_item::Column::Title.contains(kw)); } } let label_ids = parse_filter_ids(q.label_id.as_deref())?; if !label_ids.is_empty() { // Any-of: picking two labels widens the list, matching the panel's // checkbox semantics rather than intersecting them. let issue_ids: Vec = issue_relation_label::Entity::find() .filter(issue_relation_label::Column::LabelId.is_in(label_ids)) .all(self.db().read()) .await? .into_iter() .map(|r| r.issue_id) .collect(); select = select.filter(issue_item::Column::Id.is_in(issue_ids)); } let assignee_ids = parse_filter_ids(q.assignee_id.as_deref())?; if !assignee_ids.is_empty() { let issue_ids: Vec = issue_member::Entity::find() .filter(issue_member::Column::UserId.is_in(assignee_ids)) .filter(issue_member::Column::Kind.eq(issue_member::IssueMemberKind::Assignee)) .all(self.db().read()) .await? .into_iter() .map(|r| r.issue_id) .collect(); select = select.filter(issue_item::Column::Id.is_in(issue_ids)); } let priorities = parse_filter_priorities(q.priority.as_deref())?; if !priorities.is_empty() { select = select.filter(issue_item::Column::Priority.is_in(priorities)); } match q.sort.as_deref() { Some("updated_at") => select = select.order_by_desc(issue_item::Column::UpdatedAt), Some("due_at") => select = select.order_by_asc(issue_item::Column::DueAt), Some("priority") => select = select.order_by_desc(issue_item::Column::Priority), Some("number") => select = select.order_by_asc(issue_item::Column::AutoincrementId), _ => 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 issue_ids: Vec = rows.iter().map(|i| i.id).collect(); let assignee_map = self.load_assignee_map(&issue_ids).await?; let label_map = self.load_label_map(&issue_ids).await?; let items = rows .into_iter() .map(|i| { let author = user_map.get(&i.author_id).cloned(); let assignees = assignee_map.get(&i.id).cloned().unwrap_or_default(); let labels = label_map.get(&i.id).cloned().unwrap_or_default(); let state = if i.closed_at.is_some() { IssueState::Closed } else { IssueState::Open }; IssueListItemDto { id: i.id, board_id: i.collection_id, number: i.autoincrement_id, author, title: i.title, labels, milestone_id: i.milestone_id, priority: i.priority, due_at: i.due_at, assignees, state, created_at: i.created_at, updated_at: i.updated_at, closed_at: i.closed_at, } }) .collect(); Ok(IssuePage { items, total, limit, offset, }) } pub async fn get_issue( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, ) -> Result { let (_, _, item) = issue_context(self, caller, space_name, board_id, issue_id).await?; self.build_issue_dto(caller, item).await } #[tracing::instrument(name = "issues.update_issue", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id))] pub async fn update_issue( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, cmd: UpdateIssueCmd, ) -> Result { let (space, board, item) = issue_context(self, caller, space_name, board_id, issue_id).await?; if item.author_id != caller && !is_board_manager(self, &space, caller).await? { return Err(IssueError::Forbidden); } let old_title = item.title.clone(); let old_description = item.description.clone(); let old_body = item.body.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 .content .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; } } for a_uid in cmd.assignee_ids.iter().flatten() { let is_member = *a_uid == space.owner_id || space_members::Entity::find_by_id((space.id, *a_uid)) .one(self.db().read()) .await? .is_some_and(|m| m.status == MemberStatus::Active); if !is_member { return Err(IssueError::BadRequest(format!( "assignee {a_uid} is not a member of this space" ))); } } if let Some(label_ids) = &cmd.label_ids && !label_ids.is_empty() { let count = issue_label::Entity::find() .filter(issue_label::Column::Id.is_in(label_ids.clone())) .filter(issue_label::Column::DirId.eq(board.dir_id)) .count(self.db().read()) .await?; if count != label_ids.len() as u64 { return Err(IssueError::BadRequest( "one or more labels do not belong to this board".to_string(), )); } } let now = OffsetDateTime::now_utc(); let mut description_changed: Option> = None; let mut body_changed: Option = None; let mut am: issue_item::ActiveModel = item.into(); if let Some(priority) = cmd.priority { am.priority = Set(priority); } if let Some(due) = cmd.due_at { am.due_at = Set(due); } if let Some(ref t) = title_changed { am.title = Set(t.clone()); } if let Some(desc) = cmd.description { if desc != old_description { description_changed = Some(desc.clone()); } am.description = Set(desc); } if let Some(b) = cmd.content { if b != old_body { body_changed = Some(b.clone()); } am.body = Set(b); } if milestone_changed { am.milestone_id = Set(new_milestone_val); } am.updated_at = Set(Some(now)); let assignee_ids_val = cmd.assignee_ids.clone(); let label_ids_val = cmd.label_ids.clone(); let this = self.clone(); let updated = self .db() .primary() .transaction::<_, issue_item::Model, IssueError>(move |txn| { let title_change_clone = title_changed.clone(); let desc_change_clone = description_changed.clone(); let body_change_clone = body_changed.clone(); let assignee_replace = assignee_ids_val.clone(); let label_replace = label_ids_val.clone(); Box::pin(async move { let u = am.update(txn).await?; if let Some(new_assignees) = assignee_replace { issue_member::Entity::delete_many() .filter(issue_member::Column::IssueId.eq(u.id)) .filter( issue_member::Column::Kind .eq(issue_member::IssueMemberKind::Assignee), ) .exec(txn) .await?; for a_uid in new_assignees { if a_uid == u.author_id { continue; } let member_row = issue_member::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(u.id), user_id: Set(a_uid), kind: Set(issue_member::IssueMemberKind::Assignee), created_at: Set(OffsetDateTime::now_utc()), updated_at: Set(Some(OffsetDateTime::now_utc())), }; member_row.insert(txn).await?; } } if let Some(new_labels) = label_replace { let old_links = issue_relation_label::Entity::find() .filter(issue_relation_label::Column::IssueId.eq(u.id)) .all(txn) .await?; let old_set: std::collections::HashSet = old_links.iter().map(|l| l.label_id).collect(); let new_set: std::collections::HashSet = new_labels.iter().cloned().collect(); for l in old_links { if !new_set.contains(&l.label_id) { issue_relation_label::Entity::delete_by_id(l.id) .exec(txn) .await?; } } for lid in &new_labels { if old_set.contains(lid) { continue; } let link = issue_relation_label::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(u.id), label_id: Set(*lid), created_at: Set(OffsetDateTime::now_utc()), }; link.insert(txn).await?; } } if let Some(new_t) = title_change_clone { this.write_event( txn, issue_id, caller, IssueEventType::TitleUpdated, IssueEventDetail { comment_id: None, old_title: Some(old_title.clone()), new_title: Some(new_t.clone()), content: None, }, ) .await?; issue_content_history::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(issue_id), poster_id: Set(caller), comment_id: Set(None), content_before: Set(Some(old_title)), content_after: Set(Some(new_t)), edited_at: Set(now), created_at: Set(now), } .insert(txn) .await?; } if let Some(new_desc) = desc_change_clone { issue_content_history::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(issue_id), poster_id: Set(caller), comment_id: Set(None), content_before: Set(old_description), content_after: Set(new_desc), edited_at: Set(now), created_at: Set(now), } .insert(txn) .await?; } if let Some(new_body) = body_change_clone { issue_content_history::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(issue_id), poster_id: Set(caller), comment_id: Set(None), content_before: Set(Some(old_body)), content_after: Set(Some(new_body)), edited_at: Set(now), created_at: Set(now), } .insert(txn) .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, IssueEventType::MilestoneUpdated, IssueEventDetail { comment_id: None, old_title: None, new_title: None, content: Some(content), }, ) .await?; } Ok(u) }) }) .await?; self.build_issue_dto(caller, updated).await } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum BulkAction { Close, Reopen, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct BulkIssuesCmd { pub action: BulkAction, pub issue_refs: Vec, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct BulkResultDto { pub updated: u64, } impl IssueService { #[tracing::instrument(name = "issues.bulk_update_state", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id))] pub async fn bulk_update_state( &self, caller: i64, space_name: &str, board_id: Uuid, cmd: BulkIssuesCmd, ) -> Result { if cmd.issue_refs.is_empty() || cmd.issue_refs.len() > 100 { return Err(IssueError::BadRequest( "issue_refs must contain 1..=100 entries".to_string(), )); } let (space, board) = board_context(self, caller, space_name, board_id).await?; let is_manager = is_board_manager(self, &space, caller).await?; let mut updated = 0u64; for issue_ref in cmd.issue_refs { let Ok(item) = lookup_issue_by_ref(self, &board, &issue_ref).await else { continue; }; let is_closed = item.closed_at.is_some(); let should_close = matches!(cmd.action, BulkAction::Close); if is_closed == should_close { continue; } if item.author_id != caller && !is_manager { continue; } let now = OffsetDateTime::now_utc(); issue_item::Entity::update_many() .col_expr( issue_item::Column::ClosedAt, sea_orm::sea_query::Expr::value(should_close.then_some(now)), ) .col_expr( issue_item::Column::UpdatedAt, sea_orm::sea_query::Expr::value(now), ) .filter(issue_item::Column::Id.eq(item.id)) .exec(self.db().write()) .await?; self.write_event( self.db().write(), item.id, caller, if should_close { IssueEventType::Closed } else { IssueEventType::Reopened }, IssueEventDetail::default(), ) .await?; updated += 1; } Ok(BulkResultDto { updated }) } } impl IssueService { #[tracing::instrument(name = "issues.delete_issue", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id))] pub async fn delete_issue( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, ) -> Result<(), IssueError> { let (space, _, item) = issue_context(self, caller, space_name, board_id, issue_id).await?; if item.author_id != caller && !is_board_manager(self, &space, caller).await? { return Err(IssueError::Forbidden); } // Objects live outside the transaction: collect the keys first, drop // the rows in the cascade, then clean storage best effort. let attachment_keys = IssueService::issue_attachment_keys(self.db().read(), issue_id).await?; self.db() .primary() .transaction::<_, (), IssueError>(|txn| { Box::pin(async move { delete_issue_cascade_in_txn(txn, issue_id).await?; Ok(()) }) }) .await?; for key in attachment_keys { if let Err(err) = self.state.storage.client().delete(&key).await { tracing::warn!(error = %err, key = %key, "failed to delete issue attachment object"); } } Ok(()) } #[tracing::instrument(name = "issues.close_issue", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id))] pub async fn close_issue( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, closed: bool, ) -> Result { let (space, _, item) = issue_context(self, caller, space_name, board_id, issue_id).await?; if item.author_id != caller && !is_board_manager(self, &space, caller).await? { return Err(IssueError::Forbidden); } let is_currently_closed = item.closed_at.is_some(); if is_currently_closed == closed { return self.build_issue_dto(caller, item).await; } 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 { IssueEventType::Closed } else { IssueEventType::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, IssueEventDetail::default(), ) .await?; Ok(u) }) }) .await?; self.build_issue_dto(caller, updated).await } }