use std::collections::HashMap; use models::issue::{issue_item, issue_pin, issues, milestone}; 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 BoardDto { pub id: Uuid, pub name: String, /// Number prefix for the board's issues (`CAD` renders as `CAD-12`). pub prefix: Option, pub icon: Option, pub color: Option, pub description: Option, pub position: i64, pub open_issues_count: i64, pub closed_issues_count: i64, pub created_at: OffsetDateTime, pub updated_at: Option, } pub type BoardPage = api::pagination::Page; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateBoardCmd { pub name: String, #[serde(default)] pub prefix: Option, #[serde(default)] pub icon: Option, #[serde(default)] pub description: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateBoardCmd { #[serde(default)] pub name: Option, #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")] pub prefix: Option>, #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")] pub icon: Option>, #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")] pub description: Option>, #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")] pub color: Option>, #[serde(default)] pub position: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] #[serde(deny_unknown_fields)] pub struct ListBoardQuery { pub q: Option, pub limit: Option, pub offset: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct BoardPath { pub space_name: String, pub board_id: Uuid, } impl IssueService { /// `None`/blank clears the prefix; anything else is upper-cased and must /// look like a GitLab-style project key (letter first, alphanumeric only). fn normalize_board_prefix(&self, raw: &str) -> Result, IssueError> { let trimmed = raw.trim(); if trimmed.is_empty() { return Ok(None); } let prefix = trimmed.to_ascii_uppercase(); let first_is_letter = prefix.starts_with(|c: char| c.is_ascii_alphabetic()); let valid = first_is_letter && prefix.chars().count() <= self.settings.issues.max_board_prefix_len && prefix.chars().all(|c| c.is_ascii_alphanumeric()); if !valid { return Err(IssueError::BadRequest( "board prefix must start with a letter and contain only letters and digits" .to_string(), )); } Ok(Some(prefix)) } /// Prefixes address issues (`CAD-12`), so two boards of one space must not /// share one — the ambiguity would be permanent in every pasted link. async fn board_prefix_taken( &self, space_id: Uuid, prefix: &str, exclude_board: Option, ) -> Result { let mut select = issues::Entity::find() .filter(issues::Column::SpaceId.eq(space_id)) .filter(issues::Column::Prefix.eq(prefix)); if let Some(board_id) = exclude_board { select = select.filter(issues::Column::Id.ne(board_id)); } Ok(select.one(self.database.read()).await?.is_some()) } fn board_dto( board: issues::Model, open_issues_count: i64, closed_issues_count: i64, ) -> BoardDto { BoardDto { id: board.id, name: board.name, prefix: board.prefix, icon: board.icon, color: board.color, description: board.description, position: board.position, open_issues_count, closed_issues_count, created_at: board.created_at, updated_at: board.updated_at, } } async fn issue_counts_by_board( &self, board_ids: &[Uuid], ) -> Result<(HashMap, HashMap), IssueError> { let mut total_counts = HashMap::new(); let mut closed_counts = HashMap::new(); if board_ids.is_empty() { return Ok((total_counts, closed_counts)); } let total_rows: Vec<(Uuid, i64)> = issue_item::Entity::find() .select_only() .column(issue_item::Column::CollectionId) .column_as(issue_item::Column::CollectionId.count(), "cnt") .filter(issue_item::Column::CollectionId.is_in(board_ids.to_vec())) .group_by(issue_item::Column::CollectionId) .into_tuple() .all(self.database.read()) .await?; for (collection_id, cnt) in total_rows { total_counts.insert(collection_id, cnt); } let closed_rows: Vec<(Uuid, i64)> = issue_item::Entity::find() .select_only() .column(issue_item::Column::CollectionId) .column_as(issue_item::Column::CollectionId.count(), "cnt") .filter(issue_item::Column::CollectionId.is_in(board_ids.to_vec())) .filter(issue_item::Column::ClosedAt.is_not_null()) .group_by(issue_item::Column::CollectionId) .into_tuple() .all(self.database.read()) .await?; for (collection_id, cnt) in closed_rows { closed_counts.insert(collection_id, cnt); } Ok((total_counts, closed_counts)) } async fn build_board_dto(&self, board: issues::Model) -> Result { let (total_counts, closed_counts) = self.issue_counts_by_board(&[board.id]).await?; let closed_count = closed_counts.get(&board.id).copied().unwrap_or(0); let open_count = total_counts.get(&board.id).copied().unwrap_or(0) - closed_count; Ok(Self::board_dto(board, open_count, closed_count)) } #[tracing::instrument(name = "issues.create_board", skip_all, fields(caller = %caller, space = %space_name))] pub async fn create_board( &self, caller: i64, space_name: &str, cmd: CreateBoardCmd, ) -> Result { let space = space_context(self, caller, space_name).await?; authz::require_board_manage(self, &space, caller).await?; let name = cmd.name.trim(); if name.is_empty() || name.chars().count() > self.settings.issues.max_board_name_len { return Err(IssueError::BadRequest( "board name is empty or too long".to_string(), )); } let prefix = match cmd.prefix.as_deref() { Some(raw) => self.normalize_board_prefix(raw)?, None => None, }; if let Some(ref prefix) = prefix && self.board_prefix_taken(space.id(), prefix, None).await? { return Err(IssueError::BadRequest( "board prefix is already used by another board".to_string(), )); } let position = issues::Entity::find() .filter(issues::Column::SpaceId.eq(space.id())) .order_by_desc(issues::Column::Position) .one(self.database.read()) .await? .map(|row| row.position + 1) .unwrap_or(0); let board = { let txn = self.database.write().begin().await?; let now = OffsetDateTime::now_utc(); let board_row = issues::ActiveModel { id: Set(Uuid::now_v7()), space_id: Set(space.id()), name: Set(name.to_string()), prefix: Set(prefix), icon: Set(cmd.icon), color: Set(None), description: Set(cmd.description), position: Set(position), created_at: Set(now), updated_at: Set(None), }; let board = board_row.insert(&txn).await?; txn.commit().await?; board }; Ok(BoardDto { id: board.id, name: board.name, prefix: board.prefix, icon: board.icon, color: board.color, description: board.description, position: board.position, open_issues_count: 0, closed_issues_count: 0, created_at: board.created_at, updated_at: board.updated_at, }) } pub async fn list_boards( &self, caller: i64, space_name: &str, q: ListBoardQuery, ) -> Result { let space = space_context(self, caller, space_name).await?; let (limit, offset) = self.paginate(q.limit, q.offset)?; let mut select = issues::Entity::find().filter(issues::Column::SpaceId.eq(space.id())); if let Some(ref search) = q.q { let kw = search.trim(); if !kw.is_empty() { select = select.filter(issues::Column::Name.contains(kw)); } } select = select.order_by_desc(issues::Column::CreatedAt); let total = select.clone().count(self.database.read()).await?; let rows = select .limit(limit) .offset(offset) .all(self.database.read()) .await?; let board_ids: Vec = rows.iter().map(|b| b.id).collect(); let (total_counts, closed_counts) = self.issue_counts_by_board(&board_ids).await?; let items = rows .into_iter() .map(|board| { let closed_count = closed_counts.get(&board.id).copied().unwrap_or(0); let open_count = total_counts.get(&board.id).copied().unwrap_or(0) - closed_count; Self::board_dto(board, open_count, closed_count) }) .collect(); Ok(BoardPage { items, total, limit, offset, }) } pub async fn get_board( &self, caller: i64, space_name: &str, board_id: Uuid, ) -> Result { let (_, board) = board_context(self, caller, space_name, board_id).await?; self.build_board_dto(board).await } #[tracing::instrument(name = "issues.update_board", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id))] pub async fn update_board( &self, caller: i64, space_name: &str, board_id: Uuid, cmd: UpdateBoardCmd, ) -> 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 now = OffsetDateTime::now_utc(); let mut am: issues::ActiveModel = board.clone().into(); if let Some(new_name) = cmd.name { let name = new_name.trim(); if name.is_empty() || name.chars().count() > self.settings.issues.max_board_name_len { return Err(IssueError::BadRequest( "board name is empty or too long".to_string(), )); } am.name = Set(name.to_string()); } if let Some(raw_prefix) = cmd.prefix { let prefix = match raw_prefix.as_deref() { Some(raw) => self.normalize_board_prefix(raw)?, None => None, }; if let Some(ref prefix) = prefix && self .board_prefix_taken(space.id(), prefix, Some(board_id)) .await? { return Err(IssueError::BadRequest( "board prefix is already used by another board".to_string(), )); } am.prefix = Set(prefix); } if let Some(icon) = cmd.icon { am.icon = Set(icon); } if let Some(desc) = cmd.description { am.description = Set(desc); } if let Some(color) = cmd.color { am.color = Set(color); } if let Some(position) = cmd.position { if position < 0 { return Err(IssueError::BadRequest( "position must be non-negative".to_string(), )); } am.position = Set(position); } am.updated_at = Set(Some(now)); let updated = am.update(self.database.write()).await?; self.build_board_dto(updated).await } #[tracing::instrument(name = "issues.delete_board", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id))] pub async fn delete_board( &self, caller: i64, space_name: &str, board_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?; self.database .primary() .transaction::<_, (), IssueError>(|txn| { Box::pin(async move { let issue_rows = issue_item::Entity::find() .filter(issue_item::Column::CollectionId.eq(board.id)) .all(txn) .await?; issue_pin::Entity::delete_many() .filter(issue_pin::Column::CollectionId.eq(board.id)) .exec(txn) .await?; for item in issue_rows { crate::services::issues::delete_issue_cascade_in_txn(txn, item.id).await?; } milestone::Entity::delete_many() .filter(milestone::Column::CollectionId.eq(board.id)) .exec(txn) .await?; issues::Entity::delete_by_id(board.id).exec(txn).await?; Ok(()) }) }) .await?; Ok(()) } }