Class: Organizations::JoinCode
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- Organizations::JoinCode
- 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.
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
-
.normalize(code) ⇒ String
Normalize user input to storage form: uppercase, strip separators.
-
.redeem(code, user:) ⇒ Membership, JoinRequest
Redeem a code for a user.
Instance Method Summary collapse
- #active? ⇒ Boolean
-
#display_code ⇒ String
Presentation form, grouped for readability: "7FHK2MPX" => "7FHK-2MPX".
- #exhausted? ⇒ Boolean
- #expired? ⇒ Boolean
-
#redeem!(user:) ⇒ Object
Instance-level redemption.
-
#revoke! ⇒ self
Revoke this code (rotation = revoke + generate a new one).
- #revoked? ⇒ Boolean
-
#status ⇒ Symbol
:active, :revoked, :expired, or :exhausted.
Class Method Details
.normalize(code) ⇒ String
Normalize user input to storage form: uppercase, strip separators.
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.
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
94 95 96 |
# File 'lib/organizations/models/join_code.rb', line 94 def active? !revoked? && !expired? && !exhausted? end |
#display_code ⇒ String
Presentation form, grouped for readability: "7FHK2MPX" => "7FHK-2MPX". Storage and matching always use the bare form.
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
89 90 91 |
# File 'lib/organizations/models/join_code.rb', line 89 def exhausted? max_uses.present? && uses_count >= max_uses end |
#expired? ⇒ 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.
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.
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
79 80 81 |
# File 'lib/organizations/models/join_code.rb', line 79 def revoked? revoked_at.present? end |
#status ⇒ Symbol
Returns :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 |