use std::collections::HashMap; use models::dir::dirs::{self, DirKind}; use models::issue::issue_item; use models::issue::issue_pin; use models::issue::issues; use models::issue::milestone; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, Unchanged, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::IssueError; use crate::services::issues::delete_issue_cascade_in_txn; 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 BoardDto { pub id: Uuid, pub dir_id: Uuid, pub name: String, /// Number prefix for the board's issues (`CAD` renders as `CAD-12`). pub prefix: Option, pub icon: Option, pub description: Option, pub open_issues_count: i64, pub closed_issues_count: i64, pub created_at: OffsetDateTime, pub updated_at: Option, } pub type BoardPage = export::pagination::Page; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateBoardCmd { pub name: String, #[serde(default, alias = "dir_id")] pub parent_dir_id: Option, #[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 = "export::serde_patch::deserialize_opt_opt")] pub prefix: Option>, #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub icon: Option>, #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub description: Option>, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct ListBoardQuery { pub dir_id: Option, 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.config().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 dir_ids: Vec = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(Some(space_id))) .filter(dirs::Column::Kind.eq(DirKind::Object)) .all(self.db().read()) .await? .into_iter() .map(|d| d.id) .collect(); if dir_ids.is_empty() { return Ok(false); } let mut select = issues::Entity::find() .filter(issues::Column::DirId.is_in(dir_ids)) .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.db().read()).await?.is_some()) } fn board_dto( board: issues::Model, open_issues_count: i64, closed_issues_count: i64, ) -> BoardDto { BoardDto { id: board.id, dir_id: board.dir_id, name: board.name, prefix: board.prefix, icon: board.icon, description: board.description, 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.db().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.db().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?; require_board_manage(self, &space, caller).await?; let name = cmd.name.trim(); if name.is_empty() || name.chars().count() > self.config().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(), )); } if let Some(p) = cmd.parent_dir_id { let parent = dirs::Entity::find_by_id(p) .filter(dirs::Column::SpaceId.eq(Some(space.id))) .one(self.db().read()) .await? .ok_or(IssueError::NotFound)?; if parent.kind != DirKind::Dir { return Err(IssueError::BadRequest( "parent node must be a dir".to_string(), )); } } let (_dir, board) = { let txn = self.db().write().begin().await?; let position = { let max_node = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(Some(space.id))) .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id)) .order_by_desc(dirs::Column::Position) .lock(sea_orm::sea_query::LockType::Update) .one(&txn) .await?; max_node.map(|n| n.position + 1).unwrap_or(0) }; let now = OffsetDateTime::now_utc(); let dir_row = dirs::ActiveModel { id: Set(Uuid::now_v7()), space_id: Set(Some(space.id)), name: Set(name.to_string()), kind: Set(DirKind::Object), parent_dir_id: Set(cmd.parent_dir_id), color: Set(None), position: Set(position), icon: Set(cmd.icon.clone()), topic: Set(None), purpose: Set(cmd.description.clone()), is_archived: Set(false), archived_at: Set(None), archived_by: Set(None), created_at: Set(now), updated_at: Set(now), created_by_uid: Set(caller), }; let dir = dir_row.insert(&txn).await?; let board_row = issues::ActiveModel { id: Set(Uuid::now_v7()), dir_id: Set(dir.id), name: Set(name.to_string()), prefix: Set(prefix), icon: Set(cmd.icon), description: Set(cmd.description), created_at: Set(now), updated_at: Set(None), }; let board = board_row.insert(&txn).await?; txn.commit().await?; (dir, board) }; Ok(BoardDto { id: board.id, dir_id: board.dir_id, name: board.name, prefix: board.prefix, icon: board.icon, description: board.description, 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 dirs_in_space = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(Some(space.id))) .filter(dirs::Column::Kind.eq(DirKind::Object)) .all(self.db().read()) .await?; if dirs_in_space.is_empty() { return Ok(BoardPage { items: Vec::new(), total: 0, limit, offset, }); } let dir_ids: Vec = if let Some(target_dir) = q.dir_id { dirs_in_space .into_iter() .filter(|d| d.parent_dir_id == Some(target_dir) || d.id == target_dir) .map(|d| d.id) .collect() } else { dirs_in_space.into_iter().map(|d| d.id).collect() }; if dir_ids.is_empty() { return Ok(BoardPage { items: Vec::new(), total: 0, limit, offset, }); } let mut select = issues::Entity::find().filter(issues::Column::DirId.is_in(dir_ids)); 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.db().read()).await? as i64; let rows = select .limit(limit) .offset(offset) .all(self.db().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?; 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(); let mut dir_am = dirs::ActiveModel { id: Unchanged(board.dir_id), ..Default::default() }; let mut dir_changed = false; if let Some(new_name) = cmd.name { let name = new_name.trim(); if name.is_empty() || name.chars().count() > self.config().issues.max_board_name_len { return Err(IssueError::BadRequest( "board name is empty or too long".to_string(), )); } am.name = Set(name.to_string()); dir_am.name = Set(name.to_string()); dir_changed = true; } 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.clone()); dir_am.icon = Set(icon); dir_changed = true; } if let Some(desc) = cmd.description { am.description = Set(desc.clone()); dir_am.purpose = Set(desc); dir_changed = true; } am.updated_at = Set(Some(now)); let updated = { let txn = self.db().write().begin().await?; if dir_changed { dir_am.updated_at = Set(now); dir_am.update(&txn).await?; } let updated = am.update(&txn).await?; txn.commit().await?; updated }; 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?; require_board_manage(self, &space, caller).await?; let board = lookup_board(self, &space, board_id, caller).await?; self.db() .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 { 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?; dirs::Entity::delete_by_id(board.dir_id).exec(txn).await?; Ok(()) }) }) .await?; Ok(()) } }