use export::pagination::Page; use models::admin::admin_user::AdminRole; use models::dir::dirs; use models::repo::repos; use models::room::rooms; use models::space::space_members::{self, MemberStatus, MessageNotifications}; use models::space::spaces; use models::users::user; use sea_orm::sea_query::Expr; use sea_orm::{ ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use serde::{Deserialize, Serialize}; use serde_json::json; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::AdminError; use crate::services::audit::{AuditCtx, AuditRecord}; use crate::services::service::AdminService; use crate::share::roles::AdminActor; #[derive(Debug, Clone, Deserialize, IntoParams)] pub struct ListSpacesQuery { pub q: Option, pub status: Option, pub owner_id: Option, pub limit: Option, pub offset: Option, } #[derive(Debug, Clone, Deserialize, IntoParams)] pub struct ListMembersQuery { pub status: Option, pub limit: Option, pub offset: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AdminSpaceSummary { pub id: Uuid, pub name: String, pub lower_name: String, pub description: Option, pub owner_id: Uuid, pub owner_username: Option, pub is_private: bool, pub is_active: bool, pub allow_join: bool, pub allow_invite: bool, pub member_must_enable_2fa: bool, pub nums_members: i64, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AdminSpaceDetail { pub space: AdminSpaceSummary, pub repos_count: i64, pub repos_size: i64, pub rooms_count: i64, pub dirs_count: i64, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateSpaceCmd { #[serde(default)] pub name: Option, #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub description: Option>, #[serde(default)] pub is_private: Option, #[serde(default)] pub is_active: Option, #[serde(default)] pub allow_join: Option, #[serde(default)] pub allow_invite: Option, #[serde(default)] pub member_must_enable_2fa: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct AddSpaceMemberCmd { pub user_id: Uuid, #[serde(default)] pub alias_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AddSpaceMemberResp { pub space_id: Uuid, pub user_id: Uuid, pub status: String, pub added: bool, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TransferSpaceCmd { pub new_owner_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AdminSpaceMemberDto { pub user_id: Uuid, pub username: Option, pub display_name: Option, pub alias_name: Option, pub status: String, pub joined_at: OffsetDateTime, pub last_active_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct RemoveMemberResp { pub space_id: Uuid, pub user_id: Uuid, pub removed: bool, } fn member_status_str(status: MemberStatus) -> String { match status { MemberStatus::Active => "active", MemberStatus::Inactive => "inactive", MemberStatus::Left => "left", MemberStatus::Removed => "removed", } .to_string() } impl AdminService { async fn owner_usernames( &self, owner_ids: &[Uuid], ) -> Result, AdminError> { if owner_ids.is_empty() { return Ok(std::collections::HashMap::new()); } let rows = user::Entity::find() .filter(user::Column::Uid.is_in(owner_ids.to_vec())) .all(self.db().read()) .await?; Ok(rows .into_iter() .map(|row| (row.uid, row.username)) .collect()) } pub async fn list_spaces( &self, query: ListSpacesQuery, ) -> Result, AdminError> { let limit = query.limit.unwrap_or(50).clamp(1, 100); let offset = query.offset.unwrap_or(0); let mut select = spaces::Entity::find(); if let Some(raw) = query .q .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { let needle = raw.to_ascii_lowercase(); select = select.filter( Condition::any() .add(spaces::Column::LowerName.contains(needle)) .add(spaces::Column::Name.contains(raw)), ); } if let Some(owner_id) = query.owner_id { select = select.filter(spaces::Column::OwnerId.eq(owner_id)); } select = match query.status.as_deref() { Some("active") => select.filter(spaces::Column::IsActive.eq(true)), Some("disabled") => select.filter(spaces::Column::IsActive.eq(false)), _ => select, }; select = select.order_by_desc(spaces::Column::CreatedAt); let total = select.clone().count(self.db().read()).await? as i64; let rows = select .limit(limit) .offset(offset) .all(self.db().read()) .await?; let owner_ids: Vec = rows.iter().map(|row| row.owner_id).collect(); let owners = self.owner_usernames(&owner_ids).await?; let items = rows .iter() .map(|row| AdminSpaceSummary { id: row.id, name: row.name.clone(), lower_name: row.lower_name.clone(), description: row.description.clone(), owner_id: row.owner_id, owner_username: owners.get(&row.owner_id).cloned(), is_private: row.is_private, is_active: row.is_active, allow_join: row.allow_join, allow_invite: row.allow_invite, member_must_enable_2fa: row.member_must_enable_2fa, nums_members: row.nums_members, created_at: row.created_at, updated_at: row.updated_at, }) .collect(); Ok(Page { items, total, limit, offset, }) } pub async fn get_space(&self, id: Uuid) -> Result { let row = spaces::Entity::find_by_id(id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; let owners = self.owner_usernames(&[row.owner_id]).await?; let dir_ids: Vec = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(id)) .all(self.db().read()) .await? .into_iter() .map(|dir| dir.id) .collect(); let dirs_count = dir_ids.len() as i64; let (repos_count, repos_size) = if dir_ids.is_empty() { (0, 0) } else { let repo_rows = repos::Entity::find() .filter(repos::Column::DirId.is_in(dir_ids)) .all(self.db().read()) .await?; let count = repo_rows.len() as i64; let size = repo_rows.iter().map(|repo| repo.size).sum(); (count, size) }; let rooms_count = rooms::Entity::find() .filter(rooms::Column::SpaceId.eq(id)) .count(self.db().read()) .await? as i64; Ok(AdminSpaceDetail { space: AdminSpaceSummary { id: row.id, name: row.name.clone(), lower_name: row.lower_name.clone(), description: row.description.clone(), owner_id: row.owner_id, owner_username: owners.get(&row.owner_id).cloned(), is_private: row.is_private, is_active: row.is_active, allow_join: row.allow_join, allow_invite: row.allow_invite, member_must_enable_2fa: row.member_must_enable_2fa, nums_members: row.nums_members, created_at: row.created_at, updated_at: row.updated_at, }, repos_count, repos_size, rooms_count, dirs_count, }) } pub async fn update_space( &self, actor: &AdminActor, id: Uuid, cmd: UpdateSpaceCmd, ctx: &AuditCtx, ) -> Result { actor.require(AdminRole::Operator)?; let row = spaces::Entity::find_by_id(id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; let mut am: spaces::ActiveModel = row.into(); let mut changed: Vec = Vec::new(); if let Some(name) = cmd.name { let name = name.trim().to_string(); if name.is_empty() || name.chars().count() > 64 { return Err(AdminError::BadRequest( "name must be 1 to 64 characters".to_string(), )); } am.name = Set(name); changed.push("name".to_string()); } if let Some(description) = cmd.description { am.description = Set(description.map(|value| value.trim().to_string())); changed.push("description".to_string()); } if let Some(value) = cmd.is_private { am.is_private = Set(value); changed.push("is_private".to_string()); } if let Some(value) = cmd.is_active { am.is_active = Set(value); changed.push("is_active".to_string()); } if let Some(value) = cmd.allow_join { am.allow_join = Set(value); changed.push("allow_join".to_string()); } if let Some(value) = cmd.allow_invite { am.allow_invite = Set(value); changed.push("allow_invite".to_string()); } if let Some(value) = cmd.member_must_enable_2fa { am.member_must_enable_2fa = Set(value); changed.push("member_must_enable_2fa".to_string()); } if changed.is_empty() { return self.get_space(id).await; } am.updated_at = Set(OffsetDateTime::now_utc()); am.update(self.db().write()).await?; self.audit( Some(actor), AuditRecord { action: "space.updated", target: Some(("space", id.to_string())), detail: json!({ "changed": changed }), }, ctx, ) .await?; self.get_space(id).await } pub async fn transfer_space( &self, actor: &AdminActor, id: Uuid, cmd: TransferSpaceCmd, ctx: &AuditCtx, ) -> Result { actor.require(AdminRole::SuperAdmin)?; let row = spaces::Entity::find_by_id(id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; let new_owner = user::Entity::find_by_id(cmd.new_owner_id) .one(self.db().read()) .await? .ok_or_else(|| AdminError::BadRequest("new owner does not exist".to_string()))?; if !new_owner.is_active { return Err(AdminError::BadRequest( "new owner account is not active".to_string(), )); } if new_owner.uid == row.owner_id { return Err(AdminError::Conflict( "new owner is already the space owner".to_string(), )); } let previous = row.owner_id; let mut am: spaces::ActiveModel = row.into(); am.owner_id = Set(new_owner.uid); am.updated_at = Set(OffsetDateTime::now_utc()); am.update(self.db().write()).await?; self.audit( Some(actor), AuditRecord { action: "space.transferred", target: Some(("space", id.to_string())), detail: json!({ "previous_owner_id": previous, "new_owner_id": new_owner.uid, "new_owner_username": new_owner.username }), }, ctx, ) .await?; self.get_space(id).await } pub async fn delete_space( &self, actor: &AdminActor, id: Uuid, ctx: &AuditCtx, ) -> Result<(), AdminError> { actor.require(AdminRole::SuperAdmin)?; let row = spaces::Entity::find_by_id(id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; if !row.is_active { return Ok(()); } let mut am: spaces::ActiveModel = row.into(); am.is_active = Set(false); am.updated_at = Set(OffsetDateTime::now_utc()); am.update(self.db().write()).await?; self.audit( Some(actor), AuditRecord { action: "space.deleted", target: Some(("space", id.to_string())), detail: json!({}), }, ctx, ) .await?; Ok(()) } pub async fn list_space_members( &self, space_id: Uuid, query: ListMembersQuery, ) -> Result, AdminError> { let limit = query.limit.unwrap_or(50).clamp(1, 100); let offset = query.offset.unwrap_or(0); let mut select = space_members::Entity::find().filter(space_members::Column::SpaceId.eq(space_id)); select = match query.status.as_deref() { Some("active") => select.filter(space_members::Column::Status.eq(MemberStatus::Active)), Some("inactive") => { select.filter(space_members::Column::Status.eq(MemberStatus::Inactive)) } Some("left") => select.filter(space_members::Column::Status.eq(MemberStatus::Left)), Some("removed") => { select.filter(space_members::Column::Status.eq(MemberStatus::Removed)) } _ => select, }; select = select.order_by_desc(space_members::Column::JoinedAt); let total = select.clone().count(self.db().read()).await? as i64; let rows = select .limit(limit) .offset(offset) .all(self.db().read()) .await?; let user_ids: Vec = rows.iter().map(|row| row.user_id).collect(); let users: std::collections::HashMap = if user_ids.is_empty() { std::collections::HashMap::new() } else { user::Entity::find() .filter(user::Column::Uid.is_in(user_ids)) .all(self.db().read()) .await? .into_iter() .map(|row| (row.uid, (row.username, row.display_name))) .collect() }; let items = rows .iter() .map(|row| { let info = users.get(&row.user_id); AdminSpaceMemberDto { user_id: row.user_id, username: info.map(|value| value.0.clone()), display_name: info.map(|value| value.1.clone()), alias_name: row.alias_name.clone(), status: member_status_str(row.status), joined_at: row.joined_at, last_active_at: row.last_active_at, } }) .collect(); Ok(Page { items, total, limit, offset, }) } pub async fn remove_space_member( &self, actor: &AdminActor, space_id: Uuid, user_id: Uuid, ctx: &AuditCtx, ) -> Result { actor.require(AdminRole::Operator)?; let space = spaces::Entity::find_by_id(space_id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; let row = space_members::Entity::find_by_id((space_id, user_id)) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; if row.status == MemberStatus::Removed { return Ok(RemoveMemberResp { space_id, user_id, removed: false, }); } let was_active = row.status == MemberStatus::Active; let now = OffsetDateTime::now_utc(); let mut am: space_members::ActiveModel = row.into(); am.status = Set(MemberStatus::Removed); am.left_at = Set(Some(now)); am.updated_at = Set(Some(now)); am.update(self.db().write()).await?; if was_active { spaces::Entity::update_many() .col_expr( spaces::Column::NumsMembers, Expr::cust("GREATEST(nums_members - 1, 0)"), ) .filter(spaces::Column::Id.eq(space.id)) .exec(self.db().write()) .await?; } self.audit( Some(actor), AuditRecord { action: "space.member_removed", target: Some(("space", space_id.to_string())), detail: json!({ "user_id": user_id }), }, ctx, ) .await?; Ok(RemoveMemberResp { space_id, user_id, removed: true, }) } } impl AdminService { pub async fn add_space_member( &self, actor: &AdminActor, space_id: Uuid, cmd: AddSpaceMemberCmd, ctx: &AuditCtx, ) -> Result { actor.require(AdminRole::Operator)?; let space = spaces::Entity::find_by_id(space_id) .one(self.db().read()) .await? .ok_or(AdminError::NotFound)?; let target = user::Entity::find_by_id(cmd.user_id) .one(self.db().read()) .await? .ok_or_else(|| AdminError::BadRequest("user does not exist".to_string()))?; if space.member_must_enable_2fa && !target.totp_enabled { return Err(AdminError::BadRequest( "space requires members to enable two-factor authentication".to_string(), )); } if let Some(alias) = cmd.alias_name.as_deref() && alias.chars().count() > self.config().space.max_alias_name_len { return Err(AdminError::BadRequest("alias name is too long".to_string())); } let existing = space_members::Entity::find_by_id((space_id, cmd.user_id)) .one(self.db().read()) .await?; let now = OffsetDateTime::now_utc(); match existing { Some(row) if row.status == MemberStatus::Active => Ok(AddSpaceMemberResp { space_id, user_id: cmd.user_id, status: member_status_str(MemberStatus::Active), added: false, }), Some(row) => { let mut am: space_members::ActiveModel = row.into(); am.status = Set(MemberStatus::Active); am.left_at = Set(None); am.alias_name = Set(cmd.alias_name.clone()); am.updated_at = Set(Some(now)); am.update(self.db().write()).await?; spaces::Entity::update_many() .col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1")) .filter(spaces::Column::Id.eq(space_id)) .exec(self.db().write()) .await?; self.audit( Some(actor), AuditRecord { action: "space.member_added", target: Some(("space", space_id.to_string())), detail: json!({ "user_id": cmd.user_id, "reactivated": true }), }, ctx, ) .await?; Ok(AddSpaceMemberResp { space_id, user_id: cmd.user_id, status: member_status_str(MemberStatus::Active), added: true, }) } None => { space_members::ActiveModel { space_id: Set(space_id), user_id: Set(cmd.user_id), alias_name: Set(cmd.alias_name.clone()), alias_avatar_url: Set(None), status: Set(MemberStatus::Active), last_active_at: Set(None), left_at: Set(None), invited_at: Set(None), invited_by: Set(None), joined_at: Set(now), created_at: Set(now), updated_at: Set(None), message_notifications: Set(MessageNotifications::default()), muted: Set(false), mute_until: Set(None), mobile_push: Set(false), suppress_everyone: Set(false), suppress_roles: Set(false), hide_muted_channels: Set(false), } .insert(self.db().write()) .await?; spaces::Entity::update_many() .col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1")) .filter(spaces::Column::Id.eq(space_id)) .exec(self.db().write()) .await?; self.audit( Some(actor), AuditRecord { action: "space.member_added", target: Some(("space", space_id.to_string())), detail: json!({ "user_id": cmd.user_id, "reactivated": false }), }, ctx, ) .await?; Ok(AddSpaceMemberResp { space_id, user_id: cmd.user_id, status: member_status_str(MemberStatus::Active), added: true, }) } } } }