use db::conn::sea_orm::*; use db::space::space_members as member_entity; use db::space::spaces as space_entity; use time::OffsetDateTime; use uuid::Uuid; use crate::error::{SpaceError, SpaceResult}; use crate::features::SpaceService; use super::types::{AddMemberCmd, SpaceMemberPage, SpaceMemberView, UpdateMemberCmd}; async fn space(caller: Uuid, space_id: Uuid, svc: &SpaceService) -> SpaceResult { let db = svc.db.reader(); let space = space_entity::Entity::find_by_id(space_id) .one(&db) .await? .ok_or_else(|| SpaceError::NotFound(format!("space {space_id}")))?; if space.owner_id == caller { Ok(space) } else { Err(SpaceError::Forbidden( "only the owner can manage members".to_string(), )) } } pub async fn add_member( svc: &SpaceService, caller: Uuid, space_id: Uuid, cmd: AddMemberCmd, ) -> SpaceResult { space(caller, space_id, svc).await?; let db = svc.db.writer(); let now = OffsetDateTime::now_utc(); let existing = member_entity::Entity::find_by_id((space_id, cmd.user_id)) .one(&db) .await?; if existing.is_some() { return Err(SpaceError::Conflict(format!( "user {} is already a member of space {space_id}", cmd.user_id ))); } let mute_until = parse_mute_until(cmd.mute_until.as_deref())?; let active: member_entity::ActiveModel = member_entity::ActiveModel::builder() .set_space_id(space_id) .set_user_id(cmd.user_id) .set_alias_name(cmd.alias_name) .set_alias_avatar_url(cmd.alias_avatar_url) .set_status(member_entity::MemberStatus::Active) .set_invited_at(Option::::None) .set_invited_by(Option::::None) .set_joined_at(now) .set_created_at(now) .set_updated_at(Option::::Some(now)) .set_message_notifications(member_entity::MessageNotifications::from( cmd.message_notifications.unwrap_or_default(), )) .set_muted(cmd.muted.unwrap_or(false)) .set_mute_until(mute_until) .set_mobile_push(cmd.mobile_push.unwrap_or(false)) .set_suppress_everyone(cmd.suppress_everyone.unwrap_or(false)) .set_suppress_roles(cmd.suppress_roles.unwrap_or(false)) .set_hide_muted_channels(cmd.hide_muted_channels.unwrap_or(false)) .into(); let saved: member_entity::Model = active.insert(&db).await?; Ok(saved.into()) } pub async fn get_member( svc: &SpaceService, caller: Uuid, space_id: Uuid, user_id: Uuid, ) -> SpaceResult { space(caller, space_id, svc).await?; let db = svc.db.reader(); let m = member_entity::Entity::find_by_id((space_id, user_id)) .one(&db) .await? .ok_or_else(|| SpaceError::NotFound(format!("member {user_id} in space {space_id}")))?; Ok(m.into()) } fn parse_mute_until(s: Option<&str>) -> SpaceResult> { match s { None => Ok(None), Some("") => Ok(None), Some(v) => OffsetDateTime::parse(v, &time::format_description::well_known::Rfc3339) .map(Some) .map_err(|_| SpaceError::BadRequest("invalid mute_until timestamp".to_string())), } } pub async fn update_member( svc: &SpaceService, caller: Uuid, space_id: Uuid, user_id: Uuid, cmd: UpdateMemberCmd, ) -> SpaceResult { space(caller, space_id, svc).await?; let db = svc.db.writer(); let txn = db.begin().await?; let m = member_entity::Entity::find_by_id((space_id, user_id)) .one(&txn) .await? .ok_or_else(|| SpaceError::NotFound(format!("member {user_id} in space {space_id}")))?; let mut active: member_entity::ActiveModel = m.into(); if let Some(v) = cmd.alias_name { match v { None => active.alias_name = Set(None), Some(s) => { if s.chars().count() > svc.config.max_alias_name_len { return Err(SpaceError::BadRequest(format!( "alias_name exceeds {} characters", svc.config.max_alias_name_len ))); } active.alias_name = Set(Some(s)); } } } if let Some(v) = cmd.alias_avatar_url { active.alias_avatar_url = Set(v); } if let Some(v) = cmd.message_notifications { active.message_notifications = Set(v.into()); } if let Some(v) = cmd.muted { active.muted = Set(v); } if let Some(v) = cmd.mute_until { active.mute_until = Set(parse_mute_until(v.as_deref())?); } if let Some(v) = cmd.mobile_push { active.mobile_push = Set(v); } if let Some(v) = cmd.suppress_everyone { active.suppress_everyone = Set(v); } if let Some(v) = cmd.suppress_roles { active.suppress_roles = Set(v); } if let Some(v) = cmd.hide_muted_channels { active.hide_muted_channels = Set(v); } active.updated_at = Set(Some(OffsetDateTime::now_utc())); let saved = active.update(&txn).await?; txn.commit().await?; Ok(saved.into()) } pub async fn remove_member( svc: &SpaceService, caller: Uuid, space_id: Uuid, user_id: Uuid, ) -> SpaceResult<()> { space(caller, space_id, svc).await?; if caller == user_id { return Err(SpaceError::BadRequest( "owner cannot remove themselves".to_string(), )); } let db = svc.db.writer(); let txn = db.begin().await?; let m = member_entity::Entity::find_by_id((space_id, user_id)) .one(&txn) .await? .ok_or_else(|| SpaceError::NotFound(format!("member {user_id} in space {space_id}")))?; let mut active: member_entity::ActiveModel = m.into(); active.status = Set(member_entity::MemberStatus::Removed); active.left_at = Set(Some(OffsetDateTime::now_utc())); active.updated_at = Set(Some(OffsetDateTime::now_utc())); active.update(&txn).await?; txn.commit().await?; Ok(()) } pub async fn list_members( svc: &SpaceService, caller: Uuid, space_id: Uuid, limit: u64, offset: u64, ) -> SpaceResult { space(caller, space_id, svc).await?; let db = svc.db.reader(); let limit = std::cmp::max(std::cmp::min(limit, svc.config.max_pagination), 1) as u64; let items = member_entity::Entity::find() .filter(member_entity::Column::SpaceId.eq(space_id)) .order_by_asc(member_entity::Column::JoinedAt) .limit(Some(limit)) .offset(Some(offset)) .all(&db) .await?; let total = member_entity::Entity::find() .filter(member_entity::Column::SpaceId.eq(space_id)) .count(&db) .await?; Ok(SpaceMemberPage { items: items.into_iter().map(SpaceMemberView::from).collect(), total: total as i64, }) }