diff --git a/crates/api/src/comment/save.rs b/crates/api/src/comment/save.rs index 6efa6296d..cca6d06bc 100644 --- a/crates/api/src/comment/save.rs +++ b/crates/api/src/comment/save.rs @@ -16,10 +16,7 @@ pub async fn save_comment( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { - let comment_saved_form = CommentSavedForm { - comment_id: data.comment_id, - person_id: local_user_view.person.id, - }; + let comment_saved_form = CommentSavedForm::new(data.comment_id, local_user_view.person.id); if data.save { CommentSaved::save(&mut context.pool(), &comment_saved_form) diff --git a/crates/api/src/local_user/list_saved.rs b/crates/api/src/local_user/list_saved.rs index e69de29bb..5f0deff39 100644 --- a/crates/api/src/local_user/list_saved.rs +++ b/crates/api/src/local_user/list_saved.rs @@ -0,0 +1,40 @@ +use activitypub_federation::config::Data; +use actix_web::web::{Json, Query}; +use lemmy_api_common::{ + context::LemmyContext, + person::{ListPersonSaved, ListPersonSavedResponse}, + utils::check_private_instance, +}; +use lemmy_db_views::{ + person_saved_combined_view::PersonSavedCombinedQuery, + structs::{LocalUserView, SiteView}, +}; +use lemmy_utils::error::LemmyResult; + +#[tracing::instrument(skip(context))] +pub async fn list_person_saved( + data: Query, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + let local_site = SiteView::read_local(&mut context.pool()).await?; + + check_private_instance(&Some(local_user_view.clone()), &local_site.local_site)?; + + // parse pagination token + let page_after = if let Some(pa) = &data.page_cursor { + Some(pa.read(&mut context.pool()).await?) + } else { + None + }; + let page_back = data.page_back; + + let saved = PersonSavedCombinedQuery { + page_after, + page_back, + } + .list(&mut context.pool(), &local_user_view) + .await?; + + Ok(Json(ListPersonSavedResponse { saved })) +} diff --git a/crates/api_common/src/person.rs b/crates/api_common/src/person.rs index 37f03ecaf..92a524543 100644 --- a/crates/api_common/src/person.rs +++ b/crates/api_common/src/person.rs @@ -11,6 +11,7 @@ use lemmy_db_views::structs::{ LocalImageView, PersonContentCombinedPaginationCursor, PersonContentCombinedView, + PersonSavedCombinedPaginationCursor, }; use lemmy_db_views_actor::structs::{ CommentReplyView, @@ -273,9 +274,9 @@ pub struct ListPersonContentResponse { #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] /// Gets your saved posts and comments -pub struct ListSaved { +pub struct ListPersonSaved { #[cfg_attr(feature = "full", ts(optional))] - pub page_cursor: Option, + pub page_cursor: Option, #[cfg_attr(feature = "full", ts(optional))] pub page_back: Option, } @@ -285,7 +286,7 @@ pub struct ListSaved { #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] /// A person's saved content response. -pub struct ListSavedResponse { +pub struct ListPersonSavedResponse { pub saved: Vec, } diff --git a/crates/apub/src/api/user_settings_backup.rs b/crates/apub/src/api/user_settings_backup.rs index 6184df7d3..d98df25ad 100644 --- a/crates/apub/src/api/user_settings_backup.rs +++ b/crates/apub/src/api/user_settings_backup.rs @@ -212,10 +212,7 @@ pub async fn import_settings( &context, |(saved, context)| async move { let comment = saved.dereference(&context).await?; - let form = CommentSavedForm { - person_id, - comment_id: comment.id, - }; + let form = CommentSavedForm::new(comment.id, person_id); CommentSaved::save(&mut context.pool(), &form).await?; LemmyResult::Ok(()) }, diff --git a/crates/db_schema/replaceable_schema/triggers.sql b/crates/db_schema/replaceable_schema/triggers.sql index 9768bb1d2..91c4fb841 100644 --- a/crates/db_schema/replaceable_schema/triggers.sql +++ b/crates/db_schema/replaceable_schema/triggers.sql @@ -714,33 +714,74 @@ CALL r.create_person_content_combined_trigger ('post'); CALL r.create_person_content_combined_trigger ('comment'); -- person_saved (comment, post) --- TODO, not sure how to handle changes to post_actions and comment_actions.saved column. --- False should delete this row, true should insert --- CREATE PROCEDURE r.create_person_saved_combined_trigger (table_name text) --- LANGUAGE plpgsql --- AS $a$ --- BEGIN --- EXECUTE replace($b$ CREATE FUNCTION r.person_saved_combined_thing_insert ( ) --- RETURNS TRIGGER --- LANGUAGE plpgsql --- AS $$ --- BEGIN --- INSERT INTO person_saved_combined (published, thing_id) --- VALUES (NEW.saved, NEW.id); --- RETURN NEW; --- END $$; --- CREATE TRIGGER person_saved_combined --- AFTER INSERT ON thing --- FOR EACH ROW --- EXECUTE FUNCTION r.person_saved_combined_thing_insert ( ); --- $b$, --- 'thing', --- table_name); --- END; --- $a$; +-- TODO, not sure how to handle changes to post_actions and comment_actions.saved column using @dullbanana's trigger method. +-- Post +CREATE FUNCTION r.person_saved_combined_change_values_post () + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ +BEGIN + IF (TG_OP = 'DELETE') THEN + DELETE FROM person_saved_combined AS p + WHERE p.person_id = OLD.person_id + AND p.post_id = OLD.post_id; + ELSIF (TG_OP = 'INSERT') THEN + IF NEW.saved IS NOT NULL THEN + INSERT INTO person_saved_combined (published, person_id, post_id) + VALUES (NEW.saved, NEW.person_id, NEW.post_id); + END IF; + ELSIF (TG_OP = 'UPDATE') THEN + IF NEW.saved IS NOT NULL THEN + INSERT INTO person_saved_combined (published, person_id, post_id) + VALUES (NEW.saved, NEW.person_id, NEW.post_id); + -- If saved gets set as null, delete the row + ELSE + DELETE FROM person_saved_combined AS p + WHERE p.person_id = NEW.person_id + AND p.post_id = NEW.post_id; + END IF; + END IF; + RETURN NULL; +END +$$; --- CALL r.create_person_saved_combined_trigger ('post_actions'); +CREATE TRIGGER person_saved_combined_post + AFTER INSERT OR DELETE OR UPDATE OF saved ON post_actions + FOR EACH ROW + EXECUTE FUNCTION r.person_saved_combined_change_values_post (); --- CALL r.create_person_saved_combined_trigger ('comment_actions'); +-- Comment +CREATE FUNCTION r.person_saved_combined_change_values_comment () + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ +BEGIN + IF (TG_OP = 'DELETE') THEN + DELETE FROM person_saved_combined AS p + WHERE p.person_id = OLD.person_id + AND p.comment_id = OLD.comment_id; + ELSIF (TG_OP = 'INSERT') THEN + IF NEW.saved IS NOT NULL THEN + INSERT INTO person_saved_combined (published, person_id, comment_id) + VALUES (NEW.saved, NEW.person_id, NEW.comment_id); + END IF; + ELSIF (TG_OP = 'UPDATE') THEN + IF NEW.saved IS NOT NULL THEN + INSERT INTO person_saved_combined (published, person_id, comment_id) + VALUES (NEW.saved, NEW.person_id, NEW.comment_id); + -- If saved gets set as null, delete the row + ELSE + DELETE FROM person_saved_combined AS p + WHERE p.person_id = NEW.person_id + AND p.comment_id = NEW.comment_id; + END IF; + END IF; + RETURN NULL; +END +$$; +CREATE TRIGGER person_saved_combined_comment + AFTER INSERT OR DELETE OR UPDATE OF saved ON comment_actions + FOR EACH ROW + EXECUTE FUNCTION r.person_saved_combined_change_values_comment (); diff --git a/crates/db_schema/src/impls/comment.rs b/crates/db_schema/src/impls/comment.rs index 7dcc033a1..17cd6ce5c 100644 --- a/crates/db_schema/src/impls/comment.rs +++ b/crates/db_schema/src/impls/comment.rs @@ -184,10 +184,6 @@ impl Saveable for CommentSaved { comment_saved_form: &CommentSavedForm, ) -> Result { let conn = &mut get_conn(pool).await?; - let comment_saved_form = ( - comment_saved_form, - comment_actions::saved.eq(now().nullable()), - ); insert_into(comment_actions::table) .values(comment_saved_form) .on_conflict((comment_actions::comment_id, comment_actions::person_id)) @@ -319,11 +315,7 @@ mod tests { }; // Comment Saved - let comment_saved_form = CommentSavedForm { - comment_id: inserted_comment.id, - person_id: inserted_person.id, - }; - + let comment_saved_form = CommentSavedForm::new(inserted_comment.id, inserted_person.id); let inserted_comment_saved = CommentSaved::save(pool, &comment_saved_form).await?; let expected_comment_saved = CommentSaved { diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index dd6690849..51e4304e7 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -752,6 +752,7 @@ diesel::table! { person_saved_combined (id) { id -> Int4, published -> Timestamptz, + person_id -> Int4, post_id -> Nullable, comment_id -> Nullable, } @@ -1053,6 +1054,7 @@ diesel::joinable!(person_content_combined -> post (post_id)); diesel::joinable!(person_mention -> comment (comment_id)); diesel::joinable!(person_mention -> person (recipient_id)); diesel::joinable!(person_saved_combined -> comment (comment_id)); +diesel::joinable!(person_saved_combined -> person (person_id)); diesel::joinable!(person_saved_combined -> post (post_id)); diesel::joinable!(post -> community (community_id)); diesel::joinable!(post -> language (language_id)); diff --git a/crates/db_schema/src/source/combined/person_saved.rs b/crates/db_schema/src/source/combined/person_saved.rs index 08cdd0415..298360a6d 100644 --- a/crates/db_schema/src/source/combined/person_saved.rs +++ b/crates/db_schema/src/source/combined/person_saved.rs @@ -1,4 +1,4 @@ -use crate::newtypes::{CommentId, PersonSavedCombinedId, PostId}; +use crate::newtypes::{CommentId, PersonId, PersonSavedCombinedId, PostId}; #[cfg(feature = "full")] use crate::schema::person_saved_combined; use chrono::{DateTime, Utc}; @@ -23,6 +23,7 @@ use ts_rs::TS; pub struct PersonSavedCombined { pub id: PersonSavedCombinedId, pub published: DateTime, + pub person_id: PersonId, #[cfg_attr(feature = "full", ts(optional))] pub post_id: Option, #[cfg_attr(feature = "full", ts(optional))] diff --git a/crates/db_schema/src/source/comment.rs b/crates/db_schema/src/source/comment.rs index d4001807f..cc5d8c20c 100644 --- a/crates/db_schema/src/source/comment.rs +++ b/crates/db_schema/src/source/comment.rs @@ -142,7 +142,10 @@ pub struct CommentSaved { #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = comment_actions))] +#[derive(derive_new::new)] pub struct CommentSavedForm { pub comment_id: CommentId, pub person_id: PersonId, + #[new(value = "Utc::now()")] + pub saved: DateTime, } diff --git a/crates/db_views/src/person_content_combined_view.rs b/crates/db_views/src/person_content_combined_view.rs index 69be392de..a9af32f40 100644 --- a/crates/db_views/src/person_content_combined_view.rs +++ b/crates/db_views/src/person_content_combined_view.rs @@ -121,8 +121,8 @@ impl PersonContentCombinedQuery { person::table.on( comment::creator_id .eq(item_creator) - // Need to filter out the post rows where both the post and comment creator are the - // same. + // Need to filter out the post rows where the post_id given is null + // Otherwise you'll get duped post rows .or( post::creator_id .eq(item_creator) @@ -372,7 +372,7 @@ mod tests { .await?; assert_eq!(3, timmy_content.len()); - // Make sure the report types are correct + // Make sure the types are correct if let PersonContentCombinedView::Comment(v) = &timmy_content[0] { assert_eq!(data.timmy_comment.id, v.comment.id); assert_eq!(data.timmy.id, v.creator.id); diff --git a/crates/db_views/src/person_saved_combined_view.rs b/crates/db_views/src/person_saved_combined_view.rs index 2e0a9f5bf..d252dcca7 100644 --- a/crates/db_views/src/person_saved_combined_view.rs +++ b/crates/db_views/src/person_saved_combined_view.rs @@ -1,554 +1,393 @@ -// use crate::{ -// structs::{ -// CommentView, -// LocalUserView, -// PostView, -// ProfileCombinedPaginationCursor, -// PersonContentCombinedView, -// PersonContentViewInternal, -// }, -// InternalToCombinedView, -// }; -// use diesel::{ -// result::Error, -// BoolExpressionMethods, -// ExpressionMethods, -// JoinOnDsl, -// NullableExpressionMethods, -// QueryDsl, -// SelectableHelper, -// }; -// use diesel_async::RunQueryDsl; -// use i_love_jesus::PaginatedQueryBuilder; -// use lemmy_db_schema::{ -// aliases::creator_community_actions, -// newtypes::{CommunityId, PersonId}, -// schema::{ -// comment, -// comment_actions, -// comment_aggregates, -// community, -// community_actions, -// image_details, -// local_user, -// person, -// person_actions, -// post, -// post_actions, -// post_aggregates, -// profile_combined, -// }, -// source::{ -// combined::profile::{profile_combined_keys as key, ProfileCombined}, -// community::CommunityFollower, -// }, -// utils::{actions, actions_alias, functions::coalesce, get_conn, DbPool, ReverseTimestampKey}, -// }; -// use lemmy_utils::error::LemmyResult; +use crate::{ + structs::{ + LocalUserView, + PersonContentCombinedView, + PersonContentViewInternal, + PersonSavedCombinedPaginationCursor, + }, + InternalToCombinedView, +}; +use diesel::{ + result::Error, + BoolExpressionMethods, + ExpressionMethods, + JoinOnDsl, + NullableExpressionMethods, + QueryDsl, + SelectableHelper, +}; +use diesel_async::RunQueryDsl; +use i_love_jesus::PaginatedQueryBuilder; +use lemmy_db_schema::{ + aliases::creator_community_actions, + schema::{ + comment, + comment_actions, + comment_aggregates, + community, + community_actions, + image_details, + local_user, + person, + person_actions, + person_saved_combined, + post, + post_actions, + post_aggregates, + }, + source::{ + combined::person_saved::{person_saved_combined_keys as key, PersonSavedCombined}, + community::CommunityFollower, + }, + utils::{actions, actions_alias, functions::coalesce, get_conn, DbPool}, +}; +use lemmy_utils::error::LemmyResult; -// impl ProfileCombinedPaginationCursor { -// // get cursor for page that starts immediately after the given post -// pub fn after_post(view: &PersonContentCombinedView) -> ProfileCombinedPaginationCursor { -// let (prefix, id) = match view { -// PersonContentCombinedView::Comment(v) => ('C', v.comment.id.0), -// PersonContentCombinedView::Post(v) => ('P', v.post.id.0), -// }; -// // hex encoding to prevent ossification -// ProfileCombinedPaginationCursor(format!("{prefix}{id:x}")) -// } +impl PersonSavedCombinedPaginationCursor { + // get cursor for page that starts immediately after the given post + pub fn after_post(view: &PersonContentCombinedView) -> PersonSavedCombinedPaginationCursor { + let (prefix, id) = match view { + PersonContentCombinedView::Comment(v) => ('C', v.comment.id.0), + PersonContentCombinedView::Post(v) => ('P', v.post.id.0), + }; + // hex encoding to prevent ossification + PersonSavedCombinedPaginationCursor(format!("{prefix}{id:x}")) + } -// pub async fn read(&self, pool: &mut DbPool<'_>) -> Result { -// let err_msg = || Error::QueryBuilderError("Could not parse pagination token".into()); -// let mut query = profile_combined::table -// .select(ProfileCombined::as_select()) -// .into_boxed(); -// let (prefix, id_str) = self.0.split_at_checked(1).ok_or_else(err_msg)?; -// let id = i32::from_str_radix(id_str, 16).map_err(|_err| err_msg())?; -// query = match prefix { -// "C" => query.filter(profile_combined::comment_id.eq(id)), -// "P" => query.filter(profile_combined::post_id.eq(id)), -// _ => return Err(err_msg()), -// }; -// let token = query.first(&mut get_conn(pool).await?).await?; + pub async fn read(&self, pool: &mut DbPool<'_>) -> Result { + let err_msg = || Error::QueryBuilderError("Could not parse pagination token".into()); + let mut query = person_saved_combined::table + .select(PersonSavedCombined::as_select()) + .into_boxed(); + let (prefix, id_str) = self.0.split_at_checked(1).ok_or_else(err_msg)?; + let id = i32::from_str_radix(id_str, 16).map_err(|_err| err_msg())?; + query = match prefix { + "C" => query.filter(person_saved_combined::comment_id.eq(id)), + "P" => query.filter(person_saved_combined::post_id.eq(id)), + _ => return Err(err_msg()), + }; + let token = query.first(&mut get_conn(pool).await?).await?; -// Ok(PaginationCursorData(token)) -// } -// } + Ok(PaginationCursorData(token)) + } +} -// #[derive(Clone)] -// pub struct PaginationCursorData(ProfileCombined); +#[derive(Clone)] +pub struct PaginationCursorData(PersonSavedCombined); -// #[derive(Default)] -// pub struct ProfileCombinedQuery { -// pub creator_id: PersonId, -// pub page_after: Option, -// pub page_back: Option, -// } +#[derive(Default)] +pub struct PersonSavedCombinedQuery { + pub page_after: Option, + pub page_back: Option, +} -// impl ProfileCombinedQuery { -// pub async fn list( -// self, -// pool: &mut DbPool<'_>, -// user: &Option, -// ) -> LemmyResult> { -// let my_person_id = user -// .as_ref() -// .map(|u| u.local_user.person_id) -// .unwrap_or(PersonId(-1)); -// let item_creator = person::id; +impl PersonSavedCombinedQuery { + pub async fn list( + self, + pool: &mut DbPool<'_>, + user: &LocalUserView, + ) -> LemmyResult> { + let my_person_id = user.local_user.person_id; + let item_creator = person::id; -// let conn = &mut get_conn(pool).await?; + let conn = &mut get_conn(pool).await?; -// // Notes: since the post_id and comment_id are optional columns, -// // many joins must use an OR condition. -// // For example, the creator must be the person table joined to either: -// // - post.creator_id -// // - comment.creator_id -// let mut query = profile_combined::table -// // The comment -// .left_join(comment::table.on(profile_combined::comment_id.eq(comment::id.nullable()))) -// // The post -// .inner_join( -// post::table.on( -// profile_combined::post_id -// .eq(post::id.nullable()) -// .or(comment::post_id.nullable().eq(profile_combined::post_id)), -// ), -// ) -// // The item creator -// .inner_join( -// person::table.on( -// comment::creator_id -// .eq(person::id) -// .or(post::creator_id.eq(person::id)), -// ), -// ) -// // The community -// .inner_join(community::table.on(post::community_id.eq(community::id))) -// .left_join(actions_alias( -// creator_community_actions, -// item_creator, -// post::community_id, -// )) -// .left_join( -// local_user::table.on( -// item_creator -// .eq(local_user::person_id) -// .and(local_user::admin.eq(true)), -// ), -// ) -// .left_join(actions( -// community_actions::table, -// Some(my_person_id), -// post::community_id, -// )) -// .left_join(actions(post_actions::table, Some(my_person_id), post::id)) -// .left_join(actions( -// person_actions::table, -// Some(my_person_id), -// item_creator, -// )) -// .inner_join(post_aggregates::table.on(post::id.eq(post_aggregates::post_id))) -// .left_join( -// comment_aggregates::table -// .on(profile_combined::comment_id.eq(comment_aggregates::comment_id.nullable())), -// ) -// .left_join(actions( -// comment_actions::table, -// Some(my_person_id), -// comment::id, -// )) -// .left_join(image_details::table.on(post::thumbnail_url.eq(image_details::link.nullable()))) -// // The creator id filter -// .filter(item_creator.eq(self.creator_id)) -// .select(( -// // Post-specific -// post_aggregates::all_columns, -// coalesce( -// post_aggregates::comments.nullable() - post_actions::read_comments_amount.nullable(), -// post_aggregates::comments, -// ), -// post_actions::saved.nullable().is_not_null(), -// post_actions::read.nullable().is_not_null(), -// post_actions::hidden.nullable().is_not_null(), -// post_actions::like_score.nullable(), -// image_details::all_columns.nullable(), -// // Comment-specific -// comment::all_columns.nullable(), -// comment_aggregates::all_columns.nullable(), -// comment_actions::saved.nullable().is_not_null(), -// comment_actions::like_score.nullable(), -// // Shared -// post::all_columns, -// community::all_columns, -// person::all_columns, -// CommunityFollower::select_subscribed_type(), -// local_user::admin.nullable().is_not_null(), -// creator_community_actions -// .field(community_actions::became_moderator) -// .nullable() -// .is_not_null(), -// creator_community_actions -// .field(community_actions::received_ban) -// .nullable() -// .is_not_null(), -// person_actions::blocked.nullable().is_not_null(), -// community_actions::received_ban.nullable().is_not_null(), -// )) -// .into_boxed(); + // Notes: since the post_id and comment_id are optional columns, + // many joins must use an OR condition. + // For example, the creator must be the person table joined to either: + // - post.creator_id + // - comment.creator_id + let query = person_saved_combined::table + // The comment + .left_join(comment::table.on(person_saved_combined::comment_id.eq(comment::id.nullable()))) + // The post + // It gets a bit complicated here, because since both comments and post combined have a post + // attached, you can do an inner join. + .inner_join( + post::table.on( + person_saved_combined::post_id + .eq(post::id.nullable()) + .or(comment::post_id.eq(post::id)), + ), + ) + // The item creator + .inner_join( + person::table.on( + comment::creator_id + .eq(item_creator) + // Need to filter out the post rows where the post_id given is null + // Otherwise you'll get duped post rows + .or( + post::creator_id + .eq(item_creator) + .and(person_saved_combined::post_id.is_not_null()), + ), + ), + ) + // The community + .inner_join(community::table.on(post::community_id.eq(community::id))) + .left_join(actions_alias( + creator_community_actions, + item_creator, + post::community_id, + )) + .left_join( + local_user::table.on( + item_creator + .eq(local_user::person_id) + .and(local_user::admin.eq(true)), + ), + ) + .left_join(actions( + community_actions::table, + Some(my_person_id), + post::community_id, + )) + .left_join(actions(post_actions::table, Some(my_person_id), post::id)) + .left_join(actions( + person_actions::table, + Some(my_person_id), + item_creator, + )) + .inner_join(post_aggregates::table.on(post::id.eq(post_aggregates::post_id))) + .left_join( + comment_aggregates::table + .on(person_saved_combined::comment_id.eq(comment_aggregates::comment_id.nullable())), + ) + .left_join(actions( + comment_actions::table, + Some(my_person_id), + comment::id, + )) + .left_join(image_details::table.on(post::thumbnail_url.eq(image_details::link.nullable()))) + // The person id filter + .filter(person_saved_combined::person_id.eq(my_person_id)) + .select(( + // Post-specific + post_aggregates::all_columns, + coalesce( + post_aggregates::comments.nullable() - post_actions::read_comments_amount.nullable(), + post_aggregates::comments, + ), + post_actions::saved.nullable().is_not_null(), + post_actions::read.nullable().is_not_null(), + post_actions::hidden.nullable().is_not_null(), + post_actions::like_score.nullable(), + image_details::all_columns.nullable(), + // Comment-specific + comment::all_columns.nullable(), + comment_aggregates::all_columns.nullable(), + comment_actions::saved.nullable().is_not_null(), + comment_actions::like_score.nullable(), + // Shared + post::all_columns, + community::all_columns, + person::all_columns, + CommunityFollower::select_subscribed_type(), + local_user::admin.nullable().is_not_null(), + creator_community_actions + .field(community_actions::became_moderator) + .nullable() + .is_not_null(), + creator_community_actions + .field(community_actions::received_ban) + .nullable() + .is_not_null(), + person_actions::blocked.nullable().is_not_null(), + community_actions::received_ban.nullable().is_not_null(), + )) + .into_boxed(); -// let mut query = PaginatedQueryBuilder::new(query); + let mut query = PaginatedQueryBuilder::new(query); -// let page_after = self.page_after.map(|c| c.0); + let page_after = self.page_after.map(|c| c.0); -// if self.page_back.unwrap_or_default() { -// query = query.before(page_after).limit_and_offset_from_end(); -// } else { -// query = query.after(page_after); -// } + if self.page_back.unwrap_or_default() { + query = query.before(page_after).limit_and_offset_from_end(); + } else { + query = query.after(page_after); + } -// // Sorting by published -// query = query -// .then_desc(ReverseTimestampKey(key::published)) -// // Tie breaker -// .then_desc(key::id); + // Sorting by published + query = query + .then_desc(key::published) + // Tie breaker + .then_desc(key::id); -// let res = query.load::(conn).await?; + let res = query.load::(conn).await?; -// // Map the query results to the enum -// let out = res.into_iter().filter_map(|u| u.map_to_enum()).collect(); + // Map the query results to the enum + let out = res.into_iter().filter_map(|u| u.map_to_enum()).collect(); -// Ok(out) -// } -// } + Ok(out) + } +} -// impl InternalToCombinedView for PersonContentViewInternal { -// type CombinedView = PersonContentCombinedView; +#[cfg(test)] +#[expect(clippy::indexing_slicing)] +mod tests { -// fn map_to_enum(&self) -> Option { -// // Use for a short alias -// let v = self.clone(); + use crate::{ + person_saved_combined_view::PersonSavedCombinedQuery, + structs::{LocalUserView, PersonContentCombinedView}, + }; + use lemmy_db_schema::{ + source::{ + comment::{Comment, CommentInsertForm, CommentSaved, CommentSavedForm}, + community::{Community, CommunityInsertForm}, + instance::Instance, + local_user::{LocalUser, LocalUserInsertForm}, + local_user_vote_display_mode::LocalUserVoteDisplayMode, + person::{Person, PersonInsertForm}, + post::{Post, PostInsertForm, PostSaved, PostSavedForm}, + }, + traits::{Crud, Saveable}, + utils::{build_db_pool_for_tests, DbPool}, + }; + use lemmy_utils::error::LemmyResult; + use pretty_assertions::assert_eq; + use serial_test::serial; -// if let (Some(comment), Some(counts)) = (v.comment, v.comment_counts) { -// Some(PersonContentCombinedView::Comment(CommentView { -// comment, -// counts, -// post: v.post, -// community: v.community, -// creator: v.item_creator, -// creator_banned_from_community: v.item_creator_banned_from_community, -// creator_is_moderator: v.item_creator_is_moderator, -// creator_is_admin: v.item_creator_is_admin, -// creator_blocked: v.item_creator_blocked, -// subscribed: v.subscribed, -// saved: v.comment_saved, -// my_vote: v.my_comment_vote, -// banned_from_community: v.banned_from_community, -// })) -// } else { -// Some(PersonContentCombinedView::Post(PostView { -// post: v.post, -// community: v.community, -// unread_comments: v.post_unread_comments, -// counts: v.post_counts, -// creator: v.item_creator, -// creator_banned_from_community: v.item_creator_banned_from_community, -// creator_is_moderator: v.item_creator_is_moderator, -// creator_is_admin: v.item_creator_is_admin, -// creator_blocked: v.item_creator_blocked, -// subscribed: v.subscribed, -// saved: v.post_saved, -// read: v.post_read, -// hidden: v.post_hidden, -// my_vote: v.my_post_vote, -// image_details: v.image_details, -// banned_from_community: v.banned_from_community, -// })) -// } -// } -// } + struct Data { + instance: Instance, + timmy: Person, + timmy_view: LocalUserView, + sara: Person, + timmy_post: Post, + sara_comment: Comment, + sara_comment_2: Comment, + } -// #[cfg(test)] -// #[expect(clippy::indexing_slicing)] -// mod tests { + async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { + let instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; -// use crate::{ -// profile_combined_view::ProfileCombinedQuery, -// report_combined_view::ReportCombinedQuery, -// structs::{ -// CommentReportView, -// LocalUserView, -// PostReportView, -// PersonContentCombinedView, -// ReportCombinedView, -// ReportCombinedViewInternal, -// }, -// }; -// use lemmy_db_schema::{ -// aggregates::structs::{CommentAggregates, PostAggregates}, -// assert_length, -// source::{ -// comment::{Comment, CommentInsertForm, CommentSaved, CommentSavedForm}, -// comment_report::{CommentReport, CommentReportForm}, -// community::{Community, CommunityInsertForm, CommunityModerator, CommunityModeratorForm}, -// instance::Instance, -// local_user::{LocalUser, LocalUserInsertForm}, -// local_user_vote_display_mode::LocalUserVoteDisplayMode, -// person::{Person, PersonInsertForm}, -// post::{Post, PostInsertForm}, -// post_report::{PostReport, PostReportForm}, -// private_message::{PrivateMessage, PrivateMessageInsertForm}, -// private_message_report::{PrivateMessageReport, PrivateMessageReportForm}, -// }, -// traits::{Crud, Joinable, Reportable, Saveable}, -// utils::{build_db_pool_for_tests, DbPool}, -// }; -// use lemmy_utils::error::LemmyResult; -// use pretty_assertions::assert_eq; -// use serial_test::serial; + let timmy_form = PersonInsertForm::test_form(instance.id, "timmy_pcv"); + let timmy = Person::create(pool, &timmy_form).await?; + let timmy_local_user_form = LocalUserInsertForm::test_form(timmy.id); + let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?; + let timmy_view = LocalUserView { + local_user: timmy_local_user, + local_user_vote_display_mode: LocalUserVoteDisplayMode::default(), + person: timmy.clone(), + counts: Default::default(), + }; -// struct Data { -// instance: Instance, -// timmy: Person, -// sara: Person, -// timmy_view: LocalUserView, -// community: Community, -// timmy_post: Post, -// timmy_post_2: Post, -// sara_post: Post, -// timmy_comment: Comment, -// sara_comment: Comment, -// sara_comment_2: Comment, -// } + let sara_form = PersonInsertForm::test_form(instance.id, "sara_pcv"); + let sara = Person::create(pool, &sara_form).await?; -// async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { -// let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; + let community_form = CommunityInsertForm::new( + instance.id, + "test community pcv".to_string(), + "nada".to_owned(), + "pubkey".to_string(), + ); + let community = Community::create(pool, &community_form).await?; -// let timmy_form = PersonInsertForm::test_form(inserted_instance.id, "timmy_pcv"); -// let inserted_timmy = Person::create(pool, &timmy_form).await?; -// let timmy_local_user_form = LocalUserInsertForm::test_form(inserted_timmy.id); -// let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?; -// let timmy_view = LocalUserView { -// local_user: timmy_local_user, -// local_user_vote_display_mode: LocalUserVoteDisplayMode::default(), -// person: inserted_timmy.clone(), -// counts: Default::default(), -// }; + let timmy_post_form = PostInsertForm::new("timmy post prv".into(), timmy.id, community.id); + let timmy_post = Post::create(pool, &timmy_post_form).await?; -// let sara_form = PersonInsertForm::test_form(inserted_instance.id, "sara_pcv"); -// let inserted_sara = Person::create(pool, &sara_form).await?; + let timmy_post_form_2 = PostInsertForm::new("timmy post prv 2".into(), timmy.id, community.id); + let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?; -// let community_form = CommunityInsertForm::new( -// inserted_instance.id, -// "test community pcv".to_string(), -// "nada".to_owned(), -// "pubkey".to_string(), -// ); -// let inserted_community = Community::create(pool, &community_form).await?; + let sara_post_form = PostInsertForm::new("sara post prv".into(), sara.id, community.id); + let _sara_post = Post::create(pool, &sara_post_form).await?; -// let timmy_post_form = PostInsertForm::new( -// "timmy post prv".into(), -// inserted_timmy.id, -// inserted_community.id, -// ); -// let timmy_post = Post::create(pool, &timmy_post_form).await?; + let timmy_comment_form = + CommentInsertForm::new(timmy.id, timmy_post.id, "timmy comment prv".into()); + let _timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; -// let timmy_post_form_2 = PostInsertForm::new( -// "timmy post prv 2".into(), -// inserted_timmy.id, -// inserted_community.id, -// ); -// let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?; + let sara_comment_form = + CommentInsertForm::new(sara.id, timmy_post.id, "sara comment prv".into()); + let sara_comment = Comment::create(pool, &sara_comment_form, None).await?; -// let sara_post_form = PostInsertForm::new( -// "sara post prv".into(), -// inserted_sara.id, -// inserted_community.id, -// ); -// let sara_post = Post::create(pool, &sara_post_form).await?; + let sara_comment_form_2 = + CommentInsertForm::new(sara.id, timmy_post_2.id, "sara comment prv 2".into()); + let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?; -// let timmy_comment_form = -// CommentInsertForm::new(inserted_timmy.id, timmy_post.id, "timmy comment prv".into()); -// let timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; + Ok(Data { + instance, + timmy, + timmy_view, + sara, + timmy_post, + sara_comment, + sara_comment_2, + }) + } -// let sara_comment_form = -// CommentInsertForm::new(inserted_sara.id, timmy_post.id, "sara comment prv".into()); -// let sara_comment = Comment::create(pool, &sara_comment_form, None).await?; + async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { + Instance::delete(pool, data.instance.id).await?; -// let sara_comment_form_2 = CommentInsertForm::new( -// inserted_sara.id, -// timmy_post_2.id, -// "sara comment prv 2".into(), -// ); -// let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?; + Ok(()) + } -// Ok(Data { -// instance: inserted_instance, -// timmy: inserted_timmy, -// sara: inserted_sara, -// timmy_view, -// community: inserted_community, -// timmy_post, -// timmy_post_2, -// sara_post, -// timmy_comment, -// sara_comment, -// sara_comment_2, -// }) -// } + #[tokio::test] + #[serial] + async fn test_combined() -> LemmyResult<()> { + let pool = &build_db_pool_for_tests(); + let pool = &mut pool.into(); + let data = init_data(pool).await?; -// async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { -// Instance::delete(pool, data.instance.id).await?; + // Do a batch read of timmy saved + let timmy_saved = PersonSavedCombinedQuery::default() + .list(pool, &data.timmy_view) + .await?; + assert_eq!(0, timmy_saved.len()); -// Ok(()) -// } + // Save a few things + let save_sara_comment_2 = + CommentSavedForm::new(data.sara_comment_2.id, data.timmy_view.person.id); + CommentSaved::save(pool, &save_sara_comment_2).await?; -// #[tokio::test] -// #[serial] -// async fn test_combined() -> LemmyResult<()> { -// let pool = &build_db_pool_for_tests(); -// let pool = &mut pool.into(); -// let data = init_data(pool).await?; + let save_sara_comment = CommentSavedForm::new(data.sara_comment.id, data.timmy_view.person.id); + CommentSaved::save(pool, &save_sara_comment).await?; -// // Do a batch read of timmy -// let timmy_content = ProfileCombinedQuery::default().list(pool, &None).await?; -// assert_eq!(3, timmy_content.len()); + let post_save_form = PostSavedForm::new(data.timmy_post.id, data.timmy.id); + PostSaved::save(pool, &post_save_form).await?; -// // Make sure the report types are correct -// if let PersonContentCombinedView::Comment(v) = &timmy_content[0] { -// assert_eq!(data.timmy_comment.id, v.comment.id); -// assert_eq!(data.timmy.id, v.creator.id); -// } else { -// panic!("wrong type"); -// } -// if let PersonContentCombinedView::Post(v) = &timmy_content[1] { -// assert_eq!(data.timmy_post_2.id, v.post.id); -// assert_eq!(data.timmy.id, v.post.creator_id); -// } else { -// panic!("wrong type"); -// } -// if let PersonContentCombinedView::Post(v) = &timmy_content[2] { -// assert_eq!(data.timmy_post.id, v.post.id); -// assert_eq!(data.timmy.id, v.post.creator_id); -// } else { -// panic!("wrong type"); -// } + let timmy_saved = PersonSavedCombinedQuery::default() + .list(pool, &data.timmy_view) + .await?; + assert_eq!(3, timmy_saved.len()); -// // Do a batch read of sara -// let sara_content = ProfileCombinedQuery::default().list(pool, &None).await?; -// assert_eq!(3, sara_content.len()); + // Make sure the types and order are correct + if let PersonContentCombinedView::Post(v) = &timmy_saved[0] { + assert_eq!(data.timmy_post.id, v.post.id); + assert_eq!(data.timmy.id, v.post.creator_id); + } else { + panic!("wrong type"); + } + if let PersonContentCombinedView::Comment(v) = &timmy_saved[1] { + assert_eq!(data.sara_comment.id, v.comment.id); + assert_eq!(data.sara.id, v.comment.creator_id); + } else { + panic!("wrong type"); + } + if let PersonContentCombinedView::Comment(v) = &timmy_saved[2] { + assert_eq!(data.sara_comment_2.id, v.comment.id); + assert_eq!(data.sara.id, v.comment.creator_id); + } else { + panic!("wrong type"); + } -// // Make sure the report types are correct -// if let PersonContentCombinedView::Comment(v) = &sara_content[0] { -// assert_eq!(data.sara_comment_2.id, v.comment.id); -// assert_eq!(data.sara.id, v.creator.id); -// // This one was to timmy_post_2 -// assert_eq!(data.timmy_post_2.id, v.post.id); -// assert_eq!(data.timmy.id, v.post.creator_id); -// } else { -// panic!("wrong type"); -// } -// if let PersonContentCombinedView::Comment(v) = &sara_content[1] { -// assert_eq!(data.sara_comment.id, v.comment.id); -// assert_eq!(data.sara.id, v.creator.id); -// assert_eq!(data.timmy_post.id, v.post.id); -// assert_eq!(data.timmy.id, v.post.creator_id); -// } else { -// panic!("wrong type"); -// } -// if let PersonContentCombinedView::Post(v) = &sara_content[2] { -// assert_eq!(data.timmy_post.id, v.post.id); -// assert_eq!(data.timmy.id, v.post.creator_id); -// } else { -// panic!("wrong type"); -// } + // Try unsaving 2 things + CommentSaved::unsave(pool, &save_sara_comment).await?; + PostSaved::unsave(pool, &post_save_form).await?; -// // Timmy saves sara's comment, and his 2nd post -// let save_comment_0_form = CommentSavedForm { -// person_id: data.timmy.id, -// comment_id: data.sara_comment.id, -// }; -// CommentSaved::save(pool, &save_comment_0_form).await?; + let timmy_saved = PersonSavedCombinedQuery::default() + .list(pool, &data.timmy_view) + .await?; + assert_eq!(1, timmy_saved.len()); -// // Timmy saves sara's comment, and his 2nd post -// let save_comment_0_form = CommentSavedForm { -// person_id: data.timmy.id, -// comment_id: data.sara_comment.id, -// }; -// CommentSaved::save(pool, &save_comment_0_form).await?; + if let PersonContentCombinedView::Comment(v) = &timmy_saved[0] { + assert_eq!(data.sara_comment_2.id, v.comment.id); + assert_eq!(data.sara.id, v.comment.creator_id); + } else { + panic!("wrong type"); + } -// // Do a saved_only query -// let timmy_content_saved_only = ProfileCombinedQuery {}.list(pool, &None).await?; + cleanup(data, pool).await?; -// cleanup(data, pool).await?; - -// Ok(()) -// } -// } -// #[tokio::test] -// #[serial] -// async fn test_saved_order() -> LemmyResult<()> { -// let pool = &build_db_pool_for_tests(); -// let pool = &mut pool.into(); -// let data = init_data(pool).await?; - -// // Save two comments -// let save_comment_0_form = CommentSavedForm { -// person_id: data.timmy_local_user_view.person.id, -// comment_id: data.inserted_comment_0.id, -// }; -// CommentSaved::save(pool, &save_comment_0_form).await?; - -// let save_comment_2_form = CommentSavedForm { -// person_id: data.timmy_local_user_view.person.id, -// comment_id: data.inserted_comment_2.id, -// }; -// CommentSaved::save(pool, &save_comment_2_form).await?; - -// // Fetch the saved comments -// let comments = CommentQuery { -// local_user: Some(&data.timmy_local_user_view.local_user), -// saved_only: Some(true), -// ..Default::default() -// } -// .list(&data.site, pool) -// .await?; - -// // There should only be two comments -// assert_eq!(2, comments.len()); - -// // The first comment, should be the last one saved (descending order) -// assert_eq!(comments[0].comment.id, data.inserted_comment_2.id); - -// // The second comment, should be the first one saved -// assert_eq!(comments[1].comment.id, data.inserted_comment_0.id); - -// cleanup(data, pool).await -// } -// #[tokio::test] -// #[serial] -// async fn post_listing_saved_only() -> LemmyResult<()> { -// let pool = &build_db_pool()?; -// let pool = &mut pool.into(); -// let data = init_data(pool).await?; - -// // Save only the bot post -// // The saved_only should only show the bot post -// let post_save_form = -// PostSavedForm::new(data.inserted_bot_post.id, data.local_user_view.person.id); -// PostSaved::save(pool, &post_save_form).await?; - -// // Read the saved only -// let read_saved_post_listing = PostQuery { -// community_id: Some(data.inserted_community.id), -// saved_only: Some(true), -// ..data.default_post_query() -// } -// .list(&data.site, pool) -// .await?; - -// // This should only include the bot post, not the one you created -// assert_eq!(vec![POST_BY_BOT], names(&read_saved_post_listing)); - -// cleanup(data, pool).await -// } + Ok(()) + } +} diff --git a/migrations/2024-12-05-233704_add_person_content_combined_table/down.sql b/migrations/2024-12-05-233704_add_person_content_combined_table/down.sql index a8db9ec61..0733315a7 100644 --- a/migrations/2024-12-05-233704_add_person_content_combined_table/down.sql +++ b/migrations/2024-12-05-233704_add_person_content_combined_table/down.sql @@ -1,3 +1,4 @@ DROP TABLE person_content_combined; + DROP TABLE person_saved_combined; diff --git a/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql b/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql index 40ced8238..a53f52925 100644 --- a/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql +++ b/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql @@ -33,6 +33,7 @@ FROM CREATE TABLE person_saved_combined ( id serial PRIMARY KEY, published timestamptz NOT NULL, + person_id int NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, post_id int UNIQUE REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, comment_id int UNIQUE REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, -- Make sure only one of the columns is not null @@ -43,23 +44,26 @@ CREATE INDEX idx_person_saved_combined_published ON person_saved_combined (publi CREATE INDEX idx_person_saved_combined_published_asc ON person_saved_combined (reverse_timestamp_sort (published) DESC, id DESC); +CREATE INDEX idx_person_saved_combined ON person_saved_combined (person_id); + -- Updating the history -INSERT INTO person_saved_combined (published, post_id) +INSERT INTO person_saved_combined (published, person_id, post_id) SELECT saved, + person_id, post_id FROM post_actions -WHERE +WHERE saved IS NOT NULL; -INSERT INTO person_saved_combined (published, comment_id) +INSERT INTO person_saved_combined (published, person_id, comment_id) SELECT saved, + person_id, comment_id FROM comment_actions -WHERE +WHERE saved IS NOT NULL; - diff --git a/src/api_routes_http.rs b/src/api_routes_http.rs index 472728ded..74764ac7d 100644 --- a/src/api_routes_http.rs +++ b/src/api_routes_http.rs @@ -31,6 +31,7 @@ use lemmy_api::{ list_banned::list_banned_users, list_logins::list_logins, list_media::list_media, + list_saved::list_person_saved, login::login, logout::logout, notifications::{ @@ -341,7 +342,7 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { .route("", get().to(read_person)) .route("/content", get().to(list_person_content)) // TODO move this to /account/saved after http routes - // .route("/saved", get().to(read_person_saved)) + .route("/saved", get().to(list_person_saved)) .route("/mention", get().to(list_mentions)) .route( "/mention/mark_as_read",