Skip to content
Clone
main
421 lines 14.5 KB Rust
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<String>,
pub icon: Option<String>,
pub color: Option<String>,
pub description: Option<String>,
pub position: i64,
pub open_issues_count: i64,
pub closed_issues_count: i64,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
}
pub type BoardPage = api::pagination::Page<BoardDto>;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateBoardCmd {
pub name: String,
#[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 = "api::serde_patch::deserialize_opt_opt")]
pub prefix: Option<Option<String>>,
#[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")]
pub icon: Option<Option<String>>,
#[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")]
pub description: Option<Option<String>>,
#[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")]
pub color: Option<Option<String>>,
#[serde(default)]
pub position: Option<i64>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct ListBoardQuery {
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.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<Uuid>,
) -> Result<bool, IssueError> {
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<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.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<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?;
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<BoardPage, IssueError> {
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<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?;
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(())
}
}