organizations 0.2.0 → 0.3.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.
@@ -6,17 +6,15 @@ module Organizations
6
6
  class Engine < ::Rails::Engine
7
7
  isolate_namespace Organizations
8
8
 
9
- # Load models when ActiveRecord is ready
10
- # Note: We use explicit requires instead of autoload_paths because the models
11
- # are namespaced under Organizations:: but live in lib/organizations/models/
9
+ # Make has_organizations available on all ActiveRecord models.
10
+ #
11
+ # NOTE: We intentionally avoid explicit `require` calls for models here.
12
+ # In Rails apps, Organizations models are loaded through Zeitwerk from app/models,
13
+ # which keeps them reload-safe in development. Explicit requires would create
14
+ # non-reloadable class references that cause STI errors with gems like Pay
15
+ # after code reloads.
12
16
  initializer "organizations.active_record" do
13
17
  ActiveSupport.on_load(:active_record) do
14
- require "organizations/models/organization"
15
- require "organizations/models/membership"
16
- require "organizations/models/invitation"
17
- require "organizations/models/concerns/has_organizations"
18
-
19
- # Make has_organizations available on all AR models
20
18
  extend Organizations::Models::Concerns::HasOrganizations::ClassMethods
21
19
  end
22
20
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Value object representing a failed invitation acceptance attempt.
5
+ class InvitationAcceptanceFailure
6
+ REASONS = %i[
7
+ missing_user
8
+ missing_token
9
+ invitation_not_found
10
+ invitation_expired
11
+ email_mismatch
12
+ already_accepted_without_membership
13
+ ].freeze
14
+
15
+ attr_reader :reason, :invitation
16
+
17
+ def initialize(reason:, invitation: nil)
18
+ unless REASONS.include?(reason)
19
+ raise ArgumentError, "Invalid reason: #{reason.inspect}. Must be one of: #{REASONS.join(', ')}"
20
+ end
21
+
22
+ @reason = reason
23
+ @invitation = invitation
24
+ end
25
+
26
+ def success?
27
+ false
28
+ end
29
+
30
+ def failure?
31
+ true
32
+ end
33
+
34
+ def failure_reason
35
+ reason
36
+ end
37
+
38
+ REASONS.each do |value|
39
+ define_method("#{value}?") do
40
+ reason == value
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Value object returned by accept_pending_organization_invitation!
5
+ # Encapsulates the result of an invitation acceptance attempt.
6
+ #
7
+ # @example Checking result status
8
+ # result = accept_pending_organization_invitation!(user)
9
+ # if result&.accepted?
10
+ # redirect_to dashboard_path, notice: "Welcome!"
11
+ # end
12
+ #
13
+ class InvitationAcceptanceResult
14
+ STATUSES = %i[accepted already_member].freeze
15
+
16
+ attr_reader :status, :invitation, :membership
17
+
18
+ # @param status [Symbol] :accepted or :already_member
19
+ # @param invitation [Organizations::Invitation] The invitation that was accepted
20
+ # @param membership [Organizations::Membership] The resulting membership
21
+ # @param switched [Boolean] Whether organization context was switched (default: false)
22
+ def initialize(status:, invitation:, membership:, switched: false)
23
+ unless STATUSES.include?(status)
24
+ raise ArgumentError, "Invalid status: #{status.inspect}. Must be one of: #{STATUSES.join(', ')}"
25
+ end
26
+
27
+ @status = status
28
+ @invitation = invitation
29
+ @membership = membership
30
+ @switched = switched
31
+ end
32
+
33
+ # @return [Boolean] true if invitation was freshly accepted
34
+ def accepted?
35
+ status == :accepted
36
+ end
37
+
38
+ def success?
39
+ true
40
+ end
41
+
42
+ def failure?
43
+ false
44
+ end
45
+
46
+ def failure_reason
47
+ nil
48
+ end
49
+
50
+ # @return [Boolean] true if user was already a member
51
+ def already_member?
52
+ status == :already_member
53
+ end
54
+
55
+ # @return [Boolean] true if organization context was switched
56
+ def switched?
57
+ !!@switched
58
+ end
59
+ end
60
+ end
@@ -204,9 +204,12 @@ module Organizations
204
204
 
