organizations 0.4.3 → 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 +46 -0
  5. data/README.md +405 -41
  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 +8 -12
  60. data/lib/organizations.rb +165 -0
  61. metadata +34 -2
@@ -0,0 +1,627 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "active_support/security_utils"
5
+
6
+ module Organizations
7
+ # A user's petition to join an organization — the mirror image of Invitation
8
+ # (invitation = org→user, join request = user→org; both converge on
9
+ # Membership). Memberships stay active-only: all "pending" state lives here,
10
+ # so every existing membership invariant (counter cache, single owner,
11
+ # uniqueness) is untouched.
12
+ #
13
+ # Stored statuses: pending → approved | rejected | withdrawn.
14
+ # `:expired` is DERIVED from expires_at (same approach as invitations).
15
+ #
16
+ # A request may carry an email-verification challenge: the user proves
17
+ # control of an inbox that either belongs to one of the organization's
18
+ # Domains or matches an unclaimed AllowlistEntry. The 6-digit code is
19
+ # stored as a SHA-256 digest only (peppered with the row id) — plaintext
20
+ # never touches the database.
21
+ #
22
+ # @example Request to join (manual approval)
23
+ # request = user.request_to_join!(org, message: "Soy socio nº 442")
24
+ # org.approve_join_request!(request, approved_by: admin)
25
+ #
26
+ # @example Domain-email verified join
27
+ # request = user.request_to_join!(org)
28
+ # request.start_email_verification!(email: "j.doe@inizio.com")
29
+ # request.verify_email_code!("492817") # => Membership (auto-approved)
30
+ #
31
+ # The class carries the request's FULL lifecycle (statuses, challenge,
32
+ # decisions) exactly like its mirror Invitation does — splitting it would
33
+ # scatter one cohesive state machine across files.
34
+ # rubocop:disable Metrics/ClassLength
35
+ class JoinRequest < ActiveRecord::Base
36
+ self.table_name = "organizations_join_requests"
37
+
38
+ STATUSES = %w[pending approved rejected withdrawn].freeze
39
+
40
+ # === Associations ===
41
+
42
+ belongs_to :organization,
43
+ class_name: "Organizations::Organization",
44
+ inverse_of: :join_requests
45
+
46
+ # Explicit class_name (NOT inferred from the association name) so hosts
47
+ # with a differently-named account model work: config.user_class.
48
+ belongs_to :user, class_name: Organizations.user_class_name
49
+
50
+ belongs_to :join_code,
51
+ class_name: "Organizations::JoinCode",
52
+ inverse_of: :join_requests,
53
+ optional: true
54
+
55
+ belongs_to :decided_by,
56
+ class_name: Organizations.user_class_name,
57
+ optional: true
58
+
59
+ # === Validations ===
60
+
61
+ validates :status, presence: true, inclusion: { in: STATUSES }
62
+ validate :unique_pending_request, on: :create
63
+
64
+ # === Callbacks ===
65
+
66
+ before_validation :set_expiry, on: :create, if: -> { expires_at.blank? }
67
+
68
+ # === Scopes ===
69
+
70
+ # Open requests (pending status and not past expiry)
71
+ scope :pending, lambda {
72
+ where(status: "pending")
73
+ .where("expires_at IS NULL OR expires_at > ?", Time.current)
74
+ }
75
+
76
+ # Requests that timed out while pending
77
+ scope :expired, lambda {
78
+ where(status: "pending")
79
+ .where("expires_at IS NOT NULL AND expires_at <= ?", Time.current)
80
+ }
81
+
82
+ scope :approved, -> { where(status: "approved") }
83
+ scope :rejected, -> { where(status: "rejected") }
84
+ scope :withdrawn, -> { where(status: "withdrawn") }
85
+
86
+ # === Status Methods ===
87
+
88
+ # @return [Boolean]
89
+ def pending?
90
+ status == "pending" && !expired?
91
+ end
92
+
93
+ # @return [Boolean]
94
+ def approved?
95
+ status == "approved"
96
+ end
97
+
98
+ # @return [Boolean]
99
+ def rejected?
100
+ status == "rejected"
101
+ end
102
+
103
+ # @return [Boolean]
104
+ def withdrawn?
105
+ status == "withdrawn"
106
+ end
107
+
108
+ # @return [Boolean] request timed out while pending
109
+ def expired?
110
+ status == "pending" && expires_at.present? && expires_at <= Time.current
111
+ end
112
+
113
+ # @return [Boolean] a terminal decision was made
114
+ def decided?
115
+ approved? || rejected? || withdrawn?
116
+ end
117
+
118
+ # Effective status as a symbol (derives :expired)
119
+ # @return [Symbol] :pending, :approved, :rejected, :withdrawn, or :expired
120
+ def effective_status
121
+ return :expired if expired?
122
+
123
+ status.to_sym
124
+ end
125
+
126
+ # @return [Boolean] the email challenge was completed
127
+ def email_verified?
128
+ verified_at.present?
129
+ end
130
+
131
+ # === Email Verification Challenge ===
132
+
133
+ # Start (or restart) the emailed-code challenge for an address the user
134
+ # claims to control. The address must belong to one of the organization's
135
+ # domains or match an unclaimed allowlist entry — proof of control is
136
+ # required in BOTH cases (a leaked roster must not grant membership
137
+ # without inbox access).
138
+ #
139
+ # @param email [String] the address to prove (may differ from user.email)
140
+ # @return [self]
141
+ # @raise [VerificationEmailNotEligible] address invalid or not eligible for this org
142
+ # @raise [VerificationEmailAlreadyClaimed] address already proven by another membership
143
+ # @raise [VerificationThrottled] resend interval / send cap hit
144
+ # @raise [JoinRequestExpired, JoinRequestAlreadyDecided] request not open
145
+ def start_email_verification!(email:)
146
+ address = email.to_s.strip
147
+
148
+ unless address.match?(URI::MailTo::EMAIL_REGEXP)
149
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_invalid")
150
+ end
151
+
152
+ code = nil
153
+
154
+ ActiveRecord::Base.transaction do
155
+ lock!
156
+ ensure_open!
157
+
158
+ matched_domain, matched_entry = eligible_instruments_for!(address)
159
+ ensure_address_unclaimed!(address)
160
+ ensure_send_allowed!
161
+
162
+ code = generate_verification_code
163
+ update!(challenge_attributes(address, code, matched_domain, matched_entry))
164
+ end
165
+
166
+ deliver_verification_email(code)
167
+ self
168
+ end
169
+
170
+ # Verify the emailed code. On success, auto-approves when the governing
171
+ # instrument allows it (domain/roster joins: always; code joins: per the
172
+ # code's auto_approve flag).
173
+ #
174
+ # @param code [String] the 6-digit code as typed
175
+ # @return [Membership] when verification auto-approved the request
176
+ # @return [self] when verified but awaiting manual approval
177
+ # @raise [VerificationCodeInvalid, VerificationCodeExpired, VerificationAttemptsExceeded]
178
+ # @raise [JoinRequestExpired, JoinRequestAlreadyDecided]
179
+ def verify_email_code!(code)
180
+ failure = nil
181
+
182
+ ActiveRecord::Base.transaction do
183
+ lock!
184
+ ensure_open!
185
+ ensure_active_challenge!
186
+
187
+ if correct_code?(code)
188
+ # Burn the code: single-use by construction.
189
+ update!(verified_at: Time.current, verification_code_digest: nil)
190
+ else
191
+ # Persist the failed attempt, then raise OUTSIDE the transaction —
192
+ # raising here would roll the increment back and give attackers
193
+ # unlimited tries.
194
+ update!(verification_attempts: verification_attempts + 1)
195
+ failure = VerificationCodeInvalid.new(Organizations.t(:"errors.verification_code_invalid"))
196
+ end
197
+ end
198
+
199
+ raise failure if failure
200
+
201
+ return approve!(decided_by: nil) if auto_approvable_after_verification?
202
+
203
+ self
204
+ end
205
+
206
+ # === Decisions ===
207
+
208
+ # Approve this request and create the membership (the ONLY way a join
209
+ # request becomes a membership). Row-locked and idempotent: approving an
210
+ # already-approved request returns the existing membership.
211
+ #
212
+ # Provenance is stamped on the membership (joined_via, verified_email,
213
+ # verified_at) and `membership_metadata` from the governing instruments is
214
+ # merged in (matched domain, then matched allowlist entry, then join code
215
+ # — later wins).
216
+ #
217
+ # @param decided_by [User, nil] approver (nil for auto-approvals)
218
+ # @return [Membership]
219
+ # @raise [JoinRequestExpired, JoinRequestAlreadyDecided]
220
+ def approve!(decided_by: nil)
221
+ membership = nil
222
+ reused_existing = false
223
+
224
+ ActiveRecord::Base.transaction do
225
+ lock!
226
+
227
+ return approved_membership! if approved? # idempotent re-approval
228
+
229
+ ensure_open!
230
+
231
+ existing = existing_membership
232
+
233
+ if existing
234
+ membership = existing
235
+ reused_existing = true
236
+ else
237
+ membership = create_membership!
238
+ claim_matched_allowlist_entry!
239
+ end
240
+
241
+ update!(status: "approved", decided_by_id: decided_by&.id, decided_at: Time.current)
242
+ end
243
+
244
+ dispatch_approval_callbacks(membership, decided_by, reused_existing)
245
+ membership
246
+ end
247
+
248
+ # Reject this request.
249
+ # @param rejected_by [User, nil]
250
+ # @param reason [String, nil] stored in metadata for audit; never shown by the gem
251
+ # @return [self]
252
+ # @raise [JoinRequestAlreadyDecided]
253
+ def reject!(rejected_by: nil, reason: nil)
254
+ ActiveRecord::Base.transaction do
255
+ lock!
256
+ ensure_undecided!
257
+
258
+ new_metadata = metadata || {}
259
+ new_metadata = new_metadata.merge("rejection_reason" => reason) if reason.present?
260
+
261
+ update!(
262
+ status: "rejected",
263
+ decided_by_id: rejected_by&.id,
264
+ decided_at: Time.current,
265
+ metadata: new_metadata
266
+ )
267
+ end
268
+
269
+ Callbacks.dispatch(
270
+ :join_request_rejected,
271
+ organization: organization,
272
+ user: user,
273
+ join_request: self,
274
+ decided_by: rejected_by
275
+ )
276
+
277
+ self
278
+ end
279
+
280
+ # Withdraw this request (the user cancels their own petition).
281
+ # @return [self]
282
+ # @raise [JoinRequestAlreadyDecided]
283
+ def withdraw!
284
+ ActiveRecord::Base.transaction do
285
+ lock!
286
+ ensure_undecided!
287
+
288
+ update!(status: "withdrawn", decided_at: Time.current)
289
+ end
290
+
291
+ self
292
+ end
293
+
294
+ # Digest a verification code, peppered with the row id so identical codes
295
+ # on different requests produce different digests.
296
+ # @return [String]
297
+ def self.digest_verification_code(code, request_id)
298
+ Digest::SHA256.hexdigest("#{code}-#{request_id}")
299
+ end
300
+
301
+ # Data-minimization sweep (GDPR posture): decided (rejected/withdrawn)
302
+ # and expired requests hold a verification email address with no ongoing
303
+ # purpose — purge them once they're old enough. APPROVED requests are
304
+ # deliberately kept: they are the join audit trail (provenance for the
305
+ # membership they created). The retention PERIOD is host policy; the
306
+ # knowledge of which states hold purposeless PII is the gem's.
307
+ #
308
+ # Wire it from a scheduled job/rake task, e.g.:
309
+ # Organizations::JoinRequest.purge_stale!(older_than: 12.months)
310
+ #
311
+ # @param older_than [ActiveSupport::Duration] minimum age before purging
312
+ # @return [Integer] number of purged rows
313
+ def self.purge_stale!(older_than: 12.months)
314
+ cutoff = Time.current - older_than
315
+
316
+ # in_batches: this is a maintenance sweep over potentially years of
317
+ # rows — batched deletes keep the lock/undo footprint flat on large
318
+ # tables (returns the summed count, same contract as delete_all).
319
+ where(status: %w[rejected withdrawn]).where(decided_at: ...cutoff)
320
+ .or(expired.where(expires_at: ...cutoff))
321
+ .in_batches
322
+ .delete_all
323
+ end
324
+
325
+ private
326
+
327
+ # Raise unless this request is still open (pending and not expired)
328
+ def ensure_open!
329
+ raise JoinRequestExpired, Organizations.t(:"errors.join_request_expired") if expired?
330
+ raise JoinRequestAlreadyDecided, already_decided_message if decided?
331
+ end
332
+
333
+ # Like ensure_open! but tolerates expiry — used by reject!/withdraw! so
334
+ # stale requests can still be cleaned up explicitly.
335
+ def ensure_undecided!
336
+ raise JoinRequestAlreadyDecided, already_decided_message if decided?
337
+ end
338
+
339
+ # "…has already been %{status}" — the status word itself is translated
340
+ # (organizations.join_request_status.*, lowercase for mid-sentence use)
341
+ # so the whole sentence localizes as one unit.
342
+ def already_decided_message
343
+ Organizations.t(:"errors.join_request_already_decided",
344
+ status: Organizations.t(:"join_request_status.#{status}", default: status))
345
+ end
346
+
347
+ def auto_approvable_after_verification?
348
+ return join_code.auto_approve? if join_code
349
+
350
+ true
351
+ end
352
+
353
+ def existing_membership
354
+ organization.memberships.find_by(user_id: user_id)
355
+ end
356
+
357
+ # The membership behind an already-approved request (idempotent path).
358
+ # Raises if it was removed after approval — the request can't be reused.
359
+ def approved_membership!
360
+ membership = existing_membership
361
+ raise JoinRequestAlreadyDecided, Organizations.t(:"errors.join_request_membership_gone") unless membership
362
+
363
+ membership
364
+ end
365
+
366
+ # Post-approval callback dispatches (outside the approval transaction).
367
+ # member_joined is skipped when the membership pre-existed via another
368
+ # path — it already fired when that membership was created.
369
+ def dispatch_approval_callbacks(membership, decided_by, reused_existing)
370
+ unless reused_existing
371
+ Callbacks.dispatch(
372
+ :member_joined,
373
+ organization: organization,
374
+ membership: membership,
375
+ user: user
376
+ )
377
+ end
378
+
379
+ Callbacks.dispatch(
380
+ :join_request_approved,
381
+ organization: organization,
382
+ user: user,
383
+ join_request: self,
384
+ membership: membership,
385
+ decided_by: decided_by
386
+ )
387
+ end
388
+
389
+ # === Challenge guards & builders (all called under lock) ===
390
+
391
+ # Resolve which join instrument makes this address eligible.
392
+ # Domains win over allowlist entries (an org can have both).
393
+ # @return [Array(Domain|nil, AllowlistEntry|nil)]
394
+ def eligible_instruments_for!(address)
395
+ matched_domain = organization.domains.matching_email(address).first
396
+ matched_entry = matched_domain ? nil : organization.allowlist_entries.unclaimed.for_email(address).first
397
+
398
+ unless matched_domain || matched_entry
399
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible")
400
+ end
401
+
402
+ [matched_domain, matched_entry]
403
+ end
404
+
405
+ # One proven email => one membership per org. Pre-check for a friendly
406
+ # error; the unique index on memberships is the backstop.
407
+ def ensure_address_unclaimed!(address)
408
+ normalized = Organizations.configuration.normalize_verification_email(address)
409
+ return unless Membership.where(organization_id: organization_id, verified_email_normalized: normalized).exists?
410
+
411
+ raise VerificationEmailAlreadyClaimed,
412
+ Organizations.t(:"errors.verification_email_already_claimed")
413
+ end
414
+
415
+ def ensure_send_allowed!
416
+ config = Organizations.configuration
417
+
418
+ if verification_sends_count >= config.verification_max_sends
419
+ raise VerificationThrottled, Organizations.t(:"errors.verification_sends_exceeded")
420
+ end
421
+
422
+ resend_floor = interval_ago(config.verification_resend_interval)
423
+ return unless verification_sent_at.present? && verification_sent_at > resend_floor
424
+
425
+ raise VerificationThrottled, Organizations.t(:"errors.verification_resend_throttled")
426
+ end
427
+
428
+ def ensure_active_challenge!
429
+ config = Organizations.configuration
430
+
431
+ raise VerificationCodeInvalid, Organizations.t(:"errors.verification_code_missing") if verification_code_digest.blank?
432
+
433
+ if verification_attempts >= config.verification_max_attempts
434
+ raise VerificationAttemptsExceeded, Organizations.t(:"errors.verification_attempts_exceeded")
435
+ end
436
+
437
+ return unless verification_expires_at.blank? || verification_expires_at <= Time.current
438
+
439
+ raise VerificationCodeExpired, Organizations.t(:"errors.verification_code_expired")
440
+ end
441
+
442
+ def correct_code?(code)
443
+ submitted = self.class.digest_verification_code(code.to_s.strip, id)
444
+ ActiveSupport::SecurityUtils.secure_compare(submitted, verification_code_digest)
445
+ end
446
+
447
+ def challenge_attributes(address, code, matched_domain, matched_entry)
448
+ config = Organizations.configuration
449
+
450
+ {
451
+ verification_email: address,
452
+ verification_email_normalized: config.normalize_verification_email(address),
453
+ verification_code_digest: self.class.digest_verification_code(code, id),
454
+ verification_sent_at: Time.current,
455
+ verification_expires_at: Time.current + config.verification_code_ttl,
456
+ verification_attempts: 0,
457
+ verification_sends_count: verification_sends_count + 1,
458
+ verified_at: nil,
459
+ joined_via: joined_via.presence || (matched_domain ? "domain_email" : "allowlist"),
460
+ metadata: (metadata || {}).merge(
461
+ "matched_domain_id" => matched_domain&.id,
462
+ "matched_allowlist_entry_id" => matched_entry&.id
463
+ ).compact
464
+ }
465
+ end
466
+
467
+ def create_membership!
468
+ effective_joined_via = joined_via.presence || "manual"
469
+ dispatch_member_joining_gate!(effective_joined_via)
470
+
471
+ # SAVEPOINT (requires_new): this INSERT runs inside approve!'s locked
472
+ # transaction and its unique-violation is RESCUED below. On PostgreSQL
473
+ # a statement error ABORTS the whole transaction ("current transaction
474
+ # is aborted, commands ignored…"), so without the savepoint the rescue
475
+ # path's queries explode instead of degrading gracefully — proven by
476
+ # running this suite against PG (SQLite forgives the pattern, which is
477
+ # how it stayed green). Source: https://www.postgresql.org/docs/current/tutorial-transactions.html
478
+ ActiveRecord::Base.transaction(requires_new: true) do
479
+ insert_membership_row!(effective_joined_via)
480
+ end
481
+ rescue ActiveRecord::RecordNotUnique
482
+ # Two possible unique collisions:
483
+ # 1. (user, org) membership race — another path just made them a member.
484
+ # 2. (org, verified_email_normalized) — the proven address was claimed
485
+ # concurrently. Surface the same friendly error as the pre-check.
486
+ existing = organization.memberships.find_by(user_id: user_id)
487
+ return existing if existing
488
+
489
+ raise VerificationEmailAlreadyClaimed,
490
+ Organizations.t(:"errors.verification_email_already_claimed")
491
+ end
492
+
493
+ def insert_membership_row!(effective_joined_via)
494
+ organization.memberships.create!(
495
+ user: user,
496
+ role: "member",
497
+ joined_via: effective_joined_via,
498
+ verified_email: email_verified? ? verification_email : nil,
499
+ verified_email_normalized: email_verified? ? verification_email_normalized : nil,
500
+ verified_at: verified_at,
501
+ metadata: resolved_membership_metadata
502
+ )
503
+ end
504
+
505
+ # THE MEMBERSHIP GATE (strict, vetoing, pre-persist) — covers every
506
+ # verified-joining path, since approval is the only way a request becomes
507
+ # a membership (codes, domains, allowlists, account-email shortcut all
508
+ # funnel here). Runs inside approve!'s locked transaction: a veto rolls
509
+ # back the status flip too, so the request stays PENDING — a safe,
510
+ # resumable state (approve again once the host unblocks).
511
+ # ⚠️ Join-code nuance: redemption consumes a use BEFORE approval, so a
512
+ # vetoed redemption still spends a use — max_uses is an anti-abuse cap,
513
+ # not a seat count (see README "Verified joining").
514
+ def dispatch_member_joining_gate!(effective_joined_via)
515
+ Callbacks.dispatch(
516
+ :member_joining,
517
+ strict: true,
518
+ organization: organization,
519
+ user: user,
520
+ role: "member",
521
+ joined_via: effective_joined_via,
522
+ join_request: self
523
+ )
524
+ end
525
+
526
+ def resolved_membership_metadata
527
+ merged = {}
528
+ merged = merged.merge(hashify(matched_domain&.membership_metadata))
529
+ merged = merged.merge(hashify(matched_allowlist_entry&.membership_metadata))
530
+ merged.merge(hashify(join_code&.membership_metadata))
531
+ end
532
+
533
+ def hashify(value)
534
+ value.is_a?(Hash) ? value : {}
535
+ end
536
+
537
+ def matched_domain
538
+ domain_id = (metadata || {})["matched_domain_id"]
539
+ return nil unless domain_id
540
+
541
+ @matched_domain ||= organization.domains.find_by(id: domain_id)
542
+ end
543
+
544
+ def matched_allowlist_entry
545
+ entry_id = (metadata || {})["matched_allowlist_entry_id"]
546
+ return nil unless entry_id
547
+
548
+ @matched_allowlist_entry ||= organization.allowlist_entries.find_by(id: entry_id)
549
+ end
550
+
551
+ def claim_matched_allowlist_entry!
552
+ matched_allowlist_entry&.claim!(user)
553
+ end
554
+
555
+ def generate_verification_code
556
+ format("%06d", SecureRandom.random_number(1_000_000))
557
+ end
558
+
559
+ def interval_ago(interval)
560
+ Time.current - interval
561
+ end
562
+
563
+ def deliver_verification_email(code)
564
+ mailer_class = Organizations.configuration.verification_mailer.constantize
565
+ mailer_class.code_email(self, code).deliver_later
566
+ rescue StandardError => e
567
+ # The challenge row already COMMITTED (throttle stamped, send counted)
568
+ # before delivery was attempted — if we only logged here, the user
569
+ # would sit behind the resend throttle (and burn one of max_sends)
570
+ # waiting for a code that never left the building. Roll the throttle
571
+ # bookkeeping back so an immediate retry is allowed, and give the host
572
+ # a real signal (the on_verification_delivery_failed callback) instead
573
+ # of a log line nobody watches.
574
+ rollback_undelivered_challenge!(code)
575
+ Callbacks.log_error("[Organizations] Failed to send verification email: #{e.message}")
576
+ Callbacks.dispatch(
577
+ :verification_delivery_failed,
578
+ organization: organization,
579
+ user: user,
580
+ join_request: self,
581
+ metadata: { "error_class" => e.class.name, "error_message" => e.message }
582
+ )
583
+ end
584
+
585
+ # Revert the challenge bookkeeping for a code that never got delivered.
586
+ # Digest-guarded under lock: if a concurrent resend already minted a new
587
+ # code (different digest) or a verify burned this one (nil digest), the
588
+ # state belongs to that other operation — leave it alone.
589
+ def rollback_undelivered_challenge!(code)
590
+ with_lock do
591
+ break unless verification_code_digest == self.class.digest_verification_code(code, id)
592
+
593
+ update!(
594
+ verification_code_digest: nil,
595
+ verification_sent_at: nil,
596
+ verification_expires_at: nil,
597
+ verification_attempts: 0,
598
+ verification_sends_count: [verification_sends_count - 1, 0].max
599
+ )
600
+ end
601
+ rescue StandardError => e
602
+ # Never let cleanup failure mask the original delivery failure.
603
+ Callbacks.log_error("[Organizations] Failed to roll back undelivered challenge: #{e.message}")
604
+ end
605
+
606
+ def set_expiry
607
+ expiry = Organizations.configuration.join_request_expiry
608
+ self.expires_at = expiry ? Time.current + expiry : nil
609
+ end
610
+
611
+ # Mirrors the DB partial unique index: one open request per (org, user)
612
+ def unique_pending_request
613
+ return unless organization_id && user_id
614
+ return unless status == "pending"
615
+
616
+ existing = JoinRequest.where(organization_id: organization_id, user_id: user_id, status: "pending")
617
+ .where.not(id: id)
618
+ .exists?
619
+
620
+ errors.add(:user_id, Organizations.t(:"attributes.pending_request_taken")) if existing
621
+ end
622
+ end
623
+ # rubocop:enable Metrics/ClassLength
624
+ end
625
+
626
+ # Host extension seam — see the load-hooks note in models/organization.rb.
627
+ ActiveSupport.run_load_hooks(:organizations_join_request, Organizations::JoinRequest)