use crate::error::RoomError; use crate::service::RoomService; use crate::ws_context::WsUserContext; use models::rooms::{room, room_message, room_user_state}; use redis::AsyncCommands; use sea_orm::*; use uuid::Uuid; impl RoomService { const ROOM_LIST_CACHE_TTL: u64 = 15; pub async fn room_list( &self, project_name: String, only_public: Option, ctx: &WsUserContext, ) -> Result, RoomError> { let user_id = ctx.user_id; let project = self.utils_find_project_by_name(project_name).await?; self.check_project_access(project.id, user_id).await?; let cache_key = format!( "room:list:{}:{}:public={}", project.id, user_id, only_public.unwrap_or(false) ); if let Ok(mut conn) = self.cache.conn().await { if let Ok(Some(cached)) = redis::cmd("GET") .arg(&cache_key) .query_async::>(&mut conn) .await { if let Ok(responses) = serde_json::from_str::>(&cached) { tracing::debug!(cache_key = %cache_key, "room_list: cache hit"); return Ok(responses); } } } tracing::debug!(cache_key = %cache_key, "room_list: cache miss"); let mut query = room::Entity::find().filter(room::Column::Project.eq(project.id)); if only_public.unwrap_or(false) { query = query.filter(room::Column::Public.eq(true)); } let models = query .order_by_desc(room::Column::LastMsgAt) .all(&self.db) .await?; let room_ids: Vec = models.iter().map(|r| r.id).collect(); let latest_seqs: std::collections::HashMap = room_message::Entity::find() .select_only() .column(room_message::Column::Room) .column_as(room_message::Column::Seq.max(), "max_seq") .filter(room_message::Column::Room.is_in(room_ids.clone())) .group_by(room_message::Column::Room) .into_tuple::<(Uuid, Option)>() .all(&self.db) .await? .into_iter() .map(|(room, seq)| (room, seq.unwrap_or(0))) .collect(); // Use room_user_state for read position (lazy — only exists if user has interacted) let user_read_seqs: std::collections::HashMap = room_user_state::Entity::find() .filter(room_user_state::Column::User.eq(user_id)) .filter(room_user_state::Column::Room.is_in(room_ids.clone())) .all(&self.db) .await? .into_iter() .map(|s| (s.room, s.last_read_seq.unwrap_or(0))) .collect(); let _unread_counts: std::collections::HashMap = if !room_ids.is_empty() { let _q = room_message::Entity::find() .select_only() .column(room_message::Column::Room) .column_as(room_message::Column::Id.count(), "count") .filter(room_message::Column::Room.is_in(room_ids.clone())) .group_by(room_message::Column::Room); // This is still tricky because last_read_seq is per room-user. // For now, let's keep the latest_seq - last_read_seq logic but // ensure it doesn't over-report when seq starts at a high number. // A better fix would be to store the "base seq" or "start seq" per room. // But the most accurate is counting per room. latest_seqs.clone() } else { std::collections::HashMap::new() }; let mut responses = Vec::new(); for model in models { let last_read_seq = user_read_seqs.get(&model.id).copied().unwrap_or(0); let latest_seq = latest_seqs.get(&model.id).copied().unwrap_or(0); // If user has never read, unread count is the total messages in room. // If they have read, it's the gap between latest and last read. // This is still an approximation if there are gaps, but better than before. let unread_count = if last_read_seq == 0 { // If never read, we ideally want the count of all messages. // But for performance in a list, we'll use latest_seq as a hint // or just stick to the gap logic if we assume seq is monotonic. // The issue is latest_seq = 136, last_read = 0 -> 136. // We'll use a heuristic: if latest_seq > 0 and last_read == 0, // we'll assume they haven't read anything. latest_seq } else { std::cmp::max(latest_seq - last_read_seq, 0) }; let mut response = super::RoomResponse::from(model); response.unread_count = unread_count; responses.push(response); } if let Ok(mut conn) = self.cache.conn().await { if let Ok(json) = serde_json::to_string(&responses) { let _: Option = redis::cmd("SETEX") .arg(&cache_key) .arg(Self::ROOM_LIST_CACHE_TTL) .arg(&json) .query_async(&mut conn) .await .inspect_err(|e| { tracing::warn!(cache_key = %cache_key, error = %e, "room_list: failed to cache"); }) .ok(); } } Ok(responses) } pub async fn room_get( &self, room_id: Uuid, ctx: &WsUserContext, ) -> Result { let user_id = ctx.user_id; let model = self.find_room_or_404(room_id).await?; self.ensure_room_visible_for_user(&model, user_id).await?; let version = self.get_room_version(room_id).await?; let mut resp = super::RoomResponse::from(model); resp.version = version; Ok(resp) } pub(crate) async fn invalidate_room_list_cache(&self, project_id: Uuid) { self.invalidate_room_list_cache_for_prefix(&format!("room:list:{}:", project_id)) .await; } pub(crate) async fn invalidate_room_list_cache_for_user( &self, project_id: Uuid, user_id: Uuid, ) { self.invalidate_room_list_cache_for_prefix(&format!("room:list:{}:{}:", project_id, user_id)) .await; } async fn invalidate_room_list_cache_for_prefix(&self, prefix: &str) { let pattern = format!("{}*", prefix); if let Ok(mut conn) = self.cache.conn().await { let mut cursor: u64 = 0; loop { match redis::cmd("SCAN") .arg(cursor) .arg("MATCH") .arg(&pattern) .arg("COUNT") .arg(100) .query_async::<(u64, Vec)>(&mut conn) .await { Ok((next_cursor, keys)) => { for key in &keys { let _: () = conn.del(key).await.unwrap_or(()); } if next_cursor == 0 { break; } cursor = next_cursor; } Err(e) => { tracing::debug!(pattern = %pattern, error = ?e, "room_list cache scan failed"); break; } } } } } }