diff --git a/app/models/badge.rb b/app/models/badge.rb index 38011348a..3bee2e6be 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -1,16 +1,22 @@ class Badge < ActiveRecord::Base - # badge ids + # NOTE: These badge ids are not in order! They are grouped logically. When picking an id + # search for it. + Welcome = 5 NicePost = 6 GoodPost = 7 GreatPost = 8 Autobiographer = 9 Editor = 10 + FirstLike = 11 FirstShare = 12 FirstFlag = 13 FirstLink = 14 FirstQuote = 15 + FirstMention = 40 + FirstEmoji = 41 + ReadGuidelines = 16 Reader = 17 NiceTopic = 18 @@ -58,13 +64,14 @@ class Badge < ActiveRecord::Base PostRevision = 2 TrustLevelChange = 4 UserChange = 8 + PostProcessed = 16 def self.is_none?(trigger) [None].include? trigger end def self.uses_user_ids?(trigger) - [TrustLevelChange, UserChange].include? trigger + [TrustLevelChange, UserChange, PostProcessed].include? trigger end def self.uses_post_ids?(trigger) @@ -72,240 +79,6 @@ class Badge < ActiveRecord::Base end end - module Queries - - Reader = < 100 - GROUP BY pt.user_id, pt.topic_id, t.posts_count - HAVING count(*) >= t.posts_count - ) -SQL - - ReadGuidelines = < p2.topic_id AND not quote AND - (:backfill OR ( p1.id in (:post_ids) )) - GROUP BY l1.user_id - ) ids - JOIN topic_links l ON l.id = ids.id -SQL - - FirstShare = < 0 AND - (:backfill OR p.id IN (:post_ids) ) - GROUP BY p.user_id -SQL - - Welcome = < #{Badge::AutobiographerMinBioLength} AND - uploaded_avatar_id IS NOT NULL AND - (:backfill OR u.id IN (:user_ids) ) -SQL - - # member for a year + has posted at least once during that year - OneYearAnniversary = <<-SQL - SELECT u.id AS user_id, MIN(u.created_at + interval '1 year') AS granted_at - FROM users u - JOIN posts p ON p.user_id = u.id - WHERE u.id > 0 - AND u.active - AND NOT u.blocked - AND u.created_at + interval '1 year' < now() - AND p.deleted_at IS NULL - AND NOT p.hidden - AND p.created_at + interval '1 year' > now() - AND (:backfill OR u.id IN (:user_ids)) - GROUP BY u.id - HAVING COUNT(p.id) > 0 -SQL - - def self.invite_badge(count,trust_level) -" - SELECT u.id user_id, current_timestamp granted_at - FROM users u - WHERE u.id IN ( - SELECT invited_by_id - FROM invites i - JOIN users u2 ON u2.id = i.user_id - WHERE i.deleted_at IS NULL AND u2.active AND u2.trust_level >= #{trust_level.to_i} AND not u2.blocked - GROUP BY invited_by_id - HAVING COUNT(*) >= #{count.to_i} - ) AND u.active AND NOT u.blocked AND u.id > 0 AND - (:backfill OR u.id IN (:user_ids) ) -" - end - - def self.like_badge(count, is_topic) - # we can do better with dates, but its hard work -" - SELECT p.user_id, p.id post_id, p.updated_at granted_at - FROM badge_posts p - WHERE #{is_topic ? "p.post_number = 1" : "p.post_number > 1" } AND p.like_count >= #{count.to_i} AND - (:backfill OR p.id IN (:post_ids) ) -" - end - - def self.trust_level(level) - # we can do better with dates, but its hard work figuring this out historically -" - SELECT u.id user_id, current_timestamp granted_at FROM users u - WHERE trust_level >= #{level.to_i} AND ( - :backfill OR u.id IN (:user_ids) - ) -" - end - - def self.sharing_badge(count) -< #{count} - ) as views - JOIN incoming_links i2 ON i2.id = views.i_id -SQL - end - - def self.linking_badge(count) - <<-SQL - SELECT tl.user_id, post_id, current_timestamp granted_at - FROM topic_links tl - JOIN posts p ON p.id = post_id AND p.deleted_at IS NULL - JOIN topics t ON t.id = p.topic_id AND t.deleted_at IS NULL AND t.archetype <> 'private_message' - WHERE NOT tl.internal - AND tl.clicks >= #{count} - GROUP BY tl.user_id, tl.post_id - SQL - end - - def self.liked_posts(post_count, like_count) - <<-SQL - SELECT p.user_id, current_timestamp AS granted_at - FROM posts AS p - WHERE p.like_count >= #{like_count} - AND (:backfill OR p.user_id IN (:user_ids)) - GROUP BY p.user_id - HAVING count(*) > #{post_count} - SQL - end - - def self.like_rate_limit(count) - <<-SQL - SELECT gdl.user_id, current_timestamp AS granted_at - FROM given_daily_likes AS gdl - WHERE gdl.limit_reached - AND (:backfill OR gdl.user_id IN (:user_ids)) - GROUP BY gdl.user_id - HAVING COUNT(*) >= #{count} - SQL - end - - def self.liked_back(likes_received, likes_given) - <<-SQL - SELECT us.user_id, current_timestamp AS granted_at - FROM user_stats AS us - WHERE us.likes_received >= #{likes_received} - AND us.likes_given >= #{likes_given} - AND (:backfill OR us.user_id IN (:user_ids)) - SQL - end - end - belongs_to :badge_type belongs_to :badge_grouping @@ -433,6 +206,7 @@ SQL end protected + def ensure_not_system unless id self.id = [Badge.maximum(:id) + 1, 100].max diff --git a/app/models/user_first.rb b/app/models/user_first.rb new file mode 100644 index 000000000..6a953b5d1 --- /dev/null +++ b/app/models/user_first.rb @@ -0,0 +1,14 @@ +class UserFirst < ActiveRecord::Base + + def self.types + @types ||= Enum.new(used_emoji: 1, mentioned_user: 2) + end + + def self.create_for(user_id, type, post_id=nil) + create!(user_id: user_id, first_type: types[type], post_id: post_id) + true + rescue PG::UniqueViolation, ActiveRecord::RecordNotUnique + # Violating the index just means the user already did it + false + end +end diff --git a/app/services/badge_granter.rb b/app/services/badge_granter.rb index 7655329d4..89056a6ec 100644 --- a/app/services/badge_granter.rb +++ b/app/services/badge_granter.rb @@ -102,6 +102,12 @@ class BadgeGranter type: "PostAction", post_ids: [action.post_id, action.related_post_id].compact! } + when Badge::Trigger::PostProcessed + user = opt[:user] + payload = { + type: "PostProcessed", + user_ids: [user.id] + } end $redis.lpush queue_key, payload.to_json if payload diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index aca521dcd..e1fb3ff26 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2738,6 +2738,7 @@ en: post_revision: "When a user edits or creates a post" trust_level_change: "When a user changes trust level" user_change: "When a user is edited or created" + post_processed: "After a post is processed" preview: link_text: "Preview granted badges" plan_text: "Preview with query plan" diff --git a/db/fixtures/006_badges.rb b/db/fixtures/006_badges.rb index bb2e9fba0..22ab975cd 100644 --- a/db/fixtures/006_badges.rb +++ b/db/fixtures/006_badges.rb @@ -1,3 +1,4 @@ +require 'badge_queries' BadgeGrouping.seed do |g| g.id = BadgeGrouping::GettingStarted @@ -50,7 +51,7 @@ trust_level_badges.each do |spec| b.id = spec[:id] b.default_name = spec[:name] b.badge_type_id = spec[:type] - b.query = Badge::Queries.trust_level(spec[:id]) + b.query = BadgeQueries.trust_level(spec[:id]) b.default_badge_grouping_id = BadgeGrouping::TrustLevel b.trigger = Badge::Trigger::TrustLevelChange @@ -68,7 +69,7 @@ Badge.seed do |b| b.multiple_grant = false b.target_posts = false b.show_posts = false - b.query = Badge::Queries::Reader + b.query = BadgeQueries::Reader b.default_badge_grouping_id = BadgeGrouping::GettingStarted b.auto_revoke = false b.system = true @@ -81,7 +82,7 @@ Badge.seed do |b| b.multiple_grant = false b.target_posts = false b.show_posts = false - b.query = Badge::Queries::ReadGuidelines + b.query = BadgeQueries::ReadGuidelines b.default_badge_grouping_id = BadgeGrouping::GettingStarted b.trigger = Badge::Trigger::UserChange b.system = true @@ -94,7 +95,7 @@ Badge.seed do |b| b.multiple_grant = false b.target_posts = true b.show_posts = true - b.query = Badge::Queries::FirstLink + b.query = BadgeQueries::FirstLink b.default_badge_grouping_id = BadgeGrouping::GettingStarted b.trigger = Badge::Trigger::PostRevision b.system = true @@ -107,7 +108,7 @@ Badge.seed do |b| b.multiple_grant = false b.target_posts = true b.show_posts = true - b.query = Badge::Queries::FirstQuote + b.query = BadgeQueries::FirstQuote b.default_badge_grouping_id = BadgeGrouping::GettingStarted b.trigger = Badge::Trigger::PostRevision b.system = true @@ -120,7 +121,7 @@ Badge.seed do |b| b.multiple_grant = false b.target_posts = true b.show_posts = true - b.query = Badge::Queries::FirstLike + b.query = BadgeQueries::FirstLike b.default_badge_grouping_id = BadgeGrouping::GettingStarted b.trigger = Badge::Trigger::PostAction b.system = true @@ -133,7 +134,7 @@ Badge.seed do |b| b.multiple_grant = false b.target_posts = true b.show_posts = false - b.query = Badge::Queries::FirstFlag + b.query = BadgeQueries::FirstFlag b.badge_grouping_id = BadgeGrouping::GettingStarted b.default_badge_grouping_id = BadgeGrouping::GettingStarted b.trigger = Badge::Trigger::PostAction @@ -154,7 +155,7 @@ end b.multiple_grant = false b.target_posts = false b.show_posts = false - b.query = Badge::Queries.invite_badge(count,trust_level) + b.query = BadgeQueries.invite_badge(count,trust_level) b.default_badge_grouping_id = BadgeGrouping::Community # daily is good enough b.trigger = Badge::Trigger::None @@ -170,7 +171,7 @@ Badge.seed do |b| b.multiple_grant = false b.target_posts = true b.show_posts = true - b.query = Badge::Queries::FirstShare + b.query = BadgeQueries::FirstShare b.default_badge_grouping_id = BadgeGrouping::GettingStarted # don't trigger for now, its too expensive b.trigger = Badge::Trigger::None @@ -191,7 +192,7 @@ end b.multiple_grant = true b.target_posts = true b.show_posts = true - b.query = Badge::Queries.sharing_badge(count) + b.query = BadgeQueries.sharing_badge(count) b.default_badge_grouping_id = BadgeGrouping::Community # don't trigger for now, its too expensive b.trigger = Badge::Trigger::None @@ -206,7 +207,7 @@ Badge.seed do |b| b.multiple_grant = false b.target_posts = true b.show_posts = true - b.query = Badge::Queries::Welcome + b.query = BadgeQueries::Welcome b.default_badge_grouping_id = BadgeGrouping::Community b.trigger = Badge::Trigger::PostAction b.system = true @@ -217,7 +218,7 @@ Badge.seed do |b| b.default_name = "Autobiographer" b.badge_type_id = BadgeType::Bronze b.multiple_grant = false - b.query = Badge::Queries::Autobiographer + b.query = BadgeQueries::Autobiographer b.default_badge_grouping_id = BadgeGrouping::GettingStarted b.trigger = Badge::Trigger::UserChange b.system = true @@ -228,7 +229,7 @@ Badge.seed do |b| b.default_name = "Editor" b.badge_type_id = BadgeType::Bronze b.multiple_grant = false - b.query = Badge::Queries::Editor + b.query = BadgeQueries::Editor b.badge_grouping_id = BadgeGrouping::GettingStarted b.default_badge_grouping_id = BadgeGrouping::GettingStarted b.trigger = Badge::Trigger::PostRevision @@ -254,7 +255,7 @@ like_badges.each do |spec| b.multiple_grant = true b.target_posts = true b.show_posts = true - b.query = Badge::Queries.like_badge(Badge.like_badge_counts[spec[:id]], spec[:topic]) + b.query = BadgeQueries.like_badge(Badge.like_badge_counts[spec[:id]], spec[:topic]) b.default_badge_grouping_id = BadgeGrouping::Posting b.trigger = Badge::Trigger::PostAction b.system = true @@ -266,7 +267,7 @@ Badge.seed do |b| b.default_name = "Anniversary" b.default_icon = "fa-clock-o" b.badge_type_id = BadgeType::Silver - b.query = Badge::Queries::OneYearAnniversary + b.query = BadgeQueries::OneYearAnniversary b.default_badge_grouping_id = BadgeGrouping::Community b.trigger = Badge::Trigger::None b.auto_revoke = false @@ -286,7 +287,7 @@ end b.multiple_grant = true b.target_posts = true b.show_posts = true - b.query = Badge::Queries.linking_badge(count) + b.query = BadgeQueries.linking_badge(count) b.badge_grouping_id = BadgeGrouping::Posting b.default_badge_grouping_id = BadgeGrouping::Posting # don't trigger for now, its too expensive @@ -307,7 +308,7 @@ end b.default_name = name b.default_icon = "fa-heart" b.badge_type_id = level - b.query = Badge::Queries.liked_posts(post_count, like_count) + b.query = BadgeQueries.liked_posts(post_count, like_count) b.default_badge_grouping_id = BadgeGrouping::Community b.trigger = Badge::Trigger::None b.auto_revoke = false @@ -327,7 +328,7 @@ end b.default_name = name b.default_icon = "fa-heart" b.badge_type_id = level - b.query = Badge::Queries.liked_back(count, ratio) + b.query = BadgeQueries.liked_back(count, ratio) b.badge_grouping_id = BadgeGrouping::Community b.default_badge_grouping_id = BadgeGrouping::Community b.trigger = Badge::Trigger::None @@ -348,7 +349,7 @@ end b.default_name = name b.default_icon = "fa-heart" b.badge_type_id = level - b.query = Badge::Queries.like_rate_limit(count) + b.query = BadgeQueries.like_rate_limit(count) b.badge_grouping_id = BadgeGrouping::Community b.default_badge_grouping_id = BadgeGrouping::Community b.trigger = Badge::Trigger::None @@ -357,6 +358,31 @@ end end end +Badge.seed do |b| + b.id = Badge::FirstMention + b.default_name = "First Mention" + b.badge_type_id = BadgeType::Bronze + b.multiple_grant = false + b.target_posts = true + b.show_posts = true + b.query = BadgeQueries.has_user_first(:mentioned_user) + b.default_badge_grouping_id = BadgeGrouping::GettingStarted + b.trigger = Badge::Trigger::PostProcessed + b.system = true +end + +Badge.seed do |b| + b.id = Badge::FirstEmoji + b.default_name = "First Emoji" + b.badge_type_id = BadgeType::Bronze + b.multiple_grant = false + b.target_posts = true + b.show_posts = true + b.query = BadgeQueries.has_user_first(:used_emoji) + b.default_badge_grouping_id = BadgeGrouping::GettingStarted + b.trigger = Badge::Trigger::PostProcessed + b.system = true +end Badge.where("NOT system AND id < 100").each do |badge| new_id = [Badge.maximum(:id) + 1, 100].max diff --git a/db/migrate/20160405172827_create_user_firsts.rb b/db/migrate/20160405172827_create_user_firsts.rb new file mode 100644 index 000000000..87dc3ed73 --- /dev/null +++ b/db/migrate/20160405172827_create_user_firsts.rb @@ -0,0 +1,12 @@ +class CreateUserFirsts < ActiveRecord::Migration + def change + create_table :user_firsts, force: true do |t| + t.integer :user_id, null: false + t.integer :first_type, null: false + t.integer :post_id + t.datetime :created_at, null: false + end + + add_index :user_firsts, [:user_id, :first_type], unique: true + end +end diff --git a/lib/badge_queries.rb b/lib/badge_queries.rb new file mode 100644 index 000000000..f32bbf7cf --- /dev/null +++ b/lib/badge_queries.rb @@ -0,0 +1,242 @@ +module BadgeQueries + Reader = < 100 + GROUP BY pt.user_id, pt.topic_id, t.posts_count + HAVING count(*) >= t.posts_count + ) +SQL + + ReadGuidelines = < p2.topic_id AND not quote AND + (:backfill OR ( p1.id in (:post_ids) )) + GROUP BY l1.user_id + ) ids + JOIN topic_links l ON l.id = ids.id +SQL + + FirstShare = < 0 AND + (:backfill OR p.id IN (:post_ids) ) + GROUP BY p.user_id +SQL + + Welcome = < #{Badge::AutobiographerMinBioLength} AND + uploaded_avatar_id IS NOT NULL AND + (:backfill OR u.id IN (:user_ids) ) +SQL + + # member for a year + has posted at least once during that year + OneYearAnniversary = <<-SQL + SELECT u.id AS user_id, MIN(u.created_at + interval '1 year') AS granted_at + FROM users u + JOIN posts p ON p.user_id = u.id + WHERE u.id > 0 + AND u.active + AND NOT u.blocked + AND u.created_at + interval '1 year' < now() + AND p.deleted_at IS NULL + AND NOT p.hidden + AND p.created_at + interval '1 year' > now() + AND (:backfill OR u.id IN (:user_ids)) + GROUP BY u.id + HAVING COUNT(p.id) > 0 +SQL + + def self.invite_badge(count,trust_level) +" + SELECT u.id user_id, current_timestamp granted_at + FROM users u + WHERE u.id IN ( + SELECT invited_by_id + FROM invites i + JOIN users u2 ON u2.id = i.user_id + WHERE i.deleted_at IS NULL AND u2.active AND u2.trust_level >= #{trust_level.to_i} AND not u2.blocked + GROUP BY invited_by_id + HAVING COUNT(*) >= #{count.to_i} + ) AND u.active AND NOT u.blocked AND u.id > 0 AND + (:backfill OR u.id IN (:user_ids) ) +" + end + + def self.like_badge(count, is_topic) + # we can do better with dates, but its hard work +" + SELECT p.user_id, p.id post_id, p.updated_at granted_at + FROM badge_posts p + WHERE #{is_topic ? "p.post_number = 1" : "p.post_number > 1" } AND p.like_count >= #{count.to_i} AND + (:backfill OR p.id IN (:post_ids) ) +" + end + + def self.trust_level(level) + # we can do better with dates, but its hard work figuring this out historically +" + SELECT u.id user_id, current_timestamp granted_at FROM users u + WHERE trust_level >= #{level.to_i} AND ( + :backfill OR u.id IN (:user_ids) + ) +" + end + + def self.sharing_badge(count) +< #{count} + ) as views + JOIN incoming_links i2 ON i2.id = views.i_id +SQL + end + + def self.linking_badge(count) + <<-SQL + SELECT tl.user_id, post_id, current_timestamp granted_at + FROM topic_links tl + JOIN posts p ON p.id = post_id AND p.deleted_at IS NULL + JOIN topics t ON t.id = p.topic_id AND t.deleted_at IS NULL AND t.archetype <> 'private_message' + WHERE NOT tl.internal + AND tl.clicks >= #{count} + GROUP BY tl.user_id, tl.post_id + SQL + end + + def self.liked_posts(post_count, like_count) + <<-SQL + SELECT p.user_id, current_timestamp AS granted_at + FROM posts AS p + WHERE p.like_count >= #{like_count} + AND (:backfill OR p.user_id IN (:user_ids)) + GROUP BY p.user_id + HAVING count(*) > #{post_count} + SQL + end + + def self.like_rate_limit(count) + <<-SQL + SELECT gdl.user_id, current_timestamp AS granted_at + FROM given_daily_likes AS gdl + WHERE gdl.limit_reached + AND (:backfill OR gdl.user_id IN (:user_ids)) + GROUP BY gdl.user_id + HAVING COUNT(*) >= #{count} + SQL + end + + def self.liked_back(likes_received, likes_given) + <<-SQL + SELECT us.user_id, current_timestamp AS granted_at + FROM user_stats AS us + WHERE us.likes_received >= #{likes_received} + AND us.likes_given >= #{likes_given} + AND (:backfill OR us.user_id IN (:user_ids)) + SQL + end + + def self.has_user_first(type) + < 0 + created |= UserFirst.create_for(@post.user_id, :used_emoji, @post.id) + end + + if @doc.css("span.mention, a.mention").size > 0 + created |= UserFirst.create_for(@post.user_id, :mentioned_user, @post.id) + end + + if created + BadgeGranter.queue_badge_grant(Badge::Trigger::PostProcessed, user: @post.user) end end diff --git a/spec/models/user_firsts_spec.rb b/spec/models/user_firsts_spec.rb new file mode 100644 index 000000000..69bc5996d --- /dev/null +++ b/spec/models/user_firsts_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +describe UserFirst do + + let(:user) { Fabricate(:user) } + + context "#create_for" do + it "doesn't raise an error on duplicate" do + expect(UserFirst.create_for(user.id, :used_emoji)).to eq(true) + expect(UserFirst.create_for(user.id, :used_emoji)).to eq(false) + end + end + + it "creates one the first time a user posts an emoji" do + post = PostCreator.create(user, title: "this topic is about candy", raw: "time to eat some sweet :candy: mmmm") + + uf = UserFirst.where(user_id: user.id, first_type: UserFirst.types[:used_emoji]).first + expect(uf).to be_present + expect(uf.post_id).to eq(post.id) + end + + context 'mentioning' do + let(:codinghorror) { Fabricate(:codinghorror) } + + it "creates one the first time a user mentions another" do + post = PostCreator.create(user, title: "gonna mention another user", raw: "what is up @codinghorror?") + uf = UserFirst.where(user_id: user.id, first_type: UserFirst.types[:mentioned_user]).first + expect(uf).to be_present + expect(uf.post_id).to eq(post.id) + end + end + +end