Files
discourse/plugins/voice/db/fixtures/001_badges.rb
T
Rafael dos Santos Silva 07b05ee8cd FEATURE: Enable voice badges by default and harden them first (#43190)
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.
2026-09-04 14:00:17 -03:00

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