use crate::AppService; use crate::error::AppError; use chrono::{Duration, Utc}; use email::EmailMessage; use models::WorkspaceRole; use models::users::{user, user_email}; use models::workspaces::workspace; use models::workspaces::workspace_membership; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct WorkspaceMemberInfo { pub user_id: Uuid, pub username: String, pub display_name: Option, pub avatar_url: Option, pub role: String, pub joined_at: chrono::DateTime, /// Username of the person who invited this member. pub invited_by_username: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct PendingInvitationInfo { pub user_id: Uuid, pub username: String, pub display_name: Option, pub avatar_url: Option, pub email: Option, pub role: String, pub invited_by_username: Option, pub invited_at: chrono::DateTime, pub expires_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct WorkspaceMembersResponse { pub members: Vec, pub total: u64, pub page: u64, pub per_page: u64, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct WorkspaceInviteParams { pub email: String, pub role: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct WorkspaceInviteAcceptParams { pub token: String, } impl AppService { pub async fn workspace_members( &self, ctx: &Session, workspace_slug: String, page: Option, per_page: Option, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; // Check membership let _ = self .utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Member]) .await; let page = page.unwrap_or(1); let per_page = per_page.unwrap_or(20); let memberships = workspace_membership::Entity::find() .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) .filter(workspace_membership::Column::Status.eq("active")) .order_by_desc(workspace_membership::Column::JoinedAt) .paginate(&self.db, per_page) .fetch_page(page - 1) .await?; let total = workspace_membership::Entity::find() .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) .filter(workspace_membership::Column::Status.eq("active")) .count(&self.db) .await?; let user_ids: Vec = memberships.iter().map(|m| m.user_id).collect(); let users = user::Entity::find() .filter(user::Column::Uid.is_in(user_ids)) .all(&self.db) .await?; // Collect invited_by user IDs let inviter_ids: Vec = memberships.iter().filter_map(|m| m.invited_by).collect(); let inviters = if !inviter_ids.is_empty() { user::Entity::find() .filter(user::Column::Uid.is_in(inviter_ids)) .all(&self.db) .await? } else { vec![] }; let members: Vec = memberships .into_iter() .filter_map(|m| { let u = users.iter().find(|u| u.uid == m.user_id)?; let invited_by_username = m.invited_by.and_then(|uid| { inviters .iter() .find(|i| i.uid == uid) .map(|i| i.username.clone()) }); Some(WorkspaceMemberInfo { user_id: u.uid, username: u.username.clone(), display_name: u.display_name.clone(), avatar_url: u.avatar_url.clone(), role: m.role, joined_at: m.joined_at, invited_by_username, }) }) .collect(); Ok(WorkspaceMembersResponse { members, total, page, per_page, }) } /// List pending (invited but not accepted) memberships for a workspace. pub async fn workspace_pending_invitations( &self, ctx: &Session, workspace_slug: String, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) .await?; let pending = workspace_membership::Entity::find() .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) .filter(workspace_membership::Column::Status.eq("pending")) .order_by_desc(workspace_membership::Column::JoinedAt) .all(&self.db) .await?; let user_ids: Vec = pending.iter().map(|m| m.user_id).collect(); let users = if !user_ids.is_empty() { user::Entity::find() .filter(user::Column::Uid.is_in(user_ids.clone())) .all(&self.db) .await? } else { vec![] }; // Get email addresses for invited users let emails: Vec<(Uuid, String)> = user_email::Entity::find() .filter(user_email::Column::User.is_in(user_ids)) .all(&self.db) .await? .into_iter() .map(|e| (e.user, e.email)) .collect(); // Get inviter usernames let inviter_ids: Vec = pending.iter().filter_map(|m| m.invited_by).collect(); let inviters = if !inviter_ids.is_empty() { user::Entity::find() .filter(user::Column::Uid.is_in(inviter_ids)) .all(&self.db) .await? } else { vec![] }; let invitations: Vec = pending .into_iter() .filter_map(|m| { let u = users.iter().find(|u| u.uid == m.user_id)?; let email = emails .iter() .find(|(uid, _)| *uid == m.user_id) .map(|(_, e)| e.clone()); let invited_by_username = m.invited_by.and_then(|uid| { inviters .iter() .find(|i| i.uid == uid) .map(|i| i.username.clone()) }); Some(PendingInvitationInfo { user_id: u.uid, username: u.username.clone(), display_name: u.display_name.clone(), avatar_url: u.avatar_url.clone(), email, role: m.role, invited_by_username, invited_at: m.joined_at, expires_at: m.invite_expires_at, }) }) .collect(); Ok(invitations) } /// Cancel a pending invitation (remove the pending membership record). pub async fn workspace_cancel_invitation( &self, ctx: &Session, workspace_slug: String, target_user_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) .await?; let deleted = workspace_membership::Entity::delete_many() .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) .filter(workspace_membership::Column::UserId.eq(target_user_id)) .filter(workspace_membership::Column::Status.eq("pending")) .exec(&self.db) .await?; if deleted.rows_affected == 0 { return Err(AppError::NotFound("Invitation not found".to_string())); } Ok(()) } pub async fn workspace_invite_member( &self, ctx: &Session, workspace_slug: String, params: WorkspaceInviteParams, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ws = self .utils_find_workspace_by_slug(workspace_slug.clone()) .await?; // Only owner/admin can invite self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) .await?; let inviter = self.utils_find_user_by_uid(user_uid).await?; // Find target user by email let target_email = user_email::Entity::find() .filter(user_email::Column::Email.eq(¶ms.email)) .one(&self.db) .await? .ok_or(AppError::UserNotFound)?; let target_user = self.utils_find_user_by_uid(target_email.user).await?; // Check if already a member if workspace_membership::Entity::find() .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) .filter(workspace_membership::Column::UserId.eq(target_user.uid)) .filter(workspace_membership::Column::Status.eq("active")) .one(&self.db) .await? .is_some() { return Err(AppError::BadRequest("User is already a member".to_string())); } // Generate invite token let token = generate_invite_token(); let expires_at = Utc::now() + Duration::days(7); // Create or update pending membership let existing: Option = workspace_membership::Entity::find() .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) .filter(workspace_membership::Column::UserId.eq(target_user.uid)) .one(&self.db) .await?; let txn = self.db.begin().await?; match existing { Some(m) => { let mut m: workspace_membership::ActiveModel = m.into(); m.invite_token = Set(Some(token.clone())); m.invite_expires_at = Set(Some(expires_at)); m.invited_by = Set(Some(user_uid)); m.role = Set(params .role .unwrap_or_else(|| WorkspaceRole::Member.to_string())); m.status = Set("pending".to_string()); m.update(&txn).await?; } None => { let m = workspace_membership::ActiveModel { id: Default::default(), workspace_id: Set(ws.id), user_id: Set(target_user.uid), role: Set(params .role .unwrap_or_else(|| WorkspaceRole::Member.to_string())), status: Set("pending".to_string()), invited_by: Set(Some(user_uid)), joined_at: Set(Utc::now()), invite_token: Set(Some(token.clone())), invite_expires_at: Set(Some(expires_at)), }; m.insert(&txn).await?; } } txn.commit().await?; // Send invitation email let domain = self .config .main_domain() .map_err(|_| AppError::DoMainNotSet)?; let invite_link = format!( "https://{}/auth/accept-workspace-invite?token={}", domain, token.clone() ); let envelope = EmailMessage { to: target_email.email.clone(), subject: format!("You've been invited to join {}", ws.name), body: format!( "Hello {},\n\n\ {} has invited you to join the workspace \"{}\".\n\n\ Click the link below to accept the invitation:\n\ {}\n\n\ This invitation expires in 7 days.\n\n\ Best regards,\n\ GitDataAI Team", target_user.username, inviter.username, ws.name, invite_link ), }; self.email.send(envelope).await.map_err(|e| { AppError::InternalServerError(format!("Failed to send invitation email: {}", e)) })?; Ok(()) } pub async fn workspace_accept_invitation( &self, ctx: &Session, params: WorkspaceInviteAcceptParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let membership = workspace_membership::Entity::find() .filter(workspace_membership::Column::InviteToken.eq(¶ms.token)) .one(&self.db) .await? .ok_or(AppError::WorkspaceInviteTokenInvalid)?; if membership.user_id != user_uid { return Err(AppError::WorkspaceInviteTokenInvalid); } if membership.status == "active" { return Err(AppError::WorkspaceInviteAlreadyAccepted); } if let Some(expires_at) = membership.invite_expires_at { if Utc::now() > expires_at { return Err(AppError::WorkspaceInviteExpired); } } let ws_id = membership.workspace_id; let mut m: workspace_membership::ActiveModel = membership.into(); m.status = Set("active".to_string()); m.invite_token = Set(None); m.invite_expires_at = Set(None); m.update(&self.db).await?; self.utils_find_workspace_by_id(ws_id).await } pub async fn workspace_remove_member( &self, ctx: &Session, workspace_slug: String, target_user_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; // Only owner/admin can remove members self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) .await?; // Cannot remove owner let target_membership = workspace_membership::Entity::find() .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) .filter(workspace_membership::Column::UserId.eq(target_user_id)) .one(&self.db) .await? .ok_or(AppError::NotWorkspaceMember)?; if target_membership.role == WorkspaceRole::Owner.to_string() { return Err(AppError::BadRequest( "Cannot remove workspace owner".to_string(), )); } workspace_membership::Entity::delete_many() .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) .filter(workspace_membership::Column::UserId.eq(target_user_id)) .exec(&self.db) .await?; Ok(()) } pub async fn workspace_update_member_role( &self, ctx: &Session, workspace_slug: String, target_user_id: Uuid, new_role: String, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) .await?; let target_role: WorkspaceRole = new_role.parse().map_err(|_| AppError::RoleParseError)?; let membership = workspace_membership::Entity::find() .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) .filter(workspace_membership::Column::UserId.eq(target_user_id)) .one(&self.db) .await? .ok_or(AppError::NotWorkspaceMember)?; // Cannot demote owner if membership.role == WorkspaceRole::Owner.to_string() && target_role != WorkspaceRole::Owner { return Err(AppError::BadRequest( "Cannot demote workspace owner".to_string(), )); } let mut m: workspace_membership::ActiveModel = membership.into(); m.role = Set(new_role); m.update(&self.db).await?; Ok(()) } } fn generate_invite_token() -> String { use rand::RngExt; use rand::distr::Alphanumeric; let token: String = rand::rng() .sample_iter(Alphanumeric) .take(64) .map(char::from) .collect(); format!("ws_inv_{}", token) }