mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
The team wants the voice badge set on by default. Reviewing the fixtures and grant hooks first turned up a handful of issues that only bite once real sites accumulate history, so this PR fixes them and flips the default in one go. ### Fixes - **Scheduled badges were auto-revocable.** Core's `Badge.auto_revoke` defaults to `true` and the fixtures never overrode it. Sessions and co-presence rows are purged after `voice_session_retention_days` (400), and a deleted user's co-presence rows are dropped, so the daily backfill would have silently revoked airtime, bonding, loyalty, exploration and hosting badges (and any titles set from them) as data aged out. All 19 query badges now set `auto_revoke = false`, same as core's cumulative badges. - **Crowd Puller / Master of Ceremonies were farmable.** The hosting query counted `COUNT(s.id)` across a creator's rooms, including the creator's own joins. At the 30/min join rate limit a Gold title took ~17 minutes. It now counts `DISTINCT` visitors excluding the creator. - **Packed House was unreachable for most rooms.** It read `room.max_participants`, which is `nil` whenever the room is capped by the site-wide setting. It now uses `effective_max_participants`, matching admission. - **Bulk toggles skipped badge maintenance.** `enable_all!`/`disable_all!` used `update_all`, bypassing the `Badge` callbacks that refresh featured ranks and distinct badge counts, leaving stale profile counts. They now run that once after the flip, and enabling also enqueues a backfill for the query badges so they show up right away instead of after the next daily job. - **Analytics dependency made explicit.** Every badge is computed from `voice_sessions`; with analytics off nothing can be earned. The hooks now check `voice_analytics_enabled` and the setting description references it. ### Default flip `voice_badges_enabled` now defaults to `true` and the fixtures seed badges as `default_enabled`. Neither reaches existing sites on its own: the settings-changed hook doesn't fire for a default change, and `default_enabled` only applies to new records. A post-migration enables the Voice-grouping badges on sites that have no explicit `voice_badges_enabled = false` row. ### Tests - New `badge_backfill_spec.rb` runs `BadgeGranter.backfill` for every SQL family at its threshold boundary, including the retention-purge case and the hosting self-join exclusion. - Migration spec covers untouched, explicitly-disabled, and non-Voice badges. - Hooks spec updated for the site-cap fallback, analytics gate, default-enabled seeding, and backfill enqueueing. ### Not changed (design notes from the review) - Weekend Warrior and Loyalty use server UTC for day boundaries while Night Owl / Early Bird use the user's timezone. Left as is; user timezones in SQL would need per-row `AT TIME ZONE` with invalid-name handling. - Icebreaker largely overlaps Mic Check since co-presence rows lag five minutes. Bronze, harmless. - Marathoner relies on `voice_afk_disconnect_threshold_minutes` (30) to keep idle tabs from earning a Gold title.
379 lines
10 KiB
Ruby
379 lines
10 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
voice_badge_group = "Voice"
|
|
|
|
BadgeGrouping.seed(:name) do |g|
|
|
g.name = voice_badge_group
|
|
g.default_position = 15
|
|
end
|
|
|
|
voice_grouping = BadgeGrouping.find_by(name: voice_badge_group)
|
|
|
|
duration_sql = "EXTRACT(EPOCH FROM (COALESCE(left_at, CURRENT_TIMESTAMP) - joined_at))"
|
|
|
|
co_presence_distinct_partners = lambda { |min_count| <<~SQL }
|
|
SELECT uid user_id, current_timestamp granted_at FROM (
|
|
SELECT user_id_1 AS uid, user_id_2 AS partner_id
|
|
FROM voice_co_presences
|
|
GROUP BY user_id_1, user_id_2
|
|
HAVING SUM(total_seconds) >= 300
|
|
UNION ALL
|
|
SELECT user_id_2 AS uid, user_id_1 AS partner_id
|
|
FROM voice_co_presences
|
|
GROUP BY user_id_2, user_id_1
|
|
HAVING SUM(total_seconds) >= 300
|
|
) sub
|
|
GROUP BY uid
|
|
HAVING COUNT(*) >= #{min_count}
|
|
SQL
|
|
|
|
co_presence_with_one_partner = lambda { |min_seconds| <<~SQL }
|
|
SELECT DISTINCT uid user_id, current_timestamp granted_at FROM (
|
|
SELECT user_id_1 AS uid
|
|
FROM voice_co_presences
|
|
GROUP BY user_id_1, user_id_2
|
|
HAVING SUM(total_seconds) >= #{min_seconds}
|
|
UNION ALL
|
|
SELECT user_id_2 AS uid
|
|
FROM voice_co_presences
|
|
GROUP BY user_id_1, user_id_2
|
|
HAVING SUM(total_seconds) >= #{min_seconds}
|
|
) sub
|
|
SQL
|
|
|
|
loyalty_query = lambda { |min_days| <<~SQL }
|
|
SELECT DISTINCT user_id, current_timestamp granted_at FROM (
|
|
SELECT user_id, room_id
|
|
FROM voice_sessions
|
|
GROUP BY user_id, room_id
|
|
HAVING COUNT(DISTINCT DATE(joined_at)) >= #{min_days}
|
|
) sub
|
|
SQL
|
|
|
|
# -- Welcome (instant) --
|
|
|
|
Badge.seed(:name) do |b|
|
|
b.name = "Mic Check"
|
|
b.default_icon = "microphone"
|
|
b.badge_type_id = BadgeType::Bronze
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = nil
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.system = true
|
|
end
|
|
|
|
# -- Airtime (scheduled) --
|
|
|
|
{
|
|
"Rookie" => [BadgeType::Bronze, 1.hour.to_i],
|
|
"Chatterbox" => [BadgeType::Silver, 10.hours.to_i],
|
|
"Silver Tongue" => [BadgeType::Gold, 100.hours.to_i],
|
|
}.each do |name, (type, threshold)|
|
|
Badge.seed(:name) do |b|
|
|
b.name = name
|
|
b.default_icon = "clock"
|
|
b.badge_type_id = type
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = <<~SQL
|
|
SELECT user_id, current_timestamp granted_at
|
|
FROM voice_sessions
|
|
GROUP BY user_id
|
|
HAVING SUM(#{duration_sql}) >= #{threshold}
|
|
SQL
|
|
b.auto_revoke = false
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.default_allow_title = type == BadgeType::Gold
|
|
b.system = true
|
|
end
|
|
end
|
|
|
|
# -- Networker (instant: Icebreaker, scheduled: Social Butterfly, Life of the Party) --
|
|
|
|
Badge.seed(:name) do |b|
|
|
b.name = "Icebreaker"
|
|
b.default_icon = "handshake"
|
|
b.badge_type_id = BadgeType::Bronze
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = nil
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.system = true
|
|
end
|
|
|
|
{
|
|
"Social Butterfly" => [BadgeType::Silver, 10],
|
|
"Life of the Party" => [BadgeType::Gold, 50],
|
|
}.each do |name, (type, count)|
|
|
Badge.seed(:name) do |b|
|
|
b.name = name
|
|
b.default_icon = "users"
|
|
b.badge_type_id = type
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = co_presence_distinct_partners.call(count)
|
|
b.auto_revoke = false
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.default_allow_title = type == BadgeType::Gold
|
|
b.system = true
|
|
end
|
|
end
|
|
|
|
# -- Bonding (scheduled) --
|
|
|
|
{
|
|
"Familiar Face" => [BadgeType::Bronze, 2.hours.to_i],
|
|
"Inner Circle" => [BadgeType::Silver, 10.hours.to_i],
|
|
"Partners in Crime" => [BadgeType::Gold, 50.hours.to_i],
|
|
}.each do |name, (type, threshold)|
|
|
Badge.seed(:name) do |b|
|
|
b.name = name
|
|
b.default_icon = "user-group"
|
|
b.badge_type_id = type
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = co_presence_with_one_partner.call(threshold)
|
|
b.auto_revoke = false
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.default_allow_title = type == BadgeType::Gold
|
|
b.system = true
|
|
end
|
|
end
|
|
|
|
# -- Exploration (scheduled) --
|
|
|
|
{
|
|
"Explorer" => [BadgeType::Bronze, 5],
|
|
"Nomad" => [BadgeType::Silver, 20],
|
|
"Omnipresent" => [BadgeType::Gold, 50],
|
|
}.each do |name, (type, count)|
|
|
Badge.seed(:name) do |b|
|
|
b.name = name
|
|
b.default_icon = "compass"
|
|
b.badge_type_id = type
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = <<~SQL
|
|
SELECT s.user_id, current_timestamp granted_at
|
|
FROM voice_sessions s
|
|
JOIN voice_rooms r ON r.id = s.room_id AND NOT r.ephemeral
|
|
GROUP BY s.user_id
|
|
HAVING COUNT(DISTINCT s.room_id) >= #{count}
|
|
SQL
|
|
b.auto_revoke = false
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.default_allow_title = type == BadgeType::Gold
|
|
b.system = true
|
|
end
|
|
end
|
|
|
|
# -- Loyalty (scheduled) --
|
|
|
|
{
|
|
"Patron" => [BadgeType::Bronze, 10],
|
|
"Barfly" => [BadgeType::Silver, 30],
|
|
"The Mayor" => [BadgeType::Gold, 100],
|
|
}.each do |name, (type, days)|
|
|
Badge.seed(:name) do |b|
|
|
b.name = name
|
|
b.default_icon = "calendar"
|
|
b.badge_type_id = type
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = loyalty_query.call(days)
|
|
b.auto_revoke = false
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.default_allow_title = type == BadgeType::Gold
|
|
b.system = true
|
|
end
|
|
end
|
|
|
|
# -- Hosting (instant: Host, scheduled: Crowd Puller, Master of Ceremonies) --
|
|
|
|
Badge.seed(:name) do |b|
|
|
b.name = "Host"
|
|
b.default_icon = "house"
|
|
b.badge_type_id = BadgeType::Bronze
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = nil
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.system = true
|
|
end
|
|
|
|
{
|
|
"Crowd Puller" => [BadgeType::Silver, 50, "bullhorn"],
|
|
"Master of Ceremonies" => [BadgeType::Gold, 500, "star"],
|
|
}.each do |name, (type, count, icon)|
|
|
Badge.seed(:name) do |b|
|
|
b.name = name
|
|
b.default_icon = icon
|
|
b.badge_type_id = type
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = <<~SQL
|
|
SELECT r.creator_id user_id, current_timestamp granted_at
|
|
FROM voice_rooms r
|
|
JOIN voice_sessions s ON s.room_id = r.id AND s.user_id <> r.creator_id
|
|
WHERE NOT r.ephemeral
|
|
GROUP BY r.creator_id
|
|
HAVING COUNT(DISTINCT s.user_id) >= #{count}
|
|
SQL
|
|
b.auto_revoke = false
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.default_allow_title = type == BadgeType::Gold
|
|
b.system = true
|
|
end
|
|
end
|
|
|
|
# -- Inviting (instant: Plus One, scheduled: Connector, People Magnet) --
|
|
|
|
Badge.seed(:name) do |b|
|
|
b.name = "Plus One"
|
|
b.default_icon = "user-plus"
|
|
b.badge_type_id = BadgeType::Bronze
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = nil
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.system = true
|
|
end
|
|
|
|
{
|
|
"Connector" => [BadgeType::Silver, 10, "circle-nodes"],
|
|
"People Magnet" => [BadgeType::Gold, 50, "magnet"],
|
|
}.each do |name, (type, count, icon)|
|
|
Badge.seed(:name) do |b|
|
|
b.name = name
|
|
b.default_icon = icon
|
|
b.badge_type_id = type
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = <<~SQL
|
|
SELECT invited_by_id user_id, current_timestamp granted_at
|
|
FROM voice_invites
|
|
WHERE redeemed_at IS NOT NULL
|
|
GROUP BY invited_by_id
|
|
HAVING COUNT(DISTINCT user_id) >= #{count}
|
|
SQL
|
|
b.auto_revoke = false
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.default_allow_title = type == BadgeType::Gold
|
|
b.system = true
|
|
end
|
|
end
|
|
|
|
# -- Standalone --
|
|
|
|
Badge.seed(:name) do |b|
|
|
b.name = "Night Owl"
|
|
b.default_icon = "moon"
|
|
b.badge_type_id = BadgeType::Bronze
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = nil
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.system = true
|
|
end
|
|
|
|
Badge.seed(:name) do |b|
|
|
b.name = "Early Bird"
|
|
b.default_icon = "sun"
|
|
b.badge_type_id = BadgeType::Bronze
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = nil
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.system = true
|
|
end
|
|
|
|
Badge.seed(:name) do |b|
|
|
b.name = "Packed House"
|
|
b.default_icon = "people-group"
|
|
b.badge_type_id = BadgeType::Silver
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = nil
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.system = true
|
|
end
|
|
|
|
Badge.seed(:name) do |b|
|
|
b.name = "Weekend Warrior"
|
|
b.default_icon = "calendar-week"
|
|
b.badge_type_id = BadgeType::Silver
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = <<~SQL
|
|
SELECT user_id, current_timestamp granted_at
|
|
FROM voice_sessions
|
|
WHERE EXTRACT(DOW FROM joined_at) IN (0, 6)
|
|
GROUP BY user_id
|
|
HAVING SUM(#{duration_sql}) >= #{5.hours.to_i}
|
|
SQL
|
|
b.auto_revoke = false
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.system = true
|
|
end
|
|
|
|
Badge.seed(:name) do |b|
|
|
b.name = "Marathoner"
|
|
b.default_icon = "trophy"
|
|
b.badge_type_id = BadgeType::Gold
|
|
b.multiple_grant = false
|
|
b.target_posts = false
|
|
b.show_posts = false
|
|
b.query = nil
|
|
b.default_badge_grouping_id = voice_grouping.id
|
|
b.trigger = Badge::Trigger::None
|
|
b.default_enabled = true
|
|
b.default_allow_title = true
|
|
b.system = true
|
|
end
|