docbrown/app/models/reaction.rb
Jeremy Friesen 8a4e1ebeba
Adding more goals to feed experiments (#17673)
This commit adds three new feed experiment goals:

* user publishes four posts within a week
* user reacts with a "heart", "unicorn", or "reading list" to an article
* user reacts with a "heart", "unicorn", or "reading list" to an article
  four times in a week

In addition it adds it to the existing experiment.  Adding it to the
existing experiment is acceptable because:

1. We can ignore the results
2. The experiments are structured such that these new goals could be met
   with prior data.
3. Both variants are playing by the same rules, so the results relative
   to each other are valid.

**There is a nuanced assumption in how we handle reactions:**

This implementation does not count by unique article reactions. However,
most folks will ❤️ , 🦄 , and 🔖 in one swoop; thus
those 3 reactions are all grouped into happening on one day.

Further, it's a reflection of the person taking an action after reading
a post, not how much action on that particular post.

**Rollback considerations:**

This also includes a feature flag that we can explicitly disable if we
overload the application with workers handling reaction goals.  Using
`FeatureFlag.accessible?(:field_test_event_for_reactions)` returns
`true` unless we explicitly disable this flag.

Closes forem/forem#17669
2022-05-13 13:22:06 -04:00

228 lines
8.2 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
NEGATIVE_PRIVILEGED_CATEGORIES = %w[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) { 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 :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]
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
%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?
NEGATIVE_PRIVILEGED_CATEGORIES.include?(category)
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
# @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 PUBLIC_CATEGORIES.include?(category)
return unless reactable.is_a?(Article)
return unless user_id
Users::RecordFieldTestEventWorker
.perform_async(user_id, AbExperiment::GoalConversionHandler::USER_CREATES_ARTICLE_REACTION_GOAL)
end
end