From 406286d07a34e6dd08ebb696c59aa1d4df3deea0 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:07:41 +0000 Subject: [PATCH 1/4] Implement blocking feature This commit introduces a blocking feature that allows members to block other members. A blocked member is prevented from: - following the blocker - sending private messages to the blocker - replying to the blocker's posts - liking the blocker's content The implementation includes: - A new `Block` model and a corresponding database table. - Updates to the `Member` model to include associations for blocks. - A new `BlocksController` to handle blocking and unblocking actions. - New routes for the `BlocksController`. - UI changes to add block/unblock buttons to the member profile page. - Validations in the `Follow`, `Comment`, and `Like` models to enforce the blocking rules. - A check in the `MessagesController` to prevent sending messages to a member who has blocked the sender. - A callback in the `Block` model to destroy the follow relationship when a block is created. - New feature and model specs to test the blocking functionality. --- app/controllers/blocks_controller.rb | 32 ++++++++++ app/controllers/messages_controller.rb | 11 ++++ app/models/block.rb | 18 ++++++ app/models/comment.rb | 9 +++ app/models/follow.rb | 9 +++ app/models/like.rb | 18 ++++++ app/models/member.rb | 17 +++++ app/views/members/_follow_buttons.haml | 7 ++- config/routes.rb | 2 + db/migrate/20250901144900_create_blocks.rb | 13 ++++ spec/features/members/blocking_spec.rb | 72 ++++++++++++++++++++++ spec/features/members/following_spec.rb | 11 ++++ spec/models/comment_spec.rb | 15 +++++ spec/models/like_spec.rb | 15 +++++ 14 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 app/controllers/blocks_controller.rb create mode 100644 app/models/block.rb create mode 100644 db/migrate/20250901144900_create_blocks.rb create mode 100644 spec/features/members/blocking_spec.rb diff --git a/app/controllers/blocks_controller.rb b/app/controllers/blocks_controller.rb new file mode 100644 index 0000000000..06521ac182 --- /dev/null +++ b/app/controllers/blocks_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class BlocksController < ApplicationController + load_and_authorize_resource + skip_load_resource only: :create + + def create + @block = current_member.blocks.build(blocked: Member.find(params[:blocked])) + + if @block.save + flash[:notice] = "Blocked #{@block.blocked.login_name}" + else + flash[:error] = "Already blocking or error while blocking." + end + redirect_back fallback_location: root_path + end + + def destroy + @block = current_member.blocks.find(params[:id]) + @unblocked = @block.blocked + @block.destroy + + flash[:notice] = "Unblocked #{@unblocked.login_name}" + redirect_to @unblocked + end + + private + + def block_params + params.permit(:id, :blocked) + end +end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index bd34e3ce16..974c09d29d 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -27,10 +27,21 @@ def new def create if params[:conversation_id].present? @conversation = Mailboxer::Conversation.find(params[:conversation_id]) + # Check if any of the recipients have blocked the sender + if @conversation.recipients.any? { |recipient| recipient.already_blocking?(current_member) } + flash[:error] = "You cannot reply to this conversation because one of the recipients has blocked you." + redirect_to conversation_path(@conversation) + return + end current_member.reply_to_conversation(@conversation, params[:body]) redirect_to conversation_path(@conversation) else recipient = Member.find(params[:recipient_id]) + if recipient.already_blocking?(current_member) + flash[:error] = "You cannot send a message to a member who has blocked you." + redirect_back fallback_location: root_path + return + end body = params[:body] subject = params[:subject] @conversation = current_member.send_message(recipient, body, subject) diff --git a/app/models/block.rb b/app/models/block.rb new file mode 100644 index 0000000000..a149f75b19 --- /dev/null +++ b/app/models/block.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Block < ApplicationRecord + belongs_to :blocker, class_name: "Member" + belongs_to :blocked, class_name: "Member" + + validates :blocker_id, uniqueness: { scope: :blocked_id } + + after_create :destroy_follow_relationship + + private + + def destroy_follow_relationship + # Destroy the follow relationship in both directions + Follow.where(follower: blocker, followed: blocked).destroy_all + Follow.where(follower: blocked, followed: blocker).destroy_all + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index a351b2fc1e..e1f994c3ae 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -4,6 +4,7 @@ class Comment < ApplicationRecord belongs_to :author, class_name: 'Member', inverse_of: :comments belongs_to :commentable, polymorphic: true, counter_cache: true # validates :body, presence: true + validate :author_is_not_blocked scope :post_order, -> { order(created_at: :asc) } # for display on post page @@ -25,4 +26,12 @@ class Comment < ApplicationRecord def to_s "#{author.login_name} commented on #{commentable.subject}" end + + private + + def author_is_not_blocked + if commentable.author.already_blocking?(author) + errors.add(:base, "You cannot comment on a post of a member who has blocked you.") + end + end end diff --git a/app/models/follow.rb b/app/models/follow.rb index 0b9c9236d4..b5d310587b 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -4,6 +4,7 @@ class Follow < ApplicationRecord belongs_to :follower, class_name: "Member", inverse_of: :follows belongs_to :followed, class_name: "Member", inverse_of: :inverse_follows validates :follower_id, uniqueness: { scope: :followed_id } + validate :follower_is_not_blocked after_create do Notification.create( @@ -14,4 +15,12 @@ class Follow < ApplicationRecord notifiable: self ) end + + private + + def follower_is_not_blocked + if followed.already_blocking?(follower) + errors.add(:base, "You cannot follow a member who has blocked you.") + end + end end diff --git a/app/models/like.rb b/app/models/like.rb index ed16065de8..f457fd701f 100644 --- a/app/models/like.rb +++ b/app/models/like.rb @@ -5,4 +5,22 @@ class Like < ApplicationRecord belongs_to :likeable, polymorphic: true, counter_cache: true, touch: true validates :member, :likeable, presence: true validates :member, uniqueness: { scope: :likeable } + validate :member_is_not_blocked + + def likeable_author + if likeable.respond_to?(:author) + likeable.author + elsif likeable.respond_to?(:owner) + likeable.owner + end + end + + private + + def member_is_not_blocked + author = likeable_author + if author && author.already_blocking?(member) + errors.add(:base, "You cannot like content of a member who has blocked you.") + end + end end diff --git a/app/models/member.rb b/app/models/member.rb index 2427092f69..3c9bda05de 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -36,6 +36,15 @@ class Member < ApplicationRecord has_many :followed, through: :follows has_many :followers, through: :inverse_follows, source: :follower + # + # Blocking other members + has_many :blocks, class_name: "Block", foreign_key: "blocker_id", dependent: :destroy, + inverse_of: :blocker + has_many :inverse_blocks, class_name: "Block", foreign_key: "blocked_id", + dependent: :destroy, inverse_of: :blocked + has_many :blocked_members, through: :blocks, source: :blocked + has_many :blockers, through: :inverse_blocks, source: :blocker + # # Global data records this member created has_many :requested_crops, class_name: 'Crop', foreign_key: 'requester_id', dependent: :nullify, @@ -162,4 +171,12 @@ def already_following?(member) def get_follow(member) follows.find_by(followed_id: member.id) if already_following?(member) end + + def already_blocking?(member) + blocks.exists?(blocked_id: member.id) + end + + def get_block(member) + blocks.find_by(blocked_id: member.id) if already_blocking?(member) + end end diff --git a/app/views/members/_follow_buttons.haml b/app/views/members/_follow_buttons.haml index 244dc15f41..19ed2d4011 100644 --- a/app/views/members/_follow_buttons.haml +++ b/app/views/members/_follow_buttons.haml @@ -3,4 +3,9 @@ - if !follow && can?(:create, Follow) # not already following = link_to 'Follow', follows_path(followed: member), method: :post, class: 'btn btn-block btn-success' - if follow && can?(:destroy, follow) # already following - = link_to 'Unfollow', follow_path(follow), method: :delete, class: 'btn btn-block' \ No newline at end of file + = link_to 'Unfollow', follow_path(follow), method: :delete, class: 'btn btn-block' + - block = current_member.get_block(member) + - if !block && can?(:create, Block) # not already blocking + = link_to 'Block', blocks_path(blocked: member), method: :post, class: 'btn btn-block btn-danger' + - if block && can?(:destroy, block) # already blocking + = link_to 'Unblock', block_path(block), method: :delete, class: 'btn btn-block' \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 084cf98e0b..5cf7dd0702 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -95,6 +95,7 @@ resources :forums resources :follows, only: %i(create destroy) + resources :blocks, only: %i(create destroy) post 'likes' => 'likes#create' delete 'likes' => 'likes#destroy' @@ -111,6 +112,7 @@ resources :follows get 'followers' => 'follows#followers' + resources :blocks, only: %i(create destroy) end resources :messages diff --git a/db/migrate/20250901144900_create_blocks.rb b/db/migrate/20250901144900_create_blocks.rb new file mode 100644 index 0000000000..3c386d5aa5 --- /dev/null +++ b/db/migrate/20250901144900_create_blocks.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateBlocks < ActiveRecord::Migration[6.1] + def change + create_table :blocks do |t| + t.references :blocker, foreign_key: { to_table: :members } + t.references :blocked, foreign_key: { to_table: :members } + + t.timestamps + end + add_index :blocks, [:blocker_id, :blocked_id], unique: true + end +end diff --git a/spec/features/members/blocking_spec.rb b/spec/features/members/blocking_spec.rb new file mode 100644 index 0000000000..81db5f3721 --- /dev/null +++ b/spec/features/members/blocking_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe "blocks", :js do + context "when signed in" do + include_context 'signed in member' + let(:other_member) { create(:member) } + + it "your profile doesn't have a block button" do + visit member_path(member) + expect(page).to have_no_link "Block" + expect(page).to have_no_link "Unblock" + end + + context "blocking another member" do + before { visit member_path(other_member) } + + it "has a block button" do + expect(page).to have_link "Block", href: blocks_path(blocked: other_member.slug) + end + + it "has correct message and unblock button" do + click_link 'Block' + expect(page).to have_content "Blocked #{other_member.login_name}" + expect(page).to have_link "Unblock", href: block_path(member.get_block(other_member)) + end + + it "has correct message and block button after unblock" do + click_link 'Block' + click_link 'Unblock' + expect(page).to have_content "Unblocked #{other_member.login_name}" + visit member_path(other_member) # unblocking redirects to root + expect(page).to have_link "Block", href: blocks_path(blocked: other_member.slug) + end + + context "when a member is blocked" do + before do + click_link 'Block' + end + + it "prevents following" do + visit member_path(other_member) + expect(page).to have_no_link "Follow" + end + + it "prevents messaging" do + visit new_message_path(recipient_id: other_member.id) + fill_in "Subject", with: "Test message" + fill_in "Body", with: "Test message body" + click_button "Send message" + expect(page).to have_content "You cannot send a message to a member who has blocked you." + end + + it "prevents commenting" do + post = create(:post, author: other_member) + visit post_path(post) + fill_in "comment_body", with: "Test comment" + click_button "Post Comment" + expect(page).to have_content "You cannot comment on a post of a member who has blocked you." + end + + it "prevents liking" do + post = create(:post, author: other_member) + visit post_path(post) + click_link "Like" + expect(page).to have_content "You cannot like content of a member who has blocked you." + end + end + end + end +end diff --git a/spec/features/members/following_spec.rb b/spec/features/members/following_spec.rb index 4c2f51116f..e45928d9a0 100644 --- a/spec/features/members/following_spec.rb +++ b/spec/features/members/following_spec.rb @@ -23,6 +23,17 @@ expect(page).to have_no_link "Unfollow" end + context "when the other member is blocked" do + before do + member.blocks.create(blocked: other_member) + visit member_path(other_member) + end + + it "does not have a follow button" do + expect(page).to have_no_link "Follow" + end + end + context "following another member" do before { visit member_path(other_member) } diff --git a/spec/models/comment_spec.rb b/spec/models/comment_spec.rb index 4d627c1696..bccc0198e6 100644 --- a/spec/models/comment_spec.rb +++ b/spec/models/comment_spec.rb @@ -41,6 +41,21 @@ end end + context "when the post author has blocked the comment author" do + let(:post_author) { create(:member) } + let(:comment_author) { create(:member) } + let(:post) { create(:post, author: post_author) } + + before do + post_author.blocks.create(blocked: comment_author) + end + + it "is not valid" do + comment = build(:comment, commentable: post, author: comment_author) + expect(comment).not_to be_valid + end + end + context "ordering" do before do @m = FactoryBot.create(:member) diff --git a/spec/models/like_spec.rb b/spec/models/like_spec.rb index 13f51412b9..5ec8bef4cf 100644 --- a/spec/models/like_spec.rb +++ b/spec/models/like_spec.rb @@ -63,6 +63,21 @@ expect(Like.all).not_to include like end + context "when the likeable author has blocked the member" do + let(:likeable_author) { create(:member) } + let(:post_author) { create(:member) } + let(:post) { create(:post, author: likeable_author) } + + before do + likeable_author.blocks.create(blocked: member) + end + + it "is not valid" do + like = build(:like, likeable: post, member: member) + expect(like).not_to be_valid + end + end + it 'liked_by_members_names' do expect(post.liked_by_members_names).to eq [] Like.create(member:, likeable: post) From 3ff1d4ec26962c3995e4a1bd53a431622eccff5c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:16:57 +0000 Subject: [PATCH 2/4] Implement blocking feature and fix failing tests This commit introduces a blocking feature that allows members to block other members. A blocked member is prevented from: - following the blocker - sending private messages to the blocker - replying to the blocker's posts - liking the blocker's content The implementation includes: - A new `Block` model and a corresponding database table. - Updates to the `Member` model to include associations for blocks. - A new `BlocksController` to handle blocking and unblocking actions. - New routes for the `BlocksController`. - UI changes to add block/unblock buttons to the member profile page. - Validations in the `Follow`, `Comment`, and `Like` models to enforce the blocking rules. - A check in the `MessagesController` to prevent sending messages to a member who has blocked the sender. - A callback in the `Block` model to destroy the follow relationship when a block is created. - New feature and model specs to test the blocking functionality. This commit also fixes a failing test in the blocking feature. The error was caused by the validation being called even when the `member` association was `nil`. A guard has been added to the validation methods in the `Like`, `Follow`, and `Comment` models to prevent this from happening. --- app/models/comment.rb | 1 + app/models/follow.rb | 1 + app/models/like.rb | 1 + 3 files changed, 3 insertions(+) diff --git a/app/models/comment.rb b/app/models/comment.rb index e1f994c3ae..b6b80b2417 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -30,6 +30,7 @@ def to_s private def author_is_not_blocked + return unless author if commentable.author.already_blocking?(author) errors.add(:base, "You cannot comment on a post of a member who has blocked you.") end diff --git a/app/models/follow.rb b/app/models/follow.rb index b5d310587b..e86c0dddd2 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -19,6 +19,7 @@ class Follow < ApplicationRecord private def follower_is_not_blocked + return unless follower if followed.already_blocking?(follower) errors.add(:base, "You cannot follow a member who has blocked you.") end diff --git a/app/models/like.rb b/app/models/like.rb index f457fd701f..493ed938c6 100644 --- a/app/models/like.rb +++ b/app/models/like.rb @@ -18,6 +18,7 @@ def likeable_author private def member_is_not_blocked + return unless member author = likeable_author if author && author.already_blocking?(member) errors.add(:base, "You cannot like content of a member who has blocked you.") From 0f0303aba534dee7d2f20251c995f15bea548e81 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Sun, 7 Sep 2025 10:30:46 +0000 Subject: [PATCH 3/4] Generate schema --- db/schema.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index b09d3956bc..52500324fd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_09_01_130830) do +ActiveRecord::Schema[7.2].define(version: 2025_09_01_144900) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -85,6 +85,16 @@ t.index ["member_id"], name: "index_authentications_on_member_id" end + create_table "blocks", force: :cascade do |t| + t.bigint "blocker_id" + t.bigint "blocked_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["blocked_id"], name: "index_blocks_on_blocked_id" + t.index ["blocker_id", "blocked_id"], name: "index_blocks_on_blocker_id_and_blocked_id", unique: true + t.index ["blocker_id"], name: "index_blocks_on_blocker_id" + end + create_table "comfy_cms_categories", id: :serial, force: :cascade do |t| t.integer "site_id", null: false t.string "label", null: false @@ -651,6 +661,8 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "blocks", "members", column: "blocked_id" + add_foreign_key "blocks", "members", column: "blocker_id" add_foreign_key "harvests", "plantings" add_foreign_key "mailboxer_conversation_opt_outs", "mailboxer_conversations", column: "conversation_id", name: "mb_opt_outs_on_conversations_id" add_foreign_key "mailboxer_notifications", "mailboxer_conversations", column: "conversation_id", name: "notifications_on_conversation_id" From 4551faa7bbc71b0e70dce91e53434ef533c4d247 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Sun, 7 Sep 2025 11:39:21 +0000 Subject: [PATCH 4/4] Fix tests --- app/views/members/_follow_buttons.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/members/_follow_buttons.haml b/app/views/members/_follow_buttons.haml index 19ed2d4011..d0e064d808 100644 --- a/app/views/members/_follow_buttons.haml +++ b/app/views/members/_follow_buttons.haml @@ -1,10 +1,10 @@ - if current_member && current_member != member # must be logged in, can't follow yourself + - block = current_member.get_block(member) - follow = current_member.get_follow(member) - - if !follow && can?(:create, Follow) # not already following + - if !block && !follow && can?(:create, Follow) # not already following, and not blocking = link_to 'Follow', follows_path(followed: member), method: :post, class: 'btn btn-block btn-success' - if follow && can?(:destroy, follow) # already following = link_to 'Unfollow', follow_path(follow), method: :delete, class: 'btn btn-block' - - block = current_member.get_block(member) - if !block && can?(:create, Block) # not already blocking = link_to 'Block', blocks_path(blocked: member), method: :post, class: 'btn btn-block btn-danger' - if block && can?(:destroy, block) # already blocking