ef0b83a
use models::issue::issue_event_line::IssueEventType;
use models::issue::issue_member::{self, IssueMemberKind};
use models::space::MemberStatus;
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::dto::UserDto;
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<UserDto>,
pub kind: IssueMemberKind,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
}
pub type IssueMemberPage = export::pagination::Page<IssueMemberDto>;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateIssueMemberCmd {
pub user_id: i64,
pub kind: IssueMemberKind,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct ListIssueMemberQuery {
pub kind: Option<IssueMemberKind>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[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<IssueMemberDto, 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 item = lookup_issue(self, &board, issue_id).await?;
let target_is_member = if cmd.user_id == space.owner_user_id {
true
} else {
space::authority::member(&self.state, space.owner_user_id, 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.db().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.clone();
let pending = export::notify::PendingNotifications::default();
let pending_txn = pending.clone();
let inserted = self
.db()
.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.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.state).await;
if kind_enum == IssueMemberKind::Assignee && target_uid != caller {
let emails = self.primary_emails(&[target_uid]).await?;
if let Some(addr) = emails.get(&target_uid) {
let locale = self.recipient_locale(target_uid).await;
let subject = i18n::catalog::issue_assigned_subject(
locale,
item.autoincrement_id,
&item.title,
);
let link = format!(
"{}/issues/{}/{}/{}",
self.config().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#"<p>{subject}</p><p><a href="{link}">{}</a></p>"#,
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 assignment email");
}
}
}
let user_map = self.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<IssueMemberPage, IssueError> {
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.db().read()).await? as i64;
let rows = select
.limit(limit)
.offset(offset)
.all(self.db().read())
.await?;
let user_ids: Vec<i64> = rows.iter().map(|m| m.user_id).collect();
let user_map = self.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.db().read())
.await?
.ok_or(IssueError::NotFound)?;
let content_str = format!("{:?} {}", m.kind, m.user_id);
let this = self.clone();
self.db()
.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(())
}
}