diff --git a/Procfile b/Procfile
index fd4fe014..3eb630b7 100644
--- a/Procfile
+++ b/Procfile
@@ -1,2 +1,3 @@
+release: bundle exec rails db:migrate
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -C config/sidekiq.yml
diff --git a/app.json b/app.json
index 9c425d4e..fcf27c70 100644
--- a/app.json
+++ b/app.json
@@ -5,11 +5,6 @@
{ "url": "https://github.com/heroku/heroku-buildpack-nodejs.git" },
{ "url": "https://github.com/heroku/heroku-buildpack-ruby.git" }
],
- "scripts": {
- "dokku": {
- "predeploy": "bundle exec rails db:migrate"
- }
- },
"healthchecks": {
"web": [
{
diff --git a/app/controllers/family/invitations_controller.rb b/app/controllers/family/invitations_controller.rb
index 4d4f13e3..040451c1 100644
--- a/app/controllers/family/invitations_controller.rb
+++ b/app/controllers/family/invitations_controller.rb
@@ -3,9 +3,8 @@
class Family::InvitationsController < ApplicationController
before_action :authenticate_user!, 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, only: %i[accept]
def index
authorize @family, :show?
@@ -14,7 +13,7 @@ class Family::InvitationsController < ApplicationController
end
def show
- @invitation = FamilyInvitation.find_by!(token: params[:token])
+ @invitation = Family::Invitation.find_by!(token: params[:token])
if @invitation.expired?
redirect_to root_path, alert: 'This invitation has expired.' and return
@@ -41,34 +40,6 @@ class Family::InvitationsController < ApplicationController
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
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
end
- def set_invitation_by_id
- @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
diff --git a/app/controllers/family/memberships_controller.rb b/app/controllers/family/memberships_controller.rb
index 65784f07..34750f42 100644
--- a/app/controllers/family/memberships_controller.rb
+++ b/app/controllers/family/memberships_controller.rb
@@ -3,8 +3,37 @@
class Family::MembershipsController < ApplicationController
before_action :authenticate_user!
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_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
authorize @membership
@@ -34,4 +63,8 @@ class Family::MembershipsController < ApplicationController
def set_membership
@membership = @family.family_memberships.find(params[:id])
end
+
+ def set_invitation
+ @invitation = Family::Invitation.find_by!(token: params[:token])
+ end
end
diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb
index 8193d199..fd6a448c 100644
--- a/app/controllers/users/registrations_controller.rb
+++ b/app/controllers/users/registrations_controller.rb
@@ -49,7 +49,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
def set_invitation
return unless invitation_token.present?
- @invitation = FamilyInvitation.find_by(token: invitation_token)
+ @invitation = Family::Invitation.find_by(token: invitation_token)
end
def self_hosted_mode?
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
index 58e5984a..151bddc5 100644
--- a/app/controllers/users/sessions_controller.rb
+++ b/app/controllers/users/sessions_controller.rb
@@ -11,7 +11,7 @@ class Users::SessionsController < Devise::SessionsController
def after_sign_in_path_for(resource)
if invitation_token.present?
- invitation = FamilyInvitation.find_by(token: invitation_token)
+ invitation = Family::Invitation.find_by(token: invitation_token)
if invitation&.can_be_accepted?
return family_invitation_path(invitation.token)
@@ -26,7 +26,7 @@ class Users::SessionsController < Devise::SessionsController
def load_invitation_context
return unless invitation_token.present?
- @invitation = FamilyInvitation.find_by(token: invitation_token)
+ @invitation = Family::Invitation.find_by(token: invitation_token)
end
def invitation_token
diff --git a/app/jobs/family/invitations/cleanup_job.rb b/app/jobs/family/invitations/cleanup_job.rb
index b55d39c7..b5076472 100644
--- a/app/jobs/family/invitations/cleanup_job.rb
+++ b/app/jobs/family/invitations/cleanup_job.rb
@@ -6,16 +6,16 @@ class FamilyInvitationsCleanupJob < ApplicationJob
def perform
Rails.logger.info 'Starting family invitations cleanup'
- expired_count = FamilyInvitation.where(status: :pending)
- .where('expires_at < ?', Time.current)
- .update_all(status: :expired)
+ expired_count = Family::Invitation.where(status: :pending)
+ .where('expires_at < ?', Time.current)
+ .update_all(status: :expired)
Rails.logger.info "Updated #{expired_count} expired family invitations"
cleanup_threshold = 30.days.ago
- deleted_count = FamilyInvitation.where(status: [:expired, :cancelled])
- .where('updated_at < ?', cleanup_threshold)
- .delete_all
+ deleted_count = Family::Invitation.where(status: [:expired, :cancelled])
+ .where('updated_at < ?', cleanup_threshold)
+ .delete_all
Rails.logger.info "Deleted #{deleted_count} old family invitations"
diff --git a/app/mailers/family_mailer.rb b/app/mailers/family_mailer.rb
index 46c57c53..e86c68ea 100644
--- a/app/mailers/family_mailer.rb
+++ b/app/mailers/family_mailer.rb
@@ -6,10 +6,10 @@ class FamilyMailer < ApplicationMailer
@family = invitation.family
@invited_by = invitation.invited_by
@accept_url = family_invitation_url(@invitation.token)
- pp @accept_url
+
mail(
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
diff --git a/app/models/concerns/user_family.rb b/app/models/concerns/user_family.rb
index de1f56ef..53119792 100644
--- a/app/models/concerns/user_family.rb
+++ b/app/models/concerns/user_family.rb
@@ -4,11 +4,10 @@ module UserFamily
extend ActiveSupport::Concern
included do
- # Family associations
- has_one :family_membership, dependent: :destroy
+ has_one :family_membership, dependent: :destroy, class_name: '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_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
before_destroy :check_family_ownership
@@ -30,25 +29,14 @@ module UserFamily
end
def family_sharing_enabled?
- # User must be in a family and have explicitly enabled location sharing
return false unless in_family?
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
- return sharing_settings if [true, false].include?(sharing_settings)
-
- # 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
+ expires_at = sharing_settings['expires_at']
+ expires_at.blank? || Time.parse(expires_at).future?
end
def update_family_location_sharing!(enabled, duration: nil)
@@ -60,21 +48,14 @@ module UserFamily
if enabled
sharing_config = { 'enabled' => true }
- # Add expiration if duration is specified
if duration.present?
expiration_time = case duration
- when '1h'
- 1.hour.from_now
- when '6h'
- 6.hours.from_now
- when '12h'
- 12.hours.from_now
- when '24h'
- 24.hours.from_now
- when 'permanent'
- nil # No expiration
- else
- duration.to_i.hours.from_now if duration.to_i > 0
+ when '1h' then 1.hour.from_now
+ when '6h' then 6.hours.from_now
+ when '12h' then 12.hours.from_now
+ when '24h' then 24.hours.from_now
+ when 'permanent' then nil
+ else duration.to_i.hours.from_now if duration.to_i > 0
end
sharing_config['expires_at'] = expiration_time.iso8601 if expiration_time
@@ -106,21 +87,21 @@ module UserFamily
def latest_location_for_family
return nil unless family_sharing_enabled?
- # Use select to only fetch needed columns and limit to 1 for efficiency
- latest_point = points.select(:latitude, :longitude, :timestamp)
- .order(timestamp: :desc)
- .limit(1)
- .first
+ latest_point =
+ points.select(:lonlat, :timestamp)
+ .order(timestamp: :desc)
+ .limit(1)
+ .first
return nil unless latest_point
{
user_id: id,
email: email,
- latitude: latest_point.latitude,
- longitude: latest_point.longitude,
+ latitude: latest_point.lat,
+ longitude: latest_point.lon,
timestamp: latest_point.timestamp,
- updated_at: Time.at(latest_point.timestamp)
+ updated_at: Time.zone.at(latest_point.timestamp)
}
end
diff --git a/app/models/family.rb b/app/models/family.rb
index ab12339d..421fce79 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
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 :family_invitations, dependent: :destroy
+ has_many :family_invitations, dependent: :destroy, class_name: 'Family::Invitation'
belongs_to :creator, class_name: 'User'
validates :name, presence: true, length: { maximum: 50 }
diff --git a/app/models/family/invitation.rb b/app/models/family/invitation.rb
new file mode 100644
index 00000000..68fb4c6d
--- /dev/null
+++ b/app/models/family/invitation.rb
@@ -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
diff --git a/app/models/family/membership.rb b/app/models/family/membership.rb
new file mode 100644
index 00000000..63ef5e1f
--- /dev/null
+++ b/app/models/family/membership.rb
@@ -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
diff --git a/app/policies/family/invitation_policy.rb b/app/policies/family/invitation_policy.rb
new file mode 100644
index 00000000..b65d6ae8
--- /dev/null
+++ b/app/policies/family/invitation_policy.rb
@@ -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
diff --git a/app/policies/family/membership_policy.rb b/app/policies/family/membership_policy.rb
new file mode 100644
index 00000000..4a80b125
--- /dev/null
+++ b/app/policies/family/membership_policy.rb
@@ -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
diff --git a/app/services/families/accept_invitation.rb b/app/services/families/accept_invitation.rb
index 8ea37afb..4869d9fe 100644
--- a/app/services/families/accept_invitation.rb
+++ b/app/services/families/accept_invitation.rb
@@ -65,7 +65,7 @@ module Families
end
def create_membership
- FamilyMembership.create!(
+ Family::Membership.create!(
family: invitation.family,
user: user,
role: :member
diff --git a/app/services/families/create.rb b/app/services/families/create.rb
index e736462b..c958dc32 100644
--- a/app/services/families/create.rb
+++ b/app/services/families/create.rb
@@ -81,7 +81,7 @@ module Families
end
def create_owner_membership
- FamilyMembership.create!(
+ Family::Membership.create!(
family: family,
user: user,
role: :owner
diff --git a/app/services/families/invite.rb b/app/services/families/invite.rb
index a8956666..322109cf 100644
--- a/app/services/families/invite.rb
+++ b/app/services/families/invite.rb
@@ -73,7 +73,7 @@ module Families
end
def create_invitation
- @invitation = FamilyInvitation.create!(
+ @invitation = Family::Invitation.create!(
family: family,
email: email,
invited_by: invited_by
diff --git a/app/views/family/invitations/show.html.erb b/app/views/family/invitations/show.html.erb
index 99580a8d..fac38927 100644
--- a/app/views/family/invitations/show.html.erb
+++ b/app/views/family/invitations/show.html.erb
@@ -121,7 +121,7 @@
<% if user_signed_in? %>
- <%= link_to accept_family_invitation_path(@invitation.family, @invitation),
+ <%= link_to accept_family_invitation_path(token: @invitation.token),
method: :post,
class: "btn btn-success btn-lg w-full text-lg shadow-lg" do %>
✓ Accept Invitation & Join Family
diff --git a/app/views/family_mailer/invitation.html.erb b/app/views/family_mailer/invitation.html.erb
index 13d46b11..7a469b53 100644
--- a/app/views/family_mailer/invitation.html.erb
+++ b/app/views/family_mailer/invitation.html.erb
@@ -42,7 +42,7 @@
Best regards,
- The Dawarich Team
+ Evgenii from Dawarich
-
\ No newline at end of file
+
diff --git a/app/views/family_mailer/invitation.text.erb b/app/views/family_mailer/invitation.text.erb
index 39bcc527..cd9b266c 100644
--- a/app/views/family_mailer/invitation.text.erb
+++ b/app/views/family_mailer/invitation.text.erb
@@ -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.
Best regards,
-The Dawarich Team
\ No newline at end of file
+Evgenii from Dawarich
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb
index d1c05889..94e66489 100644
--- a/app/views/shared/_navbar.html.erb
+++ b/app/views/shared/_navbar.html.erb
@@ -21,7 +21,7 @@
<% end %>
<% else %>
- <%= link_to 'Family', new_family_path, class: "#{active_class?(new_family_path)}" %>
+ <%= link_to 'Familyα'.html_safe, new_family_path, class: "#{active_class?(new_family_path)}" %>
<% end %>
<% end %>
@@ -79,14 +79,14 @@
<%= link_to family_path, class: "mx-1 #{active_class?(family_path)} flex items-center space-x-2" do %>
-
Family
+
Familyα
<% end %>
<% else %>
- <%= link_to 'Family', new_family_path, class: "mx-1 #{active_class?(new_family_path)}" %>
+ <%= link_to 'Familyα'.html_safe, new_family_path, class: "mx-1 #{active_class?(new_family_path)}" %>
<% end %>
<% end %>
diff --git a/app/views/stats/_reverse_geocoding_stats.html.erb b/app/views/stats/_reverse_geocoding_stats.html.erb
index 11d3768f..7d387ec9 100644
--- a/app/views/stats/_reverse_geocoding_stats.html.erb
+++ b/app/views/stats/_reverse_geocoding_stats.html.erb
@@ -38,17 +38,18 @@
<%= current_user.total_cities %>
Cities visited
-
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 9e35fa26..d34aa775 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -62,15 +62,12 @@ Rails.application.routes.draw do
resource :family, only: %i[show new create edit update destroy] do
patch :update_location_sharing, on: :member
- resources :invitations, except: %i[edit update], controller: 'family/invitations' do
- member do
- post :accept
- end
- end
+ resources :invitations, except: %i[edit update], controller: 'family/invitations'
resources :members, only: %i[destroy], controller: 'family/memberships'
end
get 'invitations/:token', to: 'family/invitations#show', as: :public_invitation
+ post 'family/memberships', to: 'family/memberships#create', as: :accept_family_invitation
end
resources :points, only: %i[index] do
diff --git a/spec/factories/family_invitations.rb b/spec/factories/family_invitations.rb
index 5d3e7785..41e71969 100644
--- a/spec/factories/family_invitations.rb
+++ b/spec/factories/family_invitations.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :family_invitation do
+ factory :family_invitation, class: 'Family::Invitation' do
association :family
association :invited_by, factory: :user
sequence(:email) { |n| "invite#{n}@example.com" }
diff --git a/spec/factories/family_memberships.rb b/spec/factories/family_memberships.rb
index 9979df5a..0796c9af 100644
--- a/spec/factories/family_memberships.rb
+++ b/spec/factories/family_memberships.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :family_membership do
+ factory :family_membership, class: 'Family::Membership' do
association :family
association :user
role { :member }
diff --git a/spec/models/family_invitation_spec.rb b/spec/models/family/invitation_spec.rb
similarity index 93%
rename from spec/models/family_invitation_spec.rb
rename to spec/models/family/invitation_spec.rb
index 3792bd72..2abd5db4 100644
--- a/spec/models/family_invitation_spec.rb
+++ b/spec/models/family/invitation_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-RSpec.describe FamilyInvitation, type: :model do
+RSpec.describe Family::Invitation, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:family) }
it { is_expected.to belong_to(:invited_by).class_name('User') }
@@ -44,9 +44,9 @@ RSpec.describe FamilyInvitation, type: :model do
describe '.active' do
it 'returns only pending and non-expired invitations' do
- expect(FamilyInvitation.active).to include(pending_invitation)
- expect(FamilyInvitation.active).not_to include(expired_invitation)
- expect(FamilyInvitation.active).not_to include(accepted_invitation)
+ expect(Family::Invitation.active).to include(pending_invitation)
+ expect(Family::Invitation.active).not_to include(expired_invitation)
+ expect(Family::Invitation.active).not_to include(accepted_invitation)
end
end
end
@@ -63,7 +63,7 @@ RSpec.describe FamilyInvitation, type: :model do
it 'sets expiry date' do
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
it 'does not override existing token' do
@@ -136,7 +136,7 @@ RSpec.describe FamilyInvitation, type: :model do
describe 'constants' do
it 'defines EXPIRY_DAYS' do
- expect(FamilyInvitation::EXPIRY_DAYS).to eq(7)
+ expect(Family::Invitation::EXPIRY_DAYS).to eq(7)
end
end
diff --git a/spec/models/family_membership_spec.rb b/spec/models/family/membership_spec.rb
similarity index 97%
rename from spec/models/family_membership_spec.rb
rename to spec/models/family/membership_spec.rb
index 96d76f07..0cc859e7 100644
--- a/spec/models/family_membership_spec.rb
+++ b/spec/models/family/membership_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-RSpec.describe FamilyMembership, type: :model do
+RSpec.describe Family::Membership, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:family) }
it { is_expected.to belong_to(:user) }
diff --git a/spec/models/family_spec.rb b/spec/models/family_spec.rb
index a6d6cf12..7f81b898 100644
--- a/spec/models/family_spec.rb
+++ b/spec/models/family_spec.rb
@@ -107,8 +107,8 @@ RSpec.describe Family, type: :model do
it 'destroys associated invitations when family is destroyed' do
invitation = create(:family_invitation, family: family, invited_by: user)
- expect { family.destroy }.to change(FamilyInvitation, :count).by(-1)
- expect(FamilyInvitation.find_by(id: invitation.id)).to be_nil
+ expect { family.destroy }.to change(Family::Invitation, :count).by(-1)
+ expect(Family::Invitation.find_by(id: invitation.id)).to be_nil
end
end
@@ -118,8 +118,8 @@ RSpec.describe Family, type: :model do
it 'destroys associated memberships when family is destroyed' do
membership = create(:family_membership, family: family, user: user, role: :owner)
- expect { family.destroy }.to change(FamilyMembership, :count).by(-1)
- expect(FamilyMembership.find_by(id: membership.id)).to be_nil
+ expect { family.destroy }.to change(Family::Membership, :count).by(-1)
+ expect(Family::Membership.find_by(id: membership.id)).to be_nil
end
end
end
diff --git a/spec/models/user_family_spec.rb b/spec/models/user_family_spec.rb
index 9afacb1c..0a0d2879 100644
--- a/spec/models/user_family_spec.rb
+++ b/spec/models/user_family_spec.rb
@@ -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)
}
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
@@ -119,7 +119,7 @@ RSpec.describe User, 'family methods', type: :model do
end
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
@@ -129,7 +129,7 @@ RSpec.describe User, 'family methods', type: :model do
end
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
diff --git a/spec/requests/families_spec.rb b/spec/requests/families_spec.rb
index 4bab31bf..5bc4e826 100644
--- a/spec/requests/families_spec.rb
+++ b/spec/requests/families_spec.rb
@@ -69,7 +69,7 @@ RSpec.describe 'Family', type: :request do
it 'creates a family membership for the user' do
expect do
post '/family', params: valid_attributes
- end.to change(FamilyMembership, :count).by(1)
+ end.to change(Family::Membership, :count).by(1)
end
it 'redirects to the new family with success message' do
diff --git a/spec/requests/family/invitations_spec.rb b/spec/requests/family/invitations_spec.rb
index fd9e1c19..b75d501e 100644
--- a/spec/requests/family/invitations_spec.rb
+++ b/spec/requests/family/invitations_spec.rb
@@ -92,7 +92,7 @@ RSpec.describe 'Family::Invitations', type: :request do
it 'creates a new invitation' do
expect do
post "/family/invitations", params: valid_params
- end.to change(FamilyInvitation, :count).by(1)
+ end.to change(Family::Invitation, :count).by(1)
end
it 'redirects with success message' do
@@ -112,7 +112,7 @@ RSpec.describe 'Family::Invitations', type: :request do
invitation # create the existing invitation
expect do
post "/family/invitations", params: duplicate_params
- end.not_to change(FamilyInvitation, :count)
+ end.not_to change(Family::Invitation, :count)
end
it 'redirects with error message' do
@@ -161,81 +161,6 @@ RSpec.describe 'Family::Invitations', type: :request do
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
before { sign_in user }
@@ -294,7 +219,7 @@ RSpec.describe 'Family::Invitations', type: :request do
}
expect(response).to redirect_to(family_path)
- created_invitation = FamilyInvitation.last
+ created_invitation = Family::Invitation.last
expect(created_invitation.email).to eq(invitee.email)
# 2. Invitee views public invitation page
@@ -304,7 +229,7 @@ RSpec.describe 'Family::Invitations', type: :request do
# 3. Invitee accepts invitation
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)
# 4. Verify invitee is now in family
diff --git a/spec/requests/family/memberships_spec.rb b/spec/requests/family/memberships_spec.rb
index c276147b..5efde1ba 100644
--- a/spec/requests/family/memberships_spec.rb
+++ b/spec/requests/family/memberships_spec.rb
@@ -15,12 +15,89 @@ RSpec.describe 'Family::Memberships', type: :request do
sign_in user
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
context 'when removing a regular member' do
it 'removes the member from the family' do
expect do
delete "/family/members/#{member_membership.id}"
- end.to change(FamilyMembership, :count).by(-1)
+ end.to change(Family::Membership, :count).by(-1)
end
it 'redirects with success message' do
@@ -41,7 +118,7 @@ RSpec.describe 'Family::Memberships', type: :request do
it 'does not remove the owner' do
expect do
delete "/family/members/#{owner_membership.id}"
- end.not_to change(FamilyMembership, :count)
+ end.not_to change(Family::Membership, :count)
end
it 'redirects with error message explaining owners must delete family' do
@@ -56,7 +133,7 @@ RSpec.describe 'Family::Memberships', type: :request do
expect do
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)
follow_redirect!
@@ -149,7 +226,7 @@ RSpec.describe 'Family::Memberships', type: :request do
# Try to remove owner - should be prevented
expect do
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(user.reload.family).to eq(family)
diff --git a/spec/requests/family_workflows_spec.rb b/spec/requests/family_workflows_spec.rb
index b4c1e576..38f64ed9 100644
--- a/spec/requests/family_workflows_spec.rb
+++ b/spec/requests/family_workflows_spec.rb
@@ -52,7 +52,7 @@ RSpec.describe 'Family Workflows', type: :request do
# User2 accepts invitation
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(user2.reload.family).to eq(family)
@@ -71,7 +71,7 @@ RSpec.describe 'Family Workflows', type: :request do
# Step 5: User3 accepts invitation
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(family.reload.members.count).to eq(3)
@@ -108,7 +108,7 @@ RSpec.describe 'Family Workflows', type: :request do
# User2 tries to accept expired invitation
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(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
# User3 accepts invitation to Family 1
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(user3.family).to eq(family1)
# 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(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: {
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')
expect(invitation.email).to eq('newuser@example.com')
diff --git a/spec/services/families/accept_invitation_spec.rb b/spec/services/families/accept_invitation_spec.rb
index 80492e6c..bf947b4a 100644
--- a/spec/services/families/accept_invitation_spec.rb
+++ b/spec/services/families/accept_invitation_spec.rb
@@ -11,8 +11,8 @@ RSpec.describe Families::AcceptInvitation do
describe '#call' do
context 'when invitation can be accepted' do
it 'creates membership for user' do
- expect { service.call }.to change(FamilyMembership, :count).by(1)
- membership = invitee.family_membership
+ expect { service.call }.to change(Family::Membership, :count).by(1)
+ membership = invitee.reload.family_membership
expect(membership.family).to eq(family)
expect(membership.role).to eq('member')
end
@@ -47,7 +47,7 @@ RSpec.describe Families::AcceptInvitation do
end
it 'does not create membership' do
- expect { service.call }.not_to change(FamilyMembership, :count)
+ expect { service.call }.not_to change(Family::Membership, :count)
end
it 'sets appropriate error message' do
@@ -68,7 +68,7 @@ RSpec.describe Families::AcceptInvitation do
end
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
@@ -80,7 +80,7 @@ RSpec.describe Families::AcceptInvitation do
end
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
@@ -93,7 +93,7 @@ RSpec.describe Families::AcceptInvitation do
end
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
@@ -108,7 +108,7 @@ RSpec.describe Families::AcceptInvitation do
end
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
diff --git a/spec/services/families/create_spec.rb b/spec/services/families/create_spec.rb
index f4329001..216c344b 100644
--- a/spec/services/families/create_spec.rb
+++ b/spec/services/families/create_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe Families::Create do
it 'creates owner membership' do
service.call
- membership = user.family_membership
+ membership = user.reload.family_membership
expect(membership.role).to eq('owner')
expect(membership.family).to eq(service.family)
end
@@ -38,7 +38,7 @@ RSpec.describe Families::Create do
end
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
it 'sets appropriate error message' do
@@ -65,7 +65,7 @@ RSpec.describe Families::Create do
end
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
it 'sets appropriate error message' do
diff --git a/spec/services/families/invite_spec.rb b/spec/services/families/invite_spec.rb
index 3367efd3..8ea3c747 100644
--- a/spec/services/families/invite_spec.rb
+++ b/spec/services/families/invite_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Families::Invite do
describe '#call' do
context 'when invitation is valid' 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
@@ -51,7 +51,7 @@ RSpec.describe Families::Invite do
end
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
@@ -66,7 +66,7 @@ RSpec.describe Families::Invite do
end
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
@@ -83,7 +83,7 @@ RSpec.describe Families::Invite do
end
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
@@ -97,7 +97,7 @@ RSpec.describe Families::Invite do
end
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
diff --git a/spec/services/families/memberships/destroy_spec.rb b/spec/services/families/memberships/destroy_spec.rb
index e1227d7a..ac2475e5 100644
--- a/spec/services/families/memberships/destroy_spec.rb
+++ b/spec/services/families/memberships/destroy_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Families::Memberships::Destroy do
it 'removes the membership' do
result = service.call
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
end
@@ -47,7 +47,7 @@ RSpec.describe Families::Memberships::Destroy do
let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) }
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
end
@@ -75,7 +75,7 @@ RSpec.describe Families::Memberships::Destroy do
end
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
end
end