Skip to content

Commit f6211db

Browse files
authored
Merge pull request #2423 from basecamp/forbid-comments-on-draft-cards
Forbid comments on draft cards
2 parents 4f2308d + 9aa15e9 commit f6211db

File tree

15 files changed

+135
-90
lines changed

15 files changed

+135
-90
lines changed

app/controllers/cards/comments_controller.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ class Cards::CommentsController < ApplicationController
33

44
before_action :set_comment, only: %i[ show edit update destroy ]
55
before_action :ensure_creatorship, only: %i[ edit update destroy ]
6+
before_action :ensure_card_is_commentable, only: :create
67

78
def index
89
set_page_and_extract_portion_from @card.comments.chronologically
@@ -50,6 +51,10 @@ def ensure_creatorship
5051
head :forbidden if Current.user != @comment.creator
5152
end
5253

54+
def ensure_card_is_commentable
55+
head :forbidden unless @card.commentable?
56+
end
57+
5358
def comment_params
5459
params.expect(comment: [ :body, :created_at ])
5560
end

app/models/card.rb

Lines changed: 2 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
class Card < ApplicationRecord
2-
include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
3-
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
2+
include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Commentable,
3+
Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
44
Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
55

66
belongs_to :account, default: -> { board.account }
77
belongs_to :board
88
belongs_to :creator, class_name: "User", default: -> { Current.user }
99

10-
has_many :comments, dependent: :destroy
1110
has_one_attached :image, dependent: :purge_later
1211

1312
has_rich_text :description
@@ -66,55 +65,6 @@ def filled?
6665
end
6766

6867
private
69-
STORAGE_BATCH_SIZE = 1000
70-
71-
# Override to include comments, but only load comments that have attachments.
72-
# Cards can have thousands of comments; most won't have attachments.
73-
# Streams in batches to avoid loading all IDs into memory at once.
74-
def storage_transfer_records
75-
comment_ids_with_attachments = storage_comment_ids_with_attachments
76-
77-
if comment_ids_with_attachments.any?
78-
[ self, *comments.where(id: comment_ids_with_attachments).to_a ]
79-
else
80-
[ self ]
81-
end
82-
end
83-
84-
def storage_comment_ids_with_attachments
85-
direct = []
86-
rich_text_map = {}
87-
88-
# Stream comment IDs in batches to avoid loading all into memory
89-
comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
90-
batch_ids = batch.pluck(:id)
91-
92-
direct.concat \
93-
ActiveStorage::Attachment
94-
.where(record_type: "Comment", record_id: batch_ids)
95-
.distinct
96-
.pluck(:record_id)
97-
98-
ActionText::RichText
99-
.where(record_type: "Comment", record_id: batch_ids)
100-
.pluck(:id, :record_id)
101-
.each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
102-
end
103-
104-
embed_comment_ids = if rich_text_map.any?
105-
rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
106-
ActiveStorage::Attachment
107-
.where(record_type: "ActionText::RichText", record_id: batch_ids)
108-
.distinct
109-
.pluck(:record_id)
110-
end.filter_map { |rt_id| rich_text_map[rt_id] }
111-
else
112-
[]
113-
end
114-
115-
(direct + embed_comment_ids).uniq
116-
end
117-
11868
def set_default_title
11969
self.title = "Untitled" if title.blank?
12070
end

app/models/card/commentable.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
module Card::Commentable
2+
extend ActiveSupport::Concern
3+
4+
included do
5+
has_many :comments, dependent: :destroy
6+
end
7+
8+
def commentable?
9+
published?
10+
end
11+
12+
private
13+
STORAGE_BATCH_SIZE = 1000
14+
15+
# Override to include comments, but only load comments that have attachments.
16+
# Cards can have thousands of comments; most won't have attachments.
17+
# Streams in batches to avoid loading all IDs into memory at once.
18+
def storage_transfer_records
19+
comment_ids_with_attachments = storage_comment_ids_with_attachments
20+
21+
if comment_ids_with_attachments.any?
22+
[ self, *comments.where(id: comment_ids_with_attachments).to_a ]
23+
else
24+
[ self ]
25+
end
26+
end
27+
28+
def storage_comment_ids_with_attachments
29+
direct = []
30+
rich_text_map = {}
31+
32+
# Stream comment IDs in batches to avoid loading all into memory
33+
comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
34+
batch_ids = batch.pluck(:id)
35+
36+
direct.concat \
37+
ActiveStorage::Attachment
38+
.where(record_type: "Comment", record_id: batch_ids)
39+
.distinct
40+
.pluck(:record_id)
41+
42+
ActionText::RichText
43+
.where(record_type: "Comment", record_id: batch_ids)
44+
.pluck(:id, :record_id)
45+
.each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
46+
end
47+
48+
embed_comment_ids = if rich_text_map.any?
49+
rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
50+
ActiveStorage::Attachment
51+
.where(record_type: "ActionText::RichText", record_id: batch_ids)
52+
.distinct
53+
.pluck(:record_id)
54+
end.filter_map { |rt_id| rich_text_map[rt_id] }
55+
else
56+
[]
57+
end
58+
59+
(direct + embed_comment_ids).uniq
60+
end
61+
end

