use crate::AppService; use crate::error::AppError; use crate::project::activity::ActivityLogParams; use chrono::Utc; use models::issues::{issue, issue_label}; use models::projects::project_members; use models::system::label; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::ToSchema; use uuid::Uuid; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct IssueAddLabelRequest { pub label_id: i64, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateLabelRequest { pub name: String, pub color: String, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueLabelResponse { pub issue: Uuid, pub label_id: i64, pub label_name: Option, pub label_color: Option, pub relation_at: chrono::DateTime, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct LabelResponse { pub id: i64, pub project: Uuid, pub name: String, pub color: String, } impl From for LabelResponse { fn from(l: label::Model) -> Self { Self { id: l.id, project: l.project, name: l.name, color: l.color, } } } impl AppService { /// List all labels for a project. pub async fn label_list( &self, project_name: String, ctx: &Session, ) -> Result, AppError> { let project = self.utils_find_project_by_name(project_name).await?; if let Some(uid) = ctx.user() { self.check_project_access(project.id, uid).await?; } let labels = label::Entity::find() .filter(label::Column::Project.eq(project.id)) .order_by_asc(label::Column::Name) .all(&self.db) .await?; Ok(labels.into_iter().map(LabelResponse::from).collect()) } pub async fn label_create( &self, project_name: String, request: CreateLabelRequest, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; let _member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NoPower)?; // Check for duplicate label name in project let existing = label::Entity::find() .filter(label::Column::Project.eq(project.id)) .filter(label::Column::Name.eq(&request.name)) .one(&self.db) .await?; if existing.is_some() { return Err(AppError::BadRequest( "Label with this name already exists".to_string(), )); } let active = label::ActiveModel { id: Set(0), // auto-increment project: Set(project.id), name: Set(request.name), color: Set(request.color), ..Default::default() }; let model = active.insert(&self.db).await?; Ok(LabelResponse::from(model)) } pub async fn label_delete( &self, project_name: String, label_id: i64, ctx: &Session, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; let _member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NoPower)?; let lbl = label::Entity::find_by_id(label_id) .filter(label::Column::Project.eq(project.id)) .one(&self.db) .await? .ok_or(AppError::NotFound("Label not found".to_string()))?; // Cascade delete issue-label relations issue_label::Entity::delete_many() .filter(issue_label::Column::Label.eq(lbl.id)) .exec(&self.db) .await?; label::Entity::delete_by_id(lbl.id).exec(&self.db).await?; Ok(()) } /// List labels on an issue. pub async fn issue_label_list( &self, project_name: String, issue_number: i64, ctx: &Session, ) -> Result, AppError> { let project = self.utils_find_project_by_name(project_name).await?; if let Some(uid) = ctx.user() { self.check_project_access(project.id, uid).await?; } let issue = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Number.eq(issue_number)) .one(&self.db) .await? .ok_or(AppError::NotFound("Issue not found".to_string()))?; let issue_labels = issue_label::Entity::find() .filter(issue_label::Column::Issue.eq(issue.id)) .all(&self.db) .await?; let label_ids: Vec = issue_labels.iter().map(|l| l.label).collect(); let labels = if label_ids.is_empty() { vec![] } else { label::Entity::find() .filter(label::Column::Id.is_in(label_ids)) .all(&self.db) .await? }; let responses: Vec = issue_labels .into_iter() .map(|il| { let lbl = labels.iter().find(|l| l.id == il.label); IssueLabelResponse { issue: il.issue, label_id: il.label, label_name: lbl.map(|l| l.name.clone()), label_color: lbl.map(|l| l.color.clone()), relation_at: il.relation_at, } }) .collect(); Ok(responses) } /// Add a label to an issue. pub async fn issue_label_add( &self, project_name: String, issue_number: i64, request: IssueAddLabelRequest, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; let _member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NoPower)?; let issue = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Number.eq(issue_number)) .one(&self.db) .await? .ok_or(AppError::NotFound("Issue not found".to_string()))?; let lbl = label::Entity::find_by_id(request.label_id) .filter(label::Column::Project.eq(project.id)) .one(&self.db) .await? .ok_or(AppError::NotFound("Label not found".to_string()))?; // Check if already linked let existing = issue_label::Entity::find() .filter(issue_label::Column::Issue.eq(issue.id)) .filter(issue_label::Column::Label.eq(request.label_id)) .one(&self.db) .await?; if existing.is_some() { return Err(AppError::BadRequest("Label already applied".to_string())); } let now = Utc::now(); let active = issue_label::ActiveModel { issue: Set(issue.id), label: Set(lbl.id), relation_at: Set(now), ..Default::default() }; let model = active.insert(&self.db).await?; self.invalidate_issue_cache(project.id, issue_number).await; let response = Ok(IssueLabelResponse { issue: model.issue, label_id: model.label, label_name: Some(lbl.name.clone()), label_color: Some(lbl.color.clone()), relation_at: model.relation_at, }); let _ = self .project_log_activity( project.id, None, user_uid, ActivityLogParams { event_type: "issue_label_add".to_string(), title: format!( "{} added label '{}' to issue #{}", user_uid, lbl.name.clone(), issue_number ), repo_id: None, content: None, event_id: Some(model.issue), event_sub_id: Some(issue_number), metadata: Some(serde_json::json!({ "label_id": lbl.id, "label_name": lbl.name.clone(), "label_color": lbl.color.clone(), })), is_private: false, }, ) .await; response } /// Remove a label from an issue. pub async fn issue_label_remove( &self, project_name: String, issue_number: i64, label_id: i64, ctx: &Session, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; let _member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NoPower)?; let issue = issue::Entity::find() .filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Number.eq(issue_number)) .one(&self.db) .await? .ok_or(AppError::NotFound("Issue not found".to_string()))?; let issue_id = issue.id; let lbl = label::Entity::find_by_id(label_id) .one(&self.db) .await? .ok_or(AppError::NotFound("Label not found".to_string()))?; let label_name = lbl.name.clone(); issue_label::Entity::delete_many() .filter(issue_label::Column::Issue.eq(issue.id)) .filter(issue_label::Column::Label.eq(label_id)) .exec(&self.db) .await?; self.invalidate_issue_cache(project.id, issue_number).await; let _ = self .project_log_activity( project.id, None, user_uid, ActivityLogParams { event_type: "issue_label_remove".to_string(), title: format!( "{} removed label '{}' from issue #{}", user_uid, label_name, issue_number ), repo_id: None, content: None, event_id: Some(issue_id), event_sub_id: Some(issue_number), metadata: Some(serde_json::json!({ "label_id": label_id, "label_name": label_name, })), is_private: false, }, ) .await; Ok(()) } }