use std::collections::HashMap; use export::AppState; use models::space::space_extras::{self, Entity as SpaceExtras}; use models::space::space_member_extras::{self, Entity as MemberExtras}; use models::space::space_members::{self, Entity as Members}; use models::space::spaces::{self, Entity as Spaces}; use models::space::{AllowJoinLevel, SpaceJoinPolicy, SpaceStatus}; use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, QuerySelect, Set}; use time::OffsetDateTime; use uuid::Uuid; use crate::errors::SpaceError; use crate::services::dto::{MemberDto, SpaceDto, UserDto, resolve_space_image_url}; use crate::services::service::SpaceService; pub const RPC_KEY: &str = "kenoauth is not configured"; pub fn client(state: &AppState) -> Result<&dyn export::AuthApi, SpaceError> { state .kenauth .as_deref() .ok_or_else(|| SpaceError::Internal(RPC_KEY.to_string())) } pub fn to_time(value: chrono::DateTime) -> OffsetDateTime { OffsetDateTime::from_unix_timestamp(value.timestamp()) .map(|row| { row.replace_nanosecond(value.timestamp_subsec_nanos()) .unwrap_or(row) }) .unwrap_or_else(|_| OffsetDateTime::now_utc()) } pub fn to_time_opt(value: Option>) -> Option { value.map(to_time) } pub fn space_dto( record: &spaces::Model, extras: Option<&space_extras::Model>, nums_members: i64, owners: &HashMap, ) -> SpaceDto { SpaceDto { id: record.id, lower_name: record.slug.clone(), name: record.name.clone(), description: record.description.clone(), owner: owners.get(&record.owner_user_id).cloned(), avatar_url: resolve_space_image_url( &record.slug, "avatar", extras .and_then(|row| row.avatar_url.clone()) .or_else(|| record.avatar_url.clone()), ), is_private: record.join_policy == SpaceJoinPolicy::Private, is_active: record.status == SpaceStatus::Active && record.archived_at.is_none(), allow_join: record.allow_join, allow_invite: record.allow_invite, members_count: nums_members, welcome_message: record.welcome_message.clone(), allow_join_level: AllowJoinLevel::from_i16(record.allow_join_level), member_must_enable_2fa: record.member_must_enable_2fa, welcome_object: extras.and_then(|row| row.welcome_object), created_at: to_time(record.created_at), updated_at: to_time(record.updated_at), } } pub fn space_is_active(space: &spaces::Model) -> bool { space.status == SpaceStatus::Active && space.archived_at.is_none() } pub fn member_dto( record: &space_members::Model, extras: Option<&space_member_extras::Model>, users: &HashMap, ) -> MemberDto { MemberDto { space_id: record.organization_id, user: users.get(&record.user_id).cloned(), alias_name: record.alias_name.clone(), alias_avatar_url: record.alias_avatar_url.clone(), status: record.status(), last_active_at: to_time_opt(record.last_active_at), left_at: to_time_opt(record.left_at), invited_at: extras.and_then(|row| row.invited_at), invited_by: record.invited_by.and_then(|uid| users.get(&uid).cloned()), joined_at: to_time(record.joined_at), created_at: extras .map(|row| row.created_at) .unwrap_or_else(|| to_time(record.joined_at)), updated_at: extras.map(|row| row.updated_at), message_notifications: record.message_notifications, muted: record.muted, mute_until: to_time_opt(record.mute_until), mobile_push: record.mobile_push, suppress_everyone: record.suppress_everyone, suppress_roles: record.suppress_roles, hide_muted_channels: record.hide_muted_channels, } } pub async fn extras( state: &AppState, space_id: Uuid, ) -> Result, SpaceError> { Ok(SpaceExtras::find_by_id(space_id) .one(state.database.read()) .await?) } pub async fn member_extras( state: &AppState, space_id: Uuid, user_id: i64, ) -> Result, SpaceError> { Ok(MemberExtras::find_by_id((space_id, user_id)) .one(state.database.read()) .await?) } pub async fn space_avatar( state: &AppState, space: &spaces::Model, ) -> Result, SpaceError> { Ok(extras(state, space.id) .await? .and_then(|row| row.avatar_url) .or_else(|| space.avatar_url.clone())) } pub async fn member_count(state: &AppState, space_id: Uuid) -> Result { use sea_orm::PaginatorTrait; let count = Members::find() .filter(space_members::Column::OrganizationId.eq(space_id)) .filter(space_members::Column::LeftAt.is_null()) .filter(space_members::Column::BannedAt.is_null()) .count(state.database.read()) .await?; Ok(count as i64) } pub async fn space_by_id( state: &AppState, _actor: i64, space_id: Uuid, ) -> Result { Spaces::find_by_id(space_id) .filter(spaces::Column::DeletedAt.is_null()) .one(state.database.read()) .await? .ok_or(SpaceError::NotFound) } pub async fn space_by_slug( state: &AppState, _actor: i64, slug: &str, ) -> Result { Spaces::find() .filter(spaces::Column::Slug.eq(slug.trim().to_lowercase())) .filter(spaces::Column::DeletedAt.is_null()) .one(state.database.read()) .await? .ok_or(SpaceError::NotFound) } pub async fn member( state: &AppState, _actor: i64, space_id: Uuid, user_id: i64, ) -> Result, SpaceError> { Ok(Members::find() .filter(space_members::Column::OrganizationId.eq(space_id)) .filter(space_members::Column::UserId.eq(user_id)) .one(state.database.read()) .await?) } pub async fn require_member( state: &AppState, space_id: Uuid, user_id: i64, ) -> Result { match member(state, user_id, space_id, user_id).await? { Some(member) if member.status() == models::space::MemberStatus::Active => Ok(member), _ => Err(SpaceError::Forbidden), } } pub async fn list_members( state: &AppState, _actor: i64, space_id: Uuid, include_inactive: bool, include_banned: bool, ) -> Result, SpaceError> { let mut query = Members::find().filter(space_members::Column::OrganizationId.eq(space_id)); if !include_inactive { query = query.filter(space_members::Column::LeftAt.is_null()); } if !include_banned { query = query.filter(space_members::Column::BannedAt.is_null()); } Ok(query.all(state.database.read()).await?) } pub async fn list_user_spaces( state: &AppState, _actor: i64, user_id: i64, ) -> Result, SpaceError> { let space_ids: Vec = Members::find() .filter(space_members::Column::UserId.eq(user_id)) .filter(space_members::Column::LeftAt.is_null()) .filter(space_members::Column::BannedAt.is_null()) .select_only() .column(space_members::Column::OrganizationId) .into_tuple() .all(state.database.read()) .await?; spaces_by_ids(state, space_ids).await } /// Public directory (ACTIVE, joinable). Presentation fields come from the extras row. pub async fn list_public_spaces( state: &AppState, query: &str, ) -> Result, SpaceError> { use sea_orm::Condition; let mut select = Spaces::find() .filter(spaces::Column::Status.eq(SpaceStatus::Active)) .filter(spaces::Column::DeletedAt.is_null()) .filter( spaces::Column::JoinPolicy.is_in([SpaceJoinPolicy::Open, SpaceJoinPolicy::Approval]), ); let needle = query.trim(); if !needle.is_empty() { select = select.filter( Condition::any() .add(spaces::Column::Name.contains(needle)) .add(spaces::Column::Slug.contains(needle)), ); } Ok(select.limit(1_000).all(state.database.read()).await?) } async fn spaces_by_ids(state: &AppState, ids: Vec) -> Result, SpaceError> { if ids.is_empty() { return Ok(Vec::new()); } Ok(Spaces::find() .filter(spaces::Column::Id.is_in(ids)) .filter(spaces::Column::DeletedAt.is_null()) .all(state.database.read()) .await?) } pub async fn account_created_at( state: &AppState, user_id: i64, ) -> Result, SpaceError> { let user = client(state)?.get_authority_user(user_id).await?; let Some(created) = user.created_at else { return Ok(None); }; Ok(OffsetDateTime::from_unix_timestamp(created.seconds) .ok() .map(|row| { row.replace_nanosecond(created.nanos.clamp(0, 999_999_999) as u32) .unwrap_or(row) })) } pub async fn mfa_enabled(state: &AppState, user_id: i64) -> Result { let status = client(state)?.get_user_status(user_id).await?; Ok(status.active_mfa_factors > 0) } pub async fn has_verified_email(state: &AppState, user_id: i64) -> Result { let status = client(state)?.get_user_status(user_id).await?; Ok(status.email_verified_at.is_some()) } pub async fn permissions( state: &AppState, space_id: Uuid, user_id: i64, actions: &[String], ) -> Result, SpaceError> { let service = SpaceService::new(state.clone()); let checks = service .check_permissions(user_id, space_id, user_id, actions, None) .await?; Ok(checks.into_iter().map(|check| check.allowed).collect()) } pub async fn upsert_extras( state: &AppState, space_id: Uuid, avatar_url: Option>, welcome_object: Option>, banner_url: Option>, banner_width: Option>, banner_height: Option>, invite_banner_url: Option>, invite_banner_width: Option>, invite_banner_height: Option>, embed_splash_url: Option>, embed_splash_width: Option>, embed_splash_height: Option>, invite_sort_url_hash: Option>, ) -> Result { let now = OffsetDateTime::now_utc(); let existing = extras(state, space_id).await?; let row = match existing { Some(row) => { let mut active: space_extras::ActiveModel = row.into(); if let Some(value) = avatar_url { active.avatar_url = Set(value); } if let Some(value) = welcome_object { active.welcome_object = Set(value); } if let Some(value) = banner_url { active.banner_url = Set(value); } if let Some(value) = banner_width { active.banner_width = Set(value); } if let Some(value) = banner_height { active.banner_height = Set(value); } if let Some(value) = invite_banner_url { active.invite_banner_url = Set(value); } if let Some(value) = invite_banner_width { active.invite_banner_width = Set(value); } if let Some(value) = invite_banner_height { active.invite_banner_height = Set(value); } if let Some(value) = embed_splash_url { active.embed_splash_url = Set(value); } if let Some(value) = embed_splash_width { active.embed_splash_width = Set(value); } if let Some(value) = embed_splash_height { active.embed_splash_height = Set(value); } if let Some(value) = invite_sort_url_hash { active.invite_sort_url_hash = Set(value); } active.updated_at = Set(now); active.update(state.database.write()).await? } None => { space_extras::ActiveModel { space_id: Set(space_id), avatar_url: Set(avatar_url.unwrap_or(None)), invite_sort_url_hash: Set(invite_sort_url_hash.unwrap_or(None)), banner_url: Set(banner_url.unwrap_or(None)), banner_width: Set(banner_width.unwrap_or(None)), banner_height: Set(banner_height.unwrap_or(None)), invite_banner_url: Set(invite_banner_url.unwrap_or(None)), invite_banner_width: Set(invite_banner_width.unwrap_or(None)), invite_banner_height: Set(invite_banner_height.unwrap_or(None)), embed_splash_url: Set(embed_splash_url.unwrap_or(None)), embed_splash_width: Set(embed_splash_width.unwrap_or(None)), embed_splash_height: Set(embed_splash_height.unwrap_or(None)), welcome_object: Set(welcome_object.unwrap_or(None)), created_at: Set(now), updated_at: Set(now), } .insert(state.database.write()) .await? } }; Ok(row) } pub async fn upsert_member_extras( state: &AppState, space_id: Uuid, user_id: i64, invited_at: Option, ) -> Result<(), SpaceError> { let now = OffsetDateTime::now_utc(); let existing = member_extras(state, space_id, user_id).await?; match existing { Some(row) => { let mut active: space_member_extras::ActiveModel = row.into(); if invited_at.is_some() { active.invited_at = Set(invited_at); } active.updated_at = Set(now); active.update(state.database.write()).await?; } None => { space_member_extras::ActiveModel { space_id: Set(space_id), user_id: Set(user_id), invited_at: Set(invited_at), created_at: Set(now), updated_at: Set(now), } .insert(state.database.write()) .await?; } } Ok(()) } pub async fn remove_extras(state: &AppState, space_id: Uuid) -> Result<(), SpaceError> { SpaceExtras::delete_by_id(space_id) .exec(state.database.write()) .await?; MemberExtras::delete_many() .filter(space_member_extras::Column::SpaceId.eq(space_id)) .exec(state.database.write()) .await?; Ok(()) } pub async fn remove_member_extras( state: &AppState, space_id: Uuid, user_id: i64, ) -> Result<(), SpaceError> { MemberExtras::delete_by_id((space_id, user_id)) .exec(state.database.write()) .await?; Ok(()) } pub fn db_error(error: DbErr) -> SpaceError { SpaceError::from(error) }