mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-04 11:03:50 +08:00
Adds four tiered badges to discourse-topic-voting so topic authors are recognized when their ideas get traction: - Daydreamer (Bronze, 1 vote) - Brainstormer (Silver, 5 votes) - Innovator (Silver, 15 votes) - Visionary (Gold, 25 votes) Badges are multi-grant and tied to the qualifying topic's first post, so a user earns each tier once per topic that reaches the threshold. Self-votes (voting on your own topic) are excluded. All badges are disabled by default; the Silver and Gold tiers (Brainstormer, Innovator, Visionary) allow the badge to be used as a title when enabled. <img width="287" height="216" alt="image" src="https://github.com/user-attachments/assets/3a84c2b2-6157-4504-b5a5-45e7c660e90c" /> ### How it is wired - `Votes::Cast` enqueues a `BackfillBadges` job after a vote is cast. - `TopicMerger.merge` and `VoteReclaim` also enqueue the job so merged or reclaimed topics are re-evaluated immediately rather than having to wait for the daily consistency pass. - The job calls `BadgeGranter.backfill` scoped to the topic's first post. Queries join `badge_posts`, which already filters out deleted and unlisted topics, read-restricted categories, and categories with `allow_badges` disabled. - Each query returns `granted_at` as the timestamp of the Nth qualifying vote (via `ROW_NUMBER()`), so every tier reflects when that threshold was actually crossed rather than when the most recent vote landed. - Revocation (vote removed, topic deleted, category changed) runs on the daily full backfill via `auto_revoke`, consistent with how discourse-solved handles the same pattern. ### Notification handling To avoid "granted badge" notification avalanches when the feature is first enabled on a community with years of existing votes, core now exposes a `:badge_granter_suppress_notification` modifier. The plugin registers it and suppresses notifications for its four badges when the qualifying vote is older than 2 weeks. Combined with the per-tier `granted_at`, this means only the tier the author just crossed produces a notification; lower tiers whose threshold was reached long ago stay silent. Ref - t/182304
60 lines
1.9 KiB
Ruby
60 lines
1.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module DiscourseTopicVoting
|
|
class TopicMerger
|
|
def self.merge(source_topic, target_topic)
|
|
new(source_topic, target_topic).merge
|
|
end
|
|
|
|
def initialize(source_topic, target_topic)
|
|
@source_topic = source_topic
|
|
@target_topic = target_topic
|
|
end
|
|
|
|
def merge
|
|
moved_votes = 0
|
|
duplicated_votes = 0
|
|
|
|
who_voted = @source_topic.votes.map(&:user)
|
|
if who_voted.present? && @source_topic.closed
|
|
who_voted.each do |user|
|
|
next if user.blank?
|
|
|
|
user_votes = user.topics_with_vote.pluck(:topic_id)
|
|
user_archived_votes = user.topics_with_archived_vote.pluck(:topic_id)
|
|
|
|
if user_votes.include?(@source_topic.id) || user_archived_votes.include?(@source_topic.id)
|
|
if user_votes.include?(@target_topic.id) ||
|
|
user_archived_votes.include?(@target_topic.id)
|
|
duplicated_votes += 1
|
|
user.votes.destroy_by(topic_id: @source_topic.id)
|
|
else
|
|
user
|
|
.votes
|
|
.find_by(topic_id: @source_topic.id, user_id: user.id)
|
|
.update!(topic_id: @target_topic.id, archive: @target_topic.closed)
|
|
moved_votes += 1
|
|
end
|
|
else
|
|
next
|
|
end
|
|
end
|
|
end
|
|
|
|
if moved_votes > 0
|
|
@source_topic.update_vote_count
|
|
@target_topic.update_vote_count
|
|
|
|
Jobs.enqueue(Jobs::DiscourseTopicVoting::BackfillBadges, topic_id: @target_topic.id)
|
|
|
|
if moderator_post = @source_topic.ordered_posts.where(action_code: "split_topic").last
|
|
moderator_post.raw << "\n\n#{I18n.t("topic_voting.votes_moved", count: moved_votes)}"
|
|
if duplicated_votes > 0
|
|
moderator_post.raw << " #{I18n.t("topic_voting.duplicated_votes", count: duplicated_votes)}"
|
|
end
|
|
moderator_post.save!
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|