Skip to content

Commit

Permalink
Comments count according to display rules in feed (forem#20753)
Browse files Browse the repository at this point in the history
* Move calculating comments score from worker to a service

* Display displayed comments count in feed

* Recalculating displayed comments counts on article show (if needed)

* Rubocop fixes

* Fixed lambda
  • Loading branch information
lightalloy authored Mar 28, 2024
1 parent ec32a8e commit 51316c7
Show file tree
Hide file tree
Showing 11 changed files with 144 additions and 40 deletions.
2 changes: 1 addition & 1 deletion app/controllers/stories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ def assign_article_show_variables
@organization = @article.organization
@comments_order = fetch_sort_order

@comments_count = Comments::Count.new(@article).call
@comments_count = Comments::Count.call(@article)

if @article.collection
@collection = @article.collection
Expand Down
33 changes: 22 additions & 11 deletions app/queries/comments/count.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,33 @@

module Comments
class Count
attr_reader :article
def self.call(...)
new(...).call
end

def initialize(article)
def initialize(article, recalculate: false)
@article = article
@recalculate = recalculate
end

def call
# comments that are not displayed at all (not even a "comment deleted" message):
# with the score below hiding threshold and w/o children
count_sql = "SELECT COUNT(id) FROM comments c1 WHERE score < ? AND commentable_id = ? " \
"AND commentable_type = ? AND NOT EXISTS " \
"(SELECT 1 FROM comments c2 WHERE c2.ancestry LIKE CONCAT('%/', c1.id::varchar(255)) " \
"OR c2.ancestry = c1.id::varchar(255))"
san_count_sql = Comment.sanitize_sql([count_sql, Comment::HIDE_THRESHOLD, @article.id, "Article"])
hidden_comments_cnt = Comment.count_by_sql(san_count_sql)
article.comments_count - hidden_comments_cnt
if recalculate || !article.displayed_comments_count?
# comments that are not displayed at all (not even a "comment deleted" message):
# with the score below hiding threshold and w/o children
count_sql = "SELECT COUNT(id) FROM comments c1 WHERE score < ? AND commentable_id = ? " \
"AND commentable_type = ? AND NOT EXISTS " \
"(SELECT 1 FROM comments c2 WHERE c2.ancestry LIKE CONCAT('%/', c1.id::varchar(255)) " \
"OR c2.ancestry = c1.id::varchar(255))"
san_count_sql = Comment.sanitize_sql([count_sql, Comment::HIDE_THRESHOLD, @article.id, "Article"])
hidden_comments_cnt = Comment.count_by_sql(san_count_sql)
displayed_comments_count = article.comments.count - hidden_comments_cnt
article.update_column(:displayed_comments_count, displayed_comments_count)
end
article.displayed_comments_count
end

private

attr_reader :article, :recalculate
end
end
1 change: 1 addition & 0 deletions app/queries/homepage/articles_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class ArticlesQuery
cached_tag_list
comments_count
crossposted_at
displayed_comments_count
id
organization_id
path
Expand Down
5 changes: 4 additions & 1 deletion app/serializers/homepage/article_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ def self.serialized_collection_from(relation:)
attributes(
:class_name,
:cloudinary_video_url,
:comments_count,
:id,
:path,
:public_reactions_count,
Expand All @@ -31,6 +30,10 @@ def self.serialized_collection_from(relation:)
:public_reaction_categories,
)

# return displayed_comments_count (excluding low score comments) if it was calculated earlier
attribute :comments_count, (lambda do |article|
article.displayed_comments_count? ? article.displayed_comments_count : article.comments_count
end)
attribute :video_duration_string, &:video_duration_in_minutes
attribute :published_at_int, ->(article) { article.published_at.to_i }
attribute :tag_list, ->(article) { article.cached_tag_list.to_s.split(", ") }
Expand Down
32 changes: 32 additions & 0 deletions app/services/comments/calculate_score.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module Comments
class CalculateScore
def self.call(...)
new(...).call
end

def initialize(comment)
@comment = comment
end

def call
score = BlackBox.comment_quality_score(comment)
score -= 500 if comment.user&.spam?
comment.update_columns(score: score, updated_at: Time.current)

comment.user&.touch(:last_comment_at)

# update commentable
commentable = comment.commentable

commentable.touch(:last_comment_at) if commentable.respond_to?(:last_comment_at)
Comments::Count.call(commentable, recalculate: true) if commentable.is_a?(Article)

# busting comment cache includes busting commentable cache
Comments::BustCacheWorker.new.perform(comment.id)
end

private

attr_reader :comment
end
end
10 changes: 1 addition & 9 deletions app/workers/comments/calculate_score_worker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,7 @@ def perform(comment_id)
comment = Comment.find_by(id: comment_id)
return unless comment

score = BlackBox.comment_quality_score(comment)
score -= 500 if comment.user&.spam?
comment.update_columns(score: score, updated_at: Time.current)

comment.commentable.touch(:last_comment_at) if comment.commentable.respond_to?(:last_comment_at)
comment.user.touch(:last_comment_at) if comment.user

# busting comment cache includes busting commentable cache
Comments::BustCacheWorker.new.perform(comment.id)
Comments::CalculateScore.call(comment)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddDisplayedCommentsCountToArticles < ActiveRecord::Migration[7.0]
def change
add_column :articles, :displayed_comments_count, :integer
end
end
1 change: 1 addition & 0 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
t.datetime "created_at", precision: nil, null: false
t.datetime "crossposted_at", precision: nil
t.string "description"
t.integer "displayed_comments_count"
t.datetime "edited_at", precision: nil
t.boolean "email_digest_eligible", default: true
t.float "experience_level_rating", default: 5.0
Expand Down
33 changes: 33 additions & 0 deletions spec/queries/comments/count_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,37 @@
count = described_class.new(article).call
expect(count).to eq(4)
end

context "with recalculate option" do
# displayed comments count = 4
# comments count = 5
before do
comment.update_column(:score, -500)
create(:comment, commentable: article, parent: comment, score: 10)
create(:comment, commentable: article, parent: comment, score: -490)
create(:comment, commentable: article, parent: comment2, score: 0)
article.reload
end

it "returns displayed_comments_count if it exists + no recalculate" do
article.update_column(:displayed_comments_count, 3)
cnt = described_class.call(article, recalculate: false)
expect(cnt).to eq(3)
end

it "recalculates if recalculate is passed", :aggregate_failures do
article.update_column(:displayed_comments_count, 3)
cnt = described_class.call(article, recalculate: true)
expect(cnt).to eq(4)
article.reload
expect(article.displayed_comments_count).to eq(4)
end

it "recalculates if no recalculate and no displayed_comments_count", :aggregate_failures do
cnt = described_class.call(article, recalculate: false)
expect(cnt).to eq(4)
article.reload
expect(article.displayed_comments_count).to eq(4)
end
end
end
40 changes: 40 additions & 0 deletions spec/services/comments/calculate_score_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
require "rails_helper"

RSpec.describe Comments::CalculateScore, type: :service do
let(:article) { create(:article) }
let(:comment) { create(:comment, commentable: article) }
let(:user) { instance_double(User, spam?: false) }

before do
allow(BlackBox).to receive(:comment_quality_score).and_return(7)
end

it "updates the score" do
described_class.call(comment)
comment.reload
expect(comment.score).to be(7)
end

context "when adding spam role" do
before do
comment.user.add_role(:spam)
comment.update_column(:updated_at, 1.day.ago)
end

it "updates the score and updated_at with a penalty if the user is a spammer", :aggregate_failures do
described_class.call(comment)
comment.reload
expect(comment.score).to be(-493)
expect(comment.updated_at).to be_within(1.minute).of(Time.current)
end

it "updates article displayed comments count" do
other_comment = create(:comment, commentable: article)
create(:comment, commentable: article, parent: other_comment)
described_class.call(comment)
article.reload
expect(article.comments_count).to eq(3)
expect(article.displayed_comments_count).to eq(2)
end
end
end
22 changes: 4 additions & 18 deletions spec/workers/comments/calculate_score_worker_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,15 @@
let(:worker) { subject }

context "with comment" do
let(:article) { create(:article) }
let(:comment) { create(:comment, commentable: article) }
let(:root_comment) { instance_double(Comment) }
let(:user) { instance_double(User, spam?: false) }
let(:comment) { create(:comment) }

before do
allow(BlackBox).to receive(:comment_quality_score).and_return(7)
allow(Comments::CalculateScore).to receive(:call)
end

it "updates the score" do
it "calls CalculateScore" do
worker.perform(comment.id)

comment.reload
expect(comment.score).to be(7)
end

it "updates the score and updated_at with a penalty if the user is a spammer", :aggregate_failures do
comment.user.add_role(:spam)
comment.update_column(:updated_at, 1.day.ago)
worker.perform(comment.id)
comment.reload
expect(comment.score).to be(-493)
expect(comment.updated_at).to be_within(1.minute).of(Time.current)
expect(Comments::CalculateScore).to have_received(:call).with(comment)
end
end

Expand Down

0 comments on commit 51316c7

Please sign in to comment.