Files
sure/app/models/invitation.rb
Juan José Mata 674799a6e0 Enforce one pending invitation per email across all families (#1173)
* Enforce one pending invitation per email across all families

Users can only belong to one family, so allowing the same email to have
pending invitations from multiple families leads to ambiguous behavior.
Add a `no_other_pending_invitation` validation on create to prevent this.
Accepted and expired invitations from other families are not blocked.

Fixes #1172

https://claude.ai/code/session_016fGqgha18jP48dhznm6k4m

* Normalize email before validation and use case-insensitive lookup

When ActiveRecord encryption is not configured, the email column stores
raw values preserving original casing. The prior validation used a direct
equality match which would miss case variants (e.g. Case@Test.com vs
case@test.com), leaving a gap in the cross-family uniqueness guarantee.

Fix by:
1. Adding a normalize_email callback that downcases/strips email before
   validation, so all new records store lowercase consistently.
2. Using LOWER() in the SQL query for non-encrypted deployments to catch
   any pre-existing mixed-case records.

https://claude.ai/code/session_016fGqgha18jP48dhznm6k4m

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-10 13:44:53 +01:00

84 lines
2.3 KiB
Ruby

class Invitation < ApplicationRecord
include Encryptable
belongs_to :family
belongs_to :inviter, class_name: "User"
# Encrypt sensitive fields if ActiveRecord encryption is configured
if encryption_ready?
encrypts :token, deterministic: true
encrypts :email, deterministic: true, downcase: true
end
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, presence: true, inclusion: { in: %w[admin member guest] }
validates :token, presence: true, uniqueness: true
validates_uniqueness_of :email, scope: :family_id, message: "has already been invited to this family"
validate :inviter_is_admin
validate :no_other_pending_invitation, on: :create
before_validation :normalize_email
before_validation :generate_token, on: :create
before_create :set_expiration
scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
scope :accepted, -> { where.not(accepted_at: nil) }
def pending?
accepted_at.nil? && expires_at > Time.current
end
def accept_for(user)
return false if user.blank?
return false unless pending?
return false unless emails_match?(user)
transaction do
user.update!(family_id: family_id, role: role.to_s)
update!(accepted_at: Time.current)
end
true
end
private
def emails_match?(user)
inv_email = email.to_s.strip.downcase
usr_email = user.email.to_s.strip.downcase
inv_email.present? && usr_email.present? && inv_email == usr_email
end
def generate_token
loop do
self.token = SecureRandom.hex(32)
break unless self.class.exists?(token: token)
end
end
def set_expiration
self.expires_at = 3.days.from_now
end
def normalize_email
self.email = email.to_s.strip.downcase if email.present?
end
def no_other_pending_invitation
return if email.blank?
existing = if self.class.encryption_ready?
self.class.pending.where(email: email).where.not(family_id: family_id).exists?
else
self.class.pending.where("LOWER(email) = ?", email.downcase).where.not(family_id: family_id).exists?
end
if existing
errors.add(:email, "already has a pending invitation from another family")
end
end
def inviter_is_admin
inviter.admin?
end
end