mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
Previously, the duplicate topic title check compared new titles against every topic on the site regardless of visibility: users were blocked by titles in categories they couldn't see or unlisted topics they couldn't find, the bare "Title has already been used" error gave no way to locate the conflict (and doubled as an existence oracle for hidden titles), and the behavior was controlled by two entangled boolean settings. This change scopes the check to the destination category plus whatever the acting user can actually see, links the conflicting topic in the error — safe by construction, since being blocked now implies being able to see it: > This title has already been used by [another topic](). It also consolidates the two booleans into a single `duplicate_topic_titles` enum (`disallowed` / `allowed_across_categories` / `allowed`), with existing values migrated and the old names kept as hidden deprecated aliases that admin search still resolves. Reported in https://meta.discourse.org/t/title-has-already-been-used-in-a-secure-category/123047 Note for self-hosters: env-provided settings can't be migrated — `DISCOURSE_ALLOW_DUPLICATE_TOPIC_TITLES=true` configs need to switch to `DISCOURSE_DUPLICATE_TOPIC_TITLES=allowed`.
849 lines
29 KiB
Ruby
Vendored
849 lines
29 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
class PostMover
|
|
attr_reader :original_topic, :destination_topic, :user, :post_ids
|
|
|
|
def self.move_types
|
|
@move_types ||= Enum.new(:new_topic, :existing_topic)
|
|
end
|
|
|
|
# options:
|
|
# freeze_original: :boolean - if true, the original topic will be frozen but not deleted and posts will be "copied" to topic
|
|
def initialize(original_topic, user, post_ids, move_to_pm: false, options: {})
|
|
@original_topic = original_topic
|
|
@original_topic_title = original_topic.title
|
|
@user = user
|
|
@post_ids = post_ids
|
|
# For now we store a copy of post_ids. If `freeze_original` is present, we will have new post_ids.
|
|
# When we create the new posts, we will pluck out post_ids out of this and replace with updated ids.
|
|
@post_ids_after_move = post_ids
|
|
@move_to_pm = move_to_pm
|
|
@options = options
|
|
end
|
|
|
|
def to_topic(id, participants: nil, chronological_order: false)
|
|
@move_type = PostMover.move_types[:existing_topic]
|
|
@creating_new_topic = false
|
|
@chronological_order = chronological_order
|
|
|
|
topic = Topic.find_by_id(id)
|
|
if topic.archetype != @original_topic.archetype &&
|
|
[@original_topic.archetype, topic.archetype].include?(Archetype.private_message)
|
|
raise Discourse::InvalidParameters
|
|
end
|
|
|
|
Topic.transaction { move_posts_to topic }
|
|
add_allowed_users(participants) if participants.present? && @move_to_pm
|
|
enqueue_jobs(topic)
|
|
topic
|
|
end
|
|
|
|
def to_new_topic(title, category_id = nil, tag_ids: nil, tags: nil)
|
|
@move_type = PostMover.move_types[:new_topic]
|
|
@creating_new_topic = true
|
|
|
|
post = Post.find_by(id: post_ids.first)
|
|
raise Discourse::InvalidParameters unless post
|
|
archetype = @move_to_pm ? Archetype.private_message : Archetype.default
|
|
|
|
topic =
|
|
Topic.transaction do
|
|
new_topic =
|
|
Topic.create!(
|
|
user: post.user,
|
|
acting_user: user,
|
|
title: title,
|
|
category_id: category_id,
|
|
created_at: post.created_at,
|
|
archetype: archetype,
|
|
)
|
|
if tag_ids.present?
|
|
DiscourseTagging.tag_topic_by_ids(new_topic, Guardian.new(user), tag_ids)
|
|
elsif tags.present?
|
|
DiscourseTagging.tag_topic_by_names(new_topic, Guardian.new(user), tags)
|
|
end
|
|
move_posts_to new_topic
|
|
watch_new_topic
|
|
update_topic_excerpt new_topic
|
|
new_topic
|
|
end
|
|
enqueue_jobs(topic)
|
|
topic
|
|
end
|
|
|
|
private
|
|
|
|
def update_topic_excerpt(topic)
|
|
topic.update_excerpt(topic.first_post.excerpt_for_topic)
|
|
end
|
|
|
|
def move_posts_to(topic)
|
|
Guardian.new(user).ensure_can_see! topic
|
|
@destination_topic = topic
|
|
|
|
# Serialize concurrent moves/merges on the same source topic. Without this
|
|
# lock, two transactions can each read `ordered_posts.last.post_number` at
|
|
# the same value, both compute the same `@first_post_number_moved`, and
|
|
# then collide on the `(topic_id, post_number)` unique index when each
|
|
# tries to insert the "split_topic" moderator post. The lock is released
|
|
# on commit/rollback of the surrounding `Topic.transaction`.
|
|
@original_topic.lock!
|
|
|
|
ensure_acting_user_is_allowed_in_destination
|
|
|
|
@full_move = (posts_preventing_close.pluck(:id) - posts.map(&:id)).empty?
|
|
|
|
@first_post_number_moved =
|
|
posts.first.is_first_post? ? posts[1]&.post_number : posts.first.post_number
|
|
|
|
if @options[:freeze_original]
|
|
# in this case we need to add the moderator post after the last copied post
|
|
if @full_move
|
|
# Use max_post_number (which includes soft-deleted posts) so the number
|
|
# we force via `add_moderator_post` matches what `Topic.next_post_number`
|
|
# will atomically assign via raw SQL. Otherwise a deleted post at the
|
|
# tail leaves a tombstone in `index_posts_on_topic_id_and_post_number`
|
|
# that collides with our `update!(post_number: ...)`.
|
|
@first_post_number_moved = @original_topic.max_post_number + 1
|
|
else
|
|
# Same reason: shift all posts above `posts.last.post_number`, including
|
|
# soft-deleted ones, so the slot at `posts.last.post_number + 1` is
|
|
# guaranteed free in the unique index.
|
|
from_posts =
|
|
@original_topic
|
|
.posts
|
|
.with_deleted
|
|
.where("post_number > ?", posts.last.post_number)
|
|
.order(:post_number)
|
|
shift_post_numbers(from_posts)
|
|
@first_post_number_moved = posts.last.post_number + 1
|
|
end
|
|
end
|
|
|
|
create_temp_table
|
|
move_each_post
|
|
handle_moved_references
|
|
|
|
create_moderator_post_in_original_topic
|
|
update_statistics
|
|
update_user_actions
|
|
update_last_post_stats
|
|
update_upload_security_status
|
|
update_bookmarks
|
|
update_reviewables
|
|
|
|
close_topic_and_schedule_deletion if @full_move
|
|
|
|
destination_topic.reload
|
|
DiscourseEvent.trigger(
|
|
:posts_moved,
|
|
destination_topic_id: destination_topic.id,
|
|
original_topic_id: original_topic.id,
|
|
)
|
|
destination_topic
|
|
end
|
|
|
|
def ensure_acting_user_is_allowed_in_destination
|
|
return if !@move_to_pm
|
|
return if destination_topic.archetype != Archetype.private_message
|
|
return if user.id.blank? || user.bot?
|
|
return if destination_topic.topic_allowed_users.exists?(user_id: user.id)
|
|
|
|
destination_topic.topic_allowed_users.create!(user_id: user.id)
|
|
destination_topic.notifier.watch!(user.id)
|
|
end
|
|
|
|
def create_temp_table
|
|
DB.exec("DROP TABLE IF EXISTS temp_moved_posts") if Rails.env.test?
|
|
|
|
DB.exec <<~SQL
|
|
CREATE TEMPORARY TABLE temp_moved_posts (
|
|
old_topic_id INTEGER,
|
|
old_post_id INTEGER,
|
|
old_post_number INTEGER,
|
|
new_topic_id INTEGER,
|
|
new_topic_title VARCHAR,
|
|
new_post_id INTEGER,
|
|
new_post_number INTEGER
|
|
) ON COMMIT DROP;
|
|
CREATE INDEX moved_posts_old_post_number ON temp_moved_posts(old_post_number);
|
|
CREATE INDEX moved_posts_old_post_id ON temp_moved_posts(old_post_id);
|
|
SQL
|
|
end
|
|
|
|
def handle_moved_references
|
|
move_incoming_emails
|
|
move_notifications
|
|
update_reply_counts
|
|
update_quotes
|
|
move_first_post_replies
|
|
delete_post_replies
|
|
copy_shifted_post_timings_to_temp
|
|
delete_invalid_post_timings
|
|
copy_shifted_post_timings_from_temp
|
|
move_post_timings
|
|
copy_first_post_timings
|
|
copy_topic_users
|
|
end
|
|
|
|
def move_each_post
|
|
if @chronological_order
|
|
move_each_post_chronological
|
|
else
|
|
move_each_post_sequential
|
|
end
|
|
end
|
|
|
|
def move_each_post_sequential
|
|
max_post_number = destination_topic.max_post_number + 1
|
|
|
|
@post_creator = nil
|
|
@move_map = {}
|
|
@reply_count = {}
|
|
posts.each_with_index do |post, offset|
|
|
@move_map[post.post_number] = offset + max_post_number
|
|
|
|
if post.reply_to_post_number.present?
|
|
@reply_count[post.reply_to_post_number] = (@reply_count[post.reply_to_post_number] || 0) + 1
|
|
end
|
|
end
|
|
|
|
posts.each do |post|
|
|
metadata = movement_metadata(post, new_post_number: @move_map[post.post_number])
|
|
new_post = post.is_first_post? ? create_first_post(post) : move(post)
|
|
|
|
store_movement(metadata, new_post)
|
|
|
|
if @move_to_pm && !destination_topic.topic_allowed_users.exists?(user_id: post.user_id)
|
|
destination_topic.topic_allowed_users.create!(user_id: post.user_id)
|
|
end
|
|
end
|
|
end
|
|
|
|
def move_each_post_chronological
|
|
destination_posts = destination_topic.ordered_posts.with_deleted
|
|
|
|
# drops posts from destination_topic until it finds one that was created after posts.first
|
|
min_created_at = posts.first.created_at
|
|
moved_posts = destination_posts.drop_while { |post| post.created_at <= min_created_at }
|
|
|
|
# if no post in destination_topic was created after posts.first it's equal to sequential
|
|
if moved_posts.empty?
|
|
initial_post_number = destination_topic.max_post_number + 1
|
|
else
|
|
initial_post_number = moved_posts.first.post_number
|
|
end
|
|
|
|
last_index = 0
|
|
posts.each do |post|
|
|
while last_index < moved_posts.length && moved_posts[last_index].created_at <= post.created_at
|
|
last_index += 1
|
|
end
|
|
|
|
moved_posts.insert(last_index, post)
|
|
end
|
|
|
|
@post_creator = nil
|
|
@move_map = {}
|
|
@shift_map = {}
|
|
@reply_count = {}
|
|
next_post_number = initial_post_number
|
|
moved_posts.each do |post|
|
|
if post.topic_id == destination_topic.id
|
|
# avoid shifting to a lower post number
|
|
next_post_number = post.post_number if post.post_number > next_post_number
|
|
|
|
@shift_map[post.post_number] = next_post_number
|
|
else
|
|
@move_map[post.post_number] = next_post_number
|
|
|
|
if post.reply_to_post_number.present?
|
|
@reply_count[post.reply_to_post_number] = (@reply_count[post.reply_to_post_number] || 0) +
|
|
1
|
|
end
|
|
end
|
|
|
|
next_post_number += 1
|
|
end
|
|
|
|
moved_posts.reverse_each do |post|
|
|
if post.topic_id == destination_topic.id
|
|
metadata = movement_metadata(post, new_post_number: @shift_map[post.post_number])
|
|
new_post = move_same_topic(post)
|
|
else
|
|
metadata = movement_metadata(post, new_post_number: @move_map[post.post_number])
|
|
new_post = post.is_first_post? ? create_first_post(post) : move(post)
|
|
|
|
if @move_to_pm && !destination_topic.topic_allowed_users.exists?(user_id: post.user_id)
|
|
destination_topic.topic_allowed_users.create!(user_id: post.user_id)
|
|
end
|
|
end
|
|
|
|
store_movement(metadata, new_post)
|
|
end
|
|
|
|
# change topic owner if there's a new first post
|
|
destination_topic.update_column(:user_id, posts.first.user_id) if initial_post_number == 1
|
|
end
|
|
|
|
def create_first_post(post)
|
|
@post_creator =
|
|
PostCreator.new(
|
|
post.user,
|
|
raw: post.raw,
|
|
topic_id: destination_topic.id,
|
|
acting_user: user,
|
|
cook_method: post.cook_method,
|
|
via_email: post.via_email,
|
|
raw_email: post.raw_email,
|
|
skip_validations: true,
|
|
created_at: post.created_at,
|
|
guardian: Guardian.new(user),
|
|
skip_jobs: true,
|
|
no_bump: true,
|
|
)
|
|
new_post = @post_creator.create!
|
|
|
|
move_email_logs(post, new_post)
|
|
|
|
PostAction.copy(post, new_post)
|
|
|
|
PostRevision.copy(post, new_post)
|
|
|
|
attrs_to_update = {
|
|
reply_count: @reply_count[1] || 0,
|
|
version: post.version,
|
|
public_version: post.public_version,
|
|
}
|
|
|
|
if new_post.post_number != @move_map[post.post_number]
|
|
attrs_to_update[:post_number] = @move_map[post.post_number]
|
|
attrs_to_update[:sort_order] = @move_map[post.post_number]
|
|
end
|
|
|
|
new_post.update_columns(attrs_to_update)
|
|
new_post.custom_fields = post.custom_fields
|
|
new_post.save_custom_fields
|
|
|
|
@post_ids_after_move =
|
|
@post_ids_after_move.map { |post_id| post_id == post.id ? new_post.id : post_id }
|
|
|
|
DiscourseEvent.trigger(:first_post_moved, new_post, post)
|
|
DiscourseEvent.trigger(:post_moved, new_post, original_topic.id, post)
|
|
|
|
# we don't want to keep the old topic's OP bookmarked when we are
|
|
# moving it into a new topic
|
|
Bookmark.where(bookmarkable: post).update_all(bookmarkable_id: new_post.id)
|
|
|
|
new_post
|
|
end
|
|
|
|
def move(post)
|
|
update = {
|
|
reply_count: @reply_count[post.post_number] || 0,
|
|
post_number: @move_map[post.post_number],
|
|
reply_to_post_number: @move_map[post.reply_to_post_number],
|
|
topic_id: destination_topic.id,
|
|
sort_order: @move_map[post.post_number],
|
|
baked_version: nil,
|
|
}
|
|
|
|
update[:reply_to_user_id] = nil unless @move_map[post.reply_to_post_number]
|
|
|
|
moved_post =
|
|
if @options[:freeze_original]
|
|
post.dup
|
|
else
|
|
post
|
|
end
|
|
|
|
moved_post.attributes = update
|
|
moved_post.disable_rate_limits! if @options[:freeze_original]
|
|
moved_post.save(validate: false)
|
|
|
|
if moved_post.id != post.id
|
|
@post_ids_after_move =
|
|
@post_ids_after_move.map { |post_id| post_id == post.id ? moved_post.id : post_id }
|
|
end
|
|
|
|
DiscourseEvent.trigger(:post_moved, moved_post, original_topic.id, post)
|
|
|
|
# Move any links from the post to the new topic
|
|
moved_post.topic_links.update_all(topic_id: destination_topic.id)
|
|
|
|
moved_post
|
|
end
|
|
|
|
def move_same_topic(post)
|
|
update = {
|
|
post_number: @shift_map[post.post_number],
|
|
sort_order: @shift_map[post.post_number],
|
|
baked_version: nil,
|
|
}
|
|
|
|
if @shift_map[post.reply_to_post_number]
|
|
update[:reply_to_post_number] = @shift_map[post.reply_to_post_number]
|
|
end
|
|
|
|
post.attributes = update
|
|
post.save(validate: false)
|
|
|
|
post
|
|
end
|
|
|
|
def movement_metadata(post, new_post_number: nil)
|
|
{
|
|
old_topic_id: post.topic_id,
|
|
old_post_id: post.id,
|
|
old_post_number: post.post_number,
|
|
post_user_id: post.user_id,
|
|
new_topic_id: destination_topic.id,
|
|
new_post_number: new_post_number,
|
|
new_topic_title: destination_topic.title,
|
|
}
|
|
end
|
|
|
|
def store_movement(metadata, new_post)
|
|
metadata[:new_post_id] = new_post.id
|
|
metadata[:now] = Time.zone.now
|
|
metadata[:created_new_topic] = @creating_new_topic
|
|
metadata[:old_topic_title] = @original_topic_title
|
|
metadata[:user_id] = @user.id
|
|
metadata[:full_move] = @full_move
|
|
|
|
DB.exec(<<~SQL, metadata)
|
|
INSERT INTO temp_moved_posts(old_topic_id, old_post_id, old_post_number, new_topic_id, new_topic_title, new_post_id, new_post_number)
|
|
VALUES (:old_topic_id, :old_post_id, :old_post_number, :new_topic_id, :new_topic_title, :new_post_id, :new_post_number)
|
|
SQL
|
|
DB.exec(<<~SQL, metadata)
|
|
INSERT INTO moved_posts(old_topic_id, old_topic_title, old_post_id, old_post_number, post_user_id, user_id, full_move, new_topic_id, new_topic_title, new_post_id, new_post_number, created_new_topic, created_at, updated_at)
|
|
VALUES (:old_topic_id, :old_topic_title, :old_post_id, :old_post_number, :post_user_id, :user_id, :full_move, :new_topic_id, :new_topic_title, :new_post_id, :new_post_number, :created_new_topic, :now, :now)
|
|
SQL
|
|
end
|
|
|
|
def shift_post_numbers(from_posts)
|
|
from_posts.reverse_each { |post| post.update_columns(post_number: post.post_number + 1) }
|
|
end
|
|
|
|
def move_incoming_emails
|
|
DB.exec <<~SQL
|
|
UPDATE incoming_emails ie
|
|
SET topic_id = mp.new_topic_id,
|
|
post_id = mp.new_post_id
|
|
FROM temp_moved_posts mp
|
|
WHERE ie.topic_id = mp.old_topic_id AND ie.post_id = mp.old_post_id
|
|
AND mp.old_topic_id <> mp.new_topic_id
|
|
SQL
|
|
end
|
|
|
|
def move_email_logs(old_post, new_post)
|
|
EmailLog.where(post_id: old_post.id).update_all(post_id: new_post.id)
|
|
end
|
|
|
|
def move_notifications
|
|
DB.exec <<~SQL
|
|
UPDATE notifications n
|
|
SET topic_id = mp.new_topic_id,
|
|
post_number = mp.new_post_number,
|
|
data = (data :: JSONB ||
|
|
jsonb_strip_nulls(
|
|
jsonb_build_object(
|
|
'topic_title', CASE WHEN data :: JSONB ->> 'topic_title' IS NULL
|
|
THEN NULL
|
|
ELSE mp.new_topic_title END
|
|
)
|
|
)) :: JSON
|
|
FROM temp_moved_posts mp
|
|
WHERE n.topic_id = mp.old_topic_id AND n.post_number = mp.old_post_number
|
|
AND n.notification_type <> #{Notification.types[:watching_first_post]}
|
|
SQL
|
|
end
|
|
|
|
def update_reply_counts
|
|
DB.exec <<~SQL
|
|
UPDATE posts p
|
|
SET reply_count = GREATEST(0, reply_count - x.moved_reply_count)
|
|
FROM (
|
|
SELECT r.post_id, mp.new_topic_id, COUNT(1) AS moved_reply_count
|
|
FROM temp_moved_posts mp
|
|
JOIN post_replies r ON (mp.old_post_id = r.reply_post_id)
|
|
GROUP BY r.post_id, mp.new_topic_id
|
|
) x
|
|
WHERE x.post_id = p.id AND x.new_topic_id <> p.topic_id
|
|
SQL
|
|
end
|
|
|
|
def update_quotes
|
|
DB.exec <<~SQL
|
|
UPDATE posts p
|
|
SET raw = REPLACE(p.raw,
|
|
', post:' || mp.old_post_number || ', topic:' || mp.old_topic_id,
|
|
', post:' || mp.new_post_number || ', topic:' || mp.new_topic_id),
|
|
baked_version = NULL
|
|
FROM temp_moved_posts mp, quoted_posts qp
|
|
WHERE p.id = qp.post_id AND mp.old_post_id = qp.quoted_post_id
|
|
SQL
|
|
end
|
|
|
|
def move_first_post_replies
|
|
DB.exec <<~SQL
|
|
UPDATE post_replies pr
|
|
SET post_id = mp.new_post_id
|
|
FROM temp_moved_posts mp
|
|
WHERE mp.old_post_id <> mp.new_post_id AND pr.post_id = mp.old_post_id AND
|
|
EXISTS (SELECT 1 FROM temp_moved_posts mr WHERE mr.new_post_id = pr.reply_post_id)
|
|
SQL
|
|
end
|
|
|
|
def delete_post_replies
|
|
DB.exec <<~SQL
|
|
DELETE FROM post_replies pr USING temp_moved_posts mp
|
|
WHERE (SELECT topic_id FROM posts WHERE id = pr.post_id) <>
|
|
(SELECT topic_id FROM posts WHERE id = pr.reply_post_id)
|
|
AND (pr.reply_post_id = mp.old_post_id OR pr.post_id = mp.old_post_id)
|
|
SQL
|
|
end
|
|
|
|
def copy_shifted_post_timings_to_temp
|
|
DB.exec("DROP TABLE IF EXISTS temp_post_timings") if Rails.env.test?
|
|
|
|
# copy post_timings for shifted posts to a temp table using the new_post_number
|
|
# they'll be copied back after delete_invalid_post_timings makes room for them
|
|
DB.exec <<~SQL
|
|
CREATE TEMPORARY TABLE temp_post_timings ON COMMIT DROP
|
|
AS (
|
|
SELECT pt.topic_id, mp.new_post_number as post_number, pt.user_id, pt.msecs
|
|
FROM post_timings pt
|
|
JOIN temp_moved_posts mp
|
|
ON mp.old_topic_id = pt.topic_id
|
|
AND mp.old_post_number = pt.post_number
|
|
AND mp.old_topic_id = mp.new_topic_id
|
|
)
|
|
SQL
|
|
end
|
|
|
|
def copy_shifted_post_timings_from_temp
|
|
DB.exec <<~SQL
|
|
INSERT INTO post_timings (topic_id, user_id, post_number, msecs)
|
|
SELECT DISTINCT ON (topic_id, post_number, user_id) topic_id, user_id, post_number, msecs
|
|
FROM temp_post_timings
|
|
ORDER BY topic_id, post_number, user_id, msecs DESC
|
|
ON CONFLICT (topic_id, post_number, user_id) DO UPDATE
|
|
SET msecs = GREATEST(post_timings.msecs, excluded.msecs)
|
|
SQL
|
|
end
|
|
|
|
def copy_first_post_timings
|
|
DB.exec(<<~SQL, post_ids: @post_ids_after_move)
|
|
INSERT INTO post_timings (topic_id, user_id, post_number, msecs)
|
|
SELECT mp.new_topic_id, pt.user_id, mp.new_post_number, pt.msecs
|
|
FROM post_timings pt
|
|
JOIN temp_moved_posts mp ON (pt.topic_id = mp.old_topic_id AND pt.post_number = mp.old_post_number)
|
|
WHERE mp.old_post_id <> mp.new_post_id
|
|
AND mp.new_post_id IN (:post_ids)
|
|
ON CONFLICT (topic_id, post_number, user_id) DO UPDATE
|
|
SET msecs = GREATEST(post_timings.msecs, excluded.msecs)
|
|
SQL
|
|
end
|
|
|
|
def delete_invalid_post_timings
|
|
DB.exec <<~SQL
|
|
DELETE
|
|
FROM post_timings pt
|
|
USING temp_moved_posts mp
|
|
WHERE pt.topic_id = mp.new_topic_id
|
|
AND pt.post_number = mp.new_post_number
|
|
SQL
|
|
end
|
|
|
|
def move_post_timings
|
|
DB.exec(<<~SQL, post_ids: @post_ids_after_move)
|
|
UPDATE post_timings pt
|
|
SET topic_id = mp.new_topic_id,
|
|
post_number = mp.new_post_number
|
|
FROM temp_moved_posts mp
|
|
WHERE pt.topic_id = mp.old_topic_id
|
|
AND pt.post_number = mp.old_post_number
|
|
AND mp.old_post_id = mp.new_post_id
|
|
AND mp.old_topic_id <> mp.new_topic_id
|
|
AND mp.new_post_id IN (:post_ids)
|
|
SQL
|
|
end
|
|
|
|
def copy_topic_users
|
|
params = {
|
|
old_topic_id: original_topic.id,
|
|
new_topic_id: destination_topic.id,
|
|
old_highest_post_number: destination_topic.highest_post_number,
|
|
old_highest_staff_post_number: destination_topic.highest_staff_post_number,
|
|
}
|
|
|
|
DB.exec(<<~SQL, params)
|
|
INSERT INTO topic_users(user_id, topic_id, posted, last_read_post_number,
|
|
last_emailed_post_number, first_visited_at, last_visited_at, notification_level,
|
|
notifications_changed_at, notifications_reason_id)
|
|
SELECT tu.user_id,
|
|
:new_topic_id AS topic_id,
|
|
EXISTS(
|
|
SELECT 1
|
|
FROM posts p
|
|
WHERE p.topic_id = :new_topic_id
|
|
AND p.user_id = tu.user_id
|
|
LIMIT 1
|
|
) AS posted,
|
|
(
|
|
SELECT MAX(lr.new_post_number)
|
|
FROM temp_moved_posts lr
|
|
WHERE lr.old_topic_id = tu.topic_id
|
|
AND lr.old_post_number <= tu.last_read_post_number
|
|
AND lr.old_topic_id <> lr.new_topic_id
|
|
) AS last_read_post_number,
|
|
(
|
|
SELECT MAX(le.new_post_number)
|
|
FROM temp_moved_posts le
|
|
WHERE le.old_topic_id = tu.topic_id
|
|
AND le.old_post_number <= tu.last_emailed_post_number
|
|
AND le.old_topic_id <> le.new_topic_id
|
|
) AS last_emailed_post_number,
|
|
GREATEST(tu.first_visited_at, t.created_at) AS first_visited_at,
|
|
GREATEST(tu.last_visited_at, t.created_at) AS last_visited_at,
|
|
tu.notification_level,
|
|
tu.notifications_changed_at,
|
|
tu.notifications_reason_id
|
|
FROM topic_users tu
|
|
JOIN topics t ON (t.id = :new_topic_id)
|
|
WHERE tu.topic_id = :old_topic_id
|
|
AND GREATEST(
|
|
tu.last_read_post_number,
|
|
tu.last_emailed_post_number
|
|
) >= (SELECT MIN(mp.old_post_number) FROM temp_moved_posts mp WHERE mp.old_topic_id <> mp.new_topic_id)
|
|
ON CONFLICT (topic_id, user_id) DO UPDATE
|
|
SET posted = excluded.posted,
|
|
last_read_post_number = CASE
|
|
WHEN topic_users.last_read_post_number = :old_highest_staff_post_number OR (
|
|
:old_highest_post_number < :old_highest_staff_post_number
|
|
AND topic_users.last_read_post_number = :old_highest_post_number
|
|
AND NOT EXISTS(SELECT 1
|
|
FROM users u
|
|
WHERE u.id = topic_users.user_id
|
|
AND (admin OR moderator))
|
|
) THEN
|
|
GREATEST(topic_users.last_read_post_number,
|
|
excluded.last_read_post_number)
|
|
ELSE topic_users.last_read_post_number END,
|
|
last_emailed_post_number = CASE
|
|
WHEN topic_users.last_emailed_post_number = :old_highest_staff_post_number OR (
|
|
:old_highest_post_number < :old_highest_staff_post_number
|
|
AND topic_users.last_emailed_post_number = :old_highest_post_number
|
|
AND NOT EXISTS(SELECT 1
|
|
FROM users u
|
|
WHERE u.id = topic_users.user_id
|
|
AND (admin OR moderator))
|
|
) THEN
|
|
GREATEST(topic_users.last_emailed_post_number,
|
|
excluded.last_emailed_post_number)
|
|
ELSE topic_users.last_emailed_post_number END,
|
|
first_visited_at = LEAST(topic_users.first_visited_at, excluded.first_visited_at),
|
|
last_visited_at = GREATEST(topic_users.last_visited_at, excluded.last_visited_at)
|
|
SQL
|
|
end
|
|
|
|
def update_statistics
|
|
destination_topic.update_statistics!
|
|
original_topic.update_statistics!
|
|
TopicUser.update_post_action_cache(
|
|
topic_id: [original_topic.id, destination_topic.id],
|
|
post_id: @post_ids,
|
|
)
|
|
end
|
|
|
|
def update_user_actions
|
|
UserAction.synchronize_target_topic_ids(posts.map(&:id))
|
|
end
|
|
|
|
def create_moderator_post_in_original_topic
|
|
move_type_str = PostMover.move_types[@move_type].to_s
|
|
move_type_str.sub!("topic", "message") if @move_to_pm
|
|
|
|
topic_link =
|
|
if posts.first.is_first_post?
|
|
"[#{destination_topic.title}](#{destination_topic.relative_url})"
|
|
else
|
|
"[#{destination_topic.title}](#{posts.first.relative_url})"
|
|
end
|
|
|
|
post_type = @move_to_pm ? Post.types[:whisper] : Post.types[:small_action]
|
|
|
|
continue =
|
|
DiscoursePluginRegistry.apply_modifier(
|
|
:post_mover_create_moderator_post,
|
|
true,
|
|
user: user,
|
|
post_type: post_type,
|
|
post_ids: @post_ids_after_move,
|
|
full_move: @full_move,
|
|
original_topic: original_topic,
|
|
destination_topic: destination_topic,
|
|
first_post_number_moved: @first_post_number_moved,
|
|
)
|
|
return if !continue
|
|
|
|
message =
|
|
I18n.with_locale(SiteSetting.default_locale) do
|
|
I18n.t(
|
|
"move_posts.#{move_type_str}_moderator_post",
|
|
count: posts.length,
|
|
topic_link: topic_link,
|
|
)
|
|
end
|
|
|
|
original_topic.add_moderator_post(
|
|
user,
|
|
message,
|
|
post_type: post_type,
|
|
action_code: "split_topic",
|
|
post_number: @first_post_number_moved,
|
|
)
|
|
end
|
|
|
|
def posts
|
|
@posts ||=
|
|
Post
|
|
.where(topic: @original_topic, id: post_ids)
|
|
.where.not(post_type: Post.types[:small_action])
|
|
.where.not(raw: "")
|
|
.order(:created_at)
|
|
.tap { |posts| raise Discourse::InvalidParameters.new(:post_ids) if posts.empty? }
|
|
end
|
|
|
|
def posts_preventing_close
|
|
@original_topic
|
|
.posts
|
|
.where(post_type: [Post.types[:regular], Post.types[:whisper]])
|
|
.where.not(raw: "")
|
|
.where("action_code IS DISTINCT FROM 'split_topic'")
|
|
end
|
|
|
|
def update_last_post_stats
|
|
post = destination_topic.ordered_posts.where.not(post_type: Post.types[:whisper]).last
|
|
if post && @post_ids_after_move.include?(post.id)
|
|
attrs = {}
|
|
attrs[:last_posted_at] = post.created_at
|
|
attrs[:last_post_user_id] = post.user_id
|
|
attrs[:bumped_at] = Time.now
|
|
attrs[:updated_at] = Time.now
|
|
destination_topic.update_columns(attrs)
|
|
end
|
|
end
|
|
|
|
def update_upload_security_status
|
|
DB.after_commit { Jobs.enqueue(:update_topic_upload_security, topic_id: @destination_topic.id) }
|
|
end
|
|
|
|
def update_bookmarks
|
|
DB.after_commit do
|
|
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @original_topic.id)
|
|
Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: @destination_topic.id)
|
|
end
|
|
end
|
|
|
|
def update_reviewables
|
|
Reviewable.where(target_type: "Post", target_id: @post_ids).update_all(
|
|
topic_id: @destination_topic.id,
|
|
category_id: @destination_topic.category_id,
|
|
)
|
|
end
|
|
|
|
def watch_new_topic
|
|
if @destination_topic.archetype == Archetype.private_message
|
|
if @original_topic.archetype == Archetype.private_message
|
|
notification_levels =
|
|
TopicUser
|
|
.where(topic_id: @original_topic.id, user_id: posts.pluck(:user_id))
|
|
.pluck(:user_id, :notification_level)
|
|
.to_h
|
|
else
|
|
notification_levels =
|
|
posts
|
|
.pluck(:user_id)
|
|
.uniq
|
|
.map { |user_id| [user_id, TopicUser.notification_levels[:watching]] }
|
|
.to_h
|
|
end
|
|
else
|
|
notification_levels = [[@destination_topic.user_id, TopicUser.notification_levels[:watching]]]
|
|
end
|
|
|
|
notification_levels.each do |user_id, notification_level|
|
|
TopicUser.change(
|
|
user_id,
|
|
@destination_topic.id,
|
|
notification_level: notification_level,
|
|
notifications_reason_id:
|
|
TopicUser.notification_reasons[
|
|
destination_topic.user_id == user_id ? :created_topic : :created_post
|
|
],
|
|
)
|
|
end
|
|
end
|
|
|
|
def add_allowed_users(usernames)
|
|
return if usernames.blank?
|
|
|
|
names = usernames.split(",").flatten
|
|
User
|
|
.where(username: names)
|
|
.find_each do |user|
|
|
unless destination_topic.topic_allowed_users.where(user_id: user.id).exists?
|
|
destination_topic.topic_allowed_users.build(user_id: user.id)
|
|
end
|
|
end
|
|
destination_topic.save!
|
|
end
|
|
|
|
def enqueue_jobs(topic)
|
|
enqueue_post_creator_jobs =
|
|
DiscoursePluginRegistry.apply_modifier(
|
|
:post_mover_enqueue_post_creator_jobs,
|
|
@post_creator.present?,
|
|
)
|
|
@post_creator.enqueue_jobs if enqueue_post_creator_jobs
|
|
|
|
Jobs.enqueue(:notify_moved_posts, post_ids: @post_ids_after_move, moved_by_id: user.id)
|
|
|
|
Jobs.enqueue(:delete_inaccessible_notifications, topic_id: topic.id)
|
|
end
|
|
|
|
def close_topic_and_schedule_deletion
|
|
@original_topic.update_status("closed", true, @user)
|
|
return if @options[:freeze_original] # we only close the topic when freezing it
|
|
|
|
days_to_deleting = SiteSetting.delete_merged_stub_topics_after_days
|
|
if days_to_deleting == 0
|
|
is_allowed_to_delete_after_merge =
|
|
DiscoursePluginRegistry.apply_modifier(
|
|
:is_allowed_to_delete_after_merge,
|
|
Guardian.new(@user).can_delete?(@original_topic),
|
|
@original_topic,
|
|
@user,
|
|
)
|
|
if is_allowed_to_delete_after_merge
|
|
first_post = @original_topic.ordered_posts.first
|
|
|
|
PostDestroyer.new(
|
|
@user,
|
|
first_post,
|
|
context: I18n.t("topic_statuses.auto_deleted_by_merge"),
|
|
).destroy
|
|
|
|
@original_topic.trash!(Discourse.system_user)
|
|
end
|
|
elsif days_to_deleting > 0
|
|
@original_topic.set_or_create_timer(
|
|
TopicTimer.types[:delete],
|
|
days_to_deleting * 24,
|
|
by_user: @user,
|
|
)
|
|
end
|
|
end
|
|
end
|