require_dependency 'rate_limiter' require_dependency 'system_message' class PostAction < ActiveRecord::Base class AlreadyActed < StandardError; end include RateLimiter::OnCreateRecord include Trashable belongs_to :post belongs_to :user belongs_to :post_action_type belongs_to :related_post, class_name: 'Post' belongs_to :target_user, class_name: 'User' rate_limit :post_action_rate_limiter scope :spam_flags, -> { where(post_action_type_id: PostActionType.types[:spam]) } scope :flags, -> { where(post_action_type_id: PostActionType.notify_flag_type_ids) } scope :publics, -> { where(post_action_type_id: PostActionType.public_type_ids) } scope :active, -> { where(disagreed_at: nil, defered_at: nil, agreed_at: nil, deleted_at: nil) } after_save :update_counters after_save :enforce_rules after_commit :notify_subscribers def disposed_by_id disagreed_by_id || agreed_by_id || defered_by_id end def disposed_at disagreed_at || agreed_at || defered_at end def disposition return :disagreed if disagreed_at return :agreed if agreed_at return :defered if defered_at nil end def self.update_flagged_posts_count posts_flagged_count = PostAction.active .flags .joins(post: :topic) .where('posts.deleted_at' => nil) .where('topics.deleted_at' => nil) .count('DISTINCT posts.id') $redis.set('posts_flagged_count', posts_flagged_count) user_ids = User.staff.pluck(:id) MessageBus.publish('/flagged_counts', { total: posts_flagged_count }, { user_ids: user_ids }) end def self.flagged_posts_count $redis.get('posts_flagged_count').to_i end def self.counts_for(collection, user) return {} if collection.blank? collection_ids = collection.map(&:id) user_id = user.try(:id) || 0 post_actions = PostAction.where(post_id: collection_ids, user_id: user_id) user_actions = {} post_actions.each do |post_action| user_actions[post_action.post_id] ||= {} user_actions[post_action.post_id][post_action.post_action_type_id] = post_action end user_actions end def self.count_per_day_for_type(post_action_type, since_days_ago=30) unscoped.where(post_action_type_id: post_action_type) .where('created_at > ?', since_days_ago.days.ago) .group('date(created_at)') .order('date(created_at)') .count end def self.agree_flags!(post, moderator, delete_post=false) actions = PostAction.active .where(post_id: post.id) .where(post_action_type_id: PostActionType.flag_types.values) actions.each do |action| action.agreed_at = Time.zone.now action.agreed_by_id = moderator.id # so callback is called action.save action.add_moderator_post_if_needed(moderator, :agreed, delete_post) end update_flagged_posts_count end def self.clear_flags!(post, moderator) # -1 is the automatic system cleary action_type_ids = moderator.id == -1 ? PostActionType.auto_action_flag_types.values : PostActionType.flag_types.values actions = PostAction.where(post_id: post.id) .where(post_action_type_id: action_type_ids) actions.each do |action| action.disagreed_at = Time.zone.now action.disagreed_by_id = moderator.id # so callback is called action.save action.add_moderator_post_if_needed(moderator, :disagreed) end # reset all cached counters f = action_type_ids.map { |t| ["#{PostActionType.types[t]}_count", 0] } Post.with_deleted.where(id: post.id).update_all(Hash[*f.flatten]) update_flagged_posts_count end def self.defer_flags!(post, moderator, delete_post=false) actions = PostAction.active .where(post_id: post.id) .where(post_action_type_id: PostActionType.flag_types.values) actions.each do |action| action.defered_at = Time.zone.now action.defered_by_id = moderator.id # so callback is called action.save action.add_moderator_post_if_needed(moderator, :defered, delete_post) end update_flagged_posts_count end def add_moderator_post_if_needed(moderator, disposition, delete_post=false) return unless related_post message_key = "flags_dispositions.#{disposition}" message_key << "_and_deleted" if delete_post related_post.topic.add_moderator_post(moderator, I18n.t(message_key)) end def self.create_message_for_post_action(user, post, post_action_type_id, opts) post_action_type = PostActionType.types[post_action_type_id] return unless opts[:message] && [:notify_moderators, :notify_user].include?(post_action_type) title = I18n.t("post_action_types.#{post_action_type}.email_title", title: post.topic.title) body = I18n.t("post_action_types.#{post_action_type}.email_body", message: opts[:message], link: "#{Discourse.base_url}#{post.url}") opts = { archetype: Archetype.private_message, title: title, raw: body } if post_action_type == :notify_moderators opts[:subtype] = TopicSubtype.notify_moderators opts[:target_group_names] = "moderators" else opts[:subtype] = TopicSubtype.notify_user opts[:target_usernames] = if post_action_type == :notify_user post.user.username elsif post_action_type != :notify_moderators # this is a hack to allow a PM with no reciepients, we should think through # a cleaner technique, a PM with myself is valid for flagging 'x' end end PostCreator.new(user, opts).create.id end def self.act(user, post, post_action_type_id, opts={}) related_post_id = create_message_for_post_action(user, post, post_action_type_id, opts) staff_took_action = opts[:take_action] || false targets_topic = if opts[:flag_topic] && post.topic post.topic.reload post.topic.posts_count != 1 end where_attrs = { post_id: post.id, user_id: user.id, post_action_type_id: post_action_type_id } action_attributes = { staff_took_action: staff_took_action, related_post_id: related_post_id, targets_topic: !!targets_topic } # First try to revive a trashed record row_count = PostAction.where(where_attrs) .with_deleted .where("deleted_at IS NOT NULL") .update_all(action_attributes.merge(deleted_at: nil)) if row_count == 0 post_action = create(where_attrs.merge(action_attributes)) if post_action && post_action.errors.count == 0 BadgeGranter.queue_badge_grant(Badge::Trigger::PostAction, post_action: post_action) end else post_action = PostAction.where(where_attrs).first end # agree with other flags PostAction.agree_flags!(post, user) if staff_took_action # update counters post_action.try(:update_counters) post_action rescue ActiveRecord::RecordNotUnique # can happen despite being .create # since already bookmarked true end def self.remove_act(user, post, post_action_type_id) finder = PostAction.where(post_id: post.id, user_id: user.id, post_action_type_id: post_action_type_id) finder = finder.with_deleted if user.try(:staff?) if action = finder.first action.remove_act!(user) end end def remove_act!(user) trash!(user) # NOTE: save is called to ensure all callbacks are called # trash will not trigger callbacks, and triggering after_commit # is not trivial save end def is_bookmark? post_action_type_id == PostActionType.types[:bookmark] end def is_like? post_action_type_id == PostActionType.types[:like] end def is_flag? PostActionType.flag_types.values.include?(post_action_type_id) end def is_private_message? post_action_type_id == PostActionType.types[:notify_user] || post_action_type_id == PostActionType.types[:notify_moderators] end # A custom rate limiter for this model def post_action_rate_limiter return unless is_flag? || is_bookmark? || is_like? return @rate_limiter if @rate_limiter.present? %w(like flag bookmark).each do |type| if send("is_#{type}?") @rate_limiter = RateLimiter.new(user, "create_#{type}:#{Date.today.to_s}", SiteSetting.send("max_#{type}s_per_day"), 1.day.to_i) return @rate_limiter end end end before_create do post_action_type_ids = is_flag? ? PostActionType.flag_types.values : post_action_type_id raise AlreadyActed if PostAction.where(user_id: user_id) .where(post_id: post_id) .where(post_action_type_id: post_action_type_ids) .where(deleted_at: nil) .where(disagreed_at: nil) .where(targets_topic: targets_topic) .exists? end # Returns the flag counts for a post, taking into account that some users # can weigh flags differently. def self.flag_counts_for(post_id) flag_counts = exec_sql("SELECT SUM(CASE WHEN pa.disagreed_at IS NULL AND pa.staff_took_action THEN :flags_required_to_hide_post WHEN pa.disagreed_at IS NULL AND NOT pa.staff_took_action THEN 1 ELSE 0 END) AS new_flags, SUM(CASE WHEN pa.disagreed_at IS NOT NULL AND pa.staff_took_action THEN :flags_required_to_hide_post WHEN pa.disagreed_at IS NOT NULL AND NOT pa.staff_took_action THEN 1 ELSE 0 END) AS old_flags FROM post_actions AS pa INNER JOIN users AS u ON u.id = pa.user_id WHERE pa.post_id = :post_id AND pa.post_action_type_id IN (:post_action_types) AND pa.deleted_at IS NULL", post_id: post_id, post_action_types: PostActionType.auto_action_flag_types.values, flags_required_to_hide_post: SiteSetting.flags_required_to_hide_post).first [flag_counts['old_flags'].to_i, flag_counts['new_flags'].to_i] end def post_action_type_key PostActionType.types[post_action_type_id] end def update_counters # Update denormalized counts column = "#{post_action_type_key.to_s}_count" count = PostAction.where(post_id: post_id) .where(post_action_type_id: post_action_type_id) .count # We probably want to refactor this method to something cleaner. case post_action_type_key when :vote # Voting also changes the sort_order Post.where(id: post_id).update_all ["vote_count = :count, sort_order = :max - :count", count: count, max: Topic.max_sort_order] when :like # `like_score` is weighted higher for staff accounts score = PostAction.joins(:user) .where(post_id: post_id) .sum("CASE WHEN users.moderator OR users.admin THEN #{SiteSetting.staff_like_weight} ELSE 1 END") Post.where(id: post_id).update_all ["like_count = :count, like_score = :score", count: count, score: score] else Post.where(id: post_id).update_all ["#{column} = ?", count] end topic_id = Post.with_deleted.where(id: post_id).pluck(:topic_id).first topic_count = Post.where(topic_id: topic_id).sum(column) Topic.where(id: topic_id).update_all ["#{column} = ?", topic_count] if PostActionType.notify_flag_type_ids.include?(post_action_type_id) PostAction.update_flagged_posts_count end end def enforce_rules post = Post.with_deleted.where(id: post_id).first PostAction.auto_hide_if_needed(post, post_action_type_key) SpamRulesEnforcer.enforce!(post.user) if post_action_type_key == :spam end def notify_subscribers if (is_like? || is_flag?) && post MessageBus.publish("/topic/#{post.topic_id}",{ id: post.id, post_number: post.post_number, type: "acted" }, group_ids: post.topic.secure_group_ids ) end end def self.auto_hide_if_needed(post, post_action_type) return if post.hidden if PostActionType.auto_action_flag_types.include?(post_action_type) && SiteSetting.flags_required_to_hide_post > 0 old_flags, new_flags = PostAction.flag_counts_for(post.id) if new_flags >= SiteSetting.flags_required_to_hide_post hide_post!(post, post_action_type, guess_hide_reason(old_flags)) end end end def self.hide_post!(post, post_action_type, reason=nil) return if post.hidden unless reason old_flags,_ = PostAction.flag_counts_for(post.id) reason = guess_hide_reason(old_flags) end Post.where(id: post.id).update_all(["hidden = true, hidden_at = CURRENT_TIMESTAMP, hidden_reason_id = COALESCE(hidden_reason_id, ?)", reason]) Topic.where(["id = :topic_id AND NOT EXISTS(SELECT 1 FROM POSTS WHERE topic_id = :topic_id AND NOT hidden)", topic_id: post.topic_id]).update_all(visible: false) # inform user if post.user options = { url: post.url, edit_delay: SiteSetting.cooldown_minutes_after_hiding_posts, flag_reason: I18n.t("flag_reasons.#{post_action_type}"), } SystemMessage.create(post.user, :post_hidden, options) end end def self.guess_hide_reason(old_flags) old_flags > 0 ? Post.hidden_reasons[:flag_threshold_reached_again] : Post.hidden_reasons[:flag_threshold_reached] end def self.post_action_type_for_post(post_id) post_action = PostAction.find_by(defered_at: nil, post_id: post_id, post_action_type_id: PostActionType.flag_types.values, deleted_at: nil) PostActionType.types[post_action.post_action_type_id] end protected def self.target_moderators Group[:moderators].name end end # == Schema Information # # Table name: post_actions # # id :integer not null, primary key # post_id :integer not null # user_id :integer not null # post_action_type_id :integer not null # deleted_at :datetime # created_at :datetime # updated_at :datetime # deleted_by_id :integer # related_post_id :integer # staff_took_action :boolean default(FALSE), not null # defered_by_id :integer # targets_topic :boolean default(FALSE) # agreed_at :datetime # agreed_by_id :integer # defered_at :datetime # disagreed_at :datetime # disagreed_by_id :integer # # Indexes # # idx_unique_actions (user_id,post_action_type_id,post_id,deleted_at,targets_topic) UNIQUE # index_post_actions_on_post_id (post_id) #