use models::room::room_members; use models::room::rooms::{self, RoomKind}; use models::space::space_members::{self, MemberStatus}; use models::space::spaces; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QuerySelect, Set, TransactionTrait, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::SpaceError; use crate::services::service::SpaceService; use crate::share::authz::{MANAGE_CHANNELS, require_perm}; use crate::share::lookup::lookup_space_by_name; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateRoomCmd { pub name: String, #[serde(default)] pub kind: RoomKind, #[serde(default)] pub topic: Option, #[serde(default)] pub is_private: bool, #[serde(default)] pub nsfw: bool, #[serde(default)] pub user_limit: Option, #[serde(default)] pub rate_limit_per_user: Option, #[serde(default)] pub parent_id: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct RoomDto { pub id: Uuid, pub space_id: Option, pub kind: RoomKind, pub name: String, pub topic: Option, pub position: i64, pub parent_id: Option, pub owner_id: i64, pub is_private: bool, pub nsfw: bool, pub user_limit: Option, pub rate_limit_per_user: Option, pub is_active: bool, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl From for RoomDto { fn from(r: rooms::Model) -> Self { Self { id: r.id, space_id: r.space_id, kind: r.kind, name: r.name, topic: r.topic, position: r.position, parent_id: r.parent_id, owner_id: r.owner_id, is_private: r.is_private, nsfw: r.nsfw, user_limit: r.user_limit, rate_limit_per_user: r.rate_limit_per_user, is_active: r.is_active, created_at: r.created_at, updated_at: r.updated_at, } } } fn is_valid_lower_name(name: &str) -> bool { !name.is_empty() && name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } impl SpaceService { #[tracing::instrument(name = "space.create_room", skip_all, fields(caller = %caller, space = %space_name))] pub async fn create_room( &self, caller: i64, space_name: &str, cmd: CreateRoomCmd, ) -> Result { if !self .state .redis .quota_allow( &format!("quota:room_create:{caller}"), self.config().quota.room_create_per_day, 86_400, ) .await { return Err(SpaceError::RateLimited( "room creation quota exceeded, retry tomorrow".to_string(), )); } let space = lookup_space_by_name(self, space_name).await?; require_perm(self, &space, caller, MANAGE_CHANNELS).await?; if !space.is_active { return Err(SpaceError::NotFound); } if !matches!( cmd.kind, RoomKind::GuildRoom | RoomKind::Text | RoomKind::Category | RoomKind::News | RoomKind::Voice | RoomKind::Stage ) { return Err(SpaceError::BadRequest( "unsupported room kind: only guild_room, text, category, news, voice and stage can be created" .to_string(), )); } let name = cmd.name.trim(); if name.is_empty() || name.chars().count() > 100 { return Err(SpaceError::BadRequest( "room name is empty or too long".to_string(), )); } let lower = name.to_lowercase(); if !is_valid_lower_name(&lower) { return Err(SpaceError::BadRequest( "invalid room name: must contain only lowercase letters, digits, and hyphens" .to_string(), )); } let existing = rooms::Entity::find() .filter(rooms::Column::SpaceId.eq(space.id)) .filter(rooms::Column::LowerName.eq(&lower)) .one(self.db().read()) .await?; if existing.is_some() { return Err(SpaceError::Conflict(format!( "room name {lower} already exists in this space" ))); } let now = OffsetDateTime::now_utc(); let room_id = Uuid::now_v7(); let max_pos: i64 = rooms::Entity::find() .filter(rooms::Column::SpaceId.eq(space.id)) .select_only() .column_as( sea_orm::sea_query::Expr::col(rooms::Column::Position).max(), "max_pos", ) .into_tuple::>() .one(self.db().read()) .await? .flatten() .unwrap_or(-1); let position = max_pos + 1; let room_row = rooms::ActiveModel { id: Set(room_id), space_id: Set(Some(space.id)), kind: Set(cmd.kind), lower_name: Set(lower.clone()), name: Set(name.to_string()), topic: Set(cmd.topic.clone()), position: Set(position), parent_id: Set(cmd.parent_id), owner_id: Set(caller), is_private: Set(cmd.is_private), nsfw: Set(cmd.nsfw), user_limit: Set(cmd.user_limit), rate_limit_per_user: Set(cmd.rate_limit_per_user), bitrate: Set(None), last_message_id: Set(None), is_active: Set(true), created_at: Set(now), updated_at: Set(now), }; let member_uids: Vec = if !cmd.is_private { space_members::Entity::find() .filter(space_members::Column::SpaceId.eq(space.id)) .filter(space_members::Column::Status.eq(MemberStatus::Active)) .select_only() .column(space_members::Column::UserId) .into_tuple::() .all(self.db().read()) .await? } else { vec![] }; let member_uids_clone = member_uids.clone(); let inserted = self .db() .primary() .transaction::<_, rooms::Model, SpaceError>(|txn| { Box::pin(async move { let r = room_row.insert(txn).await?; for &uid in &member_uids_clone { let rm = room_members::ActiveModel { role: Set(if uid == caller { room_members::RoomMemberRole::Owner } else { room_members::RoomMemberRole::Member }), room_id: Set(room_id), user_id: Set(uid), nick: Set(None), joined_at: Set(now), left_at: Set(None), created_at: Set(now), updated_at: Set(Some(now)), }; rm.insert(txn).await?; } Ok(r) }) }) .await?; export::events::enqueue_chat_users( self.db().write(), self.state.events_enabled, "chat.membership.create", &member_uids .iter() .copied() .filter(|uid| *uid != caller) .collect::>(), room_id, &serde_json::json!({ "kind": "room_added", "room_id": room_id }), ) .await?; Ok(RoomDto::from(inserted)) } #[tracing::instrument(name = "space.delete_room", skip_all, fields(caller = %caller, room_id = %room_id))] pub async fn delete_room(&self, caller: i64, room_id: Uuid) -> Result<(), SpaceError> { let room = rooms::Entity::find_by_id(room_id) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; if !room.is_active { return Err(SpaceError::NotFound); } let Some(space_id) = room.space_id else { return Err(SpaceError::NotFound); }; let space = spaces::Entity::find_by_id(space_id) .one(self.db().read()) .await? .ok_or(SpaceError::NotFound)?; require_perm(self, &space, caller, MANAGE_CHANNELS).await?; if !space.is_active { return Err(SpaceError::NotFound); } let now = OffsetDateTime::now_utc(); let mut am: rooms::ActiveModel = room.into(); am.is_active = Set(false); am.updated_at = Set(now); am.update(self.db().write()).await?; export::events::enqueue_room( self.db().write(), self.state.events_enabled, "chat.room.update", room_id, serde_json::json!({ "room_id": room_id, "deleted": true }), ) .await?; Ok(()) } }