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
@@ -15,9 +15,23 @@ module Organizations
15
15
  # membership.promote_to!(:admin)
16
16
  # membership.demote_to!(:member)
17
17
  #
18
+ # ⚠️ THE MEMBERSHIP GATE IS NOT ON THIS MODEL. `on_member_joining` (the
19
+ # strict, vetoing host callback — seat limits, member caps) is dispatched
20
+ # by the SANCTIONED join paths: Organization#add_member!,
21
+ # Invitation#accept!, and JoinRequest#approve! (which covers codes,
22
+ # domains, allowlists, and the account-email shortcut). A direct
23
+ # `Membership.create!` bypasses the gate entirely — that's deliberate for
24
+ # ops/console/test usage (Organization.create_with_owner!, TestHelpers),
25
+ # but NEVER create memberships directly from request-cycle product code,
26
+ # and any NEW join path added to the gem must dispatch the gate itself
27
+ # (see Configuration#on_member_joining).
18
28
  class Membership < ActiveRecord::Base
19
29
  self.table_name = "organizations_memberships"
20
30
 
31
+ # metadata_flag macro for typed boolean toggles over the metadata bag —
32
+ # see Organizations::MetadataFlags.
33
+ extend Organizations::MetadataFlags
34
+
21
35
  # Error raised when trying to demote below current role
22
36
  class CannotDemoteOwner < Organizations::Error; end
23
37
  class CannotPromoteToOwner < Organizations::Error; end
@@ -25,20 +39,25 @@ module Organizations
25
39
 
26
40
  # === Associations ===
27
41
 
28
- belongs_to :user
42
+ # Explicit class_name (NOT inferred from the association name) so hosts
43
+ # with a differently-named account model work: config.user_class.
44
+ belongs_to :user, class_name: Organizations.user_class_name
29
45
  belongs_to :organization,
30
46
  class_name: "Organizations::Organization",
31
47
  inverse_of: :memberships,
32
48
  counter_cache: :memberships_count
33
49
 
34
50
  belongs_to :invited_by,
35
- class_name: "User",
51
+ class_name: Organizations.user_class_name,
36
52
  optional: true
37
53
 
38
54
  # === Validations ===
39
55
 
40
56
  validates :role, presence: true, inclusion: { in: ->(_) { Roles::HIERARCHY.map(&:to_s) } }
41
- validates :user_id, uniqueness: { scope: :organization_id, message: "is already a member of this organization" }
57
+ # Proc message: resolved at VALIDATION time so it follows I18n.locale
58
+ # a literal string here would be frozen in whatever locale loaded first.
59
+ validates :user_id, uniqueness: { scope: :organization_id,
60
+ message: ->(*) { Organizations.t(:"attributes.membership_taken") } }
42
61
  validate :single_owner_per_organization, if: :owner?
43
62
 
44
63
  # === Scopes ===
@@ -71,6 +90,15 @@ module Organizations
71
90
  SQL
72
91
  }
73
92
 
93
+ # === Verified Joining (v0.5.0) ===
94
+
95
+ # Whether this membership was created with a proven email address
96
+ # (emailed-code challenge, confirmed account email, or accepted invitation)
97
+ # @return [Boolean]
98
+ def verified?
99
+ verified_at.present?
100
+ end
101
+
74
102
  # === Role Methods ===
75
103
 
76
104
  # Get the role as a symbol
@@ -152,11 +180,11 @@ module Organizations
152
180
 
153
181
  # Owner role is only assignable via transfer_ownership_to!
154
182
  if new_role_sym == :owner
155
- raise CannotPromoteToOwner, "Cannot promote to owner. Use organization.transfer_ownership_to! instead."
183
+ raise CannotPromoteToOwner, Organizations.t(:"errors.cannot_promote_to_owner")
156
184
  end
157
185
 
158
186
  unless Roles.at_least?(new_role_sym, role_sym)
159
- raise InvalidRoleChange, "Cannot promote to #{new_role} - it's not a higher role than #{role}"
187
+ raise InvalidRoleChange, Organizations.t(:"errors.promote_not_higher", new_role: new_role, role: role)
160
188
  end
161
189
 
162
190
  change_role_to!(new_role_sym, changed_by: changed_by)
@@ -173,11 +201,11 @@ module Organizations
173
201
  validate_role!(new_role_sym)
174
202
 
175
203
  if owner?
176
- raise CannotDemoteOwner, "Cannot demote owner. Transfer ownership first."
204
+ raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner")
177
205
  end
