discourse/app/controllers/reviewables_controller.rb
Alan Guo Xiang Tan ce531913a8
FIX: Sync user's reviewables count when loading reviewables list (#20128)
1. What is the problem here?

When a user's reviewables count changes, the changes are published via
MessageBus in a background Sidekiq job which means there is a delay before the
client receives the MessageBus message with the updated count. During
the time the reviewables count for a user has been updated and the time
when the client receives the MessageBus message with the updated count,
a user may view the reviewables list in the user menu. When that happens, the number of
reviewables in the list may be out of sync with the count shown.

2. What is the fix?

Going forward, the response for the `ReviewablesController#user_menu_list` action will include the user's reviewables count as
the `reviewables_count` attribute. This is then used by the client side
to update the user's reviewables count to ensure that the reviewables
list and count are kept in sync.
2023-02-02 10:19:51 +08:00

303 lines
9.1 KiB
Ruby

# frozen_string_literal: true
class ReviewablesController < ApplicationController
requires_login
PER_PAGE = 10
before_action :version_required, only: %i[update perform]
before_action :ensure_can_see, except: [:destroy]
def index
offset = params[:offset].to_i
if params[:type].present?
raise Discourse::InvalidParameters.new(:type) unless Reviewable.valid_type?(params[:type])
end
status = (params[:status] || "pending").to_sym
raise Discourse::InvalidParameters.new(:status) unless allowed_statuses.include?(status)
topic_id = params[:topic_id] ? params[:topic_id].to_i : nil
category_id = params[:category_id] ? params[:category_id].to_i : nil
custom_keys = Reviewable.custom_filters.map(&:first)
additional_filters =
JSON.parse(params.fetch(:additional_filters, {}), symbolize_names: true).slice(*custom_keys)
filters = {
ids: params[:ids],
status: status,
category_id: category_id,
topic_id: topic_id,
additional_filters: additional_filters.reject { |_, v| v.blank? },
}
%i[priority username reviewed_by from_date to_date type sort_order].each do |filter_key|
filters[filter_key] = params[filter_key]
end
total_rows = Reviewable.list_for(current_user, **filters).count
reviewables =
Reviewable.list_for(current_user, **filters.merge(limit: PER_PAGE, offset: offset)).to_a
claimed_topics = ReviewableClaimedTopic.claimed_hash(reviewables.map { |r| r.topic_id }.uniq)
# This is a bit awkward, but ActiveModel serializers doesn't seem to serialize STI. Note `hash`
# is mutated by the serializer and contains the side loaded records which must be merged in the end.
hash = {}
json = {
reviewables:
reviewables.map! do |r|
result =
r
.serializer
.new(r, root: nil, hash: hash, scope: guardian, claimed_topics: claimed_topics)
.as_json
hash[:bundled_actions].uniq!
(hash["actions"] || []).uniq!
result
end,
meta:
filters.merge(
total_rows_reviewables: total_rows,
types: meta_types,
reviewable_types: Reviewable.types,
reviewable_count: current_user.reviewable_count,
unseen_reviewable_count: Reviewable.unseen_reviewable_count(current_user),
),
}
if (offset + PER_PAGE) < total_rows
json[:meta][:load_more_reviewables] = review_path(filters.merge(offset: offset + PER_PAGE))
end
json.merge!(hash)
render_json_dump(json, rest_serializer: true)
end
def user_menu_list
json = {
reviewables:
Reviewable.basic_serializers_for_list(
Reviewable.user_menu_list_for(current_user),
current_user,
).as_json,
reviewable_count: current_user.reviewable_count,
}
render_json_dump(json, rest_serializer: true)
end
def count
render_json_dump(count: Reviewable.pending_count(current_user))
end
def topics
topic_ids = Set.new
stats = {}
unique_users = {}
# topics isn't indexed on `reviewable_score` and doesn't know what the current user can see,
# so let's query from the inside out.
pending = Reviewable.viewable_by(current_user).pending
pending = pending.where("score >= ?", Reviewable.min_score_for_priority)
pending.each do |r|
topic_ids << r.topic_id
meta = stats[r.topic_id] ||= { count: 0, unique_users: 0 }
users = unique_users[r.topic_id] ||= Set.new
r.reviewable_scores.each do |rs|
users << rs.user_id
meta[:count] += 1
end
meta[:unique_users] = users.size
end
topics = Topic.where(id: topic_ids).order("reviewable_score DESC")
render_serialized(
topics,
ReviewableTopicSerializer,
root: "reviewable_topics",
stats: stats,
claimed_topics: ReviewableClaimedTopic.claimed_hash(topic_ids),
rest_serializer: true,
meta: {
types: meta_types,
},
)
end
def explain
reviewable = find_reviewable
render_serialized(
{ reviewable: reviewable, scores: reviewable.explain_score },
ReviewableExplanationSerializer,
rest_serializer: true,
root: "reviewable_explanation",
)
end
def show
reviewable = find_reviewable
render_serialized(
reviewable,
reviewable.serializer,
rest_serializer: true,
claimed_topics: ReviewableClaimedTopic.claimed_hash([reviewable.topic_id]),
root: "reviewable",
meta: {
types: meta_types,
},
)
end
def destroy
reviewable = Reviewable.find_by(id: params[:reviewable_id], created_by: current_user)
raise Discourse::NotFound.new if reviewable.blank?
reviewable.perform(current_user, :delete)
render json: success_json
end
def update
reviewable = find_reviewable
if error = claim_error?(reviewable)
return render_json_error(error)
end
editable = reviewable.editable_for(guardian)
raise Discourse::InvalidAccess.new unless editable.present?
# Validate parameters are all editable
edit_params = params[:reviewable] || {}
edit_params.each do |name, value|
if value.is_a?(ActionController::Parameters)
value.each do |pay_name, pay_value|
raise Discourse::InvalidAccess.new unless editable.has?("#{name}.#{pay_name}")
end
else
raise Discourse::InvalidAccess.new unless editable.has?(name)
end
end
begin
if reviewable.update_fields(edit_params, current_user, version: params[:version].to_i)
result = edit_params.merge(version: reviewable.version)
render json: result
else
render_json_error(reviewable.errors)
end
rescue Reviewable::UpdateConflict
render_json_error(I18n.t("reviewables.conflict"), status: 409)
end
end
def perform
args = { version: params[:version].to_i }
result = nil
begin
reviewable = find_reviewable
if error = claim_error?(reviewable)
return render_json_error(error)
end
if reviewable.type == "ReviewableUser"
args.merge!(
reject_reason: params[:reject_reason],
send_email: params[:send_email] != "false",
)
end
plugin_params =
DiscoursePluginRegistry.reviewable_params.select do |reviewable_param|
reviewable.type == reviewable_param[:type].to_s.classify
end
args.merge!(params.slice(*plugin_params.map { |pp| pp[:param] }).permit!)
result = reviewable.perform(current_user, params[:action_id].to_sym, args)
rescue Reviewable::InvalidAction => e
if reviewable.type == "ReviewableUser" && !reviewable.pending? && reviewable.target.blank?
raise Discourse::NotFound.new(
e.message,
custom_message: "reviewables.already_handled_and_user_not_exist",
)
else
# Consider InvalidAction an InvalidAccess
raise Discourse::InvalidAccess.new(e.message)
end
rescue Reviewable::UpdateConflict
return render_json_error(I18n.t("reviewables.conflict"), status: 409)
end
if result.success?
render_serialized(result, ReviewablePerformResultSerializer)
else
render_json_error(result)
end
end
def settings
raise Discourse::InvalidAccess.new unless current_user.admin?
post_action_types = PostActionType.where(id: PostActionType.flag_types.values).order("id")
if request.put?
params[:reviewable_priorities].each do |id, priority|
if !priority.nil? && Reviewable.priorities.has_value?(priority.to_i)
# For now, the score bonus is equal to the priority. In the future we might want
# to calculate it a different way.
PostActionType.where(id: id).update_all(
reviewable_priority: priority.to_i,
score_bonus: priority.to_f,
)
end
end
end
data = { reviewable_score_types: post_action_types }
render_serialized(data, ReviewableSettingsSerializer, rest_serializer: true)
end
protected
def claim_error?(reviewable)
return if SiteSetting.reviewable_claiming == "disabled" || reviewable.topic_id.blank?
claimed_by_id = ReviewableClaimedTopic.where(topic_id: reviewable.topic_id).pluck(:user_id)[0]
if SiteSetting.reviewable_claiming == "required" && claimed_by_id.blank?
I18n.t("reviewables.must_claim")
elsif claimed_by_id.present? && claimed_by_id != current_user.id
I18n.t("reviewables.user_claimed")
end
end
def find_reviewable
reviewable = Reviewable.viewable_by(current_user).where(id: params[:reviewable_id]).first
raise Discourse::NotFound.new if reviewable.blank?
reviewable
end
def allowed_statuses
@allowed_statuses ||= (%i[reviewed all] + Reviewable.statuses.symbolize_keys.keys)
end
def version_required
render_json_error(I18n.t("reviewables.missing_version"), status: 422) if params[:version].blank?
end
def meta_types
{ created_by: "user", target_created_by: "user", reviewed_by: "user", claimed_by: "user" }
end
def ensure_can_see
Guardian.new(current_user).ensure_can_see_review_queue!
end
end