app/models/comment.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ class Comment < ApplicationRecord
88

99
has_rich_text :body
1010

11+
validate :card_is_commentable
12+
1113
scope :chronologically, -> { order created_at: :asc, id: :desc }
1214
scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) }
1315
scope :by_system, -> { joins(:creator).where(creator: { role: :system }) }
@@ -22,6 +24,10 @@ def to_partial_path
2224
end
2325

2426
private
27+
def card_is_commentable
28+
errors.add(:card, "does not allow comments") unless card.commentable?
29+
end
30+
2531
def watch_card_by_creator
2632
card.watch_by creator
2733
end

test/controllers/cards/comments_controller_test.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
1313
assert_response :success
1414
end
1515

16+
test "create on draft card is forbidden" do
17+
draft_card = boards(:writebook).cards.create!(status: :drafted, creator: users(:kevin))
18+
19+
assert_no_difference -> { draft_card.comments.count } do
20+
post card_comments_path(draft_card), params: { comment: { body: "This should be forbidden" } }, as: :json
21+
end
22+
23+
assert_response :forbidden
24+
end
25+
1626
test "update" do
1727
put card_comment_path(cards(:logo), comments(:logo_agreement_kevin)), params: { comment: { body: "I've changed my mind" } }, as: :turbo_stream
1828

test/controllers/searches_controller_test.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
55

66
setup do
77
@board.update!(all_access: true)
8-
@card = @board.cards.create!(title: "Layout is broken", description: "Look at this mess.", creator: @user)
9-
@comment_card = @board.cards.create!(title: "Some card", creator: @user)
8+
@card = @board.cards.create!(title: "Layout is broken", description: "Look at this mess.", status: "published", creator: @user)
9+
@comment_card = @board.cards.create!(title: "Some card", status: "published", creator: @user)
1010
@comment_card.comments.create!(body: "overflowing text issue", creator: @user)
11-
@comment2_card = @board.cards.create!(title: "Just haggis", description: "More haggis", creator: @user)
11+
@comment2_card = @board.cards.create!(title: "Just haggis", description: "More haggis", status: "published", creator: @user)
1212
@comment2_card.comments.create!(body: "I love haggis", creator: @user)
1313

1414
untenanted { sign_in_as @user }
@@ -43,7 +43,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
4343
end
4444

4545
test "search highlights matched terms with proper HTML marks" do
46-
@board.cards.create!(title: "Testing search highlighting", creator: @user)
46+
@board.cards.create!(title: "Testing search highlighting", status: "published", creator: @user)
4747

4848
get search_path(q: "highlighting", script_name: "/#{@account.external_account_id}")
4949
assert_response :success
@@ -52,6 +52,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
5252
test "search preserves highlight marks but escapes surrounding HTML" do
5353
@board.cards.create!(
5454
title: "<b>Bold</b> testing content",
55+
status: "published",
5556
creator: @user
5657
)
5758

test/models/card/commentable_test.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
require "test_helper"
22

33
class Card::CommentableTest < ActiveSupport::TestCase
4+
setup do
5+
Current.session = sessions(:david)
6+
end
7+
8+
test "capturing comments" do
9+
assert_difference -> { cards(:logo).comments.count }, +1 do
10+
cards(:logo).comments.create!(body: "Agreed.")
11+
end
12+
13+
assert_equal "Agreed.", cards(:logo).comments.last.body.to_plain_text.chomp
14+
end
15+
416
test "creating a comment on a card makes the creator watch the card" do
517
boards(:writebook).access_for(users(:kevin)).access_only!
618
assert_not cards(:text).watched_by?(users(:kevin))
@@ -11,4 +23,9 @@ class Card::CommentableTest < ActiveSupport::TestCase
1123

1224
assert cards(:text).watched_by?(users(:kevin))
1325
end
26+
27+
test "commentable is true for published cards, false for drafts" do
28+
assert cards(:logo).commentable?
29+
assert_not cards(:unfinished_thoughts).commentable?
30+
end
1431
end

