organizations 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -9
  3. data/.rubocop_todo.yml +625 -0
  4. data/CHANGELOG.md +53 -0
  5. data/README.md +414 -46
  6. data/app/controllers/organizations/application_controller.rb +4 -0
  7. data/app/controllers/organizations/memberships_controller.rb +3 -3
  8. data/app/controllers/organizations/public_controller.rb +26 -0
  9. data/app/mailers/organizations/invitation_mailer.rb +38 -18
  10. data/app/mailers/organizations/verification_mailer.rb +56 -0
  11. data/app/models/organizations/allowlist_entry.rb +6 -0
  12. data/app/models/organizations/domain.rb +6 -0
  13. data/app/models/organizations/join_code.rb +6 -0
  14. data/app/models/organizations/join_request.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
  19. data/config/locales/en.yml +158 -0
  20. data/config/locales/es.yml +126 -0
  21. data/config/routes.rb +46 -28
  22. data/gemfiles/rails_7.2.gemfile +3 -3
  23. data/gemfiles/rails_8.1.gemfile +3 -3
  24. data/lib/generators/organizations/install/install_generator.rb +3 -0
  25. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
  26. data/lib/generators/organizations/install/templates/initializer.rb +53 -0
  27. data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
  28. data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
  29. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
  30. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
  31. data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
  32. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
  33. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
  34. data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
  35. data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
  36. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
  37. data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
  38. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
  39. data/lib/generators/organizations/views/views_generator.rb +47 -0
  40. data/lib/organizations/callback_context.rb +9 -0
  41. data/lib/organizations/callbacks.rb +26 -18
  42. data/lib/organizations/configuration.rb +275 -1
  43. data/lib/organizations/controller_helpers.rb +38 -12
  44. data/lib/organizations/email_normalizer.rb +65 -0
  45. data/lib/organizations/join_flow.rb +227 -0
  46. data/lib/organizations/join_state.rb +117 -0
  47. data/lib/organizations/metadata_flags.rb +79 -0
  48. data/lib/organizations/models/allowlist_entry.rb +88 -0
  49. data/lib/organizations/models/concerns/has_organizations.rb +100 -12
  50. data/lib/organizations/models/domain.rb +85 -0
  51. data/lib/organizations/models/invitation.rb +105 -17
  52. data/lib/organizations/models/join_code.rb +242 -0
  53. data/lib/organizations/models/join_request.rb +627 -0
  54. data/lib/organizations/models/membership.rb +39 -8
  55. data/lib/organizations/models/organization.rb +317 -19
  56. data/lib/organizations/organization_scoped.rb +135 -0
  57. data/lib/organizations/test_helpers.rb +35 -1
  58. data/lib/organizations/version.rb +1 -1
  59. data/lib/organizations/view_helpers.rb +27 -19
  60. data/lib/organizations.rb +165 -0
  61. metadata +34 -2
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # An email domain owned/claimed by an organization, used for verified joining.
5
+ #
6
+ # A user who proves control of an inbox under one of the organization's
7
+ # domains (emailed code, or an already-confirmed account email — see
8
+ # Organization#join_with_account_email!) is considered a verified member.
9
+ #
10
+ # Matching is EXACT and dot-boundary safe: "alumnos.urjc.es" and "urjc.es"
11
+ # are two different domains and must both be enrolled if both should join.
12
+ # This is deliberate — subdomains often carry different member semantics
13
+ # (e.g. students vs employees), and suffix matching would collapse them.
14
+ # It also neutralizes lookalike attacks ("urjc.es.evil.com" never equals
15
+ # "urjc.es").
16
+ #
17
+ # `membership_metadata` is copied onto memberships created through this
18
+ # domain (see JoinRequest#approve!) — hosts use it for cohort tags like
19
+ # { "member_kind" => "student" } without the gem interpreting the contents.
20
+ #
21
+ # @example
22
+ # org.add_domain!("inizio.com")
23
+ # org.add_domain!("alumnos.urjc.es", membership_metadata: { member_kind: "student" })
24
+ # Organizations::Domain.matching_email("j.doe@inizio.com") # => [domain]
25
+ #
26
+ class Domain < ActiveRecord::Base
27
+ self.table_name = "organizations_domains"
28
+
29
+ # === Associations ===
30
+
31
+ belongs_to :organization,
32
+ class_name: "Organizations::Organization",
33
+ inverse_of: :domains
34
+
35
+ # === Validations ===
36
+
37
+ validates :domain, presence: true, uniqueness: { scope: :organization_id }
38
+ validate :domain_shape
39
+
40
+ # === Callbacks ===
41
+
42
+ before_validation :normalize_domain
43
+
44
+ # === Scopes ===
45
+
46
+ # All domain rows (across organizations) matching an email's domain.
47
+ # Returns none for emails whose domain can't be safely extracted
48
+ # (multi-@ evasion shapes, blanks).
49
+ scope :matching_email, lambda { |email|
50
+ extracted = EmailNormalizer.domain_of(email)
51
+ extracted ? where(domain: extracted) : none
52
+ }
53
+
54
+ # === Matching ===
55
+
56
+ # Whether an email address belongs to this domain (exact match).
57
+ # @param email [String]
58
+ # @return [Boolean]
59
+ def matches_email?(email)
60
+ extracted = EmailNormalizer.domain_of(email)
61
+ extracted.present? && extracted == domain
62
+ end
63
+
64
+ private
65
+
66
+ def normalize_domain
67
+ return if domain.blank?
68
+
69
+ self.domain = domain.to_s.strip.downcase.delete_prefix("@").chomp(".")
70
+ end
71
+
72
+ # Pragmatic hostname shape check: lowercase labels, at least one dot,
73
+ # no whitespace or "@". (IDN domains should be enrolled in punycode form.)
74
+ def domain_shape
75
+ return if domain.blank? # presence validation covers this
76
+
77
+ unless domain.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\z/)
78
+ errors.add(:domain, Organizations.t(:"attributes.domain_invalid"))
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ # Host extension seam — see the load-hooks note in models/organization.rb.
85
+ ActiveSupport.run_load_hooks(:organizations_domain, Organizations::Domain)
@@ -28,7 +28,7 @@ module Organizations
28
28
 
