use api::users::UserDto; use models::issue::issue_event_line::IssueEventType; 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::MemberStatus; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::IssueError; use crate::services::service::{IssueEventDetail, IssueService}; use crate::share::authz; use crate::share::lookup::{lookup_board, lookup_issue, require_issue_access, space_context}; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct IssueMemberDto { pub id: Uuid, pub issue_id: Uuid, pub user: Option, pub kind: IssueMemberKind, pub created_at: OffsetDateTime, pub updated_at: Option, } pub type IssueMemberPage = api::pagination::Page; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateIssueMemberCmd { pub user_id: i64, pub kind: IssueMemberKind, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] #[serde(deny_unknown_fields)] pub struct ListIssueMemberQuery { pub kind: Option, pub limit: Option, pub offset: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct IssueMemberPath { pub space_name: String, pub board_id: Uuid, pub issue_id: String, pub member_id: Uuid, } impl IssueService { #[tracing::instrument(name = "issues.create_member", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id))] pub async fn create_member( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, cmd: CreateIssueMemberCmd, ) -> Result { let space = space_context(self, caller, space_name).await?; authz::require_board_manage(self, &space, caller).await?; let board = lookup_board(self, &space, board_id, caller).await?; let item = lookup_issue(self, &board, issue_id).await?; let target_is_member = if cmd.user_id == space.owner() { true } else { self.access .member(space.id(), cmd.user_id) .await? .is_some_and(|m| m.status() == MemberStatus::Active) }; if !target_is_member { return Err(IssueError::Forbidden); } let existing = issue_member::Entity::find() .filter(issue_member::Column::IssueId.eq(issue_id)) .filter(issue_member::Column::UserId.eq(cmd.user_id)) .filter(issue_member::Column::Kind.eq(cmd.kind)) .one(self.database.read()) .await?; if existing.is_some() { return Err(IssueError::Conflict( "member already exists with this role".to_string(), )); } let now = OffsetDateTime::now_utc(); let member_id = Uuid::now_v7(); let kind_enum = cmd.kind; let target_uid = cmd.user_id; let this = self.clone(); let item_title_for_txn = item.title.clone(); let space_lower_for_txn = space.slug().to_owned(); let pending = push::notify::PendingNotifications::default(); let pending_txn = pending.clone(); let inserted = self.database .primary() .transaction::<_, issue_member::Model, IssueError>(move |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 = issue_member::ActiveModel { id: Set(member_id), issue_id: Set(issue_id), user_id: Set(target_uid), kind: Set(kind_enum), created_at: Set(now), updated_at: Set(None), }; let ins = row.insert(txn).await?; let content_str = format!("{:?} {}", kind_enum, target_uid); this.write_event( txn, issue_id, caller, IssueEventType::MemberAdded, IssueEventDetail { comment_id: None, old_title: None, new_title: None, content: Some(content_str), }, ) .await?; if kind_enum == IssueMemberKind::Assignee && target_uid != caller { let data = serde_json::json!({ "space_name": space_name_clone, "board_id": board.id, "issue_id": issue_id, "autoincrement_id": issue_num, }); this.audience.notify_user( txn, &pending_txn, target_uid, NotificationType::Assignment, format!("You were assigned to issue #{issue_num}: {issue_title_clone}"), Some(issue_title_clone.clone()), Some(data), Some(caller), ) .await?; } Ok(ins) }) }) .await?; pending.flush(&self.audience.notifications).await; if kind_enum == IssueMemberKind::Assignee && target_uid != caller { let emails = self.audience.primary_emails(&[target_uid]).await?; if let Some(addr) = emails.get(&target_uid) { let locale = self.audience.recipient_locale(target_uid).await; let subject = i18n::catalog::issue_assigned_subject( locale, item.autoincrement_id, &item.title, ); let link = format!( "{}/issues/{}/{}/{}", self.settings.web.web_base_url, space.slug(), board.id, item.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.audience.send_email(addr, &subject, &text, &html).await { tracing::warn!(error = %err, "failed to send issue assignment email"); } } } let user_map = self.audience.load_user_briefs(&[cmd.user_id]).await?; let user_brief = user_map.get(&cmd.user_id).cloned(); Ok(IssueMemberDto { id: inserted.id, issue_id: inserted.issue_id, user: user_brief, kind: inserted.kind, created_at: inserted.created_at, updated_at: inserted.updated_at, }) } pub async fn list_members( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, q: ListIssueMemberQuery, ) -> Result { require_issue_access(self, caller, space_name, board_id, issue_id).await?; let (limit, offset) = self.paginate(q.limit, q.offset)?; let mut select = issue_member::Entity::find().filter(issue_member::Column::IssueId.eq(issue_id)); if let Some(kind) = q.kind { select = select.filter(issue_member::Column::Kind.eq(kind)); } select = select.order_by_asc(issue_member::Column::CreatedAt); let total = select.clone().count(self.database.read()).await?; let rows = select .limit(limit) .offset(offset) .all(self.database.read()) .await?; let user_ids: Vec = rows.iter().map(|m| m.user_id).collect(); let user_map = self.audience.load_user_briefs(&user_ids).await?; let items = rows .into_iter() .map(|m| { let user = user_map.get(&m.user_id).cloned(); IssueMemberDto { id: m.id, issue_id: m.issue_id, user, kind: m.kind, created_at: m.created_at, updated_at: m.updated_at, } }) .collect(); Ok(IssueMemberPage { items, total, limit, offset, }) } #[tracing::instrument(name = "issues.delete_member", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, member_id = %member_id))] pub async fn delete_member( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, member_id: Uuid, ) -> Result<(), IssueError> { let space = space_context(self, caller, space_name).await?; authz::require_board_manage(self, &space, caller).await?; let board = lookup_board(self, &space, board_id, caller).await?; let _ = lookup_issue(self, &board, issue_id).await?; let m = issue_member::Entity::find_by_id(member_id) .filter(issue_member::Column::IssueId.eq(issue_id)) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; let content_str = format!("{:?} {}", m.kind, m.user_id); let this = self.clone(); self.database .primary() .transaction::<_, (), IssueError>(move |txn| { Box::pin(async move { issue_member::Entity::delete_by_id(member_id) .exec(txn) .await?; this.write_event( txn, issue_id, caller, IssueEventType::MemberRemoved, IssueEventDetail { comment_id: None, old_title: None, new_title: None, content: Some(content_str), }, ) .await?; Ok(()) }) }) .await?; Ok(()) } }