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