Class: Organizations::JoinCode

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

Overview

A shareable join code ("PIN") for an organization — the classroom/Slack style joining mechanism. Codes are globally unique so redemption needs no organization context (QR posters, links, plain typing).

Per-code knobs:

  • requires_verified_domain_email — redemption additionally requires the emailed-code challenge against one of the org's domains (the "reinforced" level). Per-code (not per-org) so one org can run both levels at once.
  • auto_approve — false means redemption parks a pending JoinRequest for manual approval instead of granting membership immediately.
  • expires_at, max_uses, revoked_at — abuse containment. Rotation is revoke + generate a new code (history preserved for audit).
  • label — campaign attribution ("cafeteria poster" vs "newsletter").

membership_metadata is copied onto memberships created through this code.

Examples:

code = org.generate_join_code!(label: "poster", requires_verified_domain_email: true)
Organizations::JoinCode.redeem(code.code, user: user)
# => Membership (instant join) or JoinRequest (challenge/approval pending)

Constant Summary collapse

CODE_ALPHABET =

Ambiguity-free alphabet (no I/L/O/0/1 lookalikes) — same idea as user-facing referral codes: survives posters, print, and dictation.

"ABCDEFGHJKMNPQRSTUVWXYZ23456789"
DEFAULT_CODE_LENGTH =
8

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.normalize(code) ⇒ String

Normalize user input to storage form: uppercase, strip separators.

Parameters:

  • code (String, nil)

Returns:

  • (String)


185
186
187
# File 'lib/organizations/models/join_code.rb', line 185

def self.normalize(code)
  code.to_s.upcase.gsub(/[\s-]/, "")
end

.redeem(code, user:) ⇒ Membership, JoinRequest

Redeem a code for a user.

Parameters:

  • code (String)

    the code as typed (case/hyphen/space insensitive)

  • user (User)

    the redeeming user

Returns:

  • (Membership)

    when the code grants instant membership (or the user is already a member)

  • (JoinRequest)

    when a challenge or manual approval is still needed

Raises:



138
139
140
141
142
143
144
145
146
# File 'lib/organizations/models/join_code.rb', line 138

def self.redeem(code, user:)
  normalized = normalize(code)
  raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") if normalized.blank?

  join_code = find_by(code: normalized)
  raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") unless join_code

  join_code.redeem!(user: user)
end

Instance Method Details

#active?Boolean

Returns:

  • (Boolean)


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

def active?
  !revoked? && !expired? && !exhausted?
end

#display_codeString

Presentation form, grouped for readability: "7FHK2MPX" => "7FHK-2MPX". Storage and matching always use the bare form.

Returns:

  • (String)


124
125
126
# File 'lib/organizations/models/join_code.rb', line 124

def display_code
  code.to_s.scan(/.{1,4}/).join("-")
end

#exhausted?Boolean

Returns:

  • (Boolean)


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

def exhausted?
  max_uses.present? && uses_count >= max_uses
end

#expired?Boolean

Returns:

  • (Boolean)


84
85
86
# File 'lib/organizations/models/join_code.rb', line 84

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

#redeem!(user:) ⇒ Object

Instance-level redemption. See .redeem for semantics.

Raises:

  • (ArgumentError)


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
# File 'lib/organizations/models/join_code.rb', line 149

def redeem!(user:)
  raise ArgumentError, "user is required" unless user

  outcome = nil

  ActiveRecord::Base.transaction do
    # Lock the code row: uses_count accounting and the max_uses cap must
    # be race-safe (two concurrent redemptions of a max_uses: 1 code must
    # yield exactly one success).
    lock!
    ensure_redeemable!

    # Already a member — idempotent no-op that does NOT consume a use.
    existing_membership = organization.memberships.find_by(user_id: user.id)
    if existing_membership
      outcome = existing_membership
      raise ActiveRecord::Rollback # nothing to persist
    end

    outcome = attach_request!(user)
  end

  # Instant-join path: no email challenge required and auto-approve on.
  # Approval runs AFTER the redemption transaction commits so the
  # member_joined/join_request_approved callbacks never fire for a
  # transaction that could still roll back. If approval fails, the
  # pending request survives — a safe, resumable state.
  return outcome unless outcome.is_a?(JoinRequest)
  return outcome if requires_verified_domain_email? || !auto_approve?

  outcome.approve!(decided_by: nil)
end

#revoke!self

Revoke this code (rotation = revoke + generate a new one). Idempotent.

Returns:

  • (self)


109
110
111
112
113
114
115
116
117
118
119
# File 'lib/organizations/models/join_code.rb', line 109

def revoke!
  return self if revoked?

  with_lock do
    break if revoked?

    update!(revoked_at: Time.current)
  end

  self
end

#revoked?Boolean

Returns:

  • (Boolean)


79
80
81
# File 'lib/organizations/models/join_code.rb', line 79

def revoked?
  revoked_at.present?
end

#statusSymbol

Returns :active, :revoked, :expired, or :exhausted.

Returns:

  • (Symbol)

    :active, :revoked, :expired, or :exhausted



99
100
101
102
103
104
105
# File 'lib/organizations/models/join_code.rb', line 99

def status
  return :revoked if revoked?
  return :expired if expired?
  return :exhausted if exhausted?

  :active
end