mirror of
https://github.com/discourse/discourse.git
synced 2026-08-11 02:59:07 +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
140 lines
5.7 KiB
Ruby
Vendored
140 lines
5.7 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
describe DiscourseTopicVoting::TopicMerger do
|
|
fab!(:user_0, :user)
|
|
fab!(:user_1, :user)
|
|
fab!(:user_2, :user)
|
|
fab!(:user_3, :user)
|
|
fab!(:user_4, :user)
|
|
fab!(:user_5, :user)
|
|
|
|
fab!(:category_1, :category)
|
|
fab!(:category_2, :category)
|
|
|
|
fab!(:topic_0) { Fabricate(:topic, category: category_1) }
|
|
fab!(:topic_1) { Fabricate(:topic, category: category_2) }
|
|
|
|
before { SiteSetting.topic_voting_enabled = true }
|
|
|
|
describe ".merge" do
|
|
it "does nothing when the source topic is still open" do
|
|
DiscourseTopicVoting::Vote.create!(user: user_0, topic: topic_0)
|
|
topic_0.update_vote_count
|
|
|
|
described_class.merge(topic_0, topic_1)
|
|
|
|
user_0.reload
|
|
expect(user_0.topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_0.id)
|
|
expect(user_0.topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
end
|
|
|
|
it "archives moved votes when the destination topic is closed" do
|
|
topic_0.update_status("closed", true, Discourse.system_user)
|
|
topic_1.update_status("closed", true, Discourse.system_user)
|
|
|
|
DiscourseTopicVoting::Vote.create!(user: user_0, topic: topic_0)
|
|
topic_0.update_vote_count
|
|
|
|
described_class.merge(topic_0, topic_1)
|
|
|
|
user_0.reload
|
|
expect(user_0.topics_with_vote.pluck(:topic_id)).to be_blank
|
|
expect(user_0.topics_with_archived_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
|
|
expect(topic_0.reload.vote_count).to eq(0)
|
|
expect(topic_1.reload.vote_count).to eq(1)
|
|
end
|
|
|
|
it "enqueues the backfill badges job for the destination topic when votes move" do
|
|
topic_0.update_status("closed", true, Discourse.system_user)
|
|
|
|
DiscourseTopicVoting::Vote.create!(user: user_0, topic: topic_0)
|
|
topic_0.update_vote_count
|
|
|
|
expect { described_class.merge(topic_0, topic_1) }.to change(
|
|
Jobs::DiscourseTopicVoting::BackfillBadges.jobs,
|
|
:size,
|
|
).by(1)
|
|
expect(Jobs::DiscourseTopicVoting::BackfillBadges.jobs.last["args"].first).to include(
|
|
"topic_id" => topic_1.id,
|
|
)
|
|
end
|
|
end
|
|
|
|
context "when merging topics via move_posts (topic_merged)" do
|
|
let(:users) { [user_0, user_1, user_2, user_3, user_4, user_5] }
|
|
|
|
before do
|
|
SiteSetting.topic_voting_show_who_voted = false
|
|
|
|
Fabricate(:post, topic: topic_0, user: user_0)
|
|
Fabricate(:post, topic: topic_0, user: user_0)
|
|
|
|
DiscourseTopicVoting::Vote.create!(user: users[0], topic: topic_0)
|
|
DiscourseTopicVoting::Vote.create!(user: users[1], topic: topic_1)
|
|
DiscourseTopicVoting::Vote.create!(user: users[2], topic: topic_0)
|
|
DiscourseTopicVoting::Vote.create!(user: users[2], topic: topic_1)
|
|
DiscourseTopicVoting::Vote.create!(user: users[4], topic: topic_0, archive: true)
|
|
DiscourseTopicVoting::Vote.create!(user: users[5], topic: topic_0, archive: true)
|
|
DiscourseTopicVoting::Vote.create!(user: users[5], topic: topic_1)
|
|
|
|
[topic_0, topic_1].each { |t| t.update_vote_count }
|
|
end
|
|
|
|
it "moves votes when entire topic is merged" do
|
|
topic_0.move_posts(
|
|
Discourse.system_user,
|
|
topic_0.posts.pluck(:id),
|
|
destination_topic_id: topic_1.id,
|
|
)
|
|
|
|
users.each { |user| user.reload }
|
|
expect(users[0].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
|
|
expect(users[0].topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
|
|
expect(users[1].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
|
|
expect(users[1].topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
|
|
expect(users[2].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
|
|
expect(users[2].topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
|
|
expect(users[3].topics_with_vote.pluck(:topic_id)).to be_blank
|
|
expect(users[3].topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
|
|
expect(users[4].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
|
|
expect(users[4].topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
|
|
expect(users[5].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
|
|
expect(users[5].topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
|
|
expect(topic_0.reload.vote_count).to eq(0)
|
|
expect(topic_1.reload.vote_count).to eq(5)
|
|
|
|
merged_post = topic_0.posts.find_by(action_code: "split_topic")
|
|
expect(merged_post.raw).to include(I18n.t("topic_voting.votes_moved", count: 2))
|
|
expect(merged_post.raw).to include(I18n.t("topic_voting.duplicated_votes", count: 2))
|
|
end
|
|
|
|
it "does not move votes when not all posts are moved and the original topic does not get closed" do
|
|
topic_0.move_posts(
|
|
Discourse.system_user,
|
|
[topic_0.posts.order(:post_number).first.id],
|
|
destination_topic_id: topic_1.id,
|
|
)
|
|
|
|
users.each { |user| user.reload }
|
|
expect(users[0].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_0.id)
|
|
expect(users[0].topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
expect(users[1].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
|
|
expect(users[1].topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
expect(users[2].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_0.id, topic_1.id)
|
|
expect(users[2].topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
expect(users[3].topics_with_vote.pluck(:topic_id)).to be_blank
|
|
expect(users[3].topics_with_archived_vote.pluck(:topic_id)).to be_blank
|
|
expect(users[4].topics_with_vote.pluck(:topic_id)).to be_blank
|
|
expect(users[4].topics_with_archived_vote.pluck(:topic_id)).to contain_exactly(topic_0.id)
|
|
|
|
expect(topic_0.reload.vote_count).to eq(4)
|
|
expect(topic_1.reload.vote_count).to eq(3)
|
|
end
|
|
end
|
|
end
|