178
206
 
179
207
  unless Roles.at_least?(role_sym, new_role_sym)
180
- raise InvalidRoleChange, "Cannot demote to #{new_role} - it's not a lower role than #{role}"
208
+ raise InvalidRoleChange, Organizations.t(:"errors.demote_not_lower", new_role: new_role, role: role)
181
209
  end
182
210
 
183
211
  change_role_to!(new_role_sym, changed_by: changed_by)
@@ -193,7 +221,7 @@ module Organizations
193
221
 
194
222
  return unless existing_owner.exists?
195
223
 
196
- errors.add(:role, "owner already exists for this organization")
224
+ errors.add(:role, Organizations.t(:"attributes.owner_taken"))
197
225
  end
198
226
 
199
227
  def validate_role!(role)
@@ -226,3 +254,6 @@ module Organizations
226
254
  end
227
255
  end
228
256
  end
257
+
258
+ # Host extension seam — see the load-hooks note in models/organization.rb.
259
+ ActiveSupport.run_load_hooks(:organizations_membership, Organizations::Membership)
@@ -18,6 +18,10 @@ module Organizations
18
18
  class Organization < ActiveRecord::Base
19
19
  self.table_name = "organizations_organizations"
20
20
 
21
+ # metadata_flag macro for typed boolean toggles over the metadata bag —
22
+ # see Organizations::MetadataFlags.
23
+ extend Organizations::MetadataFlags
24
+
21
25
  # Error raised when trying to perform invalid operations on organization
22
26
  class CannotRemoveOwner < Organizations::Error; end
23
27
  class CannotDemoteOwner < Organizations::Error; end
@@ -43,6 +47,32 @@ module Organizations
43
47
  inverse_of: :organization,
44
48
  dependent: :destroy
45
49
 
50
+ # === Verified joining (v0.5.0) ===
51
+
52
+ has_many :domains,
53
+ class_name: "Organizations::Domain",
54
+ inverse_of: :organization,
55
+ dependent: :destroy
56
+
57
+ # NOTE: join_requests are declared BEFORE join_codes on purpose — Rails
58
+ # runs dependent: :destroy cascades in DECLARATION order, and requests
59
+ # hold an FK to the code that created them. Requests must die first (and
60
+ # JoinCode#join_requests additionally nullifies for standalone deletes).
61
+ has_many :join_requests,
62
+ class_name: "Organizations::JoinRequest",
63
+ inverse_of: :organization,
64
+ dependent: :destroy
65
+
66
+ has_many :join_codes,
67
+ class_name: "Organizations::JoinCode",
68
+ inverse_of: :organization,
69
+ dependent: :destroy
70
+
71
+ has_many :allowlist_entries,
72
+ class_name: "Organizations::AllowlistEntry",
73
+ inverse_of: :organization,
74
+ dependent: :destroy
75
+
46
76
  # === Validations ===
47
77
 
48
78
  validates :name, presence: true
@@ -57,6 +87,44 @@ module Organizations
57
87
  joins(:memberships).where(organizations_memberships: { user_id: user.id })
58
88
  }
59
89
 
90
+ # === Creation ===
91
+
92
+ # Create an organization WITH its owner membership in one transaction —
93
+ # the ops/provisioning primitive. This is what consoles, seed scripts,
94
+ # and admin provisioning services should call instead of hand-rolling
95
+ # `create! + Membership.create!(role: "owner")` (which one production
96
+ # host did, accidentally skipping the organization_created callback).
97
+ #
98
+ # Differences from user.create_organization! (the SELF-SERVE path):
99
+ # - no session/current-organization switching (there is no session)
100
+ # - not subject to max_organizations_per_user (an ops admin may own
101
+ # hundreds of provisioned orgs)
102
+ # - does NOT fire on_member_joining (owner-at-creation is not
103
+ # "joining" — same contract as the rest of the gate)
104
+ # - DOES fire on_organization_created, like every creation path.
105
+ #
106
+ # ⚠️ Because it skips the per-user cap, do NOT expose this from
107
+ # request-cycle self-serve code — users creating their own organizations
108
+ # go through user.create_organization!. This is for consoles, seeds, and
109
+ # admin-vetted provisioning services.
110
+ #
111
+ # @param owner [User] becomes the owner (single-owner invariant holds)
112
+ # @param attributes [Hash] organization attributes (name:, plus any host columns)
113
+ # @return [Organizations::Organization]
114
+ def self.create_with_owner!(owner:, **attributes)
115
+ raise ArgumentError, "owner is required" unless owner
116
+
117
+ organization = nil
118
+ ActiveRecord::Base.transaction do
119
+ organization = create!(**attributes)
120
+ Membership.create!(user: owner, organization: organization, role: "owner")
121
+ end
122
+
123
+ Callbacks.dispatch(:organization_created, organization: organization, user: owner)
124
+
125
+ organization
126
+ end
127
+
60
128
  # === Member Query Methods ===
