mirror of
https://github.com/we-promise/sure.git
synced 2026-07-20 08:45:22 +00:00
* fix(invitations): allow re-inviting after an unaccepted invite expires An expired, never-accepted invitation still occupies the partial unique index `index_invitations_on_email_and_family_id_pending` (predicate `accepted_at IS NULL`), but the `pending` scope excludes it via the `expires_at > now` clause. The duplicate-guard validation therefore passes and the INSERT collides with the index, raising ActiveRecord::RecordNotUnique — surfaced to the admin as a 500 with no way to re-invite that address through the UI. Remove the stale expired row inside the create transaction (before_create) so the unique slot is freed and the re-invite succeeds. It runs only after validations pass, so a still-pending (non-expired) invitation can never be removed here. Add a controller-level rescue of ActiveRecord::RecordNotUnique as a safety net against a concurrent double-submit race. Closes #2535 * refactor(invitations): scope RecordNotUnique rescue to the save The method-level `rescue ActiveRecord::RecordNotUnique` wrapped the whole create action, so any future write after @invitation.save (e.g. accept_for) would silently be reported as a generic invite failure. Extract save_invitation to scope the rescue to the save alone, and add a regression test that drives the raced unique-index path directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
142 lines
4.5 KiB
Ruby
142 lines
4.5 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
|
|
validate :no_duplicate_pending_invitation_in_family
|
|
validate :inviter_is_admin
|
|
validate :no_other_pending_invitation, on: :create
|
|
|
|
before_validation :normalize_email
|
|
before_validation :generate_token, on: :create
|
|
before_create :remove_expired_duplicates_in_family
|
|
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)
|
|
return false if would_orphan_owned_accounts?(user)
|
|
|
|
transaction do
|
|
user.update!(family_id: family_id, role: role.to_s)
|
|
update!(accepted_at: Time.current)
|
|
auto_share_existing_accounts(user) if family.share_all_by_default?
|
|
end
|
|
true
|
|
end
|
|
|
|
def would_orphan_owned_accounts?(user)
|
|
return false if user.blank?
|
|
return false if user.family_id.blank?
|
|
return false if user.family_id == family_id
|
|
|
|
user.owned_accounts.where.not(family_id: family_id).exists?
|
|
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
|
|
|
|
# An expired, never-accepted invitation still occupies the partial unique
|
|
# index `index_invitations_on_email_and_family_id_pending` (predicate
|
|
# `accepted_at IS NULL`), but the `pending` scope treats it as gone because
|
|
# of the `expires_at > now` clause. The duplicate-guard validation therefore
|
|
# passes, and the INSERT collides with the index, raising RecordNotUnique.
|
|
# Removing the stale row inside the create transaction frees the slot so the
|
|
# re-invite can proceed. It runs only after validations pass, so a still
|
|
# pending (non-expired) invitation can never be removed here.
|
|
def remove_expired_duplicates_in_family
|
|
return if email.blank?
|
|
|
|
scope = self.class.where(family_id: family_id, accepted_at: nil)
|
|
.where("expires_at <= ?", Time.current)
|
|
|
|
scope = if self.class.encryption_ready?
|
|
scope.where(email: email)
|
|
else
|
|
scope.where("LOWER(email) = ?", email.to_s.strip.downcase)
|
|
end
|
|
|
|
scope.delete_all
|
|
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 no_duplicate_pending_invitation_in_family
|
|
return if email.blank?
|
|
|
|
scope = self.class.pending.where(family_id: family_id)
|
|
scope = scope.where.not(id: id) if persisted?
|
|
|
|
exists = if self.class.encryption_ready?
|
|
scope.where(email: email).exists?
|
|
else
|
|
scope.where("LOWER(email) = ?", email.to_s.strip.downcase).exists?
|
|
end
|
|
|
|
errors.add(:email, "has already been invited to this family") if exists
|
|
end
|
|
|
|
def inviter_is_admin
|
|
inviter.admin?
|
|
end
|
|
|
|
def auto_share_existing_accounts(user)
|
|
records = family.accounts.where.not(owner_id: user.id).pluck(:id).map do |account_id|
|
|
{ account_id: account_id, user_id: user.id, permission: "read_write",
|
|
include_in_finances: true, created_at: Time.current, updated_at: Time.current }
|
|
end
|
|
|
|
AccountShare.insert_all(records, unique_by: %i[account_id user_id]) if records.any?
|
|
end
|
|
end
|