diff --git a/.sqlx/query-168ed07152ae8b4b5e056d410f3439d2d93dbcf66f32b7d9d57558d47ec53c77.json b/.sqlx/query-a8031dfaced5de61f8e815c639941ef8ed89fcf20d616e29257774bae28ba54c.json similarity index 81% rename from .sqlx/query-168ed07152ae8b4b5e056d410f3439d2d93dbcf66f32b7d9d57558d47ec53c77.json rename to .sqlx/query-a8031dfaced5de61f8e815c639941ef8ed89fcf20d616e29257774bae28ba54c.json index c01c7724..144917bf 100644 --- a/.sqlx/query-168ed07152ae8b4b5e056d410f3439d2d93dbcf66f32b7d9d57558d47ec53c77.json +++ b/.sqlx/query-a8031dfaced5de61f8e815c639941ef8ed89fcf20d616e29257774bae28ba54c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT rcr.id AS \"id!: RoomId\",\n rcr.requested_by AS \"requested_by!: UserId\",\n COALESCE(u.username, '') AS \"requested_by_username!\",\n rcr.name AS \"name!\",\n rcr.description AS \"description!\",\n rc.id AS \"category_id: RoomCategoryId\",\n rc.key AS \"category_key?\",\n rc.name AS \"category_name?\",\n rc.description AS \"category_description?\",\n rc.sort_order AS \"category_sort_order?\",\n rc.is_enabled AS \"category_is_enabled?\",\n rc.created_at AS \"category_created_at?\",\n rc.updated_at AS \"category_updated_at?\",\n rcr.status AS \"status!: ReviewStatus\",\n rcr.requested_at AS \"requested_at!\",\n rcr.reviewed_at AS \"reviewed_at?\",\n rcr.reviewed_by AS \"reviewed_by?: UserId\",\n rcr.rejection_reason AS \"rejection_reason?\"\n FROM room_creation_requests rcr\n LEFT JOIN users u ON u.id = rcr.requested_by\n LEFT JOIN room_categories rc ON rc.id = rcr.category_id\n WHERE rcr.id = $1\n ", + "query": "\n SELECT rcr.id AS \"id!: RoomId\",\n rcr.requested_by AS \"requested_by!: UserId\",\n COALESCE(u.username, '') AS \"requested_by_username!\",\n rcr.name AS \"name!\",\n rcr.description AS \"description!\",\n rc.id AS \"category_id?: RoomCategoryId\",\n rc.key AS \"category_key?\",\n rc.name AS \"category_name?\",\n rc.description AS \"category_description?\",\n rc.sort_order AS \"category_sort_order?\",\n rc.is_enabled AS \"category_is_enabled?\",\n rc.created_at AS \"category_created_at?\",\n rc.updated_at AS \"category_updated_at?\",\n rcr.status AS \"status!: ReviewStatus\",\n rcr.requested_at AS \"requested_at!\",\n rcr.reviewed_at AS \"reviewed_at?\",\n rcr.reviewed_by AS \"reviewed_by?: UserId\",\n rcr.rejection_reason AS \"rejection_reason?\"\n FROM room_creation_requests rcr\n LEFT JOIN users u ON u.id = rcr.requested_by\n LEFT JOIN room_categories rc ON rc.id = rcr.category_id\n WHERE rcr.id = $1\n ", "describe": { "columns": [ { @@ -55,7 +55,7 @@ }, { "ordinal": 5, - "name": "category_id: RoomCategoryId", + "name": "category_id?: RoomCategoryId", "type_info": "Int8", "origin": { "Table": { @@ -223,5 +223,5 @@ true ] }, - "hash": "168ed07152ae8b4b5e056d410f3439d2d93dbcf66f32b7d9d57558d47ec53c77" + "hash": "a8031dfaced5de61f8e815c639941ef8ed89fcf20d616e29257774bae28ba54c" } diff --git a/.sqlx/query-fda96c60016bed7c6b88109c5c7e87169b44d2e3828c10ccb46a01f722e5081a.json b/.sqlx/query-fe641c4aef2331599d2ed24bd9ffbc138a81822000178721c841fd898ebdba98.json similarity index 78% rename from .sqlx/query-fda96c60016bed7c6b88109c5c7e87169b44d2e3828c10ccb46a01f722e5081a.json rename to .sqlx/query-fe641c4aef2331599d2ed24bd9ffbc138a81822000178721c841fd898ebdba98.json index 29452bc8..04dcd29e 100644 --- a/.sqlx/query-fda96c60016bed7c6b88109c5c7e87169b44d2e3828c10ccb46a01f722e5081a.json +++ b/.sqlx/query-fe641c4aef2331599d2ed24bd9ffbc138a81822000178721c841fd898ebdba98.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT rcr.id AS \"id!: RoomId\",\n rcr.requested_by AS \"requested_by!: UserId\",\n COALESCE(u.username, '') AS \"requested_by_username!\",\n rcr.name AS \"name!\",\n rcr.description AS \"description!\",\n rc.id AS \"category_id: RoomCategoryId\",\n rc.key AS \"category_key?\",\n rc.name AS \"category_name?\",\n rc.description AS \"category_description?\",\n rc.sort_order AS \"category_sort_order?\",\n rc.is_enabled AS \"category_is_enabled?\",\n rc.created_at AS \"category_created_at?\",\n rc.updated_at AS \"category_updated_at?\",\n rcr.status AS \"status!: ReviewStatus\",\n rcr.requested_at AS \"requested_at!\",\n rcr.reviewed_at AS \"reviewed_at?\",\n rcr.reviewed_by AS \"reviewed_by?: UserId\",\n rcr.rejection_reason AS \"rejection_reason?\"\n FROM room_creation_requests rcr\n LEFT JOIN users u ON u.id = rcr.requested_by\n LEFT JOIN room_categories rc ON rc.id = rcr.category_id\n WHERE rcr.status = $1\n AND ($2::bigint IS NULL OR rcr.requested_by = $2)\n AND ($3 = '' OR rcr.name ILIKE $3 ESCAPE '\\' OR rcr.description ILIKE $3 ESCAPE '\\')\n ORDER BY rcr.requested_at DESC, rcr.id DESC\n LIMIT $4 OFFSET $5\n ", + "query": "\n SELECT rcr.id AS \"id!: RoomId\",\n rcr.requested_by AS \"requested_by!: UserId\",\n COALESCE(u.username, '') AS \"requested_by_username!\",\n rcr.name AS \"name!\",\n rcr.description AS \"description!\",\n rc.id AS \"category_id?: RoomCategoryId\",\n rc.key AS \"category_key?\",\n rc.name AS \"category_name?\",\n rc.description AS \"category_description?\",\n rc.sort_order AS \"category_sort_order?\",\n rc.is_enabled AS \"category_is_enabled?\",\n rc.created_at AS \"category_created_at?\",\n rc.updated_at AS \"category_updated_at?\",\n rcr.status AS \"status!: ReviewStatus\",\n rcr.requested_at AS \"requested_at!\",\n rcr.reviewed_at AS \"reviewed_at?\",\n rcr.reviewed_by AS \"reviewed_by?: UserId\",\n rcr.rejection_reason AS \"rejection_reason?\"\n FROM room_creation_requests rcr\n LEFT JOIN users u ON u.id = rcr.requested_by\n LEFT JOIN room_categories rc ON rc.id = rcr.category_id\n WHERE rcr.status = $1\n AND ($2::bigint IS NULL OR rcr.requested_by = $2)\n AND ($3 = '' OR rcr.name ILIKE $3 ESCAPE '\\' OR rcr.description ILIKE $3 ESCAPE '\\')\n ORDER BY rcr.requested_at DESC, rcr.id DESC\n LIMIT $4 OFFSET $5\n ", "describe": { "columns": [ { @@ -55,7 +55,7 @@ }, { "ordinal": 5, - "name": "category_id: RoomCategoryId", + "name": "category_id?: RoomCategoryId", "type_info": "Int8", "origin": { "Table": { @@ -227,5 +227,5 @@ true ] }, - "hash": "fda96c60016bed7c6b88109c5c7e87169b44d2e3828c10ccb46a01f722e5081a" + "hash": "fe641c4aef2331599d2ed24bd9ffbc138a81822000178721c841fd898ebdba98" } diff --git a/synctv-api-common/src/impls/admin/mapping.rs b/synctv-api-common/src/impls/admin/mapping.rs index c8980be1..da3da597 100644 --- a/synctv-api-common/src/impls/admin/mapping.rs +++ b/synctv-api-common/src/impls/admin/mapping.rs @@ -438,6 +438,7 @@ pub(in crate::impls::admin) fn try_managed_room_to_proto( cover: Option<&synctv_core::models::StoredFileReference>, cover_access: Option<&crate::impls::stored_files::StoredFileObjectAccess>, presence: Option<&synctv_core::service::OnlineRoomStats>, + password_enabled: bool, public_id_codec: &synctv_adapter::PublicIdCodec, ) -> Result { let room_settings = settings.ok_or_else(|| { @@ -498,6 +499,7 @@ pub(in crate::impls::admin) fn try_managed_room_to_proto( .map(|label| room_label_to_proto(label, public_id_codec)) .collect::, _>>()?, is_public: Some(room.is_public), + password_enabled, }) } diff --git a/synctv-api-common/src/impls/admin/response.rs b/synctv-api-common/src/impls/admin/response.rs index d8cf42a2..c6f897cf 100644 --- a/synctv-api-common/src/impls/admin/response.rs +++ b/synctv-api-common/src/impls/admin/response.rs @@ -258,6 +258,10 @@ impl AdminApiImpl { cover.as_ref().map(|(reference, _)| reference), cover.as_ref().map(|(_, access)| access), Some(&presence), + self.room_service + .is_room_password_enabled(&room.id) + .await + .map_err(ApiError::from)?, &self.public_id_codec, ) } diff --git a/synctv-api-common/src/impls/admin/rooms.rs b/synctv-api-common/src/impls/admin/rooms.rs index 56117894..72cea9b8 100644 --- a/synctv-api-common/src/impls/admin/rooms.rs +++ b/synctv-api-common/src/impls/admin/rooms.rs @@ -116,16 +116,18 @@ impl AdminApiImpl { let room_ids: Vec = rooms.iter().map(|room| room.id).collect(); let room_id_refs: Vec<&synctv_core::models::RoomId> = room_ids.iter().collect(); - let (member_counts, room_settings_map, presence_stats, room_assets) = tokio::join!( + let (member_counts, room_settings_map, presence_stats, room_assets, password_room_ids) = tokio::join!( self.room_service.get_member_count_batch(&room_id_refs), self.room_service.get_room_settings_batch(&room_ids), self.presence_service.room_stats_batch(&room_ids), self.load_admin_room_list_assets(&rooms, &creator_user_map), + self.room_service.password_enabled_room_ids(&room_ids), ); let member_counts = member_counts.map_err(ApiError::from)?; let room_settings_map = room_settings_map.map_err(ApiError::from)?; let presence_stats = presence_stats.map_err(ApiError::from)?; let (creator_avatar_urls, room_covers) = room_assets?; + let password_room_ids = password_room_ids.map_err(ApiError::from)?; let presence_by_room: std::collections::HashMap = presence_stats .iter() @@ -150,6 +152,7 @@ impl AdminApiImpl { cover.map(|(reference, _)| reference), cover.map(|(_, access)| access), presence_by_room.get(&r.id).copied(), + password_room_ids.contains(&r.id), &self.public_id_codec, )?); } @@ -1035,16 +1038,18 @@ impl AdminApiImpl { let room_ids: Vec = rooms.iter().map(|room| room.id).collect(); let room_id_refs: Vec<&synctv_core::models::RoomId> = room_ids.iter().collect(); - let (member_counts, room_settings_map, presence_stats, room_assets) = tokio::join!( + let (member_counts, room_settings_map, presence_stats, room_assets, password_room_ids) = tokio::join!( self.room_service.get_member_count_batch(&room_id_refs), self.room_service.get_room_settings_batch(&room_ids), self.presence_service.room_stats_batch(&room_ids), self.load_admin_room_list_assets(&rooms, &creator_user_map), + self.room_service.password_enabled_room_ids(&room_ids), ); let member_counts = member_counts.map_err(ApiError::from)?; let room_settings_map = room_settings_map.map_err(ApiError::from)?; let presence_stats = presence_stats.map_err(ApiError::from)?; let (creator_avatar_urls, room_covers) = room_assets?; + let password_room_ids = password_room_ids.map_err(ApiError::from)?; let presence_by_room: std::collections::HashMap = presence_stats .iter() @@ -1069,6 +1074,7 @@ impl AdminApiImpl { cover.map(|(reference, _)| reference), cover.map(|(_, access)| access), presence_by_room.get(&room.id).copied(), + password_room_ids.contains(&room.id), &self.public_id_codec, )?); } diff --git a/synctv-api-common/src/impls/client/convert.rs b/synctv-api-common/src/impls/client/convert.rs index 376b6757..007b0511 100644 --- a/synctv-api-common/src/impls/client/convert.rs +++ b/synctv-api-common/src/impls/client/convert.rs @@ -2890,6 +2890,7 @@ pub fn try_room_to_proto_with_availability_and_presence( .collect::, _>>()?, is_public: Some(room.is_public), creator_blocked: false, + password_enabled: false, }) } diff --git a/synctv-api-common/src/impls/client/mod.rs b/synctv-api-common/src/impls/client/mod.rs index 169237a9..84f0dd5d 100644 --- a/synctv-api-common/src/impls/client/mod.rs +++ b/synctv-api-common/src/impls/client/mod.rs @@ -688,7 +688,7 @@ impl ClientApiImpl { }) .transpose()? .flatten(); - convert::try_room_to_proto_basic_with_cover( + let mut proto = convert::try_room_to_proto_basic_with_cover( room, settings, member_count, @@ -696,9 +696,16 @@ impl ClientApiImpl { cover.as_ref(), cover_access.as_ref(), &self.public_id_codec, - ) + )?; + proto.password_enabled = self + .room_service + .is_room_password_enabled(&room.id) + .await + .map_err(ApiError::from)?; + Ok(proto) } + #[allow(clippy::too_many_arguments)] // Keep preloaded projection inputs explicit, as in convert. pub async fn room_to_proto_with_availability_presence_and_loaded_cover( &self, room: &synctv_core::models::Room, @@ -707,6 +714,7 @@ impl ClientApiImpl { availability: synctv_core::service::ClientResourceAvailability, presence: Option<&synctv_core::service::OnlineRoomStats>, creator: Option, + password_enabled: Option, ) -> Result { let creator = async { match creator { @@ -730,7 +738,7 @@ impl ClientApiImpl { }) .transpose()? .flatten(); - convert::try_room_to_proto_with_availability_presence_and_cover( + let mut proto = convert::try_room_to_proto_with_availability_presence_and_cover( room, settings, member_count, @@ -740,7 +748,16 @@ impl ClientApiImpl { cover.as_ref(), cover_access.as_ref(), &self.public_id_codec, - ) + )?; + proto.password_enabled = match password_enabled { + Some(enabled) => enabled, + None => self + .room_service + .is_room_password_enabled(&room.id) + .await + .map_err(ApiError::from)?, + }; + Ok(proto) } pub async fn media_to_proto_for_viewer_with_loaded_cover( diff --git a/synctv-api-common/src/impls/client/room.rs b/synctv-api-common/src/impls/client/room.rs index 056901eb..f67d6022 100644 --- a/synctv-api-common/src/impls/client/room.rs +++ b/synctv-api-common/src/impls/client/room.rs @@ -243,6 +243,7 @@ impl ClientApiImpl { availability, Some(&presence), None, + None, ) .await?; proto_room.creator_blocked = creator_blocked; @@ -425,6 +426,7 @@ impl ClientApiImpl { availability, presence_by_room.get(&room.id).copied(), Some(creator), + Some(password_room_ids.contains(&room.id)), ) .await?; Ok::<_, ApiError>(DiscoveryRoomProjection { @@ -794,6 +796,7 @@ impl ClientApiImpl { creator_views, favorite_room_ids, blocked_creator_ids, + password_room_ids, ) = tokio::try_join!( async { self.room_service @@ -825,6 +828,12 @@ impl ClientApiImpl { .map(|ids| ids.into_iter().collect::>()) .map_err(ApiError::from) }, + async { + self.room_service + .password_enabled_room_ids_eventually_consistent(&room_ids) + .await + .map_err(ApiError::from) + }, )?; let presence_by_room: HashMap = presence_stats .iter() @@ -846,6 +855,7 @@ impl ClientApiImpl { let availability_map = &availability_map; let favorite_room_ids = &favorite_room_ids; let blocked_creator_ids = &blocked_creator_ids; + let password_room_ids = &password_room_ids; async move { let settings = required_room_settings(room_settings_map, &room.id)?; let availability = required_room_availability(availability_map, &room.id)?; @@ -867,6 +877,7 @@ impl ClientApiImpl { availability, presence_by_room.get(&room.id).copied(), Some(creator), + Some(password_room_ids.contains(&room.id)), ) .await?; proto_room.creator_blocked = blocked_creator_ids.contains(&room.created_by); @@ -1167,6 +1178,7 @@ impl ClientApiImpl { availability, Some(&presence), None, + None, ) .await?; proto_room.creator_blocked = creator_blocked; diff --git a/synctv-api-common/src/impls/messaging/resource_observer.rs b/synctv-api-common/src/impls/messaging/resource_observer.rs index ced72f12..3f2c1189 100644 --- a/synctv-api-common/src/impls/messaging/resource_observer.rs +++ b/synctv-api-common/src/impls/messaging/resource_observer.rs @@ -2810,6 +2810,17 @@ impl ResourceObserver { )); }; event.sequence = logged.sequence; + let chat_service = self + .chat_service + .as_ref() + .ok_or_else(|| "Chat service is unavailable".to_string())?; + chat_service + .attach_pin_event_view_metadata_for_authorized_viewer( + &mut event, + self.actor.user_id().as_ref(), + ) + .await + .map_err(|error| error.to_string())?; if !Self::apply_event_cursor_to_observation(&mut observation, &cursor) { continue; } diff --git a/synctv-core/src/repository/review.rs b/synctv-core/src/repository/review.rs index ef539ffb..02e87e61 100644 --- a/synctv-core/src/repository/review.rs +++ b/synctv-core/src/repository/review.rs @@ -540,7 +540,7 @@ impl ReviewRepository { COALESCE(u.username, '') AS "requested_by_username!", rcr.name AS "name!", rcr.description AS "description!", - rc.id AS "category_id: RoomCategoryId", + rc.id AS "category_id?: RoomCategoryId", rc.key AS "category_key?", rc.name AS "category_name?", rc.description AS "category_description?", @@ -607,7 +607,7 @@ impl ReviewRepository { COALESCE(u.username, '') AS "requested_by_username!", rcr.name AS "name!", rcr.description AS "description!", - rc.id AS "category_id: RoomCategoryId", + rc.id AS "category_id?: RoomCategoryId", rc.key AS "category_key?", rc.name AS "category_name?", rc.description AS "category_description?", diff --git a/synctv-core/src/service/chat.rs b/synctv-core/src/service/chat.rs index 5c997727..4b7ecd4a 100644 --- a/synctv-core/src/service/chat.rs +++ b/synctv-core/src/service/chat.rs @@ -902,6 +902,24 @@ impl ChatService { pub async fn edit_message_outcome( &self, request: EditChatMessage, + ) -> Result { + let viewer_user_id = request.user_id; + let mut outcome = self.edit_message_outcome_stored(request).await?; + self.attach_event_attachment_view_metadata(&mut outcome.event, Some(&viewer_user_id)) + .await?; + if let Some(pin_event) = &mut outcome.pin_event { + self.attach_attachment_view_metadata( + std::slice::from_mut(&mut pin_event.message), + Some(&viewer_user_id), + ) + .await?; + } + Ok(outcome) + } + + async fn edit_message_outcome_stored( + &self, + request: EditChatMessage, ) -> Result { validate_client_operation_id(request.client_operation_id.as_deref())?; validate_chat_metadata(request.metadata.as_ref())?; @@ -1574,8 +1592,7 @@ impl ChatService { ) .await?; - self.chat_repository - .list_pinned_messages_for_viewer(room_id, limit.clamp(1, 100), Some(user_id)) + self.list_pinned_messages_for_authorized_viewer(room_id, Some(user_id), limit) .await } @@ -1585,14 +1602,37 @@ impl ChatService { viewer_user_id: Option<&UserId>, limit: i32, ) -> Result> { - self.chat_repository + let mut pinned = self + .chat_repository .list_pinned_messages_for_viewer(room_id, limit.clamp(1, 100), viewer_user_id) - .await + .await?; + for entry in &mut pinned { + self.attach_attachment_view_metadata( + std::slice::from_mut(&mut entry.message), + viewer_user_id, + ) + .await?; + } + Ok(pinned) } pub async fn pin_message_event_outcome( &self, request: PinChatMessage, + ) -> Result { + let viewer_user_id = request.user_id; + let mut outcome = self.pin_message_event_outcome_stored(request).await?; + self.attach_attachment_view_metadata( + std::slice::from_mut(&mut outcome.event.message), + Some(&viewer_user_id), + ) + .await?; + Ok(outcome) + } + + async fn pin_message_event_outcome_stored( + &self, + request: PinChatMessage, ) -> Result { validate_client_operation_id(request.client_operation_id.as_deref())?; if let Some(note) = request.note.as_deref() { @@ -1670,6 +1710,20 @@ impl ChatService { pub async fn unpin_message_event_outcome( &self, request: UnpinChatMessage, + ) -> Result { + let viewer_user_id = request.user_id; + let mut outcome = self.unpin_message_event_outcome_stored(request).await?; + self.attach_attachment_view_metadata( + std::slice::from_mut(&mut outcome.event.message), + Some(&viewer_user_id), + ) + .await?; + Ok(outcome) + } + + async fn unpin_message_event_outcome_stored( + &self, + request: UnpinChatMessage, ) -> Result { validate_client_operation_id(request.client_operation_id.as_deref())?; self.permission_service @@ -1745,7 +1799,7 @@ impl ChatService { .await?; validate_chat_reaction_key(&request.reaction_key)?; - let inserted = self + let mut inserted = self .chat_repository .set_reaction_with_event(&request, &synctv_common::snanoid!(16), self.clock.now()) .await?; @@ -1756,6 +1810,19 @@ impl ChatService { ) .await; + self.attach_event_attachment_view_metadata( + &mut inserted.event.event, + Some(&request.user_id), + ) + .await?; + if let Some(pin_event) = &mut inserted.pin_event { + self.attach_attachment_view_metadata( + std::slice::from_mut(&mut pin_event.event.message), + Some(&request.user_id), + ) + .await?; + } + info!( room_id = %request.room_id, user_id = %request.user_id, @@ -2159,6 +2226,19 @@ impl ChatService { Ok(()) } + /// Resolves attachment views for an already-authorized pin event, including durable replay. + pub async fn attach_pin_event_view_metadata_for_authorized_viewer( + &self, + event: &mut ChatPinEvent, + viewer_user_id: Option<&UserId>, + ) -> Result<()> { + self.attach_attachment_view_metadata( + std::slice::from_mut(&mut event.message), + viewer_user_id, + ) + .await + } + async fn attach_event_attachment_view_metadata( &self, event: &mut ChatMessageEvent, diff --git a/synctv-core/src/service/chat_tests.rs b/synctv-core/src/service/chat_tests.rs index 92c2feb3..8b1a66c0 100644 --- a/synctv-core/src/service/chat_tests.rs +++ b/synctv-core/src/service/chat_tests.rs @@ -186,6 +186,14 @@ struct PrefixingFileStorageService; #[async_trait::async_trait] impl FileStorageService for PrefixingFileStorageService { + fn public_object_url( + &self, + _storage_backend: &str, + object_key: &str, + ) -> Result> { + Ok(Some(format!("https://cdn.invalid/{object_key}"))) + } + fn backend_name(&self) -> &'static str { "test-storage" } @@ -3069,7 +3077,11 @@ async fn concurrent_same_delete_returns_existing_delete_event() { async fn chat_reactions_update_history_and_emit_reaction_events() { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let username_cache = UsernameCache::local_only("test:chat:reactions:".to_string(), 100, 60); - let service = test_chat_service(&pool, username_cache.clone()); + let service = test_chat_service_with_file_storage( + &pool, + username_cache.clone(), + Arc::new(PrefixingFileStorageService), + ); let user_repository = Arc::new(UserRepository::new(pool.clone())); let owner = ok( user_repository @@ -3124,6 +3136,19 @@ async fn chat_reactions_update_history_and_emit_reaction_events() { room_service.join_room(room.id, member.id, None).await, "member should join room", ); + ok( + FileStorageRepository::new(pool.clone()) + .upsert_object(UpsertFileObject { + storage_backend: "test-storage", + object_key: "submitted/reaction-image", + mime_type: "image/webp", + size_bytes: 128, + content_manifest_sha256: &hex::encode(Sha256::digest(b"reaction-image")), + metadata: &crate::models::FileMetadata::default(), + }) + .await, + "reaction attachment should be registered", + ); let message = ok( service .send_message_event(SendChatMessage { @@ -3134,13 +3159,76 @@ async fn chat_reactions_update_history_and_emit_reaction_events() { message_type: ChatMessageType::User, reply_to_message_id: None, metadata: None, - attachments: Vec::new(), + attachments: vec![SubmittedFileReference { + id: "reaction-image".to_string(), + kind: crate::models::SubmittedFileReferenceKind::Upload, + }], mentions: Vec::new(), }) .await, "message should be stored", ); + ok( + sqlx::query("UPDATE chat_message_attachments SET url = NULL WHERE message_id = $1") + .bind(message.message.message.id) + .execute(&pool) + .await, + "stored attachment should require URL resolution", + ); + + let pin_request = PinChatMessage { + room_id: room.id, + message_id: message.message.message.id, + user_id: owner.id, + client_operation_id: Some("image-pin".to_string()), + note: None, + }; + for expected_inserted in [true, false] { + let outcome = ok( + service.pin_message_event_outcome(pin_request.clone()).await, + "image pin and replay should succeed", + ); + assert_eq!(outcome.inserted, expected_inserted); + assert_reaction_image_url(&outcome.event.message); + } + let pins = ok( + service.list_pinned_messages(&room.id, &owner.id, 10).await, + "image pins should load", + ); + assert_reaction_image_url(&pins[0].message); + let logged_pins = ok( + RoomResourceEventRepository::new(pool.clone()) + .list_room_events_after_sequence_for_resource_types( + &room.id, + &[crate::repository::RoomResourceKind::ChatPins], + 0, + 10, + ) + .await, + "image pin should have a durable event", + ); + let mut replayed_pin = some( + logged_pins + .into_iter() + .find_map(|logged| match logged.payload { + Some(crate::repository::RoomResourceEventPayload::ChatPin { event }) => Some(event), + _ => None, + }), + "image pin event should be replayable", + ); + assert!(replayed_pin.message.attachments[0].url.is_none()); + ok( + service + .attach_pin_event_view_metadata_for_authorized_viewer( + &mut replayed_pin, + Some(&owner.id), + ) + .await, + "durable image pin should resolve attachment metadata", + ); + assert_reaction_image_url(&replayed_pin.message); + let owner_reaction = ok( service .set_reaction_event_outcome(SetChatReaction { @@ -3153,6 +3241,14 @@ async fn chat_reactions_update_history_and_emit_reaction_events() { .await, "owner reaction should be stored", ); + assert_reaction_image_url(&owner_reaction.event.message); + assert_reaction_image_url( + &some( + owner_reaction.pin_event.as_ref(), + "reaction should update the pin", + ) + .message, + ); assert_eq!(owner_reaction.event.kind, ChatEventKind::ReactionsChanged); assert_eq!(owner_reaction.event.message.reactions.len(), 1); assert_eq!(owner_reaction.event.message.reactions[0].key, "like"); @@ -3311,6 +3407,45 @@ async fn chat_reactions_update_history_and_emit_reaction_events() { assert_eq!(next.users.len(), 1); assert_ne!(page.users[0].user_id, next.users[0].user_id); + let edit_request = EditChatMessage { + room_id: room.id, + message_id: message.message.message.id, + user_id: owner.id, + client_operation_id: Some("image-edit".to_string()), + content: "edited image caption".to_string(), + metadata: None, + expected_version: None, + }; + for expected_inserted in [true, false] { + let outcome = ok( + service.edit_message_outcome(edit_request.clone()).await, + "image edit and replay should succeed", + ); + assert_eq!(outcome.inserted, expected_inserted); + assert_reaction_image_url(&outcome.event.message); + if expected_inserted { + assert_reaction_image_url( + &some(outcome.pin_event.as_ref(), "edit should update the pin").message, + ); + } + } + let unpin_request = UnpinChatMessage { + room_id: room.id, + message_id: message.message.message.id, + user_id: owner.id, + client_operation_id: Some("image-unpin".to_string()), + }; + for expected_inserted in [true, false] { + let outcome = ok( + service + .unpin_message_event_outcome(unpin_request.clone()) + .await, + "image unpin and replay should succeed", + ); + assert_eq!(outcome.inserted, expected_inserted); + assert_reaction_image_url(&outcome.event.message); + } + ok( service.user_service.block_user(&member.id, &owner.id).await, "member should block message owner", @@ -3332,6 +3467,14 @@ async fn chat_reactions_update_history_and_emit_reaction_events() { )); } +fn assert_reaction_image_url(message: &ChatMessageWithAttachments) { + assert_eq!(message.attachments.len(), 1); + assert_eq!( + message.attachments[0].url.as_deref(), + Some("https://cdn.invalid/submitted/reaction-image") + ); +} + #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn pinned_chat_messages_list_and_emit_state_events() { diff --git a/synctv-core/tests/secondary_read_repository_tests.rs b/synctv-core/tests/secondary_read_repository_tests.rs index ec87df8d..c06af685 100644 --- a/synctv-core/tests/secondary_read_repository_tests.rs +++ b/synctv-core/tests/secondary_read_repository_tests.rs @@ -11,8 +11,9 @@ use synctv_core::{ repository::{ AuditLogQuery, AuditLogRepository, BanRecordListQuery, BanRecordRepository, ChatRepository, ContentReportListQuery, ContentReportListScope, ContentReportRepository, ReviewRepository, - RoomMemberRepository, RoomPasswordRepository, RoomRepository, RoomSettingsRepository, - RoomTaxonomyRepository, UserRegistrationReviewListQuery, UserRepository, + RoomCreationReviewListQuery, RoomMemberRepository, RoomPasswordRepository, RoomRepository, + RoomSettingsRepository, RoomTaxonomyRepository, UserRegistrationReviewListQuery, + UserRepository, }, }; use synctv_core_testing::{create_test_pool_with_db_and_label, ok, some}; @@ -725,3 +726,47 @@ async fn audit_log_list_reads_from_read_pool_while_detail_uses_primary() { Some("primary_audit_actor") ); } + +#[tokio::test] +#[ignore = "Requires Docker"] +async fn room_creation_review_lists_and_loads_uncategorized_requests() { + let (_container, pool) = + create_test_pool_with_db_and_label("synctv_test", "uncategorized-creation-review").await; + let owner = create_user(&pool, "uncategorized_review_owner").await; + let request_id: i64 = ok( + sqlx::query_scalar( + "INSERT INTO room_creation_requests (requested_by, name, status) VALUES ($1, $2, $3) RETURNING id", + ) + .bind(owner.id.as_i64()) + .bind("Uncategorized review") + .bind(i16::from(ReviewStatus::Pending)) + .fetch_one(&pool) + .await, + "uncategorized request should be inserted", + ); + let repo = ReviewRepository::new(pool); + let page = ok( + repo.list_room_creations(&RoomCreationReviewListQuery { + status: ReviewStatus::Pending, + requested_by: Some(owner.id), + search: None, + limit: 10, + offset: 0, + }) + .await, + "review list should decode an absent category", + ); + assert_eq!(page.total, 1); + assert_eq!(page.rows[0].name, "Uncategorized review"); + assert!(page.rows[0].category.is_none()); + let request_id = ok(RoomId::try_from(request_id), "request id should be valid"); + let detail = some( + ok( + repo.load_room_creation(request_id).await, + "detail should load", + ), + "request should exist", + ); + assert_eq!(detail.name, page.rows[0].name); + assert!(detail.category.is_none()); +} diff --git a/synctv-management/src/mapping/response.rs b/synctv-management/src/mapping/response.rs index f80b7d33..dc6b6093 100644 --- a/synctv-management/src/mapping/response.rs +++ b/synctv-management/src/mapping/response.rs @@ -1178,6 +1178,7 @@ pub(crate) fn created_room_to_client_proto( settings: &synctv_core::models::RoomSettings, member_count: i32, creator: &synctv_core::models::User, + password_enabled: bool, public_id_codec: &synctv_adapter::PublicIdCodec, ) -> Result { Ok(client_proto::Room { @@ -1197,6 +1198,7 @@ pub(crate) fn created_room_to_client_proto( presence: None, creator: Some(user_public_view_to_client_proto(creator, public_id_codec)?), creator_blocked: false, + password_enabled, category: room .category .as_ref() diff --git a/synctv-management/src/service.rs b/synctv-management/src/service.rs index a460ee41..902f833e 100644 --- a/synctv-management/src/service.rs +++ b/synctv-management/src/service.rs @@ -758,6 +758,10 @@ impl ManagementServiceImpl { &settings, member_count, &creator, + self.room_service + .is_room_password_enabled(&room.id) + .await + .map_err(map_core_error)?, &self.public_id_codec, ) } @@ -776,6 +780,10 @@ impl ManagementServiceImpl { &settings, member_count, &creator, + self.room_service + .is_room_password_enabled(&room.id) + .await + .map_err(map_core_error)?, &self.public_id_codec, ) } @@ -2430,6 +2438,10 @@ impl ManagementService for ManagementServiceImpl { &response_settings, member_count, &creator, + self.room_service + .is_room_password_enabled(&room.id) + .await + .map_err(map_core_error)?, &self.public_id_codec, )?; Ok(Response::new(response)) diff --git a/synctv-proto/proto/admin.proto b/synctv-proto/proto/admin.proto index 151cc135..e504f2d2 100644 --- a/synctv-proto/proto/admin.proto +++ b/synctv-proto/proto/admin.proto @@ -174,6 +174,8 @@ message Room { synctv.client.RoomCategory category = 17; repeated synctv.client.RoomLabel labels = 18; optional bool is_public = 19; + // Current OPAQUE credential state, independent of room policy settings. + bool password_enabled = 20; } message RuntimeSettings { diff --git a/synctv-proto/proto/client.proto b/synctv-proto/proto/client.proto index 3e203fb0..dc708d47 100644 --- a/synctv-proto/proto/client.proto +++ b/synctv-proto/proto/client.proto @@ -625,6 +625,8 @@ message Room { repeated RoomLabel labels = 17; optional bool is_public = 18; bool creator_blocked = 19; + // Current OPAQUE credential state, independent of room policy settings. + bool password_enabled = 20; } message RoomCategory { diff --git a/synctv/src/cli/tests.rs b/synctv/src/cli/tests.rs index b5b75384..2c3ef95f 100644 --- a/synctv/src/cli/tests.rs +++ b/synctv/src/cli/tests.rs @@ -5869,6 +5869,7 @@ fn render_human_output_uses_room_and_member_enums_by_context() { labels: Vec::new(), is_public: Some(true), creator_blocked: false, + password_enabled: false, }), playback_state: None, requires_approval: false, @@ -5970,6 +5971,7 @@ fn render_human_output_converts_room_listing_without_context_inference() { category: None, labels: Vec::new(), is_public: Some(false), + password_enabled: true, }], total: 1, })