use std::collections::HashMap; use models::issue::{issue_item, 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; 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, pub is_closed: bool, pub due_at: Option, pub issues_count: i64, pub closed_issues_count: i64, pub created_at: OffsetDateTime, pub updated_at: Option, } pub type MilestonePage = api::pagination::Page; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateMilestoneCmd { pub name: String, #[serde(default)] pub description: Option, #[serde( default, deserialize_with = "api::serde_datetime::deserialize_opt_datetime" )] pub due_at: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateMilestoneCmd { #[serde(default)] pub name: Option, #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")] pub description: Option>, #[serde( default, deserialize_with = "api::serde_datetime::deserialize_opt_opt_datetime" )] pub due_at: Option>, #[serde(default)] pub is_closed: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] #[serde(deny_unknown_fields)] pub struct ListMilestoneQuery { pub is_closed: Option, pub q: Option, pub limit: Option, pub offset: Option, } #[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, HashMap), 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> = milestone_ids.iter().copied().map(Some).collect(); let total_rows: Vec<(Option, 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.database.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, 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.database.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 { 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 { 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 name = cmd.name.trim(); if name.is_empty() || name.chars().count() > self.settings.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.settings.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.database.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 { 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.database.read()).await?; let rows = select .limit(limit) .offset(offset) .all(self.database.read()) .await?; let milestone_ids: Vec = 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 { 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.database.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 { 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 m = milestone::Entity::find_by_id(milestone_id) .filter(milestone::Column::CollectionId.eq(board.id)) .one(self.database.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.settings.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.settings.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.database.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?; authz::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.database.read()) .await? .ok_or(IssueError::NotFound)?; self.database .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::)) .exec(txn) .await?; milestone::Entity::delete_by_id(milestone_id) .exec(txn) .await?; Ok(()) }) }) .await?; Ok(()) } }