951a522
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<String>,
#[serde(default)]
pub is_private: bool,
#[serde(default)]
pub nsfw: bool,
#[serde(default)]
pub user_limit: Option<i32>,
#[serde(default)]
pub rate_limit_per_user: Option<i32>,
#[serde(default)]
pub parent_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct RoomDto {
pub id: Uuid,
pub space_id: Option<Uuid>,
pub kind: RoomKind,
pub name: String,
pub topic: Option<String>,
pub position: i64,
pub parent_id: Option<Uuid>,
pub owner_id: i64,
pub is_private: bool,
pub nsfw: bool,
pub user_limit: Option<i32>,
pub rate_limit_per_user: Option<i32>,
pub is_active: bool,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
impl From<rooms::Model> 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<RoomDto, SpaceError> {
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::<Option<i64>>()
.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<i64> = 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::<i64>()
.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?;
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?;
Ok(())
}
}