Class: Organizations::JoinRequest

Inherits:
ActiveRecord::Base
  • Object
show all
Defined in:
lib/organizations/models/join_request.rb

Overview

A user's petition to join an organization — the mirror image of Invitation (invitation = org→user, join request = user→org; both converge on Membership). Memberships stay active-only: all "pending" state lives here, so every existing membership invariant (counter cache, single owner, uniqueness) is untouched.

Stored statuses: pending → approved | rejected | withdrawn. :expired is DERIVED from expires_at (same approach as invitations).

A request may carry an email-verification challenge: the user proves control of an inbox that either belongs to one of the organization's Domains or matches an unclaimed AllowlistEntry. The 6-digit code is stored as a SHA-256 digest only (peppered with the row id) — plaintext never touches the database.

The class carries the request's FULL lifecycle (statuses, challenge, decisions) exactly like its mirror Invitation does — splitting it would scatter one cohesive state machine across files. rubocop:disable Metrics/ClassLength

Examples:

Request to join (manual approval)

request = user.request_to_join!(org, message: "Soy socio nº 442")
org.approve_join_request!(request, approved_by: admin)

Domain-email verified join

request = user.request_to_join!(org)
request.start_email_verification!(email: "j.doe@inizio.com")
request.verify_email_code!("492817") # => Membership (auto-approved)

Constant Summary collapse

STATUSES =
%w[pending approved rejected withdrawn].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.digest_verification_code(code, request_id) ⇒ String

Digest a verification code, peppered with the row id so identical codes on different requests produce different digests.

Returns:

  • (String)


297
298
299
# File 'lib/organizations/models/join_request.rb', line 297

def self.digest_verification_code(code, request_id)
  Digest::SHA256.hexdigest("#{code}-#{request_id}")
end

.purge_stale!(older_than: 12.months) ⇒ Integer

Data-minimization sweep (GDPR posture): decided (rejected/withdrawn) and expired requests hold a verification email address with no ongoing purpose — purge them once they're old enough. APPROVED requests are deliberately kept: they are the join audit trail (provenance for the membership they created). The retention PERIOD is host policy; the knowledge of which states hold purposeless PII is the gem's.

Wire it from a scheduled job/rake task, e.g.:

Organizations::JoinRequest.purge_stale!(older_than: 12.months)

Parameters:

  • older_than (ActiveSupport::Duration) (defaults to: 12.months)

    minimum age before purging

Returns:

  • (Integer)

    number of purged rows



313
314
315
316
317
318
319
320
321
322
323
# File 'lib/organizations/models/join_request.rb', line 313

def self.purge_stale!(older_than: 12.months)
  cutoff = Time.current - older_than

  # in_batches: this is a maintenance sweep over potentially years of
  # rows — batched deletes keep the lock/undo footprint flat on large
  # tables (returns the summed count, same contract as delete_all).
  where(status: %w[rejected withdrawn]).where(decided_at: ...cutoff)
    .or(expired.where(expires_at: ...cutoff))
    .in_batches
    .delete_all
end

Instance Method Details

#approve!(decided_by: nil) ⇒ Membership

Approve this request and create the membership (the ONLY way a join request becomes a membership). Row-locked and idempotent: approving an already-approved request returns the existing membership.

Provenance is stamped on the membership (joined_via, verified_email, verified_at) and membership_metadata from the governing instruments is merged in (matched domain, then matched allowlist entry, then join code — later wins).

Parameters:

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

    approver (nil for auto-approvals)

Returns:

Raises:



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/organizations/models/join_request.rb', line 220

def approve!(decided_by: nil)
  membership = nil
  reused_existing = false

  ActiveRecord::Base.transaction do
    lock!

    return approved_membership! if approved? # idempotent re-approval

    ensure_open!

    existing = existing_membership

    if existing
      membership = existing
      reused_existing = true
    else
      membership = create_membership!
      claim_matched_allowlist_entry!
    end

    update!(status: "approved", decided_by_id: decided_by&.id, decided_at: Time.current)
  end

  dispatch_approval_callbacks(membership, decided_by, reused_existing)
  membership
end

#approved?Boolean

Returns:

  • (Boolean)


94
95
96
# File 'lib/organizations/models/join_request.rb', line 94

def approved?
  status == "approved"
end

#decided?Boolean

Returns a terminal decision was made.

Returns:

  • (Boolean)

    a terminal decision was made



114
115
116
# File 'lib/organizations/models/join_request.rb', line 114

def decided?
  approved? || rejected? || withdrawn?
end

#effective_statusSymbol

Effective status as a symbol (derives :expired)

Returns:

  • (Symbol)

    :pending, :approved, :rejected, :withdrawn, or :expired



