use crate::AppService; use crate::error::AppError; use chrono::{DateTime, Utc}; use models::projects::{ MemberRole, project_audit_log, project_member_join_answers, project_member_join_request, project_member_join_settings, project_members, }; use models::users::user; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use uuid::Uuid; use super::join_answers::AnswerRequest; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct JoinRequestResponse { pub id: i64, pub project_uid: Uuid, pub user_uid: Uuid, pub username: String, pub status: String, pub message: Option, pub processed_by: Option, pub processed_at: Option>, pub reject_reason: Option, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct JoinRequestListResponse { pub requests: Vec, pub total: u64, pub page: u64, pub per_page: u64, } #[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] pub struct SubmitJoinRequest { pub message: Option, pub answers: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] pub struct ProcessJoinRequest { pub approve: bool, pub scope: MemberRole, pub reject_reason: Option, } impl AppService { pub async fn project_get_join_requests( &self, project_name: String, status: Option, page: Option, per_page: Option, ctx: &Session, ) -> Result { let _user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self .utils_find_project_by_name(project_name.clone()) .await?; let role = self .utils_project_context_role(&ctx, project_name.clone()) .await .map_err(|_| AppError::NoPower)?; if role != MemberRole::Owner && role != MemberRole::Admin { return Err(AppError::NoPower); } let page = page.unwrap_or(1); let per_page = per_page.unwrap_or(20); let mut query = project_member_join_request::Entity::find() .filter(project_member_join_request::Column::Project.eq(project.id)); if let Some(s) = status { query = query.filter(project_member_join_request::Column::Status.eq(s)); } let requests = query .order_by_desc(project_member_join_request::Column::CreatedAt) .paginate(&self.db, per_page) .fetch_page(page - 1) .await?; let total = project_member_join_request::Entity::find() .filter(project_member_join_request::Column::Project.eq(project.id)) .count(&self.db) .await?; let user_ids: Vec = requests.iter().map(|r| r.user).collect(); let users_data = if user_ids.is_empty() { vec![] } else { user::Entity::find() .filter(user::Column::Uid.is_in(user_ids)) .all(&self.db) .await? }; let join_requests = requests .into_iter() .filter_map(|r| { let username = users_data .iter() .find(|u| u.uid == r.user) .map(|u| u.username.clone())?; Some(JoinRequestResponse { id: r.id, project_uid: r.project, user_uid: r.user, username, status: r.status, message: r.message, processed_by: r.processed_by, processed_at: r.processed_at, reject_reason: r.reject_reason, created_at: r.created_at, }) }) .collect(); Ok(JoinRequestListResponse { requests: join_requests, total, page, per_page, }) } pub async fn project_submit_join_request( &self, project_name: String, request: SubmitJoinRequest, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self .utils_find_project_by_name(project_name.clone()) .await?; let existing_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?; if existing_member.is_some() { return Err(AppError::InternalServerError( "Already a member of this project".to_string(), )); } let existing_request = project_member_join_request::Entity::find() .filter(project_member_join_request::Column::Project.eq(project.id)) .filter(project_member_join_request::Column::User.eq(user_uid)) .filter(project_member_join_request::Column::Status.eq("pending")) .one(&self.db) .await?; if existing_request.is_some() { return Err(AppError::InternalServerError( "Already have a pending join request".to_string(), )); } // Get join settings let settings = project_member_join_settings::Entity::find() .filter(project_member_join_settings::Column::Project.eq(project.id)) .one(&self.db) .await?; // Clone message for audit log before moving let message = request.message.clone(); let txn = self.db.begin().await?; let new_request = project_member_join_request::ActiveModel { id: Default::default(), project: Set(project.id), user: Set(user_uid), status: Set("pending".to_string()), message: Set(request.message), processed_by: Set(None), processed_at: Set(None), reject_reason: Set(None), created_at: Set(Utc::now()), updated_at: Set(Utc::now()), }; let request_model = new_request.insert(&txn).await?; let request_id = request_model.id; // Save answers if questions are required if let Some(ref s) = settings { if s.require_questions { for answer in request.answers { let answer_model = project_member_join_answers::ActiveModel { id: Default::default(), project: Set(project.id), user: Set(user_uid), request_id: Set(request_id), question: Set(answer.question), answer: Set(answer.answer), created_at: Set(Utc::now()), }; answer_model.insert(&txn).await?; } } } let log = project_audit_log::ActiveModel { project: Set(project.id), actor: Set(user_uid), action: Set("submit_join_request".to_string()), details: Set(Some(serde_json::json!({ "request_id": request_id, "message": message, }))), created_at: Set(Utc::now()), ..Default::default() }; log.insert(&txn).await?; txn.commit().await?; let _ = self .project_log_activity( project.id, None, user_uid, super::activity::ActivityLogParams { event_type: "join_request_submit".to_string(), title: format!("{} requested to join the project", user_uid), repo_id: None, content: None, event_id: None, event_sub_id: Some(request_id), metadata: Some(serde_json::json!({ "request_id": request_id, })), is_private: false, }, ) .await; Ok(request_id) } pub async fn project_process_join_request( &self, project_name: String, request_id: i64, process: ProcessJoinRequest, ctx: &Session, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self .utils_find_project_by_name(project_name.clone()) .await?; let role = self .utils_project_context_role(&ctx, project_name.clone()) .await .map_err(|_| AppError::NoPower)?; if role != MemberRole::Owner && role != MemberRole::Admin { return Err(AppError::NoPower); } let join_request = project_member_join_request::Entity::find_by_id(request_id) .filter(project_member_join_request::Column::Project.eq(project.id)) .one(&self.db) .await? .ok_or(AppError::NotFound("Join request not found".to_string()))?; if join_request.status != "pending" { return Err(AppError::InternalServerError( "Join request already processed".to_string(), )); } // Clone values before moving into ActiveModel let request_user = join_request.user; let action = if process.approve { "approve_join_request" } else { "reject_join_request" }; let reject_reason = process.reject_reason.clone(); let txn = self.db.begin().await?; let mut active_request: project_member_join_request::ActiveModel = join_request.into(); if process.approve { active_request.status = Set("approved".to_string()); active_request.processed_by = Set(Some(user_uid)); active_request.processed_at = Set(Some(Utc::now())); // Add user as member let member = project_members::ActiveModel { id: Default::default(), project: Set(project.id), user: Set(request_user), scope: Set(process.scope.to_string()), joined_at: Set(Utc::now()), }; member.insert(&txn).await?; } else { active_request.status = Set("rejected".to_string()); active_request.processed_by = Set(Some(user_uid)); active_request.processed_at = Set(Some(Utc::now())); active_request.reject_reason = Set(process.reject_reason); } active_request.updated_at = Set(Utc::now()); active_request.update(&txn).await?; let log = project_audit_log::ActiveModel { project: Set(project.id), actor: Set(user_uid), action: Set(action.to_string()), details: Set(Some(serde_json::json!({ "request_id": request_id, "approved": process.approve, "scope": process.scope.to_string(), "reject_reason": reject_reason, }))), created_at: Set(Utc::now()), ..Default::default() }; log.insert(&txn).await?; txn.commit().await?; let request_username = user::Entity::find_by_id(request_user) .one(&self.db) .await .ok() .flatten() .map(|u| u.username) .unwrap_or_else(|| request_user.to_string()); let event_type = if process.approve { "join_request_approve" } else { "join_request_reject" }; let _ = self .project_log_activity( project.id, None, user_uid, super::activity::ActivityLogParams { event_type: event_type.to_string(), title: if process.approve { format!("{} approved {}'s join request", user_uid, request_username) } else { format!("{} rejected {}'s join request", user_uid, request_username) }, repo_id: None, content: None, event_id: None, event_sub_id: Some(request_id), metadata: Some(serde_json::json!({ "request_id": request_id, "approved": process.approve, })), is_private: false, }, ) .await; Ok(()) } pub async fn project_cancel_join_request( &self, project_name: String, request_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.clone()) .await?; let join_request = project_member_join_request::Entity::find_by_id(request_id) .filter(project_member_join_request::Column::Project.eq(project.id)) .filter(project_member_join_request::Column::User.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NotFound("Join request not found".to_string()))?; if join_request.status != "pending" { return Err(AppError::InternalServerError( "Only pending requests can be cancelled".to_string(), )); } let txn = self.db.begin().await?; let mut active_request: project_member_join_request::ActiveModel = join_request.into(); active_request.status = Set("cancelled".to_string()); active_request.updated_at = Set(Utc::now()); active_request.update(&txn).await?; let log = project_audit_log::ActiveModel { project: Set(project.id), actor: Set(user_uid), action: Set("cancel_join_request".to_string()), details: Set(Some(serde_json::json!({ "request_id": request_id, }))), created_at: Set(Utc::now()), ..Default::default() }; log.insert(&txn).await?; txn.commit().await?; let _ = self .project_log_activity( project.id, None, user_uid, super::activity::ActivityLogParams { event_type: "join_request_cancel".to_string(), title: format!("{} cancelled their join request", user_uid), repo_id: None, content: None, event_id: None, event_sub_id: Some(request_id), metadata: Some(serde_json::json!({ "request_id": request_id, })), is_private: false, }, ) .await; Ok(()) } pub async fn project_get_my_join_requests( &self, page: Option, per_page: Option, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let page = page.unwrap_or(1); let per_page = per_page.unwrap_or(20); let requests = project_member_join_request::Entity::find() .filter(project_member_join_request::Column::User.eq(user_uid)) .order_by_desc(project_member_join_request::Column::CreatedAt) .paginate(&self.db, per_page) .fetch_page(page - 1) .await?; let total = project_member_join_request::Entity::find() .filter(project_member_join_request::Column::User.eq(user_uid)) .count(&self.db) .await?; let project_ids: Vec<_> = requests.iter().map(|r| r.project).collect(); let projects = if project_ids.is_empty() { vec![] } else { models::projects::project::Entity::find() .filter(models::projects::project::Column::Id.is_in(project_ids)) .all(&self.db) .await? }; let join_requests = requests .into_iter() .filter_map(|r| { let project_name = projects .iter() .find(|p| p.id == r.project) .map(|p| p.name.clone())?; Some(JoinRequestResponse { id: r.id, project_uid: r.project, user_uid: r.user, username: project_name, status: r.status, message: r.message, processed_by: r.processed_by, processed_at: r.processed_at, reject_reason: r.reject_reason, created_at: r.created_at, }) }) .collect(); Ok(JoinRequestListResponse { requests: join_requests, total, page, per_page, }) } }