61
129
 
62
130
  # Get the owner of this organization
@@ -128,7 +196,7 @@ module Organizations
128
196
 
129
197
  # Owner role is only assignable via transfer_ownership_to! or initial creation
130
198
  if role_sym == :owner
131
- raise CannotHaveMultipleOwners, "Cannot add member as owner. Use transfer_ownership_to! instead."
199
+ raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_add_as_owner")
132
200
  end
133
201
 
134
202
  # Check if already a member (idempotent operation)
@@ -136,19 +204,51 @@ module Organizations
136
204
  return existing if existing
137
205
 
138
206
  membership = nil
139
- ActiveRecord::Base.transaction do
207
+ created = false
208
+ # requires_new: the rescued unique-violation below must roll back only
209
+ # a SAVEPOINT — if a HOST wraps add_member! in its own transaction, a
210
+ # plain nested block joins it, and on PostgreSQL the violation would
211
+ # poison the host's whole transaction before the rescue could run the
212
+ # idempotent lookup (see JoinRequest#create_membership!).
213
+ ActiveRecord::Base.transaction(requires_new: true) do
214
+ # Re-check under the transaction (review finding): a concurrent
215
+ # add_member! that already inserted makes this call an idempotent
216
+ # no-op — resolving here (instead of falling through to the gate)
217
+ # keeps the gate from firing/vetoing a join that already happened,
218
+ # and member_joined is NOT re-dispatched for it (the concurrent
219
+ # creator already fired it). The RecordNotUnique rescue below stays
220
+ # as the backstop for the window this narrows but cannot close.
221
+ existing = memberships.find_by(user_id: user.id)
222
+ next (membership = existing) if existing
223
+
224
+ # THE MEMBERSHIP GATE (strict, vetoing, pre-persist): the one place a
225
+ # host can abort ANY membership creation — seat limits, member caps.
226
+ # Raising here rolls back cleanly (nothing persisted yet). Runs after
227
+ # the idempotency check on purpose: an existing member isn't joining.
228
+ Callbacks.dispatch(
229
+ :member_joining,
230
+ strict: true,
231
+ organization: self,
232
+ user: user,
233
+ role: role_sym.to_s,
234
+ joined_via: "manual"
235
+ )
236
+
140
237
  membership = memberships.create!(
141
238
  user: user,
142
239
  role: role_sym.to_s
143
240
  )
241
+ created = true
144
242
  end
145
243
 
146
- Callbacks.dispatch(
147
- :member_joined,
148
- organization: self,
149
- membership: membership,
150
- user: user
151
- )
244
+ if created
245
+ Callbacks.dispatch(
246
+ :member_joined,
247
+ organization: self,
248
+ membership: membership,
249
+ user: user
250
+ )
251
+ end
152
252
 
153
253
  membership
154
254
  rescue ActiveRecord::RecordNotUnique
@@ -165,7 +265,7 @@ module Organizations
165
265
  return unless membership
166
266
 
167
267
  if membership.role.to_sym == :owner
168
- raise CannotRemoveOwner, "Cannot remove the organization owner. Transfer ownership first."
268
+ raise CannotRemoveOwner, Organizations.t(:"errors.cannot_remove_owner")
169
269
  end
170
270
 
171
271
  ActiveRecord::Base.transaction do
@@ -210,12 +310,12 @@ module Organizations
210
310
  if new_role == :owner && old_role != :owner
211
311
  # Promoting to owner - this is only allowed via transfer_ownership_to!
212
312
  # Direct role change to owner is not permitted
213
- raise CannotHaveMultipleOwners, "Cannot promote to owner. Use transfer_ownership_to! instead."
313
+ raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_promote_to_owner")
214
314
  end
215
315
 
216
316
  if old_role == :owner && new_role != :owner
217
317
  # Demoting owner - not allowed directly
218
- raise CannotDemoteOwner, "Cannot demote owner directly. Use transfer_ownership_to! instead."
318
+ raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner_directly")
219
319
  end
