609 lines
20 KiB
Rust
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(())
|
|
}
|
|
}
|