discourse/lib/post_destroyer.rb
Sam Saffron 9ebabc1de8 FEATURE: unconditionally update Topic updated_at when posts change in topic
Previously we would bypass touching `Topic.updated_at` for whispers and post
recovery / deletions.

This meant that certain types of caching can not be done where we rely on
this information for cache accuracy.

For example if we know we have zero unread topics as of yesterday and whisper
is made I need to bump this date so the cache remains accurate

This is only half of a larger change but provides the groundwork.

Confirmed none of our serializers leak out Topic.updated_at so this is safe
spot for this info

At the moment edits still do not change this but it is not relevant for the
unread cache.

This commit also cleans up some specs to use the new `eq_time` matcher for
millisecond fidelity comparison of times

Previously `freeze_time` would fudge this which is not that clean.
2019-03-28 17:28:01 +11:00

350 lines
11 KiB
Ruby

#
# How a post is deleted is affected by who is performing the action.
# this class contains the logic to delete it.
#
class PostDestroyer
def self.destroy_old_hidden_posts
Post.where(deleted_at: nil, hidden: true)
.where("hidden_at < ?", 30.days.ago)
.find_each do |post|
PostDestroyer.new(Discourse.system_user, post).destroy
end
end
def self.destroy_stubs
context = I18n.t('remove_posts_deleted_by_author')
# exclude deleted topics and posts that are actively flagged
Post.where(deleted_at: nil, user_deleted: true)
.where("NOT EXISTS (
SELECT 1 FROM topics t
WHERE t.deleted_at IS NOT NULL AND
t.id = posts.topic_id
)")
.where("updated_at < ? AND post_number > 1", SiteSetting.delete_removed_posts_after.hours.ago)
.where("NOT EXISTS (
SELECT 1
FROM post_actions pa
WHERE pa.post_id = posts.id
AND pa.deleted_at IS NULL
AND pa.deferred_at IS NULL
AND pa.post_action_type_id IN (?)
)", PostActionType.notify_flag_type_ids)
.find_each do |post|
PostDestroyer.new(Discourse.system_user, post, context: context).destroy
end
end
def initialize(user, post, opts = {})
@user = user
@post = post
@topic = post.topic if post
@opts = opts
end
def destroy
payload = WebHook.generate_payload(:post, @post) if WebHook.active_web_hooks(:post).exists?
topic = @post.topic
if @post.is_first_post? && topic
topic_view = TopicView.new(topic.id, Discourse.system_user)
topic_payload = WebHook.generate_payload(:topic, topic_view, WebHookTopicViewSerializer) if WebHook.active_web_hooks(:topic).exists?
end
delete_removed_posts_after = @opts[:delete_removed_posts_after] || SiteSetting.delete_removed_posts_after
if @user.staff? || delete_removed_posts_after < 1
perform_delete
elsif @user.id == @post.user_id
mark_for_deletion(delete_removed_posts_after)
end
DiscourseEvent.trigger(:post_destroyed, @post, @opts, @user)
WebHook.enqueue_post_hooks(:post_destroyed, @post, payload)
if @post.is_first_post? && @post.topic
DiscourseEvent.trigger(:topic_destroyed, @post.topic, @user)
WebHook.enqueue_topic_hooks(:topic_destroyed, @post.topic, topic_payload)
end
end
def recover
if @user.staff? && @post.deleted_at
staff_recovered
elsif @user.staff? || @user.id == @post.user_id
user_recovered
end
topic = Topic.with_deleted.find @post.topic_id
topic.recover! if @post.is_first_post?
topic.update_statistics
recover_user_actions
DiscourseEvent.trigger(:post_recovered, @post, @opts, @user)
if @post.is_first_post?
DiscourseEvent.trigger(:topic_recovered, topic, @user)
StaffActionLogger.new(@user).log_topic_delete_recover(topic, "recover_topic", @opts.slice(:context)) if @user.id != @post.user_id
end
end
def staff_recovered
@post.recover!
mark_topic_changed
if @post.topic && !@post.topic.private_message?
if author = @post.user
if @post.is_first_post?
author.user_stat.topic_count += 1
else
author.user_stat.post_count += 1
end
author.user_stat.save!
end
if @post.is_first_post?
# Update stats of all people who replied
counts = Post.where(post_type: Post.types[:regular], topic_id: @post.topic_id).where('post_number > 1').group(:user_id).count
counts.each do |user_id, count|
if user_stat = UserStat.where(user_id: user_id).first
user_stat.update_attributes(post_count: user_stat.post_count + count)
end
end
end
end
@post.publish_change_to_clients! :recovered
TopicTrackingState.publish_recover(@post.topic) if @post.topic && @post.is_first_post?
end
# When a post is properly deleted. Well, it's still soft deleted, but it will no longer
# show up in the topic
def perform_delete
Post.transaction do
@post.trash!(@user)
if @post.topic
make_previous_post_the_last_one
mark_topic_changed
clear_user_posted_flag
Topic.reset_highest(@post.topic_id)
end
trash_public_post_actions
trash_user_actions
@post.update_flagged_posts_count
remove_associated_replies
remove_associated_notifications
if @post.topic && @post.is_first_post?
StaffActionLogger.new(@user).log_topic_delete_recover(@post.topic, "delete_topic", @opts.slice(:context)) if @user.id != @post.user_id
@post.topic.trash!(@user)
elsif @user.id != @post.user_id
StaffActionLogger.new(@user).log_post_deletion(@post, @opts.slice(:context))
end
update_associated_category_latest_topic
update_user_counts
TopicUser.update_post_action_cache(post_id: @post.id)
DB.after_commit do
if @opts[:defer_flags]
defer_flags
else
agree_with_flags
end
end
end
feature_users_in_the_topic if @post.topic
@post.publish_change_to_clients! :deleted if @post.topic
TopicTrackingState.publish_delete(@post.topic) if @post.topic && @post.post_number == 1
end
# When a user 'deletes' their own post. We just change the text.
def mark_for_deletion(delete_removed_posts_after = SiteSetting.delete_removed_posts_after)
I18n.with_locale(SiteSetting.default_locale) do
# don't call revise from within transaction, high risk of deadlock
@post.revise(@user,
{ raw: I18n.t('js.post.deleted_by_author', count: delete_removed_posts_after) },
force_new_version: true
)
Post.transaction do
@post.update_column(:user_deleted, true)
@post.update_flagged_posts_count
@post.topic_links.each(&:destroy)
end
end
end
def user_recovered
Post.transaction do
@post.update_column(:user_deleted, false)
@post.skip_unique_check = true
@post.update_flagged_posts_count
end
# has internal transactions, if we nest then there are some very high risk deadlocks
last_revision = @post.revisions.last
@post.revise(@user, { raw: last_revision.modifications["raw"][0] }, force_new_version: true) if last_revision.present?
end
private
# we need topics to change if ever a post in them is deleted or created
# this ensures users relying on this information can keep unread tracking
# working as desired
def mark_topic_changed
# make this as fast as possible, can bypass everything
DB.exec(<<~SQL, updated_at: Time.now, id: @post.topic_id)
UPDATE topics
SET updated_at = :updated_at
WHERE id = :id
SQL
end
def make_previous_post_the_last_one
last_post = Post
.select(:created_at, :user_id, :post_number)
.where("topic_id = ? and id <> ?", @post.topic_id, @post.id)
.order('created_at desc')
.limit(1)
.first
if last_post.present? && @post.topic.present?
topic = @post.topic
topic.last_posted_at = last_post.created_at
topic.last_post_user_id = last_post.user_id
topic.highest_post_number = last_post.post_number
# we go via save here cause we need to run hooks
topic.save!(validate: false)
end
end
def clear_user_posted_flag
unless Post.exists?(["topic_id = ? and user_id = ? and id <> ?", @post.topic_id, @post.user_id, @post.id])
TopicUser.where(topic_id: @post.topic_id, user_id: @post.user_id).update_all 'posted = false'
end
end
def feature_users_in_the_topic
Jobs.enqueue(:feature_topic_users, topic_id: @post.topic_id)
end
def trash_public_post_actions
if public_post_actions = PostAction.publics.where(post_id: @post.id)
public_post_actions.each { |pa| pa.trash!(@user) }
@post.custom_fields["deleted_public_actions"] = public_post_actions.ids
@post.save_custom_fields
f = PostActionType.public_types.map { |k, _| ["#{k}_count", 0] }
Post.with_deleted.where(id: @post.id).update_all(Hash[*f.flatten])
end
end
def agree_with_flags
if @post.has_active_flag? && @user.human? && @user.staff?
Jobs.enqueue(
:send_system_message,
user_id: @post.user_id,
message_type: :flags_agreed_and_post_deleted,
message_options: {
flagged_post_raw_content: @post.raw,
url: @post.url,
flag_reason: I18n.t(
"flag_reasons.#{@post.active_flags.last.post_action_type.name_key}",
locale: SiteSetting.default_locale,
base_path: Discourse.base_path
)
}
)
end
PostAction.agree_flags!(@post, @user, delete_post: true)
end
def defer_flags
PostAction.defer_flags!(@post, @user, delete_post: true)
end
def trash_user_actions
UserAction.where(target_post_id: @post.id).each do |ua|
row = {
action_type: ua.action_type,
user_id: ua.user_id,
acting_user_id: ua.acting_user_id,
target_topic_id: ua.target_topic_id,
target_post_id: ua.target_post_id
}
UserAction.remove_action!(row)
end
end
def recover_user_actions
# TODO: Use a trash concept for `user_actions` to avoid churn and simplify this?
UserActionCreator.log_post(@post)
end
def remove_associated_replies
post_ids = PostReply.where(reply_id: @post.id).pluck(:post_id)
if post_ids.present?
PostReply.where(reply_id: @post.id).delete_all
Post.where(id: post_ids).each { |p| p.update_column :reply_count, p.replies.count }
end
end
def remove_associated_notifications
Notification
.where(topic_id: @post.topic_id, post_number: @post.post_number)
.delete_all
end
def update_associated_category_latest_topic
return unless @post.topic && @post.topic.category
return unless @post.id == @post.topic.category.latest_post_id || (@post.is_first_post? && @post.topic_id == @post.topic.category.latest_topic_id)
@post.topic.category.update_latest
end
def update_user_counts
author = @post.user
return unless author
author.create_user_stat if author.user_stat.nil?
if @post.created_at == author.user_stat.first_post_created_at
author.user_stat.first_post_created_at = author.posts.order('created_at ASC').first.try(:created_at)
end
if @post.topic && !@post.topic.private_message?
if @post.post_type == Post.types[:regular] && !@post.is_first_post? && !@topic.nil?
author.user_stat.post_count -= 1
end
author.user_stat.topic_count -= 1 if @post.is_first_post?
end
# We don't count replies to your own topics in topic_reply_count
if @topic && author.id != @topic.user_id
author.user_stat.update_topic_reply_count
end
author.user_stat.save!
if @post.created_at == author.last_posted_at
author.last_posted_at = author.posts.order('created_at DESC').first.try(:created_at)
author.save!
end
if @post.is_first_post? && @post.topic && !@post.topic.private_message?
# Update stats of all people who replied
counts = Post.where(post_type: Post.types[:regular], topic_id: @post.topic_id).where('post_number > 1').group(:user_id).count
counts.each do |user_id, count|
if user_stat = UserStat.where(user_id: user_id).first
user_stat.update_attributes(post_count: user_stat.post_count - count)
end
end
end
end
end