Skip to content
Clone
4c83001
765 lines 26.0 KB Rust
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<UserBriefDto>,
pub title: String,
pub description: Option<String>,
pub body: String,
pub milestone_id: Option<Uuid>,
pub state: IssueState,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
pub closed_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IssuePage {
pub items: Vec<IssueDto>,
pub total: i64,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateIssueCmd {
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub body: String,
#[serde(default)]
pub milestone_id: Option<Uuid>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateIssueCmd {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<Option<String>>,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub milestone_id: Option<Option<Uuid>>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct ListIssueQuery {
pub state: Option<IssueState>,
pub author_id: Option<Uuid>,
pub milestone_id: Option<Uuid>,
pub q: Option<String>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[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<Uuid> = 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<IssueDto, 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 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#"<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 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<IssuePage, 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 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<Uuid> = 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<IssueDto, 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 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<IssueDto, 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 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<IssueDto, 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 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,
})
}
}