mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 11:04:14 +00:00
* 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>
84 lines
2.3 KiB
Ruby
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
|