diff --git a/migrations/20260426011_create_room_members.sql b/migrations/20260426011_create_room_members.sql index e6a24003..64313235 100644 --- a/migrations/20260426011_create_room_members.sql +++ b/migrations/20260426011_create_room_members.sql @@ -16,6 +16,10 @@ CREATE TABLE IF NOT EXISTS room_members ( display_tag VARCHAR(16) NOT NULL DEFAULT '', joined_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_visited_at TIMESTAMPTZ, + last_counted_visit_at TIMESTAMPTZ, + visit_count BIGINT NOT NULL DEFAULT 0 + CHECK (visit_count >= 0), version BIGINT NOT NULL DEFAULT 0, @@ -28,4 +32,6 @@ CREATE INDEX IF NOT EXISTS idx_room_members_room ON room_members(room_id); CREATE INDEX IF NOT EXISTS idx_room_members_user_active ON room_members(user_id, room_id, role, joined_at DESC); CREATE INDEX IF NOT EXISTS idx_room_members_role - ON room_members(room_id, role); +ON room_members(room_id, role); +CREATE INDEX IF NOT EXISTS idx_room_members_user_frequency +ON room_members(user_id, visit_count DESC, last_visited_at DESC NULLS LAST, room_id); diff --git a/synctv-api/src/impls/client/room.rs b/synctv-api/src/impls/client/room.rs index 58d6259e..49c36e05 100644 --- a/synctv-api/src/impls/client/room.rs +++ b/synctv-api/src/impls/client/room.rs @@ -1035,6 +1035,10 @@ impl ClientApiImpl { .create_ticket_with_control(user_id, &room_id, password_version, request_control) .await .map_err(ApiError::from)?; + self.room_service + .record_room_visit(&room_id, user_id) + .await + .map_err(ApiError::from)?; let public_room_id = self .public_id_codec @@ -1088,7 +1092,7 @@ impl ClientApiImpl { .await .map_err(ApiError::from)? .version; - ws_ticket_service + let ticket = ws_ticket_service .create_ticket_with_control( &user_id, &room_id, @@ -1096,7 +1100,12 @@ impl ClientApiImpl { request_control, ) .await - .map_err(ApiError::from)? + .map_err(ApiError::from)?; + self.room_service + .record_room_visit(&room_id, &user_id) + .await + .map_err(ApiError::from)?; + ticket } RoomActor::Guest(access) => ws_ticket_service .create_guest_ticket_with_control( @@ -2817,7 +2826,7 @@ mod tests { assert_eq!(query.relation, synctv_core::models::MyRoomRelation::All); assert_eq!( query.sort_by, - synctv_core::models::MyRoomListSortBy::JoinedAt + synctv_core::models::MyRoomListSortBy::Frequent ); assert_eq!( query.sort_direction, diff --git a/synctv-api/src/impls/client/room/support.rs b/synctv-api/src/impls/client/room/support.rs index 436e7d89..b095c530 100644 --- a/synctv-api/src/impls/client/room/support.rs +++ b/synctv-api/src/impls/client/room/support.rs @@ -100,7 +100,13 @@ pub(super) fn proto_my_room_list_sort_by( ApiError::InvalidInput("Unsupported related room list sort field".to_string()) })? { synctv_proto::client::MyRoomListSortBy::Unspecified - | synctv_proto::client::MyRoomListSortBy::JoinedAt => { + | synctv_proto::client::MyRoomListSortBy::Frequent => { + Ok(synctv_core::models::MyRoomListSortBy::Frequent) + } + synctv_proto::client::MyRoomListSortBy::LastVisitedAt => { + Ok(synctv_core::models::MyRoomListSortBy::LastVisitedAt) + } + synctv_proto::client::MyRoomListSortBy::JoinedAt => { Ok(synctv_core::models::MyRoomListSortBy::JoinedAt) } synctv_proto::client::MyRoomListSortBy::Name => { diff --git a/synctv-core/src/models/mod.rs b/synctv-core/src/models/mod.rs index 3b245442..15afc324 100644 --- a/synctv-core/src/models/mod.rs +++ b/synctv-core/src/models/mod.rs @@ -347,7 +347,7 @@ mod tests { "last_activity_at", "r.last_activity_at" ); - assert_eq!(MyRoomListSortBy::default(), MyRoomListSortBy::JoinedAt); + assert_eq!(MyRoomListSortBy::default(), MyRoomListSortBy::Frequent); assert_sort_field!( NotificationListSortBy, diff --git a/synctv-core/src/models/room_member.rs b/synctv-core/src/models/room_member.rs index a73e31d4..2ce6d37a 100644 --- a/synctv-core/src/models/room_member.rs +++ b/synctv-core/src/models/room_member.rs @@ -173,6 +173,11 @@ sort_field_enum! { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MyRoomListSortBy { + Frequent => { display: "frequent", sql: "rm.visit_count" }, + LastVisitedAt => { + display: "last_visited_at", + sql: "rm.last_visited_at" + }, Name => { display: "name", sql: "r.name" }, CreatedAt => { display: "created_at", sql: "r.created_at" }, UpdatedAt => { display: "updated_at", sql: "r.updated_at" }, @@ -182,7 +187,7 @@ sort_field_enum! { }, JoinedAt => { display: "joined_at", sql: "rm.joined_at" }, } - default = JoinedAt; + default = Frequent; error = "Unknown related room list field"; } @@ -218,7 +223,7 @@ impl Default for MyRoomListQuery { status: None, is_banned: None, relation: MyRoomRelation::All, - sort_by: MyRoomListSortBy::JoinedAt, + sort_by: MyRoomListSortBy::Frequent, sort_direction: SortDirection::Desc, } } diff --git a/synctv-core/src/repository/room.rs b/synctv-core/src/repository/room.rs index ac7c6b50..25a8434a 100644 --- a/synctv-core/src/repository/room.rs +++ b/synctv-core/src/repository/room.rs @@ -1096,7 +1096,12 @@ impl RoomRepository { Self::push_where_prefix(&mut list_builder, &mut has_condition); list_builder.push(ACCESSIBLE_ROOM_CREATOR_CONDITION); list_builder - .push(" ORDER BY rf.created_at DESC, r.id DESC LIMIT ") + .push( + " ORDER BY rm.visit_count DESC, + rm.last_visited_at DESC NULLS LAST, + rf.created_at DESC, + r.id DESC LIMIT ", + ) .push_bind(limit) .push(" OFFSET ") .push_bind(offset); diff --git a/synctv-core/src/repository/room_member.rs b/synctv-core/src/repository/room_member.rs index 997dd015..48858a35 100644 --- a/synctv-core/src/repository/room_member.rs +++ b/synctv-core/src/repository/room_member.rs @@ -358,6 +358,18 @@ impl RoomMemberRepository { use crate::models::SortDirection; match (query.sort_by, query.sort_direction) { + (MyRoomListSortBy::Frequent, SortDirection::Asc) => { + "rm.visit_count ASC, rm.last_visited_at ASC NULLS FIRST, r.id ASC" + } + (MyRoomListSortBy::Frequent, SortDirection::Desc) => { + "rm.visit_count DESC, rm.last_visited_at DESC NULLS LAST, r.id DESC" + } + (MyRoomListSortBy::LastVisitedAt, SortDirection::Asc) => { + "rm.last_visited_at ASC NULLS FIRST, rm.visit_count ASC, r.id ASC" + } + (MyRoomListSortBy::LastVisitedAt, SortDirection::Desc) => { + "rm.last_visited_at DESC NULLS LAST, rm.visit_count DESC, r.id DESC" + } (MyRoomListSortBy::JoinedAt, SortDirection::Asc) => "rm.joined_at ASC, r.id ASC", (MyRoomListSortBy::JoinedAt, SortDirection::Desc) => "rm.joined_at DESC, r.id DESC", (MyRoomListSortBy::Name, SortDirection::Asc) => "r.name ASC, r.id ASC", @@ -422,6 +434,38 @@ impl RoomMemberRepository { Ok(added) } + /// Record a room visit and increment its frequency counter when the + /// previous counted visit falls outside the application-defined window. + pub async fn record_visit( + &self, + room_id: &RoomId, + user_id: &UserId, + counted_before: chrono::DateTime, + visited_at: chrono::DateTime, + ) -> Result { + let result = sqlx::query( + r"UPDATE room_members + SET visit_count = visit_count + CASE + WHEN last_counted_visit_at IS NULL OR last_counted_visit_at <= $3 THEN 1 + ELSE 0 + END, + last_counted_visit_at = CASE + WHEN last_counted_visit_at IS NULL OR last_counted_visit_at <= $3 THEN $4 + ELSE last_counted_visit_at + END, + last_visited_at = $4 + WHERE room_id = $1 AND user_id = $2", + ) + .bind(room_id) + .bind(user_id) + .bind(counted_before) + .bind(visited_at) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() == 1) + } + /// Add user to room using a provided transaction connection. /// /// Accepts `&mut PgConnection` so callers can keep room lifecycle mutations @@ -2595,7 +2639,7 @@ impl RoomMemberRepository { r.created_at, r.updated_at, r.deleted_at, r.version, r.last_activity_at, rc.id, rc.key, rc.name, rc.description, rc.sort_order, rc.is_enabled, rc.created_at, rc.updated_at, - rm.role, rm.joined_at + rm.role, rm.joined_at, rm.last_visited_at, rm.visit_count ORDER BY {order_by_sql} LIMIT $2 OFFSET $3 " @@ -2679,7 +2723,7 @@ impl RoomMemberRepository { r.created_at, r.updated_at, r.deleted_at, r.version, r.last_activity_at, rc.id, rc.key, rc.name, rc.description, rc.sort_order, rc.is_enabled, rc.created_at, rc.updated_at, - rm.role, rm.joined_at + rm.role, rm.joined_at, rm.last_visited_at, rm.visit_count ORDER BY {order_by_sql} LIMIT $2 OFFSET $3 " diff --git a/synctv-core/src/service/room/access.rs b/synctv-core/src/service/room/access.rs index 0d4232c3..52b40994 100644 --- a/synctv-core/src/service/room/access.rs +++ b/synctv-core/src/service/room/access.rs @@ -17,6 +17,8 @@ use crate::{ }; impl RoomService { + const ROOM_VISIT_COUNT_INTERVAL_SECS: i64 = 30 * 60; + #[must_use] pub const fn pool(&self) -> &PgPool { &self.pool @@ -112,6 +114,22 @@ impl RoomService { self.room_repo.exists(room_id).await } + /// Record that an authenticated member entered a room. + pub async fn record_room_visit(&self, room_id: &RoomId, user_id: &UserId) -> Result<()> { + let visited_at = self.clock.now(); + let counted_before = + visited_at - chrono::Duration::seconds(Self::ROOM_VISIT_COUNT_INTERVAL_SECS); + let recorded = self + .member_repo + .record_visit(room_id, user_id, counted_before, visited_at) + .await?; + if recorded { + Ok(()) + } else { + Err(Error::NotFound("Room membership not found".to_string())) + } + } + pub async fn get_room(&self, room_id: &RoomId) -> Result { let mut room = self .room_repo diff --git a/synctv-core/tests/room_member_repository_tests.rs b/synctv-core/tests/room_member_repository_tests.rs index df1420d2..7ee5b662 100644 --- a/synctv-core/tests/room_member_repository_tests.rs +++ b/synctv-core/tests/room_member_repository_tests.rs @@ -119,6 +119,148 @@ async fn test_add_with_options_full_flow() { assert_eq!(result.status, MemberStatus::Active); } +#[tokio::test] +#[ignore = "Requires Docker"] +async fn test_record_visit_deduplicates_and_drives_room_sorting() { + let (_container, pool) = create_test_pool().await; + let user_repo = UserRepository::new(pool.clone()); + let room_repo = RoomRepository::new(pool.clone()); + let member_repo = RoomMemberRepository::new(pool.clone()); + + let owner = user_repo + .create(&make_user("owner_room_visits")) + .await + .checked("test operation should succeed"); + let visitor = user_repo + .create(&make_user("visitor_room_visits")) + .await + .checked("test operation should succeed"); + let frequent_room = room_repo + .create(&make_room("Frequent Room", &owner.id)) + .await + .checked("test operation should succeed"); + let recent_room = room_repo + .create(&make_room("Recent Room", &owner.id)) + .await + .checked("test operation should succeed"); + + for room in [&frequent_room, &recent_room] { + member_repo + .add(&make_member(room.id, visitor.id, RoomRole::Member)) + .await + .checked("test operation should succeed"); + } + + let first_visit = Utc::now() - chrono::Duration::hours(1); + assert!(member_repo + .record_visit( + &frequent_room.id, + &visitor.id, + first_visit - chrono::Duration::minutes(30), + first_visit, + ) + .await + .checked("first visit should be recorded")); + + let duplicate_visit = first_visit + chrono::Duration::minutes(5); + member_repo + .record_visit( + &frequent_room.id, + &visitor.id, + duplicate_visit - chrono::Duration::minutes(30), + duplicate_visit, + ) + .await + .checked("duplicate visit should refresh recency"); + + let reconnect_visit = first_visit + chrono::Duration::minutes(20); + member_repo + .record_visit( + &frequent_room.id, + &visitor.id, + reconnect_visit - chrono::Duration::minutes(30), + reconnect_visit, + ) + .await + .checked("reconnect should preserve the original counting window"); + + let second_counted_visit = first_visit + chrono::Duration::minutes(40); + member_repo + .record_visit( + &frequent_room.id, + &visitor.id, + second_counted_visit - chrono::Duration::minutes(30), + second_counted_visit, + ) + .await + .checked("visit outside the counting window should increment frequency"); + + let most_recent_visit = first_visit + chrono::Duration::minutes(45); + member_repo + .record_visit( + &recent_room.id, + &visitor.id, + most_recent_visit - chrono::Duration::minutes(30), + most_recent_visit, + ) + .await + .checked("recent room visit should be recorded"); + + let visit_count: i64 = sqlx::query_scalar( + "SELECT visit_count FROM room_members WHERE room_id = $1 AND user_id = $2", + ) + .bind(frequent_room.id) + .bind(visitor.id) + .fetch_one(&pool) + .await + .checked("visit count should be readable"); + assert_eq!(visit_count, 2); + + room_repo + .favorite_for_user(&visitor.id, &frequent_room.id) + .await + .checked("frequent room should be favorited"); + room_repo + .favorite_for_user(&visitor.id, &recent_room.id) + .await + .checked("recent room should be favorited"); + let (favorite_results, favorite_total) = room_repo + .list_favorites_for_user(&visitor.id, PageParams::new(Some(1), Some(10)), None) + .await + .checked("favorite room sorting should succeed"); + assert_eq!(favorite_total, 2); + assert_eq!(favorite_results[0].id, frequent_room.id); + + let base_query = MyRoomListQuery { + pagination: PageParams::new(Some(1), Some(10)), + sort_direction: SortDirection::Desc, + ..Default::default() + }; + let (frequent_results, _) = member_repo + .list_by_user_with_query( + &visitor.id, + &MyRoomListQuery { + sort_by: MyRoomListSortBy::Frequent, + ..base_query.clone() + }, + ) + .await + .checked("frequent room sorting should succeed"); + assert_eq!(frequent_results[0].0.id, frequent_room.id); + + let (recent_results, _) = member_repo + .list_by_user_with_query( + &visitor.id, + &MyRoomListQuery { + sort_by: MyRoomListSortBy::LastVisitedAt, + ..base_query + }, + ) + .await + .checked("recent room sorting should succeed"); + assert_eq!(recent_results[0].0.id, recent_room.id); +} + #[tokio::test] #[ignore = "Requires Docker"] async fn test_add_with_options_capacity_at_max_members() { diff --git a/synctv-proto/proto/client.proto b/synctv-proto/proto/client.proto index e583eb7c..bfd6bcce 100644 --- a/synctv-proto/proto/client.proto +++ b/synctv-proto/proto/client.proto @@ -1426,11 +1426,13 @@ enum MediaListSortBy { enum MyRoomListSortBy { MY_ROOM_LIST_SORT_BY_UNSPECIFIED = 0; - MY_ROOM_LIST_SORT_BY_JOINED_AT = 1; - MY_ROOM_LIST_SORT_BY_CREATED_AT = 2; - MY_ROOM_LIST_SORT_BY_UPDATED_AT = 3; - MY_ROOM_LIST_SORT_BY_LAST_ACTIVITY_AT = 4; - MY_ROOM_LIST_SORT_BY_NAME = 5; + MY_ROOM_LIST_SORT_BY_FREQUENT = 1; + MY_ROOM_LIST_SORT_BY_LAST_VISITED_AT = 2; + MY_ROOM_LIST_SORT_BY_JOINED_AT = 3; + MY_ROOM_LIST_SORT_BY_CREATED_AT = 4; + MY_ROOM_LIST_SORT_BY_UPDATED_AT = 5; + MY_ROOM_LIST_SORT_BY_LAST_ACTIVITY_AT = 6; + MY_ROOM_LIST_SORT_BY_NAME = 7; } enum MyRoomRelation {