mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
When a group or user is renamed, `GroupMentionsUpdater` and `Jobs::UpdateUsername` update the post's raw/cooked content but skip `PostRevisor`, so `categories.description` (a denormalized copy of the first paragraph) is never refreshed. The Edit Category page and category banner keep showing the old name, while the actual topic shows the new one. Same issue with `Post#rebake!` (Rebuild HTML) and `ProcessPost`. Extract `Post#sync_category_description` as the single source of truth for deriving `categories.description` from a post's cooked HTML — both `PostRevisor` and the new `Post#sync_first_post_caches` now delegate to it. `sync_first_post_caches` also updates `topics.excerpt`, centralizing all first-post denormalized field updates. Call `sync_first_post_caches` from every code path that modifies post content outside PostRevisor: `GroupMentionsUpdater`, `UpdateUsername`, `ChangeDisplayName`, `ProcessPost`, and `Post#rebake!`. Skip the update when the description hasn't changed to avoid unnecessary writes. When it has changed, call `publish_category` and `Site.clear_cache` so connected clients see the update immediately (fixes a pre-existing flicker where the old description would briefly appear then get overwritten by stale cached site data). Also add a targeted query in `UpdateUsername` for category description posts authored by the system user, which were missed by the existing `user_actions`-based and self-mention queries. Ref - t/181445
216 lines
7.5 KiB
Ruby
Vendored
216 lines
7.5 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
module Jobs
|
|
class UpdateUsername < ::Jobs::Base
|
|
sidekiq_options queue: "low"
|
|
# this is an extremely expensive job
|
|
# we are limiting it so only 1 per cluster runs
|
|
cluster_concurrency 1
|
|
|
|
def execute(args)
|
|
@user_id = args[:user_id]
|
|
user = User.find_by(id: @user_id)
|
|
return unless user
|
|
|
|
@old_username = args[:old_username].unicode_normalize
|
|
@new_username = args[:new_username].unicode_normalize
|
|
|
|
@avatar_img = PrettyText.avatar_img(args[:avatar_template], "tiny")
|
|
|
|
@quote_rewriter = QuoteRewriter.new(@user_id)
|
|
|
|
escaped_old_username = Regexp.escape(@old_username)
|
|
@raw_mention_regex =
|
|
/
|
|
(?:
|
|
(?<![\p{Alnum}\p{M}`]) # make sure there is no preceding letter, number or backtick
|
|
)
|
|
@#{escaped_old_username}
|
|
(?:
|
|
(?![\p{Alnum}\p{M}_\-.`]) # make sure there is no trailing letter, number, underscore, dash, dot or backtick
|
|
| # or
|
|
(?=[-_.](?:\s|$)) # there is an underscore, dash or dot followed by a whitespace or end of line
|
|
)
|
|
/ix
|
|
|
|
cooked_username = PrettyText::Helpers.format_username(@old_username)
|
|
escaped_cooked_username = Regexp.escape(cooked_username)
|
|
escaped_encoded_cooked_username = Regexp.escape(UrlHelper.encode_component(cooked_username))
|
|
@cooked_mention_username_regex = /\A@#{escaped_cooked_username}\z/i
|
|
@cooked_mention_user_path_regex = %r{\A/u(?:sers)?/#{escaped_encoded_cooked_username}\z}i
|
|
|
|
update_posts
|
|
update_revisions
|
|
update_notifications
|
|
update_post_custom_fields
|
|
|
|
DiscourseEvent.trigger(:username_changed, @old_username, @new_username)
|
|
DiscourseEvent.trigger(:user_updated, user)
|
|
end
|
|
|
|
def update_posts
|
|
updated_post_ids = Set.new
|
|
|
|
# Other people mentioning this user
|
|
Post
|
|
.with_deleted
|
|
.joins(mentioned("posts.id"))
|
|
.where("a.user_id = :user_id", user_id: @user_id)
|
|
.find_each do |post|
|
|
update_post(post)
|
|
updated_post_ids << post.id
|
|
end
|
|
|
|
# User mentioning self (not included in post_actions table)
|
|
Post
|
|
.with_deleted
|
|
.where("raw ILIKE ?", "%@#{@old_username}%")
|
|
.where("posts.user_id = :user_id", user_id: @user_id)
|
|
.where.not(id: updated_post_ids)
|
|
.find_each do |post|
|
|
update_post(post)
|
|
updated_post_ids << post.id
|
|
end
|
|
|
|
# Posts quoting this user
|
|
Post
|
|
.with_deleted
|
|
.joins(quoted("posts.id"))
|
|
.where("p.user_id = :user_id", user_id: @user_id)
|
|
.where.not(id: updated_post_ids)
|
|
.find_each do |post|
|
|
update_post(post)
|
|
updated_post_ids << post.id
|
|
end
|
|
|
|
# Category description posts may be authored by the system user and have no user_actions
|
|
Post
|
|
.with_deleted
|
|
.joins("INNER JOIN categories ON categories.topic_id = posts.topic_id")
|
|
.where(post_number: 1)
|
|
.where("raw ILIKE ?", "%@#{@old_username}%")
|
|
.where.not(id: updated_post_ids)
|
|
.find_each { |post| update_post(post) }
|
|
end
|
|
|
|
def update_revisions
|
|
PostRevision
|
|
.where("modifications SIMILAR TO ?", "%(raw|cooked)%@#{@old_username}%")
|
|
.find_each { |revision| update_revision(revision) }
|
|
|
|
PostRevision
|
|
.joins(quoted("post_revisions.post_id"))
|
|
.where("p.user_id = :user_id", user_id: @user_id)
|
|
.find_each { |revision| update_revision(revision) }
|
|
end
|
|
|
|
def update_notifications
|
|
params = { user_id: @user_id, old_username: @old_username, new_username: @new_username }
|
|
|
|
DB.exec(<<~SQL, params)
|
|
UPDATE notifications
|
|
SET data = (data :: JSONB ||
|
|
jsonb_strip_nulls(
|
|
jsonb_build_object(
|
|
'original_username', CASE data :: JSONB ->> 'original_username'
|
|
WHEN :old_username
|
|
THEN :new_username
|
|
ELSE NULL END,
|
|
'display_username', CASE data :: JSONB ->> 'display_username'
|
|
WHEN :old_username
|
|
THEN :new_username
|
|
ELSE NULL END,
|
|
'username', CASE data :: JSONB ->> 'username'
|
|
WHEN :old_username
|
|
THEN :new_username
|
|
ELSE NULL END,
|
|
'username2', CASE data :: JSONB ->> 'username2'
|
|
WHEN :old_username
|
|
THEN :new_username
|
|
ELSE NULL END
|
|
)
|
|
)) :: JSON
|
|
WHERE
|
|
data :: JSONB ->> 'original_username' = :old_username OR
|
|
data :: JSONB ->> 'display_username' = :old_username OR
|
|
data :: JSONB ->> 'username' = :old_username OR
|
|
data :: JSONB ->> 'username2' = :old_username
|
|
SQL
|
|
end
|
|
|
|
def update_post_custom_fields
|
|
DB.exec(<<~SQL, old_username: @old_username, new_username: @new_username)
|
|
UPDATE post_custom_fields
|
|
SET value = :new_username
|
|
WHERE name = 'action_code_who' AND value = :old_username
|
|
SQL
|
|
end
|
|
|
|
protected
|
|
|
|
def update_post(post)
|
|
post.raw = update_raw(post.raw)
|
|
post.cooked = update_cooked(post.cooked)
|
|
|
|
post.update_columns(raw: post.raw, cooked: post.cooked)
|
|
post.sync_first_post_caches
|
|
|
|
SearchIndexer.index(post, force: true) if post.topic
|
|
rescue => e
|
|
Discourse.warn_exception(e, message: "Failed to update post with id #{post.id}")
|
|
end
|
|
|
|
def update_revision(revision)
|
|
if revision.modifications.key?("raw") || revision.modifications.key?("cooked")
|
|
revision.modifications["raw"]&.map! { |raw| update_raw(raw) }
|
|
revision.modifications["cooked"]&.map! { |cooked| update_cooked(cooked) }
|
|
revision.save!
|
|
end
|
|
rescue => e
|
|
Discourse.warn_exception(e, message: "Failed to update post revision with id #{revision.id}")
|
|
end
|
|
|
|
def mentioned(post_id_column)
|
|
<<~SQL
|
|
JOIN user_actions AS a ON (a.target_post_id = #{post_id_column} AND
|
|
a.action_type = #{UserAction::MENTION})
|
|
SQL
|
|
end
|
|
|
|
def quoted(post_id_column)
|
|
<<~SQL
|
|
JOIN quoted_posts AS q ON (q.post_id = #{post_id_column})
|
|
JOIN posts AS p ON (q.quoted_post_id = p.id)
|
|
SQL
|
|
end
|
|
|
|
def update_raw(raw)
|
|
@quote_rewriter.rewrite_raw_username(
|
|
raw.gsub(@raw_mention_regex, "@#{@new_username}"),
|
|
@old_username,
|
|
@new_username,
|
|
)
|
|
end
|
|
|
|
# Uses Nokogiri instead of rebake, because it works for posts and revisions
|
|
# and there is no reason to invalidate oneboxes, run the post analyzer etc.
|
|
# when only the username changes.
|
|
def update_cooked(cooked)
|
|
doc = Nokogiri::HTML5.fragment(cooked)
|
|
|
|
doc
|
|
.css("a.mention")
|
|
.each do |a|
|
|
a.content = a.content.gsub(@cooked_mention_username_regex, "@#{@new_username}")
|
|
a["href"] = a["href"].gsub(
|
|
@cooked_mention_user_path_regex,
|
|
"/u/#{UrlHelper.encode_component(@new_username)}",
|
|
) if a["href"]
|
|
end
|
|
|
|
@quote_rewriter.rewrite_cooked_username(doc, @old_username, @new_username, @avatar_img)
|
|
|
|
doc.to_html
|
|
end
|
|
end
|
|
end
|