Skip to content
Clone
194c44c
511 lines 17.5 KB Rust
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<String>,
pub icon: Option<String>,
pub description: Option<String>,
pub open_issues_count: i64,
pub closed_issues_count: i64,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
}
pub type BoardPage = export::pagination::Page<BoardDto>;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateBoardCmd {
pub name: String,
#[serde(default, alias = "dir_id")]
pub parent_dir_id: Option<Uuid>,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateBoardCmd {
#[serde(default)]
pub name: Option<String>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub prefix: Option<Option<String>>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub icon: Option<Option<String>>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub description: Option<Option<String>>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct ListBoardQuery {
pub dir_id: Option<Uuid>,
pub q: Option<String>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[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<Option<String>, 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<Uuid>,
) -> Result<bool, IssueError> {
let dir_ids: Vec<Uuid> = 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<Uuid, i64>, HashMap<Uuid, i64>), 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<BoardDto, IssueError> {
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<BoardDto, IssueError> {
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<BoardPage, IssueError> {
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<Uuid> = 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<Uuid> = 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<BoardDto, IssueError> {
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<BoardDto, 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 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(())
}
}