Repository: LemmyNet/lemmy (language: rust)

Issue:
Rework user reports to show to mods first, then admins
User reports are a great addition, but they could be even better with proper compartmentalisation.

The problem currently is that once a user makes a report, it's sent to the mods of a community and the admins of the instance. Then whoever gets to it first can resolve it for everyone.

This can lead to a few problems:

1. Admin overreach. Moderators expect to resolve their own reports but see that admins resolve them first.
2. On the other hand, some reports should probably be resolved by admins (like banning someone site-wide).

As an admin on an instance, there's many times where I let a report sit because I feel it's not my responsibility to take care of it but the mods'. Then it sits there with that "1" notification until they get to it, if they ever. Sometimes the mods of a community are not active anymore and so an admin needs to step in. 

There's many things we can do with reports so that both mods and admins can work better on them instead of being in a "tug of war".

But what I would propose first is to compartmentalize reports properly:

1. When a user makes a report, it goes to the mod team of that community by default, this would be a "mod report". After making their report, they get an option to bump the report to the admins if they feel it is warranted.
2. A report that is sent to mods only can be bumped up by the mods to the admins, and they can add their own comment on it. A report escalated to admins would be an "admin report".
3. If after X days (set by the instance admins?) a mod report has not been marked as solved, it will automatically be bumped up to the admins. This solves the problem of inactive mods not responding or not caring about reports.
4. As mod and admin reports are **entirely independent** from each other (the big takeaway), admins can resolve reports without taking an action if they feel it's not their responsibility and mods will still see the report in their queue. Likewise mods can resolve their report if they feel it's an admin issue without affecting the admin queue.

I have other ideas, including listing modlog actions (mod&admin) against a user under items in the report queue so that we can follow a user's history or check at a glance how the report was solved.


Discussion / hints:
With 1 and 2, the fear is that, since admins are almost always more active than mods, that the reports would never get "bumped up", and would forever sit in the mod queue.

With 3, having some really bad content sitting there for days is also not an option. Someone needs to get to it ASAP.

The admin overreach is def a problem, but I can't think of a good way to solve it without increasing everyone's workload. Seems an unavoidable tradeoff of: "get eyes on this report to solve it quickly" vs "let community mods have some time to take action on it first".

I also don't want users to have two different report buttons.

The only thing I can think of, is to have an admin action of "push to community mods", after which it would be "resolved" for admins, but "unresolved" for mods. This would be a way to mitigate the annoyance for admins of resolving things that should really be community mods jurisdiction.

I generally agree with the proposal, and I think could work more or less like this: reports initially go to community mods, and are hidden from admins unless they click a button like "show all reports". If a report isnt resolved by mods after some days, it gets shown to admins directly. Same if a single post/comment receives multiple reports, which usually happens when something really bad gets posted. 
I would like to add, that I would like an option in the report to notify the admins right away if the reporter thinks the reported content violates the server rules. So maybe a choice 'does it violate server rules or community rules?' or something like that.

If we implemented https://github.com/LemmyNet/lemmy/issues/3324 then it would be possible to categorize who should receive the reports first. So, an instance-specific rule like "Spam" may go to both admins and mods, but a local-community rule may only go to mods first, then to admins if it goes unanswered for an extended period.

It would also be awesome to be able to set timeouts for when community-local reports would be escalated to an admin to review. e.g. If a community mod doesn't resolve a local-rule breaking issue within X hours / days, it'll be flagged for admin review. That way even if a report goes unanswered, it'll eventually be dealt with.

Also, is there any way to mark the reports as resolved if a moderation action is taken against them remotely? e.g. when a local user reports a post on a remote instance, could it be auto-resolved if said post / comment is removed, since those actions appear in the modlog already iirc.
> With 1 and 2, the fear is that, since admins are almost always more active than mods, that the reports would never get "bumped up", and would forever sit in the mod queue.

Then a person should have a role of an Admin and also role of a Mod

> With 3, having some really bad content sitting there for days is also not an option. Someone needs to get to it ASAP.

