gitdataai/libs/service/workspace/members.rs
2026-04-14 19:02:01 +08:00

482 lines
16 KiB
Rust

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<String>,
pub avatar_url: Option<String>,
pub role: String,
pub joined_at: chrono::DateTime<Utc>,
/// Username of the person who invited this member.
pub invited_by_username: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PendingInvitationInfo {
pub user_id: Uuid,
pub username: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub email: Option<String>,
pub role: String,
pub invited_by_username: Option<String>,
pub invited_at: chrono::DateTime<Utc>,
pub expires_at: Option<chrono::DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceMembersResponse {
pub members: Vec<WorkspaceMemberInfo>,
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<String>,
}
#[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<u64>,
per_page: Option<u64>,
) -> Result<WorkspaceMembersResponse, AppError> {
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<Uuid> = 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<Uuid> = 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<WorkspaceMemberInfo> = 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<Vec<PendingInvitationInfo>, 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<Uuid> = 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<Uuid> = 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<PendingInvitationInfo> = 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(&params.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::Model> = 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<workspace::Model, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let membership = workspace_membership::Entity::find()
.filter(workspace_membership::Column::InviteToken.eq(&params.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)
}