discourse/lib/post_destroyer.rb
Krzysztof Kotlarek d792c77895
FEATURE: Link staff action log entries to originating reviewable (#39519)
The staff action log records moderation actions (post deletions, user
suspensions, silences, user approvals, etc.) as `UserHistory` rows, but
entries triggered from the reviewable queue have no reference back to
the reviewable that caused them. When auditing a decision in the log,
staff can't jump to the flagged post, queued post, or user-approval
review that produced it.

Link to review is visible in the log:
<img width="900" height="674" alt="Screenshot 2026-04-24 at 2 17 04 pm"
src="https://github.com/user-attachments/assets/791ebc58-0a4d-4c2c-a363-efe482f5f755"
/>
2026-04-30 09:15:45 +08:00

595 lines
19 KiB
Ruby

# frozen_string_literal: true
#
# 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,
context: "Automatically destroyed hidden posts",
).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 < ?", 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 { |post| PostDestroyer.new(Discourse.system_user, post, context: context).destroy }
end
def self.delete_with_replies(performed_by, post, reviewable_id = nil, defer_reply_flags: true)
reply_ids = post.reply_ids(Guardian.new(performed_by), only_replies_to_single_post: false)
replies = Post.where(id: reply_ids.map { |r| r[:id] })
PostDestroyer.new(performed_by, post, reviewable_id: reviewable_id).destroy
options = { defer_flags: defer_reply_flags }
if SiteSetting.notify_users_after_responses_deleted_on_flagged_post
options.merge!({ reviewable_id: reviewable_id, notify_responders: true, parent_post: post })
end
replies.each { |reply| PostDestroyer.new(performed_by, reply, options).destroy }
end
def initialize(user, post, opts = {})
@user = user
@post = post
@topic = post.topic || Topic.with_deleted.find_by(id: @post.topic_id)
@opts = opts
if user == Discourse.system_user && opts[:context].blank?
Discourse.deprecate(<<~WARNING, drop_from: "3.6.0", output_in_test: true)
Using PostDestroyer as system user without providing a context will be an error in future versions.
WARNING
end
end
def destroy
delete_removed_posts_after =
@opts[:delete_removed_posts_after] || SiteSetting.delete_removed_posts_after
should_reset_bumped_at = @post.is_last_reply? && !@post.whisper?
if delete_removed_posts_after < 1 || post_is_reviewable? ||
Guardian.new(@user).can_moderate_topic?(@topic) || permanent?
perform_delete
elsif @user.id == @post.user_id
mark_for_deletion(delete_removed_posts_after)
end
resolve_reviewables_for_author_deletion if @user.id == @post.user_id
UserActionManager.post_destroyed(@post)
DiscourseEvent.trigger(:post_destroyed, @post, @opts, @user)
if WebHook.active_web_hooks(:post_destroyed).exists?
payload = WebHook.generate_payload(:post, @post)
WebHook.enqueue_post_hooks(:post_destroyed, @post, payload)
end
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @topic.id) if @topic
is_first_post = @post.is_first_post? && @topic
if is_first_post
UserProfile.remove_featured_topic_from_all_profiles(@topic)
UserActionManager.topic_destroyed(@topic)
DiscourseEvent.trigger(:topic_destroyed, @topic, @user)
if WebHook.active_web_hooks(:topic_destroyed).exists?
topic_view = TopicView.new(@topic, Discourse.system_user, skip_staff_action: true)
topic_payload = WebHook.generate_payload(:topic, topic_view, WebHookTopicViewSerializer)
WebHook.enqueue_topic_hooks(:topic_destroyed, @topic, topic_payload)
end
if SiteSetting.tos_topic_id == @topic.id || SiteSetting.privacy_topic_id == @topic.id
Discourse.clear_urls!
end
end
@topic.reset_bumped_at if should_reset_bumped_at
end
def recover
if (post_is_reviewable? || Guardian.new(@user).can_moderate_topic?(@post.topic)) &&
@post.deleted_at
staff_recovered
elsif @user.staff? || @user.id == @post.user_id
user_recovered
end
@topic.update_column(:user_id, Discourse::SYSTEM_USER_ID) if !@topic.user_id
@topic.recover!(@user) if @post.is_first_post?
@topic.update_statistics!
Topic.publish_stats_to_clients!(@topic.id, :recovered)
@topic.reset_bumped_at(@post) if @post.is_last_reply? && !@post.whisper?
UserActionManager.post_created(@post)
DiscourseEvent.trigger(:post_recovered, @post, @opts, @user)
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @topic.id) if @topic
Jobs.enqueue(:notify_mailing_list_subscribers, post_id: @post.id)
if @post.is_first_post?
UserActionManager.topic_created(@topic)
DiscourseEvent.trigger(:topic_recovered, @topic, @user)
if @user.id != @post.user_id
StaffActionLogger.new(@user).log_topic_delete_recover(
@topic,
"recover_topic",
@opts.slice(:context),
)
end
if SiteSetting.tos_topic_id == @topic.id || SiteSetting.privacy_topic_id == @topic.id
Discourse.clear_urls!
end
end
end
def staff_recovered
new_post_attrs = { user_deleted: false }
new_post_attrs[:user_id] = Discourse::SYSTEM_USER_ID if !@post.user_id
@post.update_columns(new_post_attrs)
@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
update_post_counts(:increment)
end
end
# skip also publishing topic stats because they weren't updated yet
@post.publish_change_to_clients! :recovered, { skip_topic_stats: true }
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
# Permanent option allows to hard delete.
def perform_delete
# All posts in the topic must be force deleted if the first is force
# deleted (except @post which is destroyed by current instance).
if @topic && @post.is_first_post? && permanent?
@topic.posts.with_deleted.find_each do |post|
PostDestroyer.new(@user, post, @opts).destroy if post.id != @post.id
end
end
Post.transaction do
permanent? ? @post.destroy! : @post.trash!(@user)
if @post.topic
make_previous_post_the_last_one
mark_topic_changed
clear_user_posted_flag
end
trash_public_post_actions
trash_revisions
trash_user_actions
remove_associated_replies
remove_associated_notifications
if @user.id != @post.user_id && !@opts[:skip_staff_log]
logger = StaffActionLogger.new(@user)
if @post.topic && @post.is_first_post?
logger.log_topic_delete_recover(
@post.topic,
permanent? ? "delete_topic_permanently" : "delete_topic",
@opts.slice(:context, :reviewable_id),
)
else
logger.log_post_deletion(
@post,
**@opts.slice(:context, :reviewable_id),
permanent: permanent?,
)
end
end
if @topic && @post.is_first_post?
permanent? ? @topic.destroy! : @topic.trash!(@user)
PublishedPage.unpublish!(@user, @topic) if @topic.published_page
end
TopicLink.where(link_post_id: @post.id).destroy_all
update_associated_category_latest_topic
update_user_counts if !permanent?
TopicUser.update_post_action_cache(post_id: @post.id)
if permanent?
if @post.topic && @post.is_first_post?
UserHistory.where(topic_id: @post.topic.id).update_all(details: "(permanently deleted)")
end
UserHistory.where(post_id: @post.id).update_all(details: "(permanently deleted)")
end
DB.after_commit do
Topic.reset_highest(@post.topic_id)
handle_reviewable_after_deletion
end
end
feature_users_in_the_topic if @post.topic
@post.publish_change_to_clients!(permanent? ? :destroyed : :deleted) if @post.topic
if @post.topic && @post.post_number == 1
TopicTrackingState.send(permanent? ? :publish_destroy : :publish_delete, @post.topic)
end
end
def permanent?
@opts[:force_destroy] ||
(@opts[:permanent] && @user == @post.user && @post.topic.private_message?)
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
key =
(
if @post.is_first_post?
"js.topic.deleted_by_author_simple"
else
"js.post.deleted_by_author_simple"
end
)
@post.revise(
@user,
{ raw: I18n.t(key) },
force_new_version: true,
deleting_post: true,
skip_validations: true,
)
Post.transaction do
@post.update_column(:user_deleted, true)
@post.topic_links.each(&:destroy)
@post.topic.update_column(:closed, true) if @post.is_first_post?
end
end
end
def user_recovered
return unless @post.user_deleted?
Post.transaction do
@post.update_column(:user_deleted, false)
@post.skip_unique_check = true
@post.topic.update_column(:closed, false) if @post.is_first_post?
end
# has internal transactions, if we nest then there are some very high risk deadlocks
last_revision = @post.revisions.last
if last_revision.present? && last_revision.modifications["raw"].present?
@post.revise(@user, { raw: last_revision.modifications["raw"][0] }, force_new_version: true)
end
restore_reviewables_for_author_recovery if @user.id == @post.user_id
end
private
def post_is_reviewable?
return true if @user.staff?
Guardian.new(@user).can_review_topic?(@topic) && Reviewable.exists?(target: @post)
end
# 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)
.where.not(user_id: nil)
.where.not(post_type: Post.types[:whisper])
.order("created_at desc")
.first
if last_post.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 post_action_type_view
@post_action_type_view ||= PostActionTypeView.new
end
def trash_public_post_actions
if public_post_actions = PostAction.publics.where(post_id: @post.id)
public_post_actions.each { |pa| permanent? ? pa.destroy! : pa.trash!(@user) }
return if permanent?
@post.custom_fields["deleted_public_actions"] = public_post_actions.ids
@post.save_custom_fields
f = post_action_type_view.public_types.map { |k, _| ["#{k}_count", 0] }
Post.with_deleted.where(id: @post.id).update_all(Hash[*f.flatten])
end
end
def trash_revisions
return unless permanent?
@post.revisions.each(&:destroy!)
end
def agree(reviewable)
notify_deletion(reviewable)
result = reviewable.perform(@user, :agree_and_keep, post_was_deleted: true)
reviewable.transition_to(result.transition_to, @user)
end
def ignore(reviewable)
reviewable.perform_ignore_and_do_nothing(@user, post_was_deleted: true)
reviewable.transition_to(:ignored, @user)
end
def handle_reviewable_after_deletion
if @opts[:reviewable_id]
handle_explicit_reviewable
elsif @post.reviewable_flag
handle_post_reviewable_flag
end
end
def handle_explicit_reviewable
reviewable = Reviewable.find_by(id: @opts[:reviewable_id])
return unless reviewable
notify_deletion(
reviewable,
{ notify_responders: @opts[:notify_responders], parent_post: @opts[:parent_post] },
)
return unless @post.reviewable_flag
return unless SiteSetting.notify_users_after_responses_deleted_on_flagged_post
return if @post.reviewable_flag.potentially_illegal?
ignore(@post.reviewable_flag)
end
def handle_post_reviewable_flag
return ignore(@post.reviewable_flag) if @opts[:defer_flags]
return if @post.reviewable_flag.potentially_illegal?
agree(@post.reviewable_flag)
end
def notify_deletion(reviewable, options = {})
return if @post.user.blank?
allowed_user = @user.human? && @user.staff?
return unless allowed_user && rs = reviewable.reviewable_scores.order("created_at DESC").first
# ReviewableScore#types is a superset of PostActionType#flag_types.
# If the reviewable score type is not on the latter, it means it's not a flag by a user and
# must be an automated flag like `needs_approval`. There's no flag reason for these kind of types.
flag_type = post_action_type_view.flag_types[rs.reviewable_score_type]
return unless flag_type
notify_responders = options[:notify_responders]
Jobs.enqueue(
:send_system_message,
user_id: @post.user_id,
message_type:
(
if notify_responders
"flags_agreed_and_post_deleted_for_responders"
else
"flags_agreed_and_post_deleted"
end
),
message_options: {
flagged_post_raw_content: notify_responders ? options[:parent_post].raw : @post.raw,
flagged_post_response_raw_content: @post.raw,
url: notify_responders ? options[:parent_post].url : @post.url,
flag_reason:
I18n.t(
"flag_reasons#{".responder" if notify_responders}.#{flag_type}",
locale: SiteSetting.default_locale,
base_path: Discourse.base_path,
default: PostActionType.flags.find { |flag| flag[:name_key] == flag_type.to_s }[:name],
),
},
)
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 remove_associated_replies
post_ids = PostReply.where(reply_post_id: @post.id).pluck(:post_id)
if post_ids.present?
PostReply.where(reply_post_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
if @post.id != @post.topic.category.latest_post_id &&
!(@post.is_first_post? && @post.topic_id == @post.topic.category.latest_topic_id)
return
end
@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.update!(
first_post_created_at: author.posts.order("created_at ASC").first.try(:created_at),
)
end
UserStatCountUpdater.decrement!(@post)
if @post.created_at == author.last_posted_at
author.update_column(
:last_posted_at,
author.posts.order("created_at DESC").first.try(:created_at),
)
end
if @post.is_first_post? && @post.topic && !@post.topic.private_message?
# Update stats of all people who replied
update_post_counts(:decrement)
end
end
def update_post_counts(operator)
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
if operator == :decrement
UserStatCountUpdater.set!(
user_stat: user_stat,
count: user_stat.post_count - count,
count_column: :post_count,
)
else
UserStatCountUpdater.set!(
user_stat: user_stat,
count: user_stat.post_count + count,
count_column: :post_count,
)
end
end
end
end
def resolve_reviewables_for_author_deletion
# Don't auto-ignore if user was penalized for this post - staff should review the penalty.
return if user_penalized_for_post?
Reviewable
.where(target: @post, status: Reviewable.statuses[:pending])
.find_each { |reviewable| reviewable.transition_to(:ignored, Discourse.system_user) }
end
def user_penalized_for_post?
return false unless @post.user.silenced? || @post.user.suspended?
UserHistory.exists?(
action: [UserHistory.actions[:silence_user], UserHistory.actions[:suspend_user]],
post: @post,
)
end
def restore_reviewables_for_author_recovery
# Only restore if it was reviewed by system user
reviewables =
Reviewable
.where(target: @post, status: Reviewable.statuses[:ignored])
.joins(
"LEFT JOIN reviewable_scores ON reviewable_scores.reviewable_id = reviewables.id AND reviewable_scores.reviewed_by_id = #{Discourse::SYSTEM_USER_ID}",
)
.where("reviewable_scores.id IS NOT NULL")
reviewables.each do |reviewable|
reviewable.reviewable_notes.create!(
user: Discourse.system_user,
content: I18n.t("reviewables.post_restored_by_author"),
)
reviewable.transition_to(:pending, Discourse.system_user)
end
end
end