Class: Organizations::Invitation

Inherits:
ActiveRecord::Base
  • Object
show all
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.

Examples:

Creating an invitation

invitation = org.send_invite_to!("user@example.com", invited_by: current_user)

Accepting an invitation

invitation.accept!(user)
# or with auto-inference
invitation.accept! # uses Current.user

Checking invitation status

invitation.pending?   # => true
invitation.accepted?  # => false
invitation.expired?   # => false

Defined Under Namespace

Classes: CannotAcceptAsOwner, EmailMismatch

Instance Method Summary collapse

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

Parameters:

  • user (User, nil) (defaults to: nil)

    The user accepting (uses Current.user if not provided)

  • skip_email_validation (Boolean) (defaults to: false)

    Skip email matching (for admin acceptance)

Returns:

Raises:



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

Parameters:

  • base_url (String, nil) (defaults to: nil)

    Base URL for the link

Returns:

  • (String)


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

Returns:

  • (Boolean)


87
88
89
# File 'lib/organizations/models/invitation.rb', line 87

def accepted?
  accepted_at.present?
end

#expired?Boolean

Check if invitation has expired

Returns:

  • (Boolean)


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

Parameters:

  • check_email (String)

    Email to check

Returns:

  • (Boolean)


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)

Returns:

  • (Boolean)


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

Returns:

  • (self)


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

#statusSymbol

Get the status as a symbol

Returns:

  • (Symbol)

    :pending, :accepted, or :expired



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