test/models/card/searchable_test.rb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,18 @@ class Card::SearchableTest < ActiveSupport::TestCase
55

66
test "card search" do
77
# Searching by title
8-
card = @board.cards.create!(title: "layout is broken", creator: @user)
8+
card = @board.cards.create!(title: "layout is broken", status: "published", creator: @user)
99
results = Card.mentioning("layout", user: @user)
1010
assert_includes results, card
1111

1212
# Searching by comment
13-
card_with_comment = @board.cards.create!(title: "Some card", creator: @user)
13+
card_with_comment = @board.cards.create!(title: "Some card", status: "published", creator: @user)
1414
card_with_comment.comments.create!(body: "overflowing text", creator: @user)
1515
results = Card.mentioning("overflowing", user: @user)
1616
assert_includes results, card_with_comment
1717

1818
# Sanitizing search query
19-
card_broken = @board.cards.create!(title: "broken layout", creator: @user)
19+
card_broken = @board.cards.create!(title: "broken layout", status: "published", creator: @user)
2020
results = Card.mentioning("broken \"", user: @user)
2121
assert_includes results, card_broken
2222

@@ -25,8 +25,8 @@ class Card::SearchableTest < ActiveSupport::TestCase
2525

2626
# Filtering by board_ids
2727
other_board = Board.create!(name: "Other Board", account: @account, creator: @user)
28-
card_in_board = @board.cards.create!(title: "searchable content", creator: @user)
29-
card_in_other_board = other_board.cards.create!(title: "searchable content", creator: @user)
28+
card_in_board = @board.cards.create!(title: "searchable content", status: "published", creator: @user)
29+
card_in_other_board = other_board.cards.create!(title: "searchable content", status: "published", creator: @user)
3030
results = Card.mentioning("searchable", user: @user)
3131
assert_includes results, card_in_board
3232
assert_not_includes results, card_in_other_board
@@ -37,7 +37,7 @@ class Card::SearchableTest < ActiveSupport::TestCase
3737

3838
# Create a card with unreasonably long content
3939
long_content = "asdf " * Searchable::SEARCH_CONTENT_LIMIT
40-
card = @board.cards.create!(title: "Card with long description", creator: @user)
40+
card = @board.cards.create!(title: "Card with long description", status: "published", creator: @user)
4141
card.description = ActionText::Content.new(long_content)
4242
card.save!
4343

@@ -52,7 +52,7 @@ class Card::SearchableTest < ActiveSupport::TestCase
5252

5353
test "deleting card removes search record and FTS entry" do
5454
search_record_class = Search::Record.for(@user.account_id)
55-
card = @board.cards.create!(title: "Card to delete", creator: @user)
55+
card = @board.cards.create!(title: "Card to delete", status: "published", creator: @user)
5656

5757
# Verify search record exists
5858
search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)

test/models/card_test.rb

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,6 @@ class CardTest < ActiveSupport::TestCase
1818
assert_equal account.reload.cards_count, card.number
1919
end
2020

21-
test "capturing messages" do
22-
assert_difference -> { cards(:logo).comments.count }, +1 do
23-
cards(:logo).comments.create!(body: "Agreed.")
24-
end
25-
26-
assert_equal "Agreed.", cards(:logo).comments.last.body.to_plain_text.chomp
27-
end
28-
2921
test "assignment states" do
3022
assert cards(:logo).assigned_to?(users(:kevin))
3123
assert_not cards(:logo).assigned_to?(users(:david))

test/models/comment/searchable_test.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ class Comment::SearchableTest < ActiveSupport::TestCase
44
include SearchTestHelper
55

66
setup do
7-
@card = @board.cards.create!(title: "Test Card", creator: @user)
7+
@card = @board.cards.create!(title: "Test Card", status: "published", creator: @user)
88
end
99

1010
test "comment search" do
@@ -40,9 +40,9 @@ class Comment::SearchableTest < ActiveSupport::TestCase
4040
end
4141

4242
# Finding cards via comment search
43-
card_with_comment = @board.cards.create!(title: "Card One", creator: @user)
43+
card_with_comment = @board.cards.create!(title: "Card One", status: "published", creator: @user)
4444
card_with_comment.comments.create!(body: "unique searchable phrase", creator: @user)
45-
card_without_comment = @board.cards.create!(title: "Card Two", creator: @user)
45+
card_without_comment = @board.cards.create!(title: "Card Two", status: "published", creator: @user)
4646
results = Card.mentioning("searchable", user: @user)
4747
assert_includes results, card_with_comment
4848
assert_not_includes results, card_without_comment

0 commit comments

Comments
 (0)