220
320
 
221
321
  membership.update!(role: new_role.to_s)
@@ -250,16 +350,16 @@ module Organizations
250
350
  new_owner_membership = memberships.find_by(user_id: new_owner.id)
251
351
 
252
352
  unless old_owner_membership
253
- raise NoOwnerPresent, "Cannot transfer ownership because organization has no owner membership"
353
+ raise NoOwnerPresent, Organizations.t(:"errors.transfer_no_owner")
254
354
  end
255
355
 
256
356
  unless new_owner_membership
257
- raise CannotTransferToNonMember, "Cannot transfer ownership to a non-member"
357
+ raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member")
258
358
  end
259
359
 
260
360
  # New owner must be at least an admin (per README: "Ownership can be transferred to any admin")
261
361
  unless Roles.at_least?(new_owner_membership.role.to_sym, :admin)
262
- raise CannotTransferToNonAdmin, "Cannot transfer ownership to non-admin. Promote them to admin first."
362
+ raise CannotTransferToNonAdmin, Organizations.t(:"errors.transfer_to_non_admin")
263
363
  end
264
364
 
265
365
  # No-op transfer to the current owner.
@@ -307,7 +407,7 @@ module Organizations
307
407
 
308
408
  # Owner role cannot be assigned via invitation - only via transfer_ownership_to!
309
409
  if role_sym == :owner
310
- raise CannotInviteAsOwner, "Cannot invite as owner. Invite as admin, then use transfer_ownership_to! after they join."
410
+ raise CannotInviteAsOwner, Organizations.t(:"errors.invite_as_owner")
311
411
  end
312
412
 
313
413
  normalized_email = email.downcase.strip
@@ -318,7 +418,7 @@ module Organizations
318
418
 
319
419
  # Check if already a member (case-insensitive)
320
420
  if users.where("LOWER(email) = ?", normalized_email).exists?
321
- raise Organizations::InvitationError, "User is already a member of this organization"
421
+ raise Organizations::InvitationError, Organizations.t(:"errors.invitation_already_member")
322
422
  end
323
423
 
324
424
  # Allow callback hooks to veto invitations (e.g., plan seat limits) before write.
@@ -338,7 +438,11 @@ module Organizations
338
438
  )
339
439
 
340
440
  invitation = nil
341
- ActiveRecord::Base.transaction do
441
+ # requires_new: the rescued unique-violation must roll back only a
442
+ # SAVEPOINT so the method-level rescue's lookup still works when a HOST
443
+ # wraps this call in its own transaction (PostgreSQL poisons the whole
444
+ # transaction otherwise — see JoinRequest#create_membership!).
445
+ ActiveRecord::Base.transaction(requires_new: true) do
342
446
  # Check for expired invitation and refresh it instead of creating duplicate
343
447
  expired_invitation = invitations.expired.for_email(normalized_email).first
344
448
  if expired_invitation
@@ -370,8 +474,186 @@ module Organizations
370
474
  invitations.pending.for_email(normalized_email).first!
371
475
  end
372
476
 
