mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Use family path instead of families/id
This commit is contained in:
parent
cfe319df9b
commit
9bc0e2accc
30 changed files with 419 additions and 1458 deletions
|
|
@ -17,14 +17,14 @@ class Api::V1::FamiliesController < ApiController
|
|||
private
|
||||
|
||||
def ensure_family_feature_enabled!
|
||||
unless DawarichSettings.family_feature_enabled?
|
||||
render json: { error: 'Family feature is not enabled' }, status: :forbidden
|
||||
end
|
||||
return if DawarichSettings.family_feature_enabled?
|
||||
|
||||
render json: { error: 'Family feature is not enabled' }, status: :forbidden
|
||||
end
|
||||
|
||||
def ensure_user_in_family!
|
||||
unless current_api_user.in_family?
|
||||
render json: { error: 'User is not part of a family' }, status: :forbidden
|
||||
end
|
||||
return if current_api_user.in_family?
|
||||
|
||||
render json: { error: 'User is not part of a family' }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,38 +3,36 @@
|
|||
class FamiliesController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_family_feature_enabled!
|
||||
before_action :set_family, only: %i[show edit update destroy leave update_location_sharing]
|
||||
|
||||
def index
|
||||
redirect_to family_path(current_user.family) if current_user.in_family?
|
||||
end
|
||||
before_action :set_family, only: %i[show edit update destroy update_location_sharing]
|
||||
|
||||
def show
|
||||
authorize @family
|
||||
|
||||
# Use optimized family methods for better performance
|
||||
@members = @family.members.includes(:family_membership).order(:email)
|
||||
@pending_invitations = @family.active_invitations.order(:created_at)
|
||||
|
||||
# Use cached counts to avoid extra queries
|
||||
@member_count = @family.member_count
|
||||
@can_invite = @family.can_add_members?
|
||||
end
|
||||
|
||||
def new
|
||||
redirect_to family_path(current_user.family) if current_user.in_family?
|
||||
redirect_to family_path and return if current_user.in_family?
|
||||
|
||||
@family = Family.new
|
||||
authorize @family
|
||||
end
|
||||
|
||||
def create
|
||||
@family = Family.new(family_params)
|
||||
authorize @family
|
||||
|
||||
service = Families::Create.new(
|
||||
user: current_user,
|
||||
name: family_params[:name]
|
||||
)
|
||||
|
||||
if service.call
|
||||
redirect_to family_path(service.family), notice: 'Family created successfully!'
|
||||
redirect_to family_path, notice: 'Family created successfully!'
|
||||
else
|
||||
@family = Family.new(family_params)
|
||||
|
||||
|
|
@ -63,7 +61,7 @@ class FamiliesController < ApplicationController
|
|||
authorize @family
|
||||
|
||||
if @family.update(family_params)
|
||||
redirect_to family_path(@family), notice: 'Family updated successfully!'
|
||||
redirect_to family_path, notice: 'Family updated successfully!'
|
||||
else
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
|
|
@ -73,31 +71,14 @@ class FamiliesController < ApplicationController
|
|||
authorize @family
|
||||
|
||||
if @family.members.count > 1
|
||||
redirect_to family_path(@family), alert: 'Cannot delete family with members. Remove all members first.'
|
||||
redirect_to family_path, alert: 'Cannot delete family with members. Remove all members first.'
|
||||
else
|
||||
@family.destroy
|
||||
redirect_to families_path, notice: 'Family deleted successfully!'
|
||||
redirect_to new_family_path, notice: 'Family deleted successfully!'
|
||||
end
|
||||
end
|
||||
|
||||
def leave
|
||||
authorize @family, :leave?
|
||||
|
||||
service = Families::Leave.new(user: current_user)
|
||||
|
||||
if service.call
|
||||
redirect_to families_path, notice: 'You have left the family'
|
||||
else
|
||||
redirect_to family_path(@family), alert: service.error_message || 'Cannot leave family.'
|
||||
end
|
||||
rescue Pundit::NotAuthorizedError
|
||||
# Handle case where owner with members tries to leave
|
||||
redirect_to family_path(@family),
|
||||
alert: 'You cannot leave the family while you are the owner and there are other members. Remove all members first or transfer ownership.'
|
||||
end
|
||||
|
||||
def update_location_sharing
|
||||
# No authorization needed - users can control their own location sharing
|
||||
enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
|
||||
duration = params[:duration]
|
||||
|
||||
|
|
@ -109,7 +90,6 @@ class FamiliesController < ApplicationController
|
|||
message: build_sharing_message(enabled, duration)
|
||||
}
|
||||
|
||||
# Add expiration info if sharing is time-limited
|
||||
if enabled && current_user.family_sharing_expires_at.present?
|
||||
response_data[:expires_at] = current_user.family_sharing_expires_at.iso8601
|
||||
response_data[:expires_at_formatted] = current_user.family_sharing_expires_at.strftime('%b %d at %I:%M %p')
|
||||
|
|
@ -158,7 +138,7 @@ class FamiliesController < ApplicationController
|
|||
|
||||
def set_family
|
||||
@family = current_user.family
|
||||
redirect_to families_path unless @family
|
||||
redirect_to new_family_path, alert: 'You are not in a family' unless @family
|
||||
end
|
||||
|
||||
def family_params
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
class Family::InvitationsController < ApplicationController
|
||||
before_action :authenticate_user!, except: %i[show accept]
|
||||
before_action :ensure_family_feature_enabled!, except: %i[show accept]
|
||||
before_action :set_invitation_by_token, only: %i[show]
|
||||
before_action :set_invitation_by_id, only: %i[accept]
|
||||
before_action :set_family, except: %i[show accept]
|
||||
before_action :set_invitation_by_token, only: %i[show accept]
|
||||
before_action :set_invitation_by_id, only: %i[destroy]
|
||||
before_action :set_invitation_by_id_and_family, only: %i[destroy]
|
||||
|
||||
def index
|
||||
authorize @family, :show?
|
||||
|
|
@ -37,9 +38,9 @@ class Family::InvitationsController < ApplicationController
|
|||
)
|
||||
|
||||
if service.call
|
||||
redirect_to family_path(@family), notice: 'Invitation sent successfully!'
|
||||
redirect_to family_path, notice: 'Invitation sent successfully!'
|
||||
else
|
||||
redirect_to family_path(@family), alert: service.error_message || 'Failed to send invitation'
|
||||
redirect_to family_path, alert: service.error_message || 'Failed to send invitation'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -65,8 +66,7 @@ class Family::InvitationsController < ApplicationController
|
|||
)
|
||||
|
||||
if service.call
|
||||
redirect_to family_path(current_user.reload.family),
|
||||
notice: 'Welcome to the family!'
|
||||
redirect_to family_path, notice: 'Welcome to the family!'
|
||||
else
|
||||
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
|
||||
end
|
||||
|
|
@ -80,16 +80,13 @@ class Family::InvitationsController < ApplicationController
|
|||
|
||||
begin
|
||||
if @invitation.update(status: :cancelled)
|
||||
redirect_to family_path(@family),
|
||||
notice: 'Invitation cancelled'
|
||||
redirect_to family_path, notice: 'Invitation cancelled'
|
||||
else
|
||||
redirect_to family_path(@family),
|
||||
alert: 'Failed to cancel invitation. Please try again'
|
||||
redirect_to family_path, alert: 'Failed to cancel invitation. Please try again'
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error cancelling family invitation: #{e.message}"
|
||||
redirect_to family_path(@family),
|
||||
alert: 'An unexpected error occurred while cancelling the invitation'
|
||||
redirect_to family_path, alert: 'An unexpected error occurred while cancelling the invitation'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -104,15 +101,25 @@ class Family::InvitationsController < ApplicationController
|
|||
def set_family
|
||||
@family = current_user.family
|
||||
|
||||
redirect_to families_path, alert: 'Family not found' and return unless @family
|
||||
redirect_to new_family_path, alert: 'You are not in a family' and return unless @family
|
||||
end
|
||||
|
||||
def set_invitation_by_token
|
||||
@invitation = FamilyInvitation.find_by!(token: params[:id])
|
||||
# For public unauthenticated route: /invitations/:token
|
||||
@invitation = FamilyInvitation.find_by!(token: params[:token])
|
||||
end
|
||||
|
||||
def set_invitation_by_id
|
||||
@invitation = @family.family_invitations.find(params[:id])
|
||||
# For authenticated nested routes without family validation: /families/:family_id/invitations/:id/accept
|
||||
# The :id param contains the token value
|
||||
@invitation = FamilyInvitation.find_by!(token: params[:id])
|
||||
end
|
||||
|
||||
def set_invitation_by_id_and_family
|
||||
# For authenticated nested routes: /families/:family_id/invitations/:id
|
||||
# The :id param contains the token value
|
||||
@family = current_user.family
|
||||
@invitation = @family.family_invitations.find_by!(token: params[:id])
|
||||
end
|
||||
|
||||
def invitation_params
|
||||
|
|
|
|||
|
|
@ -9,13 +9,19 @@ class Family::MembershipsController < ApplicationController
|
|||
def destroy
|
||||
authorize @membership
|
||||
|
||||
if @membership.owner?
|
||||
redirect_to family_path(@family),
|
||||
alert: 'Family owners cannot remove their own membership. To leave the family, delete it instead.'
|
||||
member_user = @membership.user
|
||||
service = Families::Memberships::Destroy.new(user: current_user, member_to_remove: member_user)
|
||||
|
||||
if service.call
|
||||
if member_user == current_user
|
||||
# User removed themselves
|
||||
redirect_to new_family_path, notice: 'You have left the family'
|
||||
else
|
||||
# Owner removed another member
|
||||
redirect_to family_path, notice: "#{member_user.email} has been removed from the family"
|
||||
end
|
||||
else
|
||||
member_email = @membership.user.email
|
||||
@membership.destroy!
|
||||
redirect_to family_path(@family), notice: "#{member_email} has been removed from the family"
|
||||
redirect_to family_path, alert: service.error_message || 'Failed to remove member'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -30,7 +36,7 @@ class Family::MembershipsController < ApplicationController
|
|||
def set_family
|
||||
@family = current_user.family
|
||||
|
||||
redirect_to families_path, alert: 'Family not found' and return unless @family
|
||||
redirect_to new_family_path, alert: 'You are not in a family' and return unless @family
|
||||
end
|
||||
|
||||
def set_membership
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ class MapController < ApplicationController
|
|||
@years = years_range
|
||||
@points_number = points_count
|
||||
@features = DawarichSettings.features
|
||||
@family_member_locations = family_member_locations
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -94,8 +93,4 @@ class MapController < ApplicationController
|
|||
def points_from_user
|
||||
current_user.points.without_raw_data.order(timestamp: :asc)
|
||||
end
|
||||
|
||||
def family_member_locations
|
||||
Families::Locations.new(current_user).call
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,17 +2,15 @@
|
|||
|
||||
class Users::RegistrationsController < Devise::RegistrationsController
|
||||
before_action :check_registration_allowed, only: [:new, :create]
|
||||
before_action :load_invitation_context, only: [:new, :create]
|
||||
before_action :set_invitation, only: [:new, :create]
|
||||
|
||||
def new
|
||||
build_resource({})
|
||||
|
||||
# Pre-fill email if invitation exists
|
||||
if @invitation
|
||||
resource.email = @invitation.email
|
||||
end
|
||||
resource.email = @invitation.email if @invitation
|
||||
|
||||
yield resource if block_given?
|
||||
|
||||
respond_with resource
|
||||
end
|
||||
|
||||
|
|
@ -45,22 +43,18 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
|||
private
|
||||
|
||||
def check_registration_allowed
|
||||
return true unless self_hosted_mode?
|
||||
return true if DawarichSettings.self_hosted?
|
||||
return true if valid_invitation_token?
|
||||
|
||||
redirect_to root_path, alert: 'Registration is not available. Please contact your administrator for access.'
|
||||
end
|
||||
|
||||
def load_invitation_context
|
||||
def set_invitation
|
||||
return unless invitation_token.present?
|
||||
|
||||
@invitation = FamilyInvitation.find_by(token: invitation_token)
|
||||
end
|
||||
|
||||
def self_hosted_mode?
|
||||
ENV['SELF_HOSTED'] == 'true'
|
||||
end
|
||||
|
||||
def valid_invitation_token?
|
||||
return false unless invitation_token.present?
|
||||
|
||||
|
|
@ -77,7 +71,6 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
|||
def accept_invitation_for_user(user)
|
||||
return unless @invitation&.can_be_accepted?
|
||||
|
||||
# Use the existing invitation acceptance service
|
||||
service = Families::AcceptInvitation.new(
|
||||
invitation: @invitation,
|
||||
user: user
|
||||
|
|
@ -96,4 +89,4 @@ class Users::RegistrationsController < Devise::RegistrationsController
|
|||
def sign_up_params
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ class Users::SessionsController < Devise::SessionsController
|
|||
protected
|
||||
|
||||
def after_sign_in_path_for(resource)
|
||||
# If there's an invitation token, redirect to the invitation page
|
||||
if invitation_token.present?
|
||||
invitation = FamilyInvitation.find_by(token: invitation_token)
|
||||
|
||||
if invitation&.can_be_accepted?
|
||||
return family_invitation_path(invitation.token)
|
||||
end
|
||||
|
|
@ -32,4 +32,4 @@ class Users::SessionsController < Devise::SessionsController
|
|||
def invitation_token
|
||||
@invitation_token ||= params[:invitation_token] || session[:invitation_token]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationHelper
|
||||
def classes_for_flash(flash_type)
|
||||
case flash_type.to_sym
|
||||
when :error
|
||||
'bg-red-100 text-red-700 border-red-300'
|
||||
else
|
||||
'bg-blue-100 text-blue-700 border-blue-300'
|
||||
end
|
||||
end
|
||||
|
||||
def flash_alert_class(type)
|
||||
case type.to_sym
|
||||
when :notice, :success
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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...
|
||||
`
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,6 @@ class Family < ApplicationRecord
|
|||
end
|
||||
|
||||
def member_count
|
||||
# Cache the count to avoid repeated queries
|
||||
@member_count ||= members.count
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
180
app/services/families/memberships/destroy.rb
Normal file
180
app/services/families/memberships/destroy.rb
Normal 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
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
<h1 class="text-2xl font-bold text-base-content">
|
||||
<%= t('families.edit.title', default: 'Edit Family') %>
|
||||
</h1>
|
||||
<%= link_to family_path(@family),
|
||||
<%= link_to family_path,
|
||||
class: "btn btn-ghost" do %>
|
||||
<%= t('families.edit.back', default: '← Back to Family') %>
|
||||
<% end %>
|
||||
|
|
@ -77,14 +77,14 @@
|
|||
<div class="flex space-x-3">
|
||||
<%= form.submit t('families.edit.save_changes', default: 'Save Changes'),
|
||||
class: "btn btn-primary" %>
|
||||
<%= link_to family_path(@family),
|
||||
<%= link_to family_path,
|
||||
class: "btn btn-neutral" do %>
|
||||
<%= t('families.edit.cancel', default: 'Cancel') %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if policy(@family).destroy? %>
|
||||
<%= link_to family_path(@family),
|
||||
<%= link_to family_path,
|
||||
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.' },
|
||||
class: "btn btn-outline btn-error" do %>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
<div class="flex items-center justify-between">
|
||||
<%= form.submit t('families.new.create_family', default: 'Create Family'),
|
||||
class: "btn btn-primary" %>
|
||||
<%= link_to families_path,
|
||||
<%= link_to root_path,
|
||||
class: "btn btn-ghost" do %>
|
||||
<%= t('families.new.back', default: '← Back') %>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -15,26 +15,26 @@
|
|||
|
||||
<div class="flex space-x-3">
|
||||
<% if policy(@family).update? %>
|
||||
<%= link_to edit_family_path(@family),
|
||||
<%= link_to edit_family_path,
|
||||
class: "btn btn-outline btn-info" do %>
|
||||
<%= icon 'square-pen', class: "inline-block w-4" %><%= t('families.show.edit', default: 'Edit') %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if policy(@family).leave? && !current_user.family_owner? %>
|
||||
<%= link_to leave_family_path(@family),
|
||||
<% if !current_user.family_owner? && current_user.family_membership %>
|
||||
<%= link_to family_member_path(current_user.family_membership),
|
||||
method: :delete,
|
||||
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
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if policy(@family).destroy? %>
|
||||
<%= link_to family_path(@family),
|
||||
<%= link_to family_path,
|
||||
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.' },
|
||||
class: "btn btn-outline btn-error" do %>
|
||||
class: "btn btn-outline btm-sm btn-error" do %>
|
||||
<%= icon 'trash-2', class: "inline-block w-4" %>
|
||||
Delete
|
||||
<% end %>
|
||||
|
|
@ -187,7 +187,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<% if policy(@family).manage_invitations? %>
|
||||
<%= link_to family_invitation_path(@family, invitation),
|
||||
<%= link_to family_invitation_path(invitation.token),
|
||||
method: :delete,
|
||||
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 %>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<h1 class="text-2xl font-bold text-base-content">
|
||||
<%= t('family_invitations.index.title', default: 'Family Invitations') %>
|
||||
</h1>
|
||||
<%= link_to family_path(@family),
|
||||
<%= link_to family_path,
|
||||
class: "btn btn-neutral" do %>
|
||||
<%= t('family_invitations.index.back_to_family', default: 'Back to Family') %>
|
||||
<% end %>
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
<% end %>
|
||||
|
||||
<% if policy(@family).manage_invitations? %>
|
||||
<%= link_to family_invitation_path(@family, invitation),
|
||||
<%= link_to family_invitation_path(invitation.token),
|
||||
method: :delete,
|
||||
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 %>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
<div class="fixed top-5 right-5 flex flex-col gap-2 z-50" id="flash-messages">
|
||||
<% flash.each do |key, value| %>
|
||||
<div data-controller="removals"
|
||||
data-removals-timeout-value="5000"
|
||||
class="flex items-center <%= classes_for_flash(key) %> py-3 px-5 rounded-lg z-[6000]">
|
||||
<div class="mr-4"><%= value %></div>
|
||||
|
||||
<button type="button" data-action="click->removals#remove">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
data-removals-timeout-value="<%= key.to_sym.in?([:notice, :success]) ? 5000 : 0 %>"
|
||||
role="alert"
|
||||
class="alert <%= flash_alert_class(key) %> shadow-lg z-[6000]">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= flash_icon(key) %>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
<% if current_user.in_family? %>
|
||||
<div data-controller="family-navbar-indicator"
|
||||
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>
|
||||
<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"
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= link_to 'Family', families_path, class: "#{active_class?(families_path)}" %>
|
||||
<%= link_to 'Family', new_family_path, class: "#{active_class?(new_family_path)}" %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
<% if current_user.in_family? %>
|
||||
<div data-controller="family-navbar-indicator"
|
||||
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>
|
||||
<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"
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<% 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 %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -40,23 +40,7 @@ class DawarichSettings
|
|||
end
|
||||
|
||||
def family_feature_enabled?
|
||||
@family_feature_enabled ||= self_hosted? || family_subscription_active?
|
||||
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
|
||||
@family_feature_enabled ||= self_hosted?
|
||||
end
|
||||
|
||||
def features
|
||||
|
|
|
|||
|
|
@ -58,23 +58,21 @@ Rails.application.routes.draw do
|
|||
resources :trips
|
||||
|
||||
# Family management routes (only if feature is enabled)
|
||||
# if DawarichSettings.family_feature_enabled?
|
||||
resources :families do
|
||||
member do
|
||||
delete :leave
|
||||
patch :update_location_sharing
|
||||
end
|
||||
resources :invitations, except: %i[edit update], controller: 'family/invitations' do
|
||||
member do
|
||||
post :accept
|
||||
if DawarichSettings.family_feature_enabled?
|
||||
resource :family, only: %i[show new create edit update destroy] do
|
||||
patch :update_location_sharing, on: :member
|
||||
|
||||
resources :invitations, except: %i[edit update], controller: 'family/invitations' do
|
||||
member do
|
||||
post :accept
|
||||
end
|
||||
end
|
||||
resources :members, only: %i[destroy], controller: 'family/memberships'
|
||||
end
|
||||
resources :members, only: %i[destroy], controller: 'family/memberships'
|
||||
|
||||
get 'invitations/:token', to: 'family/invitations#show', as: :public_invitation
|
||||
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
|
||||
collection do
|
||||
delete :bulk_destroy
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -34,9 +34,9 @@ RSpec.describe 'Families', type: :request do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'GET /families/:id' do
|
||||
describe 'GET /family' do
|
||||
it 'shows the family page' do
|
||||
get "/families/#{family.id}"
|
||||
get "/family"
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
|
|
@ -46,8 +46,8 @@ RSpec.describe 'Families', type: :request do
|
|||
before { sign_in outsider }
|
||||
|
||||
it 'redirects to families index' do
|
||||
get "/families/#{family.id}"
|
||||
expect(response).to redirect_to(families_path)
|
||||
get "/family"
|
||||
expect(response).to redirect_to(family_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -119,7 +119,7 @@ RSpec.describe 'Families', type: :request do
|
|||
|
||||
describe 'GET /families/:id/edit' do
|
||||
it 'shows the edit form' do
|
||||
get "/families/#{family.id}/edit"
|
||||
get "/family/edit"
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ RSpec.describe 'Families', type: :request do
|
|||
before { membership.update!(role: :member) }
|
||||
|
||||
it 'redirects due to authorization failure' do
|
||||
get "/families/#{family.id}/edit"
|
||||
get "/family/edit"
|
||||
expect(response).to have_http_status(:see_other)
|
||||
expect(flash[:alert]).to include('not authorized')
|
||||
end
|
||||
|
|
@ -139,7 +139,7 @@ RSpec.describe 'Families', type: :request do
|
|||
|
||||
context 'with valid attributes' do
|
||||
it 'updates the family' do
|
||||
patch "/families/#{family.id}", params: new_attributes
|
||||
patch "/family", params: new_attributes
|
||||
family.reload
|
||||
expect(family.name).to eq('Updated Family Name')
|
||||
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
|
||||
original_name = family.name
|
||||
patch "/families/#{family.id}", params: invalid_attributes
|
||||
patch "/family", params: invalid_attributes
|
||||
family.reload
|
||||
expect(family.name).to eq(original_name)
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
|
|
@ -162,7 +162,7 @@ RSpec.describe 'Families', type: :request do
|
|||
before { membership.update!(role: :member) }
|
||||
|
||||
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(flash[:alert]).to include('not authorized')
|
||||
end
|
||||
|
|
@ -173,9 +173,9 @@ RSpec.describe 'Families', type: :request do
|
|||
context 'when family has only one member' do
|
||||
it 'deletes the family' do
|
||||
expect do
|
||||
delete "/families/#{family.id}"
|
||||
delete "/family"
|
||||
end.to change(Family, :count).by(-1)
|
||||
expect(response).to redirect_to(families_path)
|
||||
expect(response).to redirect_to(family_path)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -186,7 +186,7 @@ RSpec.describe 'Families', type: :request do
|
|||
|
||||
it 'does not delete the family' do
|
||||
expect do
|
||||
delete "/families/#{family.id}"
|
||||
delete "/family"
|
||||
end.not_to change(Family, :count)
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
follow_redirect!
|
||||
|
|
@ -198,49 +198,13 @@ RSpec.describe 'Families', type: :request do
|
|||
before { membership.update!(role: :member) }
|
||||
|
||||
it 'redirects due to authorization failure' do
|
||||
delete "/families/#{family.id}"
|
||||
delete "/family"
|
||||
expect(response).to have_http_status(:see_other)
|
||||
expect(flash[:alert]).to include('not authorized')
|
||||
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
|
||||
let(:outsider) { create(:user) }
|
||||
|
|
@ -248,29 +212,25 @@ RSpec.describe 'Families', type: :request do
|
|||
before { sign_in outsider }
|
||||
|
||||
it 'denies access to show when user is not in family' do
|
||||
get "/families/#{family.id}"
|
||||
expect(response).to redirect_to(families_path)
|
||||
get "/family"
|
||||
expect(response).to redirect_to(family_path)
|
||||
end
|
||||
|
||||
it 'redirects to families index when user is not in family for edit' do
|
||||
get "/families/#{family.id}/edit"
|
||||
expect(response).to redirect_to(families_path)
|
||||
get "/family/edit"
|
||||
expect(response).to redirect_to(family_path)
|
||||
end
|
||||
|
||||
it 'redirects to families index when user is not in family for update' do
|
||||
patch "/families/#{family.id}", params: { family: { name: 'Hacked' } }
|
||||
expect(response).to redirect_to(families_path)
|
||||
patch "/family", params: { family: { name: 'Hacked' } }
|
||||
expect(response).to redirect_to(family_path)
|
||||
end
|
||||
|
||||
it 'redirects to families index when user is not in family for destroy' do
|
||||
delete "/families/#{family.id}"
|
||||
expect(response).to redirect_to(families_path)
|
||||
delete "/family"
|
||||
expect(response).to redirect_to(family_path)
|
||||
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
|
||||
|
||||
describe 'authentication required' do
|
||||
|
|
@ -282,7 +242,7 @@ RSpec.describe 'Families', type: :request do
|
|||
end
|
||||
|
||||
it 'redirects to login for show' do
|
||||
get "/families/#{family.id}"
|
||||
get "/family"
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
|
||||
|
|
@ -297,22 +257,17 @@ RSpec.describe 'Families', type: :request do
|
|||
end
|
||||
|
||||
it 'redirects to login for edit' do
|
||||
get "/families/#{family.id}/edit"
|
||||
get "/family/edit"
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
it 'redirects to login for destroy' do
|
||||
delete "/families/#{family.id}"
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
|
||||
it 'redirects to login for leave' do
|
||||
delete "/families/#{family.id}/leave"
|
||||
delete "/family"
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
|
||||
it 'shows pending invitations' do
|
||||
invitation # create the invitation
|
||||
get "/families/#{family.id}/invitations"
|
||||
get "/family/invitations"
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
|
|
@ -28,8 +28,8 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
before { sign_in outsider }
|
||||
|
||||
it 'redirects to families index' do
|
||||
get "/families/#{family.id}/invitations"
|
||||
expect(response).to redirect_to(families_path)
|
||||
get "/family/invitations"
|
||||
expect(response).to redirect_to(new_family_path)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
before { sign_out user }
|
||||
|
||||
it 'redirects to login' do
|
||||
get "/families/#{family.id}/invitations"
|
||||
get "/family/invitations"
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
end
|
||||
|
|
@ -91,13 +91,13 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
|
||||
it 'creates a new invitation' 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
|
||||
|
||||
it 'redirects with success message' do
|
||||
post "/families/#{family.id}/invitations", params: valid_params
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
post "/family/invitations", params: valid_params
|
||||
expect(response).to redirect_to(family_path)
|
||||
follow_redirect!
|
||||
expect(response.body).to include('Invitation sent successfully!')
|
||||
end
|
||||
|
|
@ -111,14 +111,14 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
it 'does not create a duplicate invitation' do
|
||||
invitation # create the existing invitation
|
||||
expect do
|
||||
post "/families/#{family.id}/invitations", params: duplicate_params
|
||||
post "/family/invitations", params: duplicate_params
|
||||
end.not_to change(FamilyInvitation, :count)
|
||||
end
|
||||
|
||||
it 'redirects with error message' do
|
||||
invitation # create the existing invitation
|
||||
post "/families/#{family.id}/invitations", params: duplicate_params
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
post "/family/invitations", params: duplicate_params
|
||||
expect(response).to redirect_to(family_path)
|
||||
follow_redirect!
|
||||
expect(response.body).to include('Invitation already sent to this email')
|
||||
end
|
||||
|
|
@ -128,7 +128,7 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
before { membership.update!(role: :member) }
|
||||
|
||||
it 'redirects due to authorization failure' do
|
||||
post "/families/#{family.id}/invitations", params: {
|
||||
post "/family/invitations", params: {
|
||||
family_invitation: { email: 'test@example.com' }
|
||||
}
|
||||
expect(response).to have_http_status(:see_other)
|
||||
|
|
@ -142,10 +142,10 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
before { sign_in outsider }
|
||||
|
||||
it 'redirects to families index' do
|
||||
post "/families/#{family.id}/invitations", params: {
|
||||
post "/family/invitations", params: {
|
||||
family_invitation: { email: 'test@example.com' }
|
||||
}
|
||||
expect(response).to redirect_to(families_path)
|
||||
expect(response).to redirect_to(new_family_path)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
before { sign_out user }
|
||||
|
||||
it 'redirects to login' do
|
||||
post "/families/#{family.id}/invitations", params: {
|
||||
post "/family/invitations", params: {
|
||||
family_invitation: { email: 'test@example.com' }
|
||||
}
|
||||
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
|
||||
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
|
||||
|
||||
it 'redirects with success message' do
|
||||
post "/families/#{family.id}/invitations/#{invitee_invitation.token}/accept"
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
post "/family/invitations/#{invitee_invitation.token}/accept"
|
||||
expect(response).to redirect_to(family_path)
|
||||
follow_redirect!
|
||||
expect(response.body).to include('Welcome to the family!')
|
||||
end
|
||||
|
||||
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
|
||||
expect(invitee_invitation.status).to eq('accepted')
|
||||
end
|
||||
|
|
@ -198,12 +198,12 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
|
||||
it 'does not accept the invitation' 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
|
||||
|
||||
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(flash[:alert]).to include('You must leave your current family before joining a new one')
|
||||
end
|
||||
|
|
@ -217,12 +217,12 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
|
||||
it 'does not accept the invitation' 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
|
||||
|
||||
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(flash[:alert]).to include('This invitation is no longer valid or has expired')
|
||||
end
|
||||
|
|
@ -230,7 +230,7 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
|
||||
context 'when not authenticated' 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)
|
||||
end
|
||||
end
|
||||
|
|
@ -240,14 +240,14 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
before { sign_in user }
|
||||
|
||||
it 'cancels the invitation' do
|
||||
delete "/families/#{family.id}/invitations/#{invitation.id}"
|
||||
delete "/family/invitations/#{invitation.token}"
|
||||
invitation.reload
|
||||
expect(invitation.status).to eq('cancelled')
|
||||
end
|
||||
|
||||
it 'redirects with success message' do
|
||||
delete "/families/#{family.id}/invitations/#{invitation.id}"
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
delete "/family/invitations/#{invitation.token}"
|
||||
expect(response).to redirect_to(family_path)
|
||||
follow_redirect!
|
||||
expect(response.body).to include('Invitation cancelled')
|
||||
end
|
||||
|
|
@ -256,7 +256,7 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
before { membership.update!(role: :member) }
|
||||
|
||||
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(flash[:alert]).to include('not authorized')
|
||||
end
|
||||
|
|
@ -268,8 +268,8 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
before { sign_in outsider }
|
||||
|
||||
it 'redirects to families index' do
|
||||
delete "/families/#{family.id}/invitations/#{invitation.token}"
|
||||
expect(response).to redirect_to(families_path)
|
||||
delete "/family/invitations/#{invitation.token}"
|
||||
expect(response).to redirect_to(new_family_path)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -277,7 +277,7 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
before { sign_out user }
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
|
@ -289,10 +289,10 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
it 'completes full invitation acceptance workflow' do
|
||||
# 1. Owner creates invitation
|
||||
sign_in user
|
||||
post "/families/#{family.id}/invitations", params: {
|
||||
post "/family/invitations", params: {
|
||||
family_invitation: { email: invitee.email }
|
||||
}
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
expect(response).to redirect_to(family_path)
|
||||
|
||||
created_invitation = FamilyInvitation.last
|
||||
expect(created_invitation.email).to eq(invitee.email)
|
||||
|
|
@ -304,8 +304,8 @@ RSpec.describe 'Family::Invitations', type: :request do
|
|||
|
||||
# 3. Invitee accepts invitation
|
||||
sign_in invitee
|
||||
post "/families/#{family.id}/invitations/#{created_invitation.token}/accept"
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
post "/family/invitations/#{created_invitation.token}/accept"
|
||||
expect(response).to redirect_to(family_path)
|
||||
|
||||
# 4. Verify invitee is now in family
|
||||
expect(invitee.reload.family).to eq(family)
|
||||
|
|
|
|||
|
|
@ -19,20 +19,20 @@ RSpec.describe 'Family::Memberships', type: :request do
|
|||
context 'when removing a regular member' do
|
||||
it 'removes the member from the family' 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
|
||||
|
||||
it 'redirects with success message' do
|
||||
member_email = member_user.email
|
||||
delete "/families/#{family.id}/members/#{member_membership.id}"
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
delete "/family/members/#{member_membership.id}"
|
||||
expect(response).to redirect_to(family_path)
|
||||
follow_redirect!
|
||||
expect(response.body).to include("#{member_email} has been removed from the family")
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
@ -40,13 +40,13 @@ RSpec.describe 'Family::Memberships', type: :request do
|
|||
context 'when trying to remove the owner' do
|
||||
it 'does not remove the owner' do
|
||||
expect do
|
||||
delete "/families/#{family.id}/members/#{owner_membership.id}"
|
||||
delete "/family/members/#{owner_membership.id}"
|
||||
end.not_to change(FamilyMembership, :count)
|
||||
end
|
||||
|
||||
it 'redirects with error message explaining owners must delete family' do
|
||||
delete "/families/#{family.id}/members/#{owner_membership.id}"
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
delete "/family/members/#{owner_membership.id}"
|
||||
expect(response).to redirect_to(family_path)
|
||||
follow_redirect!
|
||||
expect(response.body).to include('Family owners cannot remove their own membership. To leave the family, delete it instead.')
|
||||
end
|
||||
|
|
@ -55,10 +55,10 @@ RSpec.describe 'Family::Memberships', type: :request do
|
|||
member_membership.destroy!
|
||||
|
||||
expect do
|
||||
delete "/families/#{family.id}/members/#{owner_membership.id}"
|
||||
delete "/family/members/#{owner_membership.id}"
|
||||
end.not_to change(FamilyMembership, :count)
|
||||
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
expect(response).to redirect_to(family_path)
|
||||
follow_redirect!
|
||||
expect(response.body).to include('Family owners cannot remove their own membership')
|
||||
end
|
||||
|
|
@ -80,8 +80,8 @@ RSpec.describe 'Family::Memberships', type: :request do
|
|||
before { sign_in outsider }
|
||||
|
||||
it 'redirects to families index' do
|
||||
delete "/families/#{family.id}/members/#{member_membership.id}"
|
||||
expect(response).to redirect_to(families_path)
|
||||
delete "/family/members/#{member_membership.id}"
|
||||
expect(response).to redirect_to(new_family_path)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ RSpec.describe 'Family::Memberships', type: :request do
|
|||
before { sign_out user }
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
|
@ -100,7 +100,7 @@ RSpec.describe 'Family::Memberships', type: :request do
|
|||
before { sign_in member_user }
|
||||
|
||||
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(flash[:alert]).to include('not authorized')
|
||||
end
|
||||
|
|
@ -115,7 +115,7 @@ RSpec.describe 'Family::Memberships', type: :request do
|
|||
expect(member_user.family).to eq(family)
|
||||
|
||||
# Remove member
|
||||
delete "/families/#{family.id}/members/#{member_membership.id}"
|
||||
delete "/family/members/#{member_membership.id}"
|
||||
|
||||
# Verify removal
|
||||
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
|
||||
expect do
|
||||
delete "/families/#{family.id}/members/#{owner_membership.id}"
|
||||
delete "/family/members/#{owner_membership.id}"
|
||||
end.not_to change(FamilyMembership, :count)
|
||||
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
|
|
@ -157,20 +157,14 @@ RSpec.describe 'Family::Memberships', type: :request do
|
|||
end
|
||||
|
||||
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!
|
||||
|
||||
# Try to remove owner membership - should fail
|
||||
delete "/families/#{family.id}/members/#{owner_membership.id}"
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
delete "/family/members/#{owner_membership.id}"
|
||||
expect(response).to redirect_to(family_path)
|
||||
expect(flash[:alert]).to include('Family owners cannot remove their own membership')
|
||||
|
||||
# Owner must delete the family instead
|
||||
delete "/families/#{family.id}"
|
||||
expect(response).to redirect_to(families_path)
|
||||
delete "/family"
|
||||
expect(response).to redirect_to(new_family_path)
|
||||
expect(user.reload.family).to be_nil
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,13 +17,10 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
# Step 1: User1 creates a family
|
||||
sign_in user1
|
||||
|
||||
get '/families'
|
||||
get '/family/new'
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
get '/families/new'
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
post '/families', params: { family: { name: 'The Smith Family' } }
|
||||
post '/family', params: { family: { name: 'The Smith Family' } }
|
||||
|
||||
# The redirect should be to the newly created family
|
||||
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
|
||||
|
||||
# Step 2: User1 invites User2
|
||||
post "/families/#{family.id}/invitations", params: {
|
||||
post "/family/invitations", params: {
|
||||
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)
|
||||
expect(invitation).to be_present
|
||||
|
|
@ -55,8 +52,8 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
|
||||
# User2 accepts invitation
|
||||
sign_in user2
|
||||
post "/families/#{family.id}/invitations/#{invitation.token}/accept"
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
post "/family/invitations/#{invitation.token}/accept"
|
||||
expect(response).to redirect_to(family_path)
|
||||
|
||||
expect(user2.reload.family).to eq(family)
|
||||
expect(user2.family_owner?).to be false
|
||||
|
|
@ -64,7 +61,7 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
|
||||
# Step 4: User1 invites User3
|
||||
sign_in user1
|
||||
post "/families/#{family.id}/invitations", params: {
|
||||
post "/family/invitations", params: {
|
||||
family_invitation: { email: user3.email }
|
||||
}
|
||||
|
||||
|
|
@ -74,19 +71,19 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
|
||||
# Step 5: User3 accepts invitation
|
||||
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(family.reload.members.count).to eq(3)
|
||||
|
||||
# Step 6: Family owner views members on family show page
|
||||
sign_in user1
|
||||
get "/families/#{family.id}"
|
||||
get "/family"
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
# Step 7: Owner removes a member
|
||||
delete "/families/#{family.id}/members/#{user2.family_membership.id}"
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
delete "/family/members/#{user2.family_membership.id}"
|
||||
expect(response).to redirect_to(family_path)
|
||||
|
||||
expect(user2.reload.family).to be_nil
|
||||
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
|
||||
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(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
|
||||
# User3 accepts invitation to Family 1
|
||||
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(user3.family).to eq(family1)
|
||||
|
||||
# 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(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
|
||||
sign_in user1
|
||||
|
||||
# Owner tries to leave family with members
|
||||
delete "/families/#{family.id}/leave"
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
# Owner tries to leave family with members (using memberships destroy route)
|
||||
owner_membership = user1.family_membership
|
||||
delete "/family/members/#{owner_membership.id}"
|
||||
expect(response).to redirect_to(family_path)
|
||||
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.family_owner?).to be true
|
||||
|
|
@ -165,22 +163,23 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
sign_in user1
|
||||
|
||||
# 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)
|
||||
expect do
|
||||
delete "/families/#{family.id}/leave"
|
||||
end.to change(Family, :count).by(-1)
|
||||
# Owner cannot leave even when alone - they must delete the family instead
|
||||
owner_membership = user1.reload.family_membership
|
||||
delete "/family/members/#{owner_membership.id}"
|
||||
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 be_nil
|
||||
expect(user1.reload.family).to eq(family)
|
||||
end
|
||||
|
||||
it 'allows members to leave freely' do
|
||||
sign_in user2
|
||||
|
||||
delete "/families/#{family.id}/leave"
|
||||
expect(response).to redirect_to(families_path)
|
||||
delete "/family/members/#{member_membership.id}"
|
||||
expect(response).to redirect_to(new_family_path)
|
||||
|
||||
expect(user2.reload.family).to be_nil
|
||||
expect(family.reload.members.count).to eq(1)
|
||||
|
|
@ -200,10 +199,10 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
sign_in user1
|
||||
|
||||
expect do
|
||||
delete "/families/#{family.id}"
|
||||
delete "/family"
|
||||
end.not_to change(Family, :count)
|
||||
|
||||
expect(response).to redirect_to(family_path(family))
|
||||
expect(response).to redirect_to(family_path)
|
||||
follow_redirect!
|
||||
expect(response.body).to include('Cannot delete family with members')
|
||||
end
|
||||
|
|
@ -213,10 +212,10 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
sign_in user1
|
||||
|
||||
expect do
|
||||
delete "/families/#{family.id}"
|
||||
delete "/family"
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
@ -229,19 +228,19 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
it 'enforces proper authorization for family management' do
|
||||
# Member cannot invite others
|
||||
sign_in user2
|
||||
post "/families/#{family.id}/invitations", params: {
|
||||
post "/family/invitations", params: {
|
||||
family_invitation: { email: user3.email }
|
||||
}
|
||||
expect(response).to have_http_status(:see_other)
|
||||
expect(flash[:alert]).to include('not authorized')
|
||||
|
||||
# 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(flash[:alert]).to include('not authorized')
|
||||
|
||||
# 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(flash[:alert]).to include('not authorized')
|
||||
|
||||
|
|
@ -252,8 +251,8 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
|
||||
# Outsider cannot access family
|
||||
sign_in user3
|
||||
get "/families/#{family.id}"
|
||||
expect(response).to redirect_to(families_path)
|
||||
get "/family"
|
||||
expect(response).to redirect_to(new_family_path)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -266,7 +265,7 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
|
||||
# Mock email delivery
|
||||
expect do
|
||||
post "/families/#{family.id}/invitations", params: {
|
||||
post "/family/invitations", params: {
|
||||
family_invitation: { email: 'newuser@example.com' }
|
||||
}
|
||||
end.to change(FamilyInvitation, :count).by(1)
|
||||
|
|
@ -280,22 +279,22 @@ RSpec.describe 'Family Workflows', type: :request do
|
|||
|
||||
describe 'Navigation and redirect workflow' 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
|
||||
get '/families'
|
||||
get '/family/new'
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
# User creates family
|
||||
post '/families', params: { family: { name: 'Test Family' } }
|
||||
post '/family', params: { family: { name: 'Test Family' } }
|
||||
expect(response).to have_http_status(:found)
|
||||
|
||||
# User with family gets redirected from index to family page
|
||||
get '/families'
|
||||
expect(response).to redirect_to(family_path(user1.reload.family))
|
||||
# User with family can view their family
|
||||
get '/family'
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
# User with family gets redirected from new family page
|
||||
get '/families/new'
|
||||
expect(response).to redirect_to(family_path(user1.reload.family))
|
||||
get '/family/new'
|
||||
expect(response).to redirect_to(family_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Families::Leave do
|
||||
RSpec.describe Families::Memberships::Destroy do
|
||||
let(:user) { create(:user) }
|
||||
let(:family) { create(:family, creator: 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
|
||||
let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) }
|
||||
|
||||
it 'removes the membership' do
|
||||
expect { service.call }.to change(FamilyMembership, :count).by(-1)
|
||||
expect(user.reload.family_membership).to be_nil
|
||||
it 'prevents owner from leaving' do
|
||||
expect { service.call }.not_to change(FamilyMembership, :count)
|
||||
expect(user.reload.family_membership).to be_present
|
||||
end
|
||||
|
||||
it 'deletes the family' do
|
||||
expect { service.call }.to change(Family, :count).by(-1)
|
||||
it 'does not delete the family' do
|
||||
expect { service.call }.not_to change(Family, :count)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.call).to be true
|
||||
it 'returns false' do
|
||||
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
|
||||
|
||||
Loading…
Reference in a new issue