mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47:11 -05:00
Fix some minor stuff
This commit is contained in:
parent
e711ff25fe
commit
923ea113c8
23 changed files with 852 additions and 305 deletions
12
.github/workflows/build_and_push.yml
vendored
12
.github/workflows/build_and_push.yml
vendored
|
|
@ -74,18 +74,6 @@ jobs:
|
||||||
# Set platforms based on version type and release type
|
# Set platforms based on version type and release type
|
||||||
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7"
|
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7"
|
||||||
|
|
||||||
# Check if this is a patch version (x.y.z where z > 0)
|
|
||||||
if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[1-9][0-9]*$ ]]; then
|
|
||||||
echo "Detected patch version ($VERSION) - building for AMD64 only"
|
|
||||||
PLATFORMS="linux/amd64"
|
|
||||||
elif [[ $VERSION =~ ^[0-9]+\.[0-9]+\.0$ ]]; then
|
|
||||||
echo "Detected minor version ($VERSION) - building for all platforms"
|
|
||||||
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7"
|
|
||||||
else
|
|
||||||
echo "Version format not recognized or non-semver - using AMD64 only for safety"
|
|
||||||
PLATFORMS="linux/amd64"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add :rc tag for pre-releases
|
# Add :rc tag for pre-releases
|
||||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||||
TAGS="${TAGS},freikin/dawarich:rc"
|
TAGS="${TAGS},freikin/dawarich:rc"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class FamilyInvitationsCleanupJob < ApplicationJob
|
class Family::Invitations::CleanupJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :family
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
Rails.logger.info 'Starting family invitations cleanup'
|
Rails.logger.info 'Starting family invitations cleanup'
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,14 @@ class FamilyMailer < ApplicationMailer
|
||||||
subject: "🎉 You've been invited to join #{@family.name} on Dawarich!"
|
subject: "🎉 You've been invited to join #{@family.name} on Dawarich!"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def member_joined(family, user)
|
||||||
|
@family = family
|
||||||
|
@user = user
|
||||||
|
|
||||||
|
mail(
|
||||||
|
to: @family.owner.email,
|
||||||
|
subject: "👪 #{@user.name} has joined your family #{@family.name} on Dawarich!"
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ class Family < ApplicationRecord
|
||||||
|
|
||||||
MAX_MEMBERS = 5
|
MAX_MEMBERS = 5
|
||||||
|
|
||||||
scope :with_members, -> { includes(:members, :family_memberships) }
|
|
||||||
scope :with_pending_invitations, -> { includes(family_invitations: :invited_by) }
|
|
||||||
|
|
||||||
def can_add_members?
|
def can_add_members?
|
||||||
(member_count + pending_invitations_count) < MAX_MEMBERS
|
(member_count + pending_invitations_count) < MAX_MEMBERS
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,6 @@ class Family::Invitation < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_family_cache
|
def clear_family_cache
|
||||||
family&.clear_member_cache!
|
family.clear_member_cache!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,6 @@ class Family::Membership < ApplicationRecord
|
||||||
private
|
private
|
||||||
|
|
||||||
def clear_family_cache
|
def clear_family_cache
|
||||||
family&.clear_member_cache!
|
family.clear_member_cache!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class FamilyInvitation < ApplicationRecord
|
|
||||||
EXPIRY_DAYS = 7
|
|
||||||
|
|
||||||
belongs_to :family
|
|
||||||
belongs_to :invited_by, class_name: 'User'
|
|
||||||
|
|
||||||
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
||||||
validates :token, presence: true, uniqueness: true
|
|
||||||
validates :expires_at, :status, presence: true
|
|
||||||
|
|
||||||
enum :status, { pending: 0, accepted: 1, expired: 2, cancelled: 3 }
|
|
||||||
|
|
||||||
scope :active, -> { where(status: :pending).where('expires_at > ?', Time.current) }
|
|
||||||
|
|
||||||
before_validation :generate_token, :set_expiry, on: :create
|
|
||||||
|
|
||||||
after_create :clear_family_cache
|
|
||||||
after_update :clear_family_cache, if: :saved_change_to_status?
|
|
||||||
after_destroy :clear_family_cache
|
|
||||||
|
|
||||||
def expired?
|
|
||||||
expires_at < Time.current
|
|
||||||
end
|
|
||||||
|
|
||||||
def can_be_accepted?
|
|
||||||
pending? && !expired?
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def generate_token
|
|
||||||
self.token = SecureRandom.urlsafe_base64(32) if token.blank?
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_expiry
|
|
||||||
self.expires_at = EXPIRY_DAYS.days.from_now if expires_at.blank?
|
|
||||||
end
|
|
||||||
|
|
||||||
def clear_family_cache
|
|
||||||
family&.clear_member_cache!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class FamilyMembership < ApplicationRecord
|
|
||||||
belongs_to :family
|
|
||||||
belongs_to :user
|
|
||||||
|
|
||||||
validates :user_id, presence: true, uniqueness: true
|
|
||||||
validates :role, presence: true
|
|
||||||
|
|
||||||
enum :role, { owner: 0, member: 1 }
|
|
||||||
|
|
||||||
after_create :clear_family_cache
|
|
||||||
after_update :clear_family_cache
|
|
||||||
after_destroy :clear_family_cache
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def clear_family_cache
|
|
||||||
family&.clear_member_cache!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -7,16 +7,20 @@ class Family::InvitationPolicy < ApplicationPolicy
|
||||||
end
|
end
|
||||||
|
|
||||||
def create?
|
def create?
|
||||||
|
return false unless user
|
||||||
|
|
||||||
user.family == record.family && user.family_owner?
|
user.family == record.family && user.family_owner?
|
||||||
end
|
end
|
||||||
|
|
||||||
def accept?
|
def accept?
|
||||||
# Users can accept invitations sent to their email
|
# Users can accept invitations sent to their email
|
||||||
|
return false unless user
|
||||||
|
|
||||||
user.email == record.email
|
user.email == record.email
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy?
|
def destroy?
|
||||||
# Only family owners can cancel invitations
|
# Only family owners can cancel invitations
|
||||||
user.family == record.family && user.family_owner?
|
create?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,28 @@
|
||||||
|
|
||||||
class Family::MembershipPolicy < ApplicationPolicy
|
class Family::MembershipPolicy < ApplicationPolicy
|
||||||
def show?
|
def show?
|
||||||
|
return false unless user
|
||||||
|
|
||||||
user.family == record.family
|
user.family == record.family
|
||||||
end
|
end
|
||||||
|
|
||||||
def update?
|
def update?
|
||||||
|
return false unless user
|
||||||
|
|
||||||
# Users can update their own settings
|
# Users can update their own settings
|
||||||
return true if user == record.user
|
return true if user == record.user
|
||||||
|
|
||||||
# Family owners can update any member's settings
|
# Family owners can update any member's settings
|
||||||
user.family == record.family && user.family_owner?
|
show? && user.family_owner?
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy?
|
def destroy?
|
||||||
|
return false unless user
|
||||||
|
|
||||||
# Users can remove themselves (handled by family leave logic)
|
# Users can remove themselves (handled by family leave logic)
|
||||||
return true if user == record.user
|
return true if user == record.user
|
||||||
|
|
||||||
# Family owners can remove other members
|
# Family owners can remove other members
|
||||||
user.family == record.family && user.family_owner?
|
update?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ module Families
|
||||||
|
|
||||||
if user.in_family?
|
if user.in_family?
|
||||||
@error_message = 'You must leave your current family before joining a new one.'
|
@error_message = 'You must leave your current family before joining a new one.'
|
||||||
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ module Families
|
||||||
return true if invitation.can_be_accepted?
|
return true if invitation.can_be_accepted?
|
||||||
|
|
||||||
@error_message = 'This invitation is no longer valid or has expired.'
|
@error_message = 'This invitation is no longer valid or has expired.'
|
||||||
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -54,6 +56,7 @@ module Families
|
||||||
return true if invitation.email == user.email
|
return true if invitation.email == user.email
|
||||||
|
|
||||||
@error_message = 'This invitation is not for your email address.'
|
@error_message = 'This invitation is not for your email address.'
|
||||||
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -61,6 +64,7 @@ module Families
|
||||||
return true unless invitation.family.full?
|
return true unless invitation.family.full?
|
||||||
|
|
||||||
@error_message = 'This family has reached the maximum number of members.'
|
@error_message = 'This family has reached the maximum number of members.'
|
||||||
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -100,21 +104,21 @@ module Families
|
||||||
content: "#{user.email} has joined your family"
|
content: "#{user.email} has joined your family"
|
||||||
)
|
)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
# Don't fail the entire operation if notification fails
|
ExceptionReporter.call(e, "Unexpected error in Families::AcceptInvitation: #{e.message}")
|
||||||
Rails.logger.warn "Failed to send family join notification: #{e.message}"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_record_invalid_error(error)
|
def handle_record_invalid_error(error)
|
||||||
@error_message = if error.record&.errors&.any?
|
@error_message =
|
||||||
error.record.errors.full_messages.first
|
if error.record&.errors&.any?
|
||||||
else
|
error.record.errors.full_messages.first
|
||||||
"Failed to join family: #{error.message}"
|
else
|
||||||
end
|
"Failed to join family: #{error.message}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_generic_error(error)
|
def handle_generic_error(error)
|
||||||
Rails.logger.error "Unexpected error in Families::AcceptInvitation: #{error.message}"
|
ExceptionReporter.call(error, "Unexpected error in Families::AcceptInvitation: #{error.message}")
|
||||||
Rails.logger.error error.backtrace.join("\n")
|
|
||||||
@error_message = 'An unexpected error occurred while joining the family. Please try again'
|
@error_message = 'An unexpected error occurred while joining the family. Please try again'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,15 @@ module Families
|
||||||
true
|
true
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
handle_record_invalid_error(e)
|
handle_record_invalid_error(e)
|
||||||
|
|
||||||
false
|
false
|
||||||
rescue ActiveRecord::RecordNotUnique => e
|
rescue ActiveRecord::RecordNotUnique => e
|
||||||
handle_uniqueness_error(e)
|
handle_uniqueness_error(e)
|
||||||
|
|
||||||
false
|
false
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
handle_generic_error(e)
|
handle_generic_error(e)
|
||||||
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -60,11 +63,13 @@ module Families
|
||||||
def validate_feature_access
|
def validate_feature_access
|
||||||
return true if can_create_family?
|
return true if can_create_family?
|
||||||
|
|
||||||
@error_message = if DawarichSettings.self_hosted?
|
@error_message =
|
||||||
'Family feature is not available on this instance'
|
if DawarichSettings.self_hosted?
|
||||||
else
|
'Family feature is not available on this instance'
|
||||||
'Family feature requires an active subscription'
|
else
|
||||||
end
|
'Family feature requires an active subscription'
|
||||||
|
end
|
||||||
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -99,15 +104,16 @@ module Families
|
||||||
)
|
)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
# Don't fail the entire operation if notification fails
|
# Don't fail the entire operation if notification fails
|
||||||
Rails.logger.warn "Failed to send family creation notification: #{e.message}"
|
ExceptionReporter.call(e, "Unexpected error in Families::Create: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_record_invalid_error(error)
|
def handle_record_invalid_error(error)
|
||||||
if family&.errors&.any?
|
@error_message =
|
||||||
@error_message = family.errors.full_messages.first
|
if family&.errors&.any?
|
||||||
else
|
family.errors.full_messages.first
|
||||||
@error_message = "Failed to create family: #{error.message}"
|
else
|
||||||
end
|
"Failed to create family: #{error.message}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_uniqueness_error(_error)
|
def handle_uniqueness_error(_error)
|
||||||
|
|
@ -115,8 +121,7 @@ module Families
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_generic_error(error)
|
def handle_generic_error(error)
|
||||||
Rails.logger.error "Unexpected error in Families::Create: #{error.message}"
|
ExceptionReporter.call(error, "Unexpected error in Families::Create: #{error.message}")
|
||||||
Rails.logger.error error.backtrace.join("\n")
|
|
||||||
@error_message = 'An unexpected error occurred while creating the family. Please try again'
|
@error_message = 'An unexpected error occurred while creating the family. Please try again'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ module Families
|
||||||
)
|
)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
# Don't fail the entire operation if notification fails
|
# Don't fail the entire operation if notification fails
|
||||||
Rails.logger.warn "Failed to send invitation notification: #{e.message}"
|
ExceptionReporter.call(e, "Unexpected error in Families::Invite: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_record_invalid_error(error)
|
def handle_record_invalid_error(error)
|
||||||
|
|
@ -120,8 +120,7 @@ module Families
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_generic_error(error)
|
def handle_generic_error(error)
|
||||||
Rails.logger.error "Unexpected error in Families::Invite: #{error.message}"
|
ExceptionReporter.call(error, "Unexpected error in Families::Invite: #{error.message}")
|
||||||
Rails.logger.error error.backtrace.join("\n")
|
|
||||||
@custom_error_message = 'An unexpected error occurred while sending the invitation. Please try again'
|
@custom_error_message = 'An unexpected error occurred while sending the invitation. Please try again'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ class Families::Locations
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_family_locations(sharing_members)
|
def build_family_locations(sharing_members)
|
||||||
latest_points = sharing_members.map { |member| member.points.last }.compact
|
latest_points =
|
||||||
|
sharing_members.map { _1.points.last }.compact
|
||||||
|
|
||||||
latest_points.map do |point|
|
latest_points.map do |point|
|
||||||
next unless point
|
next unless point
|
||||||
|
|
@ -42,7 +43,7 @@ class Families::Locations
|
||||||
latitude: point.lat.to_f,
|
latitude: point.lat.to_f,
|
||||||
longitude: point.lon.to_f,
|
longitude: point.lon.to_f,
|
||||||
timestamp: point.timestamp.to_i,
|
timestamp: point.timestamp.to_i,
|
||||||
updated_at: Time.at(point.timestamp.to_i)
|
updated_at: Time.zone.at(point.timestamp.to_i)
|
||||||
}
|
}
|
||||||
end.compact
|
end.compact
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,176 +5,176 @@ module Families
|
||||||
class Destroy
|
class Destroy
|
||||||
attr_reader :user, :member_to_remove, :error_message
|
attr_reader :user, :member_to_remove, :error_message
|
||||||
|
|
||||||
def initialize(user:, member_to_remove: nil)
|
def initialize(user:, member_to_remove: nil)
|
||||||
@user = user # The user performing the action (current_user)
|
@user = user # The user performing the action (current_user)
|
||||||
@member_to_remove = member_to_remove || user # The user being removed (defaults to self)
|
@member_to_remove = member_to_remove || user # The user being removed (defaults to self)
|
||||||
@error_message = nil
|
@error_message = nil
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
|
||||||
return false unless validate_can_leave
|
|
||||||
|
|
||||||
# Store family info before removing membership
|
|
||||||
@family_name = member_to_remove.family.name
|
|
||||||
@family_owner = member_to_remove.family.owner
|
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
|
||||||
handle_ownership_transfer if member_to_remove.family_owner?
|
|
||||||
remove_membership
|
|
||||||
send_notifications
|
|
||||||
end
|
end
|
||||||
|
|
||||||
true
|
def call
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
return false unless validate_can_leave
|
||||||
handle_record_invalid_error(e)
|
|
||||||
false
|
|
||||||
rescue StandardError => e
|
|
||||||
handle_generic_error(e)
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
# Store family info before removing membership
|
||||||
|
@family_name = member_to_remove.family.name
|
||||||
|
@family_owner = member_to_remove.family.owner
|
||||||
|
|
||||||
def validate_can_leave
|
ActiveRecord::Base.transaction do
|
||||||
return false unless validate_in_family
|
handle_ownership_transfer if member_to_remove.family_owner?
|
||||||
return false unless validate_removal_allowed
|
remove_membership
|
||||||
|
send_notifications
|
||||||
|
end
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
handle_record_invalid_error(e)
|
||||||
|
|
||||||
def validate_in_family
|
false
|
||||||
return true if member_to_remove.in_family?
|
rescue StandardError => e
|
||||||
|
handle_generic_error(e)
|
||||||
|
|
||||||
@error_message = 'User is not currently in a family.'
|
false
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_removal_allowed
|
|
||||||
# If removing self (user == member_to_remove)
|
|
||||||
if removing_self?
|
|
||||||
return validate_owner_can_leave
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# If removing another member, user must be owner and member must be in same family
|
private
|
||||||
return false unless validate_remover_is_owner
|
|
||||||
return false unless validate_same_family
|
|
||||||
return false unless validate_not_removing_owner
|
|
||||||
|
|
||||||
true
|
def validate_can_leave
|
||||||
end
|
return false unless validate_in_family
|
||||||
|
return false unless validate_removal_allowed
|
||||||
|
|
||||||
def removing_self?
|
true
|
||||||
user == member_to_remove
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_owner_can_leave
|
|
||||||
return true unless member_to_remove.family_owner?
|
|
||||||
|
|
||||||
@error_message = 'Family owners cannot remove their own membership. To leave the family, delete it instead.'
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_remover_is_owner
|
|
||||||
return true if user.family_owner?
|
|
||||||
|
|
||||||
@error_message = 'Only family owners can remove other members.'
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_same_family
|
|
||||||
return true if user.family == member_to_remove.family
|
|
||||||
|
|
||||||
@error_message = 'Cannot remove members from a different family.'
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_not_removing_owner
|
|
||||||
return true unless member_to_remove.family_owner?
|
|
||||||
|
|
||||||
@error_message = 'Cannot remove the family owner. The owner must delete the family or leave on their own.'
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def family_has_other_members?
|
|
||||||
member_to_remove.family.members.count > 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_ownership_transfer
|
|
||||||
# If this is the last member (owner), delete the family
|
|
||||||
return unless member_to_remove.family.members.count == 1
|
|
||||||
|
|
||||||
member_to_remove.family.destroy!
|
|
||||||
|
|
||||||
# If owner tries to leave with other members, it should be prevented in validation
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_membership
|
|
||||||
member_to_remove.family_membership.destroy!
|
|
||||||
end
|
|
||||||
|
|
||||||
def send_notifications
|
|
||||||
return unless defined?(Notification)
|
|
||||||
|
|
||||||
if removing_self?
|
|
||||||
send_self_removal_notifications
|
|
||||||
else
|
|
||||||
send_member_removed_notifications
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def send_self_removal_notifications
|
def validate_in_family
|
||||||
# Notify the user who left
|
return true if member_to_remove.in_family?
|
||||||
Notification.create!(
|
|
||||||
user: member_to_remove,
|
|
||||||
kind: :info,
|
|
||||||
title: 'Left Family',
|
|
||||||
content: "You've left the family \"#{@family_name}\""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Notify the family owner
|
@error_message = 'User is not currently in a family.'
|
||||||
return unless @family_owner&.persisted?
|
false
|
||||||
|
end
|
||||||
|
|
||||||
Notification.create!(
|
def validate_removal_allowed
|
||||||
user: @family_owner,
|
# If removing self (user == member_to_remove)
|
||||||
kind: :info,
|
return validate_owner_can_leave if removing_self?
|
||||||
title: 'Family Member Left',
|
|
||||||
content: "#{member_to_remove.email} has left the family \"#{@family_name}\""
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def send_member_removed_notifications
|
# If removing another member, user must be owner and member must be in same family
|
||||||
# Notify the member who was removed
|
return false unless validate_remover_is_owner
|
||||||
Notification.create!(
|
return false unless validate_same_family
|
||||||
user: member_to_remove,
|
return false unless validate_not_removing_owner
|
||||||
kind: :info,
|
|
||||||
title: 'Removed from Family',
|
|
||||||
content: "You have been removed from the family \"#{@family_name}\" by #{user.email}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Notify the owner who removed the member (if different from the member)
|
true
|
||||||
return unless user != member_to_remove
|
end
|
||||||
|
|
||||||
Notification.create!(
|
def removing_self?
|
||||||
user: user,
|
user == member_to_remove
|
||||||
kind: :info,
|
end
|
||||||
title: 'Member Removed',
|
|
||||||
content: "#{member_to_remove.email} has been removed from the family \"#{@family_name}\""
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_record_invalid_error(error)
|
def validate_owner_can_leave
|
||||||
@error_message = if error.record&.errors&.any?
|
return true unless member_to_remove.family_owner?
|
||||||
error.record.errors.full_messages.first
|
|
||||||
else
|
|
||||||
"Failed to leave family: #{error.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_generic_error(error)
|
@error_message = 'Family owners cannot remove their own membership. To leave the family, delete it instead.'
|
||||||
Rails.logger.error "Unexpected error in Families::Memberships::Destroy: #{error.message}"
|
false
|
||||||
Rails.logger.error error.backtrace.join("\n")
|
end
|
||||||
@error_message = 'An unexpected error occurred while removing the membership. Please try again'
|
|
||||||
end
|
def validate_remover_is_owner
|
||||||
|
return true if user.family_owner?
|
||||||
|
|
||||||
|
@error_message = 'Only family owners can remove other members.'
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_same_family
|
||||||
|
return true if user.family == member_to_remove.family
|
||||||
|
|
||||||
|
@error_message = 'Cannot remove members from a different family.'
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_not_removing_owner
|
||||||
|
return true unless member_to_remove.family_owner?
|
||||||
|
|
||||||
|
@error_message = 'Cannot remove the family owner. The owner must delete the family or leave on their own.'
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def family_has_other_members?
|
||||||
|
member_to_remove.family.members.count > 1
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_ownership_transfer
|
||||||
|
# If this is the last member (owner), delete the family
|
||||||
|
return unless member_to_remove.family.members.count == 1
|
||||||
|
|
||||||
|
member_to_remove.family.destroy!
|
||||||
|
|
||||||
|
# If owner tries to leave with other members, it should be prevented in validation
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_membership
|
||||||
|
member_to_remove.family_membership.destroy!
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_notifications
|
||||||
|
return unless defined?(Notification)
|
||||||
|
|
||||||
|
if removing_self?
|
||||||
|
send_self_removal_notifications
|
||||||
|
else
|
||||||
|
send_member_removed_notifications
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_self_removal_notifications
|
||||||
|
# Notify the user who left
|
||||||
|
Notification.create!(
|
||||||
|
user: member_to_remove,
|
||||||
|
kind: :info,
|
||||||
|
title: 'Left Family',
|
||||||
|
content: "You've left the family \"#{@family_name}\""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify the family owner
|
||||||
|
return unless @family_owner&.persisted?
|
||||||
|
|
||||||
|
Notification.create!(
|
||||||
|
user: @family_owner,
|
||||||
|
kind: :info,
|
||||||
|
title: 'Family Member Left',
|
||||||
|
content: "#{member_to_remove.email} has left the family \"#{@family_name}\""
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_member_removed_notifications
|
||||||
|
# Notify the member who was removed
|
||||||
|
Notification.create!(
|
||||||
|
user: member_to_remove,
|
||||||
|
kind: :info,
|
||||||
|
title: 'Removed from Family',
|
||||||
|
content: "You have been removed from the family \"#{@family_name}\" by #{user.email}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify the owner who removed the member (if different from the member)
|
||||||
|
return unless user != member_to_remove
|
||||||
|
|
||||||
|
Notification.create!(
|
||||||
|
user: user,
|
||||||
|
kind: :info,
|
||||||
|
title: 'Member Removed',
|
||||||
|
content: "#{member_to_remove.email} has been removed from the family \"#{@family_name}\""
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_record_invalid_error(error)
|
||||||
|
@error_message =
|
||||||
|
if error.record&.errors&.any?
|
||||||
|
error.record.errors.full_messages.first
|
||||||
|
else
|
||||||
|
"Failed to leave family: #{error.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_generic_error(error)
|
||||||
|
ExceptionReporter.call(error, "Unexpected error in Families::Memberships::Destroy: #{error.message}")
|
||||||
|
@error_message = 'An unexpected error occurred while removing the membership. Please try again'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,12 @@ class Families::UpdateLocationSharing
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
if update_location_sharing
|
return success_result if update_location_sharing
|
||||||
success_result
|
|
||||||
else
|
failure_result('Failed to update location sharing setting', :unprocessable_content)
|
||||||
failure_result('Failed to update location sharing setting', :unprocessable_content)
|
|
||||||
end
|
|
||||||
rescue => error
|
rescue => error
|
||||||
Rails.logger.error("Failed to update family location sharing: #{error.message}") if defined?(Rails)
|
ExceptionReporter.call(error, "Error in Families::UpdateLocationSharing: #{error.message}")
|
||||||
|
|
||||||
failure_result('An error occurred while updating location sharing', :internal_server_error)
|
failure_result('An error occurred while updating location sharing', :internal_server_error)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,6 @@
|
||||||
<% else %>
|
<% else %>
|
||||||
<h1 class="text-5xl font-bold text-base-content">Login now</h1>
|
<h1 class="text-5xl font-bold text-base-content">Login now</h1>
|
||||||
<p class="py-6 text-base-content opacity-70">and take control over your location data.</p>
|
<p class="py-6 text-base-content opacity-70">and take control over your location data.</p>
|
||||||
<% if ENV['DEMO_ENV'] == 'true' %>
|
|
||||||
<p class="py-6 text-base-content opacity-70">
|
|
||||||
Demo account: <strong class="text-success">demo@dawarich.app</strong> / password: <strong class="text-success">password</strong>
|
|
||||||
</p>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@
|
||||||
<%= t('families.show.invite_member', default: 'Invite New Member') %>
|
<%= t('families.show.invite_member', default: 'Invite New Member') %>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<%= form_with model: [@family, FamilyInvitation.new], url: family_invitations_path(@family), local: true, class: "space-y-3" do |form| %>
|
<%= form_with model: [@family, Family::Invitation.new], url: family_invitations_path(@family), local: true, class: "space-y-3" do |form| %>
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :email, t('families.show.email_label', default: 'Email Address'), class: "label label-text font-medium mb-1" %>
|
<%= form.label :email, t('families.show.email_label', default: 'Email Address'), class: "label label-text font-medium mb-1" %>
|
||||||
<%= form.email_field :email,
|
<%= form.email_field :email,
|
||||||
|
|
|
||||||
39
app/views/family_mailer/member_joined.html.erb
Normal file
39
app/views/family_mailer/member_joined.html.erb
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9fafb;">
|
||||||
|
<div style="background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
<h2 style="color: #1f2937; margin-bottom: 20px; text-align: center;">🎉 Great news! Someone joined your family!</h2>
|
||||||
|
|
||||||
|
<p style="color: #374151; line-height: 1.6;">Hi <%= @family.owner.email %>!</p>
|
||||||
|
|
||||||
|
<p style="color: #374151; line-height: 1.6;">
|
||||||
|
We're excited to let you know that <strong><%= @user.email %></strong> has just joined your family
|
||||||
|
"<strong><%= @family.name %></strong>" on Dawarich!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 6px; margin: 20px 0;">
|
||||||
|
<h3 style="color: #1f2937; margin-bottom: 15px; font-size: 18px;">Now you can:</h3>
|
||||||
|
<ul style="color: #374151; line-height: 1.6; margin: 0; padding-left: 20px;">
|
||||||
|
<li style="margin-bottom: 8px;">See <%= @user.email %>'s current location (if they've enabled sharing)</li>
|
||||||
|
<li style="margin-bottom: 8px;">Stay connected with your growing family</li>
|
||||||
|
<li style="margin-bottom: 8px;">Share your location with <%= @user.email %></li>
|
||||||
|
<li>Manage family members and settings from your family page</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #dbeafe; border: 1px solid #3b82f6; border-radius: 6px; padding: 15px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; color: #1e40af; font-size: 14px;">
|
||||||
|
<strong>💡 Tip:</strong> You can manage your family members and privacy settings at any time from your family dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #374151; line-height: 1.6;">
|
||||||
|
Your family now has <strong><%= @family.member_count %></strong> member<%= @family.member_count == 1 ? '' : 's' %>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
|
||||||
|
|
||||||
|
<p style="color: #6b7280; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||||
|
Best regards,<br>
|
||||||
|
Evgenii from Dawarich
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
18
app/views/family_mailer/member_joined.text.erb
Normal file
18
app/views/family_mailer/member_joined.text.erb
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
Great news! Someone joined your family!
|
||||||
|
|
||||||
|
Hi <%= @family.owner.email %>!
|
||||||
|
|
||||||
|
We're excited to let you know that <%= @user.email %> has just joined your family "<%= @family.name %>" on Dawarich!
|
||||||
|
|
||||||
|
Now you can:
|
||||||
|
• See <%= @user.email %>'s current location (if they've enabled sharing)
|
||||||
|
• Stay connected with your growing family
|
||||||
|
• Share your location with <%= @user.email %>
|
||||||
|
• Manage family members and settings from your family page
|
||||||
|
|
||||||
|
TIP: You can manage your family members and privacy settings at any time from your family dashboard.
|
||||||
|
|
||||||
|
Your family now has <%= @family.member_count %> member<%= @family.member_count == 1 ? '' : 's' %>.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Evgenii from Dawarich
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
# Database Index Audit (2025-10-05)
|
|
||||||
|
|
||||||
## Observed ActiveRecord Query Patterns
|
|
||||||
- **Visits range filter** – `log/test.log:91056` shows repeated lookups with `WHERE "visits"."user_id" = ? AND (started_at >= ? AND ended_at <= ?)` ordered by `started_at`.
|
|
||||||
- **Imports deduplication checks** – `log/test.log:11130` runs `SELECT 1 FROM "imports" WHERE "name" = ? AND "user_id" = ?` (and variants excluding an `id`).
|
|
||||||
- **Family invitations association** – `app/models/user.rb:22` loads `sent_family_invitations`, which issues queries on `invited_by_id` even though only `family_id` currently has an index (`db/schema.rb:108-120`).
|
|
||||||
|
|
||||||
## Missing or Weak Index Coverage
|
|
||||||
1. **`family_invitations(invited_by_id)`**
|
|
||||||
- Evidence: association in `app/models/user.rb:22` plus schema definition at `db/schema.rb:112` lacking an index.
|
|
||||||
- Risk: every `user.sent_family_invitations` call scans by `invited_by_id`, which will degrade as invitation counts grow.
|
|
||||||
- Suggested fix: add `add_index :family_invitations, :invited_by_id` (consider `validate: false` first, then `validate_foreign_key` to avoid locking).
|
|
||||||
|
|
||||||
2. **`visits(user_id, started_at, ended_at)`**
|
|
||||||
- Evidence: range queries in `log/test.log:91056` rely on `user_id` plus `started_at`/`ended_at`, yet the table only has single-column indexes on `user_id` and `started_at` (`db/schema.rb:338-339`).
|
|
||||||
- Risk: planner must combine two indexes or fall back to seq scans for wide ranges.
|
|
||||||
- Suggested fix: add a composite index such as `add_index :visits, [:user_id, :started_at, :ended_at]` (or at minimum `[:user_id, :started_at]`) to cover the filter and ordering.
|
|
||||||
|
|
||||||
3. **`imports(user_id, name)`**
|
|
||||||
- Evidence: deduplication queries in `log/test.log:11130` filter on both columns while only `user_id` is indexed (`db/schema.rb:146-148`).
|
|
||||||
- Risk: duplicate checks for large import histories become progressively slower.
|
|
||||||
- Suggested fix: add a unique composite index `add_index :imports, [:user_id, :name], unique: true` if business rules prevent duplicate filenames per user.
|
|
||||||
|
|
||||||
## Potentially Unused Indexes
|
|
||||||
- `active_storage_attachments.blob_id` (`db/schema.rb:34`) and `active_storage_variant_records(blob_id, variation_digest)` (`db/schema.rb:53`) do not appear in application code outside Active Storage internals. They are required for Active Storage itself, so no action recommended beyond periodic verification with `ANALYZE` stats.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
- Generate and run migrations for the suggested indexes in development, then `EXPLAIN ANALYZE` the affected queries to confirm improved plans.
|
|
||||||
- After deploying, monitor `pg_stat_statements` or query logs to ensure the new indexes are used and to detect any remaining hotspots.
|
|
||||||
264
spec/policies/family/invitation_policy_spec.rb
Normal file
264
spec/policies/family/invitation_policy_spec.rb
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Family::InvitationPolicy, type: :policy do
|
||||||
|
let(:family) { create(:family) }
|
||||||
|
let(:owner) { family.creator }
|
||||||
|
let(:member) { create(:user) }
|
||||||
|
let(:other_user) { create(:user) }
|
||||||
|
let(:invitation) { create(:family_invitation, family: family, invited_by: owner) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Set up family membership for owner
|
||||||
|
create(:family_membership, family: family, user: owner, role: :owner)
|
||||||
|
# Set up family membership for regular member
|
||||||
|
create(:family_membership, family: family, user: member, role: :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#show?' do
|
||||||
|
context 'with authenticated user' do
|
||||||
|
it 'allows any authenticated user to view invitation' do
|
||||||
|
policy = Family::InvitationPolicy.new(owner, invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:show)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows other users to view invitation' do
|
||||||
|
policy = Family::InvitationPolicy.new(other_user, invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:show)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with unauthenticated user' do
|
||||||
|
it 'allows unauthenticated access (public endpoint)' do
|
||||||
|
policy = Family::InvitationPolicy.new(nil, invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:show)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#create?' do
|
||||||
|
context 'when user is family owner' do
|
||||||
|
before do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows family owner to create invitations' do
|
||||||
|
policy = Family::InvitationPolicy.new(owner, invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:create)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is regular family member' do
|
||||||
|
before do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
allow(member).to receive(:family_owner?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies regular family member from creating invitations' do
|
||||||
|
policy = Family::InvitationPolicy.new(member, invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:create)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not in the family' do
|
||||||
|
it 'denies user not in the family from creating invitations' do
|
||||||
|
policy = Family::InvitationPolicy.new(other_user, invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:create)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with unauthenticated user' do
|
||||||
|
it 'denies unauthenticated user from creating invitations' do
|
||||||
|
policy = Family::InvitationPolicy.new(nil, invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:create)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#accept?' do
|
||||||
|
context 'when user email matches invitation email' do
|
||||||
|
let(:invited_user) { create(:user, email: invitation.email) }
|
||||||
|
|
||||||
|
it 'allows user to accept invitation sent to their email' do
|
||||||
|
policy = Family::InvitationPolicy.new(invited_user, invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:accept)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user email does not match invitation email' do
|
||||||
|
it 'denies user with different email from accepting invitation' do
|
||||||
|
policy = Family::InvitationPolicy.new(other_user, invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:accept)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when family owner tries to accept invitation' do
|
||||||
|
it 'denies family owner from accepting invitation sent to different email' do
|
||||||
|
policy = Family::InvitationPolicy.new(owner, invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:accept)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with unauthenticated user' do
|
||||||
|
it 'denies unauthenticated user from accepting invitation' do
|
||||||
|
policy = Family::InvitationPolicy.new(nil, invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:accept)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#destroy?' do
|
||||||
|
context 'when user is family owner' do
|
||||||
|
before do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows family owner to cancel invitations' do
|
||||||
|
policy = Family::InvitationPolicy.new(owner, invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is regular family member' do
|
||||||
|
before do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
allow(member).to receive(:family_owner?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies regular family member from cancelling invitations' do
|
||||||
|
policy = Family::InvitationPolicy.new(member, invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not in the family' do
|
||||||
|
it 'denies user not in the family from cancelling invitations' do
|
||||||
|
policy = Family::InvitationPolicy.new(other_user, invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with unauthenticated user' do
|
||||||
|
it 'denies unauthenticated user from cancelling invitations' do
|
||||||
|
policy = Family::InvitationPolicy.new(nil, invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'edge cases' do
|
||||||
|
context 'when invitation belongs to different family' do
|
||||||
|
let(:other_family) { create(:family) }
|
||||||
|
let(:other_family_owner) { other_family.creator }
|
||||||
|
let(:other_invitation) { create(:family_invitation, family: other_family, invited_by: other_family_owner) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:family_membership, family: other_family, user: other_family_owner, role: :owner)
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies owner from creating invitations for different family' do
|
||||||
|
policy = Family::InvitationPolicy.new(owner, other_invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:create)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies owner from destroying invitations for different family' do
|
||||||
|
policy = Family::InvitationPolicy.new(owner, other_invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with expired invitation' do
|
||||||
|
let(:expired_invitation) { create(:family_invitation, :expired, family: family, invited_by: owner) }
|
||||||
|
let(:invited_user) { create(:user, email: expired_invitation.email) }
|
||||||
|
|
||||||
|
it 'still allows user to attempt to accept expired invitation (business logic handles expiry)' do
|
||||||
|
policy = Family::InvitationPolicy.new(invited_user, expired_invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:accept)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows owner to destroy expired invitation' do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
policy = Family::InvitationPolicy.new(owner, expired_invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with accepted invitation' do
|
||||||
|
let(:accepted_invitation) { create(:family_invitation, :accepted, family: family, invited_by: owner) }
|
||||||
|
|
||||||
|
it 'allows owner to destroy accepted invitation' do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
policy = Family::InvitationPolicy.new(owner, accepted_invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with cancelled invitation' do
|
||||||
|
let(:cancelled_invitation) { create(:family_invitation, :cancelled, family: family, invited_by: owner) }
|
||||||
|
|
||||||
|
it 'allows owner to destroy cancelled invitation' do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
policy = Family::InvitationPolicy.new(owner, cancelled_invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'authorization consistency' do
|
||||||
|
it 'ensures owner can both create and destroy invitations' do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
policy = Family::InvitationPolicy.new(owner, invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:create)
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ensures regular members cannot create or destroy invitations' do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
allow(member).to receive(:family_owner?).and_return(false)
|
||||||
|
policy = Family::InvitationPolicy.new(member, invitation)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:create)
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ensures invited users can only accept their own invitations' do
|
||||||
|
invited_user = create(:user, email: invitation.email)
|
||||||
|
policy = Family::InvitationPolicy.new(invited_user, invitation)
|
||||||
|
|
||||||
|
expect(policy).to permit(:accept)
|
||||||
|
expect(policy).not_to permit(:create)
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
312
spec/policies/family/membership_policy_spec.rb
Normal file
312
spec/policies/family/membership_policy_spec.rb
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Family::MembershipPolicy, type: :policy do
|
||||||
|
let(:family) { create(:family) }
|
||||||
|
let(:owner) { family.creator }
|
||||||
|
let(:member) { create(:user) }
|
||||||
|
let(:another_member) { create(:user) }
|
||||||
|
let(:other_user) { create(:user) }
|
||||||
|
|
||||||
|
let(:owner_membership) { create(:family_membership, :owner, family: family, user: owner) }
|
||||||
|
let(:member_membership) { create(:family_membership, family: family, user: member) }
|
||||||
|
let(:another_member_membership) { create(:family_membership, family: family, user: another_member) }
|
||||||
|
|
||||||
|
describe '#show?' do
|
||||||
|
context 'when user is in the same family' do
|
||||||
|
before do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows family owner to view member details' do
|
||||||
|
policy = Family::MembershipPolicy.new(owner, member_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:show)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows family owner to view their own membership' do
|
||||||
|
policy = Family::MembershipPolicy.new(owner, owner_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:show)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows regular member to view other members' do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
policy = Family::MembershipPolicy.new(member, another_member_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:show)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows member to view their own membership' do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
policy = Family::MembershipPolicy.new(member, member_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:show)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not in the same family' do
|
||||||
|
it 'denies user from different family from viewing membership' do
|
||||||
|
policy = Family::MembershipPolicy.new(other_user, member_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:show)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with unauthenticated user' do
|
||||||
|
it 'denies unauthenticated user from viewing membership' do
|
||||||
|
policy = Family::MembershipPolicy.new(nil, member_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:show)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#update?' do
|
||||||
|
context 'when user is updating their own membership' do
|
||||||
|
it 'allows user to update their own membership settings' do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
policy = Family::MembershipPolicy.new(member, member_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:update)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows owner to update their own membership' do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
policy = Family::MembershipPolicy.new(owner, owner_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:update)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is family owner' do
|
||||||
|
before do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows family owner to update other members settings' do
|
||||||
|
policy = Family::MembershipPolicy.new(owner, member_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:update)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows family owner to update multiple members' do
|
||||||
|
policy1 = Family::MembershipPolicy.new(owner, member_membership)
|
||||||
|
policy2 = Family::MembershipPolicy.new(owner, another_member_membership)
|
||||||
|
|
||||||
|
expect(policy1).to permit(:update)
|
||||||
|
expect(policy2).to permit(:update)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is regular family member' do
|
||||||
|
before do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
allow(member).to receive(:family_owner?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies regular member from updating other members settings' do
|
||||||
|
policy = Family::MembershipPolicy.new(member, another_member_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:update)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies regular member from updating owner settings' do
|
||||||
|
policy = Family::MembershipPolicy.new(member, owner_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:update)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not in the family' do
|
||||||
|
it 'denies user from updating membership of different family' do
|
||||||
|
policy = Family::MembershipPolicy.new(other_user, member_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:update)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with unauthenticated user' do
|
||||||
|
it 'denies unauthenticated user from updating membership' do
|
||||||
|
policy = Family::MembershipPolicy.new(nil, member_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:update)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#destroy?' do
|
||||||
|
context 'when user is removing themselves' do
|
||||||
|
it 'allows user to remove their own membership (leave family)' do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
policy = Family::MembershipPolicy.new(member, member_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows owner to remove their own membership' do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
policy = Family::MembershipPolicy.new(owner, owner_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is family owner' do
|
||||||
|
before do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows family owner to remove other members' do
|
||||||
|
policy = Family::MembershipPolicy.new(owner, member_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows family owner to remove multiple members' do
|
||||||
|
policy1 = Family::MembershipPolicy.new(owner, member_membership)
|
||||||
|
policy2 = Family::MembershipPolicy.new(owner, another_member_membership)
|
||||||
|
|
||||||
|
expect(policy1).to permit(:destroy)
|
||||||
|
expect(policy2).to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is regular family member' do
|
||||||
|
before do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
allow(member).to receive(:family_owner?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies regular member from removing other members' do
|
||||||
|
policy = Family::MembershipPolicy.new(member, another_member_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies regular member from removing owner' do
|
||||||
|
policy = Family::MembershipPolicy.new(member, owner_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not in the family' do
|
||||||
|
it 'denies user from removing membership of different family' do
|
||||||
|
policy = Family::MembershipPolicy.new(other_user, member_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with unauthenticated user' do
|
||||||
|
it 'denies unauthenticated user from removing membership' do
|
||||||
|
policy = Family::MembershipPolicy.new(nil, member_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'edge cases' do
|
||||||
|
context 'when membership belongs to different family' do
|
||||||
|
let(:other_family) { create(:family) }
|
||||||
|
let(:other_family_owner) { other_family.creator }
|
||||||
|
let(:other_family_membership) do
|
||||||
|
create(:family_membership, :owner, family: other_family, user: other_family_owner)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies owner from viewing membership of different family' do
|
||||||
|
policy = Family::MembershipPolicy.new(owner, other_family_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:show)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies owner from updating membership of different family' do
|
||||||
|
policy = Family::MembershipPolicy.new(owner, other_family_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:update)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'denies owner from destroying membership of different family' do
|
||||||
|
policy = Family::MembershipPolicy.new(owner, other_family_membership)
|
||||||
|
|
||||||
|
expect(policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when owner tries to modify another owners membership' do
|
||||||
|
let(:co_owner) { create(:user) }
|
||||||
|
let(:co_owner_membership) { create(:family_membership, :owner, family: family, user: co_owner) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows owner to view another owner' do
|
||||||
|
policy = Family::MembershipPolicy.new(owner, co_owner_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:show)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows owner to update another owner (family owner has full control)' do
|
||||||
|
policy = Family::MembershipPolicy.new(owner, co_owner_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:update)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows owner to remove another owner (family owner has full control)' do
|
||||||
|
policy = Family::MembershipPolicy.new(owner, co_owner_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'authorization consistency' do
|
||||||
|
it 'ensures owner can view, update, and destroy all memberships in their family' do
|
||||||
|
allow(owner).to receive(:family).and_return(family)
|
||||||
|
allow(owner).to receive(:family_owner?).and_return(true)
|
||||||
|
|
||||||
|
policy = Family::MembershipPolicy.new(owner, member_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:show)
|
||||||
|
expect(policy).to permit(:update)
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ensures regular members can only manage their own membership' do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
allow(member).to receive(:family_owner?).and_return(false)
|
||||||
|
|
||||||
|
own_policy = Family::MembershipPolicy.new(member, member_membership)
|
||||||
|
other_policy = Family::MembershipPolicy.new(member, another_member_membership)
|
||||||
|
|
||||||
|
# Can manage own membership
|
||||||
|
expect(own_policy).to permit(:show)
|
||||||
|
expect(own_policy).to permit(:update)
|
||||||
|
expect(own_policy).to permit(:destroy)
|
||||||
|
|
||||||
|
# Can view but not manage others
|
||||||
|
expect(other_policy).to permit(:show)
|
||||||
|
expect(other_policy).not_to permit(:update)
|
||||||
|
expect(other_policy).not_to permit(:destroy)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ensures users can always leave the family (remove own membership)' do
|
||||||
|
allow(member).to receive(:family).and_return(family)
|
||||||
|
policy = Family::MembershipPolicy.new(member, member_membership)
|
||||||
|
|
||||||
|
expect(policy).to permit(:destroy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in a new issue