477
+ # === Verified Joining Methods (v0.5.0) ===
478
+
479
+ # Enroll an email domain for domain-verified joining.
480
+ # @param domain [String] e.g. "inizio.com" (exact match — subdomains must be enrolled separately)
481
+ # @param membership_metadata [Hash] copied onto memberships created through this domain
482
+ # @return [Domain]
483
+ def add_domain!(domain, membership_metadata: {})
484
+ domains.create!(domain: domain, membership_metadata: membership_metadata)
485
+ end
486
+
487
+ # Generate a shareable join code (PIN).
488
+ # @param label [String, nil] campaign attribution ("cafeteria poster")
489
+ # @param requires_verified_domain_email [Boolean] chain the emailed-code challenge ("reinforced" level)
490
+ # @param auto_approve [Boolean] false parks redemptions as pending requests for manual approval
491
+ # @param expires_at [Time, nil]
492
+ # @param max_uses [Integer, nil]
493
+ # @param created_by [User, nil]
494
+ # @param membership_metadata [Hash] copied onto memberships created through this code
495
+ # @return [JoinCode]
496
+ # Every parameter is an optional keyword with a safe default — this IS
497
+ # the public API surface, not incidental complexity.
498
+ # rubocop:disable Metrics/ParameterLists
499
+ def generate_join_code!(label: nil, requires_verified_domain_email: false, auto_approve: true,
500
+ expires_at: nil, max_uses: nil, created_by: nil, membership_metadata: {})
501
+ join_codes.create!(
502
+ label: label,
503
+ requires_verified_domain_email: requires_verified_domain_email,
504
+ auto_approve: auto_approve,
505
+ expires_at: expires_at,
506
+ max_uses: max_uses,
507
+ created_by_id: created_by&.id,
508
+ membership_metadata: membership_metadata
509
+ )
510
+ end
511
+ # rubocop:enable Metrics/ParameterLists
512
+
513
+ # Bulk-import roster emails as allowlist entries (idempotent per address:
514
+ # already-enrolled addresses are skipped, not duplicated).
515
+ # @param emails [Enumerable<String>]
516
+ # @param source [String, nil] provenance tag ("csv_2026-07")
517
+ # @param membership_metadata [Hash] copied onto memberships created through these entries
518
+ # @return [Array<AllowlistEntry>] the newly created entries
519
+ def import_allowlist!(emails, source: nil, membership_metadata: {})
520
+ Array(emails).filter_map do |email|
521
+ # SAVEPOINT per entry: the duplicate-skip rescue below must survive
522
+ # under a CALLER's transaction (real hosts wrap provisioning in one)
523
+ # — on PostgreSQL the first duplicate would otherwise poison the
524
+ # whole wrapping transaction and abort the import mid-list.
525
+ entry = ActiveRecord::Base.transaction(requires_new: true) do
526
+ allowlist_entries.create!(
527
+ email: email,
528
+ source: source,
529
+ membership_metadata: membership_metadata
530
+ )
531
+ end
532
+ entry
533
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
534
+ # Skip duplicates silently (idempotent import); re-raise anything else.
535
+ raise e unless duplicate_allowlist_error?(e)
536
+
537
+ nil
538
+ end
539
+ end
540
+
541
+ # Open join requests awaiting a decision
542
+ # @return [ActiveRecord::Relation<JoinRequest>]
543
+ def pending_join_requests
544
+ join_requests.pending
545
+ end
546
+
547
+ # Whether this organization accepts EMAIL-PROOF joining: an enrolled
548
+ # domain or an unclaimed roster entry (both run the same emailed-code
549
+ # challenge). Drives whether a join screen shows the email form.
550
+ # @return [Boolean]
551
+ def accepts_domain_joining?
552
+ domains.exists? || allowlist_entries.unclaimed.exists?
553
+ end
554
+
555
+ # Whether this organization has at least one code that can actually be
556
+ # redeemed right now (not revoked/expired/exhausted). Drives whether a
557
+ # join screen shows the code form.
558
+ # @return [Boolean]
559
+ def accepts_code_joining?
560
+ join_codes.active.exists?
561
+ end
562
+
563
+ # Whether this organization exposes any self-serve joining mechanism.
564
+ # NOTE (0.5.0 refinement): codes now count only while actually
565
+ # redeemable — an org whose every code expired or ran out no longer
566
+ # reads as joinable.
567
+ # @return [Boolean]
568
+ def accepts_join_requests?
569
+ accepts_domain_joining? || accepts_code_joining?
570
+ end
571
+
572
+ # Approve a join request (creates the membership). See JoinRequest#approve!.
573
+ # @param join_request [JoinRequest]
574
+ # @param approved_by [User, nil]
575
+ # @return [Membership]
576
+ def approve_join_request!(join_request, approved_by: nil)
577
+ ensure_join_request_belongs_here!(join_request)
578
+ join_request.approve!(decided_by: approved_by)
579
+ end
580
+
581
+ # Reject a join request. See JoinRequest#reject!.
582
+ # @param join_request [JoinRequest]
583
+ # @param rejected_by [User, nil]
584
+ # @param reason [String, nil]
585
+ # @return [JoinRequest]
586
+ def reject_join_request!(join_request, rejected_by: nil, reason: nil)
587
+ ensure_join_request_belongs_here!(join_request)
588
+ join_request.reject!(rejected_by: rejected_by, reason: reason)
589
+ end
590
+
591
+ # Zero-friction domain join for hosts with confirmed account emails
592
+ # (e.g. Devise :confirmable): if the user's own account email is confirmed
593
+ # and its domain is enrolled, the inbox was already proven at signup — no
594
+ # emailed code needed.
595
+ #
596
+ # @param user [User] must respond to #email; #confirmed_at gates trust
597
+ # @return [Membership]
598
+ # @raise [VerificationEmailNotEligible] when the account email's domain isn't enrolled,
599
+ # the email is unconfirmed, or the feature is disabled
600
+ def join_with_account_email!(user)
601
+ unless Organizations.configuration.trust_confirmed_account_email
602
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_trust_disabled")
603
+ end
604
+
605
+ email = user.respond_to?(:email) ? user.email.to_s : ""
606
+ confirmed = user.respond_to?(:confirmed_at) && user.confirmed_at.present?
607
+
608
+ unless confirmed
609
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_unconfirmed")
610
+ end
611
+
612
+ matched_domain = domains.matching_email(email).first
613
+ unless matched_domain
614
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible")
615
+ end
616
+
617
+ # Uniform funnel: every self-serve join goes through a JoinRequest so
618
+ # provenance and audit trail come for free.
619
+ request = join_requests.pending.find_by(user_id: user.id) || join_requests.new(user: user)
620
+ normalized = Organizations.configuration.normalize_verification_email(email)
621
+
622
+ ActiveRecord::Base.transaction do
623
+ if Membership.where(organization_id: id, verified_email_normalized: normalized).exists?
624
+ raise VerificationEmailAlreadyClaimed,
625
+ Organizations.t(:"errors.verification_email_already_claimed")
626
+ end
627
+
628
+ request.assign_attributes(
629
+ joined_via: "domain_email",
630
+ verification_email: email,
631
+ verification_email_normalized: normalized,
632
+ verified_at: Time.current,
633
+ metadata: (request.metadata || {}).merge("matched_domain_id" => matched_domain.id)
634
+ )
635
+ request.save!
636
+ end
637
+
638
+ request.approve!(decided_by: nil)
639
+ end
640
+
373
641
  private
