organizations 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +31 -42
- data/app/controllers/organizations/organizations_controller.rb +0 -2
- data/app/controllers/organizations/switch_controller.rb +1 -1
- data/gemfiles/rails_7.2.gemfile +25 -0
- data/gemfiles/rails_8.1.gemfile +25 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +20 -35
- data/lib/generators/organizations/install/templates/initializer.rb +0 -9
- data/lib/organizations/models/concerns/has_organizations.rb +1 -1
- data/lib/organizations/models/invitation.rb +1 -1
- data/lib/organizations/models/membership.rb +1 -1
- data/lib/organizations/models/organization.rb +3 -22
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +8 -9
- metadata +4 -18
- data/examples/demo_insert_race_condition.rb +0 -212
- data/examples/demo_slugifiable_integration.rb +0 -350
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc99dabb60fe5f7d0c4ae8567fd62c4920f4a403b804dfe4e8dcac4d3c66fa11
|
|
4
|
+
data.tar.gz: '024976f0f5cd19fd8530179977a34e21d7ca54968bbf62d50eeff0d5f70dc1ea'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b14290ba7ecfc72544a5b4553fc5fd633ab51af9bcf1e3e1ed2bd90769ce2099a658f815191f1cf8c5e4706978d0070b80654e6b6da978b636ba6a87e60e11c9
|
|
7
|
+
data.tar.gz: 07bb21cfe8a870b8f672069ac7cb3f9512dad17ad1ea472b4cc34db9eaaf292773f05c6eb105dc5e220dc3b4b2139bfcdee1d097201774ca92cb79356373c8a3
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -48,7 +48,7 @@ gem "organizations"
|
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
> [!NOTE]
|
|
51
|
-
>
|
|
51
|
+
> For beautiful invitation emails, optionally add [`goodmail`](https://github.com/rameerez/goodmail).
|
|
52
52
|
|
|
53
53
|
Then:
|
|
54
54
|
|
|
@@ -458,10 +458,10 @@ The helper returns:
|
|
|
458
458
|
|
|
459
459
|
```ruby
|
|
460
460
|
{
|
|
461
|
-
current: { id: "...", name: "Acme Corp"
|
|
461
|
+
current: { id: "...", name: "Acme Corp" },
|
|
462
462
|
others: [
|
|
463
|
-
{ id: "...", name: "Personal"
|
|
464
|
-
{ id: "...", name: "StartupCo"
|
|
463
|
+
{ id: "...", name: "Personal" },
|
|
464
|
+
{ id: "...", name: "StartupCo" }
|
|
465
465
|
],
|
|
466
466
|
switch_path: ->(org_id) { "/organizations/switch/#{org_id}" }
|
|
467
467
|
}
|
|
@@ -1026,7 +1026,6 @@ The gem works with Rails fixtures:
|
|
|
1026
1026
|
# test/fixtures/organizations.yml
|
|
1027
1027
|
acme:
|
|
1028
1028
|
name: Acme Corp
|
|
1029
|
-
slug: acme-corp
|
|
1030
1029
|
|
|
1031
1030
|
# test/fixtures/memberships.yml
|
|
1032
1031
|
john_at_acme:
|
|
@@ -1065,9 +1064,9 @@ The gem provides `Organizations::Organization` as the base model. You can extend
|
|
|
1065
1064
|
# db/migrate/xxx_add_custom_fields_to_organizations.rb
|
|
1066
1065
|
class AddCustomFieldsToOrganizations < ActiveRecord::Migration[8.0]
|
|
1067
1066
|
def change
|
|
1068
|
-
add_column :
|
|
1069
|
-
add_column :
|
|
1070
|
-
add_column :
|
|
1067
|
+
add_column :organizations_organizations, :support_email, :string
|
|
1068
|
+
add_column :organizations_organizations, :billing_address, :text
|
|
1069
|
+
add_column :organizations_organizations, :settings, :jsonb, default: {}
|
|
1071
1070
|
end
|
|
1072
1071
|
end
|
|
1073
1072
|
```
|
|
@@ -1111,24 +1110,23 @@ This is standard Rails practice — the gem provides the foundation (memberships
|
|
|
1111
1110
|
|
|
1112
1111
|
The gem creates three tables:
|
|
1113
1112
|
|
|
1114
|
-
###
|
|
1113
|
+
### organizations_organizations
|
|
1115
1114
|
|
|
1116
1115
|
```sql
|
|
1117
|
-
|
|
1116
|
+
organizations_organizations
|
|
1118
1117
|
- id (primary key, auto-detects UUID or integer from your app)
|
|
1119
1118
|
- name (string, required)
|
|
1120
|
-
- slug (string, unique, indexed) -- auto-generated via slugifiable gem
|
|
1121
1119
|
- metadata (jsonb, default: {})
|
|
1122
1120
|
- created_at
|
|
1123
1121
|
- updated_at
|
|
1124
1122
|
```
|
|
1125
1123
|
|
|
1126
|
-
> **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.
|
|
1124
|
+
> **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.
|
|
1127
1125
|
|
|
1128
|
-
###
|
|
1126
|
+
### organizations_memberships
|
|
1129
1127
|
|
|
1130
1128
|
```sql
|
|
1131
|
-
|
|
1129
|
+
organizations_memberships
|
|
1132
1130
|
- id (primary key)
|
|
1133
1131
|
- user_id (foreign key, indexed)
|
|
1134
1132
|
- organization_id (foreign key, indexed)
|
|
@@ -1140,10 +1138,10 @@ memberships
|
|
|
1140
1138
|
unique index: [user_id, organization_id]
|
|
1141
1139
|
```
|
|
1142
1140
|
|
|
1143
|
-
###
|
|
1141
|
+
### organizations_invitations
|
|
1144
1142
|
|
|
1145
1143
|
```sql
|
|
1146
|
-
|
|
1144
|
+
organizations_invitations
|
|
1147
1145
|
- id (primary key)
|
|
1148
1146
|
- organization_id (foreign key, indexed)
|
|
1149
1147
|
- email (string, required, indexed)
|
|
@@ -1181,7 +1179,6 @@ organization_invitations
|
|
|
1181
1179
|
| Ownership transfer to removed user | Transaction lock, verifies membership exists before transfer |
|
|
1182
1180
|
| Concurrent role changes on same user | Row-level lock on membership row |
|
|
1183
1181
|
| Session points to org user was removed from | `current_organization` verifies membership, clears stale session |
|
|
1184
|
-
| Duplicate org slug on creation | Unique constraint, retries with suffix (acme-corp-2) |
|
|
1185
1182
|
| Token collision on invitation | Unique constraint, regenerates token |
|
|
1186
1183
|
|
|
1187
1184
|
## Performance notes
|
|
@@ -1243,12 +1240,12 @@ The `organization_switcher_data` helper is optimized for navbar use:
|
|
|
1243
1240
|
|
|
1244
1241
|
```ruby
|
|
1245
1242
|
# Internally, it:
|
|
1246
|
-
# 1. Selects only id, name
|
|
1243
|
+
# 1. Selects only id, name (not full objects)
|
|
1247
1244
|
# 2. Memoizes within the request
|
|
1248
1245
|
# 3. Returns a lightweight hash, not ActiveRecord objects
|
|
1249
1246
|
|
|
1250
1247
|
organization_switcher_data
|
|
1251
|
-
# => { current: { id: "...", name: "Acme"
|
|
1248
|
+
# => { current: { id: "...", name: "Acme" }, others: [...] }
|
|
1252
1249
|
```
|
|
1253
1250
|
|
|
1254
1251
|
### Counter caches for member counts
|
|
@@ -1257,7 +1254,7 @@ If you display member counts frequently (pricing pages, org listings), consider
|
|
|
1257
1254
|
|
|
1258
1255
|
```ruby
|
|
1259
1256
|
# In a migration
|
|
1260
|
-
add_column :
|
|
1257
|
+
add_column :organizations_organizations, :memberships_count, :integer, default: 0, null: false
|
|
1261
1258
|
|
|
1262
1259
|
# Reset existing counts
|
|
1263
1260
|
Organization.find_each do |org|
|
|
@@ -1278,9 +1275,9 @@ org.member_count
|
|
|
1278
1275
|
Boolean checks use efficient SQL `EXISTS` queries:
|
|
1279
1276
|
|
|
1280
1277
|
```ruby
|
|
1281
|
-
user.belongs_to_any_organization? # SELECT 1 FROM
|
|
1282
|
-
user.has_pending_organization_invitations? # SELECT 1 FROM
|
|
1283
|
-
org.has_any_members? # SELECT 1 FROM
|
|
1278
|
+
user.belongs_to_any_organization? # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1
|
|
1279
|
+
user.has_pending_organization_invitations? # SELECT 1 FROM organizations_invitations WHERE ... LIMIT 1
|
|
1280
|
+
org.has_any_members? # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1
|
|
1284
1281
|
```
|
|
1285
1282
|
|
|
1286
1283
|
### Scoped associations use JOINs
|
|
@@ -1290,13 +1287,13 @@ Methods like `org.admins` and `user.owned_organizations` use proper SQL JOINs:
|
|
|
1290
1287
|
```ruby
|
|
1291
1288
|
org.admins
|
|
1292
1289
|
# SELECT users.* FROM users
|
|
1293
|
-
# INNER JOIN
|
|
1294
|
-
# WHERE
|
|
1290
|
+
# INNER JOIN organizations_memberships ON organizations_memberships.user_id = users.id
|
|
1291
|
+
# WHERE organizations_memberships.organization_id = ? AND organizations_memberships.role IN ('admin', 'owner')
|
|
1295
1292
|
|
|
1296
1293
|
user.owned_organizations
|
|
1297
|
-
# SELECT
|
|
1298
|
-
# INNER JOIN
|
|
1299
|
-
# WHERE
|
|
1294
|
+
# SELECT organizations_organizations.* FROM organizations_organizations
|
|
1295
|
+
# INNER JOIN organizations_memberships ON organizations_memberships.organization_id = organizations_organizations.id
|
|
1296
|
+
# WHERE organizations_memberships.user_id = ? AND organizations_memberships.role = 'owner'
|
|
1300
1297
|
```
|
|
1301
1298
|
|
|
1302
1299
|
### Current organization memoization
|
|
@@ -1333,7 +1330,6 @@ These constraints prevent duplicate data at the database level:
|
|
|
1333
1330
|
| `memberships [user_id, organization_id]` | User can only have one membership per org |
|
|
1334
1331
|
| `invitations [organization_id, email] WHERE accepted_at IS NULL` | Only one pending invitation per email per org |
|
|
1335
1332
|
| `invitations [token]` | Invitation tokens are globally unique |
|
|
1336
|
-
| `organizations [slug]` | Organization slugs are globally unique |
|
|
1337
1333
|
|
|
1338
1334
|
### Row-level locking
|
|
1339
1335
|
|
|
@@ -1423,10 +1419,6 @@ invitation.accept!
|
|
|
1423
1419
|
# Adding an existing member
|
|
1424
1420
|
org.add_member!(existing_user)
|
|
1425
1421
|
# => Returns existing membership (doesn't raise)
|
|
1426
|
-
|
|
1427
|
-
# Creating org with duplicate slug
|
|
1428
|
-
user.create_organization!("Acme Corp") # slug: acme-corp exists
|
|
1429
|
-
# => Creates with suffix: acme-corp-2 (via slugifiable gem)
|
|
1430
1422
|
```
|
|
1431
1423
|
|
|
1432
1424
|
### Session integrity
|
|
@@ -1453,17 +1445,14 @@ The gem creates these indexes automatically:
|
|
|
1453
1445
|
|
|
1454
1446
|
```sql
|
|
1455
1447
|
-- Fast membership lookups
|
|
1456
|
-
CREATE UNIQUE INDEX
|
|
1457
|
-
CREATE INDEX
|
|
1458
|
-
CREATE INDEX
|
|
1448
|
+
CREATE UNIQUE INDEX index_organizations_memberships_on_user_and_org ON organizations_memberships (user_id, organization_id);
|
|
1449
|
+
CREATE INDEX index_organizations_memberships_on_organization_id ON organizations_memberships (organization_id);
|
|
1450
|
+
CREATE INDEX index_organizations_memberships_on_role ON organizations_memberships (role);
|
|
1459
1451
|
|
|
1460
1452
|
-- Fast invitation lookups
|
|
1461
|
-
CREATE UNIQUE INDEX
|
|
1462
|
-
CREATE INDEX
|
|
1463
|
-
CREATE UNIQUE INDEX
|
|
1464
|
-
|
|
1465
|
-
-- Fast org lookups
|
|
1466
|
-
CREATE UNIQUE INDEX index_organizations_on_slug ON organizations (slug);
|
|
1453
|
+
CREATE UNIQUE INDEX index_organizations_invitations_on_token ON organizations_invitations (token);
|
|
1454
|
+
CREATE INDEX index_organizations_invitations_on_email ON organizations_invitations (email);
|
|
1455
|
+
CREATE UNIQUE INDEX index_organizations_invitations_pending ON organizations_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;
|
|
1467
1456
|
```
|
|
1468
1457
|
|
|
1469
1458
|
## Migration from 1:1 relationships
|
|
@@ -150,7 +150,6 @@ module Organizations
|
|
|
150
150
|
{
|
|
151
151
|
id: org.id,
|
|
152
152
|
name: org.name,
|
|
153
|
-
slug: org.slug,
|
|
154
153
|
member_count: counts[org.id] || 0,
|
|
155
154
|
role: membership.role,
|
|
156
155
|
created_at: org.created_at,
|
|
@@ -168,7 +167,6 @@ module Organizations
|
|
|
168
167
|
{
|
|
169
168
|
id: org.id,
|
|
170
169
|
name: org.name,
|
|
171
|
-
slug: org.slug,
|
|
172
170
|
member_count: org.member_count,
|
|
173
171
|
role: membership&.role,
|
|
174
172
|
created_at: org.created_at,
|
|
@@ -19,7 +19,7 @@ module Organizations
|
|
|
19
19
|
|
|
20
20
|
respond_to do |format|
|
|
21
21
|
format.html { redirect_to after_switch_path, notice: "Switched to #{org.name}" }
|
|
22
|
-
format.json { render json: { organization: { id: org.id, name: org.name
|
|
22
|
+
format.json { render json: { organization: { id: org.id, name: org.name } } }
|
|
23
23
|
end
|
|
24
24
|
else
|
|
25
25
|
respond_to do |format|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 7.2.3"
|
|
7
|
+
|
|
8
|
+
group :development do
|
|
9
|
+
gem "irb"
|
|
10
|
+
gem "rubocop", "~> 1.0"
|
|
11
|
+
gem "rubocop-minitest", "~> 0.35"
|
|
12
|
+
gem "rubocop-performance", "~> 1.0"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
group :development, :test do
|
|
16
|
+
gem "appraisal"
|
|
17
|
+
gem "minitest", "~> 6.0"
|
|
18
|
+
gem "minitest-mock"
|
|
19
|
+
gem "rack-test"
|
|
20
|
+
gem "sqlite3", ">= 2.1"
|
|
21
|
+
gem "ostruct"
|
|
22
|
+
gem "simplecov", require: false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 8.1.2"
|
|
7
|
+
|
|
8
|
+
group :development do
|
|
9
|
+
gem "irb"
|
|
10
|
+
gem "rubocop", "~> 1.0"
|
|
11
|
+
gem "rubocop-minitest", "~> 0.35"
|
|
12
|
+
gem "rubocop-performance", "~> 1.0"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
group :development, :test do
|
|
16
|
+
gem "appraisal"
|
|
17
|
+
gem "minitest", "~> 6.0"
|
|
18
|
+
gem "minitest-mock"
|
|
19
|
+
gem "rack-test"
|
|
20
|
+
gem "sqlite3", ">= 2.1"
|
|
21
|
+
gem "ostruct"
|
|
22
|
+
gem "simplecov", require: false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
gemspec path: "../"
|
|
@@ -6,20 +6,17 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
6
6
|
adapter = connection.adapter_name.downcase
|
|
7
7
|
|
|
8
8
|
# Organizations table
|
|
9
|
-
create_table :
|
|
9
|
+
create_table :organizations_organizations, id: primary_key_type do |t|
|
|
10
10
|
t.string :name, null: false
|
|
11
|
-
t.string :slug, null: false
|
|
12
11
|
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
13
12
|
|
|
14
13
|
t.timestamps
|
|
15
14
|
end
|
|
16
15
|
|
|
17
|
-
add_index :organizations, :slug, unique: true
|
|
18
|
-
|
|
19
16
|
# Memberships join table (User ↔ Organization)
|
|
20
|
-
create_table :
|
|
17
|
+
create_table :organizations_memberships, id: primary_key_type do |t|
|
|
21
18
|
t.references :user, null: false, type: foreign_key_type, foreign_key: true
|
|
22
|
-
t.references :organization, null: false, type: foreign_key_type, foreign_key:
|
|
19
|
+
t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
|
|
23
20
|
t.references :invited_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
|
|
24
21
|
t.string :role, null: false, default: "member"
|
|
25
22
|
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
@@ -27,27 +24,22 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
27
24
|
t.timestamps
|
|
28
25
|
end
|
|
29
26
|
|
|
30
|
-
add_index :
|
|
31
|
-
add_index :
|
|
27
|
+
add_index :organizations_memberships, [:user_id, :organization_id], unique: true
|
|
28
|
+
add_index :organizations_memberships, :role
|
|
32
29
|
|
|
33
30
|
# Enforce "at most one owner membership per organization" at DB level where possible.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
CREATE UNIQUE INDEX index_memberships_single_owner
|
|
37
|
-
ON memberships (organization_id)
|
|
38
|
-
WHERE role = 'owner'
|
|
39
|
-
SQL
|
|
40
|
-
elsif adapter.include?("sqlite")
|
|
31
|
+
# Both PostgreSQL and SQLite support partial indexes with identical syntax.
|
|
32
|
+
if adapter.include?("postgresql") || adapter.include?("sqlite")
|
|
41
33
|
execute <<-SQL
|
|
42
|
-
CREATE UNIQUE INDEX
|
|
43
|
-
ON
|
|
34
|
+
CREATE UNIQUE INDEX index_organizations_memberships_single_owner
|
|
35
|
+
ON organizations_memberships (organization_id)
|
|
44
36
|
WHERE role = 'owner'
|
|
45
37
|
SQL
|
|
46
38
|
end
|
|
47
39
|
|
|
48
40
|
# Invitations table
|
|
49
|
-
create_table :
|
|
50
|
-
t.references :organization, null: false, type: foreign_key_type, foreign_key:
|
|
41
|
+
create_table :organizations_invitations, id: primary_key_type do |t|
|
|
42
|
+
t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
|
|
51
43
|
# invited_by is nullable to support dependent: :nullify when user is deleted
|
|
52
44
|
t.references :invited_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
|
|
53
45
|
t.string :email, null: false
|
|
@@ -59,29 +51,22 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
59
51
|
t.timestamps
|
|
60
52
|
end
|
|
61
53
|
|
|
62
|
-
add_index :
|
|
63
|
-
add_index :
|
|
54
|
+
add_index :organizations_invitations, :token, unique: true
|
|
55
|
+
add_index :organizations_invitations, :email
|
|
64
56
|
|
|
65
57
|
# 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
|
|
58
|
+
# Both PostgreSQL and SQLite (3.8.0+) support partial indexes with identical syntax.
|
|
59
|
+
if adapter.include?("postgresql") || adapter.include?("sqlite")
|
|
75
60
|
execute <<-SQL
|
|
76
|
-
CREATE UNIQUE INDEX
|
|
77
|
-
ON
|
|
61
|
+
CREATE UNIQUE INDEX index_organizations_invitations_pending_unique
|
|
62
|
+
ON organizations_invitations (organization_id, LOWER(email))
|
|
78
63
|
WHERE accepted_at IS NULL
|
|
79
64
|
SQL
|
|
80
65
|
elsif adapter.include?("mysql")
|
|
81
66
|
# MySQL doesn't support partial indexes, so we use a generated column that is
|
|
82
67
|
# only non-NULL for pending invitations and enforce uniqueness on that value.
|
|
83
68
|
execute <<-SQL
|
|
84
|
-
ALTER TABLE
|
|
69
|
+
ALTER TABLE organizations_invitations
|
|
85
70
|
ADD COLUMN pending_email VARCHAR(255)
|
|
86
71
|
GENERATED ALWAYS AS (
|
|
87
72
|
CASE
|
|
@@ -91,10 +76,10 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
91
76
|
) STORED
|
|
92
77
|
SQL
|
|
93
78
|
|
|
94
|
-
add_index :
|
|
79
|
+
add_index :organizations_invitations, [:organization_id, :pending_email], unique: true, name: "index_organizations_invitations_pending_unique"
|
|
95
80
|
else
|
|
96
81
|
# For other adapters, fall back to app-level validation.
|
|
97
|
-
add_index :
|
|
82
|
+
add_index :organizations_invitations, [:organization_id, :email], name: "index_organizations_invitations_on_org_and_email"
|
|
98
83
|
end
|
|
99
84
|
end
|
|
100
85
|
|
|
@@ -71,13 +71,4 @@ Organizations.configure do |config|
|
|
|
71
71
|
# Default: :current_organization_id
|
|
72
72
|
# config.session_key = :current_organization_id
|
|
73
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
74
|
end
|
|
@@ -74,7 +74,7 @@ module Organizations
|
|
|
74
74
|
|
|
75
75
|
# Organizations where user is owner (efficient JOIN)
|
|
76
76
|
has_many :owned_organizations,
|
|
77
|
-
-> { where(
|
|
77
|
+
-> { where(organizations_memberships: { role: "owner" }) },
|
|
78
78
|
through: :memberships,
|
|
79
79
|
source: :organization,
|
|
80
80
|
class_name: "Organizations::Organization"
|
|
@@ -16,7 +16,7 @@ module Organizations
|
|
|
16
16
|
# membership.demote_to!(:member)
|
|
17
17
|
#
|
|
18
18
|
class Membership < ActiveRecord::Base
|
|
19
|
-
self.table_name = "
|
|
19
|
+
self.table_name = "organizations_memberships"
|
|
20
20
|
|
|
21
21
|
# Error raised when trying to demote below current role
|
|
22
22
|
class CannotDemoteOwner < Organizations::Error; end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "slugifiable/model"
|
|
4
|
-
|
|
5
3
|
module Organizations
|
|
6
4
|
# Organization model representing a team, workspace, or account.
|
|
7
5
|
# Users belong to organizations through memberships with specific roles.
|
|
@@ -18,12 +16,7 @@ module Organizations
|
|
|
18
16
|
# org.member_count # => 5
|
|
19
17
|
#
|
|
20
18
|
class Organization < ActiveRecord::Base
|
|
21
|
-
self.table_name = "
|
|
22
|
-
include Slugifiable::Model
|
|
23
|
-
|
|
24
|
-
# Keep slug semantics aligned with README and slugifiable defaults:
|
|
25
|
-
# organization names become URL-friendly slugs, with collision handling.
|
|
26
|
-
generate_slug_based_on :name
|
|
19
|
+
self.table_name = "organizations_organizations"
|
|
27
20
|
|
|
28
21
|
# Error raised when trying to perform invalid operations on organization
|
|
29
22
|
class CannotRemoveOwner < Organizations::Error; end
|
|
@@ -53,14 +46,6 @@ module Organizations
|
|
|
53
46
|
# === Validations ===
|
|
54
47
|
|
|
55
48
|
validates :name, presence: true
|
|
56
|
-
validates :slug, presence: true, uniqueness: { case_sensitive: false }
|
|
57
|
-
|
|
58
|
-
# === Callbacks ===
|
|
59
|
-
|
|
60
|
-
# slugifiable persists slugs in after_create by default, but this gem keeps
|
|
61
|
-
# organizations.slug as NOT NULL and validates presence. We therefore compute
|
|
62
|
-
# a slug before validation when needed.
|
|
63
|
-
before_validation :ensure_slug_present, on: :create, if: -> { slug.blank? && name.present? }
|
|
64
49
|
|
|
65
50
|
# === Scopes ===
|
|
66
51
|
|
|
@@ -69,7 +54,7 @@ module Organizations
|
|
|
69
54
|
# @param user [User] The user
|
|
70
55
|
# @return [ActiveRecord::Relation]
|
|
71
56
|
scope :with_member, ->(user) {
|
|
72
|
-
joins(:memberships).where(
|
|
57
|
+
joins(:memberships).where(organizations_memberships: { user_id: user.id })
|
|
73
58
|
}
|
|
74
59
|
|
|
75
60
|
# === Member Query Methods ===
|
|
@@ -90,7 +75,7 @@ module Organizations
|
|
|
90
75
|
# Uses efficient JOIN query to avoid N+1
|
|
91
76
|
# @return [ActiveRecord::Relation<User>]
|
|
92
77
|
def admins
|
|
93
|
-
users.where(
|
|
78
|
+
users.where(organizations_memberships: { role: %w[owner admin] }).distinct
|
|
94
79
|
end
|
|
95
80
|
|
|
96
81
|
# Alias for users (semantic convenience)
|
|
@@ -385,10 +370,6 @@ module Organizations
|
|
|
385
370
|
|
|
386
371
|
private
|
|
387
372
|
|
|
388
|
-
def ensure_slug_present
|
|
389
|
-
self.slug = compute_slug if slug.blank?
|
|
390
|
-
end
|
|
391
|
-
|
|
392
373
|
# Defense in depth for organization-centric API usage.
|
|
393
374
|
# The user-level API already checks this, but direct calls to `org.send_invite_to!`
|
|
394
375
|
# must enforce membership and invite permission as well.
|
|
@@ -24,7 +24,7 @@ module Organizations
|
|
|
24
24
|
# === Organization Switcher ===
|
|
25
25
|
|
|
26
26
|
# Returns optimized data for building an organization switcher
|
|
27
|
-
# Only selects needed columns (id, name
|
|
27
|
+
# Only selects needed columns (id, name) for performance
|
|
28
28
|
# Memoized within the request
|
|
29
29
|
#
|
|
30
30
|
# @return [Hash] Hash with :current, :others, and :switch_path
|
|
@@ -32,10 +32,10 @@ module Organizations
|
|
|
32
32
|
# @example
|
|
33
33
|
# organization_switcher_data
|
|
34
34
|
# # => {
|
|
35
|
-
# # current: { id: "...", name: "Acme Corp"
|
|
35
|
+
# # current: { id: "...", name: "Acme Corp" },
|
|
36
36
|
# # others: [
|
|
37
|
-
# # { id: "...", name: "Personal"
|
|
38
|
-
# # { id: "...", name: "StartupCo"
|
|
37
|
+
# # { id: "...", name: "Personal" },
|
|
38
|
+
# # { id: "...", name: "StartupCo" }
|
|
39
39
|
# # ],
|
|
40
40
|
# # switch_path: ->(org_id) { "/organizations/switch/#{org_id}" }
|
|
41
41
|
# # }
|
|
@@ -258,8 +258,8 @@ module Organizations
|
|
|
258
258
|
memberships = user.memberships
|
|
259
259
|
.includes(:organization)
|
|
260
260
|
.joins(:organization)
|
|
261
|
-
.select("
|
|
262
|
-
"
|
|
261
|
+
.select("organizations_memberships.id, organizations_memberships.organization_id, organizations_memberships.role, " \
|
|
262
|
+
"organizations_organizations.id AS org_id, organizations_organizations.name AS org_name")
|
|
263
263
|
|
|
264
264
|
current_data = nil
|
|
265
265
|
others = []
|
|
@@ -268,7 +268,6 @@ module Organizations
|
|
|
268
268
|
org_data = {
|
|
269
269
|
id: m.organization_id,
|
|
270
270
|
name: m.org_name,
|
|
271
|
-
slug: m.org_slug,
|
|
272
271
|
role: m.role.to_sym,
|
|
273
272
|
role_label: organization_role_label(m.role)
|
|
274
273
|
}
|
|
@@ -281,7 +280,7 @@ module Organizations
|
|
|
281
280
|
end
|
|
282
281
|
|
|
283
282
|
# If no current org was found in memberships, user might not be a member anymore
|
|
284
|
-
current_data ||= { id: nil, name: nil,
|
|
283
|
+
current_data ||= { id: nil, name: nil, role: nil, current: true }
|
|
285
284
|
|
|
286
285
|
{
|
|
287
286
|
current: current_data,
|
|
@@ -292,7 +291,7 @@ module Organizations
|
|
|
292
291
|
|
|
293
292
|
def empty_switcher_data
|
|
294
293
|
{
|
|
295
|
-
current: { id: nil, name: nil,
|
|
294
|
+
current: { id: nil, name: nil, role: nil, current: true },
|
|
296
295
|
others: [],
|
|
297
296
|
switch_path: build_switch_path_lambda
|
|
298
297
|
}
|
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-02-
|
|
10
|
+
date: 2026-02-20 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|
|
@@ -69,20 +69,6 @@ dependencies:
|
|
|
69
69
|
- - "<"
|
|
70
70
|
- !ruby/object:Gem::Version
|
|
71
71
|
version: '9.0'
|
|
72
|
-
- !ruby/object:Gem::Dependency
|
|
73
|
-
name: slugifiable
|
|
74
|
-
requirement: !ruby/object:Gem::Requirement
|
|
75
|
-
requirements:
|
|
76
|
-
- - ">="
|
|
77
|
-
- !ruby/object:Gem::Version
|
|
78
|
-
version: 0.1.0
|
|
79
|
-
type: :runtime
|
|
80
|
-
prerelease: false
|
|
81
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
82
|
-
requirements:
|
|
83
|
-
- - ">="
|
|
84
|
-
- !ruby/object:Gem::Version
|
|
85
|
-
version: 0.1.0
|
|
86
72
|
description: Add organizations to any Rails app (with members, roles, and invitations).
|
|
87
73
|
This gem implements the complete User → Membership → Organization pattern with scoped
|
|
88
74
|
invitations, hierarchical roles (owner, admin, member, viewer), permissions, and
|
|
@@ -113,8 +99,8 @@ files:
|
|
|
113
99
|
- app/views/organizations/invitation_mailer/invitation_email.html.erb
|
|
114
100
|
- app/views/organizations/invitation_mailer/invitation_email.text.erb
|
|
115
101
|
- config/routes.rb
|
|
116
|
-
-
|
|
117
|
-
-
|
|
102
|
+
- gemfiles/rails_7.2.gemfile
|
|
103
|
+
- gemfiles/rails_8.1.gemfile
|
|
118
104
|
- lib/generators/organizations/install/install_generator.rb
|
|
119
105
|
- lib/generators/organizations/install/templates/create_organizations_tables.rb.erb
|
|
120
106
|
- lib/generators/organizations/install/templates/initializer.rb
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Demo: Verify INSERT-time race condition handling works for Organizations
|
|
4
|
-
#
|
|
5
|
-
# This simulates the exact race condition that Codex identified:
|
|
6
|
-
# 1. Two processes try to create orgs with same name simultaneously
|
|
7
|
-
# 2. Both compute slug "acme-corp" in before_validation
|
|
8
|
-
# 3. First INSERT succeeds
|
|
9
|
-
# 4. Second INSERT fails with RecordNotUnique
|
|
10
|
-
# 5. around_create retries with recomputed slug
|
|
11
|
-
#
|
|
12
|
-
# Run: bundle exec ruby test/demo_insert_race_condition.rb
|
|
13
|
-
|
|
14
|
-
require "bundler/setup"
|
|
15
|
-
require "active_record"
|
|
16
|
-
require "sqlite3"
|
|
17
|
-
|
|
18
|
-
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
19
|
-
require "organizations"
|
|
20
|
-
|
|
21
|
-
puts "=" * 70
|
|
22
|
-
puts "INSERT-TIME RACE CONDITION DEMO"
|
|
23
|
-
puts "=" * 70
|
|
24
|
-
puts
|
|
25
|
-
|
|
26
|
-
# Setup
|
|
27
|
-
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
28
|
-
ActiveRecord::Base.logger = Logger.new(nil)
|
|
29
|
-
|
|
30
|
-
ActiveRecord::Schema.define do
|
|
31
|
-
create_table :users, force: :cascade do |t|
|
|
32
|
-
t.string :email, null: false
|
|
33
|
-
t.timestamps
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
create_table :organizations, force: :cascade do |t|
|
|
37
|
-
t.string :name, null: false
|
|
38
|
-
t.string :slug, null: false # NOT NULL - key for this test
|
|
39
|
-
t.timestamps
|
|
40
|
-
end
|
|
41
|
-
add_index :organizations, :slug, unique: true
|
|
42
|
-
|
|
43
|
-
create_table :memberships, force: :cascade do |t|
|
|
44
|
-
t.references :user, null: false
|
|
45
|
-
t.references :organization, null: false
|
|
46
|
-
t.string :role, null: false, default: "member"
|
|
47
|
-
t.timestamps
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
create_table :organization_invitations, force: :cascade do |t|
|
|
51
|
-
t.references :organization, null: false
|
|
52
|
-
t.string :email, null: false
|
|
53
|
-
t.string :token, null: false
|
|
54
|
-
t.string :role, null: false, default: "member"
|
|
55
|
-
t.datetime :accepted_at
|
|
56
|
-
t.datetime :expires_at
|
|
57
|
-
t.timestamps
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
class User < ActiveRecord::Base
|
|
62
|
-
extend Organizations::Models::Concerns::HasOrganizations::ClassMethods
|
|
63
|
-
has_organizations
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# ============================================================================
|
|
67
|
-
# Test 1: Verify around_create hook is available
|
|
68
|
-
# ============================================================================
|
|
69
|
-
puts "TEST 1: Verify around_create hook exists"
|
|
70
|
-
puts "-" * 40
|
|
71
|
-
|
|
72
|
-
org = Organizations::Organization.new(name: "Test")
|
|
73
|
-
has_hook = org.respond_to?(:retry_create_on_slug_unique_violation, true)
|
|
74
|
-
puts "Has retry_create_on_slug_unique_violation: #{has_hook ? '✅ YES' : '❌ NO'}"
|
|
75
|
-
puts
|
|
76
|
-
|
|
77
|
-
# ============================================================================
|
|
78
|
-
# Test 2: Simulate race condition with injected conflicting INSERT
|
|
79
|
-
# ============================================================================
|
|
80
|
-
puts "TEST 2: Simulate INSERT-time race condition"
|
|
81
|
-
puts "-" * 40
|
|
82
|
-
|
|
83
|
-
# Create a subclass that injects a conflicting row on first INSERT attempt
|
|
84
|
-
class RaceSimulationOrg < Organizations::Organization
|
|
85
|
-
self.table_name = "organizations"
|
|
86
|
-
|
|
87
|
-
class_attribute :insert_attempts, default: 0
|
|
88
|
-
class_attribute :collision_injected, default: false
|
|
89
|
-
|
|
90
|
-
before_create :inject_collision_once
|
|
91
|
-
|
|
92
|
-
private
|
|
93
|
-
|
|
94
|
-
def inject_collision_once
|
|
95
|
-
self.class.insert_attempts += 1
|
|
96
|
-
puts " [DEBUG] INSERT attempt ##{self.class.insert_attempts}, slug=#{slug}"
|
|
97
|
-
|
|
98
|
-
return if self.class.collision_injected
|
|
99
|
-
|
|
100
|
-
self.class.collision_injected = true
|
|
101
|
-
|
|
102
|
-
# Inject a row with the same slug BEFORE this INSERT completes
|
|
103
|
-
conn = self.class.connection
|
|
104
|
-
now = Time.current
|
|
105
|
-
conn.execute(<<~SQL)
|
|
106
|
-
INSERT INTO organizations (name, slug, created_at, updated_at)
|
|
107
|
-
VALUES (
|
|
108
|
-
#{conn.quote("Injected by race simulation")},
|
|
109
|
-
#{conn.quote(slug)},
|
|
110
|
-
#{conn.quote(now)},
|
|
111
|
-
#{conn.quote(now)}
|
|
112
|
-
)
|
|
113
|
-
SQL
|
|
114
|
-
puts " [DEBUG] Injected conflicting row with slug=#{slug}"
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
begin
|
|
119
|
-
org = RaceSimulationOrg.create!(name: "Acme Corp")
|
|
120
|
-
|
|
121
|
-
puts
|
|
122
|
-
puts "Result:"
|
|
123
|
-
puts " Created successfully: #{org.persisted? ? '✅ YES' : '❌ NO'}"
|
|
124
|
-
puts " INSERT attempts: #{RaceSimulationOrg.insert_attempts}"
|
|
125
|
-
puts " Final slug: #{org.slug}"
|
|
126
|
-
puts " Slug changed after retry: #{org.slug != 'acme-corp' ? '✅ YES' : '❌ NO'}"
|
|
127
|
-
|
|
128
|
-
if RaceSimulationOrg.insert_attempts == 2 && org.slug.start_with?("acme-corp-")
|
|
129
|
-
puts
|
|
130
|
-
puts "✅ PASS - Race condition handled correctly!"
|
|
131
|
-
else
|
|
132
|
-
puts
|
|
133
|
-
puts "⚠️ Unexpected behavior - check debug output"
|
|
134
|
-
end
|
|
135
|
-
rescue => e
|
|
136
|
-
puts
|
|
137
|
-
puts "❌ FAIL - #{e.class}: #{e.message}"
|
|
138
|
-
end
|
|
139
|
-
puts
|
|
140
|
-
|
|
141
|
-
# ============================================================================
|
|
142
|
-
# Test 3: Verify non-slug unique violations still bubble up
|
|
143
|
-
# ============================================================================
|
|
144
|
-
puts "TEST 3: Non-slug unique violations bubble up"
|
|
145
|
-
puts "-" * 40
|
|
146
|
-
|
|
147
|
-
class NonSlugViolationOrg < Organizations::Organization
|
|
148
|
-
self.table_name = "organizations"
|
|
149
|
-
|
|
150
|
-
before_create do
|
|
151
|
-
raise ActiveRecord::RecordNotUnique, "UNIQUE constraint failed: organizations.external_id"
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
begin
|
|
156
|
-
NonSlugViolationOrg.create!(name: "Should Fail")
|
|
157
|
-
puts "❌ FAIL - Should have raised RecordNotUnique"
|
|
158
|
-
rescue ActiveRecord::RecordNotUnique => e
|
|
159
|
-
if e.message.include?("external_id")
|
|
160
|
-
puts "✅ PASS - Non-slug violation bubbled up correctly"
|
|
161
|
-
else
|
|
162
|
-
puts "⚠️ Unexpected error: #{e.message}"
|
|
163
|
-
end
|
|
164
|
-
rescue => e
|
|
165
|
-
puts "❌ FAIL - Wrong error type: #{e.class}"
|
|
166
|
-
end
|
|
167
|
-
puts
|
|
168
|
-
|
|
169
|
-
# ============================================================================
|
|
170
|
-
# Test 4: High-volume stress test with real database
|
|
171
|
-
# ============================================================================
|
|
172
|
-
puts "TEST 4: High-volume concurrent-like test (100 orgs, same name)"
|
|
173
|
-
puts "-" * 40
|
|
174
|
-
|
|
175
|
-
Organizations::Organization.delete_all
|
|
176
|
-
|
|
177
|
-
start_time = Time.now
|
|
178
|
-
orgs = []
|
|
179
|
-
100.times do |i|
|
|
180
|
-
orgs << Organizations::Organization.create!(name: "Stress Test Corp")
|
|
181
|
-
end
|
|
182
|
-
elapsed = Time.now - start_time
|
|
183
|
-
|
|
184
|
-
slugs = orgs.map(&:slug)
|
|
185
|
-
unique_count = slugs.uniq.length
|
|
186
|
-
|
|
187
|
-
puts "Created 100 organizations in #{(elapsed * 1000).round(2)}ms"
|
|
188
|
-
puts "Unique slugs: #{unique_count}/100"
|
|
189
|
-
puts "First slug: #{orgs.first.slug}"
|
|
190
|
-
puts "Last slug: #{orgs.last.slug}"
|
|
191
|
-
puts "Result: #{unique_count == 100 ? '✅ PASS' : '❌ FAIL'}"
|
|
192
|
-
puts
|
|
193
|
-
|
|
194
|
-
# ============================================================================
|
|
195
|
-
# Summary
|
|
196
|
-
# ============================================================================
|
|
197
|
-
puts "=" * 70
|
|
198
|
-
puts "SUMMARY"
|
|
199
|
-
puts "=" * 70
|
|
200
|
-
puts
|
|
201
|
-
puts "The around_create hook in slugifiable correctly handles INSERT-time"
|
|
202
|
-
puts "race conditions for NOT NULL slug columns."
|
|
203
|
-
puts
|
|
204
|
-
puts "Flow:"
|
|
205
|
-
puts "1. before_validation: compute slug ('acme-corp')"
|
|
206
|
-
puts "2. around_create: wrap INSERT"
|
|
207
|
-
puts "3. INSERT fails: RecordNotUnique (slug collision)"
|
|
208
|
-
puts "4. around_create: recompute slug ('acme-corp-123456')"
|
|
209
|
-
puts "5. Retry INSERT: success"
|
|
210
|
-
puts
|
|
211
|
-
puts "This is the fix Codex added in slugifiable Round 9."
|
|
212
|
-
puts "=" * 70
|
|
@@ -1,350 +0,0 @@
|
|
|
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
|