diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs index 317ce0b..7015881 100644 --- a/crates/adapters/postgres-search/src/lib.rs +++ b/crates/adapters/postgres-search/src/lib.rs @@ -50,24 +50,37 @@ struct FeedRow { like_count: i64, boost_count: i64, reply_count: i64, + liked_by_viewer: bool, + boosted_by_viewer: bool, } -const FEED_SELECT: &str = " - SELECT - t.id AS thought_id, t.user_id AS t_user_id, t.content, - t.in_reply_to_id, - t.visibility, t.content_warning, t.sensitive, t.local AS t_local, - t.created_at AS thought_created_at, t.updated_at, - u.id AS author_id, u.username, u.email, u.password_hash, - u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, - u.local AS author_local, - u.created_at AS author_created_at, u.updated_at AS author_updated_at, - (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count, - (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count, - (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count - FROM thoughts t JOIN users u ON u.id=t.user_id"; +fn feed_select(viewer: Option) -> String { + let viewer_checks = match viewer { + Some(uid) => format!( + "EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,\n\ + EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer" + ), + None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(), + }; + format!( + "\n SELECT\n\ + t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\ + t.in_reply_to_id,\n\ + t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\ + t.created_at AS thought_created_at, t.updated_at,\n\ + u.id AS author_id, u.username, u.email, u.password_hash,\n\ + u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\ + u.local AS author_local,\n\ + u.created_at AS author_created_at, u.updated_at AS author_updated_at,\n\ + (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\ + (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,\n\ + (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\ + {viewer_checks}\n\ + FROM thoughts t JOIN users u ON u.id=t.user_id" + ) +} -fn row_to_entry(r: FeedRow) -> Result { +fn row_to_entry(r: FeedRow, viewer: Option) -> Result { let thought = Thought { id: ThoughtId::from_uuid(r.thought_id), user_id: UserId::from_uuid(r.t_user_id), @@ -102,7 +115,10 @@ fn row_to_entry(r: FeedRow) -> Result { boost_count: r.boost_count, reply_count: r.reply_count, }, - viewer: None, // Task 3 will fix this to use real viewer data + viewer: viewer.map(|_| domain::models::feed::ViewerContext { + liked: r.liked_by_viewer, + boosted: r.boosted_by_viewer, + }), }) } @@ -112,8 +128,11 @@ impl SearchPort for PgSearchRepository { &self, query: &str, page: &PageParams, - _viewer_id: Option<&UserId>, + viewer_id: Option<&UserId>, ) -> Result, DomainError> { + let viewer = viewer_id.map(|v| v.as_uuid()); + let select = feed_select(viewer); + let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'", @@ -124,7 +143,7 @@ impl SearchPort for PgSearchRepository { .map_err(|e| DomainError::Internal(e.to_string()))?; let sql = format!( - "{FEED_SELECT} + "{select} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3" @@ -140,7 +159,7 @@ impl SearchPort for PgSearchRepository { Ok(Paginated { items: rows .into_iter() - .map(row_to_entry) + .map(|r| row_to_entry(r, viewer)) .collect::, _>>()?, total, page: page.page, @@ -287,4 +306,67 @@ mod tests { .unwrap(); assert_eq!(result.total, 0); } + + #[sqlx::test(migrations = "../postgres/migrations")] + async fn search_thoughts_viewer_context(pool: sqlx::PgPool) { + use domain::models::social::Like; + use domain::ports::{LikeRepository, UserWriter}; + use domain::value_objects::LikeId; + use postgres::{like::PgLikeRepository, user::PgUserRepository}; + + let (alice, thought) = seed_thought(&pool, "alice", "hello world").await; + + // alice likes her own thought + let like_repo = PgLikeRepository::new(pool.clone()); + like_repo + .save(&Like { + id: LikeId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: chrono::Utc::now(), + }) + .await + .unwrap(); + + let repo = PgSearchRepository::new(pool); + + // with viewer — should see liked = true + let authed = repo + .search_thoughts( + "hello", + &PageParams { + page: 1, + per_page: 20, + }, + Some(&alice.id), + ) + .await + .unwrap(); + assert_eq!(authed.items.len(), 1); + let ctx = authed.items[0] + .viewer + .as_ref() + .expect("viewer context present"); + assert!(ctx.liked, "alice should see the thought as liked"); + assert!(!ctx.boosted); + + // without viewer — viewer should be None + let anon = repo + .search_thoughts( + "hello", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); + assert_eq!(anon.items.len(), 1); + assert!( + anon.items[0].viewer.is_none(), + "anonymous request has no viewer context" + ); + } }