Then Admins need to appoint a new Mod


I think forcing reports to always be sent to mods is a bad idea, for communities created in bad faith it won't do anything except be a harassment vector for the person reporting, which is why I strongly believe that admin only reports should be a feature. We shouldn't give too much legitimacy to moderators here because ultimately they can be just as bad as the people being reported.
There is a related rfc: https://github.com/LemmyNet/rfcs/pull/6

Fix patch:
diff --git a/crates/api/src/reports/comment_report/create.rs b/crates/api/src/reports/comment_report/create.rs
index 5d82762927..2449679d6e 100644
--- a/crates/api/src/reports/comment_report/create.rs
+++ b/crates/api/src/reports/comment_report/create.rs
@@ -56,6 +56,7 @@ pub async fn create_comment_report(
     comment_id,
     original_comment_text: comment_view.comment.content,
     reason,
+    violates_instance_rules: data.violates_instance_rules.unwrap_or_default(),
   };
 
   let report = CommentReport::report(&mut context.pool(), &report_form)
diff --git a/crates/api/src/reports/post_report/create.rs b/crates/api/src/reports/post_report/create.rs
index dd000ffc8c..ef4a909150 100644
--- a/crates/api/src/reports/post_report/create.rs
+++ b/crates/api/src/reports/post_report/create.rs
@@ -52,6 +52,7 @@ pub async fn create_post_report(
     original_post_url: post_view.post.url,
     original_post_body: post_view.post.body,
     reason,
+    violates_instance_rules: data.violates_instance_rules.unwrap_or_default(),
   };
 
   let report = PostReport::report(&mut context.pool(), &report_form)
diff --git a/crates/api/src/reports/report_combined/list.rs b/crates/api/src/reports/report_combined/list.rs
index a444b0e00f..d471bc016b 100644
--- a/crates/api/src/reports/report_combined/list.rs
+++ b/crates/api/src/reports/report_combined/list.rs
@@ -31,6 +31,7 @@ pub async fn list_reports(
     unresolved_only: data.unresolved_only,
     page_after,
     page_back,
+    show_community_rule_violations: data.show_community_rule_violations,
   }
   .list(&mut context.pool(), &local_user_view)
   .await?;
diff --git a/crates/api_common/src/reports/combined.rs b/crates/api_common/src/reports/combined.rs
index cd87c8a3d3..e527ff467c 100644
--- a/crates/api_common/src/reports/combined.rs
+++ b/crates/api_common/src/reports/combined.rs
@@ -30,6 +30,9 @@ pub struct ListReports {
   pub page_cursor: Option<ReportCombinedPaginationCursor>,
   #[cfg_attr(feature = "full", ts(optional))]
   pub page_back: Option<bool>,
+  /// Only for admins: also show reports with `violates_instance_rules=false`
+  #[cfg_attr(feature = "full", ts(optional))]
+  pub show_community_rule_violations: Option<bool>,
 }
 
 #[derive(Debug, Serialize, Deserialize, Clone)]
diff --git a/crates/api_common/src/reports/comment.rs b/crates/api_common/src/reports/comment.rs
index d1a51a6a85..fa9b8f991e 100644
--- a/crates/api_common/src/reports/comment.rs
+++ b/crates/api_common/src/reports/comment.rs
@@ -11,6 +11,8 @@ use ts_rs::TS;
 pub struct CreateCommentReport {
   pub comment_id: CommentId,
   pub reason: String,
+  #[cfg_attr(feature = "full", ts(optional))]
+  pub violates_instance_rules: Option<bool>,
 }
 
 #[derive(Debug, Serialize, Deserialize, Clone)]
diff --git a/crates/api_common/src/reports/post.rs b/crates/api_common/src/reports/post.rs
index a4d20d575a..f2f6637c05 100644
--- a/crates/api_common/src/reports/post.rs
+++ b/crates/api_common/src/reports/post.rs
@@ -11,6 +11,8 @@ use ts_rs::TS;
 pub struct CreatePostReport {
   pub post_id: PostId,
   pub reason: String,
+  #[cfg_attr(feature = "full", ts(optional))]
+  pub violates_instance_rules: Option<bool>,
 }
 
 #[derive(Debug, Serialize, Deserialize, Clone)]
