2019-05-02 18:17:27 -04:00
# frozen_string_literal: true
2013-02-05 14:16:51 -05:00
class TopicUser < ActiveRecord :: Base
belongs_to :user
belongs_to :topic
2013-02-07 10:45:24 -05:00
2015-01-07 02:20:10 -05:00
# used for serialization
attr_accessor :post_action_data
2019-05-08 03:18:56 -04:00
scope :level , lambda { | topic_id , level |
2013-05-29 04:11:04 -04:00
where ( topic_id : topic_id )
2019-05-08 03:18:56 -04:00
. where ( " COALESCE(topic_users.notification_level, :regular) >= :level " ,
2015-08-12 17:00:16 -04:00
regular : TopicUser . notification_levels [ :regular ] ,
2019-05-08 03:18:56 -04:00
level : TopicUser . notification_levels [ level ] )
}
scope :tracking , lambda { | topic_id |
level ( topic_id , :tracking )
}
scope :watching , lambda { | topic_id |
level ( topic_id , :watching )
2013-05-29 04:11:04 -04:00
}
2020-07-08 01:27:42 -04:00
def topic_bookmarks
Bookmark . where ( topic : topic , user : user )
end
2013-03-06 15:17:07 -05:00
# Class methods
class << self
2013-02-05 14:16:51 -05:00
2013-03-06 15:17:07 -05:00
# Enums
def notification_levels
2016-07-06 15:56:40 -04:00
NotificationLevels . topic_levels
2013-03-06 15:17:07 -05:00
end
2013-02-07 10:45:24 -05:00
2013-03-06 15:17:07 -05:00
def notification_reasons
2016-01-08 05:53:52 -05:00
@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 ,
2016-05-04 14:02:47 -04:00
plugin_changed : 9 ,
auto_watch_tag : 10 ,
auto_mute_tag : 11 ,
auto_track_tag : 12 )
2013-03-06 15:17:07 -05:00
end
2016-09-30 12:36:43 -04:00
def auto_notification ( user_id , topic_id , reason , notification_level )
2017-10-06 10:37:28 -04:00
should_change = TopicUser
. where ( user_id : user_id , topic_id : topic_id )
2018-08-23 00:36:49 -04:00
. where ( " notifications_reason_id IS NULL OR (notification_level < :max AND notification_level > :min) " , max : notification_level , min : notification_levels [ :regular ] )
2017-10-06 10:37:28 -04:00
. exists?
change ( user_id , topic_id , notification_level : notification_level , notifications_reason_id : reason ) if should_change
2013-02-05 14:16:51 -05:00
end
2017-10-06 10:48:11 -04:00
def auto_notification_for_staging ( user_id , topic_id , reason , notification_level = notification_levels [ :watching ] )
2017-10-06 10:37:28 -04:00
change ( user_id , topic_id , notification_level : notification_level , notifications_reason_id : reason )
2015-11-18 16:24:46 -05:00
end
2016-06-28 04:34:20 -04:00
def unwatch_categories! ( user , category_ids )
track_threshold = user . user_option . auto_track_topics_after_msecs
2018-06-19 02:13:14 -04:00
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
2016-06-28 04:34:20 -04:00
2018-06-19 02:13:14 -04:00
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
)
2016-06-28 04:34:20 -04:00
end
2013-03-06 15:17:07 -05:00
# 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?
2013-02-05 14:16:51 -05:00
2013-03-06 15:17:07 -05:00
topic_ids = topics . map ( & :id )
create_lookup ( TopicUser . where ( topic_id : topic_ids , user_id : user . id ) )
end
2013-02-05 14:16:51 -05:00
2013-03-06 15:17:07 -05:00
def create_lookup ( topic_users )
topic_users = topic_users . to_a
result = { }
return result if topic_users . blank?
2015-08-12 17:00:16 -04:00
topic_users . each { | ftu | result [ ftu . topic_id ] = ftu }
2013-03-06 15:17:07 -05:00
result
2013-02-05 14:16:51 -05:00
end
2014-03-26 15:20:41 -04:00
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 )
2013-02-05 14:16:51 -05:00
end
2013-03-06 15:17:07 -05:00
# 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.
2013-06-27 22:18:04 -04:00
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
2013-03-06 15:17:07 -05:00
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
2013-02-05 14:16:51 -05:00
2013-03-06 15:17:07 -05:00
attrs_sql = attrs_array . map { | t | " #{ t [ 0 ] } = ? " } . join ( " , " )
vals = attrs_array . map { | t | t [ 1 ] }
2013-07-01 14:45:52 -04:00
rows = TopicUser . where ( topic_id : topic_id , user_id : user_id ) . update_all ( [ attrs_sql , * vals ] )
2013-02-05 14:16:51 -05:00
2013-03-06 15:17:07 -05:00
if rows == 0
2016-07-07 22:58:18 -04:00
create_missing_record ( user_id , topic_id , attrs )
2013-02-05 14:16:51 -05:00
end
end
2014-06-24 19:45:12 -04:00
if attrs [ :notification_level ]
2017-05-25 15:07:12 -04:00
notification_level_change ( user_id , topic_id , attrs [ :notification_level ] , attrs [ :notifications_reason_id ] )
2014-06-24 19:45:12 -04:00
end
2013-03-06 15:17:07 -05:00
rescue ActiveRecord :: RecordNotUnique
# In case of a race condition to insert, do nothing
2013-02-05 14:16:51 -05:00
end
2017-05-25 15:07:12 -04:00
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
2016-07-07 22:58:18 -04:00
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 ] = category_notification_level == CategoryUser . notification_levels [ :watching ] ?
TopicUser . notification_reasons [ :auto_watch_category ] :
TopicUser . notification_reasons [ :auto_track_category ]
elsif tag_notification_level
attrs [ :notification_level ] = tag_notification_level
attrs [ :notifications_changed_at ] = DateTime . now
attrs [ :notifications_reason_id ] = tag_notification_level == TagUser . notification_levels [ :watching ] ?
TopicUser . notification_reasons [ :auto_watch_tag ] :
TopicUser . notification_reasons [ :auto_track_tag ]
end
end
unless attrs [ :notification_level ]
2018-02-26 04:57:16 -05:00
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?
2018-03-05 00:35:15 -05:00
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
2018-02-26 04:57:16 -05:00
else
2019-10-21 06:32:27 -04:00
auto_track_after = UserOption . where ( user_id : user_id ) . pluck_first ( :auto_track_topics_after_msecs )
2018-02-26 04:57:16 -05:00
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
2016-07-07 22:58:18 -04:00
end
end
2018-02-26 04:57:16 -05:00
TopicUser . create! ( attrs . merge! (
user_id : user_id ,
topic_id : topic_id ,
first_visited_at : now ,
last_visited_at : now
) )
2021-02-24 04:50:02 -05:00
DiscourseEvent . trigger ( :topic_first_visited_by_user , topic_id , user_id )
2016-07-07 22:58:18 -04:00
end
2016-05-14 04:06:29 -04:00
def track_visit! ( topic_id , user_id )
2013-03-06 15:17:07 -05:00
now = DateTime . now
2015-08-12 17:00:16 -04:00
rows = TopicUser . where ( topic_id : topic_id , user_id : user_id ) . update_all ( last_visited_at : now )
2016-05-14 04:06:29 -04:00
2013-03-06 15:17:07 -05:00
if rows == 0
2016-07-07 22:58:18 -04:00
change ( user_id , topic_id , last_visited_at : now , first_visited_at : now )
2013-03-06 15:17:07 -05:00
end
2013-02-05 14:16:51 -05:00
end
2013-03-06 15:17:07 -05:00
# 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
2016-02-18 00:57:22 -05:00
UPDATE_TOPIC_USER_SQL = " UPDATE topic_users
2013-03-06 15:17:07 -05:00
SET
2014-09-12 02:59:25 -04:00
last_read_post_number = GREATEST ( :post_number , tu . last_read_post_number ) ,
2014-10-30 18:40:35 -04:00
highest_seen_post_number = t . highest_post_number ,
2014-09-12 02:59:25 -04:00
total_msecs_viewed = LEAST ( tu . total_msecs_viewed + :msecs , 86400000 ) ,
2013-03-06 15:17:07 -05:00
notification_level =
case when tu . notifications_reason_id is null and ( tu . total_msecs_viewed + :msecs ) >
2016-02-18 00:57:22 -05:00
coalesce ( uo . auto_track_topics_after_msecs , :threshold ) and
2018-08-23 00:36:49 -04:00
coalesce ( uo . auto_track_topics_after_msecs , :threshold ) > = 0
and t . archetype = 'regular' then
2013-03-06 15:17:07 -05:00
: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
2016-02-18 00:57:22 -05:00
join user_options uo on uo . user_id = :user_id
2013-03-06 15:17:07 -05:00
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
2013-05-30 02:19:12 -04:00
topic_users . notification_level , tu . notification_level old_level , tu . last_read_post_number
2016-02-18 00:57:22 -05:00
"
2016-03-24 01:02:23 -04:00
2016-12-02 01:03:31 -05:00
UPDATE_TOPIC_USER_SQL_STAFF = UPDATE_TOPIC_USER_SQL . gsub ( " highest_post_number " , " highest_staff_post_number " )
2016-03-24 01:02:23 -04:00
INSERT_TOPIC_USER_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 ) "
2016-12-02 01:03:31 -05:00
INSERT_TOPIC_USER_SQL_STAFF = INSERT_TOPIC_USER_SQL . gsub ( " highest_post_number " , " highest_staff_post_number " )
2017-11-17 16:08:31 -05:00
def update_last_read ( user , topic_id , post_number , new_posts_read , msecs , opts = { } )
2016-02-18 00:57:22 -05:00
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
}
2021-05-20 21:43:47 -04:00
# In case anyone sees "highest_seen_post_number" and gets confused, like I do.
2016-02-18 00:57:22 -05:00
# 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
2016-12-02 01:03:31 -05:00
rows =
if user . staff?
2018-06-19 02:13:14 -04:00
DB . query ( UPDATE_TOPIC_USER_SQL_STAFF , args )
2016-12-02 01:03:31 -05:00
else
2018-06-19 02:13:14 -04:00
DB . query ( UPDATE_TOPIC_USER_SQL , args )
2016-12-02 01:03:31 -05:00
end
2013-03-06 15:17:07 -05:00
if rows . length == 1
2018-06-19 02:13:14 -04:00
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
2013-05-30 02:19:12 -04:00
if before_last_read < post_number
2014-01-24 15:19:20 -05:00
# The user read at least one new post
2014-02-26 15:37:42 -05:00
TopicTrackingState . publish_read ( topic_id , post_number , user . id , after )
2017-11-17 16:08:31 -05:00
end
if new_posts_read > 0
user . update_posts_read! ( new_posts_read , mobile : opts [ :mobile ] )
2013-05-30 02:19:12 -04:00
end
2013-03-06 15:17:07 -05:00
if before != after
2017-05-25 15:07:12 -04:00
notification_level_change ( user . id , topic_id , after , nil )
2013-03-06 15:17:07 -05:00
end
2013-02-05 14:16:51 -05:00
end
2013-03-06 15:17:07 -05:00
if rows . length == 0
2014-01-24 15:19:20 -05:00
# The user read at least one post in a topic that they haven't viewed before.
2014-02-26 15:37:42 -05:00
args [ :new_status ] = notification_levels [ :regular ]
2016-02-18 00:57:22 -05:00
if ( user . user_option . auto_track_topics_after_msecs || SiteSetting . default_other_auto_track_topics_after_msecs ) == 0
2014-02-26 15:37:42 -05:00
args [ :new_status ] = notification_levels [ :tracking ]
end
TopicTrackingState . publish_read ( topic_id , post_number , user . id , args [ :new_status ] )
2015-07-07 12:31:07 -04:00
2017-11-17 16:08:31 -05:00
user . update_posts_read! ( new_posts_read , mobile : opts [ :mobile ] )
2013-05-30 02:19:12 -04:00
2016-03-24 01:02:23 -04:00
begin
2016-12-02 01:03:31 -05:00
if user . staff?
2018-06-19 02:13:14 -04:00
DB . exec ( INSERT_TOPIC_USER_SQL_STAFF , args )
2016-12-02 01:03:31 -05:00
else
2018-06-19 02:13:14 -04:00
DB . exec ( INSERT_TOPIC_USER_SQL , args )
2016-12-02 01:03:31 -05:00
end
2016-03-24 01:02:23 -04:00
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
2017-11-17 16:08:31 -05:00
update_last_read ( user , topic_id , post_number , new_posts_read , msecs , opts )
2016-03-24 01:02:23 -04:00
end
end
2014-06-24 19:45:12 -04:00
2017-05-25 15:07:12 -04:00
notification_level_change ( user . id , topic_id , args [ :new_status ] , nil )
2013-03-06 15:17:07 -05:00
end
2013-02-05 14:16:51 -05:00
end
2013-03-06 15:17:07 -05:00
2013-02-05 14:16:51 -05:00
end
2013-03-06 15:17:07 -05:00
2015-01-07 22:35:56 -05:00
def self . update_post_action_cache ( opts = { } )
user_id = opts [ :user_id ]
2015-06-17 19:58:32 -04:00
post_id = opts [ :post_id ]
2015-01-07 22:35:56 -05:00
topic_id = opts [ :topic_id ]
action_type = opts [ :post_action_type ]
action_type_name = " liked " if action_type == :like
raise ArgumentError , " action_type " if action_type && ! action_type_name
unless action_type_name
update_post_action_cache ( opts . merge ( post_action_type : :like ) )
return
end
2018-06-20 03:48:02 -04:00
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
2015-01-07 22:35:56 -05:00
if user_id
builder . where ( " tu2.user_id = :user_id " , user_id : user_id )
end
if topic_id
builder . where ( " tu2.topic_id = :topic_id " , topic_id : topic_id )
end
2015-06-17 19:58:32 -04:00
if post_id
builder . where ( " tu2.topic_id IN (SELECT topic_id FROM posts WHERE id = :post_id) " , post_id : post_id )
builder . where ( " tu2.user_id IN (SELECT user_id FROM post_actions
WHERE post_id = :post_id AND
post_action_type_id = :action_type_id ) " )
end
2015-01-07 22:35:56 -05:00
builder . exec ( action_type_id : PostActionType . types [ action_type ] )
end
2015-09-06 21:57:50 -04:00
# cap number of unread topics at count, bumping up highest_seen / last_read if needed
def self . cap_unread! ( user_id , count )
sql = <<SQL
UPDATE topic_users tu
SET last_read_post_number = max_number ,
highest_seen_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
2018-06-19 02:13:14 -04:00
DB . exec ( sql , user_id : user_id , count : count )
2015-09-06 21:57:50 -04:00
end
2013-07-03 21:47:12 -04:00
def self . ensure_consistency! ( topic_id = nil )
2015-02-02 23:59:26 -05:00
update_post_action_cache ( topic_id : topic_id )
2015-01-07 22:35:56 -05:00
2014-08-10 20:26:46 -04:00
# 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
2018-06-20 03:48:02 -04:00
builder = DB . build << ~ 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
2013-07-03 21:47:12 -04:00
if topic_id
builder . where ( " t.topic_id = :topic_id " , topic_id : topic_id )
end
builder . exec
2013-04-05 00:29:46 -04:00
end
2013-02-05 14:16:51 -05:00
end
2013-05-23 22:48:32 -04:00
# == 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
2014-10-30 18:40:35 -04:00
# highest_seen_post_number :integer
2013-05-23 22:48:32 -04:00
# 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
2013-08-13 16:09:27 -04:00
# id :integer not null, primary key
2014-02-06 19:07:36 -05:00
# last_emailed_post_number :integer
2015-02-04 00:34:25 -05:00
# liked :boolean default(FALSE)
# bookmarked :boolean default(FALSE)
2020-10-27 14:12:33 -04:00
# last_posted_at :datetime
2013-05-23 22:48:32 -04:00
#
# Indexes
#
2020-10-27 14:12:33 -04:00
# index_forum_thread_users_on_forum_thread_id_and_user_id (topic_id,user_id) UNIQUE
# index_topic_users_on_user_id_and_topic_id (user_id,topic_id) UNIQUE
2013-05-23 22:48:32 -04:00
#