374
642
 
643
+ def ensure_join_request_belongs_here!(join_request)
644
+ return if join_request.organization_id == id
645
+
646
+ raise ArgumentError, "Join request does not belong to this organization"
647
+ end
648
+
649
+ def duplicate_allowlist_error?(error)
650
+ return true if error.is_a?(ActiveRecord::RecordNotUnique)
651
+
652
+ error.is_a?(ActiveRecord::RecordInvalid) &&
653
+ error.record.is_a?(Organizations::AllowlistEntry) &&
654
+ error.record.errors.of_kind?(:email_normalized, :taken)
655
+ end
656
+
375
657
  # Defense in depth for organization-centric API usage.
376
658
  # The user-level API already checks this, but direct calls to `org.send_invite_to!`
377
659
  # must enforce membership and invite permission as well.
@@ -380,7 +662,7 @@ module Organizations
380
662
 
381
663
  unless inviter_membership
382
664
  raise Organizations::NotAMember.new(
383
- "Only organization members can send invitations",
665
+ Organizations.t(:"errors.invite_not_a_member"),
384
666
  organization: self,
385
667
  user: inviter
386
668
  )
@@ -389,7 +671,7 @@ module Organizations
389
671
  return if Roles.has_permission?(inviter_membership.role.to_sym, :invite_members)
390
672
 
391
673
  raise Organizations::NotAuthorized.new(
392
- "You don't have permission to invite members",
674
+ Organizations.t(:"errors.invite_not_authorized"),
393
675
  permission: :invite_members,
394
676
  organization: self,
395
677
  user: inviter
@@ -432,3 +714,19 @@ module Organizations
432
714
  end
433
715
  end
434
716
  end
717
+
718
+ # HOST EXTENSION SEAM (pay-gem style load hooks): fires on EVERY load of this
719
+ # file — including Zeitwerk reloads via the app/models shims — so extensions
720
+ # registered with ActiveSupport.on_load survive code reloading in development.
721
+ # A bare `Organizations::Organization.class_eval` in an initializer runs ONCE
722
+ # and evaporates on the first reload (the shim `load`s a fresh class object);
723
+ # a `to_prepare` block works but is one shared basket — one bad constant in
724
+ # it silently kills every extension after it. Load hooks are per-model and
725
+ # re-run automatically. Register in an initializer:
726
+ #
727
+ # ActiveSupport.on_load(:organizations_organization) do
728
+ # include OrganizationExtensions # `self` is Organizations::Organization
729
+ # end
730
+ #
731
+ # Docs: https://api.rubyonrails.org/classes/ActiveSupport/LazyLoadHooks.html
732
+ ActiveSupport.run_load_hooks(:organizations_organization, Organizations::Organization)