diff --git a/app/controllers/api/v1/families_controller.rb b/app/controllers/api/v1/families_controller.rb index 4ffe2450..0afb9f51 100644 --- a/app/controllers/api/v1/families_controller.rb +++ b/app/controllers/api/v1/families_controller.rb @@ -17,14 +17,14 @@ class Api::V1::FamiliesController < ApiController private def ensure_family_feature_enabled! - unless DawarichSettings.family_feature_enabled? - render json: { error: 'Family feature is not enabled' }, status: :forbidden - end + return if DawarichSettings.family_feature_enabled? + + render json: { error: 'Family feature is not enabled' }, status: :forbidden end def ensure_user_in_family! - unless current_api_user.in_family? - render json: { error: 'User is not part of a family' }, status: :forbidden - end + return if current_api_user.in_family? + + render json: { error: 'User is not part of a family' }, status: :forbidden end end diff --git a/app/controllers/families_controller.rb b/app/controllers/families_controller.rb index 4135f978..59dd9e9f 100644 --- a/app/controllers/families_controller.rb +++ b/app/controllers/families_controller.rb @@ -3,38 +3,36 @@ class FamiliesController < ApplicationController before_action :authenticate_user! before_action :ensure_family_feature_enabled! - before_action :set_family, only: %i[show edit update destroy leave update_location_sharing] - - def index - redirect_to family_path(current_user.family) if current_user.in_family? - end + before_action :set_family, only: %i[show edit update destroy update_location_sharing] def show authorize @family - # Use optimized family methods for better performance @members = @family.members.includes(:family_membership).order(:email) @pending_invitations = @family.active_invitations.order(:created_at) - # Use cached counts to avoid extra queries @member_count = @family.member_count @can_invite = @family.can_add_members? end def new - redirect_to family_path(current_user.family) if current_user.in_family? + redirect_to family_path and return if current_user.in_family? @family = Family.new + authorize @family end def create + @family = Family.new(family_params) + authorize @family + service = Families::Create.new( user: current_user, name: family_params[:name] ) if service.call - redirect_to family_path(service.family), notice: 'Family created successfully!' + redirect_to family_path, notice: 'Family created successfully!' else @family = Family.new(family_params) @@ -63,7 +61,7 @@ class FamiliesController < ApplicationController authorize @family if @family.update(family_params) - redirect_to family_path(@family), notice: 'Family updated successfully!' + redirect_to family_path, notice: 'Family updated successfully!' else render :edit, status: :unprocessable_content end @@ -73,31 +71,14 @@ class FamiliesController < ApplicationController authorize @family if @family.members.count > 1 - redirect_to family_path(@family), alert: 'Cannot delete family with members. Remove all members first.' + redirect_to family_path, alert: 'Cannot delete family with members. Remove all members first.' else @family.destroy - redirect_to families_path, notice: 'Family deleted successfully!' + redirect_to new_family_path, notice: 'Family deleted successfully!' end end - def leave - authorize @family, :leave? - - service = Families::Leave.new(user: current_user) - - if service.call - redirect_to families_path, notice: 'You have left the family' - else - redirect_to family_path(@family), alert: service.error_message || 'Cannot leave family.' - end - rescue Pundit::NotAuthorizedError - # Handle case where owner with members tries to leave - redirect_to family_path(@family), - alert: 'You cannot leave the family while you are the owner and there are other members. Remove all members first or transfer ownership.' - end - def update_location_sharing - # No authorization needed - users can control their own location sharing enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled]) duration = params[:duration] @@ -109,7 +90,6 @@ class FamiliesController < ApplicationController message: build_sharing_message(enabled, duration) } - # Add expiration info if sharing is time-limited if enabled && current_user.family_sharing_expires_at.present? response_data[:expires_at] = current_user.family_sharing_expires_at.iso8601 response_data[:expires_at_formatted] = current_user.family_sharing_expires_at.strftime('%b %d at %I:%M %p') @@ -158,7 +138,7 @@ class FamiliesController < ApplicationController def set_family @family = current_user.family - redirect_to families_path unless @family + redirect_to new_family_path, alert: 'You are not in a family' unless @family end def family_params diff --git a/app/controllers/family/invitations_controller.rb b/app/controllers/family/invitations_controller.rb index 8763374f..0dccb414 100644 --- a/app/controllers/family/invitations_controller.rb +++ b/app/controllers/family/invitations_controller.rb @@ -3,9 +3,10 @@ class Family::InvitationsController < ApplicationController before_action :authenticate_user!, except: %i[show accept] before_action :ensure_family_feature_enabled!, except: %i[show accept] + before_action :set_invitation_by_token, only: %i[show] + before_action :set_invitation_by_id, only: %i[accept] before_action :set_family, except: %i[show accept] - before_action :set_invitation_by_token, only: %i[show accept] - before_action :set_invitation_by_id, only: %i[destroy] + before_action :set_invitation_by_id_and_family, only: %i[destroy] def index authorize @family, :show? @@ -37,9 +38,9 @@ class Family::InvitationsController < ApplicationController ) if service.call - redirect_to family_path(@family), notice: 'Invitation sent successfully!' + redirect_to family_path, notice: 'Invitation sent successfully!' else - redirect_to family_path(@family), alert: service.error_message || 'Failed to send invitation' + redirect_to family_path, alert: service.error_message || 'Failed to send invitation' end end @@ -65,8 +66,7 @@ class Family::InvitationsController < ApplicationController ) if service.call - redirect_to family_path(current_user.reload.family), - notice: 'Welcome to the family!' + redirect_to family_path, notice: 'Welcome to the family!' else redirect_to root_path, alert: service.error_message || 'Unable to accept invitation' end @@ -80,16 +80,13 @@ class Family::InvitationsController < ApplicationController begin if @invitation.update(status: :cancelled) - redirect_to family_path(@family), - notice: 'Invitation cancelled' + redirect_to family_path, notice: 'Invitation cancelled' else - redirect_to family_path(@family), - alert: 'Failed to cancel invitation. Please try again' + redirect_to family_path, alert: 'Failed to cancel invitation. Please try again' end rescue StandardError => e Rails.logger.error "Error cancelling family invitation: #{e.message}" - redirect_to family_path(@family), - alert: 'An unexpected error occurred while cancelling the invitation' + redirect_to family_path, alert: 'An unexpected error occurred while cancelling the invitation' end end @@ -104,15 +101,25 @@ class Family::InvitationsController < ApplicationController def set_family @family = current_user.family - redirect_to families_path, alert: 'Family not found' and return unless @family + redirect_to new_family_path, alert: 'You are not in a family' and return unless @family end def set_invitation_by_token - @invitation = FamilyInvitation.find_by!(token: params[:id]) + # For public unauthenticated route: /invitations/:token + @invitation = FamilyInvitation.find_by!(token: params[:token]) end def set_invitation_by_id - @invitation = @family.family_invitations.find(params[:id]) + # For authenticated nested routes without family validation: /families/:family_id/invitations/:id/accept + # The :id param contains the token value + @invitation = FamilyInvitation.find_by!(token: params[:id]) + end + + def set_invitation_by_id_and_family + # For authenticated nested routes: /families/:family_id/invitations/:id + # The :id param contains the token value + @family = current_user.family + @invitation = @family.family_invitations.find_by!(token: params[:id]) end def invitation_params diff --git a/app/controllers/family/memberships_controller.rb b/app/controllers/family/memberships_controller.rb index 90e8ea59..3577ee91 100644 --- a/app/controllers/family/memberships_controller.rb +++ b/app/controllers/family/memberships_controller.rb @@ -9,13 +9,19 @@ class Family::MembershipsController < ApplicationController def destroy authorize @membership - if @membership.owner? - redirect_to family_path(@family), - alert: 'Family owners cannot remove their own membership. To leave the family, delete it instead.' + member_user = @membership.user + service = Families::Memberships::Destroy.new(user: current_user, member_to_remove: member_user) + + if service.call + if member_user == current_user + # User removed themselves + redirect_to new_family_path, notice: 'You have left the family' + else + # Owner removed another member + redirect_to family_path, notice: "#{member_user.email} has been removed from the family" + end else - member_email = @membership.user.email - @membership.destroy! - redirect_to family_path(@family), notice: "#{member_email} has been removed from the family" + redirect_to family_path, alert: service.error_message || 'Failed to remove member' end end @@ -30,7 +36,7 @@ class Family::MembershipsController < ApplicationController def set_family @family = current_user.family - redirect_to families_path, alert: 'Family not found' and return unless @family + redirect_to new_family_path, alert: 'You are not in a family' and return unless @family end def set_membership diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index b45273f4..cf4540c4 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -13,7 +13,6 @@ class MapController < ApplicationController @years = years_range @points_number = points_count @features = DawarichSettings.features - @family_member_locations = family_member_locations end private @@ -94,8 +93,4 @@ class MapController < ApplicationController def points_from_user current_user.points.without_raw_data.order(timestamp: :asc) end - - def family_member_locations - Families::Locations.new(current_user).call - end end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 94c1782a..c3926413 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -2,17 +2,15 @@ class Users::RegistrationsController < Devise::RegistrationsController before_action :check_registration_allowed, only: [:new, :create] - before_action :load_invitation_context, only: [:new, :create] + before_action :set_invitation, only: [:new, :create] def new build_resource({}) - # Pre-fill email if invitation exists - if @invitation - resource.email = @invitation.email - end + resource.email = @invitation.email if @invitation yield resource if block_given? + respond_with resource end @@ -45,22 +43,18 @@ class Users::RegistrationsController < Devise::RegistrationsController private def check_registration_allowed - return true unless self_hosted_mode? + return true if DawarichSettings.self_hosted? return true if valid_invitation_token? redirect_to root_path, alert: 'Registration is not available. Please contact your administrator for access.' end - def load_invitation_context + def set_invitation return unless invitation_token.present? @invitation = FamilyInvitation.find_by(token: invitation_token) end - def self_hosted_mode? - ENV['SELF_HOSTED'] == 'true' - end - def valid_invitation_token? return false unless invitation_token.present? @@ -77,7 +71,6 @@ class Users::RegistrationsController < Devise::RegistrationsController def accept_invitation_for_user(user) return unless @invitation&.can_be_accepted? - # Use the existing invitation acceptance service service = Families::AcceptInvitation.new( invitation: @invitation, user: user @@ -96,4 +89,4 @@ class Users::RegistrationsController < Devise::RegistrationsController def sign_up_params super end -end \ No newline at end of file +end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 092bd5d3..58e5984a 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -10,9 +10,9 @@ class Users::SessionsController < Devise::SessionsController protected def after_sign_in_path_for(resource) - # If there's an invitation token, redirect to the invitation page if invitation_token.present? invitation = FamilyInvitation.find_by(token: invitation_token) + if invitation&.can_be_accepted? return family_invitation_path(invitation.token) end @@ -32,4 +32,4 @@ class Users::SessionsController < Devise::SessionsController def invitation_token @invitation_token ||= params[:invitation_token] || session[:invitation_token] end -end \ No newline at end of file +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 211fac44..4e715f28 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,15 +1,6 @@ # frozen_string_literal: true module ApplicationHelper - def classes_for_flash(flash_type) - case flash_type.to_sym - when :error - 'bg-red-100 text-red-700 border-red-300' - else - 'bg-blue-100 text-blue-700 border-blue-300' - end - end - def flash_alert_class(type) case type.to_sym when :notice, :success diff --git a/app/javascript/controllers/family_actions_controller.js b/app/javascript/controllers/family_actions_controller.js deleted file mode 100644 index 2b59dd8c..00000000 --- a/app/javascript/controllers/family_actions_controller.js +++ /dev/null @@ -1,81 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["confirmButton", "cancelButton"] - static values = { - action: String, - memberEmail: String, - familyName: String - } - - connect() { - this.setupConfirmationMessages() - } - - setupConfirmationMessages() { - const confirmButtons = this.element.querySelectorAll('[data-confirm]') - - confirmButtons.forEach(button => { - button.addEventListener('click', (event) => { - const action = button.dataset.action - const confirmMessage = this.getConfirmationMessage(action) - - if (!confirm(confirmMessage)) { - event.preventDefault() - return false - } - }) - }) - } - - getConfirmationMessage(action) { - switch(action) { - case 'leave-family': - return `Are you sure you want to leave "${this.familyNameValue}"? You'll need a new invitation to rejoin.` - case 'delete-family': - return `Are you sure you want to delete "${this.familyNameValue}"? This action cannot be undone.` - case 'remove-member': - return `Are you sure you want to remove ${this.memberEmailValue} from the family?` - case 'cancel-invitation': - return `Are you sure you want to cancel the invitation to ${this.memberEmailValue}?` - default: - return 'Are you sure you want to perform this action?' - } - } - - showLoadingState(button, action) { - const originalText = button.innerHTML - button.disabled = true - - const loadingText = this.getLoadingText(action) - button.innerHTML = ` - - ${loadingText} - ` - - // Store original text to restore if needed - button.dataset.originalText = originalText - } - - getLoadingText(action) { - switch(action) { - case 'leave-family': - return 'Leaving family...' - case 'delete-family': - return 'Deleting family...' - case 'remove-member': - return 'Removing member...' - case 'cancel-invitation': - return 'Cancelling invitation...' - default: - return 'Processing...' - } - } - - onConfirmedAction(event) { - const button = event.currentTarget - const action = button.dataset.action - - this.showLoadingState(button, action) - } -} diff --git a/app/javascript/controllers/family_invitation_controller.js b/app/javascript/controllers/family_invitation_controller.js deleted file mode 100644 index 500e5f22..00000000 --- a/app/javascript/controllers/family_invitation_controller.js +++ /dev/null @@ -1,66 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["form", "email", "submitButton", "errorMessage"] - static values = { maxMembers: Number, currentMembers: Number } - - connect() { - this.validateForm() - } - - validateForm() { - const email = this.emailTarget.value.trim() - const isValid = this.isValidEmail(email) && this.canInviteMoreMembers() - - this.submitButtonTarget.disabled = !isValid - - if (email && !this.isValidEmail(email)) { - this.showError("Please enter a valid email address") - } else if (!this.canInviteMoreMembers()) { - this.showError(`Family is full (${this.currentMembersValue}/${this.maxMembersValue} members)`) - } else { - this.hideError() - } - } - - onEmailInput() { - this.validateForm() - } - - isValidEmail(email) { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - return emailRegex.test(email) - } - - canInviteMoreMembers() { - return this.currentMembersValue < this.maxMembersValue - } - - showError(message) { - if (this.hasErrorMessageTarget) { - this.errorMessageTarget.textContent = message - this.errorMessageTarget.classList.remove("hidden") - } - } - - hideError() { - if (this.hasErrorMessageTarget) { - this.errorMessageTarget.classList.add("hidden") - } - } - - onSubmit(event) { - if (!this.isValidEmail(this.emailTarget.value.trim()) || !this.canInviteMoreMembers()) { - event.preventDefault() - this.validateForm() - return false - } - - // Show loading state - this.submitButtonTarget.disabled = true - this.submitButtonTarget.innerHTML = ` - - Sending invitation... - ` - } -} \ No newline at end of file diff --git a/app/javascript/controllers/flash_message_controller.js b/app/javascript/controllers/flash_message_controller.js deleted file mode 100644 index ce6b5bac..00000000 --- a/app/javascript/controllers/flash_message_controller.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static values = { - type: String, - autoDismiss: Boolean - } - - connect() { - this.element.style.animation = 'slideInFromRight 0.3s ease-out forwards' - - if (this.autoDismissValue) { - this.scheduleDismissal() - } - } - - scheduleDismissal() { - // Auto-dismiss success/notice messages after 5 seconds - this.dismissTimeout = setTimeout(() => { - this.dismiss() - }, 5000) - } - - dismiss() { - if (this.dismissTimeout) { - clearTimeout(this.dismissTimeout) - } - - this.element.style.animation = 'slideOutToRight 0.3s ease-in forwards' - - setTimeout(() => { - if (this.element.parentNode) { - this.element.parentNode.removeChild(this.element) - } - }, 300) - } - - disconnect() { - if (this.dismissTimeout) { - clearTimeout(this.dismissTimeout) - } - } -} \ No newline at end of file diff --git a/app/models/family.rb b/app/models/family.rb index 9c9260f9..2baaef41 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -20,7 +20,6 @@ class Family < ApplicationRecord end def member_count - # Cache the count to avoid repeated queries @member_count ||= members.count end diff --git a/app/services/families/leave.rb b/app/services/families/leave.rb deleted file mode 100644 index 7adcb8c6..00000000 --- a/app/services/families/leave.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -module Families - class Leave - attr_reader :user, :error_message - - def initialize(user:) - @user = user - @error_message = nil - end - - def call - return false unless validate_can_leave - - # Store family info before removing membership - @family_name = user.family.name - @family_owner = user.family.owner - - ActiveRecord::Base.transaction do - handle_ownership_transfer if user.family_owner? - remove_membership - send_notifications - end - - true - rescue ActiveRecord::RecordInvalid => e - handle_record_invalid_error(e) - false - rescue StandardError => e - handle_generic_error(e) - false - end - - private - - def validate_can_leave - return false unless validate_in_family - return false unless validate_owner_can_leave - - true - end - - def validate_in_family - return true if user.in_family? - - @error_message = 'You are not currently in a family.' - false - end - - def validate_owner_can_leave - return true unless user.family_owner? && family_has_other_members? - - @error_message = 'You cannot leave the family while you are the owner and there are ' \ - 'other members. Remove all members first or transfer ownership.' - false - end - - def family_has_other_members? - user.family.members.count > 1 - end - - def handle_ownership_transfer - # If this is the last member (owner), delete the family - return unless user.family.members.count == 1 - - user.family.destroy! - - # If owner tries to leave with other members, it should be prevented in validation - end - - def remove_membership - user.family_membership.destroy! - end - - def send_notifications - return unless defined?(Notification) - - # Notify the user who left - Notification.create!( - user: user, - 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: "#{user.email} has left 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) - Rails.logger.error "Unexpected error in Families::Leave: #{error.message}" - Rails.logger.error error.backtrace.join("\n") - @error_message = 'An unexpected error occurred while leaving the family. Please try again' - end - end -end diff --git a/app/services/families/memberships/destroy.rb b/app/services/families/memberships/destroy.rb new file mode 100644 index 00000000..5439f40e --- /dev/null +++ b/app/services/families/memberships/destroy.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +module Families + module Memberships + class Destroy + attr_reader :user, :member_to_remove, :error_message + + def initialize(user:, member_to_remove: nil) + @user = user # The user performing the action (current_user) + @member_to_remove = member_to_remove || user # The user being removed (defaults to self) + @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 + + true + rescue ActiveRecord::RecordInvalid => e + handle_record_invalid_error(e) + false + rescue StandardError => e + handle_generic_error(e) + false + end + + private + + def validate_can_leave + return false unless validate_in_family + return false unless validate_removal_allowed + + true + end + + def validate_in_family + return true if member_to_remove.in_family? + + @error_message = 'User is not currently in a family.' + false + end + + def validate_removal_allowed + # If removing self (user == member_to_remove) + if removing_self? + return validate_owner_can_leave + end + + # If removing another member, user must be owner and member must be in same family + return false unless validate_remover_is_owner + return false unless validate_same_family + return false unless validate_not_removing_owner + + true + end + + def removing_self? + 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 + + 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) + Rails.logger.error "Unexpected error in Families::Memberships::Destroy: #{error.message}" + Rails.logger.error error.backtrace.join("\n") + @error_message = 'An unexpected error occurred while removing the membership. Please try again' + end + end + end +end diff --git a/app/views/families/edit.html.erb b/app/views/families/edit.html.erb index e9f6e4ff..89451916 100644 --- a/app/views/families/edit.html.erb +++ b/app/views/families/edit.html.erb @@ -5,7 +5,7 @@