organizations 0.4.3 → 0.6.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -9
  3. data/.rubocop_todo.yml +625 -0
  4. data/Appraisals +2 -0
  5. data/CHANGELOG.md +65 -0
  6. data/README.md +433 -41
  7. data/app/controllers/organizations/application_controller.rb +4 -0
  8. data/app/controllers/organizations/memberships_controller.rb +3 -3
  9. data/app/controllers/organizations/public_controller.rb +26 -0
  10. data/app/mailers/organizations/invitation_mailer.rb +38 -18
  11. data/app/mailers/organizations/verification_mailer.rb +56 -0
  12. data/app/models/organizations/allowlist_entry.rb +6 -0
  13. data/app/models/organizations/domain.rb +6 -0
  14. data/app/models/organizations/join_code.rb +6 -0
  15. data/app/models/organizations/join_request.rb +6 -0
  16. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
  17. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
  18. data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
  19. data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
  20. data/config/locales/en.yml +158 -0
  21. data/config/locales/es.yml +126 -0
  22. data/config/routes.rb +46 -28
  23. data/gemfiles/rails_7.2.gemfile +4 -3
  24. data/gemfiles/rails_8.1.gemfile +3 -3
  25. data/lib/generators/organizations/install/install_generator.rb +3 -0
  26. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
  27. data/lib/generators/organizations/install/templates/initializer.rb +53 -0
  28. data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
  29. data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
  30. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
  31. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
  32. data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
  33. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
  34. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
  35. data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
  36. data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
  37. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
  38. data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
  39. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
  40. data/lib/generators/organizations/views/views_generator.rb +47 -0
  41. data/lib/organizations/callback_context.rb +9 -0
  42. data/lib/organizations/callbacks.rb +26 -18
  43. data/lib/organizations/configuration.rb +278 -3
  44. data/lib/organizations/controller_helpers.rb +38 -12
  45. data/lib/organizations/email_normalizer.rb +65 -0
  46. data/lib/organizations/join_flow.rb +227 -0
  47. data/lib/organizations/join_state.rb +117 -0
  48. data/lib/organizations/metadata_flags.rb +79 -0
  49. data/lib/organizations/models/allowlist_entry.rb +88 -0
  50. data/lib/organizations/models/concerns/has_organizations.rb +100 -12
  51. data/lib/organizations/models/domain.rb +85 -0
  52. data/lib/organizations/models/invitation.rb +108 -18
  53. data/lib/organizations/models/join_code.rb +242 -0
  54. data/lib/organizations/models/join_request.rb +627 -0
  55. data/lib/organizations/models/membership.rb +48 -18
  56. data/lib/organizations/models/organization.rb +317 -19
  57. data/lib/organizations/organization_scoped.rb +135 -0
  58. data/lib/organizations/roles.rb +143 -36
  59. data/lib/organizations/test_helpers.rb +35 -1
  60. data/lib/organizations/version.rb +1 -1
  61. data/lib/organizations/view_helpers.rb +8 -12
  62. data/lib/organizations.rb +165 -0
  63. 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)
@@ -38,7 +38,9 @@ module Organizations
38
38
 
39
39
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
40
40
  validates :token, presence: true, uniqueness: true
41
- validates :role, presence: true, inclusion: { in: ->(_) { Roles::HIERARCHY.map(&:to_s) } }
41
+ validates :role, presence: true
42
+ validates :role, inclusion: { in: ->(_) { Roles.valid_roles.map(&:to_s) } },
43
+ if: -> { new_record? || will_save_change_to_role? }
42
44
 
43
45
  # Validate only one non-accepted invitation per email per organization
44
46
  # This matches the DB constraint (accepted_at IS NULL), not expiry status
@@ -131,7 +133,7 @@ module Organizations
131
133
  # Validate email matches at model level (security)
132
134
  unless skip_email_validation
133
135
  if accepting_user.respond_to?(:email) && !for_email?(accepting_user.email)
134
- raise EmailMismatch, "This invitation was sent to a different email address"
136
+ raise EmailMismatch, Organizations.t(:"errors.invitation_email_mismatch")
135
137
  end
136
138
  end
137
139
 
@@ -148,16 +150,16 @@ module Organizations
148
150
  existing_membership = organization.memberships.find_by(user_id: accepting_user.id)
149
151
  return existing_membership if existing_membership
150
152
 
151
- raise InvitationAlreadyAccepted, "This invitation has already been accepted"
153
+ raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted")
152
154
  end
153
155
 
154
156
  if expired?
155
- raise InvitationExpired, "This invitation has expired"
157
+ raise InvitationExpired, Organizations.t(:"errors.invitation_expired")
156
158
  end
157
159
 
158
160
  # Owner role cannot be assigned via invitation (defense in depth)
159
161
  if role.to_sym == :owner
160
- raise CannotAcceptAsOwner, "Cannot accept invitation as owner. Invite as admin, then use transfer_ownership_to! after joining."
162
+ raise CannotAcceptAsOwner, Organizations.t(:"errors.invitation_accept_as_owner")
161
163
  end
162
164
 
163
165
  # Check if user is already a member (race condition from another invitation)
@@ -167,14 +169,8 @@ module Organizations
167
169
  return existing_membership
168
170
  end
169
171
 
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
- )
172
+ # Create the membership (with verified-joining provenance)
173
+ membership = create_membership_for!(accepting_user, skip_email_validation)
178
174
 
179
175
  # Mark invitation as accepted
180
176
  update!(accepted_at: Time.current)
@@ -198,7 +194,7 @@ module Organizations
198
194
  lock!
199
195
 
200
196
  if accepted?
201
- raise InvitationAlreadyAccepted, "Cannot resend an accepted invitation"
197
+ raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_cannot_resend_accepted")
202
198
  end
