use models::issue::issue_dependency; use models::issue::issue_event_line::IssueEventType; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, 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, IssueService}; use crate::share::authz; use crate::share::lookup::{lookup_board, lookup_issue, require_issue_access, space_context}; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct DependencyDto { pub id: Uuid, pub issue_id: Uuid, pub dependency_id: Uuid, pub created_at: OffsetDateTime, } impl From for DependencyDto { fn from(m: issue_dependency::Model) -> Self { Self { id: m.id, issue_id: m.issue_id, dependency_id: m.dependency_id, created_at: m.created_at, } } } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateDependencyCmd { pub dependency_id: Uuid, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct DependencyPath { pub space_name: String, pub board_id: Uuid, pub issue_id: String, pub dep_id: Uuid, } impl IssueService { #[tracing::instrument(name = "issues.create_dependency", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id))] pub async fn create_dependency( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, cmd: CreateDependencyCmd, ) -> 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 _ = lookup_issue(self, &board, issue_id).await?; if cmd.dependency_id == issue_id { return Err(IssueError::BadRequest( "issue cannot depend on itself".to_string(), )); } let _ = lookup_issue(self, &board, cmd.dependency_id).await?; let existing = issue_dependency::Entity::find() .filter(issue_dependency::Column::IssueId.eq(issue_id)) .filter(issue_dependency::Column::DependencyId.eq(cmd.dependency_id)) .one(self.database.read()) .await?; if existing.is_some() { return Err(IssueError::Conflict( "dependency already exists".to_string(), )); } let now = OffsetDateTime::now_utc(); let dep_id = Uuid::now_v7(); let dep_target = cmd.dependency_id; let this = self.clone(); let inserted = self .database .primary() .transaction::<_, issue_dependency::Model, IssueError>(move |txn| { Box::pin(async move { let row = issue_dependency::ActiveModel { id: Set(dep_id), issue_id: Set(issue_id), dependency_id: Set(dep_target), created_at: Set(now), }; let ins = row.insert(txn).await?; this.write_event( txn, issue_id, caller, IssueEventType::DependencyAdded, IssueEventDetail { comment_id: None, old_title: None, new_title: None, content: Some(dep_target.to_string()), }, ) .await?; Ok(ins) }) }) .await?; Ok(DependencyDto::from(inserted)) } pub async fn list_dependents( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, ) -> Result, IssueError> { require_issue_access(self, caller, space_name, board_id, issue_id).await?; let rows = issue_dependency::Entity::find() .filter(issue_dependency::Column::DependencyId.eq(issue_id)) .order_by_asc(issue_dependency::Column::CreatedAt) .all(self.database.read()) .await?; Ok(rows.into_iter().map(DependencyDto::from).collect()) } pub async fn list_dependencies( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, ) -> Result, IssueError> { require_issue_access(self, caller, space_name, board_id, issue_id).await?; let rows = issue_dependency::Entity::find() .filter(issue_dependency::Column::IssueId.eq(issue_id)) .order_by_asc(issue_dependency::Column::CreatedAt) .all(self.database.read()) .await?; Ok(rows.into_iter().map(DependencyDto::from).collect()) } #[tracing::instrument(name = "issues.delete_dependency", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, dep_id = %dep_id))] pub async fn delete_dependency( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, dep_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?; let _ = lookup_issue(self, &board, issue_id).await?; let dep = issue_dependency::Entity::find_by_id(dep_id) .filter(issue_dependency::Column::IssueId.eq(issue_id)) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; let dep_target = dep.dependency_id; let this = self.clone(); self.database .primary() .transaction::<_, (), IssueError>(move |txn| { Box::pin(async move { issue_dependency::Entity::delete_by_id(dep_id) .exec(txn) .await?; this.write_event( txn, issue_id, caller, IssueEventType::DependencyRemoved, IssueEventDetail { comment_id: None, old_title: None, new_title: None, content: Some(dep_target.to_string()), }, ) .await?; Ok(()) }) }) .await?; Ok(()) } }