Files
growstuff/app/models/member.rb
google-labs-jules[bot] 3b60e8f974 Implement blocking feature (#4199)
* 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.

* 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.

* Generate schema

* Fix tests

* Add permissions

* Define Block permissions in Ability model

The feature specs for member blocking were failing because the "Block"
link was not being rendered on member profiles. This was due to the
lack of explicit create and destroy permissions for the Block resource
in the Ability model, which is used by CanCanCan to authorize actions
and by the view to conditionally show links.

This change adds the necessary permissions to `member_abilities`:
- Allows members to create blocks (except for blocking themselves).
- Allows members to destroy blocks where they are the blocker.

These rules ensure that the "Block" and "Unblock" links are correctly
rendered and authorized for signed-in members.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>

* Comment out specs for now

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com>
Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-04-26 14:22:32 +09:30

200 lines
7.1 KiB
Ruby

# frozen_string_literal: true
class Member < ApplicationRecord
include Discard::Model
acts_as_messageable # messages can be sent to this model
include Geocodable
include MemberFlickr
include MemberNewsletter
extend FriendlyId
friendly_id :login_name, use: %i(slugged finders)
#
# Relationships
has_many :posts, foreign_key: 'author_id', dependent: :destroy, inverse_of: :author
has_many :comments, foreign_key: 'author_id', dependent: :destroy, inverse_of: :author
has_many :forums, foreign_key: 'owner_id', dependent: :nullify, inverse_of: :owner
has_many :gardens, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
has_many :plantings, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
has_many :seeds, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
has_many :harvests, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
has_many :activities, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
has_and_belongs_to_many :roles
has_many :notifications, foreign_key: 'recipient_id', inverse_of: :recipient
has_many :sent_notifications, foreign_key: 'sender_id', inverse_of: :sender, class_name: "Notification"
has_many :authentications, dependent: :destroy
has_one :api_token, -> { where(provider: 'api') }, class_name: 'Authentication', dependent: :destroy
def api_token?
api_token.present?
end
def regenerate_api_token
api_token.destroy if api_token?
create_api_token(
provider: 'api',
uid: id,
token: SecureRandom.hex(16)
)
end
has_many :photos, inverse_of: :owner
has_many :likes, dependent: :destroy
#
# Following other members
has_many :follows, class_name: "Follow", foreign_key: "follower_id", dependent: :destroy,
inverse_of: :follower
has_many :inverse_follows, class_name: "Follow", foreign_key: "followed_id",
dependent: :destroy, inverse_of: :followed
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,
inverse_of: :requester
has_many :created_crops, class_name: 'Crop', foreign_key: 'creator_id', dependent: :nullify,
inverse_of: :creator
has_many :created_alternate_names, class_name: 'AlternateName', foreign_key: 'creator_id', inverse_of: :creator
has_many :created_scientific_names, class_name: 'ScientificName', foreign_key: 'creator_id', inverse_of: :creator
#
# Scopes
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :located, -> { where.not(location: '').where.not(latitude: nil).where.not(longitude: nil) }
scope :recently_signed_in, -> { reorder(updated_at: :desc) }
scope :recently_joined, -> { reorder(confirmed_at: :desc) }
scope :interesting, -> { confirmed.located.recently_signed_in.has_plantings }
scope :has_plantings, -> { joins(:plantings).group("members.id") }
scope :wants_reminders, -> { where(send_planting_reminder: true) }
# Include default devise modules. Others available are:
# :token_authenticatable, :confirmable,
# :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable,
:confirmable, :lockable, :timeoutable, :omniauthable
# discarded (deleted) member cannot log in
def active_for_authentication?
super && !discarded?
end
# set up geocoding
geocoded_by :location
# Virtual attribute for authenticating by either username or email
# This is in addition to a real persisted field like 'username'
attr_accessor :login
#
# Validations
# Requires acceptance of the Terms of Service
validates :tos_agreement, acceptance: { allow_nil: true, accept: true }
validates :login_name,
length: {
minimum: 2, maximum: 25, message: :login_name_length
},
exclusion: {
in: %w(growstuff admin moderator staff nearby), message: :login_name_reserved
},
format: {
with: /\A\w+\z/, message: :login_name_format
},
uniqueness: {
case_sensitive: false
}
validates :website_url, format: { with: %r{\Ahttps?://}, message: :url_format }, allow_blank: true
validates :other_url, format: { with: %r{\Ahttps?://}, message: :url_format }, allow_blank: true
validates :instagram_handle, :facebook_handle, :bluesky_handle,
format: { without: %r{\Ahttps?://|/}, message: :handle_format }, allow_blank: true
#
# Triggers
after_validation :geocode
after_validation :empty_unwanted_geocodes
# Give each new member a default garden
# we use find_or_create to avoid accidentally creating a second one,
# which can happen sometimes especially with FactoryBot associations
after_create { |member| Garden.create(name: "Garden", owner_id: member.id) }
# allow login via either login_name or email address
def self.find_first_by_auth_conditions(warden_conditions)
conditions = warden_conditions.dup
login = conditions.delete(:login)
return where(conditions).login_name_or_email(login).first if login
find_by(conditions)
end
def to_s
discarded? ? 'deleted' : login_name
end
def to_param
slug
end
def mailboxer_email(_messageable)
send_notification_email ? email : false
end
def role?(role_sym)
roles.any? { |r| r.name.gsub(/\s+/, "_").underscore.to_sym == role_sym }
end
def auth(provider)
authentications.find_by(provider:)
end
def unread_count
receipts.where(is_read: false).count
end
def self.login_name_or_email(login)
where(["lower(login_name) = :value OR lower(email) = :value", { value: login.downcase }])
end
def self.case_insensitive_login_name(login)
where(["lower(login_name) = :value", { value: login.downcase }])
end
def self.nearest_to(place)
nearby_members = []
if place
latitude, longitude = Geocoder.coordinates(place, params: { limit: 1 })
nearby_members = Member.located.sort_by { |x| x.distance_from([latitude, longitude]) } if latitude && longitude
end
nearby_members
end
def already_following?(member)
follows.exists?(followed_id: member.id)
end
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