-
Notifications
You must be signed in to change notification settings - Fork 4k
/
reaction.rb
243 lines (204 loc) · 9.75 KB
/
reaction.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
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