//! `ChatRooms`: a community slug in, a `livechat::Room` out. //! //! One room per community, so there is no rooms table and no room id of its //! own: `RoomId` *is* the community id. A community with `chat_policy <> 'off'` //! is the room. use async_trait::async_trait; use livechat::{ChatError, ChatRooms, Retention, Room, RoomId, RoomState}; use mt_core::types::{ChatPolicy, CommunityState}; use sqlx::PgPool; use super::host_error; pub struct MtChatRooms { db: PgPool, } impl MtChatRooms { pub fn new(db: PgPool) -> Self { Self { db } } } /// Fold a community's three independent states into the room's one. /// /// Order matters and is fail-closed: the checks that hide the room entirely run /// before the ones that merely quieten it, so a suspended *and* frozen /// community is `Closed`, not `ReadOnly`. pub(crate) fn room_state(policy: ChatPolicy, state: CommunityState, suspended: bool) -> RoomState { // `off` must be total: no route, no hub room, no affordance. Closed is what // the crate renders as a 404, so a disabled room and an absent one are // indistinguishable from outside, which is the point. if !policy.is_enabled() { return RoomState::Closed; } // A suspended community 403s everywhere else in the app. Chat closes rather // than 403s: there is nothing useful to show a visitor, and a live socket // into a suspended community is exactly what suspension is meant to stop. if suspended { return RoomState::Closed; } // Frozen and Archived go read-only, matching how threads already behave. // Asking the predicate rather than matching the variants means a new // `CommunityState` gets chat's answer for free, and gets the same answer // the forum gives. if state.allows_writes_for_members() { RoomState::Open } else { RoomState::ReadOnly } } /// Build the crate's bounded `Retention` from the community's columns. /// /// Migration 039 CHECK-bounds both columns against the same ceilings /// `Retention::new` enforces, so this cannot normally fail. It still refuses /// rather than clamping if it ever does: a room quietly running a different /// retention than its settings screen reports is worse than a room that errors. fn retention(hours: i32, max_messages: i32) -> Result { Retention::new( std::time::Duration::from_hours(u64::try_from(hours).unwrap_or(0)), usize::try_from(max_messages).unwrap_or(0), ) } #[async_trait] impl ChatRooms for MtChatRooms { async fn resolve(&self, key: &str) -> Result, ChatError> { let Some(row) = mt_db::queries::get_chat_room_by_slug(&self.db, key) .await .map_err(host_error)? else { return Ok(None); }; Ok(Some(Room { id: RoomId(row.id), state: room_state(row.policy, row.state, row.suspended), retention: retention(row.retention_hours, row.max_messages)?, })) } } #[cfg(test)] mod tests { use super::*; #[test] fn off_closes_the_room_whatever_else_is_true() { for state in [ CommunityState::Active, CommunityState::Restricted, CommunityState::Frozen, CommunityState::Archived, ] { for suspended in [false, true] { assert_eq!( room_state(ChatPolicy::Off, state, suspended), RoomState::Closed, "off must be total: state={state:?} suspended={suspended}" ); } } } #[test] fn suspension_closes_rather_than_quietens() { // Fail-closed ordering: suspended and frozen together is Closed, not // ReadOnly. Getting this backwards would leave a live socket open into // a suspended community. assert_eq!( room_state(ChatPolicy::Members, CommunityState::Frozen, true), RoomState::Closed ); } #[test] fn frozen_and_archived_are_read_only() { for state in [CommunityState::Frozen, CommunityState::Archived] { assert_eq!( room_state(ChatPolicy::Members, state, false), RoomState::ReadOnly, "{state:?} must match how threads behave" ); } } #[test] fn active_and_restricted_are_open() { // Restricted restricts new threads, not continuing writes, and chat is // the continuing kind. for state in [CommunityState::Active, CommunityState::Restricted] { assert_eq!( room_state(ChatPolicy::Members, state, false), RoomState::Open ); } } #[test] fn every_enabled_policy_resolves_the_same_state() { // Policy decides who may read and write; it does not decide whether the // room is open. Only `off` is a state question. for policy in [ ChatPolicy::Members, ChatPolicy::PublicRead, ChatPolicy::FanPlus, ] { assert_eq!( room_state(policy, CommunityState::Active, false), RoomState::Open ); } } #[test] fn the_column_defaults_build_a_valid_retention() { // Migration 039's defaults must satisfy the crate ceilings, or every // room in the app fails to resolve. let r = retention(168, 5_000).expect("forum defaults"); assert_eq!(r, Retention::forum_default()); } #[test] fn retention_past_the_ceiling_is_refused_not_clamped() { assert!(retention(721, 5_000).is_err()); assert!(retention(168, 20_001).is_err()); assert!(retention(0, 5_000).is_err()); } }