473 lines
14 KiB
Ruby
473 lines
14 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
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
|
|
EDIT = 11
|
|
NEW_PRIVATE_MESSAGE = 12
|
|
GOT_PRIVATE_MESSAGE = 13
|
|
SOLVED = 15
|
|
ASSIGNED = 16
|
|
|
|
ORDER = Hash[*[
|
|
GOT_PRIVATE_MESSAGE,
|
|
NEW_PRIVATE_MESSAGE,
|
|
NEW_TOPIC,
|
|
REPLY,
|
|
RESPONSE,
|
|
LIKE,
|
|
WAS_LIKED,
|
|
MENTION,
|
|
QUOTE,
|
|
BOOKMARK,
|
|
EDIT,
|
|
SOLVED,
|
|
ASSIGNED,
|
|
].each_with_index.to_a.flatten]
|
|
|
|
def self.types
|
|
@types ||= Enum.new(
|
|
like: 1,
|
|
was_liked: 2,
|
|
bookmark: 3,
|
|
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)
|
|
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_first(: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 do |row|
|
|
(result[:groups] ||= []) << { name: row.name, count: row.count.to_i }
|
|
end
|
|
|
|
result
|
|
|
|
end
|
|
|
|
def self.count_daily_engaged_users(start_date = nil, end_date = nil)
|
|
result = select(:user_id)
|
|
.distinct
|
|
.where(action_type: [LIKE, NEW_TOPIC, REPLY, NEW_PRIVATE_MESSAGE])
|
|
|
|
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
|
|
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,
|
|
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'
|
|
/*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)
|
|
builder.where("a.action_type in (:action_types)", action_types: action_types) if action_types && action_types.length > 0
|
|
|
|
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
|
|
|
|
builder.query
|
|
end
|
|
|
|
def self.log_action!(hash)
|
|
required_parameters = [:action_type, :user_id, :acting_user_id]
|
|
|
|
required_parameters << :target_post_id
|
|
required_parameters << :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)
|
|
|
|
if hash[:created_at]
|
|
action.created_at = hash[:created_at]
|
|
end
|
|
action.save!
|
|
|
|
user_id = hash[:user_id]
|
|
|
|
topic = Topic.includes(:category).find_by(id: hash[:target_topic_id])
|
|
|
|
if topic && !topic.private_message?
|
|
update_like_count(user_id, hash[:action_type], 1)
|
|
end
|
|
|
|
# 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("/u/#{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
|
|
|
|
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
|
|
|
|
if limit
|
|
builder.where(<<~SQL, limit: limit)
|
|
user_actions.target_post_id IN (
|
|
SELECT target_post_id
|
|
FROM user_actions
|
|
WHERE created_at > :limit
|
|
)
|
|
SQL
|
|
end
|
|
|
|
if post_ids
|
|
builder.where("user_actions.target_post_id in (:post_ids)", post_ids: post_ids)
|
|
end
|
|
|
|
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)")
|
|
if post_ids
|
|
builder.where("target_post_id in (:post_ids)", post_ids: post_ids)
|
|
end
|
|
|
|
if limit
|
|
builder.where(<<~SQL, limit: limit)
|
|
target_post_id IN (
|
|
SELECT target_post_id
|
|
FROM user_actions
|
|
WHERE created_at > :limit
|
|
)
|
|
SQL
|
|
end
|
|
|
|
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)
|
|
|
|
unless (guardian.user && guardian.user.id == user_id) || guardian.is_staff?
|
|
builder.where("t.visible")
|
|
end
|
|
|
|
unless guardian.can_see_notifications?(User.where(id: user_id).first)
|
|
builder.where("a.action_type not in (#{BOOKMARK})")
|
|
end
|
|
|
|
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
|
|
|
|
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
|
|
# 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)
|
|
#
|