diff --git a/crates/apub/src/activities/community/report.rs b/crates/apub/src/activities/community/report.rs
index 15597e4a2b..f0eced013b 100644
--- a/crates/apub/src/activities/community/report.rs
+++ b/crates/apub/src/activities/community/report.rs
@@ -105,6 +105,7 @@ impl ActivityHandler for Report {
           original_post_url: post.url.clone(),
           reason,
           original_post_body: post.body.clone(),
+          violates_instance_rules: false,
         };
         PostReport::report(&mut context.pool(), &report_form).await?;
       }
@@ -116,6 +117,7 @@ impl ActivityHandler for Report {
           comment_id: comment.id,
           original_comment_text: comment.content.clone(),
           reason,
+          violates_instance_rules: false,
         };
         CommentReport::report(&mut context.pool(), &report_form).await?;
       }
diff --git a/crates/db_schema/replaceable_schema/triggers.sql b/crates/db_schema/replaceable_schema/triggers.sql
index adf06c32b6..19f606cdfe 100644
--- a/crates/db_schema/replaceable_schema/triggers.sql
+++ b/crates/db_schema/replaceable_schema/triggers.sql
@@ -392,10 +392,11 @@ BEGIN
         report_count = a.report_count + diff.report_count, unresolved_report_count = a.unresolved_report_count + diff.unresolved_report_count
     FROM (
         SELECT
-            (post_report).post_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (post_report).resolved), 0) AS unresolved_report_count
-    FROM select_old_and_new_rows AS old_and_new_rows GROUP BY (post_report).post_id) AS diff
+            (post_report).post_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (post_report).resolved
+                AND NOT (post_report).violates_instance_rules), 0) AS unresolved_report_count
+FROM select_old_and_new_rows AS old_and_new_rows GROUP BY (post_report).post_id) AS diff
 WHERE (diff.report_count, diff.unresolved_report_count) != (0, 0)
-    AND a.post_id = diff.post_id;
+AND a.post_id = diff.post_id;
 
 RETURN NULL;
 
@@ -411,10 +412,11 @@ BEGIN
         report_count = a.report_count + diff.report_count, unresolved_report_count = a.unresolved_report_count + diff.unresolved_report_count
     FROM (
         SELECT
-            (comment_report).comment_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (comment_report).resolved), 0) AS unresolved_report_count
-    FROM select_old_and_new_rows AS old_and_new_rows GROUP BY (comment_report).comment_id) AS diff
+            (comment_report).comment_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (comment_report).resolved
+                AND NOT (comment_report).violates_instance_rules), 0) AS unresolved_report_count
+FROM select_old_and_new_rows AS old_and_new_rows GROUP BY (comment_report).comment_id) AS diff
 WHERE (diff.report_count, diff.unresolved_report_count) != (0, 0)
-    AND a.comment_id = diff.comment_id;
+AND a.comment_id = diff.comment_id;
 
 RETURN NULL;
 
diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs
index 77cdfec269..b8c7bce25a 100644
--- a/crates/db_schema/src/schema.rs
+++ b/crates/db_schema/src/schema.rs
@@ -179,6 +179,7 @@ diesel::table! {
         resolver_id -> Nullable<Int4>,
         published -> Timestamptz,
         updated -> Nullable<Timestamptz>,
+        violates_instance_rules -> Bool,
     }
 }
 
@@ -917,6 +918,7 @@ diesel::table! {
         resolver_id -> Nullable<Int4>,
         published -> Timestamptz,
         updated -> Nullable<Timestamptz>,
+        violates_instance_rules -> Bool,
     }
 }
 