29
29
  # Optional because inviter can be deleted (dependent: :nullify on User)
30
30
  belongs_to :invited_by,
31
- class_name: "User",
31
+ class_name: Organizations.user_class_name,
32
32
  optional: true
33
33
 
34
34
  # Alias for invited_by (semantic convenience as per README)
@@ -131,7 +131,7 @@ module Organizations
131
131
  # Validate email matches at model level (security)
132
132
  unless skip_email_validation
133
133
  if accepting_user.respond_to?(:email) && !for_email?(accepting_user.email)
134
- raise EmailMismatch, "This invitation was sent to a different email address"
134
+ raise EmailMismatch, Organizations.t(:"errors.invitation_email_mismatch")
135
135
  end
136
136
  end
137
137
 
@@ -148,16 +148,16 @@ module Organizations
148
148
  existing_membership = organization.memberships.find_by(user_id: accepting_user.id)
149
149
  return existing_membership if existing_membership
150
150
 
151
- raise InvitationAlreadyAccepted, "This invitation has already been accepted"
151
+ raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted")
152
152
  end
153
153
 
154
154
  if expired?
155
- raise InvitationExpired, "This invitation has expired"
155
+ raise InvitationExpired, Organizations.t(:"errors.invitation_expired")
156
156
  end
157
157
 
158
158
  # Owner role cannot be assigned via invitation (defense in depth)
159
159
  if role.to_sym == :owner
160
- raise CannotAcceptAsOwner, "Cannot accept invitation as owner. Invite as admin, then use transfer_ownership_to! after joining."
160
+ raise CannotAcceptAsOwner, Organizations.t(:"errors.invitation_accept_as_owner")
161
161
  end
162
162
 
163
163
  # Check if user is already a member (race condition from another invitation)
@@ -167,14 +167,8 @@ module Organizations
167
167
  return existing_membership
