use crate::AppService; use crate::error::AppError; use crate::project::activity::ActivityLogParams; use chrono::Utc; use models::issues::{issue, issue_subscriber}; use models::projects::project_members; use models::users::user; use sea_orm::*; use serde::Serialize; use session::Session; use utoipa::ToSchema; use uuid::Uuid; #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueSubscriberResponse { pub issue: Uuid, pub user_id: Uuid, pub username: String, pub subscribed: bool, pub created_at: chrono::DateTime, } impl AppService { /// List subscribers for an issue. pub async fn issue_subscriber_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 subscribers = issue_subscriber::Entity::find() .filter(issue_subscriber::Column::Issue.eq(issue.id)) .filter(issue_subscriber::Column::Subscribed.eq(true)) .all(&self.db) .await?; let user_ids: Vec = subscribers.iter().map(|s| s.user).collect(); let users = if user_ids.is_empty() { vec![] } else { user::Entity::find() .filter(user::Column::Uid.is_in(user_ids)) .all(&self.db) .await? }; let responses: Vec = subscribers .into_iter() .filter_map(|s| { let username = users.iter().find(|u| u.uid == s.user)?.username.clone(); Some(IssueSubscriberResponse { issue: s.issue, user_id: s.user, username, subscribed: s.subscribed, created_at: s.created_at, }) }) .collect(); Ok(responses) } /// Subscribe the current user to an issue. pub async fn issue_subscribe( &self, project_name: String, issue_number: i64, 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 now = Utc::now(); // Upsert: update existing record or create new let existing = issue_subscriber::Entity::find() .filter(issue_subscriber::Column::Issue.eq(issue.id)) .filter(issue_subscriber::Column::User.eq(user_uid)) .one(&self.db) .await?; let model = if let Some(existing) = existing { let mut active: issue_subscriber::ActiveModel = existing.into(); active.subscribed = Set(true); active.update(&self.db).await? } else { let active = issue_subscriber::ActiveModel { issue: Set(issue.id), user: Set(user_uid), subscribed: Set(true), created_at: Set(now), ..Default::default() }; active.insert(&self.db).await? }; let username = user::Entity::find_by_id(user_uid) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_default(); let response = Ok(IssueSubscriberResponse { issue: model.issue, user_id: model.user, username: username.clone(), subscribed: model.subscribed, created_at: model.created_at, }); let _ = self .project_log_activity( project.id, None, user_uid, ActivityLogParams { event_type: "issue_subscribe".to_string(), title: format!("{} subscribed to issue #{}", username.clone(), issue_number), repo_id: None, content: None, event_id: Some(model.issue), event_sub_id: Some(issue_number), metadata: None, is_private: false, }, ) .await; response } /// Unsubscribe the current user from an issue. pub async fn issue_unsubscribe( &self, project_name: String, issue_number: 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()))?; // Soft-delete: set subscribed = false let existing = issue_subscriber::Entity::find() .filter(issue_subscriber::Column::Issue.eq(issue.id)) .filter(issue_subscriber::Column::User.eq(user_uid)) .one(&self.db) .await?; if let Some(existing) = existing { let mut active: issue_subscriber::ActiveModel = existing.into(); active.subscribed = Set(false); active.update(&self.db).await?; } let subscriber_username = user::Entity::find_by_id(user_uid) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_else(|| user_uid.to_string()); let _ = self .project_log_activity( project.id, None, user_uid, ActivityLogParams { event_type: "issue_unsubscribe".to_string(), title: format!( "{} unsubscribed from issue #{}", subscriber_username, issue_number ), repo_id: None, content: None, event_id: Some(issue.id), event_sub_id: Some(issue_number), metadata: None, is_private: false, }, ) .await; Ok(()) } }