use cache::AppCache; use db::{AppDatabase, sqlx}; use model::channel::RoomMessageModel; use serde::Serialize; use uuid::Uuid; use crate::{ChannelBusConfig, ChannelResult}; pub(crate) const RM_COLUMNS: &str = "id, room, seq, thread, parent, author, content, content_type, pinned, \ system_type, metadata, edited_at, created_at, updated_at, deleted_at"; pub(crate) fn room_socket_name(room: Uuid) -> String { format!("room:{room}") } pub(crate) fn user_rooms_cache_key(user: Uuid) -> String { format!("channel:user:{user}:rooms") } #[derive(Debug, Serialize)] pub struct RoomListItem { pub id: Uuid, pub name: String, pub topic: Option, /// Maps to DB `room_type` column. Serialized as `room_type` for frontend compat. pub room_type: String, pub is_private: bool, pub ai_enabled: bool, pub category: Option, pub workspace_id: Uuid, } #[derive(Debug, Serialize)] pub struct CategoryListItem { pub id: Uuid, pub name: String, pub position: i32, } pub async fn user_rooms_for_api( db: &AppDatabase, cache: &AppCache, config: &ChannelBusConfig, user: Uuid, ) -> ChannelResult> { let room_ids = user_rooms(db, cache, config, user).await?; if room_ids.is_empty() { return Ok(Vec::new()); } let rows = sqlx::query_as::< _, ( Uuid, String, Option, String, bool, bool, Option, Uuid, ), >( "SELECT id, name, topic, room_type, is_private, ai_enabled, parent, wk \ FROM room \ WHERE id = ANY($1) AND deleted_at IS NULL AND is_archived = false \ ORDER BY name", ) .bind(&room_ids) .fetch_all(db.reader()) .await?; Ok(rows .into_iter() .map( |( id, name, topic, room_type, is_private, ai_enabled, category, workspace_id, )| RoomListItem { id, name, topic, room_type, is_private, ai_enabled, category, workspace_id, }, ) .collect()) } pub async fn user_categories_for_api( db: &AppDatabase, cache: &AppCache, config: &ChannelBusConfig, user: Uuid, ) -> ChannelResult> { let room_ids = user_rooms(db, cache, config, user).await?; if room_ids.is_empty() { return Ok(Vec::new()); } let wk_rows = sqlx::query_as::<_, (Uuid,)>( "SELECT DISTINCT wk FROM room WHERE id = ANY($1) AND deleted_at IS NULL", ) .bind(&room_ids) .fetch_all(db.reader()) .await?; let wk_ids: Vec = wk_rows.into_iter().map(|r| r.0).collect(); if wk_ids.is_empty() { return Ok(Vec::new()); } let rows = sqlx::query_as::<_, (Uuid, String, i32)>( "SELECT id, name, position FROM room_category WHERE wk = ANY($1) ORDER BY position, name", ) .bind(&wk_ids) .fetch_all(db.reader()) .await?; Ok(rows .into_iter() .map(|(id, name, position)| CategoryListItem { id, name, position }) .collect()) } pub(crate) async fn user_rooms( db: &AppDatabase, cache: &AppCache, config: &ChannelBusConfig, user: Uuid, ) -> ChannelResult> { let key = user_rooms_cache_key(user); if let Some(rooms) = cache.get::>(&key).await? { return Ok(rooms); } let rooms = load_user_rooms(db, user).await?; cache_set_with_ttl(cache, &key, &rooms, config.room_cache_ttl_hint).await?; Ok(rooms) } pub(crate) async fn refresh_user_rooms_cache( db: &AppDatabase, cache: &AppCache, config: &ChannelBusConfig, user: Uuid, ) -> ChannelResult> { let key = user_rooms_cache_key(user); cache.remove(&key).await?; let rooms = load_user_rooms(db, user).await?; cache_set_with_ttl(cache, &key, &rooms, config.room_cache_ttl_hint).await?; Ok(rooms) } async fn cache_set_with_ttl( cache: &AppCache, key: &str, value: &T, ttl: Option, ) -> ChannelResult<()> where T: serde::Serialize + serde::de::DeserializeOwned, { cache.set(key, value).await?; if let Some(ttl) = ttl { if let Some(cluster) = &cache.cluster { cluster.expire(key, ttl).await?; } } Ok(()) } pub(crate) async fn active_workspace_users( db: &AppDatabase, wk: Uuid, ) -> ChannelResult> { let rows = sqlx::query_as::<_, (Uuid,)>( "SELECT \"user\" FROM wk_member WHERE wk = $1 AND leave_at IS NULL", ) .bind(wk) .fetch_all(db.reader()) .await?; Ok(rows.into_iter().map(|row| row.0).collect()) } pub(crate) async fn room_workspace( db: &AppDatabase, room: Uuid, ) -> ChannelResult> { let row = sqlx::query_as::<_, (Uuid,)>("SELECT wk FROM room WHERE id = $1") .bind(room) .fetch_optional(db.reader()) .await?; Ok(row.map(|row| row.0)) } pub(crate) async fn catchup_messages( db: &AppDatabase, config: &ChannelBusConfig, room: Uuid, after_seq: i64, ) -> ChannelResult> { let rows = sqlx::query_as::<_, RoomMessageModel>(db::sqlx::AssertSqlSafe( format!( "SELECT {RM_COLUMNS} FROM room_message \ WHERE room = $1 AND seq > $2 AND deleted_at IS NULL \ ORDER BY seq ASC \ LIMIT $3" ), )) .bind(room) .bind(after_seq) .bind(config.catchup_limit) .fetch_all(db.reader()) .await?; Ok(rows) } async fn load_user_rooms( db: &AppDatabase, user: Uuid, ) -> ChannelResult> { let rows = sqlx::query_as::<_, (Uuid,)>( "SELECT r.id \ FROM room r \ INNER JOIN wk_member wm ON wm.wk = r.wk \ WHERE wm.\"user\" = $1 \ AND wm.leave_at IS NULL \ AND r.deleted_at IS NULL \ AND r.is_archived = false \ AND (r.is_private = false \ OR EXISTS ( \ SELECT 1 FROM room_permission_overwrite po \ WHERE po.room = r.id AND po.target_id = $1 \ )) \ ORDER BY r.id", ) .bind(user) .fetch_all(db.reader()) .await?; Ok(rows.into_iter().map(|row| row.0).collect()) }