mirror of
https://github.com/discourse/discourse.git
synced 2024-12-24 06:23:45 +08:00
602 lines
20 KiB
Ruby
602 lines
20 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class TopicUser < ActiveRecord::Base
|
|
self.ignored_columns = [
|
|
:highest_seen_post_number, # TODO: Remove when 20240212034010_drop_deprecated_columns has been promoted to pre-deploy
|
|
]
|
|
|
|
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)
|
|
if rows == 0
|
|
change(user_id, topic_id, last_visited_at: now, first_visited_at: now)
|
|
DiscourseEvent.trigger(:user_first_visit_to_topic, user_id: user_id, topic_id: topic_id)
|
|
end
|
|
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 =
|
|
LEAST(
|
|
CASE WHEN :whisperer
|
|
THEN highest_staff_post_number
|
|
ELSE highest_post_number END
|
|
,
|
|
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,
|
|
whisperer: user.whisperer?,
|
|
}
|
|
|
|
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:)
|
|
update_last_read_post_number(topic_id:)
|
|
end
|
|
|
|
def self.update_last_read_post_number(topic_id: nil)
|
|
# 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_notification_level (topic_id,notification_level)
|
|
# 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
|
|
#
|