diff --git a/app/queries/comments/community_wellness_query.rb b/app/queries/comments/community_wellness_query.rb new file mode 100644 index 000000000..5b6a508c1 --- /dev/null +++ b/app/queries/comments/community_wellness_query.rb @@ -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 diff --git a/app/services/badges/award_community_wellness.rb b/app/services/badges/award_community_wellness.rb new file mode 100644 index 000000000..20687f878 --- /dev/null +++ b/app/services/badges/award_community_wellness.rb @@ -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 diff --git a/config/locales/services/en.yml b/config/locales/services/en.yml index 423e8cb62..bd5db3d39 100644 --- a/config/locales/services/en.yml +++ b/config/locales/services/en.yml @@ -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: diff --git a/config/locales/services/fr.yml b/config/locales/services/fr.yml index 3e3fd014b..7b15fb15b 100644 --- a/config/locales/services/fr.yml +++ b/config/locales/services/fr.yml @@ -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: diff --git a/config/schedule.yml b/config/schedule.yml index 74a9192c4..13bb39a28 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -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 * * * *" diff --git a/spec/queries/comments/community_wellness_query_spec.rb b/spec/queries/comments/community_wellness_query_spec.rb new file mode 100644 index 000000000..d4be717de --- /dev/null +++ b/spec/queries/comments/community_wellness_query_spec.rb @@ -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 diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index c7d06f3a9..c25183660 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -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 diff --git a/spec/services/badges/award_community_wellness_spec.rb b/spec/services/badges/award_community_wellness_spec.rb new file mode 100644 index 000000000..9f3dfc81e --- /dev/null +++ b/spec/services/badges/award_community_wellness_spec.rb @@ -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 diff --git a/spec/support/comments_helper.rb b/spec/support/comments_helper.rb new file mode 100644 index 000000000..b0de8aa40 --- /dev/null +++ b/spec/support/comments_helper.rb @@ -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