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.
- checksums.yaml +7 -0
- data/.rubocop.yml +137 -0
- data/.simplecov +35 -0
- data/AGENTS.md +5 -0
- data/Appraisals +9 -0
- data/CHANGELOG.md +14 -0
- data/CLAUDE.md +5 -0
- data/LICENSE +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +1496 -0
- data/Rakefile +15 -0
- data/app/controllers/organizations/application_controller.rb +251 -0
- data/app/controllers/organizations/invitations_controller.rb +262 -0
- data/app/controllers/organizations/memberships_controller.rb +179 -0
- data/app/controllers/organizations/organizations_controller.rb +179 -0
- data/app/controllers/organizations/switch_controller.rb +38 -0
- data/app/mailers/organizations/invitation_mailer.rb +85 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +98 -0
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +18 -0
- data/config/routes.rb +35 -0
- data/examples/demo_insert_race_condition.rb +212 -0
- data/examples/demo_slugifiable_integration.rb +350 -0
- data/lib/generators/organizations/install/install_generator.rb +42 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +128 -0
- data/lib/generators/organizations/install/templates/initializer.rb +83 -0
- data/lib/organizations/acts_as_tenant_integration.rb +54 -0
- data/lib/organizations/callback_context.rb +51 -0
- data/lib/organizations/callbacks.rb +120 -0
- data/lib/organizations/configuration.rb +286 -0
- data/lib/organizations/controller_helpers.rb +292 -0
- data/lib/organizations/engine.rb +65 -0
- data/lib/organizations/models/concerns/has_organizations.rb +509 -0
- data/lib/organizations/models/invitation.rb +295 -0
- data/lib/organizations/models/membership.rb +260 -0
- data/lib/organizations/models/organization.rb +451 -0
- data/lib/organizations/roles.rb +256 -0
- data/lib/organizations/test_helpers.rb +167 -0
- data/lib/organizations/version.rb +5 -0
- data/lib/organizations/view_helpers.rb +353 -0
- data/lib/organizations.rb +107 -0
- 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
|