0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 23:04:22 +08:00
discourse/app/models/concerns/reviewable_action_builder.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

202 lines
6.1 KiB
Ruby
Vendored

# frozen_string_literal: true
module ReviewableActionBuilder
extend ActiveSupport::Concern
attr_accessor :actions, :guardian, :action_args
def build_actions(actions, guardian, args)
@actions = actions
@guardian = guardian
@action_args = args
# For backward compatibility with plugins that override build_legacy_combined_actions
if respond_to?(:build_legacy_combined_actions)
build_legacy_combined_actions(actions, guardian, args)
else
build_combined_actions(actions, guardian, args)
end
end
def build_combined_actions(actions, guardian, args)
raise NotImplementedError, "Including class must implement #build_combined_actions"
end
def build_bundle(id, label, bundle_actions = {}, source: nil)
bundle = @actions.add_bundle(id, label:)
bundle_actions.each do |action_id, action_params|
build_action(@actions, action_id, bundle:, **action_params || {}, source:)
end
bundle
end
def build_action(
actions,
id,
icon: nil,
button_class: nil,
bundle: nil,
client_action: nil,
confirm: false,
require_reject_reason: false,
source: nil
)
actions.add(id, bundle: bundle) do |action|
source ||= type_source
if source == "core"
prefix = "reviewables.actions.#{id}"
else
prefix = "#{source.underscore}.reviewables.actions.#{id}"
end
action.icon = icon if icon
action.button_class = button_class if button_class
action.label = "#{prefix}.title"
action.description = "#{prefix}.description"
action.client_action = client_action if client_action
action.confirm_message = "#{prefix}.confirm" if confirm
action.completed_message = "#{prefix}.complete"
action.require_reject_reason = require_reject_reason
end
end
def build_penalty_actions(actions, bundle:, silence:, suspend:, user: target_user)
return if user.blank? || !guardian.can_suspend?(user)
if !user.silenced?
build_action(actions, silence, icon: "microphone-slash", bundle:, client_action: "silence")
end
if !user.suspended?
build_action(actions, suspend, icon: "ban", bundle:, client_action: "suspend")
end
end
def perform_silence_user(performed_by, args)
create_result(:success, :rejected)
end
def perform_suspend_user(performed_by, args)
create_result(:success, :rejected)
end
def perform_delete_user(performed_by, args, &)
delete_user(target_user, delete_opts, performed_by) if target_user
create_result(:success, :rejected, [], false, &)
end
def perform_delete_and_block_user(performed_by, args, &)
delete_options = delete_opts
delete_options.merge!(block_email: true, block_ip: true) if Rails.env.production?
delete_user(target_user, delete_options, performed_by) if target_user
create_result(:success, :rejected, [], false, &)
end
def perform_delete_post(performed_by, _args)
PostDestroyer.new(performed_by, target_post, reviewable_id: id).destroy
create_result(:success, :rejected, [created_by_id], false)
end
def perform_hide_post(performed_by, _args)
target_post.hide!(PostActionType.types[:inappropriate])
create_result(:success, :rejected, [created_by_id], false)
end
def perform_unhide_post(performed_by, _args)
target_post.acting_user = performed_by
target_post.unhide!
create_result(:success, :approved, [created_by_id], false)
end
def perform_restore_post(performed_by, _args)
PostDestroyer.new(performed_by, target_post).recover
create_result(:success, :approved, [created_by_id], false)
end
def perform_edit_post(performed_by, _args)
# This is handled client-side, just transition the state
create_result(:success, :approved, [created_by_id], false)
end
private
# Returns the user associated with the reviewable, if applicable.
# For most reviewables, this will be the user who created the reviewable target.
#
# @return [User] The user associated with the reviewable.
def target_user
if target_type == "User"
try(:target)
else
try(:target_created_by)
end
end
# Returns the post associated with the reviewable, if applicable.
# This method assumes that the including class has a `target` that is a Post or
# a `target_id` that can be used to look up the Post.
#
# @return [Post, nil] The post associated with the reviewable, or nil if not found.
def target_post
@post ||=
if defined?(target) && target.is_a?(Post)
target
elsif defined?(target_id)
Post.with_deleted.find_by(id: target_id)
end
end
# Options for deleting a user, used by perform_delete_user and perform_delete_and_block_user.
def delete_opts
{
delete_posts: true,
prepare_for_destroy: true,
block_urls: true,
delete_as_spammer: true,
context: "review",
}
end
def delete_user(user, delete_options, performed_by)
email = user.email
UserDestroyer.new(performed_by).destroy(user, delete_options.merge(reviewable_id: id))
message = UserNotifications.account_deleted(email, self)
Email::Sender.new(message, :account_deleted).send
end
def map_reviewable_status_to_flag_status(status)
case status
when :approved
:agreed
when :rejected
:disagreed
else
status
end
end
# Create a result object.
#
# @param status [Symbol] The status of the result.
# @param transition_to [Symbol] The state to transition to.
# @param recalculate_score [Boolean] Whether to recalculate the score.
# @yield [result] The result object.
#
# @return [Reviewable::PerformResult] The created result object.
def create_result(status, transition_to = nil, flagging_user_ids = [], recalculate_score = true)
result = Reviewable::PerformResult.new(self, status)
result.transition_to = transition_to
if flagging_user_ids.any? && target_post
result.update_flag_stats = {
status: map_reviewable_status_to_flag_status(transition_to),
user_ids: flagging_user_ids,
}
result.recalculate_score = recalculate_score
end
yield result if block_given?
result
end
end