Refactor family invitations and memberships into separate models and controllers

This commit is contained in:
Eugene Burmakin 2025-10-07 18:38:06 +02:00
parent 6fb5d98b19
commit e711ff25fe
37 changed files with 336 additions and 245 deletions

View file

@ -1,2 +1,3 @@
release: bundle exec rails db:migrate
web: bundle exec puma -C config/puma.rb web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -C config/sidekiq.yml worker: bundle exec sidekiq -C config/sidekiq.yml

View file

@ -5,11 +5,6 @@
{ "url": "https://github.com/heroku/heroku-buildpack-nodejs.git" }, { "url": "https://github.com/heroku/heroku-buildpack-nodejs.git" },
{ "url": "https://github.com/heroku/heroku-buildpack-ruby.git" } { "url": "https://github.com/heroku/heroku-buildpack-ruby.git" }
], ],
"scripts": {
"dokku": {
"predeploy": "bundle exec rails db:migrate"
}
},
"healthchecks": { "healthchecks": {
"web": [ "web": [
{ {

View file

@ -3,9 +3,8 @@
class Family::InvitationsController < ApplicationController class Family::InvitationsController < ApplicationController
before_action :authenticate_user!, except: %i[show] before_action :authenticate_user!, except: %i[show]
before_action :ensure_family_feature_enabled!, except: %i[show] before_action :ensure_family_feature_enabled!, except: %i[show]
before_action :set_family, except: %i[show accept] before_action :set_family, except: %i[show]
before_action :set_invitation_by_id_and_family, only: %i[destroy] before_action :set_invitation_by_id_and_family, only: %i[destroy]
before_action :set_invitation_by_id, only: %i[accept]
def index def index
authorize @family, :show? authorize @family, :show?
@ -14,7 +13,7 @@ class Family::InvitationsController < ApplicationController
end end
def show def show
@invitation = FamilyInvitation.find_by!(token: params[:token]) @invitation = Family::Invitation.find_by!(token: params[:token])
if @invitation.expired? if @invitation.expired?
redirect_to root_path, alert: 'This invitation has expired.' and return redirect_to root_path, alert: 'This invitation has expired.' and return
@ -41,34 +40,6 @@ class Family::InvitationsController < ApplicationController
end end
end end
def accept
unless @invitation.pending?
redirect_to root_path, alert: 'This invitation has already been processed' and return
end
if @invitation.expired?
redirect_to root_path, alert: 'This invitation is no longer valid or has expired' and return
end
if @invitation.email != current_user.email
redirect_to root_path, alert: 'This invitation is not for your email address' and return
end
service = Families::AcceptInvitation.new(
invitation: @invitation,
user: current_user
)
if service.call
redirect_to family_path, notice: 'Welcome to the family!'
else
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
end
rescue StandardError => e
Rails.logger.error "Error accepting family invitation: #{e.message}"
redirect_to root_path, alert: 'An unexpected error occurred. Please try again later'
end
def destroy def destroy
authorize @family, :manage_invitations? authorize @family, :manage_invitations?
@ -92,10 +63,6 @@ class Family::InvitationsController < ApplicationController
redirect_to new_family_path, alert: 'You are not in a family' 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_id
@invitation = FamilyInvitation.find_by!(token: params[:id])
end
def set_invitation_by_id_and_family def set_invitation_by_id_and_family
# For authenticated nested routes: /families/:family_id/invitations/:id # For authenticated nested routes: /families/:family_id/invitations/:id
# The :id param contains the token value # The :id param contains the token value

View file

@ -3,8 +3,37 @@
class Family::MembershipsController < ApplicationController class Family::MembershipsController < 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 before_action :set_family, except: %i[create]
before_action :set_membership, only: %i[destroy] before_action :set_membership, only: %i[destroy]
before_action :set_invitation, only: %i[create]
def create
unless @invitation.pending?
redirect_to root_path, alert: 'This invitation has already been processed' and return
end
if @invitation.expired?
redirect_to root_path, alert: 'This invitation is no longer valid or has expired' and return
end
if @invitation.email != current_user.email
redirect_to root_path, alert: 'This invitation is not for your email address' and return
end
service = Families::AcceptInvitation.new(
invitation: @invitation,
user: current_user
)
if service.call
redirect_to family_path, notice: 'Welcome to the family!'
else
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
end
rescue StandardError => e
Rails.logger.error "Error accepting family invitation: #{e.message}"
redirect_to root_path, alert: 'An unexpected error occurred. Please try again later'
end
def destroy def destroy
authorize @membership authorize @membership
@ -34,4 +63,8 @@ class Family::MembershipsController < ApplicationController
def set_membership def set_membership
@membership = @family.family_memberships.find(params[:id]) @membership = @family.family_memberships.find(params[:id])
end end
def set_invitation
@invitation = Family::Invitation.find_by!(token: params[:token])
end
end end

View file

@ -49,7 +49,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
def set_invitation def set_invitation
return unless invitation_token.present? return unless invitation_token.present?
@invitation = FamilyInvitation.find_by(token: invitation_token) @invitation = Family::Invitation.find_by(token: invitation_token)
end end
def self_hosted_mode? def self_hosted_mode?

View file

@ -11,7 +11,7 @@ class Users::SessionsController < Devise::SessionsController
def after_sign_in_path_for(resource) def after_sign_in_path_for(resource)
if invitation_token.present? if invitation_token.present?
invitation = FamilyInvitation.find_by(token: invitation_token) invitation = Family::Invitation.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)
@ -26,7 +26,7 @@ class Users::SessionsController < Devise::SessionsController
def load_invitation_context def load_invitation_context
return unless invitation_token.present? return unless invitation_token.present?
@invitation = FamilyInvitation.find_by(token: invitation_token) @invitation = Family::Invitation.find_by(token: invitation_token)
end end
def invitation_token def invitation_token

View file

@ -6,16 +6,16 @@ class FamilyInvitationsCleanupJob < ApplicationJob
def perform def perform
Rails.logger.info 'Starting family invitations cleanup' Rails.logger.info 'Starting family invitations cleanup'
expired_count = FamilyInvitation.where(status: :pending) expired_count = Family::Invitation.where(status: :pending)
.where('expires_at < ?', Time.current) .where('expires_at < ?', Time.current)
.update_all(status: :expired) .update_all(status: :expired)
Rails.logger.info "Updated #{expired_count} expired family invitations" Rails.logger.info "Updated #{expired_count} expired family invitations"
cleanup_threshold = 30.days.ago cleanup_threshold = 30.days.ago
deleted_count = FamilyInvitation.where(status: [:expired, :cancelled]) deleted_count = Family::Invitation.where(status: [:expired, :cancelled])
.where('updated_at < ?', cleanup_threshold) .where('updated_at < ?', cleanup_threshold)
.delete_all .delete_all
Rails.logger.info "Deleted #{deleted_count} old family invitations" Rails.logger.info "Deleted #{deleted_count} old family invitations"

View file

@ -6,10 +6,10 @@ class FamilyMailer < ApplicationMailer
@family = invitation.family @family = invitation.family
@invited_by = invitation.invited_by @invited_by = invitation.invited_by
@accept_url = family_invitation_url(@invitation.token) @accept_url = family_invitation_url(@invitation.token)
pp @accept_url
mail( mail(
to: @invitation.email, to: @invitation.email,
subject: "You've been invited to join #{@family.name} on Dawarich" subject: "🎉 You've been invited to join #{@family.name} on Dawarich!"
) )
end end
end end

View file

@ -4,11 +4,10 @@ module UserFamily
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
# Family associations has_one :family_membership, dependent: :destroy, class_name: 'Family::Membership'
has_one :family_membership, dependent: :destroy
has_one :family, through: :family_membership has_one :family, through: :family_membership
has_one :created_family, class_name: 'Family', foreign_key: 'creator_id', inverse_of: :creator, dependent: :destroy has_one :created_family, class_name: 'Family', foreign_key: 'creator_id', inverse_of: :creator, dependent: :destroy
has_many :sent_family_invitations, class_name: 'FamilyInvitation', foreign_key: 'invited_by_id', has_many :sent_family_invitations, class_name: 'Family::Invitation', foreign_key: 'invited_by_id',
inverse_of: :invited_by, dependent: :destroy inverse_of: :invited_by, dependent: :destroy
before_destroy :check_family_ownership before_destroy :check_family_ownership
@ -30,25 +29,14 @@ module UserFamily
end end
def family_sharing_enabled? def family_sharing_enabled?
# User must be in a family and have explicitly enabled location sharing
return false unless in_family? return false unless in_family?
sharing_settings = settings.dig('family', 'location_sharing') sharing_settings = settings.dig('family', 'location_sharing')
return false if sharing_settings.blank? return false unless sharing_settings.is_a?(Hash)
return false unless sharing_settings['enabled'] == true
# If it's a boolean (legacy support), return it expires_at = sharing_settings['expires_at']
return sharing_settings if [true, false].include?(sharing_settings) expires_at.blank? || Time.parse(expires_at).future?
# If it's time-limited sharing, check if it's still active
if sharing_settings.is_a?(Hash)
return false unless sharing_settings['enabled'] == true
# Check if sharing has an expiration
expires_at = sharing_settings['expires_at']
return expires_at.blank? || Time.parse(expires_at) > Time.current
end
false
end end
def update_family_location_sharing!(enabled, duration: nil) def update_family_location_sharing!(enabled, duration: nil)
@ -60,21 +48,14 @@ module UserFamily
if enabled if enabled
sharing_config = { 'enabled' => true } sharing_config = { 'enabled' => true }
# Add expiration if duration is specified
if duration.present? if duration.present?
expiration_time = case duration expiration_time = case duration
when '1h' when '1h' then 1.hour.from_now
1.hour.from_now when '6h' then 6.hours.from_now
when '6h' when '12h' then 12.hours.from_now
6.hours.from_now when '24h' then 24.hours.from_now
when '12h' when 'permanent' then nil
12.hours.from_now else duration.to_i.hours.from_now if duration.to_i > 0
when '24h'
24.hours.from_now
when 'permanent'
nil # No expiration
else
duration.to_i.hours.from_now if duration.to_i > 0
end end
sharing_config['expires_at'] = expiration_time.iso8601 if expiration_time sharing_config['expires_at'] = expiration_time.iso8601 if expiration_time
@ -106,21 +87,21 @@ module UserFamily
def latest_location_for_family def latest_location_for_family
return nil unless family_sharing_enabled? return nil unless family_sharing_enabled?
# Use select to only fetch needed columns and limit to 1 for efficiency latest_point =
latest_point = points.select(:latitude, :longitude, :timestamp) points.select(:lonlat, :timestamp)
.order(timestamp: :desc) .order(timestamp: :desc)
.limit(1) .limit(1)
.first .first
return nil unless latest_point return nil unless latest_point
{ {
user_id: id, user_id: id,
email: email, email: email,
latitude: latest_point.latitude, latitude: latest_point.lat,
longitude: latest_point.longitude, longitude: latest_point.lon,
timestamp: latest_point.timestamp, timestamp: latest_point.timestamp,
updated_at: Time.at(latest_point.timestamp) updated_at: Time.zone.at(latest_point.timestamp)
} }
end end

View file

@ -1,9 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class Family < ApplicationRecord class Family < ApplicationRecord
has_many :family_memberships, dependent: :destroy has_many :family_memberships, dependent: :destroy, class_name: 'Family::Membership'
has_many :members, through: :family_memberships, source: :user has_many :members, through: :family_memberships, source: :user
has_many :family_invitations, dependent: :destroy has_many :family_invitations, dependent: :destroy, class_name: 'Family::Invitation'
belongs_to :creator, class_name: 'User' belongs_to :creator, class_name: 'User'
validates :name, presence: true, length: { maximum: 50 } validates :name, presence: true, length: { maximum: 50 }

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
class Family::Invitation < ApplicationRecord
self.table_name = 'family_invitations'
EXPIRY_DAYS = 7
belongs_to :family
belongs_to :invited_by, class_name: 'User'
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :token, presence: true, uniqueness: true
validates :expires_at, :status, presence: true
enum :status, { pending: 0, accepted: 1, expired: 2, cancelled: 3 }
scope :active, -> { where(status: :pending).where('expires_at > ?', Time.current) }
before_validation :generate_token, :set_expiry, on: :create
after_create :clear_family_cache
after_update :clear_family_cache, if: :saved_change_to_status?
after_destroy :clear_family_cache
def expired?
expires_at.past?
end
def can_be_accepted?
pending? && !expired?
end
private
def generate_token
self.token = SecureRandom.urlsafe_base64(32) if token.blank?
end
def set_expiry
self.expires_at = EXPIRY_DAYS.days.from_now if expires_at.blank?
end
def clear_family_cache
family&.clear_member_cache!
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Family::Membership < ApplicationRecord
self.table_name = 'family_memberships'
belongs_to :family
belongs_to :user
validates :user_id, presence: true, uniqueness: true
validates :role, presence: true
enum :role, { owner: 0, member: 1 }
after_create :clear_family_cache
after_update :clear_family_cache
after_destroy :clear_family_cache
private
def clear_family_cache
family&.clear_member_cache!
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class Family::InvitationPolicy < ApplicationPolicy
def show?
# Public endpoint for invitation acceptance - no authentication required
true
end
def create?
user.family == record.family && user.family_owner?
end
def accept?
# Users can accept invitations sent to their email
user.email == record.email
end
def destroy?
# Only family owners can cancel invitations
user.family == record.family && user.family_owner?
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Family::MembershipPolicy < ApplicationPolicy
def show?
user.family == record.family
end
def update?
# Users can update their own settings
return true if user == record.user
# Family owners can update any member's settings
user.family == record.family && user.family_owner?
end
def destroy?
# Users can remove themselves (handled by family leave logic)
return true if user == record.user
# Family owners can remove other members
user.family == record.family && user.family_owner?
end
end

View file

@ -65,7 +65,7 @@ module Families
end end
def create_membership def create_membership
FamilyMembership.create!( Family::Membership.create!(
family: invitation.family, family: invitation.family,
user: user, user: user,
role: :member role: :member

View file

@ -81,7 +81,7 @@ module Families
end end
def create_owner_membership def create_owner_membership
FamilyMembership.create!( Family::Membership.create!(
family: family, family: family,
user: user, user: user,
role: :owner role: :owner

View file

@ -73,7 +73,7 @@ module Families
end end
def create_invitation def create_invitation
@invitation = FamilyInvitation.create!( @invitation = Family::Invitation.create!(
family: family, family: family,
email: email, email: email,
invited_by: invited_by invited_by: invited_by

View file

@ -121,7 +121,7 @@
<div class="space-y-4"> <div class="space-y-4">
<% if user_signed_in? %> <% if user_signed_in? %>
<!-- User is logged in, show accept button --> <!-- User is logged in, show accept button -->
<%= link_to accept_family_invitation_path(@invitation.family, @invitation), <%= link_to accept_family_invitation_path(token: @invitation.token),
method: :post, method: :post,
class: "btn btn-success btn-lg w-full text-lg shadow-lg" do %> class: "btn btn-success btn-lg w-full text-lg shadow-lg" do %>
✓ Accept Invitation & Join Family ✓ Accept Invitation & Join Family

View file

@ -42,7 +42,7 @@
<p style="color: #6b7280; font-size: 14px; line-height: 1.6; text-align: center;"> <p style="color: #6b7280; font-size: 14px; line-height: 1.6; text-align: center;">
Best regards,<br> Best regards,<br>
The Dawarich Team Evgenii from Dawarich
</p> </p>
</div> </div>
</div> </div>

View file

@ -19,4 +19,4 @@ If you don't have a Dawarich account yet, you'll be able to create one when you
If you didn't expect this invitation, you can safely ignore this email. If you didn't expect this invitation, you can safely ignore this email.
Best regards, Best regards,
The Dawarich Team Evgenii from Dawarich

View file

@ -21,7 +21,7 @@
<% end %> <% end %>
</div> </div>
<% else %> <% else %>
<%= link_to 'Family', new_family_path, class: "#{active_class?(new_family_path)}" %> <%= link_to 'Family<sup>α</sup>'.html_safe, new_family_path, class: "#{active_class?(new_family_path)}" %>
<% end %> <% end %>
</li> </li>
<% end %> <% end %>
@ -79,14 +79,14 @@
<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, class: "mx-1 #{active_class?(family_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<sup>α</sup></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"
title="<%= current_user.family_sharing_enabled? ? 'Location sharing enabled' : 'Location sharing disabled' %>"></div> title="<%= current_user.family_sharing_enabled? ? 'Location sharing enabled' : 'Location sharing disabled' %>"></div>
<% end %> <% end %>
</div> </div>
<% else %> <% else %>
<%= link_to 'Family', new_family_path, class: "mx-1 #{active_class?(new_family_path)}" %> <%= link_to 'Family<sup>α</sup>'.html_safe, new_family_path, class: "mx-1 #{active_class?(new_family_path)}" %>
<% end %> <% end %>
</li> </li>
<% end %> <% end %>

View file

@ -38,17 +38,18 @@
<%= current_user.total_cities %> <%= current_user.total_cities %>
</div> </div>
<div class="stat-title">Cities visited</div> <div class="stat-title">Cities visited</div>
<dialog id="cities_visited" class="modal"> <dialog id="cities_visited" class="modal">
<div class="modal-box"> <div class="modal-box">
<h3 class="font-bold text-lg">Cities visited</h3> <h3 class="font-bold text-lg">Cities visited</h3>
<p class="py-4"> <p class="py-4">
<% current_user.cities_visited.each do |city| %> <% current_user.cities_visited.each do |city| %>
<p><%= city %></p> <p><%= city %></p>
<% end %> <% end %>
</p> </p>
</div> </div>
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button>close</button> <button>close</button>
</form> </form>
</dialog> </dialog>
</div>
</div> </div>

View file

@ -62,15 +62,12 @@ Rails.application.routes.draw do
resource :family, only: %i[show new create edit update destroy] do resource :family, only: %i[show new create edit update destroy] do
patch :update_location_sharing, on: :member patch :update_location_sharing, on: :member
resources :invitations, except: %i[edit update], controller: 'family/invitations' do resources :invitations, except: %i[edit update], controller: 'family/invitations'
member do
post :accept
end
end
resources :members, only: %i[destroy], controller: 'family/memberships' resources :members, only: %i[destroy], controller: 'family/memberships'
end end
get 'invitations/:token', to: 'family/invitations#show', as: :public_invitation get 'invitations/:token', to: 'family/invitations#show', as: :public_invitation
post 'family/memberships', to: 'family/memberships#create', as: :accept_family_invitation
end end
resources :points, only: %i[index] do resources :points, only: %i[index] do

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :family_invitation do factory :family_invitation, class: 'Family::Invitation' do
association :family association :family
association :invited_by, factory: :user association :invited_by, factory: :user
sequence(:email) { |n| "invite#{n}@example.com" } sequence(:email) { |n| "invite#{n}@example.com" }

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
FactoryBot.define do FactoryBot.define do
factory :family_membership do factory :family_membership, class: 'Family::Membership' do
association :family association :family
association :user association :user
role { :member } role { :member }

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe FamilyInvitation, type: :model do RSpec.describe Family::Invitation, type: :model do
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:family) } it { is_expected.to belong_to(:family) }
it { is_expected.to belong_to(:invited_by).class_name('User') } it { is_expected.to belong_to(:invited_by).class_name('User') }
@ -44,9 +44,9 @@ RSpec.describe FamilyInvitation, type: :model do
describe '.active' do describe '.active' do
it 'returns only pending and non-expired invitations' do it 'returns only pending and non-expired invitations' do
expect(FamilyInvitation.active).to include(pending_invitation) expect(Family::Invitation.active).to include(pending_invitation)
expect(FamilyInvitation.active).not_to include(expired_invitation) expect(Family::Invitation.active).not_to include(expired_invitation)
expect(FamilyInvitation.active).not_to include(accepted_invitation) expect(Family::Invitation.active).not_to include(accepted_invitation)
end end
end end
end end
@ -63,7 +63,7 @@ RSpec.describe FamilyInvitation, type: :model do
it 'sets expiry date' do it 'sets expiry date' do
invitation.save invitation.save
expect(invitation.expires_at).to be_within(1.minute).of(FamilyInvitation::EXPIRY_DAYS.days.from_now) expect(invitation.expires_at).to be_within(1.minute).of(Family::Invitation::EXPIRY_DAYS.days.from_now)
end end
it 'does not override existing token' do it 'does not override existing token' do
@ -136,7 +136,7 @@ RSpec.describe FamilyInvitation, type: :model do
describe 'constants' do describe 'constants' do
it 'defines EXPIRY_DAYS' do it 'defines EXPIRY_DAYS' do
expect(FamilyInvitation::EXPIRY_DAYS).to eq(7) expect(Family::Invitation::EXPIRY_DAYS).to eq(7)
end end
end end

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe FamilyMembership, type: :model do RSpec.describe Family::Membership, type: :model do
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:family) } it { is_expected.to belong_to(:family) }
it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:user) }

View file

@ -107,8 +107,8 @@ RSpec.describe Family, type: :model do
it 'destroys associated invitations when family is destroyed' do it 'destroys associated invitations when family is destroyed' do
invitation = create(:family_invitation, family: family, invited_by: user) invitation = create(:family_invitation, family: family, invited_by: user)
expect { family.destroy }.to change(FamilyInvitation, :count).by(-1) expect { family.destroy }.to change(Family::Invitation, :count).by(-1)
expect(FamilyInvitation.find_by(id: invitation.id)).to be_nil expect(Family::Invitation.find_by(id: invitation.id)).to be_nil
end end
end end
@ -118,8 +118,8 @@ RSpec.describe Family, type: :model do
it 'destroys associated memberships when family is destroyed' do it 'destroys associated memberships when family is destroyed' do
membership = create(:family_membership, family: family, user: user, role: :owner) membership = create(:family_membership, family: family, user: user, role: :owner)
expect { family.destroy }.to change(FamilyMembership, :count).by(-1) expect { family.destroy }.to change(Family::Membership, :count).by(-1)
expect(FamilyMembership.find_by(id: membership.id)).to be_nil expect(Family::Membership.find_by(id: membership.id)).to be_nil
end end
end end
end end

View file

@ -12,7 +12,7 @@ RSpec.describe User, 'family methods', type: :model do
is_expected.to have_one(:created_family).class_name('Family').with_foreign_key('creator_id').dependent(:destroy) is_expected.to have_one(:created_family).class_name('Family').with_foreign_key('creator_id').dependent(:destroy)
} }
it { it {
is_expected.to have_many(:sent_family_invitations).class_name('FamilyInvitation').with_foreign_key('invited_by_id').dependent(:destroy) is_expected.to have_many(:sent_family_invitations).class_name('Family::Invitation').with_foreign_key('invited_by_id').dependent(:destroy)
} }
end end
@ -119,7 +119,7 @@ RSpec.describe User, 'family methods', type: :model do
end end
it 'destroys associated invitations when user is destroyed' do it 'destroys associated invitations when user is destroyed' do
expect { user.destroy }.to change(FamilyInvitation, :count).by(-1) expect { user.destroy }.to change(Family::Invitation, :count).by(-1)
end end
end end
@ -129,7 +129,7 @@ RSpec.describe User, 'family methods', type: :model do
end end
it 'destroys associated membership when user is destroyed' do it 'destroys associated membership when user is destroyed' do
expect { user.destroy }.to change(FamilyMembership, :count).by(-1) expect { user.destroy }.to change(Family::Membership, :count).by(-1)
end end
end end
end end

View file

@ -69,7 +69,7 @@ RSpec.describe 'Family', type: :request do
it 'creates a family membership for the user' do it 'creates a family membership for the user' do
expect do expect do
post '/family', params: valid_attributes post '/family', params: valid_attributes
end.to change(FamilyMembership, :count).by(1) end.to change(Family::Membership, :count).by(1)
end end
it 'redirects to the new family with success message' do it 'redirects to the new family with success message' do

View file

@ -92,7 +92,7 @@ RSpec.describe 'Family::Invitations', type: :request do
it 'creates a new invitation' do it 'creates a new invitation' do
expect do expect do
post "/family/invitations", params: valid_params post "/family/invitations", params: valid_params
end.to change(FamilyInvitation, :count).by(1) end.to change(Family::Invitation, :count).by(1)
end end
it 'redirects with success message' do it 'redirects with success message' do
@ -112,7 +112,7 @@ RSpec.describe 'Family::Invitations', type: :request do
invitation # create the existing invitation invitation # create the existing invitation
expect do expect do
post "/family/invitations", params: duplicate_params post "/family/invitations", params: duplicate_params
end.not_to change(FamilyInvitation, :count) end.not_to change(Family::Invitation, :count)
end end
it 'redirects with error message' do it 'redirects with error message' do
@ -161,81 +161,6 @@ RSpec.describe 'Family::Invitations', type: :request do
end end
end end
describe 'POST /family/invitations/:id/accept' do
let(:invitee) { create(:user) }
let(:invitee_invitation) { create(:family_invitation, family: family, invited_by: user, email: invitee.email) }
context 'with valid invitation and user' do
before { sign_in invitee }
it 'accepts the invitation' do
expect do
post "/family/invitations/#{invitee_invitation.token}/accept"
end.to change { invitee.reload.family }.from(nil).to(family)
end
it 'redirects with success message' do
post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Welcome to the family!')
end
it 'marks invitation as accepted' do
post "/family/invitations/#{invitee_invitation.token}/accept"
invitee_invitation.reload
expect(invitee_invitation.status).to eq('accepted')
end
end
context 'when user is already in a family' do
let(:other_family) { create(:family) }
before do
create(:family_membership, user: invitee, family: other_family, role: :member)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post "/family/invitations/#{invitee_invitation.token}/accept"
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('You must leave your current family before joining a new one')
end
end
context 'when invitation is expired' do
before do
invitee_invitation.update!(expires_at: 1.day.ago)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post "/family/invitations/#{invitee_invitation.token}/accept"
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('This invitation is no longer valid or has expired')
end
end
context 'when not authenticated' do
it 'redirects to login' do
post "/family/invitations/#{invitee_invitation.token}/accept"
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'DELETE /family/invitations/:id' do describe 'DELETE /family/invitations/:id' do
before { sign_in user } before { sign_in user }
@ -294,7 +219,7 @@ RSpec.describe 'Family::Invitations', type: :request do
} }
expect(response).to redirect_to(family_path) expect(response).to redirect_to(family_path)
created_invitation = FamilyInvitation.last created_invitation = Family::Invitation.last
expect(created_invitation.email).to eq(invitee.email) expect(created_invitation.email).to eq(invitee.email)
# 2. Invitee views public invitation page # 2. Invitee views public invitation page
@ -304,7 +229,7 @@ RSpec.describe 'Family::Invitations', type: :request do
# 3. Invitee accepts invitation # 3. Invitee accepts invitation
sign_in invitee sign_in invitee
post "/family/invitations/#{created_invitation.token}/accept" post accept_family_invitation_path(token: created_invitation.token)
expect(response).to redirect_to(family_path) expect(response).to redirect_to(family_path)
# 4. Verify invitee is now in family # 4. Verify invitee is now in family

View file

@ -15,12 +15,89 @@ RSpec.describe 'Family::Memberships', type: :request do
sign_in user sign_in user
end end
describe 'POST /family/memberships' do
let(:invitee) { create(:user) }
let(:invitee_invitation) { create(:family_invitation, family: family, invited_by: user, email: invitee.email) }
context 'with valid invitation and user' do
before { sign_in invitee }
it 'accepts the invitation' do
expect do
post accept_family_invitation_path(token: invitee_invitation.token)
end.to change { invitee.reload.family }.from(nil).to(family)
end
it 'redirects with success message' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(family_path)
follow_redirect!
expect(response.body).to include('Welcome to the family!')
end
it 'marks invitation as accepted' do
post accept_family_invitation_path(token: invitee_invitation.token)
invitee_invitation.reload
expect(invitee_invitation.status).to eq('accepted')
end
end
context 'when user is already in a family' do
let(:other_family) { create(:family) }
before do
create(:family_membership, user: invitee, family: other_family, role: :member)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post accept_family_invitation_path(token: invitee_invitation.token)
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('You must leave your current family before joining a new one')
end
end
context 'when invitation is expired' do
before do
invitee_invitation.update!(expires_at: 1.day.ago)
sign_in invitee
end
it 'does not accept the invitation' do
expect do
post accept_family_invitation_path(token: invitee_invitation.token)
end.not_to(change { invitee.reload.family })
end
it 'redirects with error message' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(root_path)
expect(flash[:alert]).to include('This invitation is no longer valid or has expired')
end
end
context 'when not authenticated' do
before { sign_out user }
it 'redirects to login' do
post accept_family_invitation_path(token: invitee_invitation.token)
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'DELETE /family/members/:id' do describe 'DELETE /family/members/:id' 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 "/family/members/#{member_membership.id}" delete "/family/members/#{member_membership.id}"
end.to change(FamilyMembership, :count).by(-1) end.to change(Family::Membership, :count).by(-1)
end end
it 'redirects with success message' do it 'redirects with success message' do
@ -41,7 +118,7 @@ RSpec.describe 'Family::Memberships', type: :request do
it 'does not remove the owner' do it 'does not remove the owner' do
expect do expect do
delete "/family/members/#{owner_membership.id}" delete "/family/members/#{owner_membership.id}"
end.not_to change(FamilyMembership, :count) end.not_to change(Family::Membership, :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
@ -56,7 +133,7 @@ RSpec.describe 'Family::Memberships', type: :request do
expect do expect do
delete "/family/members/#{owner_membership.id}" delete "/family/members/#{owner_membership.id}"
end.not_to change(FamilyMembership, :count) end.not_to change(Family::Membership, :count)
expect(response).to redirect_to(family_path) expect(response).to redirect_to(family_path)
follow_redirect! follow_redirect!
@ -149,7 +226,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 "/family/members/#{owner_membership.id}" delete "/family/members/#{owner_membership.id}"
end.not_to change(FamilyMembership, :count) end.not_to change(Family::Membership, :count)
expect(response).to redirect_to(family_path) expect(response).to redirect_to(family_path)
expect(user.reload.family).to eq(family) expect(user.reload.family).to eq(family)

View file

@ -52,7 +52,7 @@ RSpec.describe 'Family Workflows', type: :request do
# User2 accepts invitation # User2 accepts invitation
sign_in user2 sign_in user2
post "/family/invitations/#{invitation.token}/accept" post accept_family_invitation_path(token: invitation.token)
expect(response).to redirect_to(family_path) expect(response).to redirect_to(family_path)
expect(user2.reload.family).to eq(family) expect(user2.reload.family).to eq(family)
@ -71,7 +71,7 @@ RSpec.describe 'Family Workflows', type: :request do
# Step 5: User3 accepts invitation # Step 5: User3 accepts invitation
sign_in user3 sign_in user3
post "/family/invitations/#{invitation2.token}/accept" post accept_family_invitation_path(token: invitation2.token)
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)
@ -108,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 "/family/invitations/#{invitation.token}/accept" post accept_family_invitation_path(token: invitation.token)
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
@ -127,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 "/family/invitations/#{invitation1.token}/accept" post accept_family_invitation_path(token: invitation1.token)
expect(response).to redirect_to(family_path) expect(response).to redirect_to(family_path)
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 "/family/invitations/#{invitation2.token}/accept" post accept_family_invitation_path(token: invitation2.token)
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')
@ -268,7 +268,7 @@ RSpec.describe 'Family Workflows', type: :request do
post "/family/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(Family::Invitation, :count).by(1)
invitation = family.family_invitations.find_by(email: 'newuser@example.com') invitation = family.family_invitations.find_by(email: 'newuser@example.com')
expect(invitation.email).to eq('newuser@example.com') expect(invitation.email).to eq('newuser@example.com')

View file

@ -11,8 +11,8 @@ RSpec.describe Families::AcceptInvitation do
describe '#call' do describe '#call' do
context 'when invitation can be accepted' do context 'when invitation can be accepted' do
it 'creates membership for user' do it 'creates membership for user' do
expect { service.call }.to change(FamilyMembership, :count).by(1) expect { service.call }.to change(Family::Membership, :count).by(1)
membership = invitee.family_membership membership = invitee.reload.family_membership
expect(membership.family).to eq(family) expect(membership.family).to eq(family)
expect(membership.role).to eq('member') expect(membership.role).to eq('member')
end end
@ -47,7 +47,7 @@ RSpec.describe Families::AcceptInvitation do
end end
it 'does not create membership' do it 'does not create membership' do
expect { service.call }.not_to change(FamilyMembership, :count) expect { service.call }.not_to change(Family::Membership, :count)
end end
it 'sets appropriate error message' do it 'sets appropriate error message' do
@ -68,7 +68,7 @@ RSpec.describe Families::AcceptInvitation do
end end
it 'does not create membership' do it 'does not create membership' do
expect { service.call }.not_to change(FamilyMembership, :count) expect { service.call }.not_to change(Family::Membership, :count)
end end
end end
@ -80,7 +80,7 @@ RSpec.describe Families::AcceptInvitation do
end end
it 'does not create membership' do it 'does not create membership' do
expect { service.call }.not_to change(FamilyMembership, :count) expect { service.call }.not_to change(Family::Membership, :count)
end end
end end
@ -93,7 +93,7 @@ RSpec.describe Families::AcceptInvitation do
end end
it 'does not create membership' do it 'does not create membership' do
expect { service.call }.not_to change(FamilyMembership, :count) expect { service.call }.not_to change(Family::Membership, :count)
end end
end end
@ -108,7 +108,7 @@ RSpec.describe Families::AcceptInvitation do
end end
it 'does not create membership' do it 'does not create membership' do
expect { service.call }.not_to change(FamilyMembership, :count) expect { service.call }.not_to change(Family::Membership, :count)
end end
end end
end end

View file

@ -16,7 +16,7 @@ RSpec.describe Families::Create do
it 'creates owner membership' do it 'creates owner membership' do
service.call service.call
membership = user.family_membership membership = user.reload.family_membership
expect(membership.role).to eq('owner') expect(membership.role).to eq('owner')
expect(membership.family).to eq(service.family) expect(membership.family).to eq(service.family)
end end
@ -38,7 +38,7 @@ RSpec.describe Families::Create do
end end
it 'does not create a membership' do it 'does not create a membership' do
expect { service.call }.not_to change(FamilyMembership, :count) expect { service.call }.not_to change(Family::Membership, :count)
end end
it 'sets appropriate error message' do it 'sets appropriate error message' do
@ -65,7 +65,7 @@ RSpec.describe Families::Create do
end end
it 'does not create a membership' do it 'does not create a membership' do
expect { service.call }.not_to change(FamilyMembership, :count) expect { service.call }.not_to change(Family::Membership, :count)
end end
it 'sets appropriate error message' do it 'sets appropriate error message' do

View file

@ -12,7 +12,7 @@ RSpec.describe Families::Invite do
describe '#call' do describe '#call' do
context 'when invitation is valid' do context 'when invitation is valid' do
it 'creates an invitation' do it 'creates an invitation' do
expect { service.call }.to change(FamilyInvitation, :count).by(1) expect { service.call }.to change(Family::Invitation, :count).by(1)
invitation = owner.sent_family_invitations.last invitation = owner.sent_family_invitations.last
@ -51,7 +51,7 @@ RSpec.describe Families::Invite do
end end
it 'does not create invitation' do it 'does not create invitation' do
expect { service.call }.not_to change(FamilyInvitation, :count) expect { service.call }.not_to change(Family::Invitation, :count)
end end
end end
@ -66,7 +66,7 @@ RSpec.describe Families::Invite do
end end
it 'does not create invitation' do it 'does not create invitation' do
expect { service.call }.not_to change(FamilyInvitation, :count) expect { service.call }.not_to change(Family::Invitation, :count)
end end
end end
@ -83,7 +83,7 @@ RSpec.describe Families::Invite do
end end
it 'does not create invitation' do it 'does not create invitation' do
expect { service.call }.not_to change(FamilyInvitation, :count) expect { service.call }.not_to change(Family::Invitation, :count)
end end
end end
@ -97,7 +97,7 @@ RSpec.describe Families::Invite do
end end
it 'does not create another invitation' do it 'does not create another invitation' do
expect { service.call }.not_to change(FamilyInvitation, :count) expect { service.call }.not_to change(Family::Invitation, :count)
end end
end end

View file

@ -17,7 +17,7 @@ RSpec.describe Families::Memberships::Destroy do
it 'removes the membership' do it 'removes the membership' do
result = service.call result = service.call
expect(result).to be_truthy, "Expected service to succeed but got error: #{service.error_message}" expect(result).to be_truthy, "Expected service to succeed but got error: #{service.error_message}"
expect(FamilyMembership.count).to eq(1) # Only owner should remain expect(Family::Membership.count).to eq(1) # Only owner should remain
expect(member.reload.family_membership).to be_nil expect(member.reload.family_membership).to be_nil
end end
@ -47,7 +47,7 @@ RSpec.describe Families::Memberships::Destroy do
let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) }
it 'prevents owner from leaving' do it 'prevents owner from leaving' do
expect { service.call }.not_to change(FamilyMembership, :count) expect { service.call }.not_to change(Family::Membership, :count)
expect(user.reload.family_membership).to be_present expect(user.reload.family_membership).to be_present
end end
@ -75,7 +75,7 @@ RSpec.describe Families::Memberships::Destroy do
end end
it 'does not remove membership' do it 'does not remove membership' do
expect { service.call }.not_to change(FamilyMembership, :count) expect { service.call }.not_to change(Family::Membership, :count)
expect(user.reload.family_membership).to be_present expect(user.reload.family_membership).to be_present
end end
end end