504 lines
18 KiB
Rust
504 lines
18 KiB
Rust
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<DateTime<Utc>>,
|
|
pub rejected: bool,
|
|
pub rejected_at: Option<DateTime<Utc>>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct InvitationListResponse {
|
|
pub invitations: Vec<InvitationResponse>,
|
|
pub total: u64,
|
|
pub page: u64,
|
|
pub per_page: u64,
|
|
}
|
|
|
|
impl From<project_member_invitations::Model> 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<u64>,
|
|
per_page: Option<u64>,
|
|
ctx: &Session,
|
|
) -> Result<InvitationListResponse, AppError> {
|
|
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<u64>,
|
|
per_page: Option<u64>,
|
|
ctx: &Session,
|
|
) -> Result<InvitationListResponse, AppError> {
|
|
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(())
|
|
}
|
|
}
|