120
121
122
123
124
# File 'lib/organizations/models/join_request.rb', line 120

def effective_status
  return :expired if expired?

  status.to_sym
end

#email_verified?Boolean

Returns the email challenge was completed.

Returns:

  • (Boolean)

    the email challenge was completed



127
128
129
# File 'lib/organizations/models/join_request.rb', line 127

def email_verified?
  verified_at.present?
end

#expired?Boolean

Returns request timed out while pending.

Returns:

  • (Boolean)

    request timed out while pending



109
110
111
# File 'lib/organizations/models/join_request.rb', line 109

def expired?
  status == "pending" && expires_at.present? && expires_at <= Time.current
end

#pending?Boolean

Returns:

  • (Boolean)


89
90
91
# File 'lib/organizations/models/join_request.rb', line 89

def pending?
  status == "pending" && !expired?
end

#reject!(rejected_by: nil, reason: nil) ⇒ self

Reject this request.

Parameters:

  • rejected_by (User, nil) (defaults to: nil)
  • reason (String, nil) (defaults to: nil)

    stored in metadata for audit; never shown by the gem

Returns:

  • (self)

Raises:



253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/organizations/models/join_request.rb', line 253

def reject!(rejected_by: nil, reason: nil)
  ActiveRecord::Base.transaction do
    lock!
    ensure_undecided!

     =  || {}
     = .merge("rejection_reason" => reason) if reason.present?

    update!(
      status: "rejected",
      decided_by_id: rejected_by&.id,
      decided_at: Time.current,
      metadata: 
    )
  end

  Callbacks.dispatch(
    :join_request_rejected,
    organization: organization,
    user: user,
    join_request: self,
    decided_by: rejected_by
  )

  self
end

#rejected?Boolean

Returns:

  • (Boolean)


99
100
101
# File 'lib/organizations/models/join_request.rb', line 99

def rejected?
  status == "rejected"
end

#start_email_verification!(email:) ⇒ self

Start (or restart) the emailed-code challenge for an address the user claims to control. The address must belong to one of the organization's domains or match an unclaimed allowlist entry — proof of control is required in BOTH cases (a leaked roster must not grant membership without inbox access).

Parameters:

  • email (String)

    the address to prove (may differ from user.email)

Returns:

  • (self)

Raises:



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/organizations/models/join_request.rb', line 145

def start_email_verification!(email:)
  address = email.to_s.strip

  unless address.match?(URI::MailTo::EMAIL_REGEXP)
    raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_invalid")
  end

  code = nil

  ActiveRecord::Base.transaction do
    lock!
    ensure_open!

    matched_domain, matched_entry = eligible_instruments_for!(address)
    ensure_address_unclaimed!(address)
    ensure_send_allowed!

    code = generate_verification_code
    update!(challenge_attributes(address, code, matched_domain, matched_entry))
  end

  deliver_verification_email(code)
  self
end

#verify_email_code!(code) ⇒ Membership, self

Verify the emailed code. On success, auto-approves when the governing instrument allows it (domain/roster joins: always; code joins: per the code's auto_approve flag).

Parameters:

  • code (String)

    the 6-digit code as typed

Returns:

  • (Membership)

    when verification auto-approved the request

  • (self)

    when verified but awaiting manual approval

Raises:



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/organizations/models/join_request.rb', line 179

def verify_email_code!(code)
  failure = nil

  ActiveRecord::Base.transaction do
    lock!
    ensure_open!
    ensure_active_challenge!

    if correct_code?(code)
      # Burn the code: single-use by construction.
      update!(verified_at: Time.current, verification_code_digest: nil)
    else
      # Persist the failed attempt, then raise OUTSIDE the transaction —
      # raising here would roll the increment back and give attackers
      # unlimited tries.
      update!(verification_attempts: verification_attempts + 1)
      failure = VerificationCodeInvalid.new(Organizations.t(:"errors.verification_code_invalid"))
    end
  end

  raise failure if failure

  return approve!(decided_by: nil) if auto_approvable_after_verification?

  self
end

#withdraw!self

Withdraw this request (the user cancels their own petition).

Returns:

  • (self)

Raises:



283
284
285
286
287
288
289
290
291
292
# File 'lib/organizations/models/join_request.rb', line 283

def withdraw!
  ActiveRecord::Base.transaction do
    lock!
    ensure_undecided!

    update!(status: "withdrawn", decided_at: Time.current)
  end

  self
end

#withdrawn?Boolean

Returns:

  • (Boolean)


104
105
106
# File 'lib/organizations/models/join_request.rb', line 104

def withdrawn?
  status == "withdrawn"
end