diff --git a/crates/db_schema/src/source/comment_report.rs b/crates/db_schema/src/source/comment_report.rs
index a19b6925a8..6a7f0de979 100644
--- a/crates/db_schema/src/source/comment_report.rs
+++ b/crates/db_schema/src/source/comment_report.rs
@@ -30,6 +30,7 @@ pub struct CommentReport {
   pub published: DateTime<Utc>,
   #[cfg_attr(feature = "full", ts(optional))]
   pub updated: Option<DateTime<Utc>>,
+  pub violates_instance_rules: bool,
 }
 
 #[derive(Clone)]
@@ -40,4 +41,5 @@ pub struct CommentReportForm {
   pub comment_id: CommentId,
   pub original_comment_text: String,
   pub reason: String,
+  pub violates_instance_rules: bool,
 }
diff --git a/crates/db_schema/src/source/post_report.rs b/crates/db_schema/src/source/post_report.rs
index 610e495ae9..5847d5c665 100644
--- a/crates/db_schema/src/source/post_report.rs
+++ b/crates/db_schema/src/source/post_report.rs
@@ -37,6 +37,7 @@ pub struct PostReport {
   pub published: DateTime<Utc>,
   #[cfg_attr(feature = "full", ts(optional))]
   pub updated: Option<DateTime<Utc>>,
+  pub violates_instance_rules: bool,
 }
 
 #[derive(Clone, Default)]
@@ -49,4 +50,5 @@ pub struct PostReportForm {
   pub original_post_url: Option<DbUrl>,
   pub original_post_body: Option<String>,
   pub reason: String,
+  pub violates_instance_rules: bool,
 }
diff --git a/crates/db_views/src/combined/report_combined_view.rs b/crates/db_views/src/combined/report_combined_view.rs
index 9a54395cbc..2ecae6cb4f 100644
--- a/crates/db_views/src/combined/report_combined_view.rs
+++ b/crates/db_views/src/combined/report_combined_view.rs
@@ -8,6 +8,7 @@ use crate::structs::{
   ReportCombinedView,
   ReportCombinedViewInternal,
 };
+use chrono::{DateTime, Days, Utc};
 use diesel::{
   result::Error,
   BoolExpressionMethods,
@@ -200,13 +201,10 @@ impl ReportCombinedViewInternal {
       );
     }
 
-    // If its not an admin, get only the ones you mod
-    if !user.local_user.admin {
-      query = query.filter(
-        community_actions::became_moderator
-          .is_not_null()
-          .and(report_combined::community_report_id.is_null()),
-      );
+    if user.local_user.admin {
+      query = query.filter(filter_admin_reports(Utc::now() - Days::new(3)));
+    } else {
+      query = query.filter(filter_mod_reports());
     }
 
     query.first::<i64>(conn).await
@@ -255,6 +253,8 @@ pub struct ReportCombinedQuery {
   pub post_id: Option<PostId>,
   pub community_id: Option<CommunityId>,
   pub unresolved_only: Option<bool>,
+  /// For admins, also show reports with `violates_instance_rules=false`
+  pub show_community_rule_violations: Option<bool>,
   pub page_after: Option<PaginationCursorData>,
   pub page_back: Option<bool>,
 }
@@ -322,17 +322,17 @@ impl ReportCombinedQuery {
       );
     }
 
