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:
Fernando Valverde 2022-04-29 12:38:40 -06:00 committed by GitHub
parent 37a82b8393
commit c4df397fef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 365 additions and 0 deletions

View 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
/* Dont 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

View 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

View file

@ -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:

View file

@ -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:

View file

@ -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 * * * *"

View 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

View file

@ -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

View 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

View 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