Adding ability to make a note for a given person. (#5765)

* Adding ability to make a note for a given person.

- This comes back with PersonActions.note, already on post and comment
  views.
- Also adds person_actions to PersonView, so that the read person API
  action can return the note.
- Fixes #2353

* Adding unit test for person_view read.

* Fixing bindings check

* Addressing PR comments

* Moving API action to person/note
This commit is contained in:
Dessalines 2025-06-12 10:29:03 -04:00 committed by GitHub
parent ea9b19bea8
commit d9df8335ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 279 additions and 49 deletions

View File

@ -30,6 +30,7 @@ pub async fn ban_from_community(
local_user_view: LocalUserView,
) -> LemmyResult<Json<BanFromCommunityResponse>> {
let banned_person_id = data.person_id;
let my_person_id = local_user_view.person.id;
let expires_at = check_expire_time(data.expires_at)?;
let local_instance_id = local_user_view.person.instance_id;
let community = Community::read(&mut context.pool(), data.community_id).await?;
@ -40,7 +41,7 @@ pub async fn ban_from_community(
LocalUser::is_higher_mod_or_admin_check(
&mut context.pool(),
data.community_id,
local_user_view.person.id,
my_person_id,
vec![data.person_id],
)
.await?;
@ -76,7 +77,7 @@ pub async fn ban_from_community(
let remove_data = tx_data.ban;
remove_or_restore_user_data_in_community(
tx_data.community_id,
local_user_view.person.id,
my_person_id,
banned_person_id,
remove_data,
&tx_data.reason,
@ -87,7 +88,7 @@ pub async fn ban_from_community(
// Mod tables
let form = ModBanFromCommunityForm {
mod_person_id: local_user_view.person.id,
mod_person_id: my_person_id,
other_person_id: tx_data.person_id,
community_id: tx_data.community_id,
reason: tx_data.reason.clone(),
@ -106,6 +107,7 @@ pub async fn ban_from_community(
let person_view = PersonView::read(
&mut context.pool(),
data.person_id,
Some(my_person_id),
local_instance_id,
false,
)

View File

@ -19,17 +19,15 @@ pub async fn add_admin(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<AddAdminResponse>> {
let my_person_id = local_user_view.person.id;
// Make sure user is an admin
is_admin(&local_user_view)?;
// If its an admin removal, also check that you're a higher admin
if !data.added {
LocalUser::is_higher_admin_check(
&mut context.pool(),
local_user_view.person.id,
vec![data.person_id],
)
.await?;
LocalUser::is_higher_admin_check(&mut context.pool(), my_person_id, vec![data.person_id])
.await?;
}
// Make sure that the person_id added is local
@ -47,7 +45,7 @@ pub async fn add_admin(
// Mod tables
let form = ModAddForm {
mod_person_id: local_user_view.person.id,
mod_person_id: my_person_id,
other_person_id: added_local_user.person.id,
removed: Some(!data.added),
};
@ -58,7 +56,11 @@ pub async fn add_admin(
admins_only: Some(true),
..Default::default()
}
.list(local_user_view.person.instance_id, &mut context.pool())
.list(
Some(my_person_id),
local_user_view.person.instance_id,
&mut context.pool(),
)
.await?;
Ok(Json(AddAdminResponse { admins }))

View File

@ -26,17 +26,13 @@ pub async fn ban_from_site(
local_user_view: LocalUserView,
) -> LemmyResult<Json<BanPersonResponse>> {
let local_instance_id = local_user_view.person.instance_id;
let my_person_id = local_user_view.person.id;
// Make sure user is an admin
is_admin(&local_user_view)?;
// Also make sure you're a higher admin than the target
LocalUser::is_higher_admin_check(
&mut context.pool(),
local_user_view.person.id,
vec![data.person_id],
)
.await?;
LocalUser::is_higher_admin_check(&mut context.pool(), my_person_id, vec![data.person_id]).await?;
if let Some(reason) = &data.reason {
is_valid_body_field(reason, false)?;
@ -59,7 +55,7 @@ pub async fn ban_from_site(
if data.remove_or_restore_data.unwrap_or(false) {
let removed = data.ban;
remove_or_restore_user_data(
local_user_view.person.id,
my_person_id,
data.person_id,
removed,
&data.reason,
@ -70,7 +66,7 @@ pub async fn ban_from_site(
// Mod tables
let form = ModBanForm {
mod_person_id: local_user_view.person.id,
mod_person_id: my_person_id,
other_person_id: data.person_id,
reason: data.reason.clone(),
banned: Some(data.ban),
@ -83,6 +79,7 @@ pub async fn ban_from_site(
let person_view = PersonView::read(
&mut context.pool(),
data.person_id,
Some(my_person_id),
local_instance_id,
false,
)

View File

@ -17,15 +17,15 @@ pub async fn user_block_person(
local_user_view: LocalUserView,
) -> LemmyResult<Json<BlockPersonResponse>> {
let target_id = data.person_id;
let person_id = local_user_view.person.id;
let my_person_id = local_user_view.person.id;
let local_instance_id = local_user_view.person.instance_id;
// Don't let a person block themselves
if target_id == person_id {
if target_id == my_person_id {
Err(LemmyErrorType::CantBlockYourself)?
}
let person_block_form = PersonBlockForm::new(person_id, target_id);
let person_block_form = PersonBlockForm::new(my_person_id, target_id);
let target_user = LocalUserView::read_person(&mut context.pool(), target_id)
.await
@ -41,8 +41,14 @@ pub async fn user_block_person(
PersonActions::unblock(&mut context.pool(), &person_block_form).await?;
}
let person_view =
PersonView::read(&mut context.pool(), target_id, local_instance_id, false).await?;
let person_view = PersonView::read(
&mut context.pool(),
target_id,
Some(my_person_id),
local_instance_id,
false,
)
.await?;
Ok(Json(BlockPersonResponse {
person_view,
blocked: data.block,

View File

@ -14,6 +14,7 @@ pub mod list_read;
pub mod list_saved;
pub mod login;
pub mod logout;
pub mod note_person;
pub mod notifications;
pub mod report_count;
pub mod resend_verification_email;

View File

@ -0,0 +1,44 @@
use actix_web::web::{Data, Json};
use lemmy_api_utils::{
context::LemmyContext,
utils::{get_url_blocklist, process_markdown, slur_regex},
};
use lemmy_db_schema::source::person::{PersonActions, PersonNoteForm};
use lemmy_db_views_api_misc::{NotePerson, SuccessResponse};
use lemmy_db_views_local_user::LocalUserView;
use lemmy_utils::{
error::{LemmyErrorType, LemmyResult},
utils::{slurs::check_slurs, validation::is_valid_body_field},
};
pub async fn user_note_person(
data: Json<NotePerson>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let target_id = data.person_id;
let person_id = local_user_view.person.id;
let slur_regex = slur_regex(&context).await?;
let url_blocklist = get_url_blocklist(&context).await?;
// Don't let a person note themselves
if target_id == person_id {
Err(LemmyErrorType::CantNoteYourself)?
}
// If the note is empty, delete it
if data.note.is_empty() {
PersonActions::delete_note(&mut context.pool(), person_id, target_id).await?;
} else {
check_slurs(&data.note, &slur_regex)?;
is_valid_body_field(&data.note, false)?;
let note = process_markdown(&data.note, &slur_regex, &url_blocklist, &context).await?;
let note_form = PersonNoteForm::new(person_id, target_id, note);
PersonActions::note(&mut context.pool(), &note_form).await?;
}
Ok(Json(SuccessResponse::default()))
}

View File

@ -24,6 +24,8 @@ pub async fn leave_admin(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<GetSiteResponse>> {
let my_person_id = local_user_view.person.id;
is_admin(&local_user_view)?;
// Make sure there isn't just one admin (so if one leaves, there will still be one left)
@ -31,7 +33,11 @@ pub async fn leave_admin(
admins_only: Some(true),
..Default::default()
}
.list(local_user_view.person.instance_id, &mut context.pool())
.list(
Some(my_person_id),
local_user_view.person.instance_id,
&mut context.pool(),
)
.await?;
if admins.len() == 1 {
Err(LemmyErrorType::CannotLeaveAdmin)?
@ -51,10 +57,9 @@ pub async fn leave_admin(
.await?;
// Mod tables
let person_id = local_user_view.person.id;
let form = ModAddForm {
mod_person_id: person_id,
other_person_id: person_id,
mod_person_id: my_person_id,
other_person_id: my_person_id,
removed: Some(true),
};
@ -66,7 +71,11 @@ pub async fn leave_admin(
admins_only: Some(true),
..Default::default()
}
.list(site_view.instance.id, &mut context.pool())
.list(
Some(my_person_id),
site_view.instance.id,
&mut context.pool(),
)
.await?;
let all_languages = Language::read_all(&mut context.pool()).await?;

View File

@ -41,7 +41,7 @@ async fn read_site(context: &LemmyContext) -> LemmyResult<GetSiteResponse> {
admins_only: Some(true),
..Default::default()
}
.list(site_view.instance.id, &mut context.pool())
.list(None, site_view.instance.id, &mut context.pool())
.await?;
let all_languages = Language::read_all(&mut context.pool()).await?;
let discussion_languages = SiteLanguage::read_local_raw(&mut context.pool()).await?;

View File

@ -76,7 +76,7 @@ pub async fn check_is_mod_or_admin(
let is_mod =
CommunityModeratorView::check_is_community_moderator(pool, community_id, person_id).await;
if is_mod.is_ok()
|| PersonView::read(pool, person_id, local_instance_id, false)
|| PersonView::read(pool, person_id, None, local_instance_id, false)
.await
.is_ok_and(|t| t.is_admin)
{
@ -94,7 +94,7 @@ pub(crate) async fn check_is_mod_of_any_or_admin(
) -> LemmyResult<()> {
let is_mod_of_any = CommunityModeratorView::is_community_moderator_of_any(pool, person_id).await;
if is_mod_of_any.is_ok()
|| PersonView::read(pool, person_id, local_instance_id, false)
|| PersonView::read(pool, person_id, None, local_instance_id, false)
.await
.is_ok_and(|t| t.is_admin)
{

View File

@ -22,6 +22,7 @@ pub async fn read_person(
let site_view = SiteView::read_local(&mut context.pool()).await?;
let local_site = site_view.local_site;
let local_instance_id = site_view.site.instance_id;
let my_person_id = local_user_view.as_ref().map(|l| l.person.id);
check_private_instance(&local_user_view, &local_site)?;
@ -43,6 +44,7 @@ pub async fn read_person(
let person_view = PersonView::read(
&mut context.pool(),
person_details_id,
my_person_id,
local_instance_id,
is_admin,
)

View File

@ -48,6 +48,7 @@ pub(super) async fn resolve_object_internal(
}
.with_lemmy_type(LemmyErrorType::NotFound)?;
let my_person_id = local_user_view.as_ref().map(|l| l.person.id);
let local_user = local_user_view.as_ref().map(|l| l.local_user.clone());
let is_admin = local_user.as_ref().map(|l| l.admin).unwrap_or_default();
let pool = &mut context.pool();
@ -60,7 +61,9 @@ pub(super) async fn resolve_object_internal(
Left(Right(c)) => {
Comment(CommentView::read(pool, c.id, local_user.as_ref(), local_instance_id).await?)
}
Right(Left(u)) => Person(PersonView::read(pool, u.id, local_instance_id, is_admin).await?),
Right(Left(u)) => {
Person(PersonView::read(pool, u.id, my_person_id, local_instance_id, is_admin).await?)
}
Right(Right(c)) => {
Community(CommunityView::read(pool, c.id, local_user.as_ref(), is_admin).await?)
}

View File

@ -7,6 +7,7 @@ use crate::{
PersonBlockForm,
PersonFollowerForm,
PersonInsertForm,
PersonNoteForm,
PersonUpdateForm,
},
traits::{ApubActor, Blockable, Crud, Followable},
@ -341,6 +342,33 @@ impl PersonActions {
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}
pub async fn note(pool: &mut DbPool<'_>, form: &PersonNoteForm) -> LemmyResult<Self> {
let conn = &mut get_conn(pool).await?;
insert_into(person_actions::table)
.values(form)
.on_conflict((person_actions::person_id, person_actions::target_id))
.do_update()
.set(form)
.returning(Self::as_select())
.get_result::<Self>(conn)
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}
pub async fn delete_note(
pool: &mut DbPool<'_>,
person_id: PersonId,
target_id: PersonId,
) -> LemmyResult<uplete::Count> {
let conn = &mut get_conn(pool).await?;
uplete::new(person_actions::table.find((person_id, target_id)))
.set_null(person_actions::note)
.set_null(person_actions::noted_at)
.get_result(conn)
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}
}
#[cfg(test)]

View File

@ -151,6 +151,12 @@ pub struct PersonActions {
#[cfg_attr(feature = "full", ts(optional))]
/// When the person was blocked.
pub blocked_at: Option<DateTime<Utc>>,
#[cfg_attr(feature = "full", ts(optional))]
/// When the person was noted.
pub noted_at: Option<DateTime<Utc>>,
#[cfg_attr(feature = "full", ts(optional))]
/// A note about the person.
pub note: Option<String>,
}
#[derive(Clone, derive_new::new)]
@ -168,9 +174,19 @@ pub struct PersonFollowerForm {
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", diesel(table_name = person_actions))]
pub struct PersonBlockForm {
// This order is switched so blocks can work the same.
pub person_id: PersonId,
pub target_id: PersonId,
#[new(value = "Utc::now()")]
pub blocked_at: DateTime<Utc>,
}
#[derive(derive_new::new)]
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", diesel(table_name = person_actions))]
pub struct PersonNoteForm {
pub person_id: PersonId,
pub target_id: PersonId,
pub note: String,
#[new(value = "Utc::now()")]
pub noted_at: DateTime<Utc>,
}

View File

@ -785,6 +785,8 @@ diesel::table! {
followed_at -> Nullable<Timestamptz>,
follow_pending -> Nullable<Bool>,
blocked_at -> Nullable<Timestamptz>,
noted_at -> Nullable<Timestamptz>,
note -> Nullable<Text>,
}
}

View File

@ -1,5 +1,5 @@
use lemmy_db_schema::{
newtypes::{InstanceId, LanguageId, PaginationCursor},
newtypes::{InstanceId, LanguageId, PaginationCursor, PersonId},
sensitive::SensitiveString,
source::{community::Community, instance::Instance, login_token::LoginToken, person::Person},
};
@ -177,6 +177,17 @@ pub struct LoginResponse {
pub verify_email_sent: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "full", derive(TS))]
#[cfg_attr(feature = "full", ts(export))]
/// Make a note for a person.
///
/// An empty string deletes the note.
pub struct NotePerson {
pub person_id: PersonId,
pub note: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "full", derive(TS))]
#[cfg_attr(feature = "full", ts(export))]

View File

@ -10,7 +10,11 @@ use lemmy_db_schema::{
get_conn,
limit_fetch,
paginate,
queries::{creator_home_instance_actions_join, creator_local_instance_actions_join},
queries::{
creator_home_instance_actions_join,
creator_local_instance_actions_join,
my_person_actions_join,
},
DbPool,
},
};
@ -35,12 +39,14 @@ impl PaginationCursorBuilder for PersonView {
impl PersonView {
#[diesel::dsl::auto_type(no_type_alias)]
fn joins(local_instance_id: InstanceId) -> _ {
fn joins(my_person_id: Option<PersonId>, local_instance_id: InstanceId) -> _ {
let creator_local_instance_actions_join: creator_local_instance_actions_join =
creator_local_instance_actions_join(local_instance_id);
let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id);
person::table
.left_join(local_user::table)
.left_join(my_person_actions_join)
.left_join(creator_home_instance_actions_join())
.left_join(creator_local_instance_actions_join)
}
@ -48,11 +54,12 @@ impl PersonView {
pub async fn read(
pool: &mut DbPool<'_>,
person_id: PersonId,
my_person_id: Option<PersonId>,
local_instance_id: InstanceId,
is_admin: bool,
) -> LemmyResult<Self> {
let conn = &mut get_conn(pool).await?;
let mut query = Self::joins(local_instance_id)
let mut query = Self::joins(my_person_id, local_instance_id)
.filter(person::id.eq(person_id))
.select(Self::as_select())
.into_boxed();
@ -79,11 +86,12 @@ pub struct PersonQuery {
impl PersonQuery {
pub async fn list(
self,
my_person_id: Option<PersonId>,
local_instance_id: InstanceId,
pool: &mut DbPool<'_>,
) -> LemmyResult<Vec<PersonView>> {
let conn = &mut get_conn(pool).await?;
let mut query = PersonView::joins(local_instance_id)
let mut query = PersonView::joins(my_person_id, local_instance_id)
.filter(person::deleted.eq(false))
.select(PersonView::as_select())
.into_boxed();
@ -124,7 +132,7 @@ mod tests {
source::{
instance::Instance,
local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm},
person::{Person, PersonInsertForm, PersonUpdateForm},
person::{Person, PersonActions, PersonInsertForm, PersonNoteForm, PersonUpdateForm},
},
traits::Crud,
utils::build_db_pool_for_tests,
@ -194,11 +202,11 @@ mod tests {
)
.await?;
let read = PersonView::read(pool, data.alice.id, data.alice.instance_id, false).await;
let read = PersonView::read(pool, data.alice.id, None, data.alice.instance_id, false).await;
assert!(read.is_err());
// only admin can view deleted users
let read = PersonView::read(pool, data.alice.id, data.alice.instance_id, true).await;
let read = PersonView::read(pool, data.alice.id, None, data.alice.instance_id, true).await;
assert!(read.is_ok());
cleanup(data, pool).await
@ -225,21 +233,50 @@ mod tests {
admins_only: Some(true),
..Default::default()
}
.list(data.alice.instance_id, pool)
.list(None, data.alice.instance_id, pool)
.await?;
assert_length!(1, list);
assert_eq!(list[0].person.id, data.alice.id);
let is_admin = PersonView::read(pool, data.alice.id, data.alice.instance_id, false)
let is_admin = PersonView::read(pool, data.alice.id, None, data.alice.instance_id, false)
.await?
.is_admin;
assert!(is_admin);
let is_admin = PersonView::read(pool, data.bob.id, data.alice.instance_id, false)
let is_admin = PersonView::read(pool, data.bob.id, None, data.alice.instance_id, false)
.await?
.is_admin;
assert!(!is_admin);
cleanup(data, pool).await
}
#[tokio::test]
#[serial]
async fn note() -> LemmyResult<()> {
let pool = &build_db_pool_for_tests();
let pool = &mut pool.into();
let data = init_data(pool).await?;
let note_str = "Bob hates cats.";
let note_form = PersonNoteForm::new(data.alice.id, data.bob.id, note_str.to_string());
let inserted_note = PersonActions::note(pool, &note_form).await?;
assert_eq!(Some(note_str.to_string()), inserted_note.note);
let read = PersonView::read(
pool,
data.bob.id,
Some(data.alice.id),
data.alice.instance_id,
false,
)
.await?;
assert!(read
.person_actions
.is_some_and(|t| t.note == Some(note_str.to_string()) && t.noted_at.is_some()));
cleanup(data, pool).await
}
}

View File

@ -1,4 +1,7 @@
use lemmy_db_schema::source::{instance::InstanceActions, person::Person};
use lemmy_db_schema::source::{
instance::InstanceActions,
person::{Person, PersonActions},
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "full")]
use {
@ -38,6 +41,9 @@ pub struct PersonView {
)
)]
pub is_admin: bool,
#[cfg_attr(feature = "full", diesel(embed))]
#[cfg_attr(feature = "full", ts(optional))]
pub person_actions: Option<PersonActions>,
#[cfg_attr(feature = "full", diesel(
select_expression_type = Nullable<CreatorHomeInstanceActionsAllColumnsTuple>,
select_expression = creator_home_instance_actions_select()))]

View File

@ -547,7 +547,7 @@ mod tests {
keyword_block::LocalUserKeywordBlock,
language::Language,
local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm},
person::{Person, PersonActions, PersonBlockForm, PersonInsertForm},
person::{Person, PersonActions, PersonBlockForm, PersonInsertForm, PersonNoteForm},
post::{
Post,
PostActions,
@ -1004,6 +1004,58 @@ mod tests {
Ok(())
}
#[test_context(Data)]
#[tokio::test]
#[serial]
async fn person_note(data: &mut Data) -> LemmyResult<()> {
let pool = &data.pool();
let pool = &mut pool.into();
let note_str = "Tegan loves cats.";
let note_form = PersonNoteForm::new(
data.john_local_user_view.person.id,
data.tegan_local_user_view.person.id,
note_str.to_string(),
);
let inserted_note = PersonActions::note(pool, &note_form).await?;
assert_eq!(Some(note_str.to_string()), inserted_note.note);
let post_listing = PostView::read(
pool,
data.post.id,
Some(&data.john_local_user_view.local_user),
data.instance.id,
false,
)
.await?;
assert!(post_listing
.person_actions
.is_some_and(|t| t.note == Some(note_str.to_string()) && t.noted_at.is_some()));
let note_removed = PersonActions::delete_note(
pool,
data.john_local_user_view.person.id,
data.tegan_local_user_view.person.id,
)
.await?;
let post_listing = PostView::read(
pool,
data.post.id,
Some(&data.john_local_user_view.local_user),
data.instance.id,
false,
)
.await?;
assert_eq!(uplete::Count::only_deleted(1), note_removed);
assert!(post_listing.person_actions.is_none());
Ok(())
}
#[test_context(Data)]
#[tokio::test]
#[serial]

View File

@ -453,6 +453,7 @@ impl InternalToCombinedView for SearchCombinedViewInternal {
Some(SearchCombinedView::Person(PersonView {
person,
is_admin: v.item_creator_is_admin,
person_actions: v.person_actions,
home_instance_actions: v.creator_home_instance_actions,
local_instance_actions: v.creator_local_instance_actions,
creator_banned: v.creator_banned,

View File

@ -18,6 +18,7 @@ pub enum LemmyErrorType {
NotAModerator,
NotAnAdmin,
CantBlockYourself,
CantNoteYourself,
CantBlockAdmin,
CouldntUpdateUser,
PasswordsDoNotMatch,

View File

@ -106,7 +106,7 @@ pub fn is_valid_post_title(title: &str) -> LemmyResult<()> {
}
}
/// This could be post bodies, comments, or any description field
/// This could be post bodies, comments, notes, or any description field
pub fn is_valid_body_field(body: &str, post: bool) -> LemmyResult<()> {
if post {
max_length_check(body, POST_BODY_MAX_LENGTH, LemmyErrorType::InvalidBodyField)?;

View File

@ -0,0 +1,4 @@
ALTER TABLE person_actions
DROP COLUMN noted_at,
DROP COLUMN note;

View File

@ -0,0 +1,4 @@
ALTER TABLE person_actions
ADD COLUMN noted_at timestamptz,
ADD COLUMN note text;

View File

@ -37,6 +37,7 @@ use lemmy_api::{
list_saved::list_person_saved,
login::login,
logout::logout,
note_person::user_note_person,
notifications::{
list_inbox::list_inbox,
mark_all_read::mark_all_notifications_read,
@ -386,7 +387,8 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
.service(
scope("/person")
.route("", get().to(read_person))
.route("/content", get().to(list_person_content)),
.route("/content", get().to(list_person_content))
.route("/note", post().to(user_note_person)),
)
// Admin Actions
.service(