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,350 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Demo script to verify slugifiable integration with organizations gem
4
+ #
5
+ # This script tests:
6
+ # 1. Basic slug generation from name
7
+ # 2. Slug uniqueness handling
8
+ # 3. Slug collision resolution with random suffixes
9
+ # 4. NOT NULL constraint compatibility (before_validation hook)
10
+ # 5. Race condition handling (simulated)
11
+ #
12
+ # Run with: bundle exec ruby test/demo_slugifiable_integration.rb
13
+
14
+ require "bundler/setup"
15
+ require "active_record"
16
+ require "sqlite3"
17
+ require "securerandom"
18
+
19
+ # Setup in-memory SQLite
20
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
21
+ ActiveRecord::Base.logger = Logger.new(nil)
22
+
23
+ # Load organizations gem
24
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
25
+ require "organizations"
26
+
27
+ puts "=" * 70
28
+ puts "SLUGIFIABLE INTEGRATION DEMO"
29
+ puts "=" * 70
30
+ puts
31
+
32
+ # Create schema
33
+ ActiveRecord::Schema.define do
34
+ create_table :users, force: :cascade do |t|
35
+ t.string :name
36
+ t.string :email, null: false
37
+ t.timestamps
38
+ end
39
+ add_index :users, :email, unique: true
40
+
41
+ create_table :organizations, force: :cascade do |t|
42
+ t.string :name, null: false
43
+ t.string :slug, null: false # NOT NULL - must be present before INSERT
44
+ t.timestamps
45
+ end
46
+ add_index :organizations, :slug, unique: true
47
+
48
+ create_table :memberships, force: :cascade do |t|
49
+ t.references :user, null: false, foreign_key: true
50
+ t.references :organization, null: false, foreign_key: true
51
+ t.string :role, null: false, default: "member"
52
+ t.timestamps
53
+ end
54
+ add_index :memberships, [:user_id, :organization_id], unique: true
55
+
56
+ create_table :organization_invitations, force: :cascade do |t|
57
+ t.references :organization, null: false, foreign_key: true
58
+ t.string :email, null: false
59
+ t.string :token, null: false
60
+ t.string :role, null: false, default: "member"
61
+ t.datetime :accepted_at
62
+ t.datetime :expires_at
63
+ t.timestamps
64
+ end
65
+ add_index :organization_invitations, :token, unique: true
66
+ end
67
+
68
+ class User < ActiveRecord::Base
69
+ extend Organizations::Models::Concerns::HasOrganizations::ClassMethods
70
+ has_organizations
71
+ validates :email, presence: true, uniqueness: true
72
+ end
73
+
74
+ # ============================================================================
75
+ # Test 1: Basic Slug Generation
76
+ # ============================================================================
77
+ puts "TEST 1: Basic Slug Generation"
78
+ puts "-" * 40
79
+
80
+ org = Organizations::Organization.create!(name: "Acme Corporation")
81
+ puts "Created: '#{org.name}'"
82
+ puts "Slug: '#{org.slug}'"
83
+ puts "Expected: slug should be 'acme-corporation'"
84
+ puts "Result: #{org.slug == 'acme-corporation' ? '✅ PASS' : "❌ FAIL (got #{org.slug})"}"
85
+ puts
86
+
87
+ # ============================================================================
88
+ # Test 2: Slug Uniqueness (Multiple orgs with same name)
89
+ # ============================================================================
90
+ puts "TEST 2: Slug Uniqueness (Collision Resolution)"
91
+ puts "-" * 40
92
+
93
+ Organizations::Organization.delete_all
94
+
95
+ orgs = []
96
+ 10.times do |i|
97
+ org = Organizations::Organization.create!(name: "Test Company")
98
+ orgs << org
99
+ puts "Org #{i + 1}: slug = '#{org.slug}'"
100
+ end
101
+
102
+ slugs = orgs.map(&:slug)
103
+ unique_slugs = slugs.uniq.length
104
+ puts
105
+ puts "Total orgs: #{orgs.length}"
106
+ puts "Unique slugs: #{unique_slugs}"
107
+ puts "Result: #{unique_slugs == 10 ? '✅ PASS - All slugs unique' : '❌ FAIL - Duplicate slugs found'}"
108
+ puts
109
+
110
+ # Verify first one has clean slug, others have suffixes
111
+ puts "First org has clean slug: #{orgs.first.slug == 'test-company' ? '✅ PASS' : "❌ FAIL (got #{orgs.first.slug})"}"
112
+ suffixed = orgs[1..].all? { |o| o.slug.start_with?('test-company-') }
113
+ puts "Others have suffixes: #{suffixed ? '✅ PASS' : '❌ FAIL'}"
114
+ puts
115
+
116
+ # ============================================================================
117
+ # Test 3: NOT NULL Constraint Compatibility
118
+ # ============================================================================
119
+ puts "TEST 3: NOT NULL Constraint (before_validation hook)"
120
+ puts "-" * 40
121
+
122
+ Organizations::Organization.delete_all
123
+
124
+ # The schema has slug NOT NULL. Slugifiable normally uses after_create,
125
+ # but organizations uses before_validation to compute slug early.
126
+
127
+ begin
128
+ org = Organizations::Organization.new(name: "Startup Inc")
129
+ puts "Before save - slug: '#{org.slug.inspect}'"
130
+ org.save!
131
+ puts "After save - slug: '#{org.slug}'"
132
+ puts "Result: #{org.slug.present? ? '✅ PASS - Slug computed before INSERT' : '❌ FAIL'}"
133
+ rescue ActiveRecord::NotNullViolation => e
134
+ puts "❌ FAIL - NOT NULL violation: #{e.message}"
135
+ rescue => e
136
+ puts "❌ FAIL - Unexpected error: #{e.class} - #{e.message}"
137
+ end
138
+ puts
139
+
140
+ # ============================================================================
141
+ # Test 4: Slug Generation from Slugifiable Module
142
+ # ============================================================================
143
+ puts "TEST 4: Slugifiable Module Integration"
144
+ puts "-" * 40
145
+
146
+ org = Organizations::Organization.new(name: "My Awesome Org")
147
+
148
+ # Check that slugifiable methods are available
149
+ has_compute_slug = org.respond_to?(:compute_slug)
150
+ has_generate_unique_slug = org.respond_to?(:generate_unique_slug, true)
151
+ has_generate_slug_based_on = org.respond_to?(:generate_slug_based_on)
152
+
153
+ puts "Has compute_slug method: #{has_compute_slug ? '✅ YES' : '❌ NO'}"
154
+ puts "Has generate_unique_slug method: #{has_generate_unique_slug ? '✅ YES' : '❌ NO'}"
155
+ puts "Has generate_slug_based_on method: #{has_generate_slug_based_on ? '✅ YES' : '❌ NO'}"
156
+
157
+ if has_compute_slug
158
+ computed = org.compute_slug
159
+ puts "compute_slug returns: '#{computed}'"
160
+ puts "Result: #{computed == 'my-awesome-org' ? '✅ PASS' : "❌ FAIL (expected 'my-awesome-org')"}"
161
+ end
162
+ puts
163
+
164
+ # ============================================================================
165
+ # Test 5: High-Volume Collision Test
166
+ # ============================================================================
167
+ puts "TEST 5: High-Volume Collision Test (50 orgs, same name)"
168
+ puts "-" * 40
169
+
170
+ Organizations::Organization.delete_all
171
+
172
+ start_time = Time.now
173
+ orgs = []
174
+ 50.times do
175
+ orgs << Organizations::Organization.create!(name: "Popular Name")
176
+ end
177
+ elapsed = Time.now - start_time
178
+
179
+ slugs = orgs.map(&:slug)
180
+ unique_count = slugs.uniq.length
181
+
182
+ puts "Created 50 organizations in #{(elapsed * 1000).round(2)}ms"
183
+ puts "Unique slugs: #{unique_count}/50"
184
+ puts "Result: #{unique_count == 50 ? '✅ PASS' : '❌ FAIL - Some slugs collided'}"
185
+
186
+ # Show first few and last few slugs
187
+ puts
188
+ puts "Sample slugs:"
189
+ puts " First: #{orgs.first.slug}"
190
+ puts " #10: #{orgs[9].slug}"
191
+ puts " #25: #{orgs[24].slug}"
192
+ puts " Last: #{orgs.last.slug}"
193
+ puts
194
+
195
+ # ============================================================================
196
+ # Test 6: Slug Stability (Reloading doesn't change slug)
197
+ # ============================================================================
198
+ puts "TEST 6: Slug Stability (Reload Test)"
199
+ puts "-" * 40
200
+
201
+ Organizations::Organization.delete_all
202
+
203
+ org = Organizations::Organization.create!(name: "Stable Org")
204
+ original_slug = org.slug
205
+ puts "Original slug: '#{original_slug}'"
206
+
207
+ org.reload
208
+ reloaded_slug = org.slug
209
+ puts "After reload: '#{reloaded_slug}'"
210
+
211
+ org.touch
212
+ touched_slug = org.slug
213
+ puts "After touch: '#{touched_slug}'"
214
+
215
+ stable = (original_slug == reloaded_slug) && (reloaded_slug == touched_slug)
216
+ puts "Result: #{stable ? '✅ PASS - Slug is stable' : '❌ FAIL - Slug changed unexpectedly'}"
217
+ puts
218
+
219
+ # ============================================================================
220
+ # Test 7: Name Update Doesn't Change Slug (by default)
221
+ # ============================================================================
222
+ puts "TEST 7: Name Update Behavior"
223
+ puts "-" * 40
224
+
225
+ Organizations::Organization.delete_all
226
+
227
+ org = Organizations::Organization.create!(name: "Original Name")
228
+ original_slug = org.slug
229
+ puts "Created with name: '#{org.name}', slug: '#{original_slug}'"
230
+
231
+ org.update!(name: "Updated Name")
232
+ updated_slug = org.slug
233
+ puts "Updated name to: '#{org.name}', slug: '#{updated_slug}'"
234
+
235
+ # Slugifiable typically doesn't update slugs on name change (by design - URLs stay stable)
236
+ puts "Result: #{original_slug == updated_slug ? '✅ PASS - Slug preserved (URL stability)' : '⚠️ NOTE - Slug changed to reflect new name'}"
237
+ puts
238
+
239
+ # ============================================================================
240
+ # Test 8: Special Characters in Name
241
+ # ============================================================================
242
+ puts "TEST 8: Special Characters in Name"
243
+ puts "-" * 40
244
+
245
+ Organizations::Organization.delete_all
246
+
247
+ test_cases = [
248
+ ["Café & Bistro", "cafe-bistro"],
249
+ ["100% Organic Co.", "100-organic-co"],
250
+ ["日本語会社", nil], # Non-latin - depends on implementation
251
+ [" Lots Of Spaces ", "lots-of-spaces"],
252
+ ["UPPERCASE NAME", "uppercase-name"],
253
+ ["name-with-dashes", "name-with-dashes"],
254
+ ["name_with_underscores", "name-with-underscores"],
255
+ ]
256
+
257
+ test_cases.each do |name, expected|
258
+ begin
259
+ org = Organizations::Organization.create!(name: name)
260
+ if expected
261
+ result = org.slug == expected ? '✅' : "⚠️ got '#{org.slug}'"
262
+ else
263
+ result = org.slug.present? ? "✅ got '#{org.slug}'" : '❌ empty'
264
+ end
265
+ puts " '#{name}' → '#{org.slug}' #{result}"
266
+ rescue => e
267
+ puts " '#{name}' → ❌ ERROR: #{e.message}"
268
+ end
269
+ end
270
+ puts
271
+
272
+ # ============================================================================
273
+ # Test 9: Simulated Race Condition
274
+ # ============================================================================
275
+ puts "TEST 9: Simulated Race Condition"
276
+ puts "-" * 40
277
+ puts "(Testing that slugifiable handles RecordNotUnique gracefully)"
278
+
279
+ Organizations::Organization.delete_all
280
+
281
+ # Create an org with a specific slug
282
+ existing = Organizations::Organization.create!(name: "Race Test")
283
+ existing_slug = existing.slug
284
+ puts "Existing org slug: '#{existing_slug}'"
285
+
286
+ # Now simulate what happens if two processes try to create the same slug simultaneously
287
+ # We can't truly simulate threading in this demo, but we can verify the retry mechanism
288
+ # exists in slugifiable
289
+
290
+ # Check if slugifiable has the retry mechanism
291
+ org_instance = Organizations::Organization.new(name: "Test")
292
+ has_retry = org_instance.respond_to?(:set_slug_with_retry, true)
293
+ puts "Has set_slug_with_retry: #{has_retry ? '✅ YES' : '❌ NO'}"
294
+
295
+ if has_retry
296
+ puts "Result: ✅ PASS - Race condition handling is available"
297
+ else
298
+ puts "Result: ⚠️ NOTE - set_slug_with_retry not found (may be in older slugifiable version)"
299
+ end
300
+ puts
301
+
302
+ # ============================================================================
303
+ # Summary
304
+ # ============================================================================
305
+ puts "=" * 70
306
+ puts "SUMMARY"
307
+ puts "=" * 70
308
+
309
+ # Count passing tests
310
+ Organizations::Organization.delete_all
311
+
312
+ tests_passed = 0
313
+ tests_total = 9
314
+
315
+ # Re-run quick checks
316
+ org1 = Organizations::Organization.create!(name: "Summary Test")
317
+ tests_passed += 1 if org1.slug == "summary-test"
318
+
319
+ org2 = Organizations::Organization.create!(name: "Summary Test")
320
+ tests_passed += 1 if org2.slug != org1.slug
321
+
322
+ tests_passed += 1 if org1.slug.present? # NOT NULL works
323
+
324
+ tests_passed += 1 if org1.respond_to?(:compute_slug)
325
+
326
+ 50.times { Organizations::Organization.create!(name: "Bulk") }
327
+ tests_passed += 1 if Organizations::Organization.where("slug LIKE 'bulk%'").count == 50
328
+
329
+ org1.reload
330
+ tests_passed += 1 if org1.slug == "summary-test"
331
+
332
+ original = org1.slug
333
+ org1.update!(name: "Changed")
334
+ tests_passed += 1 if org1.slug == original # Slug stability
335
+
336
+ org3 = Organizations::Organization.create!(name: "Café Test")
337
+ tests_passed += 1 if org3.slug.present?
338
+
339
+ tests_passed += 1 if org1.respond_to?(:set_slug_with_retry, true)
340
+
341
+ puts
342
+ puts "Tests Passed: #{tests_passed}/#{tests_total}"
343
+ puts
344
+ if tests_passed == tests_total
345
+ puts "🎉 ALL TESTS PASSED - Slugifiable integration is working correctly!"
346
+ else
347
+ puts "⚠️ Some tests need attention - see details above"
348
+ end
349
+ puts
350
+ puts "=" * 70
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Organizations
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ desc "Install organizations migrations and initializer"
13
+
14
+ def self.next_migration_number(dir)
15
+ ActiveRecord::Generators::Base.next_migration_number(dir)
16
+ end
17
+
18
+ def create_migration_file
19
+ migration_template "create_organizations_tables.rb.erb", File.join(db_migrate_path, "create_organizations_tables.rb"), migration_version: migration_version
20
+ end
21
+
22
+ def create_initializer
23
+ template "initializer.rb", "config/initializers/organizations.rb"
24
+ end
25
+
26
+ def display_post_install_message
27
+ say "\n✅ organizations has been installed.", :green
28
+ say "\nNext steps:"
29
+ say " 1. Run 'rails db:migrate' to create the necessary tables."
30
+ say " 2. Review and customize 'config/initializers/organizations.rb'."
31
+ say " 3. Add 'has_organizations' to your User model."
32
+ say " 4. Mount the engine in your routes: mount Organizations::Engine => '/'"
33
+ end
34
+
35
+ private
36
+
37
+ def migration_version
38
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
6
+ adapter = connection.adapter_name.downcase
7
+
8
+ # Organizations table
9
+ create_table :organizations, id: primary_key_type do |t|
10
+ t.string :name, null: false
11
+ t.string :slug, null: false
12
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :organizations, :slug, unique: true
18
+
19
+ # Memberships join table (User ↔ Organization)
20
+ create_table :memberships, id: primary_key_type do |t|
21
+ t.references :user, null: false, type: foreign_key_type, foreign_key: true
22
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: true
23
+ t.references :invited_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
24
+ t.string :role, null: false, default: "member"
25
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
26
+
27
+ t.timestamps
28
+ end
29
+
30
+ add_index :memberships, [:user_id, :organization_id], unique: true
31
+ add_index :memberships, :role
32
+
33
+ # Enforce "at most one owner membership per organization" at DB level where possible.
34
+ if adapter.include?("postgresql")
35
+ execute <<-SQL
36
+ CREATE UNIQUE INDEX index_memberships_single_owner
37
+ ON memberships (organization_id)
38
+ WHERE role = 'owner'
39
+ SQL
40
+ elsif adapter.include?("sqlite")
41
+ execute <<-SQL
42
+ CREATE UNIQUE INDEX index_memberships_single_owner
43
+ ON memberships (organization_id)
44
+ WHERE role = 'owner'
45
+ SQL
46
+ end
47
+
48
+ # Invitations table
49
+ create_table :organization_invitations, id: primary_key_type do |t|
50
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: true
51
+ # invited_by is nullable to support dependent: :nullify when user is deleted
52
+ t.references :invited_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
53
+ t.string :email, null: false
54
+ t.string :token, null: false
55
+ t.string :role, null: false, default: "member"
56
+ t.datetime :accepted_at
57
+ t.datetime :expires_at
58
+
59
+ t.timestamps
60
+ end
61
+
62
+ add_index :organization_invitations, :token, unique: true
63
+ add_index :organization_invitations, :email
64
+
65
+ # Unique partial index: only one pending (non-accepted) invitation per email per org
66
+ # Both PostgreSQL and SQLite support partial indexes
67
+ if adapter.include?("postgresql")
68
+ execute <<-SQL
69
+ CREATE UNIQUE INDEX index_invitations_pending_unique
70
+ ON organization_invitations (organization_id, LOWER(email))
71
+ WHERE accepted_at IS NULL
72
+ SQL
73
+ elsif adapter.include?("sqlite")
74
+ # SQLite supports partial indexes since 3.8.0
75
+ execute <<-SQL
76
+ CREATE UNIQUE INDEX index_invitations_pending_unique
77
+ ON organization_invitations (organization_id, LOWER(email))
78
+ WHERE accepted_at IS NULL
79
+ SQL
80
+ elsif adapter.include?("mysql")
81
+ # MySQL doesn't support partial indexes, so we use a generated column that is
82
+ # only non-NULL for pending invitations and enforce uniqueness on that value.
83
+ execute <<-SQL
84
+ ALTER TABLE organization_invitations
85
+ ADD COLUMN pending_email VARCHAR(255)
86
+ GENERATED ALWAYS AS (
87
+ CASE
88
+ WHEN accepted_at IS NULL THEN LOWER(email)
89
+ ELSE NULL
90
+ END
91
+ ) STORED
92
+ SQL
93
+
94
+ add_index :organization_invitations, [:organization_id, :pending_email], unique: true, name: "index_invitations_pending_unique"
95
+ else
96
+ # For other adapters, fall back to app-level validation.
97
+ add_index :organization_invitations, [:organization_id, :email], name: "index_invitations_on_org_and_email"
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def primary_and_foreign_key_types
104
+ config = Rails.configuration.generators
105
+ setting = config.options[config.orm][:primary_key_type]
106
+ primary_key_type = setting || :primary_key
107
+ foreign_key_type = setting || :bigint
108
+ [primary_key_type, foreign_key_type]
109
+ end
110
+
111
+ def json_column_type
112
+ return :jsonb if connection.adapter_name.downcase.include?('postgresql')
113
+ :json
114
+ end
115
+
116
+ # MySQL 8+ doesn't allow default values on JSON columns.
117
+ # Returns an empty hash default for SQLite/PostgreSQL, nil for MySQL.
118
+ def json_column_default
119
+ return nil if connection.adapter_name.downcase.include?('mysql')
120
+ {}
121
+ end
122
+
123
+ # Keep inserts safe on MySQL where JSON defaults are restricted.
124
+ # Other adapters keep metadata required with a {} default.
125
+ def json_column_null
126
+ connection.adapter_name.downcase.include?('mysql')
127
+ end
128
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ Organizations.configure do |config|
4
+ # ============================================================================
5
+ # PERSONAL ORGANIZATIONS
6
+ # ============================================================================
7
+
8
+ # Automatically create a personal organization when a user signs up.
9
+ # The organization will be named after the user (e.g., "John's Organization").
10
+ # Set to true if you want every user to have their own organization on signup.
11
+ # Default: false (invite-to-join flow)
12
+ # config.always_create_personal_organization_for_each_user = false
13
+
14
+ # ============================================================================
15
+ # ORGANIZATION REQUIREMENTS
16
+ # ============================================================================
17
+
18
+ # Require users to belong to at least one organization.
19
+ # When true, users cannot leave their last organization.
20
+ # Set to true if users should always have an organization.
21
+ # Default: false (users can exist without any organization)
22
+ # config.always_require_users_to_belong_to_one_organization = false
23
+
24
+ # ============================================================================
25
+ # INVITATIONS
26
+ # ============================================================================
27
+
28
+ # How long invitation tokens remain valid before expiring.
29
+ # Default: 7.days
30
+ # config.invitation_expiry = 7.days
31
+
32
+ # The default role assigned to invited users when they accept.
33
+ # Default: :member
34
+ # config.default_invitation_role = :member
35
+
36
+ # ============================================================================
37
+ # ROLES & PERMISSIONS
38
+ # ============================================================================
39
+
40
+ # Built-in roles (in order of hierarchy, highest to lowest):
41
+ # :owner - Full control, can delete organization, transfer ownership
42
+ # :admin - Can manage members, invitations, and settings
43
+ # :member - Standard access, can view and collaborate
44
+ # :viewer - Read-only access
45
+ #
46
+ # Custom roles can be defined using the config.roles DSL:
47
+ #
48
+ # config.roles do
49
+ # role :viewer do
50
+ # can :view_organization
51
+ # can :view_members
52
+ # end
53
+ # role :member, inherits: :viewer do
54
+ # can :create_resources
55
+ # end
56
+ # role :admin, inherits: :member do
57
+ # can :invite_members
58
+ # can :manage_settings
59
+ # end
60
+ # role :owner, inherits: :admin do
61
+ # can :manage_billing
62
+ # can :delete_organization
63
+ # end
64
+ # end
65
+
66
+ # ============================================================================
67
+ # ORGANIZATION SWITCHING
68
+ # ============================================================================
69
+
70
+ # The session key used to store the current organization ID.
71
+ # Default: :current_organization_id
72
+ # config.session_key = :current_organization_id
73
+
74
+ # ============================================================================
75
+ # URL SLUGS
76
+ # ============================================================================
77
+
78
+ # Organizations use slugifiable for URL-friendly slugs.
79
+ # Slugs are auto-generated from the organization name.
80
+ # Example: "Acme Corp" → "acme-corp"
81
+ #
82
+ # To customize slug generation, configure slugifiable separately.
83
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Organizations
6
+ # Integration concern for acts_as_tenant gem.
7
+ # Automatically sets the current tenant to the current organization.
8
+ #
9
+ # @example Include in ApplicationController
10
+ # class ApplicationController < ActionController::Base
11
+ # include Organizations::ControllerHelpers
12
+ # include Organizations::ActsAsTenantIntegration
13
+ # end
14
+ #
15
+ # This will automatically call `set_current_tenant(current_organization)`
16
+ # on each request, allowing acts_as_tenant to scope queries.
17
+ #
18
+ module ActsAsTenantIntegration
19
+ extend ActiveSupport::Concern
20
+
21
+ included do
22
+ # Ensure this runs after organization is set
23
+ before_action :set_organization_as_tenant
24
+
25
+ # Also set tenant when organization is switched
26
+ after_action :sync_tenant_with_organization
27
+ end
28
+
29
+ private
30
+
31
+ # Set the current tenant to the current organization
32
+ def set_organization_as_tenant
33
+ return unless respond_to?(:current_organization) && current_organization
34
+ return unless acts_as_tenant_available?
35
+
36
+ ActsAsTenant.current_tenant = current_organization
37
+ end
38
+
39
+ # Sync tenant when organization changes mid-request
40
+ def sync_tenant_with_organization
41
+ return unless acts_as_tenant_available?
42
+
43
+ # If organization changed during the request, update tenant
44
+ if respond_to?(:current_organization)
45
+ ActsAsTenant.current_tenant = current_organization
46
+ end
47
+ end
48
+
49
+ # Check if acts_as_tenant is available
50
+ def acts_as_tenant_available?
51
+ defined?(ActsAsTenant) && ActsAsTenant.respond_to?(:current_tenant=)
52
+ end
53
+ end
54
+ end