use models::room::room_bans; use models::room::room_members::{self, RoomMemberRole}; use models::room::rooms; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; use serde::{Deserialize, Serialize}; use space::services::spaces::UserDto; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::ChatError; use crate::services::rooms::RoomMemberDto; use crate::services::service::ChatService; use crate::share::lookup::lookup_room; use crate::share::realtime::RoomEvent; #[derive(Debug, Clone, Serialize, ToSchema)] pub struct RoomBanDto { pub room_id: Uuid, pub user: Option, pub reason: Option, pub banned_by: Option, pub expires_at: Option, pub created_at: OffsetDateTime, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct BanRoomMemberCmd { pub user_id: i64, #[serde(default)] pub reason: Option, #[serde( default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime" )] pub expires_at: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateRoomMemberCmd { #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")] pub nick: Option>, #[serde(default)] pub role: Option, } pub async fn active_room_role( service: &ChatService, room_id: Uuid, uid: i64, ) -> Result, ChatError> { Ok(room_members::Entity::find_by_id((room_id, uid)) .one(service.db().read()) .await? .filter(|m| m.left_at.is_none()) .map(|m| m.role)) } async fn require_room_role( service: &ChatService, room_id: Uuid, uid: i64, min: RoomMemberRole, ) -> Result { let Some(role) = active_room_role(service, room_id, uid).await? else { return Err(ChatError::Forbidden); }; if role < min { return Err(ChatError::Forbidden); } Ok(role) } async fn set_left_at(service: &ChatService, room_id: Uuid, uid: i64) -> Result<(), ChatError> { let Some(row) = room_members::Entity::find_by_id((room_id, uid)) .one(service.db().write()) .await? else { return Ok(()); }; if row.left_at.is_none() { let mut am: room_members::ActiveModel = row.into(); am.left_at = Set(Some(OffsetDateTime::now_utc())); am.updated_at = Set(Some(OffsetDateTime::now_utc())); am.update(service.db().write()).await?; } Ok(()) } impl ChatService { async fn build_room_ban_dto(&self, row: room_bans::Model) -> Result { let user_map = self.load_user_briefs(&[row.user_id, row.banned_by]).await?; Ok(RoomBanDto { room_id: row.room_id, user: user_map.get(&row.user_id).cloned(), reason: row.reason, banned_by: user_map.get(&row.banned_by).cloned(), expires_at: row.expires_at, created_at: row.created_at, }) } pub async fn get_room_member( &self, caller: i64, room_id: Uuid, uid: i64, ) -> Result { crate::share::lookup::require_room_readable(self, room_id, caller).await?; let Some(row) = room_members::Entity::find_by_id((room_id, uid)) .one(self.db().read()) .await? else { return Err(ChatError::NotFound); }; let user_map = self.load_user_briefs(&[row.user_id]).await?; let user = user_map .get(&row.user_id) .cloned() .ok_or(ChatError::NotFound)?; Ok(RoomMemberDto { user, role: row.role, nick: row.nick, joined_at: row.joined_at, }) } pub async fn kick_room_member( &self, caller: i64, room_id: Uuid, uid: i64, ) -> Result<(), ChatError> { let room = lookup_room(self, room_id).await?; if caller == uid { return Err(ChatError::BadRequest( "use leave endpoint instead".to_string(), )); } let caller_role = require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?; let target_role = active_room_role(self, room_id, uid) .await? .ok_or(ChatError::NotFound)?; if uid == room.owner_id || target_role >= caller_role { return Err(ChatError::Forbidden); } set_left_at(self, room_id, uid).await?; self.realtime .publish_room(&RoomEvent::MemberLeft { room_id, user_id: uid, }) .await; Ok(()) } pub async fn ban_room_member( &self, caller: i64, room_id: Uuid, cmd: BanRoomMemberCmd, ) -> Result { let room = lookup_room(self, room_id).await?; require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?; if cmd.user_id == room.owner_id { return Err(ChatError::Forbidden); } if let Some(target_role) = active_room_role(self, room_id, cmd.user_id).await? && target_role >= RoomMemberRole::Moderator { return Err(ChatError::Forbidden); } let existing = room_bans::Entity::find_by_id((room_id, cmd.user_id)) .one(self.db().read()) .await?; if existing.is_some() { return Err(ChatError::Conflict("user already banned".to_string())); } let now = OffsetDateTime::now_utc(); let row = room_bans::ActiveModel { room_id: Set(room_id), user_id: Set(cmd.user_id), reason: Set(cmd.reason), banned_by: Set(caller), expires_at: Set(cmd.expires_at), created_at: Set(now), updated_at: Set(Some(now)), } .insert(self.db().write()) .await?; set_left_at(self, room_id, cmd.user_id).await?; self.realtime .publish_room(&RoomEvent::MemberLeft { room_id, user_id: cmd.user_id, }) .await; self.build_room_ban_dto(row).await } pub async fn unban_room_member( &self, caller: i64, room_id: Uuid, uid: i64, ) -> Result<(), ChatError> { require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?; let deleted = room_bans::Entity::delete_by_id((room_id, uid)) .exec(self.db().write()) .await?; if deleted.rows_affected == 0 { return Err(ChatError::NotFound); } Ok(()) } pub async fn list_room_bans( &self, caller: i64, room_id: Uuid, ) -> Result, ChatError> { require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?; let rows = room_bans::Entity::find() .filter(room_bans::Column::RoomId.eq(room_id)) .order_by_desc(room_bans::Column::CreatedAt) .all(self.db().read()) .await?; let mut out = Vec::with_capacity(rows.len()); for row in rows { out.push(self.build_room_ban_dto(row).await?); } Ok(out) } pub async fn transfer_room_owner( &self, caller: i64, room_id: Uuid, new_owner: i64, ) -> Result<(), ChatError> { let room = lookup_room(self, room_id).await?; if room.owner_id != caller { return Err(ChatError::Forbidden); } if new_owner == caller { return Err(ChatError::BadRequest( "cannot transfer ownership to yourself".to_string(), )); } let target = room_members::Entity::find_by_id((room_id, new_owner)) .one(self.db().read()) .await? .filter(|m| m.left_at.is_none()) .ok_or(ChatError::NotFound)?; let now = OffsetDateTime::now_utc(); let mut owner_am = rooms::ActiveModel::from(room); owner_am.owner_id = Set(new_owner); owner_am.updated_at = Set(now); owner_am.update(self.db().write()).await?; let mut new_owner_am: room_members::ActiveModel = target.into(); new_owner_am.role = Set(RoomMemberRole::Owner); new_owner_am.updated_at = Set(Some(now)); new_owner_am.update(self.db().write()).await?; if let Some(old) = room_members::Entity::find_by_id((room_id, caller)) .one(self.db().write()) .await? && old.left_at.is_none() { let mut am: room_members::ActiveModel = old.into(); am.role = Set(RoomMemberRole::Admin); am.updated_at = Set(Some(now)); am.update(self.db().write()).await?; } Ok(()) } pub async fn update_room_member( &self, caller: i64, room_id: Uuid, uid: i64, cmd: UpdateRoomMemberCmd, ) -> Result { let row = room_members::Entity::find_by_id((room_id, uid)) .one(self.db().write()) .await? .ok_or(ChatError::NotFound)?; let is_self = caller == uid; if !is_self { require_room_role(self, room_id, caller, RoomMemberRole::Admin).await?; } if let Some(new_role) = cmd.role { if is_self || new_role >= RoomMemberRole::Owner || row.role == RoomMemberRole::Owner { return Err(ChatError::Forbidden); } if let Some(caller_role) = active_room_role(self, room_id, caller).await? && new_role >= caller_role { return Err(ChatError::Forbidden); } } let mut am: room_members::ActiveModel = row.into(); if let Some(nick) = cmd.nick { let nick = nick.map(|n| n.trim().to_string()).filter(|n| !n.is_empty()); if let Some(n) = &nick && n.chars().count() > 64 { return Err(ChatError::BadRequest("nick too long".to_string())); } am.nick = Set(nick); } if let Some(role) = cmd.role { am.role = Set(role); } am.updated_at = Set(Some(OffsetDateTime::now_utc())); let updated = am.update(self.db().write()).await?; let user_map = self.load_user_briefs(&[updated.user_id]).await?; let user = user_map .get(&updated.user_id) .cloned() .ok_or(ChatError::NotFound)?; Ok(RoomMemberDto { user, role: updated.role, nick: updated.nick, joined_at: updated.joined_at, }) } }