organizations 0.1.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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +137 -0
  3. data/.simplecov +35 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +9 -0
  6. data/CHANGELOG.md +14 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE +21 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +1496 -0
  11. data/Rakefile +15 -0
  12. data/app/controllers/organizations/application_controller.rb +251 -0
  13. data/app/controllers/organizations/invitations_controller.rb +262 -0
  14. data/app/controllers/organizations/memberships_controller.rb +179 -0
  15. data/app/controllers/organizations/organizations_controller.rb +179 -0
  16. data/app/controllers/organizations/switch_controller.rb +38 -0
  17. data/app/mailers/organizations/invitation_mailer.rb +85 -0
  18. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +98 -0
  19. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +18 -0
  20. data/config/routes.rb +35 -0
  21. data/examples/demo_insert_race_condition.rb +212 -0
  22. data/examples/demo_slugifiable_integration.rb +350 -0
  23. data/lib/generators/organizations/install/install_generator.rb +42 -0
  24. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +128 -0
  25. data/lib/generators/organizations/install/templates/initializer.rb +83 -0
  26. data/lib/organizations/acts_as_tenant_integration.rb +54 -0
  27. data/lib/organizations/callback_context.rb +51 -0
  28. data/lib/organizations/callbacks.rb +120 -0
  29. data/lib/organizations/configuration.rb +286 -0
  30. data/lib/organizations/controller_helpers.rb +292 -0
  31. data/lib/organizations/engine.rb +65 -0
  32. data/lib/organizations/models/concerns/has_organizations.rb +509 -0
  33. data/lib/organizations/models/invitation.rb +295 -0
  34. data/lib/organizations/models/membership.rb +260 -0
  35. data/lib/organizations/models/organization.rb +451 -0
  36. data/lib/organizations/roles.rb +256 -0
  37. data/lib/organizations/test_helpers.rb +167 -0
  38. data/lib/organizations/version.rb +5 -0
  39. data/lib/organizations/view_helpers.rb +353 -0
  40. data/lib/organizations.rb +107 -0
  41. metadata +163 -0
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Immutable context object passed to all callbacks.
5
+ # Provides consistent, typed access to event data.
6
+ #
7
+ # Different events populate different fields:
8
+ # - :organization_created => organization, user
9
+ # - :member_invited => organization, invitation, invited_by
10
+ # - :member_joined => organization, membership, user
11
+ # - :member_removed => organization, membership, user, removed_by
12
+ # - :role_changed => organization, membership, old_role, new_role, changed_by
13
+ # - :ownership_transferred => organization, old_owner, new_owner
14
+ #
15
+ # @example Accessing context data
16
+ # config.on_organization_created do |ctx|
17
+ # Analytics.track(ctx.user, "org_created", name: ctx.organization.name)
18
+ # end
19
+ #
20
+ CallbackContext = Struct.new(
21
+ :event, # Symbol - the event type (:organization_created, :member_joined, etc.)
22
+ :organization, # Organizations::Organization instance
23
+ :user, # User instance (the subject of the action)
24
+ :membership, # Organizations::Membership instance (if applicable)
25
+ :invitation, # Organizations::Invitation instance (if applicable)
26
+ :invited_by, # User instance - who sent the invitation
27
+ :removed_by, # User instance - who removed the member
28
+ :changed_by, # User instance - who changed the role
29
+ :old_role, # Symbol - previous role (for role_changed)
30
+ :new_role, # Symbol - new role (for role_changed)
31
+ :old_owner, # User instance - previous owner (for ownership_transferred)
32
+ :new_owner, # User instance - new owner (for ownership_transferred)
33
+ :permission, # Symbol - the permission that was required (for unauthorized)
34
+ :required_role, # Symbol - the role that was required (for unauthorized)
35
+ :metadata, # Hash - additional contextual data
36
+ keyword_init: true
37
+ ) do
38
+ # Convert to hash, removing nil values
39
+ # @return [Hash]
40
+ def to_h
41
+ super.compact
42
+ end
43
+
44
+ # Check if this is a specific event type
45
+ # @param event_name [Symbol] Event to check
46
+ # @return [Boolean]
47
+ def event?(event_name)
48
+ event == event_name
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Centralized callback dispatch module.
5
+ # Handles executing callbacks with error isolation - callbacks should never
6
+ # break the main organization operations.
7
+ #
8
+ # @example Dispatching a callback
9
+ # Callbacks.dispatch(:organization_created, organization: org, user: user)
10
+ #
11
+ module Callbacks
12
+ # Supported callback events
13
+ EVENTS = %i[
14
+ organization_created
15
+ member_invited
16
+ member_joined
17
+ member_removed
18
+ role_changed
19
+ ownership_transferred
20
+ ].freeze
21
+
22
+ module_function
23
+
24
+ # Dispatch a callback event.
25
+ # By default callbacks are isolated (errors logged, not raised).
26
+ # Pass strict: true when callback failures must abort the operation.
27
+ # @param event [Symbol] The event type (e.g., :organization_created)
28
+ # @param strict [Boolean] When true, callback errors are re-raised
29
+ # @param context_data [Hash] Data to pass to the callback via CallbackContext
30
+ def dispatch(event, strict: false, **context_data)
31
+ callback = callback_for(event)
32
+ return unless callback
33
+
34
+ context = CallbackContext.new(event: event, **context_data)
35
+ strict ? execute_strictly(callback, context) : execute_safely(event, callback, context)
36
+ end
37
+
38
+ # Get the callback proc for an event
39
+ # @param event [Symbol] The event type
40
+ # @return [Proc, nil]
41
+ def callback_for(event)
42
+ config = Organizations.configuration
43
+ return nil unless config
44
+
45
+ case event
46
+ when :organization_created
47
+ config.on_organization_created_callback
48
+ when :member_invited
49
+ config.on_member_invited_callback
50
+ when :member_joined
51
+ config.on_member_joined_callback
52
+ when :member_removed
53
+ config.on_member_removed_callback
54
+ when :role_changed
55
+ config.on_role_changed_callback
56
+ when :ownership_transferred
57
+ config.on_ownership_transferred_callback
58
+ end
59
+ end
60
+
61
+ # Execute callback with error isolation
62
+ # @param event [Symbol] Event name (for logging)
63
+ # @param callback [Proc] The callback to execute
64
+ # @param context [CallbackContext] The context to pass
65
+ def execute_safely(event, callback, context)
66
+ return unless callback.respond_to?(:call)
67
+
68
+ invoke_callback(callback, context)
69
+ rescue StandardError => e
70
+ # Log but don't re-raise - callbacks should never break organization operations
71
+ log_error("[Organizations] Callback error for #{event}: #{e.class}: #{e.message}")
72
+ log_debug(e.backtrace&.join("\n"))
73
+ end
74
+
75
+ # Execute callback and propagate any raised errors.
76
+ # Use this in flows where callbacks are expected to veto the operation.
77
+ def execute_strictly(callback, context)
78
+ return unless callback.respond_to?(:call)
79
+
80
+ invoke_callback(callback, context)
81
+ end
82
+
83
+ # Call callback while supporting flexible callback arities.
84
+ def invoke_callback(callback, context)
85
+ case callback.arity
86
+ when 0
87
+ callback.call
88
+ when 1, -1, -2
89
+ callback.call(context)
90
+ else
91
+ callback.call(context)
92
+ end
93
+ end
94
+
95
+ # Safe logging that works with or without Rails
96
+ def log_error(message)
97
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
98
+ Rails.logger.error(message)
99
+ else
100
+ warn(message)
101
+ end
102
+ end
103
+
104
+ def log_warn(message)
105
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
106
+ Rails.logger.warn(message)
107
+ else
108
+ warn(message)
109
+ end
110
+ end
111
+
112
+ def log_debug(message)
113
+ return unless message
114
+
115
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger&.debug?
116
+ Rails.logger.debug(message)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+
5
+ module Organizations
6
+ # Configuration class for the Organizations gem.
7
+ # Provides all customization options for organization behavior.
8
+ #
9
+ # @example Basic configuration
10
+ # Organizations.configure do |config|
11
+ # config.always_create_personal_organization_for_each_user = true
12
+ # config.invitation_expiry = 7.days
13
+ # end
14
+ #
15
+ # @example Custom roles
16
+ # Organizations.configure do |config|
17
+ # config.roles do
18
+ # role :viewer do
19
+ # can :view_organization
20
+ # end
21
+ # role :member, inherits: :viewer do
22
+ # can :create_resources
23
+ # end
24
+ # end
25
+ # end
26
+ #
27
+ class Configuration
28
+ # === Authentication ===
29
+ # Method that returns the current user (default: :current_user)
30
+ attr_accessor :current_user_method
31
+
32
+ # Method that ensures user is authenticated (default: :authenticate_user!)
33
+ attr_accessor :authenticate_user_method
34
+
35
+ # === Auto-creation ===
36
+ # Create personal organization on user signup
37
+ attr_accessor :always_create_personal_organization_for_each_user
38
+
39
+ # Name for auto-created organizations
40
+ # Can be a String or a Proc/Lambda: ->(user) { "#{user.name}'s Workspace" }
41
+ attr_accessor :default_organization_name
42
+
43
+ # === Invitations ===
44
+ # How long invitations are valid
45
+ attr_accessor :invitation_expiry
46
+
47
+ # Default role for invited members
48
+ attr_accessor :default_invitation_role
49
+
50
+ # Custom mailer for invitations (class name as string)
51
+ attr_accessor :invitation_mailer
52
+
53
+ # === Limits ===
54
+ # Maximum organizations a user can own (nil = unlimited)
55
+ attr_accessor :max_organizations_per_user
56
+
57
+ # === Onboarding ===
58
+ # Require users to always belong to at least one organization
59
+ # Set to true to prevent users from leaving their last organization
60
+ attr_accessor :always_require_users_to_belong_to_one_organization
61
+
62
+ # === Session/Switching ===
63
+ # Session key for storing current organization ID
64
+ attr_accessor :session_key
65
+
66
+ # === Redirects ===
67
+ # Where to redirect when user has no organization
68
+ attr_accessor :redirect_path_when_no_organization
69
+
70
+ # === Engine configuration ===
71
+ attr_accessor :parent_controller
72
+
73
+ # === Handlers (blocks) ===
74
+ # @private - stored handler blocks
75
+ attr_reader :unauthorized_handler, :no_organization_handler
76
+
77
+ # === Callbacks ===
78
+ # @private - stored callback blocks
79
+ attr_reader :on_organization_created_callback,
80
+ :on_member_invited_callback,
81
+ :on_member_joined_callback,
82
+ :on_member_removed_callback,
83
+ :on_role_changed_callback,
84
+ :on_ownership_transferred_callback
85
+
86
+ # === Custom Roles ===
87
+ # @private - custom roles definition
88
+ attr_reader :custom_roles_definition
89
+
90
+ def initialize
91
+ # Authentication defaults
92
+ @current_user_method = :current_user
93
+ @authenticate_user_method = :authenticate_user!
94
+
95
+ # Auto-creation defaults
96
+ @always_create_personal_organization_for_each_user = false
97
+ @default_organization_name = "Personal"
98
+
99
+ # Invitation defaults
100
+ @invitation_expiry = 7.days
101
+ @default_invitation_role = :member
102
+ @invitation_mailer = "Organizations::InvitationMailer"
103
+
104
+ # Limits
105
+ @max_organizations_per_user = nil
106
+
107
+ # Onboarding
108
+ @always_require_users_to_belong_to_one_organization = false
109
+
110
+ # Session/switching
111
+ @session_key = :current_organization_id
112
+
113
+ # Redirects
114
+ @redirect_path_when_no_organization = "/organizations/new"
115
+
116
+ # Engine
117
+ @parent_controller = "::ApplicationController"
118
+
119
+ # Handlers (nil by default - use default behavior)
120
+ @unauthorized_handler = nil
121
+ @no_organization_handler = nil
122
+
123
+ # Callbacks (nil by default - no-op)
124
+ @on_organization_created_callback = nil
125
+ @on_member_invited_callback = nil
126
+ @on_member_joined_callback = nil
127
+ @on_member_removed_callback = nil
128
+ @on_role_changed_callback = nil
129
+ @on_ownership_transferred_callback = nil
130
+
131
+ # Custom roles
132
+ @custom_roles_definition = nil
133
+ end
134
+
135
+ # === Handler Configuration Methods ===
136
+
137
+ # Configure unauthorized access handler
138
+ # @yield [context] Block to handle unauthorized access
139
+ # @yieldparam context [CallbackContext] Context with user, organization, permission info
140
+ #
141
+ # @example
142
+ # config.on_unauthorized do |context|
143
+ # redirect_to root_path, alert: "You don't have permission."
144
+ # end
145
+ #
146
+ def on_unauthorized(&block)
147
+ @unauthorized_handler = block if block_given?
148
+ end
149
+
150
+ # Configure no organization handler
151
+ # @yield [context] Block to handle when user has no organization
152
+ # @yieldparam context [CallbackContext] Context with user info
153
+ #
154
+ # @example
155
+ # config.on_no_organization do |context|
156
+ # redirect_to new_organization_path, notice: "Please create an organization."
157
+ # end
158
+ #
159
+ def on_no_organization(&block)
160
+ @no_organization_handler = block if block_given?
161
+ end
162
+
163
+ # === Callback Configuration Methods ===
164
+
165
+ # Called when an organization is created
166
+ # @yield [context] Block to execute
167
+ # @yieldparam context [CallbackContext] Context with organization and user
168
+ def on_organization_created(&block)
169
+ @on_organization_created_callback = block if block_given?
170
+ end
171
+
172
+ # Called when a member is invited
173
+ # @yield [context] Block to execute
174
+ # @yieldparam context [CallbackContext] Context with organization, invitation, invited_by
175
+ def on_member_invited(&block)
176
+ @on_member_invited_callback = block if block_given?
177
+ end
178
+
179
+ # Called when a member joins (invitation accepted)
180
+ # @yield [context] Block to execute
181
+ # @yieldparam context [CallbackContext] Context with organization, membership, user
182
+ def on_member_joined(&block)
183
+ @on_member_joined_callback = block if block_given?
184
+ end
185
+
186
+ # Called when a member is removed
187
+ # @yield [context] Block to execute
188
+ # @yieldparam context [CallbackContext] Context with organization, membership, user, removed_by
189
+ def on_member_removed(&block)
190
+ @on_member_removed_callback = block if block_given?
191
+ end
192
+
193
+ # Called when a member's role changes
194
+ # @yield [context] Block to execute
195
+ # @yieldparam context [CallbackContext] Context with organization, membership, old_role, new_role, changed_by
196
+ def on_role_changed(&block)
197
+ @on_role_changed_callback = block if block_given?
198
+ end
199
+
200
+ # Called when ownership is transferred
201
+ # @yield [context] Block to execute
202
+ # @yieldparam context [CallbackContext] Context with organization, old_owner, new_owner
203
+ def on_ownership_transferred(&block)
204
+ @on_ownership_transferred_callback = block if block_given?
205
+ end
206
+
207
+ # === Roles Configuration ===
208
+
209
+ # Define custom roles with permissions
210
+ # @yield DSL block for role definition
211
+ #
212
+ # @example
213
+ # config.roles do
214
+ # role :viewer do
215
+ # can :view_organization
216
+ # can :view_members
217
+ # end
218
+ # role :member, inherits: :viewer do
219
+ # can :create_resources
220
+ # end
221
+ # end
222
+ #
223
+ def roles(&block)
224
+ if block_given?
225
+ @custom_roles_definition = block
226
+ # Reset cached permissions so new roles take effect
227
+ Roles.reset!
228
+ end
229
+ end
230
+
231
+ # Resolve the default organization name for a user
232
+ # @param user [Object] The user object
233
+ # @return [String] The organization name
234
+ def resolve_default_organization_name(user)
235
+ case @default_organization_name
236
+ when Proc
237
+ @default_organization_name.call(user)
238
+ when String
239
+ @default_organization_name
240
+ else
241
+ "Personal"
242
+ end
243
+ end
244
+
245
+ # Validate the configuration
246
+ # @raise [ConfigurationError] if configuration is invalid
247
+ def validate!
248
+ validate_authentication_methods!
249
+ validate_invitation_settings!
250
+ validate_limits!
251
+ true
252
+ end
253
+
254
+ private
255
+
256
+ def validate_authentication_methods!
257
+ unless @current_user_method.is_a?(Symbol)
258
+ raise ConfigurationError, "current_user_method must be a Symbol"
259
+ end
260
+
261
+ unless @authenticate_user_method.is_a?(Symbol)
262
+ raise ConfigurationError, "authenticate_user_method must be a Symbol"
263
+ end
264
+ end
265
+
266
+ def validate_invitation_settings!
267
+ unless @invitation_expiry.nil? || @invitation_expiry.is_a?(ActiveSupport::Duration) || @invitation_expiry.is_a?(Numeric)
268
+ raise ConfigurationError, "invitation_expiry must be a Duration (e.g., 7.days) or nil"
269
+ end
270
+
271
+ unless Roles::HIERARCHY.include?(@default_invitation_role.to_sym)
272
+ raise ConfigurationError, "default_invitation_role must be one of: #{Roles::HIERARCHY.join(', ')}"
273
+ end
274
+ end
275
+
276
+ def validate_limits!
277
+ if @max_organizations_per_user && !@max_organizations_per_user.is_a?(Integer)
278
+ raise ConfigurationError, "max_organizations_per_user must be an Integer or nil"
279
+ end
280
+
281
+ if @max_organizations_per_user && @max_organizations_per_user < 1
282
+ raise ConfigurationError, "max_organizations_per_user must be at least 1"
283
+ end
284
+ end
285
+ end
286
+ end