Skip to content
Clone
194c44c
376 lines 12.6 KB Rust
use std::collections::HashMap;
use models::issue::issue_item;
use models::issue::milestone;
use sea_orm::sea_query::Expr;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::errors::IssueError;
use crate::services::service::IssueService;
use crate::share::authz::require_board_manage;
use crate::share::lookup::{board_context, lookup_board, space_context};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MilestoneDto {
pub id: Uuid,
pub board_id: Uuid,
pub name: String,
pub description: Option<String>,
pub is_closed: bool,
pub due_at: Option<OffsetDateTime>,
pub issues_count: i64,
pub closed_issues_count: i64,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
}
pub type MilestonePage = export::pagination::Page<MilestoneDto>;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateMilestoneCmd {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(
default,
deserialize_with = "export::serde_datetime::deserialize_opt_datetime"
)]
pub due_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateMilestoneCmd {
#[serde(default)]
pub name: Option<String>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub description: Option<Option<String>>,
#[serde(
default,
deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime"
)]
pub due_at: Option<Option<OffsetDateTime>>,
#[serde(default)]
pub is_closed: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct ListMilestoneQuery {
pub is_closed: Option<bool>,
pub q: Option<String>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct MilestonePath {
pub space_name: String,
pub board_id: Uuid,
pub milestone_id: Uuid,
}
impl IssueService {
fn milestone_dto(
m: milestone::Model,
issues_count: i64,
closed_issues_count: i64,
) -> MilestoneDto {
MilestoneDto {
id: m.id,
board_id: m.collection_id,
name: m.name,
description: m.description,
is_closed: m.is_closed,
due_at: m.due_at,
issues_count,
closed_issues_count,
created_at: m.created_at,
updated_at: m.updated_at,
}
}
async fn issue_counts_by_milestone(
&self,
milestone_ids: &[Uuid],
) -> Result<(HashMap<Uuid, i64>, HashMap<Uuid, i64>), IssueError> {
let mut total_counts = HashMap::new();
let mut closed_counts = HashMap::new();
if milestone_ids.is_empty() {
return Ok((total_counts, closed_counts));
}
let opt_ids: Vec<Option<Uuid>> = milestone_ids.iter().copied().map(Some).collect();
let total_rows: Vec<(Option<Uuid>, i64)> = issue_item::Entity::find()
.select_only()
.column(issue_item::Column::MilestoneId)
.column_as(issue_item::Column::MilestoneId.count(), "cnt")
.filter(issue_item::Column::MilestoneId.is_in(opt_ids.clone()))
.group_by(issue_item::Column::MilestoneId)
.into_tuple()
.all(self.db().read())
.await?;
for (milestone_id, cnt) in total_rows {
if let Some(mid) = milestone_id {
total_counts.insert(mid, cnt);
}
}
let closed_rows: Vec<(Option<Uuid>, i64)> = issue_item::Entity::find()
.select_only()
.column(issue_item::Column::MilestoneId)
.column_as(issue_item::Column::MilestoneId.count(), "cnt")
.filter(issue_item::Column::MilestoneId.is_in(opt_ids))
.filter(issue_item::Column::ClosedAt.is_not_null())
.group_by(issue_item::Column::MilestoneId)
.into_tuple()
.all(self.db().read())
.await?;
for (milestone_id, cnt) in closed_rows {
if let Some(mid) = milestone_id {
closed_counts.insert(mid, cnt);
}
}
Ok((total_counts, closed_counts))
}
async fn build_milestone_dto(&self, m: milestone::Model) -> Result<MilestoneDto, IssueError> {
let (total_counts, closed_counts) = self.issue_counts_by_milestone(&[m.id]).await?;
let nums_issues = total_counts.get(&m.id).copied().unwrap_or(0);
let nums_closed_issues = closed_counts.get(&m.id).copied().unwrap_or(0);
Ok(Self::milestone_dto(m, nums_issues, nums_closed_issues))
}
#[tracing::instrument(name = "issues.create_milestone", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id))]
pub async fn create_milestone(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
cmd: CreateMilestoneCmd,
) -> Result<MilestoneDto, IssueError> {
let space = space_context(self, caller, space_name).await?;
require_board_manage(self, &space, caller).await?;
let board = lookup_board(self, &space, board_id, caller).await?;
let name = cmd.name.trim();
if name.is_empty() || name.chars().count() > self.config().issues.max_milestone_title_len {
return Err(IssueError::BadRequest(
"milestone name is empty or too long".to_string(),
));
}
if cmd
.description
.as_ref()
.is_some_and(|d| d.chars().count() > self.config().issues.max_milestone_description_len)
{
return Err(IssueError::BadRequest(
"milestone description is too long".to_string(),
));
}
let now = OffsetDateTime::now_utc();
let row = milestone::ActiveModel {
id: Set(Uuid::now_v7()),
collection_id: Set(board.id),
name: Set(name.to_string()),
description: Set(cmd.description),
is_closed: Set(false),
due_at: Set(cmd.due_at),
nums_issues: Set(0),
nums_closed_issues: Set(0),
created_at: Set(now),
updated_at: Set(None),
};
let inserted = row.insert(self.db().write()).await?;
Ok(MilestoneDto {
id: inserted.id,
board_id: inserted.collection_id,
name: inserted.name,
description: inserted.description,
is_closed: inserted.is_closed,
due_at: inserted.due_at,
issues_count: 0,
closed_issues_count: 0,
created_at: inserted.created_at,
updated_at: inserted.updated_at,
})
}
pub async fn list_milestones(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
q: ListMilestoneQuery,
) -> Result<MilestonePage, IssueError> {
let (_, board) = board_context(self, caller, space_name, board_id).await?;
let (limit, offset) = self.paginate(q.limit, q.offset);
let mut select =
milestone::Entity::find().filter(milestone::Column::CollectionId.eq(board.id));
if let Some(closed) = q.is_closed {
select = select.filter(milestone::Column::IsClosed.eq(closed));
}
if let Some(ref search) = q.q {
let kw = search.trim();
if !kw.is_empty() {
select = select.filter(milestone::Column::Name.contains(kw));
}
}
select = select.order_by_desc(milestone::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 milestone_ids: Vec<Uuid> = rows.iter().map(|m| m.id).collect();
let (total_counts, closed_counts) = self.issue_counts_by_milestone(&milestone_ids).await?;
let items = rows
.into_iter()
.map(|m| {
let nums_issues = total_counts.get(&m.id).copied().unwrap_or(0);
let nums_closed_issues = closed_counts.get(&m.id).copied().unwrap_or(0);
Self::milestone_dto(m, nums_issues, nums_closed_issues)
})
.collect();
Ok(MilestonePage {
items,
total,
limit,
offset,
})
}
pub async fn get_milestone(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
milestone_id: Uuid,
) -> Result<MilestoneDto, IssueError> {
let (_, board) = board_context(self, caller, space_name, board_id).await?;
let m = milestone::Entity::find_by_id(milestone_id)
.filter(milestone::Column::CollectionId.eq(board.id))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
self.build_milestone_dto(m).await
}
#[tracing::instrument(name = "issues.update_milestone", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, milestone_id = %milestone_id))]
pub async fn update_milestone(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
milestone_id: Uuid,
cmd: UpdateMilestoneCmd,
) -> Result<MilestoneDto, IssueError> {
let space = space_context(self, caller, space_name).await?;
require_board_manage(self, &space, caller).await?;
let board = lookup_board(self, &space, board_id, caller).await?;
let m = milestone::Entity::find_by_id(milestone_id)
.filter(milestone::Column::CollectionId.eq(board.id))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
let mut am: milestone::ActiveModel = m.into();
if let Some(new_name) = cmd.name {
let name = new_name.trim();
if name.is_empty()
|| name.chars().count() > self.config().issues.max_milestone_title_len
{
return Err(IssueError::BadRequest(
"milestone name is empty or too long".to_string(),
));
}
am.name = Set(name.to_string());
}
if let Some(desc) = cmd.description {
if desc.as_ref().is_some_and(|d| {
d.chars().count() > self.config().issues.max_milestone_description_len
}) {
return Err(IssueError::BadRequest(
"milestone description is too long".to_string(),
));
}
am.description = Set(desc);
}
if let Some(due) = cmd.due_at {
am.due_at = Set(due);
}
if let Some(closed) = cmd.is_closed {
am.is_closed = Set(closed);
}
let now = OffsetDateTime::now_utc();
am.updated_at = Set(Some(now));
let updated = am.update(self.db().write()).await?;
self.build_milestone_dto(updated).await
}
#[tracing::instrument(name = "issues.delete_milestone", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, milestone_id = %milestone_id))]
pub async fn delete_milestone(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
milestone_id: Uuid,
) -> Result<(), IssueError> {
let space = space_context(self, caller, space_name).await?;
require_board_manage(self, &space, caller).await?;
let board = lookup_board(self, &space, board_id, caller).await?;
let _ = milestone::Entity::find_by_id(milestone_id)
.filter(milestone::Column::CollectionId.eq(board.id))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
self.db()
.primary()
.transaction::<_, (), IssueError>(|txn| {
Box::pin(async move {
issue_item::Entity::update_many()
.filter(issue_item::Column::MilestoneId.eq(Some(milestone_id)))
.col_expr(issue_item::Column::MilestoneId, Expr::value(None::<Uuid>))
.exec(txn)
.await?;
milestone::Entity::delete_by_id(milestone_id)
.exec(txn)
.await?;
Ok(())
})
})
.await?;
Ok(())
}
}