gitdataai/libs/service/project/members.rs

609 lines
20 KiB
Rust

use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use models::projects::{MemberRole, project_audit_log, project_members};
use models::rooms::{room, room_user_state};
use models::users::user;
use sea_orm::sea_query::OnConflict;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct MemberInfo {
pub user_id: Uuid,
pub username: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub scope: MemberRole,
pub joined_at: chrono::DateTime<Utc>,
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct MemberListResponse {
pub members: Vec<MemberInfo>,
pub total: u64,
pub page: u64,
pub per_page: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct UpdateMemberRoleRequest {
pub user_id: Uuid,
pub scope: MemberRole,
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct MemberGroup {
pub role: String,
pub members: Vec<MemberInfo>,
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct GroupedMemberListResponse {
pub groups: Vec<MemberGroup>,
pub total: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct RolePriorityInfo {
pub id: i64,
pub role_key: String,
pub display_name: String,
pub priority: i32,
pub color: Option<String>,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct RolePriorityListResponse {
pub roles: Vec<RolePriorityInfo>,
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct UpsertRolePriorityRequest {
pub role_key: String,
pub display_name: String,
pub priority: i32,
pub color: Option<String>,
}
impl AppService {
pub async fn project_get_members(
&self,
project_name: String,
page: Option<u64>,
per_page: Option<u64>,
ctx: &Session,
) -> Result<MemberListResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self
.utils_find_project_by_name(project_name.clone())
.await?;
let _requester_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?;
let page = page.unwrap_or(1);
let per_page = per_page.unwrap_or(20);
let members = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.order_by_asc(project_members::Column::JoinedAt)
.paginate(&self.db, per_page)
.fetch_page(page - 1)
.await?;
let total = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.count(&self.db)
.await?;
let user_ids: Vec<Uuid> = members.iter().map(|m| m.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 member_infos: Vec<MemberInfo> = members
.into_iter()
.filter_map(|member| {
let role = member.scope_role().ok()?;
users_data
.iter()
.find(|u| u.uid == member.user)
.map(|user| MemberInfo {
user_id: user.uid,
username: user.username.clone(),
display_name: user.display_name.clone(),
avatar_url: user.avatar_url.clone(),
scope: role.clone(),
joined_at: member.joined_at,
})
})
.collect();
Ok(MemberListResponse {
members: member_infos,
total,
page,
per_page,
})
}
pub async fn project_get_members_grouped(
&self,
project_name: String,
ctx: &Session,
) -> Result<GroupedMemberListResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self.utils_find_project_by_name(project_name).await?;
let _requester_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?;
let members = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.order_by_asc(project_members::Column::JoinedAt)
.all(&self.db)
.await?;
let total = members.len() as u64;
let user_ids: Vec<Uuid> = members.iter().map(|m| m.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 member_infos: Vec<MemberInfo> = members
.into_iter()
.filter_map(|member| {
let role = member.scope_role().ok()?;
users_data
.iter()
.find(|u| u.uid == member.user)
.map(|user| MemberInfo {
user_id: user.uid,
username: user.username.clone(),
display_name: user.display_name.clone(),
avatar_url: user.avatar_url.clone(),
scope: role.clone(),
joined_at: member.joined_at,
})
})
.collect();
let mut groups: std::collections::BTreeMap<String, Vec<MemberInfo>> =
std::collections::BTreeMap::new();
for m in member_infos {
let role_str = m.scope.to_string();
groups.entry(role_str).or_default().push(m);
}
let role_priority = vec!["owner", "admin", "member"];
let mut sorted_groups: Vec<MemberGroup> = groups
.into_iter()
.map(|(role, members)| MemberGroup { role, members })
.collect();
sorted_groups.sort_by(|a, b| {
let pa = role_priority
.iter()
.position(|&r| r == a.role)
.unwrap_or(99);
let pb = role_priority
.iter()
.position(|&r| r == b.role)
.unwrap_or(99);
pa.cmp(&pb)
});
Ok(GroupedMemberListResponse {
groups: sorted_groups,
total,
})
}
pub async fn project_update_member_role(
&self,
project_name: String,
request: UpdateMemberRoleRequest,
ctx: &Session,
) -> Result<(), AppError> {
let actor_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self
.utils_find_project_by_name(project_name.clone())
.await?;
let actor_member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.filter(project_members::Column::User.eq(actor_uid))
.one(&self.db)
.await?
.ok_or(AppError::PermissionDenied)?;
let actor_role = actor_member
.scope_role()
.map_err(|_| AppError::RoleParseError)?;
if actor_role != MemberRole::Owner && actor_role != MemberRole::Admin {
return Err(AppError::NoPower);
}
let target_member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.filter(project_members::Column::User.eq(request.user_id))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Member not found".to_string()))?;
let target_role = target_member
.scope_role()
.map_err(|_| AppError::RoleParseError)?;
if target_role == MemberRole::Owner {
return Err(AppError::NoPower);
}
if request.scope == MemberRole::Admin && actor_role != MemberRole::Owner {
return Err(AppError::NoPower);
}
if request.scope == MemberRole::Owner {
return Err(AppError::NoPower);
}
let mut active_member: project_members::ActiveModel = target_member.into();
active_member.scope = Set(request.scope.to_string());
active_member.update(&self.db).await?;
let actor_username = user::Entity::find_by_id(actor_uid)
.one(&self.db)
.await
.ok()
.flatten()
.map(|u| u.username)
.unwrap_or_default();
let target_username = user::Entity::find_by_id(request.user_id)
.one(&self.db)
.await
.ok()
.flatten()
.map(|u| u.username)
.unwrap_or_default();
let _ = self
.project_log_activity(
project.id,
None,
actor_uid,
super::activity::ActivityLogParams {
event_type: "member_role_change".to_string(),
title: format!(
"{} changed {}'s role to {}",
actor_username, target_username, request.scope
),
repo_id: None,
content: None,
event_id: None,
event_sub_id: None,
metadata: Some(serde_json::json!({
"target_user_id": request.user_id.to_string(),
"new_role": request.scope.to_string(),
})),
is_private: false,
},
)
.await;
let log = project_audit_log::ActiveModel {
project: Set(project.id),
actor: Set(actor_uid),
action: Set("update_member_role".to_string()),
details: Set(Some(serde_json::json!({
"project_name": project.name,
"target_user_id": request.user_id,
"new_role": request.scope.to_string(),
}))),
created_at: Set(Utc::now()),
..Default::default()
};
log.insert(&self.db).await?;
Ok(())
}
pub async fn project_remove_member(
&self,
project_name: String,
user_id: Uuid,
ctx: &Session,
) -> Result<(), AppError> {
let actor_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self
.utils_find_project_by_name(project_name.clone())
.await?;
let actor_member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.filter(project_members::Column::User.eq(actor_uid))
.one(&self.db)
.await?
.ok_or(AppError::PermissionDenied)?;
let actor_role = actor_member
.scope_role()
.map_err(|_| AppError::RoleParseError)?;
if actor_role != MemberRole::Owner && actor_role != MemberRole::Admin {
return Err(AppError::NoPower);
}
let target_member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.filter(project_members::Column::User.eq(user_id))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Member not found".to_string()))?;
let target_role = target_member
.scope_role()
.map_err(|_| AppError::RoleParseError)?;
if target_role == MemberRole::Owner {
return Err(AppError::NoPower);
}
if actor_role == MemberRole::Admin && target_role == MemberRole::Admin {
return Err(AppError::NoPower);
}
project_members::Entity::delete_many()
.filter(project_members::Column::Project.eq(project.id))
.filter(project_members::Column::User.eq(user_id))
.exec(&self.db)
.await?;
let actor_username = user::Entity::find_by_id(actor_uid)
.one(&self.db)
.await
.ok()
.flatten()
.map(|u| u.username)
.unwrap_or_default();
let target_username = user::Entity::find_by_id(user_id)
.one(&self.db)
.await
.ok()
.flatten()
.map(|u| u.username)
.unwrap_or_default();
let _ = self
.project_log_activity(
project.id,
None,
actor_uid,
super::activity::ActivityLogParams {
event_type: "member_remove".to_string(),
title: format!(
"{} removed {} from the project",
actor_username, target_username
),
repo_id: None,
content: None,
event_id: None,
event_sub_id: None,
metadata: Some(serde_json::json!({
"removed_user_id": user_id.to_string(),
})),
is_private: false,
},
)
.await;
let log = project_audit_log::ActiveModel {
project: Set(project.id),
actor: Set(actor_uid),
action: Set("remove_member".to_string()),
details: Set(Some(serde_json::json!({
"project_name": project.name,
"removed_user_id": user_id,
}))),
created_at: Set(Utc::now()),
..Default::default()
};
log.insert(&self.db).await?;
Ok(())
}
/// Creates room_user_state entries for all public rooms + private rooms the user should access.
pub async fn add_user_to_all_project_rooms(
db: &impl ConnectionTrait,
project_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError> {
let rooms: Vec<room::Model> = room::Entity::find()
.filter(room::Column::Project.eq(project_id))
.all(db)
.await?;
if rooms.is_empty() {
return Ok(());
}
let now = Utc::now();
// Create room_user_state for all rooms (public + private)
let values: Vec<room_user_state::ActiveModel> = rooms
.iter()
.map(|r| room_user_state::ActiveModel {
room: Set(r.id),
user: Set(user_id),
last_read_seq: Set(None),
do_not_disturb: Set(false),
dnd_start_hour: Set(None),
dnd_end_hour: Set(None),
joined_at: Set(Some(now)),
})
.collect();
room_user_state::Entity::insert_many(values)
.on_conflict(OnConflict::new().do_nothing().to_owned())
.exec(db)
.await?;
Ok(())
}
pub async fn project_get_role_priorities(
&self,
project_name: String,
ctx: &Session,
) -> Result<RolePriorityListResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self.utils_find_project_by_name(project_name).await?;
let _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?;
let roles = models::projects::ProjectRolePriority::find()
.filter(models::projects::project_role_priority::Column::Project.eq(project.id))
.order_by_asc(models::projects::project_role_priority::Column::Priority)
.all(&self.db)
.await?;
let infos: Vec<RolePriorityInfo> = roles
.into_iter()
.map(|r| RolePriorityInfo {
id: r.id,
role_key: r.role_key,
display_name: r.display_name,
priority: r.priority,
color: r.color,
created_at: r.created_at,
updated_at: r.updated_at,
})
.collect();
Ok(RolePriorityListResponse { roles: infos })
}
pub async fn project_upsert_role_priority(
&self,
project_name: String,
request: UpsertRolePriorityRequest,
ctx: &Session,
) -> Result<RolePriorityInfo, AppError> {
let actor_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self.utils_find_project_by_name(project_name).await?;
let actor_member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.filter(project_members::Column::User.eq(actor_uid))
.one(&self.db)
.await?
.ok_or(AppError::PermissionDenied)?;
let actor_role = actor_member
.scope_role()
.map_err(|_| AppError::RoleParseError)?;
if actor_role != MemberRole::Owner && actor_role != MemberRole::Admin {
return Err(AppError::NoPower);
}
let existing = models::projects::ProjectRolePriority::find()
.filter(models::projects::project_role_priority::Column::Project.eq(project.id))
.filter(models::projects::project_role_priority::Column::RoleKey.eq(&request.role_key))
.one(&self.db)
.await?;
let model = if let Some(existing) = existing {
let mut active: models::projects::project_role_priority::ActiveModel = existing.into();
active.display_name = Set(request.display_name);
active.priority = Set(request.priority);
active.color = Set(request.color);
active.updated_at = Set(Utc::now());
active.update(&self.db).await?
} else {
let active = models::projects::project_role_priority::ActiveModel {
project: Set(project.id),
role_key: Set(request.role_key),
display_name: Set(request.display_name),
priority: Set(request.priority),
color: Set(request.color),
created_at: Set(Utc::now()),
updated_at: Set(Utc::now()),
..Default::default()
};
active.insert(&self.db).await?
};
Ok(RolePriorityInfo {
id: model.id,
role_key: model.role_key,
display_name: model.display_name,
priority: model.priority,
color: model.color,
created_at: model.created_at,
updated_at: model.updated_at,
})
}
pub async fn project_delete_role_priority(
&self,
project_name: String,
role_key: String,
ctx: &Session,
) -> Result<(), AppError> {
let actor_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self.utils_find_project_by_name(project_name).await?;
let actor_member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.filter(project_members::Column::User.eq(actor_uid))
.one(&self.db)
.await?
.ok_or(AppError::PermissionDenied)?;
let actor_role = actor_member
.scope_role()
.map_err(|_| AppError::RoleParseError)?;
if actor_role != MemberRole::Owner && actor_role != MemberRole::Admin {
return Err(AppError::NoPower);
}
models::projects::ProjectRolePriority::delete_many()
.filter(models::projects::project_role_priority::Column::Project.eq(project.id))
.filter(models::projects::project_role_priority::Column::RoleKey.eq(role_key))
.exec(&self.db)
.await?;
Ok(())
}
}