Community Wellness badge (#17394)
* First greedy algorithm approach to awarding community wellness badge
* Update app/services/badges/award_community_wellness.rb
I'm sorry I ignored you rubocop. It's still on draft for a reason 😅
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* Update app/services/badges/award_community_wellness.rb
Okay, whatever you say rubocop. This line will go away before merging anyways
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* Use app/queries/comments/community_wellness_query.rb to replace greedy algorithm
* Query indenting + rename positive_reactions to negative_reactions in EXCEPT
* First step towards query spec
* Fix spec and make it more robust
* Slight cleanup/tweaks
* Badge reward & message
* copy edits + spec improvements + 3 days ago min for query
* Cron update to run daily + small tweak for FeatureFlag use
* inline comment tweaks
* Slight reorder of guard checks in service (FeatureFlag related)
* PR review feedback
* Fix specs from static method refactor
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
37a82b8393
commit
c4df397fef
9 changed files with 365 additions and 0 deletions
68
app/queries/comments/community_wellness_query.rb
Normal file
68
app/queries/comments/community_wellness_query.rb
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# This query will return an array of hashes that have the following structure:
|
||||
# [
|
||||
# {
|
||||
# "user_id"=>11,
|
||||
# "serialized_weeks_ago"=>"1,16,20",
|
||||
# "serialized_comment_counts"=>"2,2,1"
|
||||
# },
|
||||
# ...
|
||||
# ]
|
||||
#
|
||||
# Each result (hash) comes with `user_id` and the data required to check for the
|
||||
# Community Wellness Badge. These limitations consist of users that have at
|
||||
# least 2 comments without negative reactions (thumbsdown/vomit) per week.
|
||||
#
|
||||
# Note: Using `connection.execute("").to_a` instead of `.find_by_sql` because
|
||||
# ActiveRecord doesn't populate any other fields that aren't included in the raw
|
||||
# result set, so we don't get any added value from instantiating AR objects
|
||||
module Comments
|
||||
class CommunityWellnessQuery
|
||||
def self.call
|
||||
ActiveRecord::Base.connection.execute(sql_query).to_a
|
||||
end
|
||||
|
||||
def self.sql_query
|
||||
<<~SQL
|
||||
SELECT user_id,
|
||||
/* A comma separated string of "weeks_ago" */
|
||||
array_to_string(array_agg(weeks_ago), ',') AS serialized_weeks_ago,
|
||||
/* A comma separated string of comment counts. The first value in this string happens on the week that is the first value in serialized_weeks_ago */
|
||||
array_to_string(array_agg(number_of_comments_with_positive_reaction), ',') AS serialized_comment_counts
|
||||
FROM
|
||||
(
|
||||
SELECT user_id,
|
||||
COUNT(user_id) AS number_of_comments_with_positive_reaction,
|
||||
/* Get the number of weeks, since today for posts */
|
||||
(trunc((extract(epoch FROM (current_timestamp- created_at))) / 604800) + 1) AS weeks_ago
|
||||
FROM comments
|
||||
INNER JOIN
|
||||
(
|
||||
SELECT DISTINCT reactable_id
|
||||
FROM reactions
|
||||
WHERE reactable_type = 'Comment'
|
||||
AND created_at > (now() - interval '224' day)
|
||||
EXCEPT
|
||||
SELECT DISTINCT reactable_id
|
||||
FROM reactions
|
||||
WHERE reactable_type = 'Comment'
|
||||
AND created_at > (now() - interval '224' day)
|
||||
AND category IN ('thumbsdown', 'vomit')) AS negative_reactions
|
||||
ON comments.id = negative_reactions.reactable_id
|
||||
INNER JOIN
|
||||
(
|
||||
SELECT count(id) AS number_of_comments,
|
||||
user_id AS comment_counts_user_id
|
||||
FROM comments
|
||||
WHERE created_at >= (now() - interval '7' day)
|
||||
GROUP BY user_id) AS comment_counts
|
||||
ON comments.user_id = comment_counts_user_id
|
||||
AND comment_counts.number_of_comments > 1
|
||||
/* Don’t select anything older than 224 days ago, or 32 weeks ago */
|
||||
WHERE created_at > (now() - interval '224' day)
|
||||
AND created_at < (now() - interval '3' day)
|
||||
GROUP BY user_id, weeks_ago) AS user_comment_counts_by_week
|
||||
GROUP BY user_id
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
||||
65
app/services/badges/award_community_wellness.rb
Normal file
65
app/services/badges/award_community_wellness.rb
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
module Badges
|
||||
class AwardCommunityWellness
|
||||
REWARD_STREAK_WEEKS = [1, 2, 4, 8, 16, 32].freeze
|
||||
|
||||
def self.call
|
||||
# These are the users 'eligible' to be awarded the badge
|
||||
results = Comments::CommunityWellnessQuery.call
|
||||
|
||||
results.each do |hash|
|
||||
# Parse the serialized results that come from the query
|
||||
weeks_ago = hash["serialized_weeks_ago"].split(",").map(&:to_i)
|
||||
comment_counts = hash["serialized_comment_counts"].split(",").map(&:to_i)
|
||||
|
||||
# `weeks_ago` can have values like the following:
|
||||
# - [1,2,10,11,12]
|
||||
# - [5]
|
||||
# - [1,2,3,4,5,6,7,8]
|
||||
# - [1,4,17]
|
||||
# We only care for active streak (starting at 1) so we need to filter
|
||||
# these to check how far back the (continuous) streak goes
|
||||
week_streak = 0
|
||||
weeks_ago.each_with_index do |week, index|
|
||||
# Must have 2 or more non-flagged comments posted on that week
|
||||
next unless comment_counts[index] > 1
|
||||
|
||||
# Must be a consecutive streak
|
||||
next unless week_streak + 1 == week
|
||||
|
||||
week_streak = week
|
||||
end
|
||||
|
||||
# Check that the current streak matches a reward level
|
||||
# Otherwise continue with next iteration (next user in query results)
|
||||
next unless REWARD_STREAK_WEEKS.include?(week_streak)
|
||||
next unless (user = User.find_by(id: hash["user_id"]))
|
||||
|
||||
# TODO: Remove FeatureFlag when truly ready for production use
|
||||
if FeatureFlag.enabled?(:community_wellness_badge)
|
||||
badge_slug = "#{week_streak}-week-community-wellness-streak"
|
||||
next unless (badge_id = Badge.id_for_slug(badge_slug))
|
||||
|
||||
user.badge_achievements.create(
|
||||
badge_id: badge_id,
|
||||
rewarding_context_message_markdown: generate_message(weeks: week_streak),
|
||||
)
|
||||
else
|
||||
# If the FeatureFlag isn't enabled only track which users would get
|
||||
# the badge to get an understanding of how the service will work
|
||||
Ahoy.instance&.track("Community Wellness Badge Award", user_id: user.id, weeks: week_streak)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.generate_message(weeks:)
|
||||
case weeks
|
||||
when 1
|
||||
I18n.t("services.badges.community_wellness.first")
|
||||
when REWARD_STREAK_WEEKS.last
|
||||
I18n.t("services.badges.community_wellness.longest")
|
||||
else
|
||||
I18n.t("services.badges.community_wellness.other", weeks: weeks)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -27,6 +27,10 @@ en:
|
|||
other: Happy %{community} birthday! Can you believe it's been %{count} years already?!
|
||||
congrats: Congrats!!!
|
||||
thank_you: Thank you so much for your contributions!
|
||||
community_wellness:
|
||||
first: Thank you for participating in constructive conversation! You posted two or more comments this week. Keep it up to continue the streak!
|
||||
longest: 32 weeks! You've achieved the longest streak possible for the Community Wellness badge. Amazing job engaging in the community and keep up your amazing contributions to our community!
|
||||
other: Congrats on achieving a %{weeks} week streak for the Community Wellness Badge! Keep the constructive comments flowing to continue the streak!
|
||||
broadcasts:
|
||||
welcome_notification:
|
||||
generator:
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ fr:
|
|||
other: Happy %{community} birthday! Can you believe it's been %{count} years already?!
|
||||
congrats: Congrats!!!
|
||||
thank_you: Thank you so much for your contributions!
|
||||
community_wellness:
|
||||
first: Thank you for participating in constructive conversation! You posted two or more comments this week. Keep it up to continue the streak!
|
||||
longest: 32 weeks! You've achieved the longest streak possible for the Community Wellness badge. Amazing job engaging in the community and keep up your amazing contributions to our community!
|
||||
other: Congrats on achieving a %{weeks} week streak for the Community Wellness Badge! Keep the constructive comments flowing to continue the streak!
|
||||
broadcasts:
|
||||
welcome_notification:
|
||||
generator:
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ award_contributor_badges_from_github:
|
|||
- ""
|
||||
- award_contributor_from_github
|
||||
- ""
|
||||
award_community_wellness_badges:
|
||||
description: "Awards 'Community Wellness' badges to users (runs daily at 00:45 UTC)"
|
||||
cron: "45 0 * * *"
|
||||
class: "BadgeAchievements::BadgeAwardWorker"
|
||||
args:
|
||||
- ""
|
||||
- award_community_wellness
|
||||
- ""
|
||||
remove_old_html_variant_data:
|
||||
description: "Deletes old HTML variants (runs hourly on the 10th minute after the hour)"
|
||||
cron: "10 * * * *"
|
||||
|
|
|
|||
127
spec/queries/comments/community_wellness_query_spec.rb
Normal file
127
spec/queries/comments/community_wellness_query_spec.rb
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Comments::CommunityWellnessQuery, type: :query do
|
||||
include ActionView::Helpers::DateHelper
|
||||
|
||||
let!(:articles) { create_list(:article, 4) }
|
||||
let!(:mod) { create(:user, :trusted) }
|
||||
let!(:user1) { create(:user) }
|
||||
let!(:user2) { create(:user) }
|
||||
let!(:user3) { create(:user) }
|
||||
let!(:user4) { create(:user) }
|
||||
let!(:user5) { create(:user) }
|
||||
|
||||
# Pre-populate "filler users" that shouldn't appear in results because they
|
||||
# don't meet the query criteria: min 2 comments in a week within last 32 weeks
|
||||
before do
|
||||
# User 3 - only one comment
|
||||
create_comment_time_ago(user3.id, 5.days.ago, commentable: articles.sample)
|
||||
|
||||
# User 4 - has 3 comments but doesn't meet criteria bc comments are too old
|
||||
create_comment_time_ago(user4.id, 226.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user4.id, 227.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user4.id, 227.days.ago, commentable: articles.sample)
|
||||
end
|
||||
|
||||
context "when multiple users match criteria" do
|
||||
before do
|
||||
# User 1 - week 1
|
||||
create_comment_time_ago(user1.id, 5.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user1.id, 6.days.ago, commentable: articles.sample)
|
||||
# User 1 - week 2
|
||||
create_comment_time_ago(user1.id, 8.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user1.id, 11.days.ago, commentable: articles.sample)
|
||||
|
||||
# User 2 - week 1
|
||||
create_comment_time_ago(user2.id, 4.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user2.id, 6.days.ago, commentable: articles.sample)
|
||||
# User 1 - week 2
|
||||
create_comment_time_ago(user2.id, 9.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user2.id, 13.days.ago, commentable: articles.sample)
|
||||
# User 1 - week 3
|
||||
create_comment_time_ago(user2.id, 17.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user2.id, 17.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user2.id, 18.days.ago, commentable: articles.sample)
|
||||
|
||||
# User 5 - has 2 comments in the first week but too early to award because
|
||||
# comments must be 3 days old or more to give mods time to flag before award
|
||||
create_comment_time_ago(user5.id, 2.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user5.id, 6.days.ago, commentable: articles.sample)
|
||||
end
|
||||
|
||||
it "returns the correct data structure (array of hashes w/ correct keys)" do
|
||||
result = described_class.call
|
||||
|
||||
expect(result).to be_instance_of(Array)
|
||||
expect(result.count).to eq(3)
|
||||
|
||||
result.each do |hash|
|
||||
expect(hash["user_id"]).to be_instance_of(Integer)
|
||||
expect(hash["serialized_weeks_ago"]).to be_instance_of(String)
|
||||
expect(hash["serialized_comment_counts"]).to be_instance_of(String)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns users with correct data on their corresponding hash" do
|
||||
result = described_class.call
|
||||
|
||||
result_user_ids = result.map { |hash| hash["user_id"] }
|
||||
expect(result_user_ids).to contain_exactly(user1.id, user2.id, user5.id)
|
||||
|
||||
index1 = result.index { |hash| hash["user_id"] == user1.id }
|
||||
expect(result[index1]["serialized_weeks_ago"]).to eq("1,2")
|
||||
expect(result[index1]["serialized_comment_counts"]).to eq("2,2")
|
||||
|
||||
index2 = result.index { |hash| hash["user_id"] == user2.id }
|
||||
expect(result[index2]["serialized_weeks_ago"]).to eq("1,2,3")
|
||||
expect(result[index2]["serialized_comment_counts"]).to eq("2,2,3")
|
||||
|
||||
# user5 will still appear in the query because it has 2 comments in the
|
||||
# week but the count will reflect only those that are 3+ days old
|
||||
index5 = result.index { |hash| hash["user_id"] == user5.id }
|
||||
expect(result[index5]["serialized_weeks_ago"]).to eq("1")
|
||||
expect(result[index5]["serialized_comment_counts"]).to eq("1")
|
||||
end
|
||||
end
|
||||
|
||||
context "when users match criteria but mod reaction reduces their comment counts" do
|
||||
before do
|
||||
# User 1 - week 1
|
||||
create_comment_time_ago(user1.id, 5.days.ago, commentable: articles.sample, flagged_by: mod)
|
||||
create_comment_time_ago(user1.id, 6.days.ago, commentable: articles.sample)
|
||||
# User 1 - week 2
|
||||
create_comment_time_ago(user1.id, 8.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user1.id, 11.days.ago, commentable: articles.sample)
|
||||
|
||||
# User 2 - week 1
|
||||
create_comment_time_ago(user2.id, 4.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user2.id, 6.days.ago, commentable: articles.sample)
|
||||
# User 1 - week 2
|
||||
create_comment_time_ago(user2.id, 9.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user2.id, 13.days.ago, commentable: articles.sample, flagged_by: mod)
|
||||
# User 1 - week 3
|
||||
create_comment_time_ago(user2.id, 17.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user2.id, 17.days.ago, commentable: articles.sample)
|
||||
create_comment_time_ago(user2.id, 18.days.ago, commentable: articles.sample)
|
||||
end
|
||||
|
||||
it "matches the correct comment count for each week in result hash" do
|
||||
result = described_class.call
|
||||
|
||||
result_user_ids = result.map { |hash| hash["user_id"] }
|
||||
# Result includes both users because they have > 1 comment per week
|
||||
expect(result_user_ids).to contain_exactly(user1.id, user2.id)
|
||||
|
||||
# user1 must have `1` in first week comment count because one of their 2
|
||||
# comments is flagged by a moderator
|
||||
index1 = result.index { |hash| hash["user_id"] == user1.id }
|
||||
expect(result[index1]["serialized_weeks_ago"]).to eq("1,2")
|
||||
expect(result[index1]["serialized_comment_counts"]).to eq("1,2")
|
||||
|
||||
# Second
|
||||
index2 = result.index { |hash| hash["user_id"] == user2.id }
|
||||
expect(result[index2]["serialized_weeks_ago"]).to eq("1,2,3")
|
||||
expect(result[index2]["serialized_comment_counts"]).to eq("2,1,3")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -71,6 +71,7 @@ RSpec.configure do |config|
|
|||
|
||||
config.include ActionMailer::TestHelper
|
||||
config.include ApplicationHelper
|
||||
config.include CommentsHelpers
|
||||
config.include Devise::Test::ControllerHelpers, type: :view
|
||||
config.include Devise::Test::IntegrationHelpers, type: :request
|
||||
config.include Devise::Test::IntegrationHelpers, type: :system
|
||||
|
|
|
|||
59
spec/services/badges/award_community_wellness_spec.rb
Normal file
59
spec/services/badges/award_community_wellness_spec.rb
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Badges::AwardCommunityWellness, type: :service do
|
||||
# Create one user per reward streak to test against
|
||||
let(:reward_weeks) { Badges::AwardCommunityWellness::REWARD_STREAK_WEEKS }
|
||||
let!(:users) { create_list(:user, reward_weeks.count) }
|
||||
# Using a list of articles to sample from helps to avoid creating new articles
|
||||
# for each comment to be created by `create_comment_time_ago`
|
||||
let!(:articles) { create_list(:article, 4) }
|
||||
|
||||
before do
|
||||
reward_weeks.each do |week|
|
||||
create(
|
||||
:badge,
|
||||
title: "#{week} Week Community Wellness Streak",
|
||||
slug: "#{week}-week-community-wellness-streak",
|
||||
)
|
||||
end
|
||||
|
||||
users.each_with_index do |_user, index|
|
||||
# Create 2 comments per-week to be tested, i.e. week 8 would create 2
|
||||
# comments on each of the 8 weeks (calculated on days_ago per week)
|
||||
reward_weeks[index].times do |week|
|
||||
days_ago = (5 + (week * 7)).days.ago
|
||||
create_comment_time_ago(users[index].id, days_ago, commentable: articles.sample)
|
||||
create_comment_time_ago(users[index].id, days_ago, commentable: articles.sample)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when user meets a new streak level" do
|
||||
# Test against each week that has a reward associated to it. All mock users
|
||||
# are expected to receive a badge in this spec (one per reward_weeks)
|
||||
#
|
||||
# TODO: When confirmed/tested we must remove the FeatureFlag
|
||||
it "awards a badge to each user with a streak of non-flagged comments" do
|
||||
allow(FeatureFlag).to receive(:enabled?).with(:community_wellness_badge).and_return(true)
|
||||
|
||||
expect do
|
||||
described_class.call
|
||||
users.each_with_index do |user, index|
|
||||
# Each user must be tested against it's corresponding streak
|
||||
expected_user_streak = reward_weeks[index]
|
||||
badge_slug = "#{expected_user_streak}-week-community-wellness-streak"
|
||||
expect(user.reload.badges.last.slug).to eq(badge_slug)
|
||||
end
|
||||
end.to change(BadgeAchievement, :count).by(users.count)
|
||||
end
|
||||
|
||||
# TODO: When confirmed/tested we must remove the FeatureFlag and this spec
|
||||
it "tracks awards with Ahoy if FeatureFlag isn't enabled" do
|
||||
allow(FeatureFlag).to receive(:enabled?).with(:community_wellness_badge).and_return(false)
|
||||
|
||||
expect do
|
||||
described_class.call
|
||||
end.to change(BadgeAchievement, :count).by(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
29
spec/support/comments_helper.rb
Normal file
29
spec/support/comments_helper.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
module CommentsHelpers
|
||||
FLAGGED_CATEGORIES = %w[thumbsdown vomit].freeze
|
||||
|
||||
def create_comment_time_ago(user_id, time_ago, flagged_by: nil, commentable: nil)
|
||||
comment = create(
|
||||
:comment,
|
||||
commentable: commentable || create(:article),
|
||||
user_id: user_id,
|
||||
created_at: time_ago,
|
||||
)
|
||||
# User default self-like
|
||||
create(
|
||||
:reaction,
|
||||
user_id: user_id,
|
||||
reactable_id: comment.id,
|
||||
reactable_type: "Comment",
|
||||
)
|
||||
|
||||
return if flagged_by.nil?
|
||||
|
||||
create(
|
||||
:reaction,
|
||||
user_id: flagged_by.id,
|
||||
category: FLAGGED_CATEGORIES.sample,
|
||||
reactable_id: comment.id,
|
||||
reactable_type: "Comment",
|
||||
)
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue