# frozen_string_literal: true class UserAction < ActiveRecord::Base belongs_to :user belongs_to :acting_user, class_name: "User" belongs_to :target_user, class_name: "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 NEW_TOPIC = 4 REPLY = 5 RESPONSE = 6 MENTION = 7 QUOTE = 9 EDIT = 11 NEW_PRIVATE_MESSAGE = 12 GOT_PRIVATE_MESSAGE = 13 SOLVED = 15 ASSIGNED = 16 LINKED = 17 ORDER = Hash[ *[ GOT_PRIVATE_MESSAGE, NEW_PRIVATE_MESSAGE, NEW_TOPIC, REPLY, RESPONSE, LIKE, WAS_LIKED, MENTION, QUOTE, EDIT, SOLVED, ASSIGNED, LINKED, ].each_with_index.to_a.flatten ] USER_ACTED_TYPES = [LIKE, NEW_TOPIC, REPLY, NEW_PRIVATE_MESSAGE] def self.types @types ||= Enum.new( like: 1, was_liked: 2, # NOTE: Previously type 3 was bookmark but this was removed when we # changed to using the Bookmark model. new_topic: 4, reply: 5, response: 6, mention: 7, quote: 9, edit: 11, new_private_message: 12, got_private_message: 13, solved: 15, assigned: 16, linked: 17, ) end def self.private_types @private_types ||= [WAS_LIKED, RESPONSE, MENTION, QUOTE, EDIT] 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") .pick(:target_post_id) end def self.stats(user_id, guardian) # Sam: I tried this in AR and it got complex builder = DB.build <<~SQL SELECT action_type, COUNT(*) count FROM user_actions a LEFT JOIN topics t ON t.id = a.target_topic_id LEFT JOIN posts p on p.id = a.target_post_id LEFT JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1 LEFT JOIN categories c ON c.id = t.category_id /*where*/ GROUP BY action_type SQL builder.where("a.user_id = :user_id", user_id: user_id) apply_common_filters(builder, user_id, guardian) results = builder.query results.sort! { |a, b| ORDER[a.action_type] <=> 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/groups (topic-based) sql = <<-SQL SELECT COUNT(*) "all" , SUM(CASE WHEN t.user_id = :user_id THEN 1 ELSE 0 END) "mine" , SUM(CASE WHEN tu.last_read_post_number IS NULL OR tu.last_read_post_number < t.highest_post_number THEN 1 ELSE 0 END) "unread" FROM topics t LEFT JOIN topic_users tu ON t.id = tu.topic_id AND tu.user_id = :user_id WHERE t.deleted_at IS NULL AND t.archetype = 'private_message' AND t.id IN (SELECT topic_id FROM topic_allowed_users WHERE user_id = :user_id) SQL # map is there due to count returning nil all, mine, unread = DB.query_single(sql, user_id: user_id).map(&:to_i) sql = <<-SQL SELECT g.name, COUNT(*) "count" FROM topics t JOIN topic_allowed_groups tg ON topic_id = t.id JOIN group_users gu ON gu.user_id = :user_id AND gu.group_id = tg.group_id JOIN groups g ON g.id = gu.group_id WHERE deleted_at IS NULL AND archetype = 'private_message' GROUP BY g.name SQL result = { all: all, mine: mine, unread: unread } DB .query(sql, user_id: user_id) .each { |row| (result[:groups] ||= []) << { name: row.name, count: row.count.to_i } } result end def self.count_daily_engaged_users(start_date = nil, end_date = nil) result = select(:user_id).distinct.where(action_type: USER_ACTED_TYPES) if start_date && end_date result = result.group("date(created_at)") result = result.where("created_at > ? AND created_at < ?", start_date, end_date) result = result.order("date(created_at)") end result.count end def self.stream_item(action_id, guardian) stream(action_id: action_id, guardian: guardian).first end NULL_QUEUED_STREAM_COLS = %i[ cooked uploaded_avatar_id acting_name acting_username acting_user_id target_name target_username target_user_id post_number post_id deleted hidden post_type action_type action_code action_code_who action_code_path topic_closed topic_id topic_archived ].map! { |s| "NULL as #{s}" }.join(", ") def self.stream(opts = nil) opts ||= {} action_types = opts[:action_types] user_id = opts[:user_id] action_id = opts[:action_id] guardian = opts[:guardian] ignore_private_messages = opts[:ignore_private_messages] offset = opts[:offset] || 0 limit = opts[:limit] || 60 acting_username = opts[:acting_username] # Acting user columns. Can be extended by plugins to include custom avatar # columns acting_cols = ["u.id AS acting_user_id", "u.name AS acting_name"] UserLookup.lookup_columns.each do |c| next if c == :id || c["."] acting_cols << "u.#{c} AS acting_#{c}" end # 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 = DB.build <<~SQL SELECT a.id, t.title, a.action_type, a.created_at, t.id topic_id, t.closed AS topic_closed, t.archived AS topic_archived, 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.id as post_id, p.reply_to_post_number, pu.username, pu.name, pu.id user_id, pu.uploaded_avatar_id, #{acting_cols.join(", ")}, 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.action_code, pc.value AS action_code_who, pc2.value AS action_code_path, p.edit_reason, t.category_id 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 LEFT JOIN post_custom_fields pc ON pc.post_id = a.target_post_id AND pc.name = 'action_code_who' LEFT JOIN post_custom_fields pc2 ON pc2.post_id = a.target_post_id AND pc2.name = 'action_code_path' /*left_join*/ /*where*/ /*order_by*/ /*offset*/ /*limit*/ SQL 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) if action_types && action_types.length > 0 builder.where("a.action_type in (:action_types)", action_types: action_types) end if acting_username builder.where( "u.username_lower = :acting_username", acting_username: acting_username.downcase, ) end unless SiteSetting.enable_mentions? builder.where("a.action_type <> :mention_type", mention_type: UserAction::MENTION) end builder.order_by("a.created_at desc").offset(offset.to_i).limit(limit.to_i) end DiscoursePluginRegistry.apply_modifier(:user_action_stream_builder, builder) builder.query end def self.log_action!(hash) required_parameters = %i[action_type user_id acting_user_id target_post_id target_topic_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 { |k, _| required_parameters.include?(k) }) return action if action action = self.new(hash) action.created_at = hash[:created_at] if hash[:created_at] action.save! user_id = hash[:user_id] topic = Topic.includes(:category).find_by(id: hash[:target_topic_id]) update_like_count(user_id, hash[:action_type], 1) if topic && !topic.private_message? user_ids = user_id != action.acting_user_id ? [user_id] : nil group_ids = nil if topic&.category&.read_restricted group_ids = [Group::AUTO_GROUPS[:admins]] | topic.category.groups.pluck("groups.id") end if action.user && (user_ids.present? || group_ids.present?) MessageBus.publish( "/u/#{action.user.username_lower}", action.id, user_ids: user_ids, 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 if !Topic.where(id: hash[:target_topic_id], archetype: Archetype.private_message).exists? update_like_count(hash[:user_id], hash[:action_type], -1) end end def self.synchronize_target_topic_ids(post_ids = nil, limit: nil) # nuke all dupes, using magic builder = DB.build <<~SQL DELETE FROM user_actions USING user_actions ua2 /*where*/ SQL builder.where <<~SQL user_actions.action_type = ua2.action_type AND user_actions.user_id = ua2.user_id AND user_actions.acting_user_id = ua2.acting_user_id AND user_actions.target_post_id = ua2.target_post_id AND user_actions.target_post_id > 0 AND user_actions.id > ua2.id SQL builder.where(<<~SQL, limit: limit) if limit user_actions.target_post_id IN ( SELECT target_post_id FROM user_actions WHERE created_at > :limit ) SQL builder.where("user_actions.target_post_id in (:post_ids)", post_ids: post_ids) if post_ids builder.exec builder = DB.build <<~SQL UPDATE user_actions SET target_topic_id = (select topic_id from posts where posts.id = target_post_id) /*where*/ SQL builder.where("target_topic_id <> (select topic_id from posts where posts.id = target_post_id)") builder.where("target_post_id in (:post_ids)", post_ids: post_ids) if post_ids builder.where(<<~SQL, limit: limit) if limit target_post_id IN ( SELECT target_post_id FROM user_actions WHERE created_at > :limit ) SQL builder.exec end def self.ensure_consistency!(limit = nil) self.synchronize_target_topic_ids(nil, limit: limit) end 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 visible_post_types = Topic.visible_post_types(guardian.user) builder.where( "COALESCE(p.post_type, p2.post_type) IN (:visible_post_types)", visible_post_types: visible_post_types, ) builder.where("t.visible") if guardian.user&.id != user_id && !guardian.is_staff? filter_private_messages(builder, user_id, guardian, ignore_private_messages) filter_categories(builder, guardian) end def self.filter_private_messages(builder, user_id, guardian, ignore_private_messages = false) if !guardian.can_see_private_messages?(user_id) || ignore_private_messages || !guardian.user builder.where("t.archetype <> :private_message", private_message: Archetype.private_message) else unless guardian.is_admin? sql = <<~SQL t.archetype <> :private_message OR EXISTS ( SELECT 1 FROM topic_allowed_users tu WHERE tu.topic_id = t.id AND tu.user_id = :current_user_id ) OR EXISTS ( SELECT 1 FROM topic_allowed_groups tg WHERE tg.topic_id = t.id AND tg.group_id IN ( SELECT group_id FROM group_users gu WHERE gu.user_id = :current_user_id ) ) SQL builder.where( sql, private_message: Archetype.private_message, current_user_id: guardian.user.id, ) end end builder end def self.filter_categories(builder, guardian) 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 builder end def self.require_parameters(data, *params) params.each { |p| raise Discourse::InvalidParameters.new(p) if data[p].nil? } 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 # idx_user_actions_speed_up_user_all (user_id,created_at,action_type) # index_user_actions_on_acting_user_id (acting_user_id) # index_user_actions_on_action_type_and_created_at (action_type,created_at) # index_user_actions_on_target_post_id (target_post_id) # index_user_actions_on_target_user_id (target_user_id) WHERE (target_user_id IS NOT NULL) # index_user_actions_on_user_id_and_action_type (user_id,action_type) #