Class: Organizations::Invitation
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- Organizations::Invitation
- Defined in:
- lib/organizations/models/invitation.rb
Overview
Invitation model for inviting users to join an organization. Handles both existing users and new signups with a single invitation link.
Defined Under Namespace
Classes: CannotAcceptAsOwner, EmailMismatch
Instance Method Summary collapse
-
#accept!(user = nil, skip_email_validation: false) ⇒ Membership
Accept the invitation and create membership Uses row-level locking to prevent race conditions.
-
#acceptance_url(base_url: nil) ⇒ String
Get the acceptance URL.
-
#accepted? ⇒ Boolean
Check if invitation has been accepted.
-
#expired? ⇒ Boolean
Check if invitation has expired.
-
#for_email?(check_email) ⇒ Boolean
Check if invitation matches a specific email.
-
#pending? ⇒ Boolean
Check if invitation is pending (not accepted and not expired).
-
#resend! ⇒ self
Resend the invitation email Generates new token and resets expiry.
-
#status ⇒ Symbol
Get the status as a symbol.
Instance Method Details
#accept!(user = nil, skip_email_validation: false) ⇒ Membership
Accept the invitation and create membership Uses row-level locking to prevent race conditions
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/organizations/models/invitation.rb', line 124 def accept!(user = nil, skip_email_validation: false) accepting_user = user || current_user_from_context unless accepting_user raise ArgumentError, "User is required to accept invitation (or set Current.user)" end # Validate email matches at model level (security) unless skip_email_validation if accepting_user.respond_to?(:email) && !for_email?(accepting_user.email) raise EmailMismatch, Organizations.t(:"errors.invitation_email_mismatch") end end membership = nil ActiveRecord::Base.transaction do # Lock the invitation row to prevent race conditions lock! # Re-check status after acquiring lock if accepted? # Already accepted - return existing membership when present. # If membership was removed later, keep this invitation non-reusable. existing_membership = organization.memberships.find_by(user_id: accepting_user.id) return existing_membership if existing_membership raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted") end if expired? raise InvitationExpired, Organizations.t(:"errors.invitation_expired") end # Owner role cannot be assigned via invitation (defense in depth) if role.to_sym == :owner raise CannotAcceptAsOwner, Organizations.t(:"errors.invitation_accept_as_owner") end # Check if user is already a member (race condition from another invitation) existing_membership = organization.memberships.find_by(user_id: accepting_user.id) if existing_membership update!(accepted_at: Time.current) return existing_membership end # Create the membership (with verified-joining provenance) membership = create_membership_for!(accepting_user, skip_email_validation) # Mark invitation as accepted update!(accepted_at: Time.current) end Callbacks.dispatch( :member_joined, organization: organization, membership: membership, user: accepting_user ) membership end |
#acceptance_url(base_url: nil) ⇒ String
Get the acceptance URL
211 212 213 214 215 216 217 |
# File 'lib/organizations/models/invitation.rb', line 211 def acceptance_url(base_url: nil) base = base_url || default_base_url # Organizations.engine_mount_path keeps this correct for hosts that # mount the engine somewhere other than root ("/orgs/invitations/…") — # a hardcoded "/invitations/…" silently 404'd for them. "#{base}#{Organizations.engine_mount_path}/invitations/#{token}" end |
#accepted? ⇒ Boolean
Check if invitation has been accepted
87 88 89 |
# File 'lib/organizations/models/invitation.rb', line 87 def accepted? accepted_at.present? end |
#expired? ⇒ Boolean
Check if invitation has expired
93 94 95 96 97 |
# File 'lib/organizations/models/invitation.rb', line 93 def expired? return false if expires_at.nil? expires_at <= Time.current end |
#for_email?(check_email) ⇒ Boolean
Check if invitation matches a specific email
222 223 224 |
# File 'lib/organizations/models/invitation.rb', line 222 def for_email?(check_email) email.downcase == check_email.to_s.downcase.strip end |
#pending? ⇒ Boolean
Check if invitation is pending (not accepted and not expired)
81 82 83 |
# File 'lib/organizations/models/invitation.rb', line 81 def pending? accepted_at.nil? && !expired? end |
#resend! ⇒ self
Resend the invitation email Generates new token and resets expiry
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 |
# File 'lib/organizations/models/invitation.rb', line 190 def resend! ActiveRecord::Base.transaction do lock! if accepted? raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_cannot_resend_accepted") end update!( token: generate_unique_token, expires_at: calculate_expiry ) end send_invitation_email self end |
#status ⇒ Symbol
Get the status as a symbol
101 102 103 104 105 106 |
# File 'lib/organizations/models/invitation.rb', line 101 def status return :accepted if accepted? return :expired if expired? :pending end |