Skip to content
Clone
951a522
479 lines 15.3 KB Rust
use models::issue::issue_event_line::IssueEventType;
use models::issue::issue_label;
use models::issue::issue_relation_label;
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::IssueEventDetail;
use crate::services::service::IssueService;
use crate::share::authz::require_board_manage;
use crate::share::lookup::{
board_context, lookup_board, lookup_issue, require_issue_access, space_context,
};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct LabelDto {
pub id: Uuid,
pub dir_id: Uuid,
pub name: String,
pub exclusive: bool,
pub exclusive_order: i32,
pub description: Option<String>,
pub color: String,
pub is_archived: bool,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
}
impl From<issue_label::Model> for LabelDto {
fn from(m: issue_label::Model) -> Self {
Self {
id: m.id,
dir_id: m.dir_id,
name: m.name,
exclusive: m.exclusive,
exclusive_order: m.exclusive_order,
description: m.description,
color: m.color,
is_archived: m.is_archived,
created_at: m.created_at,
updated_at: m.updated_at,
}
}
}
pub type LabelPage = export::pagination::Page<LabelDto>;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateLabelCmd {
pub name: String,
pub color: String,
#[serde(default)]
pub exclusive: Option<bool>,
#[serde(default)]
pub exclusive_order: Option<i32>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateLabelCmd {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub exclusive: Option<bool>,
#[serde(default)]
pub exclusive_order: Option<i32>,
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub description: Option<Option<String>>,
#[serde(default)]
pub is_archived: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct ListLabelQuery {
pub q: Option<String>,
pub is_archived: Option<bool>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct LabelPath {
pub space_name: String,
pub board_id: Uuid,
pub label_id: Uuid,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct IssueLabelPath {
pub space_name: String,
pub board_id: Uuid,
pub issue_id: String,
pub label_id: Uuid,
}
fn validate_hex_color(color: &str, max_len: usize) -> bool {
let clean = color.trim();
if clean.len() != 7 || clean.len() > max_len || !clean.starts_with('#') {
return false;
}
clean[1..].chars().all(|c| c.is_ascii_hexdigit())
}
impl IssueService {
async fn lookup_managed_label(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
issue_id: Uuid,
label_id: Uuid,
) -> Result<issue_label::Model, 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 _ = lookup_issue(self, &board, issue_id).await?;
let label = issue_label::Entity::find_by_id(label_id)
.filter(issue_label::Column::DirId.eq(board.dir_id))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
Ok(label)
}
#[tracing::instrument(name = "issues.create_label", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id))]
pub async fn create_label(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
cmd: CreateLabelCmd,
) -> Result<LabelDto, 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 name = cmd.name.trim();
if name.is_empty() || name.chars().count() > self.config().issues.max_label_name_len {
return Err(IssueError::BadRequest(
"label name is empty or too long".to_string(),
));
}
let color = cmd.color.trim();
if !validate_hex_color(color, self.config().issues.max_label_color_len) {
return Err(IssueError::BadRequest(
"invalid color format: must be #rrggbb hex code".to_string(),
));
}
if let Some(eo) = cmd.exclusive_order
&& eo < 0
{
return Err(IssueError::BadRequest(
"exclusive_order must be non-negative".to_string(),
));
}
let now = OffsetDateTime::now_utc();
let row = issue_label::ActiveModel {
id: Set(Uuid::now_v7()),
dir_id: Set(board.dir_id),
name: Set(name.to_string()),
exclusive: Set(cmd.exclusive.unwrap_or(false)),
exclusive_order: Set(cmd.exclusive_order.unwrap_or(0)),
description: Set(cmd.description),
color: Set(color.to_string()),
is_archived: Set(false),
created_at: Set(now),
updated_at: Set(None),
};
let inserted = row.insert(self.db().write()).await?;
Ok(LabelDto::from(inserted))
}
pub async fn list_labels(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
q: ListLabelQuery,
) -> Result<LabelPage, IssueError> {
let (_, board) = board_context(self, caller, space_name, board_id).await?;
let max_pagination = self.config().issues.max_pagination;
let default_pagination = self.config().issues.default_pagination;
let limit = q
.limit
.unwrap_or(default_pagination)
.clamp(1, max_pagination);
let offset = q.offset.unwrap_or(0).clamp(0, 5000);
let mut select =
issue_label::Entity::find().filter(issue_label::Column::DirId.eq(board.dir_id));
if let Some(archived) = q.is_archived {
select = select.filter(issue_label::Column::IsArchived.eq(archived));
}
if let Some(ref search) = q.q {
let kw = search.trim();
if !kw.is_empty() {
select = select.filter(issue_label::Column::Name.contains(kw));
}
}
select = select.order_by_desc(issue_label::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 items = rows.into_iter().map(LabelDto::from).collect();
Ok(LabelPage {
items,
total,
limit,
offset,
})
}
pub async fn get_label(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
label_id: Uuid,
) -> Result<LabelDto, IssueError> {
let (_, board) = board_context(self, caller, space_name, board_id).await?;
let l = issue_label::Entity::find_by_id(label_id)
.filter(issue_label::Column::DirId.eq(board.dir_id))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
Ok(LabelDto::from(l))
}
#[tracing::instrument(name = "issues.update_label", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, label_id = %label_id))]
pub async fn update_label(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
label_id: Uuid,
cmd: UpdateLabelCmd,
) -> Result<LabelDto, IssueError> {
let (_, board) = board_context(self, caller, space_name, board_id).await?;
let l = issue_label::Entity::find_by_id(label_id)
.filter(issue_label::Column::DirId.eq(board.dir_id))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
let mut am: issue_label::ActiveModel = l.into();
if let Some(new_name) = cmd.name {
let name = new_name.trim();
if name.is_empty() || name.chars().count() > self.config().issues.max_label_name_len {
return Err(IssueError::BadRequest(
"label name is empty or too long".to_string(),
));
}
am.name = Set(name.to_string());
}
if let Some(new_color) = cmd.color {
let color = new_color.trim();
if !validate_hex_color(color, self.config().issues.max_label_color_len) {
return Err(IssueError::BadRequest(
"invalid color format: must be #rrggbb hex code".to_string(),
));
}
am.color = Set(color.to_string());
}
if let Some(ex) = cmd.exclusive {
am.exclusive = Set(ex);
}
if let Some(eo) = cmd.exclusive_order {
if eo < 0 {
return Err(IssueError::BadRequest(
"exclusive_order must be non-negative".to_string(),
));
}
am.exclusive_order = Set(eo);
}
if let Some(desc) = cmd.description {
am.description = Set(desc);
}
if let Some(archived) = cmd.is_archived {
am.is_archived = Set(archived);
}
let now = OffsetDateTime::now_utc();
am.updated_at = Set(Some(now));
let updated = am.update(self.db().write()).await?;
Ok(LabelDto::from(updated))
}
#[tracing::instrument(name = "issues.delete_label", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, label_id = %label_id))]
pub async fn delete_label(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
label_id: Uuid,
) -> Result<(), IssueError> {
let (_, board) = board_context(self, caller, space_name, board_id).await?;
let _ = issue_label::Entity::find_by_id(label_id)
.filter(issue_label::Column::DirId.eq(board.dir_id))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
Ok(())
}
pub async fn list_labels_for_issue(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
issue_id: Uuid,
) -> Result<Vec<LabelDto>, IssueError> {
require_issue_access(self, caller, space_name, board_id, issue_id).await?;
let rels = issue_relation_label::Entity::find()
.filter(issue_relation_label::Column::IssueId.eq(issue_id))
.all(self.db().read())
.await?;
if rels.is_empty() {
return Ok(Vec::new());
}
let label_ids: Vec<Uuid> = rels.into_iter().map(|r| r.label_id).collect();
let labels = issue_label::Entity::find()
.filter(issue_label::Column::Id.is_in(label_ids))
.all(self.db().read())
.await?;
Ok(labels.into_iter().map(LabelDto::from).collect())
}
#[tracing::instrument(name = "issues.bind_label", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, label_id = %label_id))]
pub async fn bind_label(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
issue_id: Uuid,
label_id: Uuid,
) -> Result<(), IssueError> {
let label = self
.lookup_managed_label(caller, space_name, board_id, issue_id, label_id)
.await?;
let existing = issue_relation_label::Entity::find()
.filter(issue_relation_label::Column::IssueId.eq(issue_id))
.filter(issue_relation_label::Column::LabelId.eq(label_id))
.one(self.db().read())
.await?;
if existing.is_some() {
return Err(IssueError::Conflict(
"label already bound to issue".to_string(),
));
}
let now = OffsetDateTime::now_utc();
let label_name = label.name.clone();
let this = self.clone();
self.db()
.primary()
.transaction::<_, (), IssueError>(move |txn| {
let name_clone = label_name.clone();
Box::pin(async move {
let row = issue_relation_label::ActiveModel {
id: Set(Uuid::now_v7()),
issue_id: Set(issue_id),
label_id: Set(label_id),
created_at: Set(now),
};
row.insert(txn).await?;
this.write_event(
txn,
issue_id,
caller,
IssueEventType::LabelAdded,
IssueEventDetail {
comment_id: None,
old_title: None,
new_title: None,
content: Some(name_clone),
},
)
.await?;
Ok(())
})
})
.await?;
Ok(())
}
#[tracing::instrument(name = "issues.unbind_label", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, label_id = %label_id))]
pub async fn unbind_label(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
issue_id: Uuid,
label_id: Uuid,
) -> Result<(), IssueError> {
let label = self
.lookup_managed_label(caller, space_name, board_id, issue_id, label_id)
.await?;
let rel = issue_relation_label::Entity::find()
.filter(issue_relation_label::Column::IssueId.eq(issue_id))
.filter(issue_relation_label::Column::LabelId.eq(label_id))
.one(self.db().read())
.await?
.ok_or(IssueError::NotFound)?;
let label_name = label.name.clone();
let this = self.clone();
self.db()
.primary()
.transaction::<_, (), IssueError>(move |txn| {
let name_clone = label_name.clone();
Box::pin(async move {
issue_relation_label::Entity::delete_by_id(rel.id)
.exec(txn)
.await?;
this.write_event(
txn,
issue_id,
caller,
IssueEventType::LabelRemoved,
IssueEventDetail {
comment_id: None,
old_title: None,
new_title: None,
content: Some(name_clone),
},
)
.await?;
Ok(())
})
})
.await?;
Ok(())
}
}