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, pub avatar_url: Option, pub scope: MemberRole, pub joined_at: chrono::DateTime, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct MemberListResponse { pub members: Vec, 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, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct GroupedMemberListResponse { pub groups: Vec, 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, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct RolePriorityListResponse { pub roles: Vec, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct UpsertRolePriorityRequest { pub role_key: String, pub display_name: String, pub priority: i32, pub color: Option, } impl AppService { pub async fn project_get_members( &self, project_name: String, page: Option, per_page: Option, ctx: &Session, ) -> Result { 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 = 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 = 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 { 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 = 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 = 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> = 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 = 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::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 = 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 { 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 = 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 { 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(()) } }