docbrown/app/models/notification.rb
Daniel Uber 4b4d8a7234
Ensure arguments to perform_async are json safe (#16285)
* Convert symbol hash keys to strings when calling .perform_async

Fixes a warning from Sidekiq 6.4.0+ about perform_async arguments
which are not equal when passed to perform (`JSON.parse(JSON.dump(arg))`
should equal arg).

This is a safety measure to prevent passing objects (like classes, or
model instances) rather than their representations (like a class name,
or a model's attributes hash).

This warning will be an error in sidekiq 7

* Turn warning into an error in non-production environments

* Use string keys for reaction notification and article fetched

Missed these two on the first pass

* Update example argument hashes for #enqueues_on_correct_queue

Since this calls perform_async under the hood we need to pass json
safe hashes in the test cases as well.

https://github.com/forem/forem/blob/main/spec/workers/shared_examples/enqueues_on_correct_queue.rb

* Convert keys from FollowData#to_h to string before perform_async

I'm not sure enough where else (outside of notification) to_h is being
called, so I'm converting here when building args, rather than in
FollowData#to_h, which might be my next step.

* Let FollowData#to_h return a hash with string keys

Update spec to use string keys as well.

* Make to_h return string keys for ReactionData

Like FollowData, the #to_h method is only used to call
notifications (this is used to enqueue sidekiq jobs).

* Remove a key that was in the hash

Since reaction_data calls to_h, it gets string and not symbol,
keys. Call Hash#except with a key that was actually there.
2022-01-25 18:07:40 -06:00

175 lines
7.1 KiB
Ruby

# @note When we destroy the related user, it's using dependent:
# :delete for the relationship. That means no before/after
# destroy callbacks will be called on this object.
class Notification < ApplicationRecord
belongs_to :notifiable, polymorphic: true
belongs_to :user, optional: true
belongs_to :organization, optional: true
validates :user_id, presence: true, if: proc { |notification| notification.organization_id.nil? }
validates :organization_id, presence: true, if: proc { |notification| notification.user_id.nil? }
validates :user_id, uniqueness: { scope: %i[organization_id notifiable_id notifiable_type action] }
before_create :mark_notified_at_time
scope :for_published_articles, -> { where(notifiable_type: "Article", action: "Published") }
scope :for_comments, -> { where(notifiable_type: "Comment", action: nil) } # nil action means "not a reaction"
scope :for_mentions, -> { where(notifiable_type: "Mention") }
scope :for_organization, ->(org_id) { where(organization_id: org_id, user_id: nil) }
scope :for_organization_comments, lambda { |org_id|
# nil action means "not a reaction"
where(organization_id: org_id, notifiable_type: "Comment", action: nil, user_id: nil)
}
scope :for_organization_mentions, lambda { |org_id|
where(organization_id: org_id, notifiable_type: "Mention", user_id: nil)
}
scope :unread, -> { where(read: false) }
class << self
def send_new_follower_notification(follow, is_read: false)
return unless follow && Follow.need_new_follower_notification_for?(follow.followable_type)
return if follow.followable_type == "User" && UserBlock.blocking?(follow.followable_id, follow.follower_id)
follow_data = Notifications::NewFollower::FollowData.coerce(follow).to_h
Notifications::NewFollowerWorker.perform_async(follow_data, is_read)
end
def send_new_follower_notification_without_delay(follow, is_read: false)
return unless follow && Follow.need_new_follower_notification_for?(follow.followable_type)
return if follow.followable_type == "User" && UserBlock.blocking?(follow.followable_id, follow.follower_id)
follow_data = Notifications::NewFollower::FollowData.coerce(follow).to_h
Notifications::NewFollowerWorker.new.perform(follow_data, is_read)
end
def send_to_mentioned_users_and_followers(notifiable, _action = nil)
return unless notifiable.is_a?(Article) && notifiable.published?
# We need to create associated mentions inline because they need to exist _before_ creating any
# other Article-related notifications. This ensures that users will not receive a second notification for the
# post being published if they have already received an initial notification about being @-mentioned in the post.
Mentions::CreateAll.call(notifiable)
# Kicks off a worker to send any notifications about the post being published, if necessary.
Notification.send_to_followers(notifiable, "Published")
end
def send_to_followers(notifiable, action = nil)
Notifications::NotifiableActionWorker.perform_async(notifiable.id, notifiable.class.name, action)
end
def send_new_comment_notifications_without_delay(comment)
return if comment.commentable_type == "PodcastEpisode"
return if UserBlock.blocking?(comment.commentable.user_id, comment.user_id)
Notifications::NewComment::Send.call(comment)
end
def send_new_badge_achievement_notification(badge_achievement)
Notifications::NewBadgeAchievementWorker.perform_async(badge_achievement.id)
end
def send_reaction_notification(reaction, receiver)
return if reaction.skip_notification_for?(receiver)
return if UserBlock.blocking?(receiver, reaction.user_id)
Notifications::NewReactionWorker.perform_async(*reaction_notification_attributes(reaction, receiver))
end
def send_reaction_notification_without_delay(reaction, receiver)
return if reaction.skip_notification_for?(receiver)
return if UserBlock.blocking?(receiver, reaction.user_id)
Notifications::NewReactionWorker.new.perform(*reaction_notification_attributes(reaction, receiver))
end
def send_mention_notification(mention)
return if MentionDecorator.new(mention).mentioned_by_blocked_user?
Notifications::MentionWorker.perform_async(mention.id)
end
def send_mention_notification_without_delay(mention)
return if MentionDecorator.new(mention).mentioned_by_blocked_user?
Notifications::NewMention::Send.call(mention) if mention
end
def send_welcome_notification(receiver_id, broadcast_id)
Notifications::WelcomeNotificationWorker.perform_async(receiver_id, broadcast_id)
end
def send_moderation_notification(notifiable)
# TODO: make this work for articles in the future. only works for comments right now
return unless notifiable.commentable
return if UserBlock.blocking?(notifiable.commentable.user_id, notifiable.user_id)
Notifications::ModerationNotificationWorker.perform_async(notifiable.id)
end
def send_tag_adjustment_notification(tag_adjustment)
Notifications::TagAdjustmentNotificationWorker.perform_async(tag_adjustment.id)
end
def send_milestone_notification(type:, article_id:)
Notifications::MilestoneWorker.perform_async(type, article_id)
end
def remove_all_by_action_without_delay(notifiable_ids:, notifiable_type:, action: nil)
return unless %w[Article Comment Mention].include?(notifiable_type) && notifiable_ids.present?
Notifications::RemoveAllByAction.call(Array.wrap(notifiable_ids), notifiable_type, action)
end
def remove_all(notifiable_ids:, notifiable_type:)
return unless %w[Article Comment Mention].include?(notifiable_type) && notifiable_ids.present?
Notifications::RemoveAllWorker.perform_async(notifiable_ids, notifiable_type)
end
def remove_all_without_delay(notifiable_ids:, notifiable_type:)
return unless %w[Article Comment Mention].include?(notifiable_type) && notifiable_ids.present?
Notifications::RemoveAllWorker.new.perform(notifiable_ids, notifiable_type)
end
def update_notifications(notifiable, action = nil)
Notifications::UpdateWorker.perform_async(notifiable.id, notifiable.class.name, action)
end
def fast_destroy_old_notifications(destroy_before_timestamp = 3.months.ago)
sql = <<-SQL.squish
DELETE FROM notifications
WHERE notifications.id IN (
SELECT notifications.id
FROM notifications
WHERE created_at < ?
LIMIT 50000
)
SQL
notification_sql = Notification.sanitize_sql([sql, destroy_before_timestamp])
BulkSqlDelete.delete_in_batches(notification_sql)
end
private
def reaction_notification_attributes(reaction, receiver)
reactable_data = Notifications::Reactions::ReactionData.coerce(reaction).to_h
receiver_data = { "klass" => receiver.class.name, "id" => receiver.id }
[reactable_data, receiver_data]
end
end
def aggregated?
action == "Reaction" || action == "Follow"
end
private
def mark_notified_at_time
self.notified_at = Time.current
end
end