//! chat backlog reads for one community room //! //! Timestamps come back as Unix seconds rather than `DateTime` because //! that is what `livechat::Message::created_at` is, and converting at the query //! boundary keeps the chrono type out of the hot replay path entirely. use super::{ChatPolicy, CommunityState, PgPool, Uuid}; /// Everything resolving a community slug to a `livechat::Room` needs. /// /// A dedicated query rather than widening `CommunityRow`: that struct is loaded /// on effectively every request in the app, and chat is on almost none of them. pub struct ChatRoomRow { pub id: Uuid, pub policy: ChatPolicy, pub state: CommunityState, pub suspended: bool, pub retention_hours: i32, pub max_messages: i32, } /// Resolve a community slug to its chat room. /// /// `Ok(None)` means no such community. A community whose chat is off still /// comes back as a row: the caller maps `ChatPolicy::Off` to a closed room, and /// keeping the two cases distinct here is what lets it decide whether "disabled" /// and "absent" should look the same from outside (they should, and do). #[tracing::instrument(skip_all)] pub async fn get_chat_room_by_slug( pool: &PgPool, slug: &str, ) -> Result, sqlx::Error> { sqlx::query_as!( ChatRoomRow, r#"SELECT id, chat_policy AS "policy: ChatPolicy", state AS "state: CommunityState", (suspended_at IS NOT NULL) AS "suspended!", chat_retention_hours AS retention_hours, chat_max_messages AS max_messages FROM communities WHERE slug = $1"#, slug, ) .fetch_optional(pool) .await } /// One stored chat message, shaped for `livechat::Message`. /// /// `author` is absent here on purpose: display name, avatar and flair are /// resolved on the way out through `ChatIdentity::attach` and never stored, so /// a rename takes effect everywhere rather than only on messages sent after it. pub struct ChatMessageRow { pub id: i64, pub author_id: Uuid, pub body_html: String, /// Unix seconds. pub created_at: i64, } /// Messages after `cursor`, oldest first, for a client resuming a stream. /// /// `limit` is a hard ceiling on one reply, not a page size: a client that has /// been away longer than the retention window asks for everything and must not /// be able to make the server materialize an unbounded row set. It receives the /// oldest `limit` messages past its cursor and its next cursor advances, so a /// long absence resolves in a few round trips instead of one large one. #[tracing::instrument(skip_all)] pub async fn backlog_after( pool: &PgPool, community_id: Uuid, cursor: i64, limit: i64, ) -> Result, sqlx::Error> { sqlx::query_as!( ChatMessageRow, r#"SELECT id, author_id, body_html, EXTRACT(EPOCH FROM created_at)::BIGINT AS "created_at!" FROM chat_messages WHERE community_id = $1 AND id > $2 ORDER BY id LIMIT $3"#, community_id, cursor, limit, ) .fetch_all(pool) .await } /// The newest `limit` messages, oldest first, for a client with no cursor. /// /// Selected newest-first so the index gives the tail directly, then reversed to /// the ascending order the room renders in. Doing that in SQL rather than in /// Rust would mean sorting the whole room. #[tracing::instrument(skip_all)] pub async fn recent_backlog( pool: &PgPool, community_id: Uuid, limit: i64, ) -> Result, sqlx::Error> { let mut rows = sqlx::query_as!( ChatMessageRow, r#"SELECT id, author_id, body_html, EXTRACT(EPOCH FROM created_at)::BIGINT AS "created_at!" FROM chat_messages WHERE community_id = $1 ORDER BY id DESC LIMIT $2"#, community_id, limit, ) .fetch_all(pool) .await?; rows.reverse(); Ok(rows) } /// Who wrote a message, if it exists in this room. /// /// Scoped by `community_id` rather than looked up by id alone: a message id is /// a guessable integer, and answering "who wrote 4172" without asking which /// room it is in would confirm the existence and authorship of a message in a /// community the caller cannot read. The self-delete entitlement check is the /// caller for this. #[tracing::instrument(skip_all)] pub async fn chat_message_author( pool: &PgPool, community_id: Uuid, message_id: i64, ) -> Result, sqlx::Error> { sqlx::query_scalar!( "SELECT author_id FROM chat_messages WHERE community_id = $1 AND id = $2", community_id, message_id, ) .fetch_optional(pool) .await } /// Display fields for many users at once, for `ChatIdentity`. /// /// One query for the whole batch: backlog replay needs every author in the /// window, and doing that per author is an N+1 on the reconnect path, which is /// the exact moment every client in the room arrives at once. pub struct ChatIdentityRow { pub id: Uuid, pub username: String, pub display_name: Option, pub avatar_url: Option, } #[tracing::instrument(skip_all)] pub async fn chat_identities( pool: &PgPool, users: &[Uuid], ) -> Result, sqlx::Error> { sqlx::query_as!( ChatIdentityRow, "SELECT mnw_account_id AS id, username, display_name, avatar_url FROM users WHERE mnw_account_id = ANY($1)", users, ) .fetch_all(pool) .await }