docbrown/app/models/reaction.rb
Ben Halpern 9eaff67318
Update styling and refine features for analytics page (#20410)
* Update styling and refine features for analytics page

* Remove follower reference from js

* Update html

* Adjust test
2023-11-30 16:07:16 -05:00

243 lines
9.7 KiB
Ruby

class Reaction < ApplicationRecord
REACTABLE_TYPES = %w[Comment Article User].freeze
STATUSES = %w[valid invalid confirmed archived].freeze
belongs_to :reactable, polymorphic: true
belongs_to :user
counter_culture :reactable,
column_name: proc { |model|
ReactionCategory[model.category].visible_to_public? ? "public_reactions_count" : "reactions_count"
}
counter_culture :user
scope :public_category, lambda {
categories = public_reaction_types
where(category: 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) { only_articles.where(reactable_id: ids) }
scope :only_articles, -> { where(reactable_type: "Article") }
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 :valid_or_confirmed, -> { where(status: %w[valid confirmed]) }
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: ReactionCategory.privileged.map(&:to_s)) }
scope :for_user, ->(user) { where(reactable: user) }
scope :unarchived, -> { where.not(status: "archived") }
scope :from_user, ->(user) { where(user: user) }
scope :readinglist_for_user, ->(user) { readinglist.unarchived.only_articles.from_user(user) }
scope :distinct_categories, -> { select("distinct(reactions.category) as category, reactable_id, reactable_type") }
scope :live_reactable, lambda {
joins("LEFT JOIN articles ON reactions.reactable_id = articles.id AND reactions.reactable_type = 'Article'")
.joins("LEFT JOIN users ON reactions.reactable_id = users.id AND reactions.reactable_type = 'User'")
.where("
CASE
WHEN reactions.reactable_type = 'Article' THEN articles.published = TRUE
WHEN reactions.reactable_type = 'User' THEN users.username NOT LIKE 'spam_%'
ELSE TRUE
END
")
}
validates :category, inclusion: { in: ReactionCategory.all_slugs.map(&:to_s) }
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, unless: :destroyed_by_association
after_commit :async_bust
after_commit :bust_reactable_cache, :update_reactable, on: %i[create update]
after_commit :record_field_test_event, on: %i[create]
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
reaction_types = public_reaction_types
reaction_types << "readinglist" unless public_reaction_types.include?("readinglist")
reaction_types.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
def public_reaction_types
@public_reaction_types ||= ReactionCategory.public.map(&:to_s) - ["readinglist"]
end
def for_analytics
reaction_types = public_reaction_types
reaction_types << "readinglist" unless public_reaction_types.include?("readinglist")
where(category: reaction_types)
end
# @param user [User] the user who might be spamming the system
# @param threshold [Integer] the number of strikes before they are spam
# @param include_user_profile [Boolean] do we include the user's profile as part of the "check
# for spamminess"
#
# @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, include_user_profile: false)
threshold -= 1 if include_user_profile && user_has_spammy_profile_reaction?(user: user)
article_vomits.where(reactable_id: user.articles.ids).size > threshold
end
# @param user [User] the user who might be spamming the system
# @param threshold [Integer] the number of strikes before they are spam
# @param include_user_profile [Boolean] do we include the user's profile as part of the "check
# for spamminess"
#
# @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, include_user_profile: false)
threshold -= 1 if include_user_profile && user_has_spammy_profile_reaction?(user: user)
comment_vomits.where(reactable_id: user.comments.ids).size > threshold
end
# @param user [User] the user who might be spamming the system
def user_has_spammy_profile_reaction?(user:)
user_vomits.exists?(reactable_id: user.id)
end
# @param category [String] the reaction category type, see the CATEGORIES var
# @param reactable_id [Boolean] the ID of the item that was reacted on
# @param reactable_type [String] the type of the item, see the REACTABLE_TYPES var
# @param user [User] a moderator user
# @return [Array] Reactions that contain a contradictory category to the category that was passed in,
# example, if we pass in a "thumbsup", then we return reactions that have have a thumbsdown or vomit
def contradictory_mod_reactions(category:, reactable_id:, reactable_type:, user:)
negatives = ReactionCategory.negative_privileged.map(&:to_s)
contradictory_category = negatives if category == "thumbsup"
contradictory_category = "thumbsup" if category.in?(negatives)
Reaction.where(reactable_id: reactable_id,
reactable_type: reactable_type,
user: user,
category: contradictory_category)
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
# - reaction is not in a category that should be notified
def skip_notification_for?(_receiver)
reactor_id = case reactable
when User
reactable.id
else
reactable.user_id
end
!should_notify? || (status == "invalid") || points.negative? || (user_id == reactor_id)
end
def reaction_on_organization_article?
reactable_type == "Article" && reactable.organization.present?
end
def target_user
reactable_type == "User" ? reactable : reactable.user
end
delegate :negative?, :positive?, :visible_to_public?, to: :reaction_category, allow_nil: true
def reaction_category
ReactionCategory[category.to_sym]
end
def readable_date
if created_at.year == Time.current.year
I18n.l(created_at, format: :short)
else
I18n.l(created_at, format: :short_with_yy)
end
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 assign_points
self.points = CalculateReactionPoints.call(self)
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 false 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
# @see AbExperiment::GoalConversionHandler
def record_field_test_event
# TODO: Remove once we know that this test is not over-heating the application. That would be a
# few days after the deploy to DEV of this change.
return unless FeatureFlag.accessible?(:field_test_event_for_reactions)
return if FieldTest.config["experiments"].nil?
return unless visible_to_public?
return unless reactable.is_a?(Article)
return unless user_id
Users::RecordFieldTestEventWorker
.perform_async(user_id, AbExperiment::GoalConversionHandler::USER_CREATES_ARTICLE_REACTION_GOAL)
end
def should_notify?
ReactionCategory.notifiable.include?(category.to_sym)
end
end