203
199
 
204
200
  update!(
@@ -216,7 +212,10 @@ module Organizations
216
212
  # @return [String]
217
213
  def acceptance_url(base_url: nil)
218
214
  base = base_url || default_base_url
219
- "#{base}/invitations/#{token}"
215
+ # Organizations.engine_mount_path keeps this correct for hosts that
216
+ # mount the engine somewhere other than root ("/orgs/invitations/…") —
217
+ # a hardcoded "/invitations/…" silently 404'd for them.
218
+ "#{base}#{Organizations.engine_mount_path}/invitations/#{token}"
220
219
  end
221
220
 
222
221
  # Check if invitation matches a specific email
@@ -228,6 +227,94 @@ module Organizations
228
227
 
229
228
  private
230
229
 
230
+ # Create the membership for an acceptance.
231
+ # Uses invited_by_id instead of invited_by to avoid Rails class reloading
232
+ # issues (AssociationTypeMismatch when User is reloaded in development).
233
+ #
234
+ # Verified-joining provenance (v0.5.0): accepting the emailed token is
235
+ # proof of control of the invited address, so the membership records it
236
+ # as a verified email — UNLESS the acceptance bypassed the email match
237
+ # (skip_email_validation with a different account email), where no inbox
238
+ # proof exists. If the address was already claimed by another membership
239
+ # in this org (rare recycled-address edge), the membership is still
240
+ # created, just without the verified-email stamp.
241
+ def create_membership_for!(accepting_user, skip_email_validation)
242
+ # THE MEMBERSHIP GATE (strict, vetoing, pre-persist) — see
243
+ # Configuration#on_member_joining. Runs inside accept!'s locked
244
+ # transaction: a veto rolls back accepted_at too, so the invitation
245
+ # stays pending and can be accepted again once the host unblocks.
246
+ Callbacks.dispatch(
247
+ :member_joining,
248
+ strict: true,
249
+ organization: organization,
250
+ user: accepting_user,
251
+ role: role,
252
+ joined_via: "invited",
253
+ invitation: self
254
+ )
255
+
256
+ # SAVEPOINT (requires_new): rescued unique-violation inside accept!'s
257
+ # transaction — see JoinRequest#create_membership! for the PostgreSQL
258
+ # aborted-transaction rationale (proven by the PG leg of the suite).
259
+ ActiveRecord::Base.transaction(requires_new: true) do
260
+ organization.memberships.create!(
261
+ **base_membership_attributes(accepting_user),
262
+ **verified_email_attributes_for(accepting_user, skip_email_validation)
263
+ )
264
+ end
265
+ rescue ActiveRecord::RecordNotUnique
266
+ # Two unique indexes can fire here (same disambiguation as
267
+ # JoinRequest#create_membership!):
268
+ # 1. (user, org) — another acceptance/join path just made them a member:
269
+ # reuse it (invitations to one address aren't normalization-aware, so
270
+ # two plus-variant invitations can race).
271
+ # 2. (org, verified_email_normalized) — the address was claimed between
272
+ # our pre-check and the INSERT: degrade gracefully by creating the
273
+ # membership WITHOUT the verified-email stamp. Acceptance never breaks.
274
+ existing = organization.memberships.find_by(user_id: accepting_user.id)
275
+ return existing if existing
276
+
277
+ # Its own savepoint too: this fallback INSERT can itself lose the
278
+ # (user, org) race, and accept!'s rescue must stay reachable on PG.
279
+ ActiveRecord::Base.transaction(requires_new: true) do
280
+ organization.memberships.create!(**base_membership_attributes(accepting_user))
281
+ end
282
+ end
283
+
284
+ def base_membership_attributes(accepting_user)
285
+ {
286
+ user: accepting_user,
287
+ role: role,
288
+ invited_by_id: invited_by_id,
289
+ joined_via: "invited",
290
+ metadata: membership_metadata.is_a?(Hash) ? membership_metadata : {}
291
+ }
292
+ end
293
+
294
+ # Provenance attributes for the membership created by this acceptance.
295
+ # See create_membership_for! for the trust rules.
296
+ def verified_email_attributes_for(accepting_user, skip_email_validation)
297
+ email_proven =
298
+ !skip_email_validation ||
299
+ (accepting_user.respond_to?(:email) && for_email?(accepting_user.email))
300
+
301
+ return {} unless email_proven
302
+
303
+ normalized = Organizations.configuration.normalize_verification_email(email)
304
+
305
+ already_claimed = Membership
306
+ .where(organization_id: organization_id, verified_email_normalized: normalized)
307
+ .exists?
308
+
309
+ return {} if already_claimed
310
+
311
+ {
312
+ verified_email: email,
313
+ verified_email_normalized: normalized,
314
+ verified_at: Time.current
315
+ }
316
+ end
317
+
231
318
  def normalize_email
232
319
  self.email = email.to_s.downcase.strip if email.present?
233
320
  end
@@ -265,7 +352,7 @@ module Organizations
265
352
  .exists?
266
353
 
267
354
  if existing
268
- errors.add(:email, "has already been invited to this organization")
355
+ errors.add(:email, Organizations.t(:"attributes.invitation_taken"))
269
356
  end
270
357
  end
271
358
 
@@ -283,7 +370,7 @@ module Organizations
283
370
  end
284
371
 
285
372
  def default_base_url
286
- if defined?(Rails) && Rails.application&.routes
373
+ if Organizations.full_rails_app? && Rails.application.routes
287
374
  Rails.application.routes.url_helpers.root_url.chomp("/")
288
375
  else
289
376
  ""
@@ -293,3 +380,6 @@ module Organizations
293
380
  end
294
381
  end
295
382
  end
383
+
384
+ # Host extension seam — see the load-hooks note in models/organization.rb.
385
+ 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)