0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/app/jobs/regular/update_username.rb
Régis Hanol 73dd5b4da8
FIX: Sync category description when post content changes outside PostRevisor (#39184)
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
2026-05-11 16:13:23 +02:00

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