205
205
  # Check if user belongs to any organization
206
206
  # Uses efficient EXISTS query
207
+ # Falls back to DB query when loaded association is empty to avoid stale false negatives
207
208
  # @return [Boolean]
208
209
  def belongs_to_any_organization?
209
- memberships.loaded? ? memberships.any? : memberships.exists?
210
+ return true if memberships.loaded? && memberships.any?
211
+
212
+ memberships.exists?
210
213
  end
211
214
 
212
215
  # Check if user has pending invitations
@@ -271,31 +274,35 @@ module Organizations
271
274
  # === Role Checks (specific organization) ===
272
275
 
273
276
  # Get user's role in a specific organization
274
- # Smart about reusing loaded associations
277
+ # Smart about reusing loaded associations, with DB fallback for stale caches
275
278
  # @param org [Organization] The organization
276
279
  # @return [Symbol, nil]
277
280
  def role_in(org)
278
281
  return nil unless org
279
282
 
283
+ # Try org's loaded memberships first
280
284
  if org.respond_to?(:association) && org.association(:memberships).loaded?
281
285
  membership = org.memberships.find { |m| m.user_id == id }
282
- return membership&.role&.to_sym
286
+ return membership.role.to_sym if membership
283
287
  end
284
288
 
285
- # Try to use already-loaded association first
289
+ # Try user's loaded memberships
286
290
  if memberships.loaded?
287
291
  membership = memberships.find { |m| m.organization_id == org.id }
288
- return membership&.role&.to_sym
292
+ return membership.role.to_sym if membership
289
293
  end
290
294
 
295
+ # Try loaded organizations' memberships
291
296
  if organizations.loaded?
292
297
  loaded_org = organizations.find { |candidate| candidate.id == org.id }
293
298
  if loaded_org && loaded_org.respond_to?(:association) && loaded_org.association(:memberships).loaded?
294
299
  membership = loaded_org.memberships.find { |m| m.user_id == id }
295
- return membership&.role&.to_sym
300
+ return membership.role.to_sym if membership
296
301
  end
297
302
  end
298
303
 
304
+ # DB fallback - loaded associations may be stale if membership was created
305
+ # via another association path (e.g., organization.memberships.create!)
299
306
  memberships.find_by(organization_id: org.id)&.role&.to_sym
300
307
  end
301
308
 
@@ -318,16 +325,15 @@ module Organizations
318
325
 
319
326
  # Check if user is a member of specific organization
320
327
  # Uses efficient EXISTS query
328
+ # Falls back to DB query when loaded association misses to avoid stale false negatives
329
+ # (e.g., membership created via organization.memberships.create! bypasses user cache)
321
330
  # @param org [Organization] The organization
322
331
  # @return [Boolean]
323
332
  def is_member_of?(org)
324
333
  return false unless org
334
+ return true if memberships.loaded? && memberships.any? { |m| m.organization_id == org.id }
325
335
 
326
- if memberships.loaded?
327
- memberships.any? { |membership| membership.organization_id == org.id }
328
- else
329
- memberships.exists?(organization_id: org.id)
330
- end
336
+ memberships.exists?(organization_id: org.id)
331
337
  end
332
338
 
333
339
  # Check if user is viewer (or higher) of specific organization
@@ -369,11 +375,18 @@ module Organizations
369
375
 
370
376
  # Creates a new organization with this user as owner
371
377
  # Sets the new organization as the current organization
372
- # @param name_or_options [String, Hash] Organization name or options hash
378
+ # @param name_or_attributes [String, Hash] Organization name or attributes hash
373
379
  # @return [Organizations::Organization]
374
380
  # @raise [OrganizationLimitReached] if user has reached their organization limit