-    if let Some(post_id) = self.post_id {
-      query = query.filter(post::id.eq(post_id));
+    if user.local_user.admin {
+      let show_community_rule_violations = self.show_community_rule_violations.unwrap_or_default();
+      if !show_community_rule_violations {
+        query = query.filter(filter_admin_reports(Utc::now() - Days::new(3)));
+      }
+    } else {
+      query = query.filter(filter_mod_reports());
     }
 
-    // If its not an admin, get only the ones you mod
-    if !user.local_user.admin {
-      query = query.filter(
-        community_actions::became_moderator
-          .is_not_null()
-          .and(report_combined::community_report_id.is_null()),
-      );
+    if let Some(post_id) = self.post_id {
+      query = query.filter(post::id.eq(post_id));
     }
 
     let mut query = PaginatedQueryBuilder::new(query);
@@ -390,6 +390,38 @@ impl ReportCombinedQuery {
   }
 }
 
+/// Mods can only see reports for posts/comments inside of communities where they are moderator,
+/// and which have `violates_instance_rules == false`.
+#[diesel::dsl::auto_type]
+fn filter_mod_reports() -> _ {
+  community_actions::became_moderator
+    .is_not_null()
+    // Reporting a community or private message must go to admins
+    .and(report_combined::community_report_id.is_null())
+    .and(report_combined::private_message_report_id.is_null())
+    .and(filter_violates_instance_rules().is_distinct_from(true))
+}
+
+/// Admins can see reports intended for them, or mod reports older than 3 days. Also reports
+/// on communities, person and private messages.
+#[diesel::dsl::auto_type]
+fn filter_admin_reports(interval: DateTime<Utc>) -> _ {
+  filter_violates_instance_rules()
+    .or(report_combined::published.lt(interval))
+    // Also show community reports where the admin is a community mod
+    .or(community_actions::became_moderator.is_not_null())
+}
+
+/// Filter reports which are only for admins (either post/comment report with
+/// `violates_instance_rules=true`, or report on a community/person/private message.
+#[diesel::dsl::auto_type]
+fn filter_violates_instance_rules() -> _ {
+  post_report::violates_instance_rules
+    .or(comment_report::violates_instance_rules)
+    .or(report_combined::community_report_id.is_not_null())
+    .or(report_combined::private_message_report_id.is_not_null())
+}
+
 impl InternalToCombinedView for ReportCombinedViewInternal {
   type CombinedView = ReportCombinedView;
 
@@ -509,9 +541,13 @@ mod tests {
       ReportCombinedViewInternal,
     },
   };
+  use chrono::{Days, Utc};
+  use diesel::{update, ExpressionMethods, QueryDsl};
+  use diesel_async::RunQueryDsl;
   use lemmy_db_schema::{
     aggregates::structs::{CommentAggregates, PostAggregates},
     assert_length,
+    schema::report_combined,
     source::{
       comment::{Comment, CommentInsertForm},
       comment_report::{CommentReport, CommentReportForm},
@@ -527,7 +563,7 @@ mod tests {
       private_message_report::{PrivateMessageReport, PrivateMessageReportForm},
     },
     traits::{Crud, Joinable, Reportable},
-    utils::{build_db_pool_for_tests, DbPool},
+    utils::{build_db_pool_for_tests, get_conn, DbPool},
     ReportType,
   };
   use lemmy_utils::error::LemmyResult;
@@ -665,6 +701,7 @@ mod tests {
       original_post_url: None,
       original_post_body: None,
       reason: "from sara".into(),
+      violates_instance_rules: false,
     };
     let inserted_post_report = PostReport::report(pool, &sara_report_post_form).await?;
 
@@ -674,6 +711,7 @@ mod tests {
       comment_id: data.comment.id,
       original_comment_text: "A test comment rv".into(),
       reason: "from sara".into(),
+      violates_instance_rules: false,
     };
     CommentReport::report(pool, &sara_report_comment_form).await?;
 
@@ -695,9 +733,12 @@ mod tests {
     PrivateMessageReport::report(pool, &pm_report_form).await?;
 
     // Do a batch read of admins reports
-    let reports = ReportCombinedQuery::default()
-      .list(pool, &data.admin_view)
-      .await?;
+    let reports = ReportCombinedQuery {
+      show_community_rule_violations: Some(true),
+      ..Default::default()
+    }
+    .list(pool, &data.admin_view)
+    .await?;
     assert_length!(4, reports);
 
     // Make sure the report types are correct
@@ -726,16 +767,19 @@ mod tests {
       panic!("wrong type");
     }
 
+    let report_count_mod =
+      ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view, None).await?;
+    assert_eq!(2, report_count_mod);
     let report_count_admin =
       ReportCombinedViewInternal::get_report_count(pool, &data.admin_view, None).await?;
-    assert_eq!(4, report_count_admin);
+    assert_eq!(2, report_count_admin);
 
     // Make sure the type_ filter is working
     let reports_by_type = ReportCombinedQuery {
       type_: Some(ReportType::Posts),
       ..Default::default()
     }
-    .list(pool, &data.admin_view)
+    .list(pool, &data.timmy_view)
     .await?;
     assert_length!(1, reports_by_type);
 
@@ -745,7 +789,7 @@ mod tests {
       post_id: Some(data.post.id),
       ..Default::default()
     }
-    .list(pool, &data.admin_view)
+    .list(pool, &data.timmy_view)
     .await?;
     assert_length!(2, reports_by_post_id);
 
@@ -823,9 +867,12 @@ mod tests {
     };
     let pm_report = PrivateMessageReport::report(pool, &pm_report_form).await?;
 
-    let reports = ReportCombinedQuery::default()
-      .list(pool, &data.admin_view)
-      .await?;
+    let reports = ReportCombinedQuery {
+      show_community_rule_violations: Some(true),
+      ..Default::default()
+    }
+    .list(pool, &data.admin_view)
+    .await?;
     assert_length!(1, reports);
     if let ReportCombinedView::PrivateMessage(v) = &reports[0] {
       assert!(!v.private_message_report.resolved);
@@ -875,6 +922,7 @@ mod tests {
       original_post_url: None,
       original_post_body: None,
       reason: "from sara".into(),
+      violates_instance_rules: false,
     };
 
     PostReport::report(pool, &sara_report_form).await?;
@@ -887,6 +935,7 @@ mod tests {
       original_post_url: None,
       original_post_body: None,
       reason: "from jessica".into(),
+      violates_instance_rules: false,
     };
 
     let inserted_jessica_report = PostReport::report(pool, &jessica_report_form).await?;
@@ -1004,6 +1053,7 @@ mod tests {
       comment_id: data.comment.id,
       original_comment_text: "this was it at time of creation".into(),
       reason: "from sara".into(),
+      violates_instance_rules: false,
     };
 
     CommentReport::report(pool, &sara_report_form).await?;
@@ -1014,6 +1064,7 @@ mod tests {
       comment_id: data.comment.id,
       original_comment_text: "this was it at time of creation".into(),
       reason: "from jessica".into(),
+      violates_instance_rules: false,
     };
 
     let inserted_jessica_report = CommentReport::report(pool, &jessica_report_form).await?;
@@ -1126,9 +1177,12 @@ mod tests {
     };
     let community_report = CommunityReport::report(pool, &community_report_form).await?;
 
-    let reports = ReportCombinedQuery::default()
-      .list(pool, &data.admin_view)
-      .await?;
+    let reports = ReportCombinedQuery {
+      show_community_rule_violations: Some(true),
+      ..Default::default()
+    }
+    .list(pool, &data.admin_view)
+    .await?;
     assert_length!(1, reports);
     if let ReportCombinedView::Community(v) = &reports[0] {
       assert!(!v.community_report.resolved);
@@ -1143,9 +1197,12 @@ mod tests {
     // admin resolves the report (after taking appropriate action)
     CommunityReport::resolve(pool, community_report.id, data.admin_view.person.id).await?;
 
-    let reports = ReportCombinedQuery::default()
-      .list(pool, &data.admin_view)
-      .await?;
+    let reports = ReportCombinedQuery {
+      show_community_rule_violations: Some(true),
+      ..Default::default()
+    }
+    .list(pool, &data.admin_view)
+    .await?;
     assert_length!(1, reports);
     if let ReportCombinedView::Community(v) = &reports[0] {
       assert!(v.community_report.resolved);
@@ -1162,4 +1219,94 @@ mod tests {
 
     Ok(())
   }
+
+  #[tokio::test]
+  #[serial]
+  async fn test_violates_instance_rules() -> LemmyResult<()> {
+    let pool = &build_db_pool_for_tests();
+    let pool = &mut pool.into();
+    let data = init_data(pool).await?;
+
+    // create report to admins
+    let report_form = PostReportForm {
+      creator_id: data.sara.id,
+      post_id: data.post_2.id,
+      original_post_name: "Orig post".into(),
+      original_post_url: None,
+      original_post_body: None,
+      reason: "from sara".into(),
+      violates_instance_rules: true,
+    };
+    PostReport::report(pool, &report_form).await?;
+
+    // timmy is a mod and cannot see the report
+    let mod_reports = ReportCombinedQuery::default()
+      .list(pool, &data.timmy_view)
+      .await?;
+    assert_length!(0, mod_reports);
+    let count = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view, None).await?;
+    assert_eq!(0, count);
+
+    // only admin can see the report
+    let admin_reports = ReportCombinedQuery::default()
+      .list(pool, &data.admin_view)
+      .await?;
+    assert_length!(1, admin_reports);
+    let count = ReportCombinedViewInternal::get_report_count(pool, &data.admin_view, None).await?;
+    assert_eq!(1, count);
+
+    // cleanup the report for easier checks below
+    Post::delete(pool, data.post_2.id).await?;
+
+    // now create a mod report
+    let report_form = CommentReportForm {
+      creator_id: data.sara.id,
+      comment_id: data.comment.id,
+      original_comment_text: "this was it at time of creation".into(),
+      reason: "from sara".into(),
+      violates_instance_rules: false,
+    };
+    let comment_report = CommentReport::report(pool, &report_form).await?;
+
+    // this time the mod can see it
+    let mod_reports = ReportCombinedQuery::default()
+      .list(pool, &data.timmy_view)
+      .await?;
+    assert_length!(1, mod_reports);
+    let count = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view, None).await?;
+    assert_eq!(1, count);
+
+    // but not the admin
+    let admin_reports = ReportCombinedQuery::default()
+      .list(pool, &data.admin_view)
+      .await?;
+    assert_length!(0, admin_reports);
+    let count = ReportCombinedViewInternal::get_report_count(pool, &data.admin_view, None).await?;
+    assert_eq!(0, count);
+
+    // admin can see the report with `view_mod_reports` set
+    let admin_reports = ReportCombinedQuery {
+      show_community_rule_violations: Some(true),
+      ..Default::default()
+    }
+    .list(pool, &data.timmy_view)
+    .await?;
+    assert_length!(1, admin_reports);
+
+    // change a comment to be 3 days old, now admin can also see it by default
+    update(
+      report_combined::table.filter(report_combined::dsl::comment_report_id.eq(comment_report.id)),
+    )
+    .set(report_combined::published.eq(Utc::now() - Days::new(3)))
+    .execute(&mut get_conn(pool).await?)
+    .await?;
+    let admin_reports = ReportCombinedQuery::default()
+      .list(pool, &data.admin_view)
+      .await?;
+    assert_length!(1, admin_reports);
+
+    cleanup(data, pool).await?;
+
+    Ok(())
+  }
 }
diff --git a/migrations/2025-01-23-143621_report_to_admins/down.sql b/migrations/2025-01-23-143621_report_to_admins/down.sql
new file mode 100644
index 0000000000..779d47d310
--- /dev/null
+++ b/migrations/2025-01-23-143621_report_to_admins/down.sql
@@ -0,0 +1,6 @@
+ALTER TABLE post_report
+    DROP COLUMN violates_instance_rules;
+
+ALTER TABLE comment_report
+    DROP COLUMN violates_instance_rules;
+
diff --git a/migrations/2025-01-23-143621_report_to_admins/up.sql b/migrations/2025-01-23-143621_report_to_admins/up.sql
new file mode 100644
index 0000000000..ecf7e5d294
--- /dev/null
+++ b/migrations/2025-01-23-143621_report_to_admins/up.sql
@@ -0,0 +1,6 @@
+ALTER TABLE post_report
+    ADD COLUMN violates_instance_rules bool NOT NULL DEFAULT FALSE;
+
+ALTER TABLE comment_report
+    ADD COLUMN violates_instance_rules bool NOT NULL DEFAULT FALSE;
+


Test patch:
diff --git a/api_tests/package.json b/api_tests/package.json
index e2953d6b33..ea01a50463 100644
--- a/api_tests/package.json
+++ b/api_tests/package.json
@@ -29,7 +29,7 @@
     "eslint": "^9.20.0",
     "eslint-plugin-prettier": "^5.2.3",
     "jest": "^29.5.0",
-    "lemmy-js-client": "0.20.0-ap-id.1",
+    "lemmy-js-client": "0.20.0-show-mod-reports.2",
     "prettier": "^3.5.0",
     "ts-jest": "^29.1.0",
     "tsoa": "^6.6.0",
diff --git a/api_tests/pnpm-lock.yaml b/api_tests/pnpm-lock.yaml
index cd2112bb45..c59cbee647 100644
--- a/api_tests/pnpm-lock.yaml
+++ b/api_tests/pnpm-lock.yaml
@@ -33,8 +33,8 @@ importers:
         specifier: ^29.5.0
         version: 29.7.0(@types/node@22.13.1)
       lemmy-js-client:
-        specifier: 0.20.0-ap-id.1
-        version: 0.20.0-ap-id.1
+        specifier: 0.20.0-show-mod-reports.2
+        version: 0.20.0-show-mod-reports.2
       prettier:
         specifier: ^3.5.0
         version: 3.5.0
@@ -1528,8 +1528,8 @@ packages:
     resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
     engines: {node: '>=6'}
 
-  lemmy-js-client@0.20.0-ap-id.1:
-    resolution: {integrity: sha512-HzY005mhbINXa5i+GabuJSrwN27ExZKj2XxM1cAnfTWJ4ZqvbLuz4i26JDeE8pj6GGKbXBIj2VX4aOhKgCjkSA==}
+  lemmy-js-client@0.20.0-show-mod-reports.2:
+    resolution: {integrity: sha512-92Zgs5/Nf8h57U5kgL0Q9BZzoTG0JUTGSGg1e9DORzQFVfYv53dzKpdjNxvuHsxlmT4eOeZgCuyUGdRipYy6jw==}
 
   leven@3.1.0:
     resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
@@ -4169,7 +4169,7 @@ snapshots:
 
   kleur@3.0.3: {}
 
-  lemmy-js-client@0.20.0-ap-id.1: {}
+  lemmy-js-client@0.20.0-show-mod-reports.2: {}
 
   leven@3.1.0: {}
 
diff --git a/api_tests/src/post.spec.ts b/api_tests/src/post.spec.ts
index 18419cf264..80496a46cc 100644
--- a/api_tests/src/post.spec.ts
+++ b/api_tests/src/post.spec.ts
@@ -731,11 +731,12 @@ test("Report a post", async () => {
   expect(betaReport.reason).toBe(gammaReport.reason);
   await unfollowRemotes(alpha);
 
-  // Report was federated to poster's instance
+  // Report was federated to poster's instance. Alpha is not a community mod and doesnt see
+  // the report by default, so we need to pass show_mod_reports = true.
   let alphaReport = (
     (await waitUntil(
       () =>
-        listReports(alpha).then(p =>
+        listReports(alpha, true).then(p =>
           p.reports.find(r => {
             return checkPostReportName(r, gammaReport);
           }),
diff --git a/api_tests/src/shared.ts b/api_tests/src/shared.ts
index a79bf8655d..8e653f5e6b 100644
--- a/api_tests/src/shared.ts
+++ b/api_tests/src/shared.ts
@@ -801,8 +801,9 @@ export async function reportPost(
 
 export async function listReports(
   api: LemmyHttp,
+  show_community_rule_violations: boolean = false,
 ): Promise<ListReportsResponse> {
-  let form: ListReports = {};
+  let form: ListReports = { show_community_rule_violations };
   return api.listReports(form);
 }