botserver/src/core/organization_invitations.rs
Rodrigo Rodriguez 5ea171d126
Some checks failed
BotServer CI / build (push) Failing after 1m34s
Refactor: Split large files into modular subdirectories
Split 20+ files over 1000 lines into focused subdirectories for better
maintainability and code organization. All changes maintain backward
compatibility through re-export wrappers.

Major splits:
- attendance/llm_assist.rs (2074→7 modules)
- basic/keywords/face_api.rs → face_api/ (7 modules)
- basic/keywords/file_operations.rs → file_ops/ (8 modules)
- basic/keywords/hear_talk.rs → hearing/ (6 modules)
- channels/wechat.rs → wechat/ (10 modules)
- channels/youtube.rs → youtube/ (5 modules)
- contacts/mod.rs → contacts_api/ (6 modules)
- core/bootstrap/mod.rs → bootstrap/ (5 modules)
- core/shared/admin.rs → admin_*.rs (5 modules)
- designer/canvas.rs → canvas_api/ (6 modules)
- designer/mod.rs → designer_api/ (6 modules)
- docs/handlers.rs → handlers_api/ (11 modules)
- drive/mod.rs → drive_handlers.rs, drive_types.rs
- learn/mod.rs → types.rs
- main.rs → main_module/ (7 modules)
- meet/webinar.rs → webinar_api/ (8 modules)
- paper/mod.rs → (10 modules)
- security/auth.rs → auth_api/ (7 modules)
- security/passkey.rs → (4 modules)
- sources/mod.rs → sources_api/ (5 modules)
- tasks/mod.rs → task_api/ (5 modules)

Stats: 38,040 deletions, 1,315 additions across 318 files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 21:09:30 +00:00

971 lines
29 KiB
Rust