375
- def create_organization!(name_or_options)
376
- name = name_or_options.is_a?(Hash) ? name_or_options[:name] : name_or_options
381
+ #
382
+ # @example With just a name
383
+ # user.create_organization!("Acme Corp")
384
+ #
385
+ # @example With additional attributes
386
+ # user.create_organization!(name: "Acme Corp", support_email: "support@acme.com")
387
+ #
388
+ def create_organization!(name_or_attributes)
389
+ attributes = name_or_attributes.is_a?(Hash) ? name_or_attributes : { name: name_or_attributes }
377
390
 
378
391
  # Check max organizations limit
379
392
  settings = self.class.organization_settings
@@ -384,7 +397,7 @@ module Organizations
384
397
 
385
398
  org = nil
386
399
  ActiveRecord::Base.transaction do
387
- org = Organizations::Organization.create!(name: name)
400
+ org = Organizations::Organization.create!(attributes)
388
401
 
389
402
  Organizations::Membership.create!(
390
403
  user: self,
@@ -68,7 +68,11 @@ module Organizations
68
68
  # Get the owner's membership
69
69
  # @return [Membership, nil]
70
70
  def owner_membership
71
- memberships.find_by(role: "owner")
71
+ if association(:memberships).loaded?
72
+ memberships.find { |membership| membership.role == "owner" }
73
+ else
74
+ memberships.find_by(role: "owner")
75
+ end
72
76
  end
73
77
 
74
78
  # Get all admins (users with admin role or higher)
@@ -244,7 +248,9 @@ module Organizations
244
248
  # Lock organization first to prevent concurrent operations
245
249
  lock!
246
250
 
247
- old_owner_membership = owner_membership
251
+ # Always perform a fresh read in this write path, even if memberships
252
+ # are preloaded on this instance, to avoid stale-owner selection.
253
+ old_owner_membership = memberships.find_by(role: "owner")
248
254
  new_owner_membership = memberships.find_by(user_id: new_owner.id)
249
255
 
250
256
  unless old_owner_membership
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -21,6 +21,34 @@ module Organizations
21
21
  # </div>
22
22
  #
23
23
  module ViewHelpers
24
+ # === Host App Route Helper Delegation ===
25
+ #
26
+ # When views are rendered from engine controllers, route helpers like `root_path`
27
+ # resolve to the engine's routes, not the host app's routes. This delegation
28
+ # forwards missing route methods to `main_app` so host app routes work transparently.
29
+ #
30
+ # Instead of requiring `main_app.root_path` everywhere, you can just use `root_path`.
31
+ #
32
+ def method_missing(method, *args, &block)
33
+ if _organizations_route_helper?(method) && respond_to?(:main_app) && main_app.respond_to?(method)
34
+ main_app.public_send(method, *args, &block)
35
+ else
36
+ super
37
+ end
38
+ end
39
+
40
+ def respond_to_missing?(method, include_private = false)
41
+ (_organizations_route_helper?(method) && respond_to?(:main_app) && main_app.respond_to?(method)) || super
42
+ end
43
+
44
+ private
45
+
46
+ def _organizations_route_helper?(method)
47
+ method_name = method.to_s
48
+ method_name.end_with?("_path") || method_name.end_with?("_url")
49
+ end
50
+
51
+ public
24
52
  # === Organization Switcher ===
25
53
 
26
54
  # Returns optimized data for building an organization switcher
data/lib/organizations.rb CHANGED
@@ -51,23 +51,44 @@ module Organizations
51
51
  autoload :CallbackContext, "organizations/callback_context"
52
52
  autoload :ActsAsTenantIntegration, "organizations/acts_as_tenant_integration"
53
53
  autoload :TestHelpers, "organizations/test_helpers"
54
+ autoload :CurrentUserResolution, "organizations/current_user_resolution"
55
+ autoload :InvitationAcceptanceResult, "organizations/invitation_acceptance_result"
56
+ autoload :InvitationAcceptanceFailure, "organizations/invitation_acceptance_failure"
54
57
 
55
58
  # Alias for README compatibility: `include Organizations::Controller`
56
59
  Controller = ControllerHelpers
57
60
 
58
- # Models - autoload directly under Organizations namespace
59
- # Model files define Organizations::Organization, etc.
60
- autoload :Organization, "organizations/models/organization"
61
- autoload :Membership, "organizations/models/membership"
62
- autoload :Invitation, "organizations/models/invitation"
63
-
64
61
  # Models module kept for backwards compatibility
65
62
  module Models
66
63
  module Concerns
64
+ # HasOrganizations is always autoloaded from lib/ because:
65
+ # 1. It's extended onto ActiveRecord::Base at boot time via the engine initializer
66
+ # 2. It doesn't define any associations that point to reloadable classes
67
67
  autoload :HasOrganizations, "organizations/models/concerns/has_organizations"
68
68
  end
69
69
  end
70
70
 
71
+ # In Rails apps, Organization/Membership/Invitation models are loaded from
72
+ # app/models via Zeitwerk (reload-safe). This is critical because these models
73
+ # define associations to other reloadable classes (like Pay::Customer), and we
74
+ # need the association reflections to point to current class objects after reload.
75
+ #
76
+ # In non-Rails environments (plain Ruby, tests without Rails), use lib-based autoloading.
77
+ #
78
+ # NOTE on the guard: Rails::Engine is defined when `railties` has been required.
79
+ # In typical usage, this means a Rails app context where Zeitwerk manages app/models.
80
+ # Edge cases (e.g., requiring railties without a full app) are rare and would need
81
+ # custom setup anyway. For standard Rails apps, this correctly delegates to Zeitwerk.
82
+ #
83
+ # IMPORTANT: The app/models/*.rb entrypoints delegate to lib/ via `load`. This means
84
+ # Zeitwerk watches the entrypoint files, not the lib/ files. Changes to lib/ model
85
+ # files during gem development won't auto-reload; restart the server in that case.
86
+ unless defined?(Rails::Engine)
87
+ autoload :Organization, "organizations/models/organization"
88
+ autoload :Membership, "organizations/models/membership"
89
+ autoload :Invitation, "organizations/models/invitation"
90
+ end
91
+
71
92
  class << self
72
93
  attr_writer :configuration
73
94
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: organizations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-02-20 00:00:00.000000000 Z
10
+ date: 2026-02-21 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -86,7 +86,6 @@ files:
86
86
  - Appraisals
87
87
  - CHANGELOG.md
88
88
  - CLAUDE.md
89
- - LICENSE
90
89
  - LICENSE.txt
91
90
  - README.md
92
91
  - Rakefile
@@ -94,8 +93,13 @@ files:
94
93
  - app/controllers/organizations/invitations_controller.rb
95
94
  - app/controllers/organizations/memberships_controller.rb
96
95
  - app/controllers/organizations/organizations_controller.rb
96
+ - app/controllers/organizations/public_controller.rb
97
+ - app/controllers/organizations/public_invitations_controller.rb
97
98
  - app/controllers/organizations/switch_controller.rb
98
99
  - app/mailers/organizations/invitation_mailer.rb
100
+ - app/models/organizations/invitation.rb
101
+ - app/models/organizations/membership.rb
102
+ - app/models/organizations/organization.rb
99
103
  - app/views/organizations/invitation_mailer/invitation_email.html.erb
100
104
  - app/views/organizations/invitation_mailer/invitation_email.text.erb
101
105
  - config/routes.rb
@@ -110,7 +114,10 @@ files:
110
114
  - lib/organizations/callbacks.rb
111
115
  - lib/organizations/configuration.rb
112
116
  - lib/organizations/controller_helpers.rb
117
+ - lib/organizations/current_user_resolution.rb
113
118
  - lib/organizations/engine.rb
119
+ - lib/organizations/invitation_acceptance_failure.rb
120
+ - lib/organizations/invitation_acceptance_result.rb
114
121
  - lib/organizations/models/concerns/has_organizations.rb
115
122
  - lib/organizations/models/invitation.rb
116
123
  - lib/organizations/models/membership.rb
data/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Javi
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.