482 lines
16 KiB
Rust
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(¶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::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(¶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)
|
|
}
|