Comments count according to display rules in feed (#20753)
* 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
This commit is contained in:
parent
ec32a8e7cc
commit
51316c747f
11 changed files with 144 additions and 40 deletions
|
|
@ -286,7 +286,7 @@ class StoriesController < ApplicationController
|
||||||
@organization = @article.organization
|
@organization = @article.organization
|
||||||
@comments_order = fetch_sort_order
|
@comments_order = fetch_sort_order
|
||||||
|
|
||||||
@comments_count = Comments::Count.new(@article).call
|
@comments_count = Comments::Count.call(@article)
|
||||||
|
|
||||||
if @article.collection
|
if @article.collection
|
||||||
@collection = @article.collection
|
@collection = @article.collection
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,33 @@
|
||||||
|
|
||||||
module Comments
|
module Comments
|
||||||
class Count
|
class Count
|
||||||
attr_reader :article
|
def self.call(...)
|
||||||
|
new(...).call
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(article)
|
def initialize(article, recalculate: false)
|
||||||
@article = article
|
@article = article
|
||||||
|
@recalculate = recalculate
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
# comments that are not displayed at all (not even a "comment deleted" message):
|
if recalculate || !article.displayed_comments_count?
|
||||||
# with the score below hiding threshold and w/o children
|
# comments that are not displayed at all (not even a "comment deleted" message):
|
||||||
count_sql = "SELECT COUNT(id) FROM comments c1 WHERE score < ? AND commentable_id = ? " \
|
# with the score below hiding threshold and w/o children
|
||||||
"AND commentable_type = ? AND NOT EXISTS " \
|
count_sql = "SELECT COUNT(id) FROM comments c1 WHERE score < ? AND commentable_id = ? " \
|
||||||
"(SELECT 1 FROM comments c2 WHERE c2.ancestry LIKE CONCAT('%/', c1.id::varchar(255)) " \
|
"AND commentable_type = ? AND NOT EXISTS " \
|
||||||
"OR c2.ancestry = c1.id::varchar(255))"
|
"(SELECT 1 FROM comments c2 WHERE c2.ancestry LIKE CONCAT('%/', c1.id::varchar(255)) " \
|
||||||
san_count_sql = Comment.sanitize_sql([count_sql, Comment::HIDE_THRESHOLD, @article.id, "Article"])
|
"OR c2.ancestry = c1.id::varchar(255))"
|
||||||
hidden_comments_cnt = Comment.count_by_sql(san_count_sql)
|
san_count_sql = Comment.sanitize_sql([count_sql, Comment::HIDE_THRESHOLD, @article.id, "Article"])
|
||||||
article.comments_count - hidden_comments_cnt
|
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
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :article, :recalculate
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ module Homepage
|
||||||
cached_tag_list
|
cached_tag_list
|
||||||
comments_count
|
comments_count
|
||||||
crossposted_at
|
crossposted_at
|
||||||
|
displayed_comments_count
|
||||||
id
|
id
|
||||||
organization_id
|
organization_id
|
||||||
path
|
path
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ module Homepage
|
||||||
attributes(
|
attributes(
|
||||||
:class_name,
|
:class_name,
|
||||||
:cloudinary_video_url,
|
:cloudinary_video_url,
|
||||||
:comments_count,
|
|
||||||
:id,
|
:id,
|
||||||
:path,
|
:path,
|
||||||
:public_reactions_count,
|
:public_reactions_count,
|
||||||
|
|
@ -31,6 +30,10 @@ module Homepage
|
||||||
:public_reaction_categories,
|
: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 :video_duration_string, &:video_duration_in_minutes
|
||||||
attribute :published_at_int, ->(article) { article.published_at.to_i }
|
attribute :published_at_int, ->(article) { article.published_at.to_i }
|
||||||
attribute :tag_list, ->(article) { article.cached_tag_list.to_s.split(", ") }
|
attribute :tag_list, ->(article) { article.cached_tag_list.to_s.split(", ") }
|
||||||
|
|
|
||||||
32
app/services/comments/calculate_score.rb
Normal file
32
app/services/comments/calculate_score.rb
Normal file
|
|
@ -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
|
||||||
|
|
@ -8,15 +8,7 @@ module Comments
|
||||||
comment = Comment.find_by(id: comment_id)
|
comment = Comment.find_by(id: comment_id)
|
||||||
return unless comment
|
return unless comment
|
||||||
|
|
||||||
score = BlackBox.comment_quality_score(comment)
|
Comments::CalculateScore.call(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)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddDisplayedCommentsCountToArticles < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :articles, :displayed_comments_count, :integer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -103,6 +103,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_06_173309) do
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
t.datetime "crossposted_at", precision: nil
|
t.datetime "crossposted_at", precision: nil
|
||||||
t.string "description"
|
t.string "description"
|
||||||
|
t.integer "displayed_comments_count"
|
||||||
t.datetime "edited_at", precision: nil
|
t.datetime "edited_at", precision: nil
|
||||||
t.boolean "email_digest_eligible", default: true
|
t.boolean "email_digest_eligible", default: true
|
||||||
t.float "experience_level_rating", default: 5.0
|
t.float "experience_level_rating", default: 5.0
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,37 @@ RSpec.describe Comments::Count do
|
||||||
count = described_class.new(article).call
|
count = described_class.new(article).call
|
||||||
expect(count).to eq(4)
|
expect(count).to eq(4)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
40
spec/services/comments/calculate_score_spec.rb
Normal file
40
spec/services/comments/calculate_score_spec.rb
Normal file
|
|
@ -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
|
||||||
|
|
@ -7,29 +7,15 @@ RSpec.describe Comments::CalculateScoreWorker, type: :worker do
|
||||||
let(:worker) { subject }
|
let(:worker) { subject }
|
||||||
|
|
||||||
context "with comment" do
|
context "with comment" do
|
||||||
let(:article) { create(:article) }
|
let(:comment) { create(:comment) }
|
||||||
let(:comment) { create(:comment, commentable: article) }
|
|
||||||
let(:root_comment) { instance_double(Comment) }
|
|
||||||
let(:user) { instance_double(User, spam?: false) }
|
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(BlackBox).to receive(:comment_quality_score).and_return(7)
|
allow(Comments::CalculateScore).to receive(:call)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "updates the score" do
|
it "calls CalculateScore" do
|
||||||
worker.perform(comment.id)
|
worker.perform(comment.id)
|
||||||
|
expect(Comments::CalculateScore).to have_received(:call).with(comment)
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue