Use family path instead of families/id

This commit is contained in:
Eugene Burmakin 2025-10-04 22:39:47 +02:00
parent cfe319df9b
commit 9bc0e2accc
30 changed files with 419 additions and 1458 deletions

View file

@ -17,14 +17,14 @@ class Api::V1::FamiliesController < ApiController
private private
def ensure_family_feature_enabled! def ensure_family_feature_enabled!
unless DawarichSettings.family_feature_enabled? return if DawarichSettings.family_feature_enabled?
render json: { error: 'Family feature is not enabled' }, status: :forbidden
end render json: { error: 'Family feature is not enabled' }, status: :forbidden
end end
def ensure_user_in_family! def ensure_user_in_family!
unless current_api_user.in_family? return if current_api_user.in_family?
render json: { error: 'User is not part of a family' }, status: :forbidden
end render json: { error: 'User is not part of a family' }, status: :forbidden
end end
end end

View file

@ -3,38 +3,36 @@
class FamiliesController < ApplicationController class FamiliesController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :ensure_family_feature_enabled! before_action :ensure_family_feature_enabled!
before_action :set_family, only: %i[show edit update destroy leave update_location_sharing] before_action :set_family, only: %i[show edit update destroy update_location_sharing]
def index
redirect_to family_path(current_user.family) if current_user.in_family?
end
def show def show
authorize @family authorize @family
# Use optimized family methods for better performance
@members = @family.members.includes(:family_membership).order(:email) @members = @family.members.includes(:family_membership).order(:email)
@pending_invitations = @family.active_invitations.order(:created_at) @pending_invitations = @family.active_invitations.order(:created_at)
# Use cached counts to avoid extra queries
@member_count = @family.member_count @member_count = @family.member_count
@can_invite = @family.can_add_members? @can_invite = @family.can_add_members?
end end
def new 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 @family = Family.new
authorize @family
end end
def create def create
@family = Family.new(family_params)
authorize @family
service = Families::Create.new( service = Families::Create.new(
user: current_user, user: current_user,
name: family_params[:name] name: family_params[:name]
) )
if service.call if service.call
redirect_to family_path(service.family), notice: 'Family created successfully!' redirect_to family_path, notice: 'Family created successfully!'
else else
@family = Family.new(family_params) @family = Family.new(family_params)
@ -63,7 +61,7 @@ class FamiliesController < ApplicationController
authorize @family authorize @family
if @family.update(family_params) if @family.update(family_params)
redirect_to family_path(@family), notice: 'Family updated successfully!' redirect_to family_path, notice: 'Family updated successfully!'
else else
render :edit, status: :unprocessable_content render :edit, status: :unprocessable_content
end end
@ -73,31 +71,14 @@ class FamiliesController < ApplicationController
authorize @family authorize @family
if @family.members.count > 1 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 else
@family.destroy @family.destroy
redirect_to families_path, notice: 'Family deleted successfully!' redirect_to new_family_path, notice: 'Family deleted successfully!'
end end
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 def update_location_sharing
# No authorization needed - users can control their own location sharing
enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled]) enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
duration = params[:duration] duration = params[:duration]
@ -109,7 +90,6 @@ class FamiliesController < ApplicationController
message: build_sharing_message(enabled, duration) message: build_sharing_message(enabled, duration)
} }
# Add expiration info if sharing is time-limited
if enabled && current_user.family_sharing_expires_at.present? if enabled && current_user.family_sharing_expires_at.present?
response_data[:expires_at] = current_user.family_sharing_expires_at.iso8601 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') 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 def set_family
@family = current_user.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 end
def family_params def family_params

View file

@ -3,9 +3,10 @@
class Family::InvitationsController < ApplicationController class Family::InvitationsController < ApplicationController
before_action :authenticate_user!, except: %i[show accept] before_action :authenticate_user!, except: %i[show accept]
before_action :ensure_family_feature_enabled!, 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_family, except: %i[show accept]
before_action :set_invitation_by_token, only: %i[show accept] before_action :set_invitation_by_id_and_family, only: %i[destroy]
before_action :set_invitation_by_id, only: %i[destroy]
def index def index
authorize @family, :show? authorize @family, :show?
@ -37,9 +38,9 @@ class Family::InvitationsController < ApplicationController
) )
if service.call if service.call
redirect_to family_path(@family), notice: 'Invitation sent successfully!' redirect_to family_path, notice: 'Invitation sent successfully!'
else 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
end end
@ -65,8 +66,7 @@ class Family::InvitationsController < ApplicationController
) )
if service.call if service.call
redirect_to family_path(current_user.reload.family), redirect_to family_path, notice: 'Welcome to the family!'
notice: 'Welcome to the family!'
else else
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation' redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
end end
@ -80,16 +80,13 @@ class Family::InvitationsController < ApplicationController
begin begin
if @invitation.update(status: :cancelled) if @invitation.update(status: :cancelled)
redirect_to family_path(@family), redirect_to family_path, notice: 'Invitation cancelled'
notice: 'Invitation cancelled'
else else
redirect_to family_path(@family), redirect_to family_path, alert: 'Failed to cancel invitation. Please try again'
alert: 'Failed to cancel invitation. Please try again'
end end
rescue StandardError => e rescue StandardError => e
Rails.logger.error "Error cancelling family invitation: #{e.message}" Rails.logger.error "Error cancelling family invitation: #{e.message}"
redirect_to family_path(@family), redirect_to family_path, alert: 'An unexpected error occurred while cancelling the invitation'
alert: 'An unexpected error occurred while cancelling the invitation'
end end
end end
@ -104,15 +101,25 @@ class Family::InvitationsController < ApplicationController
def set_family def set_family
@family = current_user.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 end
def set_invitation_by_token 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 end
def set_invitation_by_id 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 end
def invitation_params def invitation_params

View file

@ -9,13 +9,19 @@ class Family::MembershipsController < ApplicationController
def destroy def destroy
authorize @membership authorize @membership
if @membership.owner? member_user = @membership.user
redirect_to family_path(@family), service = Families::Memberships::Destroy.new(user: current_user, member_to_remove: member_user)
alert: 'Family owners cannot remove their own membership. To leave the family, delete it instead.'
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 else
member_email = @membership.user.email redirect_to family_path, alert: service.error_message || 'Failed to remove member'
@membership.destroy!
redirect_to family_path(@family), notice: "#{member_email} has been removed from the family"
end end
end end
@ -30,7 +36,7 @@ class Family::MembershipsController < ApplicationController
def set_family def set_family
@family = current_user.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 end
def set_membership def set_membership

View file

@ -13,7 +13,6 @@ class MapController < ApplicationController
@years = years_range @years = years_range
@points_number = points_count @points_number = points_count
@features = DawarichSettings.features @features = DawarichSettings.features
@family_member_locations = family_member_locations
end end
private private
@ -94,8 +93,4 @@ class MapController < ApplicationController
def points_from_user def points_from_user
current_user.points.without_raw_data.order(timestamp: :asc) current_user.points.without_raw_data.order(timestamp: :asc)
end end
def family_member_locations
Families::Locations.new(current_user).call
end
end end

View file

@ -2,17 +2,15 @@
class Users::RegistrationsController < Devise::RegistrationsController class Users::RegistrationsController < Devise::RegistrationsController
before_action :check_registration_allowed, only: [:new, :create] 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 def new
build_resource({}) build_resource({})
# Pre-fill email if invitation exists resource.email = @invitation.email if @invitation
if @invitation
resource.email = @invitation.email
end
yield resource if block_given? yield resource if block_given?
respond_with resource respond_with resource
end end
@ -45,22 +43,18 @@ class Users::RegistrationsController < Devise::RegistrationsController
private private
def check_registration_allowed def check_registration_allowed
return true unless self_hosted_mode? return true if DawarichSettings.self_hosted?
return true if valid_invitation_token? return true if valid_invitation_token?
redirect_to root_path, alert: 'Registration is not available. Please contact your administrator for access.' redirect_to root_path, alert: 'Registration is not available. Please contact your administrator for access.'
end end
def load_invitation_context def set_invitation
return unless invitation_token.present? return unless invitation_token.present?
@invitation = FamilyInvitation.find_by(token: invitation_token) @invitation = FamilyInvitation.find_by(token: invitation_token)
end end
def self_hosted_mode?
ENV['SELF_HOSTED'] == 'true'
end
def valid_invitation_token? def valid_invitation_token?
return false unless invitation_token.present? return false unless invitation_token.present?
@ -77,7 +71,6 @@ class Users::RegistrationsController < Devise::RegistrationsController
def accept_invitation_for_user(user) def accept_invitation_for_user(user)
return unless @invitation&.can_be_accepted? return unless @invitation&.can_be_accepted?
# Use the existing invitation acceptance service
service = Families::AcceptInvitation.new( service = Families::AcceptInvitation.new(
invitation: @invitation, invitation: @invitation,
user: user user: user
@ -96,4 +89,4 @@ class Users::RegistrationsController < Devise::RegistrationsController
def sign_up_params def sign_up_params
super super
end end
end end

View file

@ -10,9 +10,9 @@ class Users::SessionsController < Devise::SessionsController
protected protected
def after_sign_in_path_for(resource) def after_sign_in_path_for(resource)
# If there's an invitation token, redirect to the invitation page
if invitation_token.present? if invitation_token.present?
invitation = FamilyInvitation.find_by(token: invitation_token) invitation = FamilyInvitation.find_by(token: invitation_token)
if invitation&.can_be_accepted? if invitation&.can_be_accepted?
return family_invitation_path(invitation.token) return family_invitation_path(invitation.token)
end end
@ -32,4 +32,4 @@ class Users::SessionsController < Devise::SessionsController
def invitation_token def invitation_token
@invitation_token ||= params[:invitation_token] || session[:invitation_token] @invitation_token ||= params[:invitation_token] || session[:invitation_token]
end end
end end

View file

@ -1,15 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module ApplicationHelper 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) def flash_alert_class(type)
case type.to_sym case type.to_sym
when :notice, :success when :notice, :success

View file

@ -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 = `
<span class="loading loading-spinner loading-sm"></span>
${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)
}
}

View file

@ -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 = `
<span class="loading loading-spinner loading-sm"></span>
Sending invitation...
`
}
}

View file

@ -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)
}
}
}

View file

@ -20,7 +20,6 @@ class Family < ApplicationRecord
end end
def member_count def member_count
# Cache the count to avoid repeated queries
@member_count ||= members.count @member_count ||= members.count
end end

View file

@ -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

View file

@ -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

View file

@ -5,7 +5,7 @@
<h1 class="text-2xl font-bold text-base-content"> <h1 class="text-2xl font-bold text-base-content">
<%= t('families.edit.title', default: 'Edit Family') %> <%= t('families.edit.title', default: 'Edit Family') %>
</h1> </h1>
<%= link_to family_path(@family), <%= link_to family_path,
class: "btn btn-ghost" do %> class: "btn btn-ghost" do %>
<%= t('families.edit.back', default: '← Back to Family') %> <%= t('families.edit.back', default: '← Back to Family') %>
<% end %> <% end %>
@ -77,14 +77,14 @@
<div class="flex space-x-3"> <div class="flex space-x-3">
<%= form.submit t('families.edit.save_changes', default: 'Save Changes'), <%= form.submit t('families.edit.save_changes', default: 'Save Changes'),
class: "btn btn-primary" %> class: "btn btn-primary" %>
<%= link_to family_path(@family), <%= link_to family_path,
class: "btn btn-neutral" do %> class: "btn btn-neutral" do %>
<%= t('families.edit.cancel', default: 'Cancel') %> <%= t('families.edit.cancel', default: 'Cancel') %>
<% end %> <% end %>
</div> </div>
<% if policy(@family).destroy? %> <% if policy(@family).destroy? %>
<%= link_to family_path(@family), <%= link_to family_path,
method: :delete, method: :delete,
data: { confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' }, data: { confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' },
class: "btn btn-outline btn-error" do %> class: "btn btn-outline btn-error" do %>

View file

@ -55,7 +55,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<%= form.submit t('families.new.create_family', default: 'Create Family'), <%= form.submit t('families.new.create_family', default: 'Create Family'),
class: "btn btn-primary" %> class: "btn btn-primary" %>
<%= link_to families_path, <%= link_to root_path,
class: "btn btn-ghost" do %> class: "btn btn-ghost" do %>
<%= t('families.new.back', default: '← Back') %> <%= t('families.new.back', default: '← Back') %>
<% end %> <% end %>

View file

@ -15,26 +15,26 @@
<div class="flex space-x-3"> <div class="flex space-x-3">
<% if policy(@family).update? %> <% if policy(@family).update? %>
<%= link_to edit_family_path(@family), <%= link_to edit_family_path,
class: "btn btn-outline btn-info" do %> class: "btn btn-outline btn-info" do %>
<%= icon 'square-pen', class: "inline-block w-4" %><%= t('families.show.edit', default: 'Edit') %> <%= icon 'square-pen', class: "inline-block w-4" %><%= t('families.show.edit', default: 'Edit') %>
<% end %> <% end %>
<% end %> <% end %>
<% if policy(@family).leave? && !current_user.family_owner? %> <% if !current_user.family_owner? && current_user.family_membership %>
<%= link_to leave_family_path(@family), <%= link_to family_member_path(current_user.family_membership),
method: :delete, method: :delete,
data: { confirm: 'Are you sure you want to leave this family?', turbo_confirm: 'Are you sure you want to leave this family?' }, data: { confirm: 'Are you sure you want to leave this family?', turbo_confirm: 'Are you sure you want to leave this family?' },
class: "btn btn-outline btn-warning" do %> class: "btn btn-outline btm-sm btn-warning" do %>
Leave Family Leave Family
<% end %> <% end %>
<% end %> <% end %>
<% if policy(@family).destroy? %> <% if policy(@family).destroy? %>
<%= link_to family_path(@family), <%= link_to family_path,
method: :delete, method: :delete,
data: { confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' }, data: { confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' },
class: "btn btn-outline btn-error" do %> class: "btn btn-outline btm-sm btn-error" do %>
<%= icon 'trash-2', class: "inline-block w-4" %> <%= icon 'trash-2', class: "inline-block w-4" %>
Delete Delete
<% end %> <% end %>
@ -187,7 +187,7 @@
</div> </div>
</div> </div>
<% if policy(@family).manage_invitations? %> <% if policy(@family).manage_invitations? %>
<%= link_to family_invitation_path(@family, invitation), <%= link_to family_invitation_path(invitation.token),
method: :delete, method: :delete,
data: { confirm: 'Are you sure you want to cancel this invitation?', turbo_confirm: 'Are you sure you want to cancel this invitation?' }, data: { confirm: 'Are you sure you want to cancel this invitation?', turbo_confirm: 'Are you sure you want to cancel this invitation?' },
class: "btn btn-outline btn-warning btn-sm opacity-70" do %> class: "btn btn-outline btn-warning btn-sm opacity-70" do %>

View file

@ -5,7 +5,7 @@
<h1 class="text-2xl font-bold text-base-content"> <h1 class="text-2xl font-bold text-base-content">
<%= t('family_invitations.index.title', default: 'Family Invitations') %> <%= t('family_invitations.index.title', default: 'Family Invitations') %>
</h1> </h1>
<%= link_to family_path(@family), <%= link_to family_path,
class: "btn btn-neutral" do %> class: "btn btn-neutral" do %>
<%= t('family_invitations.index.back_to_family', default: 'Back to Family') %> <%= t('family_invitations.index.back_to_family', default: 'Back to Family') %>
<% end %> <% end %>
@ -34,7 +34,7 @@
<% end %> <% end %>
<% if policy(@family).manage_invitations? %> <% if policy(@family).manage_invitations? %>
<%= link_to family_invitation_path(@family, invitation), <%= link_to family_invitation_path(invitation.token),
method: :delete, method: :delete,
confirm: t('family_invitations.index.cancel_confirm', default: 'Are you sure you want to cancel this invitation?'), confirm: t('family_invitations.index.cancel_confirm', default: 'Are you sure you want to cancel this invitation?'),
class: "btn btn-ghost btn-sm text-error" do %> class: "btn btn-ghost btn-sm text-error" do %>

View file

@ -1,12 +1,18 @@
<div class="fixed top-5 right-5 flex flex-col gap-2 z-50" id="flash-messages"> <div class="fixed top-5 right-5 flex flex-col gap-2 z-50" id="flash-messages">
<% flash.each do |key, value| %> <% flash.each do |key, value| %>
<div data-controller="removals" <div data-controller="removals"
data-removals-timeout-value="5000" data-removals-timeout-value="<%= key.to_sym.in?([:notice, :success]) ? 5000 : 0 %>"
class="flex items-center <%= classes_for_flash(key) %> py-3 px-5 rounded-lg z-[6000]"> role="alert"
<div class="mr-4"><%= value %></div> class="alert <%= flash_alert_class(key) %> shadow-lg z-[6000]">
<div class="flex items-center gap-2">
<button type="button" data-action="click->removals#remove"> <%= flash_icon(key) %>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <span><%= value %></span>
</div>
<button type="button"
data-action="click->removals#remove"
class="btn btn-sm btn-circle btn-ghost"
aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>

View file

@ -1,28 +0,0 @@
<% if flash.any? %>
<div class="flash-messages fixed top-4 right-4 z-50 space-y-2">
<% flash.each do |type, message| %>
<% next if message.blank? %>
<div class="alert <%= flash_alert_class(type) %> shadow-lg max-w-md"
data-controller="flash-message"
data-flash-message-type-value="<%= type %>"
data-flash-message-auto-dismiss-value="<%= %w[notice success].include?(type) %>">
<div class="flex items-center">
<%= flash_icon(type) %>
<div class="flex-1">
<div class="text-sm font-medium">
<%= message %>
</div>
</div>
<button type="button"
class="btn btn-ghost btn-sm btn-circle"
data-action="click->flash-message#dismiss"
aria-label="Dismiss">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<% end %>
</div>
<% end %>

View file

@ -13,7 +13,7 @@
<% if current_user.in_family? %> <% if current_user.in_family? %>
<div data-controller="family-navbar-indicator" <div data-controller="family-navbar-indicator"
data-family-navbar-indicator-enabled-value="<%= current_user.family_sharing_enabled? %>"> data-family-navbar-indicator-enabled-value="<%= current_user.family_sharing_enabled? %>">
<%= link_to family_path(current_user.family), class: "#{active_class?(families_path)} flex items-center space-x-2" do %> <%= link_to family_path, class: "#{active_class?(family_path)} flex items-center space-x-2" do %>
<span>Family</span> <span>Family</span>
<div data-family-navbar-indicator-target="indicator" <div data-family-navbar-indicator-target="indicator"
class="w-2 h-2 <%= current_user.family_sharing_enabled? ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %> rounded-full" class="w-2 h-2 <%= current_user.family_sharing_enabled? ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %> rounded-full"
@ -21,7 +21,7 @@
<% end %> <% end %>
</div> </div>
<% else %> <% else %>
<%= link_to 'Family', families_path, class: "#{active_class?(families_path)}" %> <%= link_to 'Family', new_family_path, class: "#{active_class?(new_family_path)}" %>
<% end %> <% end %>
</li> </li>
<% end %> <% end %>
@ -78,7 +78,7 @@
<% if current_user.in_family? %> <% if current_user.in_family? %>
<div data-controller="family-navbar-indicator" <div data-controller="family-navbar-indicator"
data-family-navbar-indicator-enabled-value="<%= current_user.family_sharing_enabled? %>"> data-family-navbar-indicator-enabled-value="<%= current_user.family_sharing_enabled? %>">
<%= link_to family_path(current_user.family), class: "mx-1 #{active_class?(families_path)} flex items-center space-x-2" do %> <%= link_to family_path, class: "mx-1 #{active_class?(family_path)} flex items-center space-x-2" do %>
<span>Family</span> <span>Family</span>
<div data-family-navbar-indicator-target="indicator" <div data-family-navbar-indicator-target="indicator"
class="w-2 h-2 <%= current_user.family_sharing_enabled? ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %> rounded-full" class="w-2 h-2 <%= current_user.family_sharing_enabled? ? 'bg-green-500 animate-pulse' : 'bg-gray-400' %> rounded-full"
@ -86,7 +86,7 @@
<% end %> <% end %>
</div> </div>
<% else %> <% else %>
<%= link_to 'Family', families_path, class: "mx-1 #{active_class?(families_path)}" %> <%= link_to 'Family', new_family_path, class: "mx-1 #{active_class?(new_family_path)}" %>
<% end %> <% end %>
</li> </li>
<% end %> <% end %>

View file

@ -40,23 +40,7 @@ class DawarichSettings
end end
def family_feature_enabled? def family_feature_enabled?
@family_feature_enabled ||= self_hosted? || family_subscription_active? @family_feature_enabled ||= self_hosted?
end
def family_subscription_active?
# Will be implemented when cloud subscriptions are added
# For now, return false for cloud instances to keep family feature
# available only for self-hosted instances
false
end
def family_feature_available_for?(user)
return true if self_hosted?
return false unless user
# For cloud instances, check if user has family subscription
# This will be implemented when subscription system is added
false
end end
def features def features

View file

@ -58,23 +58,21 @@ Rails.application.routes.draw do
resources :trips resources :trips
# Family management routes (only if feature is enabled) # Family management routes (only if feature is enabled)
# if DawarichSettings.family_feature_enabled? if DawarichSettings.family_feature_enabled?
resources :families do resource :family, only: %i[show new create edit update destroy] do
member do patch :update_location_sharing, on: :member
delete :leave
patch :update_location_sharing resources :invitations, except: %i[edit update], controller: 'family/invitations' do
end member do
resources :invitations, except: %i[edit update], controller: 'family/invitations' do post :accept
member do end
post :accept
end end
resources :members, only: %i[destroy], controller: 'family/memberships'
end end
resources :members, only: %i[destroy], controller: 'family/memberships'
get 'invitations/:token', to: 'family/invitations#show', as: :public_invitation
end end
# Public family invitation acceptance (no auth required)
get 'invitations/:id', to: 'family/invitations#show', as: :public_invitation
# end
resources :points, only: %i[index] do resources :points, only: %i[index] do
collection do collection do
delete :bulk_destroy delete :bulk_destroy

View file

@ -1,385 +0,0 @@
# Family Features Documentation
## Overview
The Family Features system allows users to create and manage family groups for shared location tracking and collaboration. This feature is designed with flexibility for both self-hosted and cloud deployments.
## Architecture
### Core Models
- **Family**: Central entity representing a family group
- **FamilyMembership**: Join table linking users to families with roles
- **FamilyInvitation**: Manages invitation flow for new family members
### Database Schema
```sql
-- families table
CREATE TABLE families (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
creator_id BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
-- family_memberships table
CREATE TABLE family_memberships (
id BIGSERIAL PRIMARY KEY,
family_id BIGINT NOT NULL REFERENCES families(id),
user_id BIGINT NOT NULL REFERENCES users(id),
role INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
-- family_invitations table
CREATE TABLE family_invitations (
id BIGSERIAL PRIMARY KEY,
family_id BIGINT NOT NULL REFERENCES families(id),
email VARCHAR(255) NOT NULL,
invited_by_id BIGINT NOT NULL REFERENCES users(id),
status INTEGER NOT NULL DEFAULT 0,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
```
### Performance Optimizations
The system includes several performance optimizations:
- **Database Indexes**: Optimized indexes for common queries
- **Caching**: Model-level caching for frequently accessed data
- **Background Jobs**: Asynchronous email processing
- **Query Optimization**: Includes and preloading for N+1 prevention
## Feature Gating
### Configuration
Family features can be enabled/disabled through `DawarichSettings`:
```ruby
# Check if family feature is enabled
DawarichSettings.family_feature_enabled?
# Check if feature is available for specific user
DawarichSettings.family_feature_available_for?(user)
```
### Deployment Types
- **Self-hosted**: Family features are enabled by default
- **Cloud hosted**: Features require subscription validation
- **Disabled**: All family routes and UI elements are hidden
## API Endpoints
### REST API
```
GET /families # List/redirect to user's family
GET /families/:id # Show family details
POST /families # Create new family
PATCH /families/:id # Update family
DELETE /families/:id # Delete family
DELETE /families/:id/leave # Leave family
# Family Invitations
GET /families/:family_id/invitations # List invitations
POST /families/:family_id/invitations # Send invitation
GET /families/:family_id/invitations/:id # Show invitation
DELETE /families/:family_id/invitations/:id # Cancel invitation
# Family Members
GET /families/:family_id/members # List members
GET /families/:family_id/members/:id # Show member
DELETE /families/:family_id/members/:id # Remove member
# Public Invitation Acceptance
GET /family_invitations/:token # Show invitation
POST /family_invitations/:token/accept # Accept invitation
POST /family_invitations/:token/decline # Decline invitation
```
### API Responses
All endpoints return consistent JSON responses:
```json
{
"success": true,
"data": { ... },
"errors": []
}
```
## Security
### Authorization
The system uses Pundit policies for authorization:
- **FamilyPolicy**: Controls family access and modifications
- **FamilyInvitationPolicy**: Manages invitation permissions
- **FamilyMembershipPolicy**: Controls member management
### Access Control
- Only family owners can send invitations
- Only family owners can remove members
- Members can only leave families voluntarily
- Invitations expire automatically for security
### Data Protection
- Email addresses in invitations are validated
- Invitation tokens are cryptographically secure
- User data is protected through proper authorization
## Error Handling
### Service Layer
All family services implement comprehensive error handling:
```ruby
class Families::Create
include ActiveModel::Validations
def call
return false unless valid?
# ... implementation
rescue ActiveRecord::RecordInvalid => e
handle_record_invalid_error(e)
false
rescue StandardError => e
handle_generic_error(e)
false
end
def error_message
return errors.full_messages.first if errors.any?
return @custom_error_message if @custom_error_message
'Operation failed'
end
end
```
### Error Types
- **Validation Errors**: Invalid input data
- **Authorization Errors**: Insufficient permissions
- **Business Logic Errors**: Family limits, existing memberships
- **System Errors**: Database, email delivery failures
## UI Components
### Interactive Elements
- **Family Creation Form**: Real-time validation
- **Invitation Management**: Dynamic invite sending
- **Member Management**: Role-based controls
- **Flash Messages**: Animated feedback system
### Stimulus Controllers
JavaScript controllers provide enhanced interactivity:
- `family_invitation_controller.js`: Invitation form validation
- `family_member_controller.js`: Member management actions
- `flash_message_controller.js`: Animated notifications
## Background Jobs
### Email Processing
```ruby
# Invitation emails are sent asynchronously
FamilyMailer.invitation(@invitation).deliver_later(
queue: :mailer,
retry: 3,
wait: 30.seconds
)
```
### Cleanup Jobs
```ruby
# Automatic cleanup of expired invitations
class FamilyInvitationsCleanupJob < ApplicationJob
def perform
# Update expired invitations
# Remove old expired/cancelled invitations
end
end
```
## Configuration
### Environment Variables
```bash
# Feature toggles
FAMILY_FEATURE_ENABLED=true
# Email configuration for invitations
SMTP_HOST=smtp.example.com
SMTP_USERNAME=user@example.com
SMTP_PASSWORD=secret
# Background job configuration
REDIS_URL=redis://localhost:6379/0
```
### Cron Jobs
```ruby
# config/schedule.rb
every 1.hour do
runner "FamilyInvitationsCleanupJob.perform_later"
end
```
## Testing
### Test Coverage
The family features include comprehensive test coverage:
- **Unit Tests**: Service classes, models, helpers
- **Integration Tests**: Controller actions, API endpoints
- **System Tests**: End-to-end user workflows
- **Job Tests**: Background job processing
### Test Patterns
```ruby
# Service testing pattern
RSpec.describe Families::Create do
describe '#call' do
context 'with valid parameters' do
it 'creates a family successfully' do
# ... test implementation
end
end
context 'with invalid parameters' do
it 'returns false and sets error message' do
# ... test implementation
end
end
end
end
```
## Deployment
### Database Migrations
Run migrations to set up family tables:
```bash
rails db:migrate
```
### Index Creation
Performance indexes are created concurrently:
```bash
# Handled automatically in migration
# Uses disable_ddl_transaction! for zero-downtime deployment
```
### Background Jobs
Ensure Sidekiq is running for email processing:
```bash
bundle exec sidekiq
```
### Cron Jobs
Set up periodic cleanup:
```bash
# Add to crontab or use whenever gem
0 * * * * cd /app && bundle exec rails runner "FamilyInvitationsCleanupJob.perform_later"
```
## Monitoring
### Metrics
Key metrics to monitor:
- Family creation rate
- Invitation acceptance rate
- Email delivery success rate
- Background job processing time
### Logging
Important events are logged:
```ruby
Rails.logger.info "Family created: #{family.id}"
Rails.logger.warn "Failed to send invitation email: #{error.message}"
Rails.logger.error "Unexpected error in family service: #{error.message}"
```
## Troubleshooting
### Common Issues
1. **Email Delivery Failures**
- Check SMTP configuration
- Verify email credentials
- Monitor Sidekiq queue
2. **Authorization Errors**
- Verify Pundit policies
- Check user permissions
- Review family membership status
3. **Performance Issues**
- Monitor database indexes
- Check query optimization
- Review caching implementation
### Debug Commands
```bash
# Check family feature status
rails console
> DawarichSettings.family_feature_enabled?
# Monitor background jobs
bundle exec sidekiq
> Sidekiq::Queue.new('mailer').size
# Check database indexes
rails dbconsole
> \d family_invitations
```
## Future Enhancements
### Planned Features
- **Family Statistics**: Shared analytics dashboard
- **Location Sharing**: Real-time family member locations
- **Group Trips**: Collaborative trip planning
- **Enhanced Permissions**: Granular access controls
### Scalability Considerations
- **Horizontal Scaling**: Stateless service design
- **Database Sharding**: Family-based data partitioning
- **Caching Strategy**: Redis-based family data caching
- **API Rate Limiting**: Per-family API quotas

View file

@ -1,417 +0,0 @@
# Family Features
Dawarich includes comprehensive family management features that allow users to create family groups, invite members, and collaborate on location tracking.
## Quick Start
### For Self-Hosted Deployments
Family features are enabled by default for self-hosted installations:
```bash
# Family features are automatically available
# No additional configuration required
```
### For Cloud Deployments
Family features require subscription validation:
```bash
# Contact support to enable family features
# Subscription-based access control
```
## Features Overview
### Family Management
- Create and name family groups
- Invite members via email
- Role-based permissions (owner/member)
- Member management and removal
### Invitation System
- Secure email-based invitations
- Automatic expiration (7 days)
- Token-based acceptance flow
- Cancellation and resending options
### Security & Privacy
- Authorization via Pundit policies
- Encrypted invitation tokens
- Email validation and verification
- Automatic cleanup of expired data
### Performance & Scalability
- Optimized database indexes
- Background job processing
- Intelligent caching strategies
- Concurrent database operations
## Getting Started
### Creating a Family
1. Navigate to the Families section
2. Click "Create Family"
3. Enter a family name
4. You become the family owner automatically
### Inviting Members
1. Go to your family page
2. Click "Invite Member"
3. Enter the email address
4. The invitation is sent automatically
5. Member receives email with acceptance link
### Accepting Invitations
1. Member receives invitation email
2. Clicks the invitation link
3. Must be logged in to Dawarich
4. Accepts or declines the invitation
5. Automatically joins the family if accepted
## API Documentation
### REST Endpoints
```bash
# List families or redirect to user's family
GET /families
# Show family details (requires authorization)
GET /families/:id
# Create new family
POST /families
Content-Type: application/json
{
"family": {
"name": "Smith Family"
}
}
# Update family name
PATCH /families/:id
Content-Type: application/json
{
"family": {
"name": "Updated Name"
}
}
# Delete family (owner only, requires empty family)
DELETE /families/:id
# Leave family (members only)
DELETE /families/:id/leave
# Send invitation
POST /families/:family_id/invitations
Content-Type: application/json
{
"invitation": {
"email": "member@example.com"
}
}
# Cancel invitation
DELETE /families/:family_id/invitations/:id
# Accept invitation (public endpoint)
POST /family_invitations/:token/accept
# Decline invitation (public endpoint)
POST /family_invitations/:token/decline
```
### API Responses
All endpoints return JSON responses:
```json
{
"success": true,
"data": {
"family": {
"id": 1,
"name": "Smith Family",
"member_count": 3,
"creator": {
"id": 1,
"email": "owner@example.com"
},
"members": [...],
"pending_invitations": [...]
}
},
"errors": []
}
```
## Configuration
### Environment Variables
```bash
# Enable/disable family features
FAMILY_FEATURE_ENABLED=true
# For cloud deployments - require subscription
FAMILY_SUBSCRIPTION_REQUIRED=true
# Email configuration for invitations
SMTP_HOST=smtp.example.com
SMTP_USERNAME=noreply@example.com
SMTP_PASSWORD=secret_password
# Background jobs
REDIS_URL=redis://localhost:6379/0
```
### Feature Gating
Family features can be controlled programmatically:
```ruby
# Check if features are enabled
DawarichSettings.family_feature_enabled?
# => true/false
# Check if available for specific user (cloud)
DawarichSettings.family_feature_available_for?(user)
# => true/false based on subscription
```
## Database Schema
### Core Tables
```sql
-- Main family entity
CREATE TABLE families (
id bigserial PRIMARY KEY,
name varchar(255) NOT NULL,
creator_id bigint NOT NULL REFERENCES users(id),
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL
);
-- User-family relationships with roles
CREATE TABLE family_memberships (
id bigserial PRIMARY KEY,
family_id bigint NOT NULL REFERENCES families(id),
user_id bigint NOT NULL REFERENCES users(id),
role integer NOT NULL DEFAULT 0, -- 0: member, 1: owner
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL,
UNIQUE(family_id, user_id)
);
-- Invitation management
CREATE TABLE family_invitations (
id bigserial PRIMARY KEY,
family_id bigint NOT NULL REFERENCES families(id),
email varchar(255) NOT NULL,
invited_by_id bigint NOT NULL REFERENCES users(id),
token varchar(255) NOT NULL UNIQUE,
status integer NOT NULL DEFAULT 0, -- 0: pending, 1: accepted, 2: declined, 3: expired, 4: cancelled
expires_at timestamp NOT NULL,
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL
);
```
### Performance Indexes
```sql
-- Optimized for common queries
CREATE INDEX CONCURRENTLY idx_family_invitations_family_status_expires
ON family_invitations (family_id, status, expires_at);
CREATE INDEX CONCURRENTLY idx_family_memberships_family_role
ON family_memberships (family_id, role);
CREATE INDEX CONCURRENTLY idx_family_invitations_email
ON family_invitations (email);
CREATE INDEX CONCURRENTLY idx_family_invitations_status_expires
ON family_invitations (status, expires_at);
```
## Testing
### Running Tests
```bash
# Run all family-related tests
bundle exec rspec spec/models/family_spec.rb
bundle exec rspec spec/services/families/
bundle exec rspec spec/controllers/families_controller_spec.rb
bundle exec rspec spec/requests/families_spec.rb
# Run specific test categories
bundle exec rspec --tag family
bundle exec rspec --tag invitation
```
### Test Coverage
The family features include comprehensive test coverage:
- **Unit Tests**: Models, services, helpers
- **Integration Tests**: Controllers, API endpoints
- **System Tests**: End-to-end user workflows
- **Job Tests**: Background email processing
## Deployment
### Production Deployment
```bash
# 1. Run database migrations
RAILS_ENV=production bundle exec rails db:migrate
# 2. Precompile assets (includes family JS/CSS)
RAILS_ENV=production bundle exec rails assets:precompile
# 3. Start background job workers
bundle exec sidekiq -e production -d
# 4. Verify deployment
curl -H "Authorization: Bearer $API_TOKEN" \
https://your-app.com/families
```
### Zero-Downtime Deployment
The family feature supports zero-downtime deployment:
- Database indexes created with `CONCURRENTLY`
- Backward-compatible migrations
- Feature flags for gradual rollout
- Background job graceful shutdown
### Monitoring
Key metrics to monitor:
```yaml
# Family creation rate
family_creation_rate: rate(families_created_total[5m])
# Invitation success rate
invitation_success_rate:
rate(invitations_accepted_total[5m]) /
rate(invitations_sent_total[5m])
# Email delivery rate
email_delivery_success_rate:
rate(family_emails_delivered_total[5m]) /
rate(family_emails_sent_total[5m])
# API response times
family_api_p95_response_time:
histogram_quantile(0.95, family_api_duration_seconds)
```
## Security
### Authorization Model
Family features use Pundit policies for authorization:
```ruby
# Family access control
class FamilyPolicy < ApplicationPolicy
def show?
user_is_member?
end
def update?
user_is_owner?
end
def destroy?
user_is_owner? && family.members.count <= 1
end
end
```
### Data Protection
- All invitation tokens are cryptographically secure
- Email addresses are validated before storage
- Automatic cleanup of expired invitations
- User data protected through proper authorization
### Security Best Practices
- Never log invitation tokens
- Validate all email addresses
- Use HTTPS for all invitation links
- Implement rate limiting on invitation sending
- Monitor for suspicious activity patterns
## Troubleshooting
### Common Issues
**1. Email Delivery Failures**
```bash
# Check SMTP configuration
RAILS_ENV=production bundle exec rails console
> ActionMailer::Base.smtp_settings
# Monitor Sidekiq queue
bundle exec sidekiq -e production
> Sidekiq::Queue.new('mailer').size
```
**2. Authorization Errors**
```bash
# Verify user permissions
RAILS_ENV=production bundle exec rails console
> user = User.find(1)
> family = Family.find(1)
> FamilyPolicy.new(user, family).show?
```
**3. Performance Issues**
```sql
-- Check index usage
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE tablename LIKE 'family%'
ORDER BY idx_scan DESC;
-- Monitor slow queries
SELECT query, mean_time, calls
FROM pg_stat_statements
WHERE query LIKE '%family%'
ORDER BY mean_time DESC;
```
## Support
### Documentation
- [Family Features Guide](FAMILY_FEATURES.md)
- [Deployment Guide](FAMILY_DEPLOYMENT.md)
- [API Documentation](/api-docs)
### Community
- [GitHub Issues](https://github.com/Freika/dawarich/issues)
- [Discord Server](https://discord.gg/pHsBjpt5J8)
- [GitHub Discussions](https://github.com/Freika/dawarich/discussions)
### Contributing
Contributions to family features are welcome:
1. Check existing issues for family-related bugs
2. Follow the existing code patterns and conventions
3. Include comprehensive tests for new features
4. Update documentation for any API changes
5. Follow the contribution guidelines in CONTRIBUTING.md

View file

@ -34,9 +34,9 @@ RSpec.describe 'Families', type: :request do
end end
end end
describe 'GET /families/:id' do describe 'GET /family' do
it 'shows the family page' do it 'shows the family page' do
get "/families/#{family.id}" get "/family"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
end end
@ -46,8 +46,8 @@ RSpec.describe 'Families', type: :request do
before { sign_in outsider } before { sign_in outsider }
it 'redirects to families index' do it 'redirects to families index' do
get "/families/#{family.id}" get "/family"
expect(response).to redirect_to(families_path) expect(response).to redirect_to(family_path)
end end
end end
end end
@ -119,7 +119,7 @@ RSpec.describe 'Families', type: :request do
describe 'GET /families/:id/edit' do describe 'GET /families/:id/edit' do
it 'shows the edit form' do it 'shows the edit form' do
get "/families/#{family.id}/edit" get "/family/edit"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
end end
@ -127,7 +127,7 @@ RSpec.describe 'Families', type: :request do
before { membership.update!(role: :member) } before { membership.update!(role: :member) }
it 'redirects due to authorization failure' do it 'redirects due to authorization failure' do
get "/families/#{family.id}/edit" get "/family/edit"
expect(response).to have_http_status(:see_other) expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized') expect(flash[:alert]).to include('not authorized')
end end
@ -139,7 +139,7 @@ RSpec.describe 'Families', type: :request do
context 'with valid attributes' do context 'with valid attributes' do
it 'updates the family' do it 'updates the family' do
patch "/families/#{family.id}", params: new_attributes patch "/family", params: new_attributes
family.reload family.reload
expect(family.name).to eq('Updated Family Name') expect(family.name).to eq('Updated Family Name')
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path(family))
@ -151,7 +151,7 @@ RSpec.describe 'Families', type: :request do
it 'does not update the family' do it 'does not update the family' do
original_name = family.name original_name = family.name
patch "/families/#{family.id}", params: invalid_attributes patch "/family", params: invalid_attributes
family.reload family.reload
expect(family.name).to eq(original_name) expect(family.name).to eq(original_name)
expect(response).to have_http_status(:unprocessable_content) expect(response).to have_http_status(:unprocessable_content)
@ -162,7 +162,7 @@ RSpec.describe 'Families', type: :request do
before { membership.update!(role: :member) } before { membership.update!(role: :member) }
it 'redirects due to authorization failure' do it 'redirects due to authorization failure' do
patch "/families/#{family.id}", params: new_attributes patch "/family", params: new_attributes
expect(response).to have_http_status(:see_other) expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized') expect(flash[:alert]).to include('not authorized')
end end
@ -173,9 +173,9 @@ RSpec.describe 'Families', type: :request do
context 'when family has only one member' do context 'when family has only one member' do
it 'deletes the family' do it 'deletes the family' do
expect do expect do
delete "/families/#{family.id}" delete "/family"
end.to change(Family, :count).by(-1) end.to change(Family, :count).by(-1)
expect(response).to redirect_to(families_path) expect(response).to redirect_to(family_path)
end end
end end
@ -186,7 +186,7 @@ RSpec.describe 'Families', type: :request do
it 'does not delete the family' do it 'does not delete the family' do
expect do expect do
delete "/families/#{family.id}" delete "/family"
end.not_to change(Family, :count) end.not_to change(Family, :count)
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path(family))
follow_redirect! follow_redirect!
@ -198,49 +198,13 @@ RSpec.describe 'Families', type: :request do
before { membership.update!(role: :member) } before { membership.update!(role: :member) }
it 'redirects due to authorization failure' do it 'redirects due to authorization failure' do
delete "/families/#{family.id}" delete "/family"
expect(response).to have_http_status(:see_other) expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized') expect(flash[:alert]).to include('not authorized')
end end
end end
end end
describe 'DELETE /families/:id/leave' do
context 'when user is not the owner' do
before { membership.update!(role: :member) }
it 'allows user to leave the family' do
expect do
delete "/families/#{family.id}/leave"
end.to change { user.reload.family }.from(family).to(nil)
expect(response).to redirect_to(families_path)
end
end
context 'when user is the owner with other members' do
before do
create(:family_membership, user: other_user, family: family, role: :member)
end
it 'prevents leaving and shows error message' do
expect do
delete "/families/#{family.id}/leave"
end.not_to(change { user.reload.family })
expect(response).to redirect_to(family_path(family))
follow_redirect!
expect(response.body).to include('cannot leave')
end
end
context 'when user is the only owner' do
it 'allows leaving and deletes the family' do
expect do
delete "/families/#{family.id}/leave"
end.to change(Family, :count).by(-1)
expect(response).to redirect_to(families_path)
end
end
end
describe 'authorization for outsiders' do describe 'authorization for outsiders' do
let(:outsider) { create(:user) } let(:outsider) { create(:user) }
@ -248,29 +212,25 @@ RSpec.describe 'Families', type: :request do
before { sign_in outsider } before { sign_in outsider }
it 'denies access to show when user is not in family' do it 'denies access to show when user is not in family' do
get "/families/#{family.id}" get "/family"
expect(response).to redirect_to(families_path) expect(response).to redirect_to(family_path)
end end
it 'redirects to families index when user is not in family for edit' do it 'redirects to families index when user is not in family for edit' do
get "/families/#{family.id}/edit" get "/family/edit"
expect(response).to redirect_to(families_path) expect(response).to redirect_to(family_path)
end end
it 'redirects to families index when user is not in family for update' do it 'redirects to families index when user is not in family for update' do
patch "/families/#{family.id}", params: { family: { name: 'Hacked' } } patch "/family", params: { family: { name: 'Hacked' } }
expect(response).to redirect_to(families_path) expect(response).to redirect_to(family_path)
end end
it 'redirects to families index when user is not in family for destroy' do it 'redirects to families index when user is not in family for destroy' do
delete "/families/#{family.id}" delete "/family"
expect(response).to redirect_to(families_path) expect(response).to redirect_to(family_path)
end end
it 'redirects to families index when user is not in family for leave' do
delete "/families/#{family.id}/leave"
expect(response).to redirect_to(families_path)
end
end end
describe 'authentication required' do describe 'authentication required' do
@ -282,7 +242,7 @@ RSpec.describe 'Families', type: :request do
end end
it 'redirects to login for show' do it 'redirects to login for show' do
get "/families/#{family.id}" get "/family"
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
end end
@ -297,22 +257,17 @@ RSpec.describe 'Families', type: :request do
end end
it 'redirects to login for edit' do it 'redirects to login for edit' do
get "/families/#{family.id}/edit" get "/family/edit"
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
end end
it 'redirects to login for update' do it 'redirects to login for update' do
patch "/families/#{family.id}", params: { family: { name: 'Test' } } patch "/family", params: { family: { name: 'Test' } }
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
end end
it 'redirects to login for destroy' do it 'redirects to login for destroy' do
delete "/families/#{family.id}" delete "/family"
expect(response).to redirect_to(new_user_session_path)
end
it 'redirects to login for leave' do
delete "/families/#{family.id}/leave"
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
end end
end end

View file

@ -18,7 +18,7 @@ RSpec.describe 'Family::Invitations', type: :request do
it 'shows pending invitations' do it 'shows pending invitations' do
invitation # create the invitation invitation # create the invitation
get "/families/#{family.id}/invitations" get "/family/invitations"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
end end
@ -28,8 +28,8 @@ RSpec.describe 'Family::Invitations', type: :request do
before { sign_in outsider } before { sign_in outsider }
it 'redirects to families index' do it 'redirects to families index' do
get "/families/#{family.id}/invitations" get "/family/invitations"
expect(response).to redirect_to(families_path) expect(response).to redirect_to(new_family_path)
end end
end end
@ -37,7 +37,7 @@ RSpec.describe 'Family::Invitations', type: :request do
before { sign_out user } before { sign_out user }
it 'redirects to login' do it 'redirects to login' do
get "/families/#{family.id}/invitations" get "/family/invitations"
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
end end
end end
@ -91,13 +91,13 @@ RSpec.describe 'Family::Invitations', type: :request do
it 'creates a new invitation' do it 'creates a new invitation' do
expect do expect do
post "/families/#{family.id}/invitations", params: valid_params post "/family/invitations", params: valid_params
end.to change(FamilyInvitation, :count).by(1) end.to change(FamilyInvitation, :count).by(1)
end end
it 'redirects with success message' do it 'redirects with success message' do
post "/families/#{family.id}/invitations", params: valid_params post "/family/invitations", params: valid_params
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
follow_redirect! follow_redirect!
expect(response.body).to include('Invitation sent successfully!') expect(response.body).to include('Invitation sent successfully!')
end end
@ -111,14 +111,14 @@ RSpec.describe 'Family::Invitations', type: :request do
it 'does not create a duplicate invitation' do it 'does not create a duplicate invitation' do
invitation # create the existing invitation invitation # create the existing invitation
expect do expect do
post "/families/#{family.id}/invitations", params: duplicate_params post "/family/invitations", params: duplicate_params
end.not_to change(FamilyInvitation, :count) end.not_to change(FamilyInvitation, :count)
end end
it 'redirects with error message' do it 'redirects with error message' do
invitation # create the existing invitation invitation # create the existing invitation
post "/families/#{family.id}/invitations", params: duplicate_params post "/family/invitations", params: duplicate_params
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
follow_redirect! follow_redirect!
expect(response.body).to include('Invitation already sent to this email') expect(response.body).to include('Invitation already sent to this email')
end end
@ -128,7 +128,7 @@ RSpec.describe 'Family::Invitations', type: :request do
before { membership.update!(role: :member) } before { membership.update!(role: :member) }
it 'redirects due to authorization failure' do it 'redirects due to authorization failure' do
post "/families/#{family.id}/invitations", params: { post "/family/invitations", params: {
family_invitation: { email: 'test@example.com' } family_invitation: { email: 'test@example.com' }
} }
expect(response).to have_http_status(:see_other) expect(response).to have_http_status(:see_other)
@ -142,10 +142,10 @@ RSpec.describe 'Family::Invitations', type: :request do
before { sign_in outsider } before { sign_in outsider }
it 'redirects to families index' do it 'redirects to families index' do
post "/families/#{family.id}/invitations", params: { post "/family/invitations", params: {
family_invitation: { email: 'test@example.com' } family_invitation: { email: 'test@example.com' }
} }
expect(response).to redirect_to(families_path) expect(response).to redirect_to(new_family_path)
end end
end end
@ -153,7 +153,7 @@ RSpec.describe 'Family::Invitations', type: :request do
before { sign_out user } before { sign_out user }
it 'redirects to login' do it 'redirects to login' do
post "/families/#{family.id}/invitations", params: { post "/family/invitations", params: {
family_invitation: { email: 'test@example.com' } family_invitation: { email: 'test@example.com' }
} }
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
@ -170,19 +170,19 @@ RSpec.describe 'Family::Invitations', type: :request do
it 'accepts the invitation' do it 'accepts the invitation' do
expect do expect do
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept" post "/family/invitations/#{invitee_invitation.token}/accept"
end.to change { invitee.reload.family }.from(nil).to(family) end.to change { invitee.reload.family }.from(nil).to(family)
end end
it 'redirects with success message' do it 'redirects with success message' do
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept" post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
follow_redirect! follow_redirect!
expect(response.body).to include('Welcome to the family!') expect(response.body).to include('Welcome to the family!')
end end
it 'marks invitation as accepted' do it 'marks invitation as accepted' do
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept" post "/family/invitations/#{invitee_invitation.token}/accept"
invitee_invitation.reload invitee_invitation.reload
expect(invitee_invitation.status).to eq('accepted') expect(invitee_invitation.status).to eq('accepted')
end end
@ -198,12 +198,12 @@ RSpec.describe 'Family::Invitations', type: :request do
it 'does not accept the invitation' do it 'does not accept the invitation' do
expect do expect do
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept" post "/family/invitations/#{invitee_invitation.token}/accept"
end.not_to(change { invitee.reload.family }) end.not_to(change { invitee.reload.family })
end end
it 'redirects with error message' do it 'redirects with error message' do
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept" post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(root_path) expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('You must leave your current family before joining a new one') expect(flash[:alert]).to include('You must leave your current family before joining a new one')
end end
@ -217,12 +217,12 @@ RSpec.describe 'Family::Invitations', type: :request do
it 'does not accept the invitation' do it 'does not accept the invitation' do
expect do expect do
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept" post "/family/invitations/#{invitee_invitation.token}/accept"
end.not_to(change { invitee.reload.family }) end.not_to(change { invitee.reload.family })
end end
it 'redirects with error message' do it 'redirects with error message' do
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept" post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(root_path) expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('This invitation is no longer valid or has expired') expect(flash[:alert]).to include('This invitation is no longer valid or has expired')
end end
@ -230,7 +230,7 @@ RSpec.describe 'Family::Invitations', type: :request do
context 'when not authenticated' do context 'when not authenticated' do
it 'redirects to login' do it 'redirects to login' do
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept" post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
end end
end end
@ -240,14 +240,14 @@ RSpec.describe 'Family::Invitations', type: :request do
before { sign_in user } before { sign_in user }
it 'cancels the invitation' do it 'cancels the invitation' do
delete "/families/#{family.id}/invitations/#{invitation.id}" delete "/family/invitations/#{invitation.token}"
invitation.reload invitation.reload
expect(invitation.status).to eq('cancelled') expect(invitation.status).to eq('cancelled')
end end
it 'redirects with success message' do it 'redirects with success message' do
delete "/families/#{family.id}/invitations/#{invitation.id}" delete "/family/invitations/#{invitation.token}"
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
follow_redirect! follow_redirect!
expect(response.body).to include('Invitation cancelled') expect(response.body).to include('Invitation cancelled')
end end
@ -256,7 +256,7 @@ RSpec.describe 'Family::Invitations', type: :request do
before { membership.update!(role: :member) } before { membership.update!(role: :member) }
it 'redirects due to authorization failure' do it 'redirects due to authorization failure' do
delete "/families/#{family.id}/invitations/#{invitation.id}" delete "/family/invitations/#{invitation.token}"
expect(response).to have_http_status(:see_other) expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized') expect(flash[:alert]).to include('not authorized')
end end
@ -268,8 +268,8 @@ RSpec.describe 'Family::Invitations', type: :request do
before { sign_in outsider } before { sign_in outsider }
it 'redirects to families index' do it 'redirects to families index' do
delete "/families/#{family.id}/invitations/#{invitation.token}" delete "/family/invitations/#{invitation.token}"
expect(response).to redirect_to(families_path) expect(response).to redirect_to(new_family_path)
end end
end end
@ -277,7 +277,7 @@ RSpec.describe 'Family::Invitations', type: :request do
before { sign_out user } before { sign_out user }
it 'redirects to login' do it 'redirects to login' do
delete "/families/#{family.id}/invitations/#{invitation.token}" delete "/family/invitations/#{invitation.token}"
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
end end
end end
@ -289,10 +289,10 @@ RSpec.describe 'Family::Invitations', type: :request do
it 'completes full invitation acceptance workflow' do it 'completes full invitation acceptance workflow' do
# 1. Owner creates invitation # 1. Owner creates invitation
sign_in user sign_in user
post "/families/#{family.id}/invitations", params: { post "/family/invitations", params: {
family_invitation: { email: invitee.email } family_invitation: { email: invitee.email }
} }
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
created_invitation = FamilyInvitation.last created_invitation = FamilyInvitation.last
expect(created_invitation.email).to eq(invitee.email) expect(created_invitation.email).to eq(invitee.email)
@ -304,8 +304,8 @@ RSpec.describe 'Family::Invitations', type: :request do
# 3. Invitee accepts invitation # 3. Invitee accepts invitation
sign_in invitee sign_in invitee
post "/families/#{family.id}/invitations/#{created_invitation.token}/accept" post "/family/invitations/#{created_invitation.token}/accept"
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
# 4. Verify invitee is now in family # 4. Verify invitee is now in family
expect(invitee.reload.family).to eq(family) expect(invitee.reload.family).to eq(family)

View file

@ -19,20 +19,20 @@ RSpec.describe 'Family::Memberships', type: :request do
context 'when removing a regular member' do context 'when removing a regular member' do
it 'removes the member from the family' do it 'removes the member from the family' do
expect do expect do
delete "/families/#{family.id}/members/#{member_membership.id}" delete "/family/members/#{member_membership.id}"
end.to change(FamilyMembership, :count).by(-1) end.to change(FamilyMembership, :count).by(-1)
end end
it 'redirects with success message' do it 'redirects with success message' do
member_email = member_user.email member_email = member_user.email
delete "/families/#{family.id}/members/#{member_membership.id}" delete "/family/members/#{member_membership.id}"
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
follow_redirect! follow_redirect!
expect(response.body).to include("#{member_email} has been removed from the family") expect(response.body).to include("#{member_email} has been removed from the family")
end end
it 'removes the user from the family' do it 'removes the user from the family' do
delete "/families/#{family.id}/members/#{member_membership.id}" delete "/family/members/#{member_membership.id}"
expect(member_user.reload.family).to be_nil expect(member_user.reload.family).to be_nil
end end
end end
@ -40,13 +40,13 @@ RSpec.describe 'Family::Memberships', type: :request do
context 'when trying to remove the owner' do context 'when trying to remove the owner' do
it 'does not remove the owner' do it 'does not remove the owner' do
expect do expect do
delete "/families/#{family.id}/members/#{owner_membership.id}" delete "/family/members/#{owner_membership.id}"
end.not_to change(FamilyMembership, :count) end.not_to change(FamilyMembership, :count)
end end
it 'redirects with error message explaining owners must delete family' do it 'redirects with error message explaining owners must delete family' do
delete "/families/#{family.id}/members/#{owner_membership.id}" delete "/family/members/#{owner_membership.id}"
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
follow_redirect! follow_redirect!
expect(response.body).to include('Family owners cannot remove their own membership. To leave the family, delete it instead.') expect(response.body).to include('Family owners cannot remove their own membership. To leave the family, delete it instead.')
end end
@ -55,10 +55,10 @@ RSpec.describe 'Family::Memberships', type: :request do
member_membership.destroy! member_membership.destroy!
expect do expect do
delete "/families/#{family.id}/members/#{owner_membership.id}" delete "/family/members/#{owner_membership.id}"
end.not_to change(FamilyMembership, :count) end.not_to change(FamilyMembership, :count)
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
follow_redirect! follow_redirect!
expect(response.body).to include('Family owners cannot remove their own membership') expect(response.body).to include('Family owners cannot remove their own membership')
end end
@ -80,8 +80,8 @@ RSpec.describe 'Family::Memberships', type: :request do
before { sign_in outsider } before { sign_in outsider }
it 'redirects to families index' do it 'redirects to families index' do
delete "/families/#{family.id}/members/#{member_membership.id}" delete "/family/members/#{member_membership.id}"
expect(response).to redirect_to(families_path) expect(response).to redirect_to(new_family_path)
end end
end end
@ -89,7 +89,7 @@ RSpec.describe 'Family::Memberships', type: :request do
before { sign_out user } before { sign_out user }
it 'redirects to login' do it 'redirects to login' do
delete "/families/#{family.id}/members/#{member_membership.id}" delete "/family/members/#{member_membership.id}"
expect(response).to redirect_to(new_user_session_path) expect(response).to redirect_to(new_user_session_path)
end end
end end
@ -100,7 +100,7 @@ RSpec.describe 'Family::Memberships', type: :request do
before { sign_in member_user } before { sign_in member_user }
it 'returns forbidden' do it 'returns forbidden' do
delete "/families/#{family.id}/members/#{owner_membership.id}" delete "/family/members/#{owner_membership.id}"
expect(response).to have_http_status(:see_other) expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized') expect(flash[:alert]).to include('not authorized')
end end
@ -115,7 +115,7 @@ RSpec.describe 'Family::Memberships', type: :request do
expect(member_user.family).to eq(family) expect(member_user.family).to eq(family)
# Remove member # Remove member
delete "/families/#{family.id}/members/#{member_membership.id}" delete "/family/members/#{member_membership.id}"
# Verify removal # Verify removal
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path(family))
@ -148,7 +148,7 @@ RSpec.describe 'Family::Memberships', type: :request do
# Try to remove owner - should be prevented # Try to remove owner - should be prevented
expect do expect do
delete "/families/#{family.id}/members/#{owner_membership.id}" delete "/family/members/#{owner_membership.id}"
end.not_to change(FamilyMembership, :count) end.not_to change(FamilyMembership, :count)
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path(family))
@ -157,20 +157,14 @@ RSpec.describe 'Family::Memberships', type: :request do
end end
it 'requires owners to use family deletion to leave the family' do it 'requires owners to use family deletion to leave the family' do
# This test documents that owners must delete the family to leave
# rather than removing their membership
# Remove other member first
member_membership.destroy! member_membership.destroy!
# Try to remove owner membership - should fail delete "/family/members/#{owner_membership.id}"
delete "/families/#{family.id}/members/#{owner_membership.id}" expect(response).to redirect_to(family_path)
expect(response).to redirect_to(family_path(family))
expect(flash[:alert]).to include('Family owners cannot remove their own membership') expect(flash[:alert]).to include('Family owners cannot remove their own membership')
# Owner must delete the family instead delete "/family"
delete "/families/#{family.id}" expect(response).to redirect_to(new_family_path)
expect(response).to redirect_to(families_path)
expect(user.reload.family).to be_nil expect(user.reload.family).to be_nil
end end
end end

View file

@ -17,13 +17,10 @@ RSpec.describe 'Family Workflows', type: :request do
# Step 1: User1 creates a family # Step 1: User1 creates a family
sign_in user1 sign_in user1
get '/families' get '/family/new'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
get '/families/new' post '/family', params: { family: { name: 'The Smith Family' } }
expect(response).to have_http_status(:ok)
post '/families', params: { family: { name: 'The Smith Family' } }
# The redirect should be to the newly created family # The redirect should be to the newly created family
expect(response).to have_http_status(:found) expect(response).to have_http_status(:found)
@ -35,10 +32,10 @@ RSpec.describe 'Family Workflows', type: :request do
expect(user1.family_owner?).to be true expect(user1.family_owner?).to be true
# Step 2: User1 invites User2 # Step 2: User1 invites User2
post "/families/#{family.id}/invitations", params: { post "/family/invitations", params: {
family_invitation: { email: user2.email } family_invitation: { email: user2.email }
} }
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
invitation = family.family_invitations.find_by(email: user2.email) invitation = family.family_invitations.find_by(email: user2.email)
expect(invitation).to be_present expect(invitation).to be_present
@ -55,8 +52,8 @@ RSpec.describe 'Family Workflows', type: :request do
# User2 accepts invitation # User2 accepts invitation
sign_in user2 sign_in user2
post "/families/#{family.id}/invitations/#{invitation.token}/accept" post "/family/invitations/#{invitation.token}/accept"
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
expect(user2.reload.family).to eq(family) expect(user2.reload.family).to eq(family)
expect(user2.family_owner?).to be false expect(user2.family_owner?).to be false
@ -64,7 +61,7 @@ RSpec.describe 'Family Workflows', type: :request do
# Step 4: User1 invites User3 # Step 4: User1 invites User3
sign_in user1 sign_in user1
post "/families/#{family.id}/invitations", params: { post "/family/invitations", params: {
family_invitation: { email: user3.email } family_invitation: { email: user3.email }
} }
@ -74,19 +71,19 @@ RSpec.describe 'Family Workflows', type: :request do
# Step 5: User3 accepts invitation # Step 5: User3 accepts invitation
sign_in user3 sign_in user3
post "/families/#{family.id}/invitations/#{invitation2.token}/accept" post "/family/invitations/#{invitation2.token}/accept"
expect(user3.reload.family).to eq(family) expect(user3.reload.family).to eq(family)
expect(family.reload.members.count).to eq(3) expect(family.reload.members.count).to eq(3)
# Step 6: Family owner views members on family show page # Step 6: Family owner views members on family show page
sign_in user1 sign_in user1
get "/families/#{family.id}" get "/family"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
# Step 7: Owner removes a member # Step 7: Owner removes a member
delete "/families/#{family.id}/members/#{user2.family_membership.id}" delete "/family/members/#{user2.family_membership.id}"
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
expect(user2.reload.family).to be_nil expect(user2.reload.family).to be_nil
expect(family.reload.members.count).to eq(2) expect(family.reload.members.count).to eq(2)
@ -111,7 +108,7 @@ RSpec.describe 'Family Workflows', type: :request do
# User2 tries to accept expired invitation # User2 tries to accept expired invitation
sign_in user2 sign_in user2
post "/families/#{family.id}/invitations/#{invitation.token}/accept" post "/family/invitations/#{invitation.token}/accept"
expect(response).to redirect_to(root_path) expect(response).to redirect_to(root_path)
expect(user2.reload.family).to be_nil expect(user2.reload.family).to be_nil
@ -130,12 +127,12 @@ RSpec.describe 'Family Workflows', type: :request do
it 'prevents users from joining multiple families' do it 'prevents users from joining multiple families' do
# User3 accepts invitation to Family 1 # User3 accepts invitation to Family 1
sign_in user3 sign_in user3
post "/families/#{family1.id}/invitations/#{invitation1.token}/accept" post "/family/invitations/#{invitation1.token}/accept"
expect(response).to redirect_to(family_path(user3.reload.family)) expect(response).to redirect_to(family_path(user3.reload.family))
expect(user3.family).to eq(family1) expect(user3.family).to eq(family1)
# User3 tries to accept invitation to Family 2 # User3 tries to accept invitation to Family 2
post "/families/#{family2.id}/invitations/#{invitation2.token}/accept" post "/family/invitations/#{invitation2.token}/accept"
expect(response).to redirect_to(root_path) expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('You must leave your current family') expect(flash[:alert]).to include('You must leave your current family')
@ -151,11 +148,12 @@ RSpec.describe 'Family Workflows', type: :request do
it 'prevents owner from leaving when members exist' do it 'prevents owner from leaving when members exist' do
sign_in user1 sign_in user1
# Owner tries to leave family with members # Owner tries to leave family with members (using memberships destroy route)
delete "/families/#{family.id}/leave" owner_membership = user1.family_membership
expect(response).to redirect_to(family_path(family)) delete "/family/members/#{owner_membership.id}"
expect(response).to redirect_to(family_path)
follow_redirect! follow_redirect!
expect(response.body).to include('cannot leave') expect(response.body).to include('cannot remove their own membership')
expect(user1.reload.family).to eq(family) expect(user1.reload.family).to eq(family)
expect(user1.family_owner?).to be true expect(user1.family_owner?).to be true
@ -165,22 +163,23 @@ RSpec.describe 'Family Workflows', type: :request do
sign_in user1 sign_in user1
# Remove the member first # Remove the member first
delete "/families/#{family.id}/members/#{member_membership.id}" delete "/family/members/#{member_membership.id}"
# Now owner can leave (which deletes the family) # Owner cannot leave even when alone - they must delete the family instead
expect do owner_membership = user1.reload.family_membership
delete "/families/#{family.id}/leave" delete "/family/members/#{owner_membership.id}"
end.to change(Family, :count).by(-1) expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('cannot remove their own membership')
expect(response).to redirect_to(families_path) expect(user1.reload.family).to eq(family)
expect(user1.reload.family).to be_nil
end end
it 'allows members to leave freely' do it 'allows members to leave freely' do
sign_in user2 sign_in user2
delete "/families/#{family.id}/leave" delete "/family/members/#{member_membership.id}"
expect(response).to redirect_to(families_path) expect(response).to redirect_to(new_family_path)
expect(user2.reload.family).to be_nil expect(user2.reload.family).to be_nil
expect(family.reload.members.count).to eq(1) expect(family.reload.members.count).to eq(1)
@ -200,10 +199,10 @@ RSpec.describe 'Family Workflows', type: :request do
sign_in user1 sign_in user1
expect do expect do
delete "/families/#{family.id}" delete "/family"
end.not_to change(Family, :count) end.not_to change(Family, :count)
expect(response).to redirect_to(family_path(family)) expect(response).to redirect_to(family_path)
follow_redirect! follow_redirect!
expect(response.body).to include('Cannot delete family with members') expect(response.body).to include('Cannot delete family with members')
end end
@ -213,10 +212,10 @@ RSpec.describe 'Family Workflows', type: :request do
sign_in user1 sign_in user1
expect do expect do
delete "/families/#{family.id}" delete "/family"
end.to change(Family, :count).by(-1) end.to change(Family, :count).by(-1)
expect(response).to redirect_to(families_path) expect(response).to redirect_to(new_family_path)
expect(user1.reload.family).to be_nil expect(user1.reload.family).to be_nil
end end
end end
@ -229,19 +228,19 @@ RSpec.describe 'Family Workflows', type: :request do
it 'enforces proper authorization for family management' do it 'enforces proper authorization for family management' do
# Member cannot invite others # Member cannot invite others
sign_in user2 sign_in user2
post "/families/#{family.id}/invitations", params: { post "/family/invitations", params: {
family_invitation: { email: user3.email } family_invitation: { email: user3.email }
} }
expect(response).to have_http_status(:see_other) expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized') expect(flash[:alert]).to include('not authorized')
# Member cannot remove other members # Member cannot remove other members
delete "/families/#{family.id}/members/#{owner_membership.id}" delete "/family/members/#{owner_membership.id}"
expect(response).to have_http_status(:see_other) expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized') expect(flash[:alert]).to include('not authorized')
# Member cannot edit family # Member cannot edit family
patch "/families/#{family.id}", params: { family: { name: 'Hacked Family' } } patch "/family", params: { family: { name: 'Hacked Family' } }
expect(response).to have_http_status(:see_other) expect(response).to have_http_status(:see_other)
expect(flash[:alert]).to include('not authorized') expect(flash[:alert]).to include('not authorized')
@ -252,8 +251,8 @@ RSpec.describe 'Family Workflows', type: :request do
# Outsider cannot access family # Outsider cannot access family
sign_in user3 sign_in user3
get "/families/#{family.id}" get "/family"
expect(response).to redirect_to(families_path) expect(response).to redirect_to(new_family_path)
end end
end end
@ -266,7 +265,7 @@ RSpec.describe 'Family Workflows', type: :request do
# Mock email delivery # Mock email delivery
expect do expect do
post "/families/#{family.id}/invitations", params: { post "/family/invitations", params: {
family_invitation: { email: 'newuser@example.com' } family_invitation: { email: 'newuser@example.com' }
} }
end.to change(FamilyInvitation, :count).by(1) end.to change(FamilyInvitation, :count).by(1)
@ -280,22 +279,22 @@ RSpec.describe 'Family Workflows', type: :request do
describe 'Navigation and redirect workflow' do describe 'Navigation and redirect workflow' do
it 'handles proper redirects for family-related navigation' do it 'handles proper redirects for family-related navigation' do
# User without family sees index # User without family can access new family page
sign_in user1 sign_in user1
get '/families' get '/family/new'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
# User creates family # User creates family
post '/families', params: { family: { name: 'Test Family' } } post '/family', params: { family: { name: 'Test Family' } }
expect(response).to have_http_status(:found) expect(response).to have_http_status(:found)
# User with family gets redirected from index to family page # User with family can view their family
get '/families' get '/family'
expect(response).to redirect_to(family_path(user1.reload.family)) expect(response).to have_http_status(:ok)
# User with family gets redirected from new family page # User with family gets redirected from new family page
get '/families/new' get '/family/new'
expect(response).to redirect_to(family_path(user1.reload.family)) expect(response).to redirect_to(family_path)
end end
end end
end end

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Families::Leave do RSpec.describe Families::Memberships::Destroy do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:family) { create(:family, creator: user) } let(:family) { create(:family, creator: user) }
let(:service) { described_class.new(user: user) } let(:service) { described_class.new(user: user) }
@ -46,17 +46,22 @@ RSpec.describe Families::Leave do
context 'when user is family owner with no other members' do context 'when user is family owner with no other members' do
let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) }
it 'removes the membership' do it 'prevents owner from leaving' do
expect { service.call }.to change(FamilyMembership, :count).by(-1) expect { service.call }.not_to change(FamilyMembership, :count)
expect(user.reload.family_membership).to be_nil expect(user.reload.family_membership).to be_present
end end
it 'deletes the family' do it 'does not delete the family' do
expect { service.call }.to change(Family, :count).by(-1) expect { service.call }.not_to change(Family, :count)
end end
it 'returns true' do it 'returns false' do
expect(service.call).to be true expect(service.call).to be false
end
it 'sets error message' do
service.call
expect(service.error_message).to include('cannot remove their own membership')
end end
end end