0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 16:23:34 +08:00
discourse/app/models/reviewable_flagged_post.rb
Régis Hanol ca469579dc
FEATURE: Allow suspending and silencing users from the review queue (#42205)
Previously, suspect-user reviewables ("user needs approval" flags) and
queued-post reviewables could only be resolved by deleting the user or
rejecting the post outright — a reviewer mistake on an automated false
positive was irreversible, and sites with no-deletion policies had to
resolve the flag and then manually suspend the user from their admin
page. Requested in [meta t/408891](https://meta.discourse.org/t/408891)
and [meta t/225660](https://meta.discourse.org/t/225660).

This change adds guardian-gated **Silence user** / **Suspend user**
resolutions to both queues via a shared `build_penalty_actions` helper,
using the same penalize-modal flow the flagged-post and
review-every-post queues already use (staff log linked back to the
reviewable). Along the way it:

- hides penalty actions that are already active everywhere (previously a
resolve-then-409 dead end), and fixes the penalize modal's unawaited
`before()` race so the penalty is only applied once the reviewable
action succeeded;
- narrows the rejected-user **scrub** affordance to records whose
identity snapshot is the last remaining copy (user deleted, or
renamed/anonymized after a failed deletion), admin-gated to match the
endpoint;
- shows an active penalty (localized end date and reason) on the
reviewable user card, so it's clear why a penalty option is absent;
- preloads `anonymous_user_master` on the queue since the new
`silenced?` gates would otherwise lazy-load it per row;
- rewrites the action descriptions in one consistent voice.

<img width="460" height="313" alt="2026-07-31 @ 07 46 57"
src="https://github.com/user-attachments/assets/87d5bf49-97e0-47a4-939e-16da3f55c6e1"
/>
2026-07-31 14:47:40 +02:00

426 lines
13 KiB
Ruby
Vendored

# frozen_string_literal: true
class ReviewableFlaggedPost < Reviewable
include ReviewableActionBuilder
scope :pending_and_default_visible, -> { pending.default_visible }
# Penalties are handled by the modal after the action is performed
def self.action_aliases
{
agree_and_keep_hidden: :agree_and_keep,
agree_and_keep_deleted: :agree_and_keep,
agree_and_silence: :agree_and_keep,
agree_and_suspend: :agree_and_keep,
agree_and_edit: :agree_and_keep,
disagree_and_restore: :disagree,
ignore_and_do_nothing: :ignore,
delete_user_block: :delete_and_block_user, # legacy name mapped to concern method
}
end
def self.counts_for(posts)
result = {}
counts = DB.query(<<~SQL, pending: statuses[:pending])
SELECT r.target_id AS post_id,
rs.reviewable_score_type,
count(*) as total
FROM reviewables AS r
INNER JOIN reviewable_scores AS rs ON rs.reviewable_id = r.id
WHERE r.type = 'ReviewableFlaggedPost'
AND r.status = :pending
GROUP BY r.target_id, rs.reviewable_score_type
SQL
counts.each do |c|
result[c.post_id] ||= {}
result[c.post_id][c.reviewable_score_type] = c.total
end
result
end
def post
@post ||= target || Post.with_deleted.find_by(id: target_id)
end
def build_actions(actions, guardian, args)
return unless pending?
return if post.blank?
super
end
def build_combined_actions(actions, guardian, args)
# existing combined logic
agree_bundle =
actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title")
if post.user_deleted?
build_action(actions, :agree_and_keep_deleted, icon: "far-eye-slash", bundle: agree_bundle)
else
if !post.hidden?
build_action(actions, :agree_and_hide, icon: "far-eye-slash", bundle: agree_bundle)
end
if post.hidden?
build_action(actions, :agree_and_keep_hidden, icon: "far-eye-slash", bundle: agree_bundle)
else
build_action(actions, :agree_and_keep, icon: "far-eye", bundle: agree_bundle)
build_action(
actions,
:agree_and_edit,
icon: "pencil",
bundle: agree_bundle,
client_action: "edit",
)
end
end
can_delete_post_or_topic = guardian.can_delete_post_or_topic?(post)
can_delete_existing_post_or_topic = can_delete_post_or_topic && !post.user_deleted?
if can_delete_existing_post_or_topic
build_action(actions, :delete_and_agree, icon: "trash-can", bundle: agree_bundle)
if post.reply_count > 0
build_action(
actions,
:delete_and_agree_replies,
icon: "trash-can",
bundle: agree_bundle,
confirm: true,
)
end
end
build_penalty_actions(
actions,
bundle: agree_bundle,
silence: :agree_and_silence,
suspend: :agree_and_suspend,
)
if (potential_spam? || potentially_illegal?) && guardian.can_delete_user?(target_created_by)
delete_user_actions(actions, agree_bundle)
end
if post.user_deleted? && !user_penalized_for_deleted_post?
build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree_bundle)
end
post_visible_or_system_user = !post.hidden? || guardian.user.is_system_user?
# We must return early in this case otherwise we can end up with a bundle
# with no associated actions, which is not valid on the client.
return if !can_delete_post_or_topic && !post_visible_or_system_user && post.hidden?
disagree_bundle =
actions.add_bundle(
"#{id}-disagree",
icon: "far-eye",
label: "reviewables.actions.disagree_bundle.title",
)
if user_silenced_for_post?
build_action(
actions,
:unsilence_user_and_ignore,
icon: "microphone-slash",
bundle: disagree_bundle,
)
elsif !user_penalized_for_deleted_post?
if post.hidden?
build_action(actions, :disagree_and_restore, icon: "far-eye", bundle: disagree_bundle)
else
build_action(actions, :disagree, icon: "far-eye", bundle: disagree_bundle)
end
end
if post_visible_or_system_user || user_penalized_for_deleted_post?
build_action(actions, :ignore_and_do_nothing, icon: "xmark", bundle: disagree_bundle)
end
if can_delete_existing_post_or_topic
build_action(actions, :delete_and_ignore, icon: "trash-can", bundle: disagree_bundle)
if post.reply_count > 0
build_action(
actions,
:delete_and_ignore_replies,
icon: "trash-can",
confirm: true,
bundle: disagree_bundle,
)
end
end
end
def perform_ignore(performed_by, args)
perform_ignore_and_do_nothing(performed_by, args)
end
def perform_unsilence_user_and_ignore(performed_by, args)
UserSilencer.unsilence(post.user, performed_by, reviewable_id: id) if user_silenced_for_post?
perform_ignore_and_do_nothing(performed_by, args)
end
def post_action_type_view
@post_action_type_view ||= PostActionTypeView.new
end
def user_silenced_for_post?
post.user_deleted? && post.user&.silenced? && UserSilencer.was_silenced_for?(post)
end
def user_penalized_for_deleted_post?
return false if !post.user_deleted? || (!post.user&.silenced? && !post.user&.suspended?)
UserHistory.exists?(
action: [UserHistory.actions[:silence_user], UserHistory.actions[:suspend_user]],
post: post,
)
end
def perform_ignore_and_do_nothing(performed_by, args)
actions =
PostAction
.active
.where(post_id: target_id)
.where(post_action_type_id: post_action_type_view.notify_flag_type_ids)
actions.each do |action|
action.deferred_at = Time.zone.now
action.deferred_by_id = performed_by.id
# so callback is called
action.save
unless args[:expired]
action.add_moderator_post_if_needed(performed_by, :ignored, args[:post_was_deleted])
end
end
if actions.first.present?
unassign_topic performed_by, post
DiscourseEvent.trigger(:flag_reviewed, post)
DiscourseEvent.trigger(:flag_deferred, actions.first)
end
create_result(:success, :ignored, actions.map(&:user_id), false)
end
def perform_agree_and_keep(performed_by, args)
agree(performed_by, args)
end
def perform_delete_user(performed_by, args)
super
agree(performed_by, args)
end
def perform_delete_and_block_user(performed_by, args)
super
agree(performed_by, args)
end
def perform_agree_and_hide(performed_by, args)
agree(performed_by, args) { |pa| post.hide!(pa.post_action_type_id) }
end
def perform_agree_and_restore(performed_by, args)
agree(performed_by, args) { PostDestroyer.new(performed_by, post).recover }
end
def perform_disagree(performed_by, args)
# -1 is the automatic system clear
action_type_ids =
if performed_by.id == Discourse::SYSTEM_USER_ID
post_action_type_view.auto_action_flag_types.values
else
post_action_type_view.notify_flag_type_ids
end
actions =
PostAction.active.where(post_id: target_id).where(post_action_type_id: action_type_ids)
actions.each do |action|
action.disagreed_at = Time.zone.now
action.disagreed_by_id = performed_by.id
# so callback is called
action.save
action.add_moderator_post_if_needed(performed_by, :disagreed)
end
# reset all cached counters
cached = {}
action_type_ids.each do |atid|
column = "#{post_action_type_view.types[atid]}_count"
cached[column] = 0 if ActiveRecord::Base.connection.column_exists?(:posts, column)
end
Post.with_deleted.where(id: target_id).update_all(cached)
if actions.first.present?
unassign_topic performed_by, post
DiscourseEvent.trigger(:flag_reviewed, post)
DiscourseEvent.trigger(:flag_disagreed, actions.first)
end
# Undo hide/silence if applicable
if post&.hidden?
notify_poster(performed_by)
post.acting_user = performed_by
post.unhide!
UserSilencer.unsilence(post.user) if UserSilencer.was_silenced_for?(post)
end
create_result(:success, :rejected, actions.map(&:user_id), false)
end
def perform_delete_and_ignore(performed_by, args)
result = perform_ignore_and_do_nothing(performed_by, args)
destroyer(performed_by, post).destroy
result
end
def perform_delete_and_ignore_replies(performed_by, args)
result = perform_ignore_and_do_nothing(performed_by, args)
PostDestroyer.delete_with_replies(performed_by, post, id)
result
end
def perform_delete_and_agree(performed_by, args)
result = agree(performed_by, args)
destroyer(performed_by, post).destroy
result
end
def perform_delete_and_agree_replies(performed_by, args)
result = agree(performed_by, args)
PostDestroyer.delete_with_replies(performed_by, post, id)
result
end
protected
def agree(performed_by, args)
actions =
PostAction
.active
.where(post_id: target_id)
.where(post_action_type_id: post_action_type_view.notify_flag_types.values)
trigger_spam = false
actions.each do |action|
ActiveRecord::Base.transaction do
action.agreed_at = Time.zone.now
action.agreed_by_id = performed_by.id
# so callback is called
action.save
DB.after_commit do
action.add_moderator_post_if_needed(performed_by, :agreed, args[:post_was_deleted])
trigger_spam = true if action.post_action_type_id == post_action_type_view.types[:spam]
end
end
end
DiscourseEvent.trigger(:confirmed_spam_post, post) if trigger_spam
if actions.first.present?
unassign_topic performed_by, post
DiscourseEvent.trigger(:flag_reviewed, post)
DiscourseEvent.trigger(:flag_agreed, actions.first)
yield(actions.first) if block_given?
end
create_result(:success, :approved, actions.map(&:user_id), false)
end
def unassign_topic(performed_by, post)
topic = post.topic
return unless topic && performed_by && SiteSetting.reviewable_claiming != "disabled"
deleted_count = ReviewableClaimedTopic.where(topic_id: topic.id, automatic: false).delete_all
if deleted_count > 0
topic.reviewables.find_each { |reviewable| reviewable.log_history(:unclaimed, performed_by) }
end
user_ids = User.staff.pluck(:id)
if SiteSetting.enable_category_group_moderation? && topic.category
user_ids.concat(
GroupUser
.joins(
"INNER JOIN category_moderation_groups ON category_moderation_groups.group_id = group_users.group_id",
)
.where("category_moderation_groups.category_id": topic.category.id)
.distinct
.pluck(:user_id),
)
user_ids.uniq!
end
data = {
topic_id: topic.id,
user: BasicUserSerializer.new(performed_by, root: false).as_json,
automatic: false,
claimed: false,
}
MessageBus.publish("/reviewable_claimed", data, user_ids: user_ids)
end
private
def destroyer(performed_by, post)
PostDestroyer.new(performed_by, post, reviewable_id: id)
end
def notify_poster(performed_by)
return unless performed_by.human? && performed_by.staff?
Jobs.enqueue(
:send_system_message,
user_id: post.user_id,
message_type: "flags_disagreed",
message_options: {
flagged_post_raw_content: post.raw,
url: post.url,
},
)
end
end
# == Schema Information
#
# Table name: reviewables
#
# id :bigint not null, primary key
# force_review :boolean default(FALSE), not null
# latest_score :datetime
# payload :json
# potential_spam :boolean default(FALSE), not null
# potentially_illegal :boolean default(FALSE)
# reject_reason :text
# reviewable_by_moderator :boolean default(FALSE), not null
# score :float default(0.0), not null
# status :integer default("pending"), not null
# target_type :string
# type :string not null
# type_source :string default("unknown"), not null
# version :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# category_id :integer
# created_by_id :integer not null
# target_created_by_id :integer
# target_id :integer
# topic_id :integer
#
# Indexes
#
# idx_reviewables_score_desc_created_at_desc (score,created_at)
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
# index_reviewables_on_status_and_created_at (status,created_at)
# index_reviewables_on_status_and_score (status,score)
# index_reviewables_on_status_and_type (status,type)
# index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text)
# index_reviewables_on_topic_id_and_status_and_created_by_id (topic_id,status,created_by_id)
# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE
#