507 lines
17 KiB
Rust
507 lines
17 KiB
Rust
use crate::AppService;
|
|
use crate::error::AppError;
|
|
use chrono::{DateTime, Utc};
|
|
use models::projects::{
|
|
MemberRole, project_audit_log, project_member_join_answers, project_member_join_request,
|
|
project_member_join_settings, project_members,
|
|
};
|
|
use models::users::user;
|
|
use sea_orm::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use session::Session;
|
|
use uuid::Uuid;
|
|
|
|
use super::join_answers::AnswerRequest;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct JoinRequestResponse {
|
|
pub id: i64,
|
|
pub project_uid: Uuid,
|
|
pub user_uid: Uuid,
|
|
pub username: String,
|
|
pub status: String,
|
|
pub message: Option<String>,
|
|
pub processed_by: Option<Uuid>,
|
|
pub processed_at: Option<DateTime<Utc>>,
|
|
pub reject_reason: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct JoinRequestListResponse {
|
|
pub requests: Vec<JoinRequestResponse>,
|
|
pub total: u64,
|
|
pub page: u64,
|
|
pub per_page: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
|
pub struct SubmitJoinRequest {
|
|
pub message: Option<String>,
|
|
pub answers: Vec<AnswerRequest>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
|
pub struct ProcessJoinRequest {
|
|
pub approve: bool,
|
|
pub scope: MemberRole,
|
|
pub reject_reason: Option<String>,
|
|
}
|
|
|
|
impl AppService {
|
|
pub async fn project_get_join_requests(
|
|
&self,
|
|
project_name: String,
|
|
status: Option<String>,
|
|
page: Option<u64>,
|
|
per_page: Option<u64>,
|
|
ctx: &Session,
|
|
) -> Result<JoinRequestListResponse, AppError> {
|
|
let _user_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 page = page.unwrap_or(1);
|
|
let per_page = per_page.unwrap_or(20);
|
|
|
|
let mut query = project_member_join_request::Entity::find()
|
|
.filter(project_member_join_request::Column::Project.eq(project.id));
|
|
|
|
if let Some(s) = status {
|
|
query = query.filter(project_member_join_request::Column::Status.eq(s));
|
|
}
|
|
|
|
let requests = query
|
|
.order_by_desc(project_member_join_request::Column::CreatedAt)
|
|
.paginate(&self.db, per_page)
|
|
.fetch_page(page - 1)
|
|
.await?;
|
|
|
|
let total = project_member_join_request::Entity::find()
|
|
.filter(project_member_join_request::Column::Project.eq(project.id))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
let user_ids: Vec<Uuid> = requests.iter().map(|r| r.user).collect();
|
|
let users_data = if user_ids.is_empty() {
|
|
vec![]
|
|
} else {
|
|
user::Entity::find()
|
|
.filter(user::Column::Uid.is_in(user_ids))
|
|
.all(&self.db)
|
|
.await?
|
|
};
|
|
|
|
let join_requests = requests
|
|
.into_iter()
|
|
.filter_map(|r| {
|
|
let username = users_data
|
|
.iter()
|
|
.find(|u| u.uid == r.user)
|
|
.map(|u| u.username.clone())?;
|
|
Some(JoinRequestResponse {
|
|
id: r.id,
|
|
project_uid: r.project,
|
|
user_uid: r.user,
|
|
username,
|
|
status: r.status,
|
|
message: r.message,
|
|
processed_by: r.processed_by,
|
|
processed_at: r.processed_at,
|
|
reject_reason: r.reject_reason,
|
|
created_at: r.created_at,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(JoinRequestListResponse {
|
|
requests: join_requests,
|
|
total,
|
|
page,
|
|
per_page,
|
|
})
|
|
}
|
|
|
|
pub async fn project_submit_join_request(
|
|
&self,
|
|
project_name: String,
|
|
request: SubmitJoinRequest,
|
|
ctx: &Session,
|
|
) -> Result<i64, AppError> {
|
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
|
let project = self
|
|
.utils_find_project_by_name(project_name.clone())
|
|
.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(&self.db)
|
|
.await?;
|
|
|
|
if existing_member.is_some() {
|
|
return Err(AppError::InternalServerError(
|
|
"Already a member of this project".to_string(),
|
|
));
|
|
}
|
|
|
|
let existing_request = project_member_join_request::Entity::find()
|
|
.filter(project_member_join_request::Column::Project.eq(project.id))
|
|
.filter(project_member_join_request::Column::User.eq(user_uid))
|
|
.filter(project_member_join_request::Column::Status.eq("pending"))
|
|
.one(&self.db)
|
|
.await?;
|
|
|
|
if existing_request.is_some() {
|
|
return Err(AppError::InternalServerError(
|
|
"Already have a pending join request".to_string(),
|
|
));
|
|
}
|
|
|
|
// Get join settings
|
|
let settings = project_member_join_settings::Entity::find()
|
|
.filter(project_member_join_settings::Column::Project.eq(project.id))
|
|
.one(&self.db)
|
|
.await?;
|
|
|
|
// Clone message for audit log before moving
|
|
let message = request.message.clone();
|
|
|
|
let txn = self.db.begin().await?;
|
|
|
|
let new_request = project_member_join_request::ActiveModel {
|
|
id: Default::default(),
|
|
project: Set(project.id),
|
|
user: Set(user_uid),
|
|
status: Set("pending".to_string()),
|
|
message: Set(request.message),
|
|
processed_by: Set(None),
|
|
processed_at: Set(None),
|
|
reject_reason: Set(None),
|
|
created_at: Set(Utc::now()),
|
|
updated_at: Set(Utc::now()),
|
|
};
|
|
let request_model = new_request.insert(&txn).await?;
|
|
let request_id = request_model.id;
|
|
|
|
// Save answers if questions are required
|
|
if let Some(ref s) = settings {
|
|
if s.require_questions {
|
|
for answer in request.answers {
|
|
let answer_model = project_member_join_answers::ActiveModel {
|
|
id: Default::default(),
|
|
project: Set(project.id),
|
|
user: Set(user_uid),
|
|
request_id: Set(request_id),
|
|
question: Set(answer.question),
|
|
answer: Set(answer.answer),
|
|
created_at: Set(Utc::now()),
|
|
};
|
|
answer_model.insert(&txn).await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
let log = project_audit_log::ActiveModel {
|
|
project: Set(project.id),
|
|
actor: Set(user_uid),
|
|
action: Set("submit_join_request".to_string()),
|
|
details: Set(Some(serde_json::json!({
|
|
"request_id": request_id,
|
|
"message": message,
|
|
}))),
|
|
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: "join_request_submit".to_string(),
|
|
title: format!("{} requested to join the project", user_uid),
|
|
repo_id: None,
|
|
content: None,
|
|
event_id: None,
|
|
event_sub_id: Some(request_id),
|
|
metadata: Some(serde_json::json!({
|
|
"request_id": request_id,
|
|
})),
|
|
is_private: false,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
Ok(request_id)
|
|
}
|
|
|
|
pub async fn project_process_join_request(
|
|
&self,
|
|
project_name: String,
|
|
request_id: i64,
|
|
process: ProcessJoinRequest,
|
|
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 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 join_request = project_member_join_request::Entity::find_by_id(request_id)
|
|
.filter(project_member_join_request::Column::Project.eq(project.id))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("Join request not found".to_string()))?;
|
|
|
|
if join_request.status != "pending" {
|
|
return Err(AppError::InternalServerError(
|
|
"Join request already processed".to_string(),
|
|
));
|
|
}
|
|
|
|
// Clone values before moving into ActiveModel
|
|
let request_user = join_request.user;
|
|
let action = if process.approve {
|
|
"approve_join_request"
|
|
} else {
|
|
"reject_join_request"
|
|
};
|
|
let reject_reason = process.reject_reason.clone();
|
|
|
|
let txn = self.db.begin().await?;
|
|
|
|
let mut active_request: project_member_join_request::ActiveModel = join_request.into();
|
|
if process.approve {
|
|
active_request.status = Set("approved".to_string());
|
|
active_request.processed_by = Set(Some(user_uid));
|
|
active_request.processed_at = Set(Some(Utc::now()));
|
|
|
|
// Add user as member
|
|
let member = project_members::ActiveModel {
|
|
id: Default::default(),
|
|
project: Set(project.id),
|
|
user: Set(request_user),
|
|
scope: Set(process.scope.to_string()),
|
|
joined_at: Set(Utc::now()),
|
|
};
|
|
member.insert(&txn).await?;
|
|
} else {
|
|
active_request.status = Set("rejected".to_string());
|
|
active_request.processed_by = Set(Some(user_uid));
|
|
active_request.processed_at = Set(Some(Utc::now()));
|
|
active_request.reject_reason = Set(process.reject_reason);
|
|
}
|
|
active_request.updated_at = Set(Utc::now());
|
|
active_request.update(&txn).await?;
|
|
|
|
let log = project_audit_log::ActiveModel {
|
|
project: Set(project.id),
|
|
actor: Set(user_uid),
|
|
action: Set(action.to_string()),
|
|
details: Set(Some(serde_json::json!({
|
|
"request_id": request_id,
|
|
"approved": process.approve,
|
|
"scope": process.scope.to_string(),
|
|
"reject_reason": reject_reason,
|
|
}))),
|
|
created_at: Set(Utc::now()),
|
|
..Default::default()
|
|
};
|
|
log.insert(&txn).await?;
|
|
|
|
txn.commit().await?;
|
|
|
|
let request_username = user::Entity::find_by_id(request_user)
|
|
.one(&self.db)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.map(|u| u.username)
|
|
.unwrap_or_else(|| request_user.to_string());
|
|
let event_type = if process.approve {
|
|
"join_request_approve"
|
|
} else {
|
|
"join_request_reject"
|
|
};
|
|
let _ = self
|
|
.project_log_activity(
|
|
project.id,
|
|
None,
|
|
user_uid,
|
|
super::activity::ActivityLogParams {
|
|
event_type: event_type.to_string(),
|
|
title: if process.approve {
|
|
format!("{} approved {}'s join request", user_uid, request_username)
|
|
} else {
|
|
format!("{} rejected {}'s join request", user_uid, request_username)
|
|
},
|
|
repo_id: None,
|
|
content: None,
|
|
event_id: None,
|
|
event_sub_id: Some(request_id),
|
|
metadata: Some(serde_json::json!({
|
|
"request_id": request_id,
|
|
"approved": process.approve,
|
|
})),
|
|
is_private: false,
|
|
},
|
|
)
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn project_cancel_join_request(
|
|
&self,
|
|
project_name: String,
|
|
request_id: i64,
|
|
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 join_request = project_member_join_request::Entity::find_by_id(request_id)
|
|
.filter(project_member_join_request::Column::Project.eq(project.id))
|
|
.filter(project_member_join_request::Column::User.eq(user_uid))
|
|
.one(&self.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound("Join request not found".to_string()))?;
|
|
|
|
if join_request.status != "pending" {
|
|
return Err(AppError::InternalServerError(
|
|
"Only pending requests can be cancelled".to_string(),
|
|
));
|
|
}
|
|
|
|
let txn = self.db.begin().await?;
|
|
|
|
let mut active_request: project_member_join_request::ActiveModel = join_request.into();
|
|
active_request.status = Set("cancelled".to_string());
|
|
active_request.updated_at = Set(Utc::now());
|
|
active_request.update(&txn).await?;
|
|
|
|
let log = project_audit_log::ActiveModel {
|
|
project: Set(project.id),
|
|
actor: Set(user_uid),
|
|
action: Set("cancel_join_request".to_string()),
|
|
details: Set(Some(serde_json::json!({
|
|
"request_id": request_id,
|
|
}))),
|
|
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: "join_request_cancel".to_string(),
|
|
title: format!("{} cancelled their join request", user_uid),
|
|
repo_id: None,
|
|
content: None,
|
|
event_id: None,
|
|
event_sub_id: Some(request_id),
|
|
metadata: Some(serde_json::json!({
|
|
"request_id": request_id,
|
|
})),
|
|
is_private: false,
|
|
},
|
|
)
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn project_get_my_join_requests(
|
|
&self,
|
|
page: Option<u64>,
|
|
per_page: Option<u64>,
|
|
ctx: &Session,
|
|
) -> Result<JoinRequestListResponse, 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 requests = project_member_join_request::Entity::find()
|
|
.filter(project_member_join_request::Column::User.eq(user_uid))
|
|
.order_by_desc(project_member_join_request::Column::CreatedAt)
|
|
.paginate(&self.db, per_page)
|
|
.fetch_page(page - 1)
|
|
.await?;
|
|
|
|
let total = project_member_join_request::Entity::find()
|
|
.filter(project_member_join_request::Column::User.eq(user_uid))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
let project_ids: Vec<_> = requests.iter().map(|r| r.project).collect();
|
|
let projects = if project_ids.is_empty() {
|
|
vec![]
|
|
} else {
|
|
models::projects::project::Entity::find()
|
|
.filter(models::projects::project::Column::Id.is_in(project_ids))
|
|
.all(&self.db)
|
|
.await?
|
|
};
|
|
|
|
let join_requests = requests
|
|
.into_iter()
|
|
.filter_map(|r| {
|
|
let project_name = projects
|
|
.iter()
|
|
.find(|p| p.id == r.project)
|
|
.map(|p| p.name.clone())?;
|
|
Some(JoinRequestResponse {
|
|
id: r.id,
|
|
project_uid: r.project,
|
|
user_uid: r.user,
|
|
username: project_name,
|
|
status: r.status,
|
|
message: r.message,
|
|
processed_by: r.processed_by,
|
|
processed_at: r.processed_at,
|
|
reject_reason: r.reject_reason,
|
|
created_at: r.created_at,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(JoinRequestListResponse {
|
|
requests: join_requests,
|
|
total,
|
|
page,
|
|
per_page,
|
|
})
|
|
}
|
|
}
|