class TopicUser < ActiveRecord::Base belongs_to :user belongs_to :topic scope :starred_since, lambda { |sinceDaysAgo| where('starred_at > ?', sinceDaysAgo.days.ago) } scope :by_date_starred, -> { group('date(starred_at)').order('date(starred_at)') } scope :tracking, lambda { |topic_id| where(topic_id: topic_id) .where("COALESCE(topic_users.notification_level, :regular) >= :tracking", regular: TopicUser.notification_levels[:regular], tracking: TopicUser.notification_levels[:tracking]) } # Class methods class << self # Enums def notification_levels @notification_levels ||= Enum.new(:muted, :regular, :tracking, :watching, start: 0) end def notification_reasons @notification_reasons ||= Enum.new( :created_topic, :user_changed, :user_interacted, :created_post, :auto_watch, :auto_watch_category, :auto_mute_category, :auto_track_category ) end def auto_track(user_id, topic_id, reason) if TopicUser.where(user_id: user_id, topic_id: topic_id, notifications_reason_id: nil).exists? change(user_id, topic_id, notification_level: notification_levels[:tracking], notifications_reason_id: reason ) MessageBus.publish("/topic/#{topic_id}", { notification_level_change: notification_levels[:tracking], notifications_reason_id: reason }, user_ids: [user_id]) end 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 do |ftu| result[ftu.topic_id] = ftu end 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) # 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 attrs[:starred_at] = DateTime.now if attrs[:starred_at].nil? && attrs[:starred] 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]) if rows == 0 now = DateTime.now auto_track_after = User.select(:auto_track_topics_after_msecs).find_by(id: user_id).auto_track_topics_after_msecs auto_track_after ||= SiteSetting.auto_track_topics_after if auto_track_after >= 0 && auto_track_after <= (attrs[:total_msecs_viewed] || 0) attrs[:notification_level] ||= notification_levels[:tracking] end TopicUser.create(attrs.merge!(user_id: user_id, topic_id: topic_id, first_visited_at: now ,last_visited_at: now)) else observe_after_save_callbacks_for topic_id, user_id end end if attrs[:notification_level] MessageBus.publish("/topic/#{topic_id}", {notification_level_change: attrs[:notification_level]}, user_ids: [user_id]) end rescue ActiveRecord::RecordNotUnique # In case of a race condition to insert, do nothing end def track_visit!(topic,user) topic_id = topic.is_a?(Topic) ? topic.id : topic user_id = user.is_a?(User) ? user.id : topic now = DateTime.now rows = TopicUser.where({topic_id: topic_id, user_id: user_id}).update_all({last_visited_at: now}) if rows == 0 TopicUser.create(topic_id: topic_id, user_id: user_id, last_visited_at: now, first_visited_at: now) else observe_after_save_callbacks_for topic_id, user_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 def update_last_read(user, topic_id, post_number, msecs) 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.auto_track_topics_after } # In case anyone seens "highest_seen_post_number" and gets confused, like I do. # highest_seen_post_number represents the highest_post_number of the topic when # the user visited it. It may be out of alignment with last_read, meaning # ... user visited the topic but did not read the posts # # 86400000 = 1 day rows = exec_sql("UPDATE topic_users SET last_read_post_number = GREATEST(:post_number, tu.last_read_post_number), highest_seen_post_number = t.highest_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(u.auto_track_topics_after_msecs,:threshold) and coalesce(u.auto_track_topics_after_msecs, :threshold) >= 0 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 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 ", args).values if rows.length == 1 before = rows[0][1].to_i after = rows[0][0].to_i before_last_read = rows[0][2].to_i if before_last_read < post_number # The user read at least one new post TopicTrackingState.publish_read(topic_id, post_number, user.id, after) user.update_posts_read!(post_number - before_last_read) end if before != after MessageBus.publish("/topic/#{topic_id}", {notification_level_change: after}, user_ids: [user.id]) end 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.auto_track_topics_after_msecs || SiteSetting.auto_track_topics_after) == 0 args[:new_status] = notification_levels[:tracking] end TopicTrackingState.publish_read(topic_id, post_number, user.id, args[:new_status]) user.update_posts_read!(post_number) exec_sql("INSERT INTO topic_users (user_id, topic_id, last_read_post_number, highest_seen_post_number, last_visited_at, first_visited_at, notification_level) SELECT :user_id, :topic_id, :post_number, ft.highest_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)", args) MessageBus.publish("/topic/#{topic_id}", {notification_level_change: args[:new_status]}, user_ids: [user.id]) end end def observe_after_save_callbacks_for(topic_id, user_id) TopicUser.where(topic_id: topic_id, user_id: user_id).each do |topic_user| UserActionObserver.instance.after_save topic_user end end end def self.ensure_consistency!(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 = SqlBuilder.new <<SQL UPDATE topic_users t SET last_read_post_number = LEAST(GREATEST(last_read, last_read_post_number), max_post_number), highest_seen_post_number = LEAST(max_post_number,GREATEST(t.highest_seen_post_number, last_read)) 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) OR highest_seen_post_number <> LEAST(max_post_number,GREATEST(t.highest_seen_post_number, last_read)) ) SQL if topic_id builder.where("t.topic_id = :topic_id", topic_id: topic_id) end builder.exec end end # == Schema Information # # Table name: topic_users # # user_id :integer not null # topic_id :integer not null # starred :boolean default(FALSE), not null # posted :boolean default(FALSE), not null # last_read_post_number :integer # highest_seen_post_number :integer # starred_at :datetime # 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 # unstarred_at :datetime # id :integer not null, primary key # last_emailed_post_number :integer # # 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 #