discourse/plugins/discourse-topic-voting/plugin.rb
Régis Hanol 5cef23ef63
FEATURE: award badges based on topic votes received (#39493)
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
2026-04-28 10:53:06 +10:00

246 lines
8.4 KiB
Ruby
Executable file

# frozen_string_literal: true
# name: discourse-topic-voting
# about: Adds the ability to vote on topics in a specified category.
# meta_topic_id: 40121
# version: 0.5
# author: Joe Buhlig joebuhlig.com, Sam Saffron
# url: https://github.com/discourse/discourse/tree/main/plugins/discourse-topic-voting
register_asset "stylesheets/common/topic-voting.scss"
register_svg_icon "check-to-slot"
register_svg_icon "vote-up"
register_svg_icon "vote-up-filled"
enabled_site_setting :topic_voting_enabled
Discourse.top_menu_items.push(:votes)
Discourse.anonymous_top_menu_items.push(:votes)
Discourse.filters.push(:votes)
Discourse.anonymous_filters.push(:votes)
module ::DiscourseTopicVoting
PLUGIN_NAME = "discourse-topic-voting"
ENABLE_TOPIC_VOTING_SETTING = "enable_topic_voting"
VOTER_PREVIEW_LIMIT = 104
BADGE_NAMES = %w[Daydreamer Brainstormer Innovator Visionary].freeze
end
require_relative "lib/discourse_topic_voting/badge_queries"
require_relative "lib/discourse_topic_voting/engine"
require_relative "lib/discourse_topic_voting/topic_votes_filter"
after_initialize do
SeedFu.fixture_paths << Rails
.root
.join("plugins", "discourse-topic-voting", "db", "fixtures")
.to_s
reloadable_patch do
register_category_type(DiscourseTopicVoting::Categories::Types::Ideas)
CategoriesController.prepend(DiscourseTopicVoting::CategoriesControllerExtension)
Category.prepend(DiscourseTopicVoting::CategoryExtension)
ListController.prepend(DiscourseTopicVoting::ListControllerExtension)
Topic.prepend(DiscourseTopicVoting::TopicExtension)
TopicQuery.prepend(DiscourseTopicVoting::TopicQueryExtension)
User.prepend(DiscourseTopicVoting::UserExtension)
WebHook.prepend(DiscourseTopicVoting::WebHookExtension)
end
add_to_serializer(:post, :can_vote, include_condition: -> { object.post_number == 1 }) do
object.topic&.can_vote?
end
add_to_serializer(:topic_view, :can_vote) { object.topic.can_vote? }
add_to_serializer(:topic_view, :vote_count) { object.topic.vote_count }
add_to_serializer(:topic_view, :user_voted) do
scope.user ? object.topic.user_voted?(scope.user) : false
end
TopicQuery.results_filter_callbacks << ->(_type, result, user, options) do
return result unless SiteSetting.topic_voting_enabled
result = result.preload(:topic_vote_count)
if user
result =
result.select(
"topics.*, COALESCE((SELECT 1 FROM topic_voting_votes WHERE user_id = #{user.id} AND topic_id = topics.id), 0) AS current_user_voted",
)
if options[:state] == "my_votes"
result =
result.joins(
"INNER JOIN topic_voting_votes ON topic_voting_votes.topic_id = topics.id AND topic_voting_votes.user_id = #{user.id} AND topic_voting_votes.archive = FALSE",
)
end
end
if options[:order] == "votes"
sort_dir = (options[:ascending] == "true") ? "ASC" : "DESC"
result =
result.joins(
"LEFT JOIN topic_voting_topic_vote_count ON topic_voting_topic_vote_count.topic_id = topics.id",
).reorder(
"COALESCE(topic_voting_topic_vote_count.votes_count,'0')::integer #{sort_dir}, topics.bumped_at DESC",
)
end
result
end
register_category_custom_field_type(DiscourseTopicVoting::ENABLE_TOPIC_VOTING_SETTING, :boolean)
register_preloaded_category_custom_fields DiscourseTopicVoting::ENABLE_TOPIC_VOTING_SETTING
add_to_serializer(:topic_list_item, :vote_count, include_condition: -> { object.can_vote? }) do
object.vote_count
end
add_to_serializer(:topic_list_item, :can_vote, include_condition: -> { object.regular? }) do
object.can_vote?
end
add_to_serializer(:topic_list_item, :user_voted, include_condition: -> { object.can_vote? }) do
object.user_voted?(scope.user) if scope.user
end
add_to_serializer(
:basic_category,
:can_vote,
include_condition: -> { Category.can_vote?(object.id) },
) { true }
register_search_advanced_filter(/^min_vote_count:(\d+)$/) do |posts, match|
posts.where(
"(SELECT votes_count FROM topic_voting_topic_vote_count WHERE topic_voting_topic_vote_count.topic_id = posts.topic_id) >= ?",
match.to_i,
)
end
register_search_advanced_order(:votes) do |posts|
posts.reorder(
"COALESCE((SELECT dvtvc.votes_count FROM topic_voting_topic_vote_count dvtvc WHERE dvtvc.topic_id = topics.id), 0) DESC",
)
end
register_modifier(:badge_granter_suppress_notification) do |suppress, badge, granted_at, _|
next true if DiscourseTopicVoting::BADGE_NAMES.include?(badge.name) && granted_at < 2.weeks.ago
suppress
end
register_modifier(:topics_filter_options) do |results, _guardian|
results.concat(
[
{
name: "votes-min:",
description: I18n.t("topic_voting.filter.description.topic_votes_min"),
type: "number",
},
{
name: "votes-max:",
description: I18n.t("topic_voting.filter.description.topic_votes_max"),
type: "number",
},
{
name: "order:votes",
description: I18n.t("topic_voting.filter.description.order_topic_votes"),
},
{
name: "order:votes-asc",
description: I18n.t("topic_voting.filter.description.order_topic_votes_asc"),
},
],
)
results
end
add_to_serializer(:current_user, :votes_exceeded) { object.reached_voting_limit? }
add_to_serializer(:current_user, :votes_count) { object.vote_count }
add_to_serializer(:current_user, :votes_left) { object.votes_left }
add_to_serializer(:current_user, :vote_limit) { object.vote_limit }
topic_votes_value_from = ->(values) do
value = values&.last
return if value.blank? || value !~ /\A\d+\z/
value.to_i
end
add_filter_custom_filter("votes-min") do |scope, filter_values, _guardian|
value = topic_votes_value_from.call(filter_values)
next scope if value.nil?
DiscourseTopicVoting::TopicVotesFilter.apply(scope, min_votes: value)
end
add_filter_custom_filter("votes-max") do |scope, filter_values, _guardian|
value = topic_votes_value_from.call(filter_values)
next scope if value.nil?
DiscourseTopicVoting::TopicVotesFilter.apply(scope, max_votes: value)
end
add_filter_custom_filter("order:votes") do |scope, order_direction, _guardian|
DiscourseTopicVoting::TopicVotesFilter.apply(scope, order_direction:)
end
on(:topic_status_updated) do |topic, status, enabled|
next if topic.trashed?
next if %w[closed autoclosed archived].exclude?(status)
if enabled
Jobs.enqueue(Jobs::DiscourseTopicVoting::VoteRelease, topic_id: topic.id)
else
is_closing_unarchived = %w[closed autoclosed].include?(status) && !topic.archived
is_archiving_open = status == "archived" && !topic.closed
if is_closing_unarchived || is_archiving_open
Jobs.enqueue(Jobs::DiscourseTopicVoting::VoteReclaim, topic_id: topic.id)
end
end
end
on(:topic_trashed) do |topic|
if !topic.closed && !topic.archived
Jobs.enqueue(Jobs::DiscourseTopicVoting::VoteRelease, topic_id: topic.id, trashed: true)
end
end
on(:topic_recovered) do |topic|
if !topic.closed && !topic.archived
Jobs.enqueue(Jobs::DiscourseTopicVoting::VoteReclaim, topic_id: topic.id)
end
end
on(:post_edited) do |post, _, revisor|
if SiteSetting.topic_voting_enabled && revisor.topic_diff.has_key?("category_id") &&
DiscourseTopicVoting::Vote.exists?(topic_id: post.topic_id) && !post.topic.closed &&
!post.topic.archived && !post.topic.trashed?
new_category_id = post.reload.topic.category_id
if Category.can_vote?(new_category_id)
Jobs.enqueue(Jobs::DiscourseTopicVoting::VoteReclaim, topic_id: post.topic_id)
else
Jobs.enqueue(Jobs::DiscourseTopicVoting::VoteRelease, topic_id: post.topic_id)
end
end
end
on(:topic_merged) { |orig, dest| DiscourseTopicVoting::TopicMerger.merge(orig, dest) }
on(:merging_users) do |source_user, target_user|
DiscourseTopicVoting::UserMerger.merge(source_user, target_user)
end
Discourse::Application.routes.prepend do
get "c/*category_slug_path_with_id/l/votes.rss" => "list#votes_feed", :format => :rss
end
Discourse::Application.routes.append do
mount DiscourseTopicVoting::Engine, at: "/voting"
get "topics/voted-by/:username" => "list#voted_by",
:as => "voted_by",
:constraints => {
username: RouteFormat.username,
}
end
end