docbrown/app/models/reaction.rb
Daniel Uber c8515967ff
Don't send notification for invalid reactions (#16721)
* Don't send notification for invalid reactions

When a moderator flags a user,
and an admin marks this invalid
and the moderator tries to flag the user again
then destroy the existing reaction, and check whether to notify

Since the points for invalid reactions are set to 0, this was not being
skipped. Now it will be.

* cleanup test cases for reaction skip notification
2022-02-28 12:36:44 -06:00

210 lines
7.4 KiB
Ruby

class Reaction < ApplicationRecord
BASE_POINTS = {
"vomit" => -50.0,
"thumbsup" => 5.0,
"thumbsdown" => -10.0
}.freeze
# The union of public and privileged categories
CATEGORIES = %w[like readinglist unicorn thinking hands thumbsup thumbsdown vomit].freeze
# These are the general category of reactions that anyone can choose
PUBLIC_CATEGORIES = %w[like readinglist unicorn thinking hands].freeze
# These are categories of reactions that administrators can select
PRIVILEGED_CATEGORIES = %w[thumbsup thumbsdown vomit].freeze
REACTABLE_TYPES = %w[Comment Article User].freeze
STATUSES = %w[valid invalid confirmed archived].freeze
# Days to ramp up new user points weight
NEW_USER_RAMPUP_DAYS_COUNT = 10
belongs_to :reactable, polymorphic: true
belongs_to :user
counter_culture :reactable,
column_name: proc { |model|
PUBLIC_CATEGORIES.include?(model.category) ? "public_reactions_count" : "reactions_count"
}
counter_culture :user
scope :public_category, -> { where(category: PUBLIC_CATEGORIES) }
# Be wary, this is all things on the reading list, but for an end
# user they might only see readinglist items that are published.
# See https://github.com/forem/forem/issues/14796
scope :readinglist, -> { where(category: "readinglist") }
scope :for_articles, ->(ids) { where(reactable_type: "Article", reactable_id: ids) }
scope :eager_load_serialized_data, -> { includes(:reactable, :user) }
scope :article_vomits, -> { where(category: "vomit", reactable_type: "Article") }
scope :comment_vomits, -> { where(category: "vomit", reactable_type: "Comment") }
scope :user_vomits, -> { where(category: "vomit", reactable_type: "User") }
scope :related_negative_reactions_for_user, lambda { |user|
article_vomits.where(reactable_id: user.article_ids)
.or(comment_vomits.where(reactable_id: user.comment_ids))
.or(user_vomits.where(user_id: user.id))
}
scope :privileged_category, -> { where(category: PRIVILEGED_CATEGORIES) }
scope :for_user, ->(user) { where(reactable: user) }
validates :category, inclusion: { in: CATEGORIES }
validates :reactable_type, inclusion: { in: REACTABLE_TYPES }
validates :status, inclusion: { in: STATUSES }
validates :user_id, uniqueness: { scope: %i[reactable_id reactable_type category] }
validate :permissions
before_save :assign_points
after_create :notify_slack_channel_about_vomit_reaction, if: -> { category == "vomit" }
before_destroy :bust_reactable_cache_without_delay
before_destroy :update_reactable_without_delay, unless: :destroyed_by_association
after_commit :async_bust
after_commit :bust_reactable_cache, :update_reactable, on: %i[create update]
class << self
def count_for_article(id)
Rails.cache.fetch("count_for_reactable-Article-#{id}", expires_in: 10.hours) do
reactions = Reaction.where(reactable_id: id, reactable_type: "Article")
counts = reactions.group(:category).count
%w[like readinglist unicorn].map do |type|
{ category: type, count: counts.fetch(type, 0) }
end
end
end
def cached_any_reactions_for?(reactable, user, category)
class_name = reactable.instance_of?(ArticleDecorator) ? "Article" : reactable.class.name
cache_name = "any_reactions_for-#{class_name}-#{reactable.id}-" \
"#{user.reactions_count}-#{user.public_reactions_count}-#{category}"
Rails.cache.fetch(cache_name, expires_in: 24.hours) do
Reaction.where(reactable_id: reactable.id, reactable_type: class_name, user: user, category: category).any?
end
end
# @param user [User] the user who might be spamming the system
#
# @return [TrueClass] yup, they're spamming the system.
# @return [FalseClass] they're not (yet) spamming the system
def user_has_been_given_too_many_spammy_article_reactions?(user:, threshold: 2)
article_vomits.where(reactable_id: user.articles.ids).size > threshold
end
# @param user [User] the user who might be spamming the system
#
# @return [TrueClass] yup, they're spamming the system.
# @return [FalseClass] they're not (yet) spamming the system
def user_has_been_given_too_many_spammy_comment_reactions?(user:, threshold: 2)
comment_vomits.where(reactable_id: user.comments.ids).size > threshold
end
end
# no need to send notification if:
# - reaction is negative
# - receiver is the same user as the one who reacted
# - reaction status is marked invalid
def skip_notification_for?(_receiver)
reactor_id = case reactable
when User
reactable.id
else
reactable.user_id
end
(status == "invalid") || points.negative? || (user_id == reactor_id)
end
def vomit_on_user?
reactable_type == "User" && category == "vomit"
end
def reaction_on_organization_article?
reactable_type == "Article" && reactable.organization.present?
end
def target_user
reactable_type == "User" ? reactable : reactable.user
end
def negative?
category == "vomit" || category == "thumbsdown"
end
private
def update_reactable
Reactions::UpdateRelevantScoresWorker.perform_async(id)
end
def bust_reactable_cache
Reactions::BustReactableCacheWorker.perform_async(id)
end
def async_bust
Reactions::BustHomepageCacheWorker.perform_async(id)
end
def bust_reactable_cache_without_delay
Reactions::BustReactableCacheWorker.new.perform(id)
end
def update_reactable_without_delay
Reactions::UpdateRelevantScoresWorker.new.perform(id)
end
def reading_time
reactable.reading_time if category == "readinglist"
end
def viewable_by
user_id
end
def assign_points
base_points = BASE_POINTS.fetch(category, 1.0)
# Ajust for certain states
base_points = 0 if status == "invalid"
base_points /= 2 if reactable_type == "User"
base_points *= 2 if status == "confirmed"
unless persisted? # Actions we only want to apply upon initial creation
# Author's comment reaction counts for more weight on to their own posts. (5.0 vs 1.0)
base_points *= 5 if positive_reaction_to_comment_on_own_article?
# New users will have their reaction weight gradually ramp by 0.1 from 0 to 1.0.
base_points *= new_user_adjusted_points if new_untrusted_user # New users get minimal reaction weight
end
self.points = user ? (base_points * user.reputation_modifier) : -5
end
def permissions
errors.add(:category, I18n.t("models.reaction.is_not_valid")) if negative_reaction_from_untrusted_user?
return unless reactable_type == "Article" && !reactable&.published
errors.add(:reactable_id, I18n.t("models.reaction.is_not_valid"))
end
def negative_reaction_from_untrusted_user?
return if user&.any_admin? || user&.id == Settings::General.mascot_user_id
negative? && !user.trusted?
end
def notify_slack_channel_about_vomit_reaction
Slack::Messengers::ReactionVomit.call(reaction: self)
end
def positive_reaction_to_comment_on_own_article?
BASE_POINTS.fetch(category, 1.0).positive? &&
reactable_type == "Comment" &&
reactable&.commentable&.user_id == user_id
end
def new_user_adjusted_points
((Time.current - user.registered_at).seconds.in_days / NEW_USER_RAMPUP_DAYS_COUNT)
end
def new_untrusted_user
user.registered_at > NEW_USER_RAMPUP_DAYS_COUNT.days.ago && !user.trusted? && !user.any_admin?
end
end