ef0b83a
use models::room::room_members::{self, RoomMemberRole};
use models::room::{room_bans, rooms};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use space::services::dto::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;
use crate::share::realtime::RoomEvent;
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct RoomBanDto {
pub room_id: Uuid,
pub user: Option<UserDto>,
pub reason: Option<String>,
pub banned_by: Option<UserDto>,
pub expires_at: Option<OffsetDateTime>,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct BanRoomMemberCmd {
pub user_id: i64,
#[serde(default)]
pub reason: Option<String>,
#[serde(
default,
deserialize_with = "export::serde_datetime::deserialize_opt_datetime"
)]
pub expires_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateRoomMemberCmd {
#[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
pub nick: Option<Option<String>>,
#[serde(default)]
pub role: Option<RoomMemberRole>,
}
pub async fn active_room_role(
service: &ChatService,
room_id: Uuid,
uid: i64,
) -> Result<Option<RoomMemberRole>, 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<RoomMemberRole, ChatError> {
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<RoomBanDto, ChatError> {
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<RoomMemberDto, ChatError> {
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::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<RoomBanDto, ChatError> {
let room = lookup::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<Vec<RoomBanDto>, 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::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<RoomMemberDto, ChatError> {
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,
})
}
}