discourse/app/models/topic_user.rb
Ted Johansson 25a226279a
DEV: Replace #pluck_first freedom patch with AR #pick in core (#19893)
The #pluck_first freedom patch, first introduced by @danielwaterworth has served us well, and is used widely throughout both core and plugins. It seems to have been a common enough use case that Rails 6 introduced it's own method #pick with the exact same implementation. This allows us to retire the freedom patch and switch over to the built-in ActiveRecord method.

There is no replacement for #pluck_first!, but a quick search shows we are using this in a very limited capacity, and in some cases incorrectly (by assuming a nil return rather than an exception), which can quite easily be replaced with #pick plus some extra handling.
2023-02-13 12:39:45 +08:00

588 lines
20 KiB
Ruby

# frozen_string_literal: true
class TopicUser < ActiveRecord::Base
self.ignored_columns = [
:highest_seen_post_number, # Remove after 01 Jan 2022
]
belongs_to :user
belongs_to :topic
# used for serialization
attr_accessor :post_action_data
scope :level,
lambda { |topic_id, level|
where(topic_id: topic_id).where(
"COALESCE(topic_users.notification_level, :regular) >= :level",
regular: TopicUser.notification_levels[:regular],
level: TopicUser.notification_levels[level],
)
}
scope :tracking, lambda { |topic_id| level(topic_id, :tracking) }
scope :watching, lambda { |topic_id| level(topic_id, :watching) }
def topic_bookmarks
Bookmark.where(topic: topic, user: user)
end
# Class methods
class << self
# Enums
def notification_levels
NotificationLevels.topic_levels
end
def notification_reasons
@notification_reasons ||=
Enum.new(
created_topic: 1,
user_changed: 2,
user_interacted: 3,
created_post: 4,
auto_watch: 5,
auto_watch_category: 6,
auto_mute_category: 7,
auto_track_category: 8,
plugin_changed: 9,
auto_watch_tag: 10,
auto_mute_tag: 11,
auto_track_tag: 12,
)
end
def auto_notification(user_id, topic_id, reason, notification_level)
should_change =
TopicUser
.where(user_id: user_id, topic_id: topic_id)
.where(
"notifications_reason_id IS NULL OR (notification_level < :max AND notification_level > :min)",
max: notification_level,
min: notification_levels[:regular],
)
.exists?
if should_change
change(
user_id,
topic_id,
notification_level: notification_level,
notifications_reason_id: reason,
)
end
end
def auto_notification_for_staging(
user_id,
topic_id,
reason,
notification_level = notification_levels[:watching]
)
change(
user_id,
topic_id,
notification_level: notification_level,
notifications_reason_id: reason,
)
end
def unwatch_categories!(user, category_ids)
track_threshold = user.user_option.auto_track_topics_after_msecs
sql = <<~SQL
UPDATE topic_users tu
SET notification_level = CASE
WHEN t.user_id = :user_id THEN :watching
WHEN total_msecs_viewed > :track_threshold AND :track_threshold >= 0 THEN :tracking
ELSE :regular
end
FROM topics t
WHERE t.id = tu.topic_id AND tu.notification_level <> :muted AND category_id IN (:category_ids) AND tu.user_id = :user_id
SQL
DB.exec(
sql,
watching: notification_levels[:watching],
tracking: notification_levels[:tracking],
regular: notification_levels[:regular],
muted: notification_levels[:muted],
category_ids: category_ids,
user_id: user.id,
track_threshold: track_threshold,
)
end
# Find the information specific to a user in a forum topic
def lookup_for(user, topics)
# If the user isn't logged in, there's no last read posts
return {} if user.blank? || topics.blank?
topic_ids = topics.map(&:id)
create_lookup(TopicUser.where(topic_id: topic_ids, user_id: user.id))
end
def create_lookup(topic_users)
topic_users = topic_users.to_a
result = {}
return result if topic_users.blank?
topic_users.each { |ftu| result[ftu.topic_id] = ftu }
result
end
def get(topic, user)
topic = topic.id if topic.is_a?(Topic)
user = user.id if user.is_a?(User)
TopicUser.find_by(topic_id: topic, user_id: user)
end
# Change attributes for a user (creates a record when none is present). First it tries an update
# since there's more likely to be an existing record than not. If the update returns 0 rows affected
# it then creates the row instead.
def change(user_id, topic_id, attrs)
# For plugin compatibility, remove after 01 Jan 2022
attrs.delete(:highest_seen_post_number) if attrs[:highest_seen_post_number]
# Sometimes people pass objs instead of the ids. We can handle that.
topic_id = topic_id.id if topic_id.is_a?(::Topic)
user_id = user_id.id if user_id.is_a?(::User)
topic_id = topic_id.to_i
user_id = user_id.to_i
TopicUser.transaction do
attrs = attrs.dup
if attrs[:notification_level]
attrs[:notifications_changed_at] ||= DateTime.now
attrs[:notifications_reason_id] ||= TopicUser.notification_reasons[:user_changed]
end
attrs_array = attrs.to_a
attrs_sql = attrs_array.map { |t| "#{t[0]} = ?" }.join(", ")
vals = attrs_array.map { |t| t[1] }
rows = TopicUser.where(topic_id: topic_id, user_id: user_id).update_all([attrs_sql, *vals])
create_missing_record(user_id, topic_id, attrs) if rows == 0
end
if attrs[:notification_level]
notification_level_change(
user_id,
topic_id,
attrs[:notification_level],
attrs[:notifications_reason_id],
)
end
rescue ActiveRecord::RecordNotUnique
# In case of a race condition to insert, do nothing
end
def notification_level_change(user_id, topic_id, notification_level, reason_id)
message = { notification_level_change: notification_level }
message[:notifications_reason_id] = reason_id if reason_id
MessageBus.publish("/topic/#{topic_id}", message, user_ids: [user_id])
DiscourseEvent.trigger(
:topic_notification_level_changed,
notification_level,
user_id,
topic_id,
)
end
def create_missing_record(user_id, topic_id, attrs)
now = DateTime.now
unless attrs[:notification_level]
category_notification_level =
CategoryUser
.where(user_id: user_id)
.where("category_id IN (SELECT category_id FROM topics WHERE id = :id)", id: topic_id)
.where(
"notification_level IN (:levels)",
levels: [
CategoryUser.notification_levels[:watching],
CategoryUser.notification_levels[:tracking],
],
)
.order("notification_level DESC")
.limit(1)
.pluck(:notification_level)
.first
tag_notification_level =
TagUser
.where(user_id: user_id)
.where("tag_id IN (SELECT tag_id FROM topic_tags WHERE topic_id = :id)", id: topic_id)
.where(
"notification_level IN (:levels)",
levels: [
CategoryUser.notification_levels[:watching],
CategoryUser.notification_levels[:tracking],
],
)
.order("notification_level DESC")
.limit(1)
.pluck(:notification_level)
.first
if category_notification_level &&
!(tag_notification_level && (tag_notification_level > category_notification_level))
attrs[:notification_level] = category_notification_level
attrs[:notifications_changed_at] = DateTime.now
attrs[:notifications_reason_id] = (
if category_notification_level == CategoryUser.notification_levels[:watching]
TopicUser.notification_reasons[:auto_watch_category]
else
TopicUser.notification_reasons[:auto_track_category]
end
)
elsif tag_notification_level
attrs[:notification_level] = tag_notification_level
attrs[:notifications_changed_at] = DateTime.now
attrs[:notifications_reason_id] = (
if tag_notification_level == TagUser.notification_levels[:watching]
TopicUser.notification_reasons[:auto_watch_tag]
else
TopicUser.notification_reasons[:auto_track_tag]
end
)
end
end
unless attrs[:notification_level]
if Topic.private_messages.where(id: topic_id).exists? &&
Notification.where(
user_id: user_id,
topic_id: topic_id,
notification_type: Notification.types[:invited_to_private_message],
).exists?
group_notification_level =
Group
.joins(
"LEFT OUTER JOIN group_users gu ON gu.group_id = groups.id AND gu.user_id = #{user_id}",
)
.joins("LEFT OUTER JOIN topic_allowed_groups tag ON tag.topic_id = #{topic_id}")
.where("gu.id IS NOT NULL AND tag.id IS NOT NULL")
.pluck(:default_notification_level)
.first
if group_notification_level.present?
attrs[:notification_level] = group_notification_level
else
attrs[:notification_level] = notification_levels[:watching]
end
else
auto_track_after = UserOption.where(user_id: user_id).pick(:auto_track_topics_after_msecs)
auto_track_after ||= SiteSetting.default_other_auto_track_topics_after_msecs
if auto_track_after >= 0 && auto_track_after <= (attrs[:total_msecs_viewed].to_i || 0)
attrs[:notification_level] ||= notification_levels[:tracking]
end
end
end
TopicUser.create!(
attrs.merge!(
user_id: user_id,
topic_id: topic_id,
first_visited_at: now,
last_visited_at: now,
),
)
DiscourseEvent.trigger(:topic_first_visited_by_user, topic_id, user_id)
end
def track_visit!(topic_id, user_id)
now = DateTime.now
rows = TopicUser.where(topic_id: topic_id, user_id: user_id).update_all(last_visited_at: now)
change(user_id, topic_id, last_visited_at: now, first_visited_at: now) if rows == 0
end
# Update the last read and the last seen post count, but only if it doesn't exist.
# This would be a lot easier if psql supported some kind of upsert
UPDATE_TOPIC_USER_SQL = <<~SQL
UPDATE topic_users
SET
last_read_post_number = GREATEST(:post_number, tu.last_read_post_number),
total_msecs_viewed = LEAST(tu.total_msecs_viewed + :msecs,86400000),
notification_level =
case when tu.notifications_reason_id is null and (tu.total_msecs_viewed + :msecs) >
coalesce(uo.auto_track_topics_after_msecs,:threshold) and
coalesce(uo.auto_track_topics_after_msecs, :threshold) >= 0
and t.archetype = 'regular' then
:tracking
else
tu.notification_level
end
FROM topic_users tu
join topics t on t.id = tu.topic_id
join users u on u.id = :user_id
join user_options uo on uo.user_id = :user_id
WHERE
tu.topic_id = topic_users.topic_id AND
tu.user_id = topic_users.user_id AND
tu.topic_id = :topic_id AND
tu.user_id = :user_id
RETURNING
topic_users.notification_level,
tu.notification_level old_level,
tu.last_read_post_number,
t.archetype
SQL
INSERT_TOPIC_USER_SQL =
"INSERT INTO topic_users (user_id, topic_id, last_read_post_number, last_visited_at, first_visited_at, notification_level)
SELECT :user_id, :topic_id, :post_number, :now, :now, :new_status
FROM topics AS ft
JOIN users u on u.id = :user_id
WHERE ft.id = :topic_id
AND NOT EXISTS(SELECT 1
FROM topic_users AS ftu
WHERE ftu.user_id = :user_id and ftu.topic_id = :topic_id)"
def update_last_read(user, topic_id, post_number, new_posts_read, msecs, opts = {})
return if post_number.blank?
msecs = 0 if msecs.to_i < 0
args = {
user_id: user.id,
topic_id: topic_id,
post_number: post_number,
now: DateTime.now,
msecs: msecs,
tracking: notification_levels[:tracking],
threshold: SiteSetting.default_other_auto_track_topics_after_msecs,
}
rows = DB.query(UPDATE_TOPIC_USER_SQL, args)
if rows.length == 1
before = rows[0].old_level.to_i
after = rows[0].notification_level.to_i
before_last_read = rows[0].last_read_post_number.to_i
archetype = rows[0].archetype
if before_last_read < post_number
# The user read at least one new post
publish_read(
topic_id: topic_id,
post_number: post_number,
user: user,
notification_level: after,
private_message: archetype == Archetype.private_message,
)
end
user.update_posts_read!(new_posts_read, mobile: opts[:mobile]) if new_posts_read > 0
notification_level_change(user.id, topic_id, after, nil) if before != after
end
if rows.length == 0
# The user read at least one post in a topic that they haven't viewed before.
args[:new_status] = notification_levels[:regular]
if (
user.user_option.auto_track_topics_after_msecs ||
SiteSetting.default_other_auto_track_topics_after_msecs
) == 0
args[:new_status] = notification_levels[:tracking]
end
publish_read(
topic_id: topic_id,
post_number: post_number,
user: user,
notification_level: args[:new_status],
private_message: Topic.exists?(archetype: Archetype.private_message, id: topic_id),
)
user.update_posts_read!(new_posts_read, mobile: opts[:mobile])
begin
DB.exec(INSERT_TOPIC_USER_SQL, args)
rescue PG::UniqueViolation
# if record is inserted between two statements this can happen
# we retry once to avoid failing the req
if opts[:retry]
raise
else
opts[:retry] = true
update_last_read(user, topic_id, post_number, new_posts_read, msecs, opts)
end
end
notification_level_change(user.id, topic_id, args[:new_status], nil)
end
end
private
def publish_read(topic_id:, post_number:, user:, notification_level: nil, private_message:)
klass =
if private_message
PrivateMessageTopicTrackingState
else
TopicTrackingState
end
klass.publish_read(topic_id, post_number, user, notification_level)
end
end
# Update the cached topic_user.liked column based on data
# from the post_actions table. This is useful when posts
# have moved around, or to ensure integrity of the data.
#
# By default this will update data for all topics and all users.
# The parameters can be used to shrink the scope, and make it faster.
# user_id, post_id and topic_id can optionally be arrays of ids.
#
# Providing post_id will automatically scope to the relevant user_id and topic_id.
# A provided `topic_id` value will always take precedence, which is
# useful when a post has been moved between topics.
def self.update_post_action_cache(
user_id: nil,
post_id: nil,
topic_id: nil,
post_action_type: :like
)
raise ArgumentError, "post_action_type must equal :like" if post_action_type != :like
raise ArgumentError, "post_id and user_id cannot be supplied together" if user_id && post_id
action_type_name = "liked"
builder = DB.build <<~SQL
UPDATE topic_users tu
SET #{action_type_name} = x.state
FROM (
SELECT CASE WHEN EXISTS (
SELECT 1
FROM post_actions pa
JOIN posts p on p.id = pa.post_id
JOIN topics t ON t.id = p.topic_id
WHERE pa.deleted_at IS NULL AND
p.deleted_at IS NULL AND
t.deleted_at IS NULL AND
pa.post_action_type_id = :action_type_id AND
tu2.topic_id = t.id AND
tu2.user_id = pa.user_id
LIMIT 1
) THEN true ELSE false END state, tu2.topic_id, tu2.user_id
FROM topic_users tu2
/*where*/
) x
WHERE x.topic_id = tu.topic_id AND x.user_id = tu.user_id AND x.state != tu.#{action_type_name}
SQL
builder.where("tu2.user_id IN (:user_id)", user_id: user_id) if user_id
builder.where("tu2.topic_id IN (:topic_id)", topic_id: topic_id) if topic_id
if post_id
if !topic_id
builder.where(
"tu2.topic_id IN (SELECT topic_id FROM posts WHERE id IN (:post_id))",
post_id: post_id,
)
end
builder.where(<<~SQL, post_id: post_id)
tu2.user_id IN (
SELECT user_id FROM post_actions
WHERE post_id IN (:post_id)
AND post_action_type_id = :action_type_id
)
SQL
end
builder.exec(action_type_id: PostActionType.types[post_action_type])
end
# cap number of unread topics at count, bumping up last_read if needed
def self.cap_unread!(user_id, count)
sql = <<SQL
UPDATE topic_users tu
SET last_read_post_number = max_number
FROM (
SELECT MAX(post_number) max_number, p.topic_id FROM posts p
WHERE deleted_at IS NULL
GROUP BY p.topic_id
) m
WHERE tu.user_id = :user_id AND
m.topic_id = tu.topic_id AND
tu.topic_id IN (
#{TopicTrackingState.report_raw_sql(skip_new: true, select: "topics.id")}
offset :count
)
SQL
DB.exec(sql, user_id: user_id, count: count)
end
def self.ensure_consistency!(topic_id = nil)
update_post_action_cache(topic_id: topic_id)
# TODO this needs some reworking, when we mark stuff skipped
# we up these numbers so they are not in-sync
# the simple fix is to add a column here, but table is already quite big
# long term we want to split up topic_users and allow for this better
builder = DB.build <<~SQL
UPDATE topic_users t
SET
last_read_post_number = LEAST(GREATEST(last_read, last_read_post_number), max_post_number)
FROM (
SELECT topic_id, user_id, MAX(post_number) last_read
FROM post_timings
GROUP BY topic_id, user_id
) as X
JOIN (
SELECT p.topic_id, MAX(p.post_number) max_post_number from posts p
GROUP BY p.topic_id
) as Y on Y.topic_id = X.topic_id
/*where*/
SQL
builder.where <<~SQL
X.topic_id = t.topic_id AND
X.user_id = t.user_id AND
(
last_read_post_number <> LEAST(GREATEST(last_read, last_read_post_number), max_post_number)
)
SQL
builder.where("t.topic_id = :topic_id", topic_id: topic_id) if topic_id
builder.exec
end
end
# == Schema Information
#
# Table name: topic_users
#
# user_id :integer not null
# topic_id :integer not null
# posted :boolean default(FALSE), not null
# last_read_post_number :integer
# last_visited_at :datetime
# first_visited_at :datetime
# notification_level :integer default(1), not null
# notifications_changed_at :datetime
# notifications_reason_id :integer
# total_msecs_viewed :integer default(0), not null
# cleared_pinned_at :datetime
# id :integer not null, primary key
# last_emailed_post_number :integer
# liked :boolean default(FALSE)
# bookmarked :boolean default(FALSE)
# last_posted_at :datetime
#
# Indexes
#
# index_topic_users_on_topic_id_and_user_id (topic_id,user_id) UNIQUE
# index_topic_users_on_user_id_and_topic_id (user_id,topic_id) UNIQUE
#