168
168
  end
169
169
 
170
- # Create the membership
171
- # Use invited_by_id instead of invited_by to avoid Rails class reloading issues
172
- # (AssociationTypeMismatch when User class is reloaded in development)
173
- membership = organization.memberships.create!(
174
- user: accepting_user,
175
- role: role,
176
- invited_by_id: invited_by_id
177
- )
170
+ # Create the membership (with verified-joining provenance)
171
+ membership = create_membership_for!(accepting_user, skip_email_validation)
178
172
 
179
173
  # Mark invitation as accepted
180
174
  update!(accepted_at: Time.current)
@@ -198,7 +192,7 @@ module Organizations
198
192
  lock!
199
193
 
200
194
  if accepted?
201
- raise InvitationAlreadyAccepted, "Cannot resend an accepted invitation"
195
+ raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_cannot_resend_accepted")
202
196
  end
203
197
 
204
198
  update!(
@@ -216,7 +210,10 @@ module Organizations
216
210
  # @return [String]
217
211
  def acceptance_url(base_url: nil)
218
212
  base = base_url || default_base_url
219
- "#{base}/invitations/#{token}"
213
+ # Organizations.engine_mount_path keeps this correct for hosts that
214
+ # mount the engine somewhere other than root ("/orgs/invitations/…") —
215
+ # a hardcoded "/invitations/…" silently 404'd for them.
216
+ "#{base}#{Organizations.engine_mount_path}/invitations/#{token}"
220
217
  end
221
218
 
222
219
  # Check if invitation matches a specific email
@@ -228,6 +225,94 @@ module Organizations
228
225
 
229
226
  private
230
227
 
228
+ # Create the membership for an acceptance.
229
+ # Uses invited_by_id instead of invited_by to avoid Rails class reloading
230
+ # issues (AssociationTypeMismatch when User is reloaded in development).
231
+ #
232
+ # Verified-joining provenance (v0.5.0): accepting the emailed token is
233
+ # proof of control of the invited address, so the membership records it
234
+ # as a verified email — UNLESS the acceptance bypassed the email match
235
+ # (skip_email_validation with a different account email), where no inbox
236
+ # proof exists. If the address was already claimed by another membership
237
+ # in this org (rare recycled-address edge), the membership is still
238
+ # created, just without the verified-email stamp.
239
+ def create_membership_for!(accepting_user, skip_email_validation)
240
+ # THE MEMBERSHIP GATE (strict, vetoing, pre-persist) — see
241
+ # Configuration#on_member_joining. Runs inside accept!'s locked
242
+ # transaction: a veto rolls back accepted_at too, so the invitation
243
+ # stays pending and can be accepted again once the host unblocks.
244
+ Callbacks.dispatch(
245
+ :member_joining,
246
+ strict: true,
247
+ organization: organization,
248
+ user: accepting_user,
249
+ role: role,
250
+ joined_via: "invited",
251
+ invitation: self
252
+ )
253
+
254
+ # SAVEPOINT (requires_new): rescued unique-violation inside accept!'s
255
+ # transaction — see JoinRequest#create_membership! for the PostgreSQL
256
+ # aborted-transaction rationale (proven by the PG leg of the suite).
257
+ ActiveRecord::Base.transaction(requires_new: true) do
258
+ organization.memberships.create!(
259
+ **base_membership_attributes(accepting_user),
260
+ **verified_email_attributes_for(accepting_user, skip_email_validation)
261
+ )
262
+ end
263
+ rescue ActiveRecord::RecordNotUnique
264
+ # Two unique indexes can fire here (same disambiguation as
265
+ # JoinRequest#create_membership!):
266
+ # 1. (user, org) — another acceptance/join path just made them a member:
267
+ # reuse it (invitations to one address aren't normalization-aware, so
268
+ # two plus-variant invitations can race).
269
+ # 2. (org, verified_email_normalized) — the address was claimed between
270
+ # our pre-check and the INSERT: degrade gracefully by creating the
271
+ # membership WITHOUT the verified-email stamp. Acceptance never breaks.
272
+ existing = organization.memberships.find_by(user_id: accepting_user.id)
273
+ return existing if existing
274
+
275
+ # Its own savepoint too: this fallback INSERT can itself lose the
276
+ # (user, org) race, and accept!'s rescue must stay reachable on PG.
277
+ ActiveRecord::Base.transaction(requires_new: true) do
278
+ organization.memberships.create!(**base_membership_attributes(accepting_user))
279
+ end
280
+ end
281
+
282
+ def base_membership_attributes(accepting_user)
283
+ {
284
+ user: accepting_user,
285
+ role: role,
286
+ invited_by_id: invited_by_id,
287
+ joined_via: "invited",
288
+ metadata: membership_metadata.is_a?(Hash) ? membership_metadata : {}
289
+ }
290
+ end
291
+
292
+ # Provenance attributes for the membership created by this acceptance.
293
+ # See create_membership_for! for the trust rules.
294
+ def verified_email_attributes_for(accepting_user, skip_email_validation)
295
+ email_proven =
296
+ !skip_email_validation ||
297
+ (accepting_user.respond_to?(:email) && for_email?(accepting_user.email))
298
+
299
+ return {} unless email_proven
300
+
301
+ normalized = Organizations.configuration.normalize_verification_email(email)
302
+
303
+ already_claimed = Membership
304
+ .where(organization_id: organization_id, verified_email_normalized: normalized)
305
+ .exists?
306
+
307
+ return {} if already_claimed
308
+
309
+ {
310
+ verified_email: email,
311
+ verified_email_normalized: normalized,
312
+ verified_at: Time.current
313
+ }
314
+ end
315
+
231
316
  def normalize_email
232
317
  self.email = email.to_s.downcase.strip if email.present?
233
318
  end
@@ -265,7 +350,7 @@ module Organizations
265
350
  .exists?
266
351
 
267
352
  if existing
268
- errors.add(:email, "has already been invited to this organization")
353
+ errors.add(:email, Organizations.t(:"attributes.invitation_taken"))
269
354
  end
270
355
  end
271
356
 
@@ -283,7 +368,7 @@ module Organizations
283
368
  end
284
369
 
285
370
  def default_base_url
286
- if defined?(Rails) && Rails.application&.routes
371
+ if Organizations.full_rails_app? && Rails.application.routes
287
372
  Rails.application.routes.url_helpers.root_url.chomp("/")
288
373
  else
289
374
  ""
@@ -293,3 +378,6 @@ module Organizations
293
378
  end
294
379
  end
295
380
  end
381
+
382
+ # Host extension seam — see the load-hooks note in models/organization.rb.
383
+ ActiveSupport.run_load_hooks(:organizations_invitation, Organizations::Invitation)
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # A shareable join code ("PIN") for an organization — the classroom/Slack
5
+ # style joining mechanism. Codes are globally unique so redemption needs no
6
+ # organization context (QR posters, links, plain typing).
7
+ #
8
+ # Per-code knobs:
9
+ # - `requires_verified_domain_email` — redemption additionally requires the
10
+ # emailed-code challenge against one of the org's domains (the "reinforced"
11
+ # level). Per-code (not per-org) so one org can run both levels at once.
12
+ # - `auto_approve` — false means redemption parks a pending JoinRequest for
13
+ # manual approval instead of granting membership immediately.
14
+ # - `expires_at`, `max_uses`, `revoked_at` — abuse containment. Rotation is
15
+ # revoke + generate a new code (history preserved for audit).
16
+ # - `label` — campaign attribution ("cafeteria poster" vs "newsletter").
17
+ #
18
+ # `membership_metadata` is copied onto memberships created through this code.
19
+ #
20
+ # @example
21
+ # code = org.generate_join_code!(label: "poster", requires_verified_domain_email: true)
22
+ # Organizations::JoinCode.redeem(code.code, user: user)
23
+ # # => Membership (instant join) or JoinRequest (challenge/approval pending)
24
+ #
25
+ class JoinCode < ActiveRecord::Base
26
+ self.table_name = "organizations_join_codes"
27
+
28
+ # Ambiguity-free alphabet (no I/L/O/0/1 lookalikes) — same idea as
29
+ # user-facing referral codes: survives posters, print, and dictation.
30
+ CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
31
+ DEFAULT_CODE_LENGTH = 8
32
+
33
+ # === Associations ===
34
+
35
+ belongs_to :organization,
36
+ class_name: "Organizations::Organization",
37
+ inverse_of: :join_codes
38
+
39
+ belongs_to :created_by,
40
+ class_name: Organizations.user_class_name,
41
+ optional: true
42
+
43
+ # Requests OUTLIVE the codes that created them (they are the join audit
44
+ # trail; joined_via/metadata are already snapshotted) — deleting a code
45
+ # nullifies the linkage instead of cascading or violating the FK.
46
+ # Prefer revoke! over destroy for rotation; destroy stays safe regardless.
47
+ has_many :join_requests,
48
+ class_name: "Organizations::JoinRequest",
49
+ inverse_of: :join_code,
50
+ dependent: :nullify
51
+
52
+ # === Validations ===
53
+
54
+ validates :code, presence: true, uniqueness: true
55
+ validates :max_uses, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
56
+ validates :uses_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
57
+
58
+ # === Callbacks ===
59
+
60
+ before_validation :normalize_code
61
+ before_validation :generate_code, on: :create, if: -> { code.blank? }
62
+
63
+ # === Scopes ===
64
+
65
+ scope :not_revoked, -> { where(revoked_at: nil) }
66
+
67
+ # Codes that can actually be redeemed right now — the SQL twin of
68
+ # #active? (not revoked, not expired, not exhausted). Powers
69
+ # Organization#accepts_code_joining? without loading rows.
70
+ scope :active, lambda {
71
+ not_revoked
72
+ .where("expires_at IS NULL OR expires_at > ?", Time.current)
73
+ .where("max_uses IS NULL OR uses_count < max_uses")
74
+ }
75
+
76
+ # === Status ===
77
+
78
+ # @return [Boolean]
79
+ def revoked?
80
+ revoked_at.present?
81
+ end
82
+
83
+ # @return [Boolean]
84
+ def expired?
85
+ expires_at.present? && expires_at <= Time.current
86
+ end
87
+
88
+ # @return [Boolean]
89
+ def exhausted?
90
+ max_uses.present? && uses_count >= max_uses
91
+ end
92
+
93
+ # @return [Boolean]
94
+ def active?
95
+ !revoked? && !expired? && !exhausted?
96
+ end
97
+
98
+ # @return [Symbol] :active, :revoked, :expired, or :exhausted
99
+ def status
100
+ return :revoked if revoked?
101
+ return :expired if expired?
102
+ return :exhausted if exhausted?
103
+
104
+ :active
105
+ end
106
+
107
+ # Revoke this code (rotation = revoke + generate a new one). Idempotent.
108
+ # @return [self]
109
+ def revoke!
110
+ return self if revoked?
111
+
112
+ with_lock do
113
+ break if revoked?
114
+
115
+ update!(revoked_at: Time.current)
116
+ end
117
+
118
+ self
119
+ end
120
+
121
+ # Presentation form, grouped for readability: "7FHK2MPX" => "7FHK-2MPX".
122
+ # Storage and matching always use the bare form.
123
+ # @return [String]
124
+ def display_code
125
+ code.to_s.scan(/.{1,4}/).join("-")
126
+ end
127
+
128
+ # === Redemption ===
129
+
130
+ # Redeem a code for a user.
131
+ # @param code [String] the code as typed (case/hyphen/space insensitive)
132
+ # @param user [User] the redeeming user
133
+ # @return [Membership] when the code grants instant membership (or the
134
+ # user is already a member)
135
+ # @return [JoinRequest] when a challenge or manual approval is still needed
136
+ # @raise [JoinCodeInvalid] for unknown, revoked, or expired codes
137
+ # @raise [JoinCodeExhausted] when max_uses is spent
138
+ def self.redeem(code, user:)
139
+ normalized = normalize(code)
140
+ raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") if normalized.blank?
141
+
142
+ join_code = find_by(code: normalized)
143
+ raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") unless join_code
144
+
145
+ join_code.redeem!(user: user)
146
+ end
147
+
148
+ # Instance-level redemption. See .redeem for semantics.
149
+ def redeem!(user:)
150
+ raise ArgumentError, "user is required" unless user
151
+
152
+ outcome = nil
153
+
154
+ ActiveRecord::Base.transaction do
155
+ # Lock the code row: uses_count accounting and the max_uses cap must
156
+ # be race-safe (two concurrent redemptions of a max_uses: 1 code must
157
+ # yield exactly one success).
158
+ lock!
159
+ ensure_redeemable!
160
+
161
+ # Already a member — idempotent no-op that does NOT consume a use.
162
+ existing_membership = organization.memberships.find_by(user_id: user.id)
163
+ if existing_membership
164
+ outcome = existing_membership
165
+ raise ActiveRecord::Rollback # nothing to persist
166
+ end
167
+
168
+ outcome = attach_request!(user)
169
+ end
170
+
171
+ # Instant-join path: no email challenge required and auto-approve on.
172
+ # Approval runs AFTER the redemption transaction commits so the
173
+ # member_joined/join_request_approved callbacks never fire for a
174
+ # transaction that could still roll back. If approval fails, the
175
+ # pending request survives — a safe, resumable state.
176
+ return outcome unless outcome.is_a?(JoinRequest)
177
+ return outcome if requires_verified_domain_email? || !auto_approve?
178
+
179
+ outcome.approve!(decided_by: nil)
180
+ end
181
+
182
+ # Normalize user input to storage form: uppercase, strip separators.
183
+ # @param code [String, nil]
184
+ # @return [String]
185
+ def self.normalize(code)
186
+ code.to_s.upcase.gsub(/[\s-]/, "")
187
+ end
188
+
189
+ private
190
+
191
+ def ensure_redeemable!
192
+ raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") if revoked? || expired?
193
+ raise JoinCodeExhausted, Organizations.t(:"errors.join_code_exhausted") if exhausted?
194
+ end
195
+
196
+ # Attach this code to the user's open request (creating one if needed),
197
+ # consuming a use — except for idempotent re-redemptions of the same code.
198
+ def attach_request!(user)
199
+ request = pending_request_for(user)
200
+
201
+ # Same user re-redeeming the same code: idempotent, no extra use.
202
+ return request if request.persisted? && request.join_code_id == id
203
+
204
+ request.join_code = self
205
+ request.joined_via = "code"
206
+ request.save!
207
+ increment!(:uses_count)
208
+ request
209
+ end
210
+
211
+ # Find or build this user's open request for the organization.
212
+ # Reuses an existing pending request (partial unique index: one pending
213
+ # request per user per org) so re-entry via a different mechanism upgrades
214
+ # the same request instead of exploding.
215
+ def pending_request_for(user)
216
+ organization.join_requests.pending.find_by(user_id: user.id) ||
217
+ organization.join_requests.new(user: user)
218
+ end
219
+
220
+ def normalize_code
221
+ self.code = self.class.normalize(code) if code.present?
222
+ end
223
+
224
+ def generate_code
225
+ generator = Organizations.configuration.join_code_generator
226
+
227
+ self.code = loop do
228
+ candidate =
229
+ if generator.respond_to?(:call)
230
+ self.class.normalize(generator.call)
231
+ else
232
+ Array.new(DEFAULT_CODE_LENGTH) { CODE_ALPHABET[SecureRandom.random_number(CODE_ALPHABET.length)] }.join
233
+ end
234
+
235
+ break candidate unless self.class.exists?(code: candidate)
236
+ end
237
+ end
238
+ end
239
+ end
240
+
241
+ # Host extension seam — see the load-hooks note in models/organization.rb.
242
+ ActiveSupport.run_load_hooks(:organizations_join_code, Organizations::JoinCode)