mirror of
https://github.com/discourse/discourse.git
synced 2026-08-14 13:58:53 +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
50 lines
1.6 KiB
Ruby
Vendored
50 lines
1.6 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe Jobs::DiscourseTopicVoting::VoteReclaim do
|
|
context "when the topic does not exist" do
|
|
fab!(:topic)
|
|
|
|
it "does nothing" do
|
|
topic.destroy!
|
|
Topic.any_instance.expects(:update_vote_count).never
|
|
Jobs::DiscourseTopicVoting::VoteReclaim.new.execute(topic_id: topic.id)
|
|
end
|
|
end
|
|
|
|
context "when the topic exists" do
|
|
fab!(:topic)
|
|
|
|
it "updates the vote count" do
|
|
Fabricate(:topic_voting_votes, topic: topic, archive: true)
|
|
expect { Jobs::DiscourseTopicVoting::VoteReclaim.new.execute(topic_id: topic.id) }.to change {
|
|
topic.reload.vote_count
|
|
}.by(1)
|
|
end
|
|
|
|
it "reclaims the votes" do
|
|
vote = Fabricate(:topic_voting_votes, topic: topic, archive: true)
|
|
expect { Jobs::DiscourseTopicVoting::VoteReclaim.new.execute(topic_id: topic.id) }.to change {
|
|
vote.reload.archived
|
|
}.from(true).to(false)
|
|
end
|
|
|
|
it "enqueues the backfill badges job" do
|
|
Fabricate(:topic_voting_votes, topic:, archive: true)
|
|
expect { Jobs::DiscourseTopicVoting::VoteReclaim.new.execute(topic_id: topic.id) }.to change(
|
|
Jobs::DiscourseTopicVoting::BackfillBadges.jobs,
|
|
:size,
|
|
).by(1)
|
|
end
|
|
|
|
context "when the topic is deleted" do
|
|
before { topic.trash! }
|
|
|
|
it "reclaims the votes" do
|
|
vote = Fabricate(:topic_voting_votes, topic: topic, archive: true)
|
|
expect {
|
|
Jobs::DiscourseTopicVoting::VoteReclaim.new.execute(topic_id: topic.id)
|
|
}.to change { vote.reload.archived }.from(true).to(false)
|
|
end
|
|
end
|
|
end
|
|
end
|