class UserAction < ActiveRecord::Base belongs_to :user belongs_to :target_post, class_name: "Post" belongs_to :target_topic, class_name: "Topic" validates_presence_of :action_type validates_presence_of :user_id LIKE = 1 WAS_LIKED = 2 BOOKMARK = 3 NEW_TOPIC = 4 REPLY = 5 RESPONSE= 6 MENTION = 7 QUOTE = 9 STAR = 10 EDIT = 11 NEW_PRIVATE_MESSAGE = 12 GOT_PRIVATE_MESSAGE = 13 ORDER = Hash[*[ GOT_PRIVATE_MESSAGE, NEW_PRIVATE_MESSAGE, NEW_TOPIC, REPLY, RESPONSE, LIKE, WAS_LIKED, MENTION, QUOTE, BOOKMARK, STAR, EDIT ].each_with_index.to_a.flatten] # note, this is temporary until we upgrade to rails 4 # in rails 4 types are mapped correctly so you dont end up # having strings where you would expect bools class UserActionRow < OpenStruct include ActiveModel::SerializerSupport end def self.last_action_in_topic(user_id, topic_id) UserAction.where(user_id: user_id, target_topic_id: topic_id, action_type: [RESPONSE, MENTION, QUOTE]).order('created_at DESC').pluck(:target_post_id).first end def self.stats(user_id, guardian) # Sam: I tried this in AR and it got complex builder = UserAction.sql_builder < ORDER[b.action_type] } results end def self.private_messages_stats(user_id, guardian) return unless guardian.can_see_private_messages?(user_id) # list the stats for: all/mine/unread (topic-based) private_messages = Topic.where("topics.id IN (SELECT topic_id FROM topic_allowed_users WHERE user_id = #{user_id})") .joins("LEFT OUTER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.user_id = #{user_id})") .private_messages all = private_messages.count mine = private_messages.where(user_id: user_id).count unread = private_messages.where("tu.last_read_post_number IS NULL OR tu.last_read_post_number < topics.highest_post_number").count { all: all, mine: mine, unread: unread } end def self.stream_item(action_id, guardian) stream(action_id: action_id, guardian: guardian).first end def self.stream(opts={}) user_id = opts[:user_id] offset = opts[:offset] || 0 limit = opts[:limit] || 60 action_id = opts[:action_id] action_types = opts[:action_types] guardian = opts[:guardian] ignore_private_messages = opts[:ignore_private_messages] # The weird thing is that target_post_id can be null, so it makes everything # ever so more complex. Should we allow this, not sure. builder = SqlBuilder.new(" SELECT a.id, t.title, a.action_type, a.created_at, t.id topic_id, a.user_id AS target_user_id, au.name AS target_name, au.username AS target_username, coalesce(p.post_number, 1) post_number, p.reply_to_post_number, pu.email, pu.username, pu.name, pu.id user_id, pu.use_uploaded_avatar, pu.uploaded_avatar_template, pu.uploaded_avatar_id, u.email acting_email, u.username acting_username, u.name acting_name, u.id acting_user_id, u.use_uploaded_avatar acting_use_uploaded_avatar, u.uploaded_avatar_template acting_uploaded_avatar_template, u.uploaded_avatar_id acting_uploaded_avatar_id, coalesce(p.cooked, p2.cooked) cooked, CASE WHEN coalesce(p.deleted_at, p2.deleted_at, t.deleted_at) IS NULL THEN false ELSE true END deleted, p.hidden, p.post_type, p.edit_reason FROM user_actions as a JOIN topics t on t.id = a.target_topic_id LEFT JOIN posts p on p.id = a.target_post_id JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1 JOIN users u on u.id = a.acting_user_id JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id) JOIN users au on au.id = a.user_id LEFT JOIN categories c on c.id = t.category_id /*where*/ /*order_by*/ /*offset*/ /*limit*/ ") apply_common_filters(builder, user_id, guardian, ignore_private_messages) if action_id builder.where("a.id = :id", id: action_id.to_i) else builder.where("a.user_id = :user_id", user_id: user_id.to_i) builder.where("a.action_type in (:action_types)", action_types: action_types) if action_types && action_types.length > 0 builder .order_by("a.created_at desc") .offset(offset.to_i) .limit(limit.to_i) end builder.map_exec(UserActionRow) end def self.log_action!(hash) required_parameters = [:action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id] require_parameters(hash, *required_parameters) transaction(requires_new: true) do begin # TODO there are conditions when this is called and user_id was already rolled back and is invalid. # protect against dupes, for some reason this is failing in some cases action = self.find_by(hash.select do |k, v| required_parameters.include?(k) end) return action if action action = self.new(hash) if hash[:created_at] action.created_at = hash[:created_at] end action.save! user_id = hash[:user_id] update_like_count(user_id, hash[:action_type], 1) topic = Topic.includes(:category).find_by(id: hash[:target_topic_id]) # move into Topic perhaps group_ids = nil if topic && topic.category && topic.category.read_restricted group_ids = topic.category.groups.pluck("groups.id") end if action.user MessageBus.publish("/users/#{action.user.username.downcase}", action.id, user_ids: [user_id], group_ids: group_ids ) end action rescue ActiveRecord::RecordNotUnique # can happen, don't care already logged raise ActiveRecord::Rollback end end end def self.remove_action!(hash) require_parameters(hash, :action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id) if action = UserAction.find_by(hash.except(:created_at)) action.destroy MessageBus.publish("/user/#{hash[:user_id]}", {user_action_id: action.id, remove: true}) end update_like_count(hash[:user_id], hash[:action_type], -1) end def self.synchronize_target_topic_ids(post_ids = nil) # nuke all dupes, using magic builder = SqlBuilder.new < 0 AND user_actions.id > ua2.id SQL if post_ids builder.where("user_actions.target_post_id in (:post_ids)", post_ids: post_ids) end builder.exec builder = SqlBuilder.new("UPDATE user_actions SET target_topic_id = (select topic_id from posts where posts.id = target_post_id) /*where*/") builder.where("target_topic_id <> (select topic_id from posts where posts.id = target_post_id)") if post_ids builder.where("target_post_id in (:post_ids)", post_ids: post_ids) end builder.exec end def self.synchronize_starred exec_sql(" DELETE FROM user_actions ua WHERE action_type = :star AND NOT EXISTS ( SELECT 1 FROM topic_users tu WHERE tu.user_id = ua.user_id AND tu.topic_id = ua.target_topic_id AND starred )", star: UserAction::STAR) exec_sql("INSERT INTO user_actions (action_type, user_id, target_topic_id, target_post_id, acting_user_id, created_at, updated_at) SELECT :star, tu.user_id, tu.topic_id, -1, tu.user_id, tu.starred_at, tu.starred_at FROM topic_users tu WHERE starred AND NOT EXISTS( SELECT 1 FROM user_actions ua WHERE tu.user_id = ua.user_id AND tu.topic_id = ua.target_topic_id AND ua.action_type = :star ) ", star: UserAction::STAR) end def self.ensure_consistency! self.synchronize_target_topic_ids self.synchronize_starred end protected def self.update_like_count(user_id, action_type, delta) if action_type == LIKE UserStat.where(user_id: user_id).update_all("likes_given = likes_given + #{delta.to_i}") elsif action_type == WAS_LIKED UserStat.where(user_id: user_id).update_all("likes_received = likes_received + #{delta.to_i}") end end def self.apply_common_filters(builder,user_id,guardian,ignore_private_messages=false) # We never return deleted topics in activity builder.where("t.deleted_at is null") # We will return deleted posts though if the user can see it unless guardian.can_see_deleted_posts? builder.where("p.deleted_at is null and p2.deleted_at is null") current_user_id = -2 current_user_id = guardian.user.id if guardian.user builder.where("NOT COALESCE(p.hidden, false) OR p.user_id = :current_user_id", current_user_id: current_user_id ) end unless (guardian.user && guardian.user.id == user_id) || guardian.is_staff? builder.where("a.action_type not in (#{BOOKMARK},#{STAR})") end if !guardian.can_see_private_messages?(user_id) || ignore_private_messages builder.where("t.archetype != :archetype", archetype: Archetype::private_message) end unless guardian.is_admin? allowed = guardian.secure_category_ids if allowed.present? builder.where("( c.read_restricted IS NULL OR NOT c.read_restricted OR (c.read_restricted and c.id in (:cats)) )", cats: guardian.secure_category_ids ) else builder.where("(c.read_restricted IS NULL OR NOT c.read_restricted)") end end end def self.require_parameters(data, *params) params.each do |p| raise Discourse::InvalidParameters.new(p) if data[p].nil? end end end # == Schema Information # # Table name: user_actions # # id :integer not null, primary key # action_type :integer not null # user_id :integer not null # target_topic_id :integer # target_post_id :integer # target_user_id :integer # acting_user_id :integer # created_at :datetime not null # updated_at :datetime not null # # Indexes # # idx_unique_rows (action_type,user_id,target_topic_id,target_post_id,acting_user_id) UNIQUE # index_actions_on_acting_user_id (acting_user_id) # index_actions_on_user_id_and_action_type (user_id,action_type) #