mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-28 01:56:01 -05:00
d23ef1d090
removed extra characters. Additionally, updating the title will not return an error message to the client app if the operation fails (rather than failing silently.)
667 lines
22 KiB
Ruby
667 lines
22 KiB
Ruby
require_dependency 'slug'
|
|
require_dependency 'avatar_lookup'
|
|
require_dependency 'topic_view'
|
|
require_dependency 'rate_limiter'
|
|
require_dependency 'text_sentinel'
|
|
require_dependency 'text_cleaner'
|
|
require_dependency 'trashable'
|
|
|
|
class Topic < ActiveRecord::Base
|
|
include ActionView::Helpers
|
|
include RateLimiter::OnCreateRecord
|
|
include Trashable
|
|
|
|
def self.max_sort_order
|
|
2**31 - 1
|
|
end
|
|
|
|
def self.featured_users_count
|
|
4
|
|
end
|
|
|
|
versioned if: :new_version_required?
|
|
|
|
|
|
def trash!
|
|
super
|
|
update_flagged_posts_count
|
|
end
|
|
|
|
def recover!
|
|
super
|
|
update_flagged_posts_count
|
|
end
|
|
|
|
rate_limit :default_rate_limiter
|
|
rate_limit :limit_topics_per_day
|
|
rate_limit :limit_private_messages_per_day
|
|
|
|
before_validation :sanitize_title
|
|
|
|
validates :title, :presence => true,
|
|
:length => { :in => SiteSetting.topic_title_length,
|
|
:allow_blank => true },
|
|
:quality_title => { :unless => :private_message? },
|
|
:unique_among => { :unless => Proc.new { |t| (SiteSetting.allow_duplicate_topic_titles? || t.private_message?) },
|
|
:message => :has_already_been_used,
|
|
:allow_blank => true,
|
|
:case_sensitive => false,
|
|
:collection => Proc.new{ Topic.listable_topics } }
|
|
|
|
before_validation do
|
|
self.title = TextCleaner.clean_title(TextSentinel.title_sentinel(title).text) if errors[:title].empty?
|
|
end
|
|
|
|
serialize :meta_data, ActiveRecord::Coders::Hstore
|
|
|
|
belongs_to :category
|
|
has_many :posts
|
|
has_many :topic_allowed_users
|
|
has_many :topic_allowed_groups
|
|
|
|
has_many :allowed_group_users, through: :allowed_groups, source: :users
|
|
has_many :allowed_groups, through: :topic_allowed_groups, source: :group
|
|
has_many :allowed_users, through: :topic_allowed_users, source: :user
|
|
|
|
has_one :hot_topic
|
|
belongs_to :user
|
|
belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id
|
|
belongs_to :featured_user1, class_name: 'User', foreign_key: :featured_user1_id
|
|
belongs_to :featured_user2, class_name: 'User', foreign_key: :featured_user2_id
|
|
belongs_to :featured_user3, class_name: 'User', foreign_key: :featured_user3_id
|
|
belongs_to :featured_user4, class_name: 'User', foreign_key: :featured_user4_id
|
|
belongs_to :auto_close_user, class_name: 'User', foreign_key: :auto_close_user_id
|
|
|
|
has_many :topic_users
|
|
has_many :topic_links
|
|
has_many :topic_invites
|
|
has_many :invites, through: :topic_invites, source: :invite
|
|
|
|
# When we want to temporarily attach some data to a forum topic (usually before serialization)
|
|
attr_accessor :user_data
|
|
attr_accessor :posters # TODO: can replace with posters_summary once we remove old list code
|
|
attr_accessor :topic_list
|
|
|
|
# The regular order
|
|
scope :topic_list_order, lambda { order('topics.bumped_at desc') }
|
|
|
|
# Return private message topics
|
|
scope :private_messages, lambda {
|
|
where(archetype: Archetype::private_message)
|
|
}
|
|
|
|
scope :listable_topics, lambda { where('topics.archetype <> ?', [Archetype.private_message]) }
|
|
|
|
scope :by_newest, order('topics.created_at desc, topics.id desc')
|
|
|
|
scope :visible, where(visible: true)
|
|
|
|
scope :created_since, lambda { |time_ago| where('created_at > ?', time_ago) }
|
|
|
|
# Helps us limit how many favorites can be made in a day
|
|
class FavoriteLimiter < RateLimiter
|
|
def initialize(user)
|
|
super(user, "favorited:#{Date.today.to_s}", SiteSetting.max_favorites_per_day, 1.day.to_i)
|
|
end
|
|
end
|
|
|
|
before_create do
|
|
self.bumped_at ||= Time.now
|
|
self.last_post_user_id ||= user_id
|
|
if !@ignore_category_auto_close and self.category and self.category.auto_close_days and self.auto_close_at.nil?
|
|
self.auto_close_at = self.category.auto_close_days.days.from_now
|
|
self.auto_close_user = (self.user.staff? ? self.user : Discourse.system_user)
|
|
end
|
|
end
|
|
|
|
after_create do
|
|
changed_to_category(category)
|
|
notifier.created_topic! user_id
|
|
if archetype == Archetype.private_message
|
|
DraftSequence.next!(user, Draft::NEW_PRIVATE_MESSAGE)
|
|
else
|
|
DraftSequence.next!(user, Draft::NEW_TOPIC)
|
|
end
|
|
end
|
|
|
|
before_save do
|
|
if (auto_close_at_changed? and !auto_close_at_was.nil?) or (auto_close_user_id_changed? and auto_close_at)
|
|
Jobs.cancel_scheduled_job(:close_topic, {topic_id: id})
|
|
true
|
|
end
|
|
end
|
|
|
|
after_save do
|
|
if auto_close_at and (auto_close_at_changed? or auto_close_user_id_changed?)
|
|
Jobs.enqueue_at(auto_close_at, :close_topic, {topic_id: id, user_id: auto_close_user_id || user_id})
|
|
end
|
|
end
|
|
|
|
# all users (in groups or directly targetted) that are going to get the pm
|
|
def all_allowed_users
|
|
# TODO we should probably change this from 3 queries to 1
|
|
User.where('id in (?)', allowed_users.select('users.id').to_a + allowed_group_users.select('users.id').to_a)
|
|
end
|
|
|
|
# Additional rate limits on topics: per day and private messages per day
|
|
def limit_topics_per_day
|
|
RateLimiter.new(user, "topics-per-day:#{Date.today.to_s}", SiteSetting.max_topics_per_day, 1.day.to_i)
|
|
end
|
|
|
|
def limit_private_messages_per_day
|
|
return unless private_message?
|
|
RateLimiter.new(user, "pms-per-day:#{Date.today.to_s}", SiteSetting.max_private_messages_per_day, 1.day.to_i)
|
|
end
|
|
|
|
def fancy_title
|
|
return title unless SiteSetting.title_fancy_entities?
|
|
|
|
# We don't always have to require this, if fancy is disabled
|
|
# see: http://meta.discourse.org/t/pattern-for-defer-loading-gems-and-profiling-with-perftools-rb/4629
|
|
require 'redcarpet' unless defined? Redcarpet
|
|
|
|
Redcarpet::Render::SmartyPants.render(title)
|
|
end
|
|
|
|
def sanitize_title
|
|
self.title = sanitize(title.to_s, tags: [], attributes: []).strip.presence
|
|
end
|
|
|
|
def new_version_required?
|
|
title_changed? || category_id_changed?
|
|
end
|
|
|
|
# Returns new topics since a date for display in email digest.
|
|
def self.new_topics(since)
|
|
Topic
|
|
.visible
|
|
.where(closed: false, archived: false)
|
|
.created_since(since)
|
|
.listable_topics
|
|
.topic_list_order
|
|
.includes(:user)
|
|
.limit(5)
|
|
end
|
|
|
|
def update_meta_data(data)
|
|
self.meta_data = (self.meta_data || {}).merge(data.stringify_keys)
|
|
save
|
|
end
|
|
|
|
def reload(options=nil)
|
|
@post_numbers = nil
|
|
super(options)
|
|
end
|
|
|
|
def post_numbers
|
|
@post_numbers ||= posts.order(:post_number).pluck(:post_number)
|
|
end
|
|
|
|
def age_in_days
|
|
((Time.zone.now - created_at) / 1.day).round
|
|
end
|
|
|
|
def has_meta_data_boolean?(key)
|
|
meta_data_string(key) == 'true'
|
|
end
|
|
|
|
def meta_data_string(key)
|
|
return unless meta_data.present?
|
|
meta_data[key.to_s]
|
|
end
|
|
|
|
|
|
|
|
def self.listable_count_per_day(sinceDaysAgo=30)
|
|
listable_topics.where('created_at > ?', sinceDaysAgo.days.ago).group('date(created_at)').order('date(created_at)').count
|
|
end
|
|
|
|
def private_message?
|
|
archetype == Archetype.private_message
|
|
end
|
|
|
|
def links_grouped
|
|
exec_sql("SELECT ftl.url,
|
|
ft.title,
|
|
ftl.link_topic_id,
|
|
ftl.reflection,
|
|
ftl.internal,
|
|
MIN(ftl.user_id) AS user_id,
|
|
SUM(clicks) AS clicks
|
|
FROM topic_links AS ftl
|
|
LEFT OUTER JOIN topics AS ft ON ftl.link_topic_id = ft.id
|
|
WHERE ftl.topic_id = ?
|
|
GROUP BY ftl.url, ft.title, ftl.link_topic_id, ftl.reflection, ftl.internal
|
|
ORDER BY clicks DESC",
|
|
id).to_a
|
|
end
|
|
|
|
# Search for similar topics
|
|
def self.similar_to(title, raw)
|
|
return [] unless title.present?
|
|
return [] unless raw.present?
|
|
|
|
# For now, we only match on title. We'll probably add body later on, hence the API hook
|
|
Topic.select(sanitize_sql_array(["topics.*, similarity(topics.title, :title) AS similarity", title: title]))
|
|
.visible
|
|
.where(closed: false, archived: false)
|
|
.listable_topics
|
|
.limit(SiteSetting.max_similar_results)
|
|
.order('similarity desc')
|
|
.all
|
|
end
|
|
|
|
def update_status(status, enabled, user)
|
|
TopicStatusUpdate.new(self, user).update! status, enabled
|
|
end
|
|
|
|
# Atomically creates the next post number
|
|
def self.next_post_number(topic_id, reply = false)
|
|
highest = exec_sql("select coalesce(max(post_number),0) as max from posts where topic_id = ?", topic_id).first['max'].to_i
|
|
|
|
reply_sql = reply ? ", reply_count = reply_count + 1" : ""
|
|
result = exec_sql("UPDATE topics SET highest_post_number = ? + 1#{reply_sql}
|
|
WHERE id = ? RETURNING highest_post_number", highest, topic_id)
|
|
result.first['highest_post_number'].to_i
|
|
end
|
|
|
|
# If a post is deleted we have to update our highest post counters
|
|
def self.reset_highest(topic_id)
|
|
result = exec_sql "UPDATE topics
|
|
SET highest_post_number = (SELECT COALESCE(MAX(post_number), 0) FROM posts WHERE topic_id = :topic_id AND deleted_at IS NULL),
|
|
posts_count = (SELECT count(*) FROM posts WHERE deleted_at IS NULL AND topic_id = :topic_id)
|
|
WHERE id = :topic_id
|
|
RETURNING highest_post_number", topic_id: topic_id
|
|
highest_post_number = result.first['highest_post_number'].to_i
|
|
|
|
# Update the forum topic user records
|
|
exec_sql "UPDATE topic_users
|
|
SET last_read_post_number = CASE
|
|
WHEN last_read_post_number > :highest THEN :highest
|
|
ELSE last_read_post_number
|
|
END,
|
|
seen_post_count = CASE
|
|
WHEN seen_post_count > :highest THEN :highest
|
|
ELSE seen_post_count
|
|
END
|
|
WHERE topic_id = :topic_id",
|
|
highest: highest_post_number,
|
|
topic_id: topic_id
|
|
end
|
|
|
|
# This calculates the geometric mean of the posts and stores it with the topic
|
|
def self.calculate_avg_time
|
|
exec_sql("UPDATE topics
|
|
SET avg_time = x.gmean
|
|
FROM (SELECT topic_id,
|
|
round(exp(avg(ln(avg_time)))) AS gmean
|
|
FROM posts
|
|
GROUP BY topic_id) AS x
|
|
WHERE x.topic_id = topics.id")
|
|
end
|
|
|
|
def changed_to_category(cat)
|
|
|
|
return if cat.blank?
|
|
return if Category.where(topic_id: id).first.present?
|
|
|
|
Topic.transaction do
|
|
old_category = category
|
|
|
|
if category_id.present? && category_id != cat.id
|
|
Category.update_all 'topic_count = topic_count - 1', ['id = ?', category_id]
|
|
end
|
|
|
|
self.category_id = cat.id
|
|
save
|
|
|
|
CategoryFeaturedTopic.feature_topics_for(old_category)
|
|
Category.update_all 'topic_count = topic_count + 1', id: cat.id
|
|
CategoryFeaturedTopic.feature_topics_for(cat) unless old_category.try(:id) == cat.try(:id)
|
|
end
|
|
end
|
|
|
|
def add_moderator_post(user, text, opts={})
|
|
new_post = nil
|
|
Topic.transaction do
|
|
creator = PostCreator.new(user,
|
|
raw: text,
|
|
post_type: Post.types[:moderator_action],
|
|
no_bump: opts[:bump].blank?,
|
|
topic_id: self.id)
|
|
new_post = creator.create
|
|
increment!(:moderator_posts_count)
|
|
new_post
|
|
end
|
|
|
|
if new_post.present?
|
|
# If we are moving posts, we want to insert the moderator post where the previous posts were
|
|
# in the stream, not at the end.
|
|
new_post.update_attributes(post_number: opts[:post_number], sort_order: opts[:post_number]) if opts[:post_number].present?
|
|
|
|
# Grab any links that are present
|
|
TopicLink.extract_from(new_post)
|
|
end
|
|
|
|
new_post
|
|
end
|
|
|
|
# Changes the category to a new name
|
|
def change_category(name)
|
|
# If the category name is blank, reset the attribute
|
|
if name.blank?
|
|
if category_id.present?
|
|
CategoryFeaturedTopic.feature_topics_for(category)
|
|
Category.update_all 'topic_count = topic_count - 1', id: category_id
|
|
end
|
|
self.category_id = nil
|
|
save
|
|
return
|
|
end
|
|
|
|
cat = Category.where(name: name).first
|
|
return if cat == category
|
|
changed_to_category(cat)
|
|
end
|
|
|
|
def featured_user_ids
|
|
[featured_user1_id, featured_user2_id, featured_user3_id, featured_user4_id].uniq.compact
|
|
end
|
|
|
|
# Invite a user to the topic by username or email. Returns success/failure
|
|
def invite(invited_by, username_or_email)
|
|
if private_message?
|
|
# If the user exists, add them to the topic.
|
|
user = User.find_by_username_or_email(username_or_email).first
|
|
if user.present?
|
|
if topic_allowed_users.create!(user_id: user.id)
|
|
# Notify the user they've been invited
|
|
user.notifications.create(notification_type: Notification.types[:invited_to_private_message],
|
|
topic_id: id,
|
|
post_number: 1,
|
|
data: { topic_title: title,
|
|
display_username: invited_by.username }.to_json)
|
|
return true
|
|
end
|
|
elsif username_or_email =~ /^.+@.+$/
|
|
# If the user doesn't exist, but it looks like an email, invite the user by email.
|
|
return invite_by_email(invited_by, username_or_email)
|
|
end
|
|
else
|
|
# Success is whether the invite was created
|
|
return invite_by_email(invited_by, username_or_email).present?
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
# Invite a user by email and return the invite. Return the previously existing invite
|
|
# if already exists. Returns nil if the invite can't be created.
|
|
def invite_by_email(invited_by, email)
|
|
lower_email = Email.downcase(email)
|
|
invite = Invite.with_deleted.where('invited_by_id = ? and email = ?', invited_by.id, lower_email).first
|
|
|
|
if invite.blank?
|
|
invite = Invite.create(invited_by: invited_by, email: lower_email)
|
|
unless invite.valid?
|
|
|
|
# If the email already exists, grant permission to that user
|
|
if invite.email_already_exists and private_message?
|
|
user = User.where(email: lower_email).first
|
|
topic_allowed_users.create!(user_id: user.id)
|
|
end
|
|
|
|
return
|
|
end
|
|
end
|
|
|
|
# Recover deleted invites if we invite them again
|
|
invite.recover if invite.deleted_at.present?
|
|
|
|
topic_invites.create(invite_id: invite.id)
|
|
Jobs.enqueue(:invite_email, invite_id: invite.id)
|
|
invite
|
|
end
|
|
|
|
def max_post_number
|
|
posts.maximum(:post_number).to_i
|
|
end
|
|
|
|
def move_posts(moved_by, post_ids, opts)
|
|
post_mover = PostMover.new(self, moved_by, post_ids)
|
|
|
|
if opts[:destination_topic_id]
|
|
post_mover.to_topic opts[:destination_topic_id]
|
|
elsif opts[:title]
|
|
post_mover.to_new_topic opts[:title]
|
|
end
|
|
end
|
|
|
|
# Updates the denormalized statistics of a topic including featured posters. They shouldn't
|
|
# go out of sync unless you do something drastic live move posts from one topic to another.
|
|
# this recalculates everything.
|
|
def update_statistics
|
|
feature_topic_users
|
|
update_action_counts
|
|
Topic.reset_highest(id)
|
|
end
|
|
|
|
def update_flagged_posts_count
|
|
PostAction.update_flagged_posts_count
|
|
end
|
|
|
|
def update_action_counts
|
|
PostActionType.types.keys.each do |type|
|
|
count_field = "#{type}_count"
|
|
update_column(count_field, Post.where(topic_id: id).sum(count_field))
|
|
end
|
|
end
|
|
|
|
# Chooses which topic users to feature
|
|
def feature_topic_users(args={})
|
|
reload
|
|
|
|
# Don't include the OP or the last poster
|
|
to_feature = posts.where('user_id NOT IN (?, ?)', user_id, last_post_user_id)
|
|
|
|
# Exclude a given post if supplied (in the case of deletes)
|
|
to_feature = to_feature.where("id <> ?", args[:except_post_id]) if args[:except_post_id].present?
|
|
|
|
# Clear the featured users by default
|
|
Topic.featured_users_count.times do |i|
|
|
send("featured_user#{i+1}_id=", nil)
|
|
end
|
|
|
|
# Assign the featured_user{x} columns
|
|
to_feature = to_feature.group(:user_id).order('count_all desc').limit(Topic.featured_users_count)
|
|
|
|
to_feature.count.keys.each_with_index do |user_id, i|
|
|
send("featured_user#{i+1}_id=", user_id)
|
|
end
|
|
|
|
save
|
|
end
|
|
|
|
def posters_summary(options = {})
|
|
@posters_summary ||= TopicPostersSummary.new(self, options).summary
|
|
end
|
|
|
|
# Enable/disable the star on the topic
|
|
def toggle_star(user, starred)
|
|
Topic.transaction do
|
|
TopicUser.change(user, id, {starred: starred}.merge( starred ? {starred_at: DateTime.now, unstarred_at: nil} : {unstarred_at: DateTime.now}))
|
|
|
|
# Update the star count
|
|
exec_sql "UPDATE topics
|
|
SET star_count = (SELECT COUNT(*)
|
|
FROM topic_users AS ftu
|
|
WHERE ftu.topic_id = topics.id
|
|
AND ftu.starred = true)
|
|
WHERE id = ?", id
|
|
|
|
if starred
|
|
FavoriteLimiter.new(user).performed!
|
|
else
|
|
FavoriteLimiter.new(user).rollback!
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.starred_counts_per_day(sinceDaysAgo=30)
|
|
TopicUser.starred_since(sinceDaysAgo).by_date_starred.count
|
|
end
|
|
|
|
def slug
|
|
unless slug = read_attribute(:slug)
|
|
return '' unless title.present?
|
|
slug = Slug.for(title).presence || "topic"
|
|
if new_record?
|
|
write_attribute(:slug, slug)
|
|
else
|
|
update_column(:slug, slug)
|
|
end
|
|
end
|
|
|
|
slug
|
|
end
|
|
|
|
def title=(t)
|
|
slug = (Slug.for(t.to_s).presence || "topic")
|
|
write_attribute(:slug, slug)
|
|
write_attribute(:title,t)
|
|
end
|
|
|
|
# NOTE: These are probably better off somewhere else.
|
|
# Having a model know about URLs seems a bit strange.
|
|
def last_post_url
|
|
"/t/#{slug}/#{id}/#{posts_count}"
|
|
end
|
|
|
|
def self.url(id, slug, post_number=nil)
|
|
url = "#{Discourse.base_url}/t/#{slug}/#{id}"
|
|
url << "/#{post_number}" if post_number.to_i > 1
|
|
url
|
|
end
|
|
|
|
def url(post_number = nil)
|
|
self.class.url id, slug, post_number
|
|
end
|
|
|
|
def relative_url(post_number=nil)
|
|
url = "/t/#{slug}/#{id}"
|
|
url << "/#{post_number}" if post_number.to_i > 1
|
|
url
|
|
end
|
|
|
|
def clear_pin_for(user)
|
|
return unless user.present?
|
|
TopicUser.change(user.id, id, cleared_pinned_at: Time.now)
|
|
end
|
|
|
|
def update_pinned(status)
|
|
update_column(:pinned_at, status ? Time.now : nil)
|
|
end
|
|
|
|
def draft_key
|
|
"#{Draft::EXISTING_TOPIC}#{id}"
|
|
end
|
|
|
|
def notifier
|
|
@topic_notifier ||= TopicNotifier.new(self)
|
|
end
|
|
|
|
# notification stuff
|
|
def notify_watch!(user)
|
|
notifier.watch! user
|
|
end
|
|
|
|
def notify_tracking!(user)
|
|
notifier.tracking! user
|
|
end
|
|
|
|
def notify_regular!(user)
|
|
notifier.regular! user
|
|
end
|
|
|
|
def notify_muted!(user)
|
|
notifier.muted! user
|
|
end
|
|
|
|
def muted?(user)
|
|
if user && user.id
|
|
notifier.muted?(user.id)
|
|
end
|
|
end
|
|
|
|
# Enable/disable the mute on the topic
|
|
def toggle_mute(user_id)
|
|
notifier.toggle_mute user_id
|
|
end
|
|
|
|
def auto_close_days=(num_days)
|
|
@ignore_category_auto_close = true
|
|
num_days = num_days.to_i
|
|
self.auto_close_at = (num_days > 0 ? num_days.days.from_now : nil)
|
|
end
|
|
|
|
def secure_category?
|
|
category && category.secure
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: topics
|
|
#
|
|
# id :integer not null, primary key
|
|
# title :string(255) not null
|
|
# last_posted_at :datetime
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# views :integer default(0), not null
|
|
# posts_count :integer default(0), not null
|
|
# user_id :integer not null
|
|
# last_post_user_id :integer not null
|
|
# reply_count :integer default(0), not null
|
|
# featured_user1_id :integer
|
|
# featured_user2_id :integer
|
|
# featured_user3_id :integer
|
|
# avg_time :integer
|
|
# deleted_at :datetime
|
|
# highest_post_number :integer default(0), not null
|
|
# image_url :string(255)
|
|
# off_topic_count :integer default(0), not null
|
|
# like_count :integer default(0), not null
|
|
# incoming_link_count :integer default(0), not null
|
|
# bookmark_count :integer default(0), not null
|
|
# star_count :integer default(0), not null
|
|
# category_id :integer
|
|
# visible :boolean default(TRUE), not null
|
|
# moderator_posts_count :integer default(0), not null
|
|
# closed :boolean default(FALSE), not null
|
|
# archived :boolean default(FALSE), not null
|
|
# bumped_at :datetime not null
|
|
# has_best_of :boolean default(FALSE), not null
|
|
# meta_data :hstore
|
|
# vote_count :integer default(0), not null
|
|
# archetype :string(255) default("regular"), not null
|
|
# featured_user4_id :integer
|
|
# notify_moderators_count :integer default(0), not null
|
|
# spam_count :integer default(0), not null
|
|
# illegal_count :integer default(0), not null
|
|
# inappropriate_count :integer default(0), not null
|
|
# pinned_at :datetime
|
|
# score :float
|
|
# percent_rank :float default(1.0), not null
|
|
# notify_user_count :integer default(0), not null
|
|
# subtype :string(255)
|
|
# slug :string(255)
|
|
# auto_close_at :datetime
|
|
# auto_close_user_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# idx_topics_user_id_deleted_at (user_id)
|
|
# index_forum_threads_on_bumped_at (bumped_at)
|
|
#
|
|
|