From 009a45dffbb38b0083860037c816cd9e3e9ecaf3 Mon Sep 17 00:00:00 2001 From: Dessalines Date: Wed, 3 Jan 2024 13:39:21 -0500 Subject: [PATCH] Adding /post/like/list and /comment/like/list for admins. (#4332) - Allows admins to view likes, sorted by downvotes first, for a given comment or post. - Fixes #4088 --- crates/api/src/comment/list_comment_likes.rs | 24 +++ crates/api/src/comment/mod.rs | 1 + crates/api/src/post/list_post_likes.rs | 24 +++ crates/api/src/post/mod.rs | 1 + crates/api_common/src/comment.rs | 21 +- crates/api_common/src/post.rs | 21 +- crates/db_views/src/lib.rs | 2 + crates/db_views/src/structs.rs | 11 ++ crates/db_views/src/vote_view.rs | 195 +++++++++++++++++++ src/api_routes_http.rs | 10 +- 10 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 crates/api/src/comment/list_comment_likes.rs create mode 100644 crates/api/src/post/list_post_likes.rs create mode 100644 crates/db_views/src/vote_view.rs diff --git a/crates/api/src/comment/list_comment_likes.rs b/crates/api/src/comment/list_comment_likes.rs new file mode 100644 index 000000000..cb487c9f1 --- /dev/null +++ b/crates/api/src/comment/list_comment_likes.rs @@ -0,0 +1,24 @@ +use actix_web::web::{Data, Json, Query}; +use lemmy_api_common::{ + comment::{ListCommentLikes, ListCommentLikesResponse}, + context::LemmyContext, + utils::is_admin, +}; +use lemmy_db_views::structs::{LocalUserView, VoteView}; +use lemmy_utils::error::LemmyError; + +/// Lists likes for a comment +#[tracing::instrument(skip(context))] +pub async fn list_comment_likes( + data: Query, + context: Data, + local_user_view: LocalUserView, +) -> Result, LemmyError> { + // Make sure user is an admin + is_admin(&local_user_view)?; + + let comment_likes = + VoteView::list_for_comment(&mut context.pool(), data.comment_id, data.page, data.limit).await?; + + Ok(Json(ListCommentLikesResponse { comment_likes })) +} diff --git a/crates/api/src/comment/mod.rs b/crates/api/src/comment/mod.rs index 8caeaf8b0..9830e295d 100644 --- a/crates/api/src/comment/mod.rs +++ b/crates/api/src/comment/mod.rs @@ -1,3 +1,4 @@ pub mod distinguish; pub mod like; +pub mod list_comment_likes; pub mod save; diff --git a/crates/api/src/post/list_post_likes.rs b/crates/api/src/post/list_post_likes.rs new file mode 100644 index 000000000..0e52052df --- /dev/null +++ b/crates/api/src/post/list_post_likes.rs @@ -0,0 +1,24 @@ +use actix_web::web::{Data, Json, Query}; +use lemmy_api_common::{ + context::LemmyContext, + post::{ListPostLikes, ListPostLikesResponse}, + utils::is_admin, +}; +use lemmy_db_views::structs::{LocalUserView, VoteView}; +use lemmy_utils::error::LemmyError; + +/// Lists likes for a post +#[tracing::instrument(skip(context))] +pub async fn list_post_likes( + data: Query, + context: Data, + local_user_view: LocalUserView, +) -> Result, LemmyError> { + // Make sure user is an admin + is_admin(&local_user_view)?; + + let post_likes = + VoteView::list_for_post(&mut context.pool(), data.post_id, data.page, data.limit).await?; + + Ok(Json(ListPostLikesResponse { post_likes })) +} diff --git a/crates/api/src/post/mod.rs b/crates/api/src/post/mod.rs index a3b84134f..6a6ed9d21 100644 --- a/crates/api/src/post/mod.rs +++ b/crates/api/src/post/mod.rs @@ -1,6 +1,7 @@ pub mod feature; pub mod get_link_metadata; pub mod like; +pub mod list_post_likes; pub mod lock; pub mod mark_read; pub mod save; diff --git a/crates/api_common/src/comment.rs b/crates/api_common/src/comment.rs index c2589fb2a..003e84d38 100644 --- a/crates/api_common/src/comment.rs +++ b/crates/api_common/src/comment.rs @@ -3,7 +3,7 @@ use lemmy_db_schema::{ CommentSortType, ListingType, }; -use lemmy_db_views::structs::{CommentReportView, CommentView}; +use lemmy_db_views::structs::{CommentReportView, CommentView, VoteView}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] @@ -176,3 +176,22 @@ pub struct ListCommentReports { pub struct ListCommentReportsResponse { pub comment_reports: Vec, } + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// List comment likes. Admins-only. +pub struct ListCommentLikes { + pub comment_id: CommentId, + pub page: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// The comment likes response +pub struct ListCommentLikesResponse { + pub comment_likes: Vec, +} diff --git a/crates/api_common/src/post.rs b/crates/api_common/src/post.rs index c7ee08983..5b3a76110 100644 --- a/crates/api_common/src/post.rs +++ b/crates/api_common/src/post.rs @@ -4,7 +4,7 @@ use lemmy_db_schema::{ PostFeatureType, SortType, }; -use lemmy_db_views::structs::{PaginationCursor, PostReportView, PostView}; +use lemmy_db_views::structs::{PaginationCursor, PostReportView, PostView, VoteView}; use lemmy_db_views_actor::structs::{CommunityModeratorView, CommunityView}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; @@ -252,3 +252,22 @@ pub struct SiteMetadata { pub(crate) image: Option, pub embed_video_url: Option, } + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// List post likes. Admins-only. +pub struct ListPostLikes { + pub post_id: PostId, + pub page: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// The post likes response +pub struct ListPostLikesResponse { + pub post_likes: Vec, +} diff --git a/crates/db_views/src/lib.rs b/crates/db_views/src/lib.rs index 8abf776ba..73310d743 100644 --- a/crates/db_views/src/lib.rs +++ b/crates/db_views/src/lib.rs @@ -22,3 +22,5 @@ pub mod registration_application_view; #[cfg(feature = "full")] pub mod site_view; pub mod structs; +#[cfg(feature = "full")] +pub mod vote_view; diff --git a/crates/db_views/src/structs.rs b/crates/db_views/src/structs.rs index d0f7fcfc9..a68001a52 100644 --- a/crates/db_views/src/structs.rs +++ b/crates/db_views/src/structs.rs @@ -184,3 +184,14 @@ pub struct CustomEmojiView { pub custom_emoji: CustomEmoji, pub keywords: Vec, } + +#[skip_serializing_none] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS, Queryable))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "full", ts(export))] +/// A vote view for checking a post or comments votes. +pub struct VoteView { + pub creator: Person, + pub score: i16, +} diff --git a/crates/db_views/src/vote_view.rs b/crates/db_views/src/vote_view.rs new file mode 100644 index 000000000..e8f5429e4 --- /dev/null +++ b/crates/db_views/src/vote_view.rs @@ -0,0 +1,195 @@ +use crate::structs::VoteView; +use diesel::{result::Error, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +use lemmy_db_schema::{ + newtypes::{CommentId, PostId}, + schema::{comment_like, person, post_like}, + utils::{get_conn, limit_and_offset, DbPool}, +}; + +impl VoteView { + pub async fn list_for_post( + pool: &mut DbPool<'_>, + post_id: PostId, + page: Option, + limit: Option, + ) -> Result, Error> { + let conn = &mut get_conn(pool).await?; + let (limit, offset) = limit_and_offset(page, limit)?; + + post_like::table + .inner_join(person::table) + .filter(post_like::post_id.eq(post_id)) + .select((person::all_columns, post_like::score)) + .order_by(post_like::score) + .limit(limit) + .offset(offset) + .load::(conn) + .await + } + + pub async fn list_for_comment( + pool: &mut DbPool<'_>, + comment_id: CommentId, + page: Option, + limit: Option, + ) -> Result, Error> { + let conn = &mut get_conn(pool).await?; + let (limit, offset) = limit_and_offset(page, limit)?; + + comment_like::table + .inner_join(person::table) + .filter(comment_like::comment_id.eq(comment_id)) + .select((person::all_columns, comment_like::score)) + .order_by(comment_like::score) + .limit(limit) + .offset(offset) + .load::(conn) + .await + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + #![allow(clippy::indexing_slicing)] + + use crate::structs::VoteView; + use lemmy_db_schema::{ + source::{ + comment::{Comment, CommentInsertForm, CommentLike, CommentLikeForm}, + community::{Community, CommunityInsertForm}, + instance::Instance, + person::{Person, PersonInsertForm}, + post::{Post, PostInsertForm, PostLike, PostLikeForm}, + }, + traits::{Crud, Likeable}, + utils::build_db_pool_for_tests, + }; + use serial_test::serial; + + #[tokio::test] + #[serial] + async fn post_and_comment_vote_views() { + let pool = &build_db_pool_for_tests().await; + let pool = &mut pool.into(); + + let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()) + .await + .unwrap(); + + let new_person = PersonInsertForm::builder() + .name("timmy_vv".into()) + .public_key("pubkey".to_string()) + .instance_id(inserted_instance.id) + .build(); + + let inserted_timmy = Person::create(pool, &new_person).await.unwrap(); + + let new_person_2 = PersonInsertForm::builder() + .name("sara_vv".into()) + .public_key("pubkey".to_string()) + .instance_id(inserted_instance.id) + .build(); + + let inserted_sara = Person::create(pool, &new_person_2).await.unwrap(); + + let new_community = CommunityInsertForm::builder() + .name("test community vv".to_string()) + .title("nada".to_owned()) + .public_key("pubkey".to_string()) + .instance_id(inserted_instance.id) + .build(); + + let inserted_community = Community::create(pool, &new_community).await.unwrap(); + + let new_post = PostInsertForm::builder() + .name("A test post vv".into()) + .creator_id(inserted_timmy.id) + .community_id(inserted_community.id) + .build(); + + let inserted_post = Post::create(pool, &new_post).await.unwrap(); + + let comment_form = CommentInsertForm::builder() + .content("A test comment vv".into()) + .creator_id(inserted_timmy.id) + .post_id(inserted_post.id) + .build(); + + let inserted_comment = Comment::create(pool, &comment_form, None).await.unwrap(); + + // Timmy upvotes his own post + let timmy_post_vote_form = PostLikeForm { + post_id: inserted_post.id, + person_id: inserted_timmy.id, + score: 1, + }; + PostLike::like(pool, &timmy_post_vote_form).await.unwrap(); + + // Sara downvotes timmy's post + let sara_post_vote_form = PostLikeForm { + post_id: inserted_post.id, + person_id: inserted_sara.id, + score: -1, + }; + PostLike::like(pool, &sara_post_vote_form).await.unwrap(); + + let expected_post_vote_views = [ + VoteView { + creator: inserted_sara.clone(), + score: -1, + }, + VoteView { + creator: inserted_timmy.clone(), + score: 1, + }, + ]; + + let read_post_vote_views = VoteView::list_for_post(pool, inserted_post.id, None, None) + .await + .unwrap(); + assert_eq!(read_post_vote_views, expected_post_vote_views); + + // Timothy votes down his own comment + let timmy_comment_vote_form = CommentLikeForm { + post_id: inserted_post.id, + comment_id: inserted_comment.id, + person_id: inserted_timmy.id, + score: -1, + }; + CommentLike::like(pool, &timmy_comment_vote_form) + .await + .unwrap(); + + // Sara upvotes timmy's comment + let sara_comment_vote_form = CommentLikeForm { + post_id: inserted_post.id, + comment_id: inserted_comment.id, + person_id: inserted_sara.id, + score: 1, + }; + CommentLike::like(pool, &sara_comment_vote_form) + .await + .unwrap(); + + let expected_comment_vote_views = [ + VoteView { + creator: inserted_timmy.clone(), + score: -1, + }, + VoteView { + creator: inserted_sara.clone(), + score: 1, + }, + ]; + + let read_comment_vote_views = VoteView::list_for_comment(pool, inserted_comment.id, None, None) + .await + .unwrap(); + assert_eq!(read_comment_vote_views, expected_comment_vote_views); + + // Cleanup + Instance::delete(pool, inserted_instance.id).await.unwrap(); + } +} diff --git a/src/api_routes_http.rs b/src/api_routes_http.rs index 5a1bb346f..018a445b1 100644 --- a/src/api_routes_http.rs +++ b/src/api_routes_http.rs @@ -1,6 +1,11 @@ use actix_web::{guard, web}; use lemmy_api::{ - comment::{distinguish::distinguish_comment, like::like_comment, save::save_comment}, + comment::{ + distinguish::distinguish_comment, + like::like_comment, + list_comment_likes::list_comment_likes, + save::save_comment, + }, comment_report::{ create::create_comment_report, list::list_comment_reports, @@ -45,6 +50,7 @@ use lemmy_api::{ feature::feature_post, get_link_metadata::get_link_metadata, like::like_post, + list_post_likes::list_post_likes, lock::lock_post, mark_read::mark_post_as_read, save::save_post, @@ -202,6 +208,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { .route("/feature", web::post().to(feature_post)) .route("/list", web::get().to(list_posts)) .route("/like", web::post().to(like_post)) + .route("/like/list", web::get().to(list_post_likes)) .route("/save", web::put().to(save_post)) .route("/report", web::post().to(create_post_report)) .route("/report/resolve", web::put().to(resolve_post_report)) @@ -226,6 +233,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { .route("/mark_as_read", web::post().to(mark_reply_as_read)) .route("/distinguish", web::post().to(distinguish_comment)) .route("/like", web::post().to(like_comment)) + .route("/like/list", web::get().to(list_comment_likes)) .route("/save", web::put().to(save_comment)) .route("/list", web::get().to(list_comments)) .route("/report", web::post().to(create_comment_report))