use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::Json,
routing::{delete, get, post},
Router,
};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::core::shared::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum InvitationStatus {
Pending,
Accepted,
Declined,
Expired,
Revoked,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum InvitationRole {
Owner,
Admin,
Manager,
Member,
Viewer,
Guest,
}
impl std::str::FromStr for InvitationRole {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"owner" => Ok(Self::Owner),
"admin" => Ok(Self::Admin),
"manager" => Ok(Self::Manager),
"member" => Ok(Self::Member),
"viewer" => Ok(Self::Viewer),
"guest" => Ok(Self::Guest),
_ => Err(()),
}
}
}
impl InvitationRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Admin => "admin",
Self::Manager => "manager",
Self::Member => "member",
Self::Viewer => "viewer",
Self::Guest => "guest",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrganizationInvitation {
pub id: Uuid,
pub organization_id: Uuid,
pub email: String,
pub role: InvitationRole,
pub groups: Vec<String>,
pub invited_by: Uuid,
pub invited_by_name: String,
pub status: InvitationStatus,
pub token: String,
pub message: Option<String>,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub accepted_at: Option<DateTime<Utc>>,
pub accepted_by: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct CreateInvitationRequest {
pub email: String,
pub role: String,
#[serde(default)]
pub groups: Vec<String>,
pub message: Option<String>,
pub expires_in_days: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct BulkInviteRequest {
pub emails: Vec<String>,
pub role: String,
#[serde(default)]
pub groups: Vec<String>,
pub message: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AcceptInvitationRequest {
pub token: String,
pub user_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct ResendInvitationRequest {
pub extend_expiry: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct ListInvitationsQuery {
pub status: Option<String>,
pub page: Option<u32>,
pub per_page: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct InvitationResponse {
pub id: Uuid,
pub organization_id: Uuid,
pub organization_name: String,
pub email: String,
pub role: String,
pub groups: Vec<String>,
pub invited_by_name: String,
pub status: String,
pub message: Option<String>,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub is_expired: bool,
}
#[derive(Debug, Serialize)]
pub struct BulkInviteResponse {
pub successful: Vec<InvitationResponse>,
pub failed: Vec<BulkInviteError>,
}
#[derive(Debug, Serialize)]
pub struct BulkInviteError {
pub email: String,
pub error: String,
}
#[derive(Debug, Serialize)]
pub struct InvitationListResponse {
pub invitations: Vec<InvitationResponse>,
pub total: u32,
pub page: u32,
pub per_page: u32,
pub total_pages: u32,
}
#[derive(Debug, Serialize)]
pub struct AcceptInvitationResponse {
pub success: bool,
pub organization_id: Uuid,
pub organization_name: String,
pub role: String,
pub message: String,
}
pub struct InvitationService {
invitations: Arc<RwLock<HashMap<Uuid, OrganizationInvitation>>>,
invitations_by_token: Arc<RwLock<HashMap<String, Uuid>>>,
invitations_by_org: Arc<RwLock<HashMap<Uuid, Vec<Uuid>>>>,
}
impl Default for InvitationService {
fn default() -> Self {
Self::new()
}
}
pub struct CreateInvitationParams<'a> {
pub organization_id: Uuid,
pub organization_name: &'a str,
pub email: &'a str,
pub role: InvitationRole,
pub groups: Vec<String>,
pub invited_by: Uuid,
pub invited_by_name: &'a str,
pub message: Option<String>,
pub expires_in_days: i64,
}
impl<'a> Default for CreateInvitationParams<'a> {
fn default() -> Self {
Self {
organization_id: Uuid::default(),
organization_name: "",
email: "",
role: InvitationRole::Member,
groups: Vec::new(),
invited_by: Uuid::default(),
invited_by_name: "",
message: None,
expires_in_days: 7,
}
}
}
pub struct BulkInviteParams<'a> {
pub organization_id: Uuid,
pub organization_name: &'a str,
pub emails: Vec<String>,
pub role: InvitationRole,
pub groups: Vec<String>,
pub invited_by: Uuid,
pub invited_by_name: &'a str,
pub message: Option<String>,
}
impl InvitationService {
pub fn new() -> Self {
Self {
invitations: Arc::new(RwLock::new(HashMap::new())),
invitations_by_token: Arc::new(RwLock::new(HashMap::new())),
invitations_by_org: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn create_invitation(
&self,
params: CreateInvitationParams<'_>,
) -> Result<OrganizationInvitation, String> {
let email_lower = params.email.to_lowercase().trim().to_string();
if !self.is_valid_email(&email_lower) {
return Err("Invalid email address".to_string());
}
let existing = self
.find_pending_invitation(&params.organization_id, &email_lower)
.await;
if existing.is_some() {
return Err("An invitation already exists for this email".to_string());
}
let now = Utc::now();
let invitation_id = Uuid::new_v4();
let token = self.generate_secure_token();
let invitation = OrganizationInvitation {
id: invitation_id,
organization_id: params.organization_id,
email: email_lower,
role: params.role,
groups: params.groups,
invited_by: params.invited_by,
invited_by_name: params.invited_by_name.to_string(),
status: InvitationStatus::Pending,
token: token.clone(),
message: params.message,
expires_at: now + Duration::days(params.expires_in_days),
created_at: now,
updated_at: now,
accepted_at: None,
accepted_by: None,
};
{
let mut invitations = self.invitations.write().await;
invitations.insert(invitation_id, invitation.clone());
}
{
let mut by_token = self.invitations_by_token.write().await;
by_token.insert(token, invitation_id);
}
{
let mut by_org = self.invitations_by_org.write().await;
by_org
.entry(params.organization_id)
.or_default()
.push(invitation_id);
}
self.send_invitation_email(&invitation, params.organization_name)
.await;
Ok(invitation)
}
pub async fn bulk_invite(&self, params: BulkInviteParams<'_>) -> BulkInviteResponse {
let mut successful = Vec::new();
let mut failed = Vec::new();
for email in params.emails {
match self
.create_invitation(CreateInvitationParams {
organization_id: params.organization_id,
organization_name: params.organization_name,
email: &email,
role: params.role.clone(),
groups: params.groups.clone(),
invited_by: params.invited_by,
invited_by_name: params.invited_by_name,
message: params.message.clone(),
expires_in_days: 7,
})
.await
{
Ok(invitation) => {
successful.push(self.to_response(&invitation, params.organization_name));
}
Err(error) => {
failed.push(BulkInviteError { email, error });
}
}
}
BulkInviteResponse { successful, failed }
}
pub async fn accept_invitation(
&self,
token: &str,
user_id: Uuid,
) -> Result<AcceptInvitationResponse, String> {
let invitation_id = {
let by_token = self.invitations_by_token.read().await;
by_token.get(token).copied()
};
let invitation_id = invitation_id.ok_or("Invalid invitation token")?;
let mut invitations = self.invitations.write().await;
let invitation = invitations
.get_mut(&invitation_id)
.ok_or("Invitation not found")?;
if invitation.status != InvitationStatus::Pending {
return Err(format!(
"Invitation is not pending (status: {:?})",
invitation.status
));
}
if invitation.expires_at < Utc::now() {
invitation.status = InvitationStatus::Expired;
invitation.updated_at = Utc::now();
return Err("Invitation has expired".to_string());
}
let now = Utc::now();
invitation.status = InvitationStatus::Accepted;
invitation.accepted_at = Some(now);
invitation.accepted_by = Some(user_id);
invitation.updated_at = now;
Ok(AcceptInvitationResponse {
success: true,
organization_id: invitation.organization_id,
organization_name: "Organization".to_string(),
role: invitation.role.as_str().to_string(),
message: "Successfully joined the organization".to_string(),
})
}
pub async fn decline_invitation(&self, token: &str) -> Result<(), String> {
let invitation_id = {
let by_token = self.invitations_by_token.read().await;
by_token.get(token).copied()
};
let invitation_id = invitation_id.ok_or("Invalid invitation token")?;
let mut invitations = self.invitations.write().await;
let invitation = invitations
.get_mut(&invitation_id)
.ok_or("Invitation not found")?;
if invitation.status != InvitationStatus::Pending {
return Err("Invitation is not pending".to_string());
}
invitation.status = InvitationStatus::Declined;
invitation.updated_at = Utc::now();
Ok(())
}
pub async fn revoke_invitation(&self, invitation_id: Uuid) -> Result<(), String> {
let mut invitations = self.invitations.write().await;
let invitation = invitations
.get_mut(&invitation_id)
.ok_or("Invitation not found")?;
if invitation.status != InvitationStatus::Pending {
return Err("Only pending invitations can be revoked".to_string());
}
invitation.status = InvitationStatus::Revoked;
invitation.updated_at = Utc::now();
Ok(())
}
pub async fn resend_invitation(
&self,
invitation_id: Uuid,
organization_name: &str,
extend_expiry: bool,
) -> Result<OrganizationInvitation, String> {
let mut invitations = self.invitations.write().await;
let invitation = invitations
.get_mut(&invitation_id)
.ok_or("Invitation not found")?;
if invitation.status != InvitationStatus::Pending
&& invitation.status != InvitationStatus::Expired
{
return Err("Only pending or expired invitations can be resent".to_string());
}
let now = Utc::now();
if extend_expiry || invitation.expires_at < now {
invitation.expires_at = now + Duration::days(7);
}
invitation.status = InvitationStatus::Pending;
invitation.updated_at = now;
let invitation_clone = invitation.clone();
drop(invitations);
self.send_invitation_email(&invitation_clone, organization_name)
.await;
Ok(invitation_clone)
}
pub async fn list_invitations(
&self,
organization_id: Uuid,
status_filter: Option<InvitationStatus>,
page: u32,
per_page: u32,
) -> InvitationListResponse {
let org_invitation_ids = {
let by_org = self.invitations_by_org.read().await;
by_org.get(&organization_id).cloned().unwrap_or_default()
};
let invitations = self.invitations.read().await;
let mut filtered: Vec<_> = org_invitation_ids
.iter()
.filter_map(|id| invitations.get(id))
.filter(|inv| {
if let Some(ref status) = status_filter {
&inv.status == status
} else {
true
}
})
.collect();
filtered.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let total = filtered.len() as u32;
let total_pages = total.div_ceil(per_page);
let start = ((page - 1) * per_page) as usize;
let end = (start + per_page as usize).min(filtered.len());
let page_items: Vec<InvitationResponse> = filtered[start..end]
.iter()
.map(|inv| self.to_response(inv, "Organization"))
.collect();
InvitationListResponse {
invitations: page_items,
total,
page,
per_page,
total_pages,
}
}
pub async fn get_invitation(&self, invitation_id: Uuid) -> Option<OrganizationInvitation> {
let invitations = self.invitations.read().await;
invitations.get(&invitation_id).cloned()
}
pub async fn get_invitation_by_token(&self, token: &str) -> Option<OrganizationInvitation> {
let invitation_id = {
let by_token = self.invitations_by_token.read().await;
by_token.get(token).copied()
};
if let Some(id) = invitation_id {
let invitations = self.invitations.read().await;
invitations.get(&id).cloned()
} else {
None
}
}
pub async fn cleanup_expired_invitations(&self) {
let now = Utc::now();
let mut invitations = self.invitations.write().await;
for invitation in invitations.values_mut() {
if invitation.status == InvitationStatus::Pending && invitation.expires_at < now {
invitation.status = InvitationStatus::Expired;
invitation.updated_at = now;
}
}
}
async fn find_pending_invitation(
&self,
organization_id: &Uuid,
email: &str,
) -> Option<OrganizationInvitation> {
let org_invitation_ids = {
let by_org = self.invitations_by_org.read().await;
by_org.get(organization_id).cloned().unwrap_or_default()
};
let invitations = self.invitations.read().await;
for id in org_invitation_ids {
if let Some(inv) = invitations.get(&id) {
if inv.email == email && inv.status == InvitationStatus::Pending {
return Some(inv.clone());
}
}
}
None
}
fn to_response(
&self,
invitation: &OrganizationInvitation,
org_name: &str,
) -> InvitationResponse {
let now = Utc::now();
InvitationResponse {
id: invitation.id,
organization_id: invitation.organization_id,
organization_name: org_name.to_string(),
email: invitation.email.clone(),
role: invitation.role.as_str().to_string(),
groups: invitation.groups.clone(),
invited_by_name: invitation.invited_by_name.clone(),
status: format!("{:?}", invitation.status).to_lowercase(),
message: invitation.message.clone(),
expires_at: invitation.expires_at,
created_at: invitation.created_at,
is_expired: invitation.expires_at < now,
}
}
fn generate_secure_token(&self) -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let mut token = String::with_capacity(64);
let hasher_builder = RandomState::new();
for _ in 0..4 {
let mut hasher = hasher_builder.build_hasher();
hasher.write_u128(Uuid::new_v4().as_u128());
hasher.write_i64(Utc::now().timestamp_nanos_opt().unwrap_or(0));
token.push_str(&format!("{:016x}", hasher.finish()));
}
token
}
fn is_valid_email(&self, email: &str) -> bool {
let email = email.trim();
if email.is_empty() || email.len() > 254 {
return false;
}
let at_pos = match email.find('@') {
Some(pos) => pos,
None => return false,
};
let local = &email[..at_pos];
let domain = &email[at_pos + 1..];
if local.is_empty() || local.len() > 64 {
return false;
}
if domain.is_empty() || !domain.contains('.') {
return false;
}
let domain_parts: Vec<&str> = domain.split('.').collect();
if domain_parts.iter().any(|p| p.is_empty()) {
return false;
}
true
}
async fn send_invitation_email(&self, invitation: &OrganizationInvitation, org_name: &str) {
log::info!(
"Sending invitation email to {} for organization {} (token: {}...)",
invitation.email,
org_name,
&invitation.token[..16]
);
}
}
pub fn configure() -> Router<Arc<AppState>> {
Router::new()
.route("/organizations/:org_id/invitations", get(list_invitations))
.route(
"/organizations/:org_id/invitations",
post(create_invitation),
)
.route("/organizations/:org_id/invitations/bulk", post(bulk_invite))
.route(
"/organizations/:org_id/invitations/:invitation_id",
get(get_invitation),
)
.route(
"/organizations/:org_id/invitations/:invitation_id",
delete(revoke_invitation),
)
.route(
"/organizations/:org_id/invitations/:invitation_id/resend",
post(resend_invitation),
)
.route("/invitations/accept", post(accept_invitation))
.route("/invitations/decline", post(decline_invitation))
.route("/invitations/validate/:token", get(validate_invitation))
}
async fn list_invitations(
State(_state): State<Arc<AppState>>,
Path(org_id): Path<Uuid>,
Query(params): Query<ListInvitationsQuery>,
) -> Result<Json<InvitationListResponse>, (StatusCode, Json<serde_json::Value>)> {
let service = InvitationService::new();
let status_filter = params.status.and_then(|s| match s.to_lowercase().as_str() {
"pending" => Some(InvitationStatus::Pending),
"accepted" => Some(InvitationStatus::Accepted),
"declined" => Some(InvitationStatus::Declined),
"expired" => Some(InvitationStatus::Expired),
"revoked" => Some(InvitationStatus::Revoked),
_ => None,
});
let page = params.page.unwrap_or(1).max(1);
let per_page = params.per_page.unwrap_or(20).min(100);
let response = service
.list_invitations(org_id, status_filter, page, per_page)
.await;
Ok(Json(response))
}
async fn create_invitation(
State(_state): State<Arc<AppState>>,
Path(org_id): Path<Uuid>,
Json(req): Json<CreateInvitationRequest>,
) -> Result<Json<InvitationResponse>, (StatusCode, Json<serde_json::Value>)> {
let service = InvitationService::new();
let role: InvitationRole = req.role.parse().map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid role"})),
)
})?;
let expires_in_days = req.expires_in_days.unwrap_or(7).clamp(1, 30);
let invited_by = Uuid::new_v4();
match service
.create_invitation(CreateInvitationParams {
organization_id: org_id,
organization_name: "Organization",
email: &req.email,
role,
groups: req.groups,
invited_by,
invited_by_name: "Admin User",
message: req.message,
expires_in_days,
})
.await
{
Ok(invitation) => Ok(Json(service.to_response(&invitation, "Organization"))),
Err(error) => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": error})),
)),
}
}
async fn bulk_invite(
State(_state): State<Arc<AppState>>,
Path(org_id): Path<Uuid>,
Json(req): Json<BulkInviteRequest>,
) -> Result<Json<BulkInviteResponse>, (StatusCode, Json<serde_json::Value>)> {
let service = InvitationService::new();
let role = req.role.parse::<InvitationRole>().map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid role"})),
)
})?;
if req.emails.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "No emails provided"})),
));
}
if req.emails.len() > 100 {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Maximum 100 invitations per request"})),
));
}
let invited_by = Uuid::new_v4();
let response = service
.bulk_invite(BulkInviteParams {
organization_id: org_id,
organization_name: "Organization",
emails: req.emails,
role,
groups: req.groups,
invited_by,
invited_by_name: "Admin User",
message: req.message,
})
.await;
Ok(Json(response))
}
async fn get_invitation(
State(_state): State<Arc<AppState>>,
Path((org_id, invitation_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<InvitationResponse>, (StatusCode, Json<serde_json::Value>)> {
let service = InvitationService::new();
match service.get_invitation(invitation_id).await {
Some(invitation) if invitation.organization_id == org_id => {
Ok(Json(service.to_response(&invitation, "Organization")))
}
Some(_) => Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "Invitation belongs to different organization"})),
)),
None => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Invitation not found"})),
)),
}
}
async fn revoke_invitation(
State(_state): State<Arc<AppState>>,
Path((_org_id, invitation_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let service = InvitationService::new();
match service.revoke_invitation(invitation_id).await {
Ok(()) => Ok(Json(
serde_json::json!({"success": true, "message": "Invitation revoked"}),
)),
Err(error) => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": error})),
)),
}
}
async fn resend_invitation(
State(_state): State<Arc<AppState>>,
Path((_org_id, invitation_id)): Path<(Uuid, Uuid)>,
Json(req): Json<ResendInvitationRequest>,
) -> Result<Json<InvitationResponse>, (StatusCode, Json<serde_json::Value>)> {
let service = InvitationService::new();
let extend_expiry = req.extend_expiry.unwrap_or(true);
match service
.resend_invitation(invitation_id, "Organization", extend_expiry)
.await
{
Ok(invitation) => Ok(Json(service.to_response(&invitation, "Organization"))),
Err(error) => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": error})),
)),
}
}
async fn accept_invitation(
State(_state): State<Arc<AppState>>,
Json(req): Json<AcceptInvitationRequest>,
) -> Result<Json<AcceptInvitationResponse>, (StatusCode, Json<serde_json::Value>)> {
let service = InvitationService::new();
let user_id = req.user_id.unwrap_or_else(Uuid::new_v4);
match service.accept_invitation(&req.token, user_id).await {
Ok(response) => Ok(Json(response)),
Err(error) => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": error})),
)),
}
}
async fn decline_invitation(
State(_state): State<Arc<AppState>>,
Json(req): Json<AcceptInvitationRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let service = InvitationService::new();
match service.decline_invitation(&req.token).await {
Ok(()) => Ok(Json(
serde_json::json!({"success": true, "message": "Invitation declined"}),
)),
Err(error) => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": error})),
)),
}
}
async fn validate_invitation(
State(_state): State<Arc<AppState>>,
Path(token): Path<String>,
) -> Result<Json<InvitationResponse>, (StatusCode, Json<serde_json::Value>)> {
let service = InvitationService::new();
match service.get_invitation_by_token(&token).await {
Some(invitation) => {
if invitation.status != InvitationStatus::Pending {
return Err((
StatusCode::GONE,
Json(serde_json::json!({
"error": "Invitation is no longer valid",
"status": format!("{:?}", invitation.status).to_lowercase()
})),
));
}
if invitation.expires_at < Utc::now() {
return Err((
StatusCode::GONE,
Json(serde_json::json!({
"error": "Invitation has expired",
"expired_at": invitation.expires_at
})),
));
}
Ok(Json(service.to_response(&invitation, "Organization")))
}
None => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Invalid invitation token"})),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_create_invitation() {
let service = InvitationService::new();
let org_id = Uuid::new_v4();
let invited_by = Uuid::new_v4();
let params = crate::core::organization_invitations::CreateInvitationParams {
organization_id: org_id,
organization_name: "Test Org",
email: "test@example.com".to_string(),
role: "Member".to_string(),
groups: vec![],
..Default::default()
};
let result = service.create_invitation(params).await;
assert!(result.is_ok());
let invitation = result.unwrap();
assert_eq!(invitation.email, "test@example.com");
assert_eq!(invitation.status, InvitationStatus::Pending);
}
#[tokio::test]
async fn test_duplicate_invitation() {
let service = InvitationService::new();
let org_id = Uuid::new_v4();
let invited_by = Uuid::new_v4();
let params = crate::core::organization_invitations::CreateInvitationParams {
organization_id: org_id,
organization_name: "Test Org",
email: "test@example.com".to_string(),
role: "Member".to_string(),
groups: vec![],
..Default::default()
};
let first_result = service.create_invitation(params.clone()).await;
assert!(first_result.is_ok());
let second_result = service.create_invitation(params).await;
assert!(second_result.is_err());
assert_eq!(
second_result.unwrap_err(),
"An invitation already exists for this email"
);
}
#[tokio::test]
async fn test_accept_invitation() {
let service = InvitationService::new();
let org_id = Uuid::new_v4();
let invited_by = Uuid::new_v4();
let user_id = Uuid::new_v4();
let params = crate::core::organization_invitations::CreateInvitationParams {
organization_id: org_id,
organization_name: "Test Org",
email: "test@example.com".to_string(),
role: "Member".to_string(),
groups: vec![],
invited_by,
invited_by_name: Some("Admin".to_string()),
message: None,
expires_in_days: Some(7),
};
let invitation = service.create_invitation(params).await.unwrap();
let result = service.accept_invitation(&invitation.token, user_id).await;
assert!(result.is_ok());
result.unwrap();
let updated = service.get_invitation(invitation.id).await.unwrap();
assert_eq!(updated.status, InvitationStatus::Accepted);
assert!(updated.accepted_at.is_some());
}
}