use crate::AppService; use crate::error::AppError; use chrono::{DateTime, Utc}; use models::projects::{ MemberRole, project_audit_log, project_member_invitations, project_members, }; use models::users::{user, user_email}; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use uuid::Uuid; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct InvitationResponse { pub project_uid: Uuid, pub user_uid: Uuid, pub invited_by: Uuid, pub scope: String, pub accepted: bool, pub accepted_at: Option>, pub rejected: bool, pub rejected_at: Option>, pub created_at: DateTime, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct InvitationListResponse { pub invitations: Vec, pub total: u64, pub page: u64, pub per_page: u64, } impl From for InvitationResponse { fn from(invitation: project_member_invitations::Model) -> Self { InvitationResponse { project_uid: invitation.project, user_uid: invitation.user, invited_by: invitation.invited_by, scope: invitation.scope, accepted: invitation.accepted, accepted_at: invitation.accepted_at, rejected: invitation.rejected, rejected_at: invitation.rejected_at, created_at: invitation.created_at, } } } impl AppService { pub async fn project_get_invitations( &self, project_name: String, 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).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 invitations = project_member_invitations::Entity::find() .filter(project_member_invitations::Column::Project.eq(project.id)) .order_by_desc(project_member_invitations::Column::CreatedAt) .paginate(&self.db, per_page) .fetch_page(page - 1) .await?; let total = project_member_invitations::Entity::find() .filter(project_member_invitations::Column::Project.eq(project.id)) .count(&self.db) .await?; let invitations = invitations .into_iter() .map(InvitationResponse::from) .collect(); Ok(InvitationListResponse { invitations, total, page, per_page, }) } pub async fn project_get_my_invitations( &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 invitations = project_member_invitations::Entity::find() .filter(project_member_invitations::Column::User.eq(user_uid)) .filter(project_member_invitations::Column::Accepted.eq(false)) .filter(project_member_invitations::Column::Rejected.eq(false)) .order_by_desc(project_member_invitations::Column::CreatedAt) .paginate(&self.db, per_page) .fetch_page(page - 1) .await?; let total = project_member_invitations::Entity::find() .filter(project_member_invitations::Column::User.eq(user_uid)) .filter(project_member_invitations::Column::Accepted.eq(false)) .filter(project_member_invitations::Column::Rejected.eq(false)) .count(&self.db) .await?; let invitations = invitations .into_iter() .map(InvitationResponse::from) .collect(); Ok(InvitationListResponse { invitations, total, page, per_page, }) } pub async fn project_invite_user( &self, project_name: String, invitee_email: String, scope: MemberRole, ctx: &Session, ) -> Result<(), AppError> { let inviter_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 target_user = user_email::Entity::find() .filter(user_email::Column::Email.eq(invitee_email.clone())) .one(&self.db) .await? .ok_or(AppError::UserNotFound)?; let user = user::Entity::find_by_id(target_user.user) .one(&self.db) .await? .ok_or(AppError::UserNotFound)?; let target_uid = user.uid; let existing_member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(target_uid)) .one(&self.db) .await?; if existing_member.is_some() { return Err(AppError::InternalServerError( "User is already a member of this project".to_string(), )); } let existing_invitation = project_member_invitations::Entity::find() .filter(project_member_invitations::Column::Project.eq(project.id)) .filter(project_member_invitations::Column::User.eq(target_uid)) .filter(project_member_invitations::Column::Accepted.eq(false)) .filter(project_member_invitations::Column::Rejected.eq(false)) .one(&self.db) .await?; if existing_invitation.is_some() { return Err(AppError::InternalServerError( "An invitation already exists for this user".to_string(), )); } let txn = self.db.begin().await.map_err(|_| AppError::InternalError)?; let invitation = project_member_invitations::ActiveModel { id: Default::default(), project: Set(project.id), user: Set(target_uid), invited_by: Set(inviter_uid), scope: Set(scope.clone().to_string()), accepted: Set(false), accepted_at: Set(None), rejected: Set(false), rejected_at: Set(None), created_at: Set(Utc::now()), }; invitation.insert(&txn).await?; let log = project_audit_log::ActiveModel { project: Set(project.id), actor: Set(inviter_uid), action: Set("invite_user".to_string()), details: Set(Some(serde_json::json!({ "invitee_uid": target_uid, "scope": format!("{:?}", scope), "project_name": project.name.clone(), }))), created_at: Set(Utc::now()), ..Default::default() }; log.insert(&txn).await?; txn.commit().await?; let _ = self .project_log_activity( project.id, None, inviter_uid, super::activity::ActivityLogParams { event_type: "member_invite".to_string(), title: format!("{} invited {} to the project", inviter_uid, user.username), repo_id: None, content: None, event_id: None, event_sub_id: None, metadata: Some(serde_json::json!({ "invitee_uid": target_uid, "invitee_username": user.username, "scope": format!("{:?}", scope), })), is_private: false, }, ) .await; let inviter = user::Entity::find_by_id(inviter_uid) .one(&self.db) .await? .ok_or(AppError::UserNotFound)?; let envelope = queue::EmailEnvelope { id: Uuid::new_v4(), to: invitee_email, subject: format!("You've been invited to join project: {}", project.name), body: format!( "Hello {},\n\n\ {} has invited you to join the project \"{}\" with the role of {:?}.\n\n\ Please log in to your account to accept or decline this invitation.\n\n\ Project: {}\n\ Role: {:?}\n\ Invited by: {}\n\n\ Best regards,\n\ GitDataAI Team", user.username, inviter.username, project.name, scope, project.name, scope, inviter.username ), created_at: chrono::Utc::now(), }; if let Err(_e) = self.queue_producer.publish_email(envelope).await { // Failed to queue invitation email } Ok(()) } pub async fn project_accept_invitation( &self, project_name: String, 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 invitation = project_member_invitations::Entity::find() .filter(project_member_invitations::Column::Project.eq(project.id)) .filter(project_member_invitations::Column::Project.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NotFound( "No pending invitation found for this project".to_string(), ))?; if invitation.accepted || invitation.rejected { return Err(AppError::InternalServerError( "Invitation already processed".to_string(), )); } let txn = self.db.begin().await.map_err(|_| AppError::InternalError)?; let mut active_invitation: project_member_invitations::ActiveModel = invitation.clone().into(); active_invitation.accepted = Set(true); active_invitation.accepted_at = Set(Some(Utc::now())); active_invitation.update(&txn).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(&txn) .await?; if existing_member.is_none() { let member = project_members::ActiveModel { id: Default::default(), project: Set(project.id), user: Set(user_uid), scope: Set(invitation.scope.clone()), joined_at: Set(Utc::now()), }; member.insert(&txn).await?; } let log = project_audit_log::ActiveModel { project: Set(project.id), actor: Set(user_uid), action: Set("accept_invitation".to_string()), details: Set(Some(serde_json::json!({ "project_name": project.name.clone(), "scope": format!("{:?}", invitation.scope), }))), created_at: Set(Utc::now()), ..Default::default() }; log.insert(&txn).await?; txn.commit().await?; let actor_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, super::activity::ActivityLogParams { event_type: "member_join".to_string(), title: format!("{} joined the project", actor_username), repo_id: None, content: None, event_id: None, event_sub_id: None, metadata: Some(serde_json::json!({ "scope": format!("{:?}", invitation.scope), })), is_private: false, }, ) .await; Ok(()) } pub async fn project_reject_invitation( &self, project_name: String, 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 invitation = project_member_invitations::Entity::find() .filter(project_member_invitations::Column::Project.eq(project.id)) .filter(project_member_invitations::Column::User.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::NotFound( "No pending invitation found for this project".to_string(), ))?; if invitation.accepted || invitation.rejected { return Err(AppError::InternalServerError( "Invitation already processed".to_string(), )); } let txn = self.db.begin().await.map_err(|_| AppError::InternalError)?; let mut active_invitation: project_member_invitations::ActiveModel = invitation.into(); active_invitation.rejected = Set(true); active_invitation.rejected_at = Set(Some(Utc::now())); active_invitation.update(&txn).await?; let log = project_audit_log::ActiveModel { project: Set(project.id), actor: Set(user_uid), action: Set("reject_invitation".to_string()), details: Set(Some(serde_json::json!({ "project_name": project.name.clone(), }))), 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: "invitation_rejected".to_string(), title: format!("{} rejected invitation to join the project", user_uid), repo_id: None, content: None, event_id: None, event_sub_id: None, metadata: Some(serde_json::json!({ "project_name": project.name.clone(), })), is_private: false, }, ) .await; Ok(()) } pub async fn project_cancel_invitation( &self, project_name: String, invitee_uid: Uuid, 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 invitation = project_member_invitations::Entity::find() .filter(project_member_invitations::Column::Project.eq(project.id)) .filter(project_member_invitations::Column::User.eq(invitee_uid)) .one(&self.db) .await? .ok_or(AppError::NotFound( "No invitation found for this user".to_string(), ))?; let role = self .utils_project_context_role(&ctx, project_name.clone()) .await .map_err(|_| AppError::NoPower)?; if role != MemberRole::Owner && role != MemberRole::Admin && invitation.invited_by != user_uid { return Err(AppError::NoPower); } let txn = self.db.begin().await.map_err(|_| AppError::InternalError)?; project_member_invitations::Entity::delete_many() .filter(project_member_invitations::Column::Project.eq(project.id)) .filter(project_member_invitations::Column::User.eq(invitee_uid)) .exec(&txn) .await?; let log = project_audit_log::ActiveModel { project: Set(project.id), actor: Set(user_uid), action: Set("cancel_invitation".to_string()), details: Set(Some(serde_json::json!({ "invitee_uid": invitee_uid, "project_name": project.name.clone(), }))), 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: "invitation_cancelled".to_string(), title: format!("{} cancelled invitation for user {}", user_uid, invitee_uid), repo_id: None, content: None, event_id: None, event_sub_id: None, metadata: Some(serde_json::json!({ "invitee_uid": invitee_uid, "project_name": project.name.clone(), })), is_private: false, }, ) .await; Ok(()) } }