organizations 0.4.3 → 0.5.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -9
  3. data/.rubocop_todo.yml +625 -0
  4. data/CHANGELOG.md +46 -0
  5. data/README.md +405 -41
  6. data/app/controllers/organizations/application_controller.rb +4 -0
  7. data/app/controllers/organizations/memberships_controller.rb +3 -3
  8. data/app/controllers/organizations/public_controller.rb +26 -0
  9. data/app/mailers/organizations/invitation_mailer.rb +38 -18
  10. data/app/mailers/organizations/verification_mailer.rb +56 -0
  11. data/app/models/organizations/allowlist_entry.rb +6 -0
  12. data/app/models/organizations/domain.rb +6 -0
  13. data/app/models/organizations/join_code.rb +6 -0
  14. data/app/models/organizations/join_request.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
  19. data/config/locales/en.yml +158 -0
  20. data/config/locales/es.yml +126 -0
  21. data/config/routes.rb +46 -28
  22. data/gemfiles/rails_7.2.gemfile +3 -3
  23. data/gemfiles/rails_8.1.gemfile +3 -3
  24. data/lib/generators/organizations/install/install_generator.rb +3 -0
  25. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
  26. data/lib/generators/organizations/install/templates/initializer.rb +53 -0
  27. data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
  28. data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
  29. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
  30. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
  31. data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
  32. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
  33. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
  34. data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
  35. data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
  36. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
  37. data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
  38. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
  39. data/lib/generators/organizations/views/views_generator.rb +47 -0
  40. data/lib/organizations/callback_context.rb +9 -0
  41. data/lib/organizations/callbacks.rb +26 -18
  42. data/lib/organizations/configuration.rb +275 -1
  43. data/lib/organizations/controller_helpers.rb +38 -12
  44. data/lib/organizations/email_normalizer.rb +65 -0
  45. data/lib/organizations/join_flow.rb +227 -0
  46. data/lib/organizations/join_state.rb +117 -0
  47. data/lib/organizations/metadata_flags.rb +79 -0
  48. data/lib/organizations/models/allowlist_entry.rb +88 -0
  49. data/lib/organizations/models/concerns/has_organizations.rb +100 -12
  50. data/lib/organizations/models/domain.rb +85 -0
  51. data/lib/organizations/models/invitation.rb +105 -17
  52. data/lib/organizations/models/join_code.rb +242 -0
  53. data/lib/organizations/models/join_request.rb +627 -0
  54. data/lib/organizations/models/membership.rb +39 -8
  55. data/lib/organizations/models/organization.rb +317 -19
  56. data/lib/organizations/organization_scoped.rb +135 -0
  57. data/lib/organizations/test_helpers.rb +35 -1
  58. data/lib/organizations/version.rb +1 -1
  59. data/lib/organizations/view_helpers.rb +8 -12
  60. data/lib/organizations.rb +165 -0
  61. metadata +34 -2
data/config/routes.rb CHANGED
@@ -1,40 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Metrics/BlockLength -- a routes file is one declarative block by nature
3
4
  Organizations::Engine.routes.draw do
4
- # Organization switching
5
- # POST /organizations/switch/:id
6
- post "organizations/switch/:id", to: "switch#create", as: :switch_organization
5
+ # Route groups are drawn per config.engine_routes (devise_for-style
6
+ # only:/except:) so hosts declare what they want instead of shadowing
7
+ # engine routes with order-load-bearing redirects before the mount.
8
+ # Routes re-draw on every reload_routes!, so config is always respected.
9
+ groups = Organizations.configuration.engine_route_groups
7
10
 
8
- # Organization management
9
- # All operations are scoped to current_organization (from session)
10
- resources :organizations, only: [:index, :show, :new, :create, :edit, :update, :destroy]
11
+ if groups.include?(:switching)
12
+ # Organization switching
13
+ # POST /organizations/switch/:id
14
+ post "organizations/switch/:id", to: "switch#create", as: :switch_organization
15
+ end
11
16
 
12
- # Membership management (scoped to current_organization)
13
- # These are flat routes - the organization is determined by session, not URL
14
- resources :memberships, only: [:index, :update, :destroy] do
15
- member do
16
- post :transfer_ownership
17
- end
18
- collection do
19
- delete :leave
17
+ if groups.include?(:organizations)
18
+ # Organization management
19
+ # All operations are scoped to current_organization (from session)
20
+ resources :organizations, only: [:index, :show, :new, :create, :edit, :update, :destroy]
21
+ end
22
+
23
+ if groups.include?(:memberships)
24
+ # Membership management (scoped to current_organization)
25
+ # These are flat routes - the organization is determined by session, not URL
26
+ resources :memberships, only: [:index, :update, :destroy] do
27
+ member do
28
+ post :transfer_ownership
29
+ end
30
+ collection do
31
+ delete :leave
32
+ end
20
33
  end
21
34
  end
22
35
 
23
- # Invitation management (scoped to current_organization)
24
- # These are flat routes - the organization is determined by session, not URL
25
- # NOTE: Must come BEFORE token-based routes so /invitations/new doesn't match /:token
26
- resources :invitations, only: [:index, :new, :create, :destroy], as: :organization_invitations do
27
- member do
28
- post :resend
36
+ if groups.include?(:invitations)
37
+ # Invitation management (scoped to current_organization)
38
+ # These are flat routes - the organization is determined by session, not URL
39
+ # NOTE: Must come BEFORE token-based routes so /invitations/new doesn't match /:token
40
+ resources :invitations, only: [:index, :new, :create, :destroy], as: :organization_invitations do
41
+ member do
42
+ post :resend
43
+ end
29
44
  end
30
45
  end
31
46
 
32
- # Invitation acceptance (public routes with token)
33
- # These use PublicInvitationsController which inherits from a minimal base controller
34
- # to avoid host app filters that might enforce authentication.
35
- # GET /invitations/:token → View invitation details
36
- # POST /invitations/:token/accept Accept the invitation
37
- # NOTE: These must come AFTER resourceful routes to avoid matching "new" as a token
38
- get "invitations/:token", to: "public_invitations#show", as: :invitation
39
- post "invitations/:token/accept", to: "public_invitations#accept", as: :accept_invitation
47
+ if groups.include?(:public_invitations)
48
+ # Invitation acceptance (public routes with token)
49
+ # These use PublicInvitationsController which inherits from a minimal base controller
50
+ # to avoid host app filters that might enforce authentication.
51
+ # GET /invitations/:token View invitation details
52
+ # POST /invitations/:token/accept Accept the invitation
53
+ # NOTE: These must come AFTER resourceful routes to avoid matching "new" as a token
54
+ get "invitations/:token", to: "public_invitations#show", as: :invitation
55
+ post "invitations/:token/accept", to: "public_invitations#accept", as: :accept_invitation
56
+ end
40
57
  end
58
+ # rubocop:enable Metrics/BlockLength
@@ -7,9 +7,9 @@ gem "rails", "~> 7.2.3"
7
7
 
8
8
  group :development do
9
9
  gem "irb"
10
- gem "rubocop", "~> 1.0"
11
- gem "rubocop-minitest", "~> 0.35"
12
- gem "rubocop-performance", "~> 1.0"
10
+ gem "rubocop", "~> 1.88"
11
+ gem "rubocop-minitest", "~> 0.39"
12
+ gem "rubocop-performance", "~> 1.26"
13
13
  end
14
14
 
15
15
  group :development, :test do
@@ -7,9 +7,9 @@ gem "rails", "~> 8.1.2"
7
7
 
8
8
  group :development do
9
9
  gem "irb"
10
- gem "rubocop", "~> 1.0"
11
- gem "rubocop-minitest", "~> 0.35"
12
- gem "rubocop-performance", "~> 1.0"
10
+ gem "rubocop", "~> 1.88"
11
+ gem "rubocop-minitest", "~> 0.39"
12
+ gem "rubocop-performance", "~> 1.26"
13
13
  end
14
14
 
15
15
  group :development, :test do
@@ -30,6 +30,9 @@ module Organizations
30
30
  say " 2. Review and customize 'config/initializers/organizations.rb'."
31
31
  say " 3. Add 'has_organizations' to your User model."
32
32
  say " 4. Mount the engine in your routes: mount Organizations::Engine => '/'"
33
+ say " 5. Run 'rails g organizations:views' to copy the reference views"
34
+ say " (the engine renders HOST views — without them you'll hit"
35
+ say " missing-template errors on the org/membership/invitation pages)."
33
36
  end
34
37
 
35
38
  private
@@ -2,6 +2,9 @@
2
2
 
3
3
  class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
+ # ⚠️ If your user model is NOT `User` (config.user_class), adjust every
6
+ # `to_table: :users` below (and `foreign_key: true` on user references)
7
+ # to your user table before migrating.
5
8
  primary_key_type, foreign_key_type = primary_and_foreign_key_types
6
9
  adapter = connection.adapter_name.downcase
7
10
 
@@ -22,20 +25,42 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
22
25
  t.string :role, null: false, default: "member"
23
26
  t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
24
27
 
28
+ # Verified-joining provenance: how this member joined and which email
29
+ # address they proved control of (if any).
30
+ t.string :joined_via
31
+ t.string :verified_email
32
+ t.string :verified_email_normalized
33
+ t.datetime :verified_at
34
+
25
35
  t.timestamps
26
36
  end
27
37
 
28
- add_index :organizations_memberships, [:user_id, :organization_id], unique: true
38
+ add_index :organizations_memberships, [ :user_id, :organization_id ], unique: true
29
39
  add_index :organizations_memberships, :role
30
40
 
41
+ # One proven email address => one membership per organization.
42
+ # NULLs never collide, so unverified memberships are unconstrained —
43
+ # a plain unique index gives identical semantics on every adapter.
44
+ add_index :organizations_memberships, [ :organization_id, :verified_email_normalized ],
45
+ unique: true, name: "index_org_memberships_verified_email_unique"
46
+
31
47
  # Enforce "at most one owner membership per organization" at DB level where possible.
32
48
  # Both PostgreSQL and SQLite support partial indexes with identical syntax.
49
+ # ⚠️ Partial-index SQL stays on ONE line on purpose: the SQLite schema
50
+ # dumper only recovers the WHERE clause from single-line index SQL — a
51
+ # multi-line statement dumps as a FULL unique index, and databases
52
+ # provisioned from schema.rb (db:schema:load, test DBs) silently lose
53
+ # the partial-index invariant.
54
+ # `reversible` + up-only: raw execute in `def change` would raise
55
+ # IrreversibleMigration on rollback; the indexes/columns die with their
56
+ # tables when the reversed create_table drops them, so down is a no-op.
33
57
  if adapter.include?("postgresql") || adapter.include?("sqlite")
34
- execute <<-SQL
35
- CREATE UNIQUE INDEX index_organizations_memberships_single_owner
36
- ON organizations_memberships (organization_id)
37
- WHERE role = 'owner'
38
- SQL
58
+ reversible do |dir|
59
+ dir.up do
60
+ execute "CREATE UNIQUE INDEX index_organizations_memberships_single_owner " \
61
+ "ON organizations_memberships (organization_id) WHERE role = 'owner'"
62
+ end
63
+ end
39
64
  end
40
65
 
41
66
  # Invitations table
@@ -48,6 +73,10 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
48
73
  t.string :role, null: false, default: "member"
49
74
  t.datetime :accepted_at
50
75
  t.datetime :expires_at
76
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
77
+ # Copied onto the membership created when this invitation is accepted
78
+ # (cohort tags etc. — the gem never interprets the contents)
79
+ t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
51
80
 
52
81
  t.timestamps
53
82
  end
@@ -57,30 +86,146 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
57
86
 
58
87
  # Unique partial index: only one pending (non-accepted) invitation per email per org
59
88
  # Both PostgreSQL and SQLite (3.8.0+) support partial indexes with identical syntax.
89
+ # (One line on purpose — see the single_owner index note above.)
60
90
  if adapter.include?("postgresql") || adapter.include?("sqlite")
61
- execute <<-SQL
62
- CREATE UNIQUE INDEX index_organizations_invitations_pending_unique
63
- ON organizations_invitations (organization_id, LOWER(email))
64
- WHERE accepted_at IS NULL
65
- SQL
91
+ reversible do |dir|
92
+ dir.up do
93
+ execute "CREATE UNIQUE INDEX index_organizations_invitations_pending_unique " \
94
+ "ON organizations_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL"
95
+ end
96
+ end
66
97
  elsif adapter.include?("mysql")
67
98
  # MySQL doesn't support partial indexes, so we use a generated column that is
68
99
  # only non-NULL for pending invitations and enforce uniqueness on that value.
69
- execute <<-SQL
70
- ALTER TABLE organizations_invitations
71
- ADD COLUMN pending_email VARCHAR(255)
72
- GENERATED ALWAYS AS (
73
- CASE
74
- WHEN accepted_at IS NULL THEN LOWER(email)
75
- ELSE NULL
76
- END
77
- ) STORED
78
- SQL
79
-
80
- add_index :organizations_invitations, [:organization_id, :pending_email], unique: true, name: "index_organizations_invitations_pending_unique"
100
+ reversible do |dir|
101
+ dir.up do
102
+ execute <<-SQL
103
+ ALTER TABLE organizations_invitations
104
+ ADD COLUMN pending_email VARCHAR(255)
105
+ GENERATED ALWAYS AS (
106
+ CASE
107
+ WHEN accepted_at IS NULL THEN LOWER(email)
108
+ ELSE NULL
109
+ END
110
+ ) STORED
111
+ SQL
112
+ end
113
+ end
114
+
115
+ add_index :organizations_invitations, [ :organization_id, :pending_email ], unique: true, name: "index_organizations_invitations_pending_unique"
81
116
  else
82
117
  # For other adapters, fall back to app-level validation.
83
- add_index :organizations_invitations, [:organization_id, :email], name: "index_organizations_invitations_on_org_and_email"
118
+ add_index :organizations_invitations, [ :organization_id, :email ], name: "index_organizations_invitations_on_org_and_email"
119
+ end
120
+
121
+ # === Verified joining ===
122
+
123
+ # Email domains owned by an organization (exact-match joining)
124
+ create_table :organizations_domains, id: primary_key_type do |t|
125
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
126
+ t.string :domain, null: false
127
+ t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
128
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
129
+
130
+ t.timestamps
131
+ end
132
+
133
+ add_index :organizations_domains, [ :organization_id, :domain ], unique: true
134
+ add_index :organizations_domains, :domain
135
+
136
+ # Shareable join codes ("PINs") — globally unique, rotatable, cappable
137
+ create_table :organizations_join_codes, id: primary_key_type do |t|
138
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
139
+ t.string :code, null: false
140
+ t.string :label
141
+ t.boolean :requires_verified_domain_email, null: false, default: false
142
+ t.boolean :auto_approve, null: false, default: true
143
+ t.datetime :expires_at
144
+ t.integer :max_uses
145
+ t.integer :uses_count, null: false, default: 0
146
+ t.datetime :revoked_at
147
+ t.references :created_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
148
+ t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
149
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
150
+
151
+ t.timestamps
152
+ end
153
+
154
+ add_index :organizations_join_codes, :code, unique: true
155
+
156
+ # Pre-approved roster emails (allowlist joining)
157
+ create_table :organizations_allowlist_entries, id: primary_key_type do |t|
158
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
159
+ t.string :email, null: false
160
+ t.string :email_normalized, null: false
161
+ t.string :source
162
+ t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
163
+ t.datetime :claimed_at
164
+ t.references :claimed_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
165
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
166
+
167
+ t.timestamps
168
+ end
169
+
170
+ add_index :organizations_allowlist_entries, [ :organization_id, :email_normalized ], unique: true
171
+
172
+ # Join requests (request-to-join workflow + email-verification challenges)
173
+ create_table :organizations_join_requests, id: primary_key_type do |t|
174
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
175
+ t.references :user, null: false, type: foreign_key_type, foreign_key: true
176
+ t.string :status, null: false, default: "pending"
177
+ t.string :joined_via
178
+ t.references :join_code, null: true, type: foreign_key_type, foreign_key: { to_table: :organizations_join_codes }
179
+ t.string :message
180
+ t.string :verification_email
181
+ t.string :verification_email_normalized
182
+ t.string :verification_code_digest
183
+ t.datetime :verification_sent_at
184
+ t.datetime :verification_expires_at
185
+ t.integer :verification_attempts, null: false, default: 0
186
+ t.integer :verification_sends_count, null: false, default: 0
187
+ t.datetime :verified_at
188
+ t.references :decided_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
189
+ t.datetime :decided_at
190
+ t.datetime :expires_at
191
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
192
+
193
+ t.timestamps
194
+ end
195
+
196
+ add_index :organizations_join_requests, :status
197
+
198
+ # One OPEN request per (organization, user). Decided requests keep their
199
+ # history without blocking a fresh request.
200
+ # (One line on purpose — see the single_owner index note above.)
201
+ if adapter.include?("postgresql") || adapter.include?("sqlite")
202
+ reversible do |dir|
203
+ dir.up do
204
+ execute "CREATE UNIQUE INDEX index_org_join_requests_pending_unique " \
205
+ "ON organizations_join_requests (organization_id, user_id) WHERE status = 'pending'"
206
+ end
207
+ end
208
+ elsif adapter.include?("mysql")
209
+ # MySQL doesn't support partial indexes: use a generated column that is
210
+ # only non-NULL for pending requests (NULLs never collide in unique indexes).
211
+ reversible do |dir|
212
+ dir.up do
213
+ execute <<-SQL
214
+ ALTER TABLE organizations_join_requests
215
+ ADD COLUMN pending_user_key VARCHAR(255)
216
+ GENERATED ALWAYS AS (
217
+ CASE
218
+ WHEN status = 'pending' THEN CONCAT(organization_id, '-', user_id)
219
+ ELSE NULL
220
+ END
221
+ ) STORED
222
+ SQL
223
+ end
224
+ end
225
+
226
+ add_index :organizations_join_requests, :pending_user_key, unique: true, name: "index_org_join_requests_pending_unique"
227
+ else
228
+ add_index :organizations_join_requests, [ :organization_id, :user_id ], name: "index_org_join_requests_on_org_and_user"
84
229
  end
85
230
  end
86
231
 
@@ -88,7 +233,7 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
88
233
 
89
234
  def primary_and_foreign_key_types
90
235
  config = Rails.configuration.generators
91
- setting = config.options[config.orm][:primary_key_type]
236
+ setting = config.options[config.orm][ :primary_key_type ]
92
237
  primary_key_type = setting || :primary_key
93
238
  foreign_key_type = setting || :bigint
94
239
  [primary_key_type, foreign_key_type]
@@ -55,6 +55,59 @@ Organizations.configure do |config|
55
55
  # Default: :member
56
56
  # config.default_invitation_role = :member
57
57
 
58
+ # ============================================================================
59
+ # VERIFIED JOINING (join requests, domains, join codes, allowlists)
60
+ # ============================================================================
61
+ #
62
+ # Users can join organizations by proving they belong:
63
+ # - request-to-join: user.request_to_join!(org) → org.approve_join_request!(...)
64
+ # - email domain: org.add_domain!("corp.com") + emailed 6-digit code
65
+ # - join code (PIN): org.generate_join_code!(...) + JoinCode.redeem(code, user:)
66
+ # - allowlist/roster: org.import_allowlist!(emails) + emailed 6-digit code
67
+ #
68
+ # The gem ships models and APIs only (bring your own UI + rate limiting on
69
+ # your join endpoints). See the README's "Verified joining" section.
70
+
71
+ # Mailer that sends verification codes. Custom mailers must implement
72
+ # code_email(join_request, code).
73
+ # Default: "Organizations::VerificationMailer"
74
+ # config.verification_mailer = "Organizations::VerificationMailer"
75
+
76
+ # How long an emailed code stays valid (codes always expire).
77
+ # Default: 15.minutes
78
+ # config.verification_code_ttl = 15.minutes
79
+
80
+ # Wrong-attempt cap per challenge, and send throttles per join request.
81
+ # Defaults: 5 attempts, 60.seconds between sends, 5 sends max
82
+ # config.verification_max_attempts = 5
83
+ # config.verification_resend_interval = 60.seconds
84
+ # config.verification_max_sends = 5
85
+
86
+ # Email normalizer used for the "one proven email = one membership per org"
87
+ # invariant. Default collapses case, whitespace, and +tags (plus-addressing).
88
+ # config.verification_email_normalizer = ->(email) { ... }
89
+
90
+ # Allow org.join_with_account_email!(user) to trust a CONFIRMED account
91
+ # email (e.g. Devise :confirmable) as inbox proof — no emailed code needed
92
+ # when the account email's domain is enrolled.
93
+ # Default: true
94
+ # config.trust_confirmed_account_email = true
95
+
96
+ # How long join requests stay open before reading as expired (nil = never).
97
+ # Default: 30.days
98
+ # config.join_request_expiry = 30.days
99
+
100
+ # Custom join-code generator (default: 8 chars, ambiguity-free alphabet).
101
+ # config.join_code_generator = -> { SecureRandom.alphanumeric(10).upcase }
102
+
103
+ # Lifecycle callbacks:
104
+ # config.on_join_request_created { |ctx| ... } # ctx: organization, user, join_request
105
+ # config.on_join_request_approved { |ctx| ... } # + membership, decided_by (nil = auto)
106
+ # config.on_join_request_rejected { |ctx| ... } # + decided_by
107
+ #
108
+ # NOTE: like all after-callbacks these are error-isolated — enforce hard
109
+ # caps (member limits etc.) BEFORE calling approve/redeem, in your own code.
110
+
58
111
  # ============================================================================
59
112
  # ROLES & PERMISSIONS
60
113
  # ============================================================================
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Upgrades organizations <= 0.4.x to 0.5.0 ("Verified Joining").
4
+ # Additive only: no existing columns/rows are touched.
5
+ class AddVerifiedJoiningToOrganizations < ActiveRecord::Migration<%= migration_version %>
6
+ def change
7
+ # ⚠️ If your user model is NOT `User` (config.user_class), adjust every
8
+ # `to_table: :users` below (and `foreign_key: true` on user references)
9
+ # to your user table before migrating.
10
+ _primary_key_type, foreign_key_type = primary_and_foreign_key_types
11
+ adapter = connection.adapter_name.downcase
12
+
13
+ # === Membership provenance ===
14
+
15
+ add_column :organizations_memberships, :joined_via, :string
16
+ add_column :organizations_memberships, :verified_email, :string
17
+ add_column :organizations_memberships, :verified_email_normalized, :string
18
+ add_column :organizations_memberships, :verified_at, :datetime
19
+
20
+ # One proven email address => one membership per organization.
21
+ # NULLs never collide in unique indexes on every supported adapter, so
22
+ # existing (unverified) memberships are unconstrained.
23
+ add_index :organizations_memberships, [ :organization_id, :verified_email_normalized ],
24
+ unique: true, name: "index_org_memberships_verified_email_unique"
25
+
26
+ # === Invitation metadata parity ===
27
+
28
+ add_column :organizations_invitations, :metadata, json_column_type,
29
+ null: json_column_null, default: json_column_default
30
+ add_column :organizations_invitations, :membership_metadata, json_column_type,
31
+ null: json_column_null, default: json_column_default
32
+
33
+ # === Email domains (exact-match joining) ===
34
+
35
+ create_table :organizations_domains, id: primary_key_type_setting do |t|
36
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
37
+ t.string :domain, null: false
38
+ t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
39
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
40
+
41
+ t.timestamps
42
+ end
43
+
44
+ add_index :organizations_domains, [ :organization_id, :domain ], unique: true
45
+ add_index :organizations_domains, :domain
46
+
47
+ # === Join codes ("PINs") ===
48
+
49
+ create_table :organizations_join_codes, id: primary_key_type_setting do |t|
50
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
51
+ t.string :code, null: false
52
+ t.string :label
53
+ t.boolean :requires_verified_domain_email, null: false, default: false
54
+ t.boolean :auto_approve, null: false, default: true
55
+ t.datetime :expires_at
56
+ t.integer :max_uses
57
+ t.integer :uses_count, null: false, default: 0
58
+ t.datetime :revoked_at
59
+ t.references :created_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
60
+ t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
61
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
62
+
63
+ t.timestamps
64
+ end
65
+
66
+ add_index :organizations_join_codes, :code, unique: true
67
+
68
+ # === Allowlist / roster entries ===
69
+
70
+ create_table :organizations_allowlist_entries, id: primary_key_type_setting do |t|
71
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
72
+ t.string :email, null: false
73
+ t.string :email_normalized, null: false
74
+ t.string :source
75
+ t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
76
+ t.datetime :claimed_at
77
+ t.references :claimed_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
78
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
79
+
80
+ t.timestamps
81
+ end
82
+
83
+ add_index :organizations_allowlist_entries, [ :organization_id, :email_normalized ], unique: true
84
+
85
+ # === Join requests ===
86
+
87
+ create_table :organizations_join_requests, id: primary_key_type_setting do |t|
88
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
89
+ t.references :user, null: false, type: foreign_key_type, foreign_key: true
90
+ t.string :status, null: false, default: "pending"
91
+ t.string :joined_via
92
+ t.references :join_code, null: true, type: foreign_key_type, foreign_key: { to_table: :organizations_join_codes }
93
+ t.string :message
94
+ t.string :verification_email
95
+ t.string :verification_email_normalized
96
+ t.string :verification_code_digest
97
+ t.datetime :verification_sent_at
98
+ t.datetime :verification_expires_at
99
+ t.integer :verification_attempts, null: false, default: 0
100
+ t.integer :verification_sends_count, null: false, default: 0
101
+ t.datetime :verified_at
102
+ t.references :decided_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
103
+ t.datetime :decided_at
104
+ t.datetime :expires_at
105
+ t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
106
+
107
+ t.timestamps
108
+ end
109
+
110
+ add_index :organizations_join_requests, :status
111
+
112
+ # One OPEN request per (organization, user); decided history never blocks
113
+ if adapter.include?("postgresql") || adapter.include?("sqlite")
114
+ reversible do |dir|
115
+ dir.up do
116
+ # ONE line on purpose: the SQLite schema dumper only recovers a
117
+ # partial index's WHERE clause from single-line index SQL — a
118
+ # multi-line statement dumps as a FULL unique index, and databases
119
+ # provisioned from schema.rb (db:schema:load, test DBs) silently
120
+ # lose the partial-index invariant.
121
+ execute "CREATE UNIQUE INDEX index_org_join_requests_pending_unique " \
122
+ "ON organizations_join_requests (organization_id, user_id) WHERE status = 'pending'"
123
+ end
124
+ dir.down do
125
+ execute "DROP INDEX IF EXISTS index_org_join_requests_pending_unique"
126
+ end
127
+ end
128
+ elsif adapter.include?("mysql")
129
+ reversible do |dir|
130
+ dir.up do
131
+ execute <<-SQL
132
+ ALTER TABLE organizations_join_requests
133
+ ADD COLUMN pending_user_key VARCHAR(255)
134
+ GENERATED ALWAYS AS (
135
+ CASE
136
+ WHEN status = 'pending' THEN CONCAT(organization_id, '-', user_id)
137
+ ELSE NULL
138
+ END
139
+ ) STORED
140
+ SQL
141
+ end
142
+ dir.down do
143
+ execute "ALTER TABLE organizations_join_requests DROP COLUMN pending_user_key"
144
+ end
145
+ end
146
+ add_index :organizations_join_requests, :pending_user_key, unique: true, name: "index_org_join_requests_pending_unique"
147
+ else
148
+ add_index :organizations_join_requests, [ :organization_id, :user_id ], name: "index_org_join_requests_on_org_and_user"
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def primary_and_foreign_key_types
155
+ config = Rails.configuration.generators
156
+ setting = config.options[config.orm][ :primary_key_type ]
157
+ primary_key_type = setting || :primary_key
158
+ foreign_key_type = setting || :bigint
159
+ [primary_key_type, foreign_key_type]
160
+ end
161
+
162
+ def primary_key_type_setting
163
+ primary_and_foreign_key_types.first
164
+ end
165
+
166
+ def json_column_type
167
+ return :jsonb if connection.adapter_name.downcase.include?('postgresql')
168
+ :json
169
+ end
170
+
171
+ # MySQL 8+ doesn't allow default values on JSON columns.
172
+ def json_column_default
173
+ return nil if connection.adapter_name.downcase.include?('mysql')
174
+ {}
175
+ end
176
+
177
+ def json_column_null
178
+ connection.adapter_name.downcase.include?('mysql')
179
+ end
180
+ end
@@ -0,0 +1,50 @@
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
+ # Upgrades an existing organizations install (<= 0.4.x) to 0.5.0
9
+ # ("Verified Joining"): new tables for domains, join codes, allowlist
10
+ # entries and join requests, plus provenance columns on memberships and
11
+ # metadata columns on invitations.
12
+ #
13
+ # Fresh installs don't need this — `rails g organizations:install`
14
+ # already includes everything.
15
+ #
16
+ # rails g organizations:upgrade
17
+ # rails db:migrate
18
+ #
19
+ class UpgradeGenerator < Rails::Generators::Base
20
+ include ActiveRecord::Generators::Migration
21
+
22
+ source_root File.expand_path("templates", __dir__)
23
+ desc "Add the verified-joining tables/columns (organizations 0.5.0) to an existing install"
24
+
25
+ def self.next_migration_number(dir)
26
+ ActiveRecord::Generators::Base.next_migration_number(dir)
27
+ end
28
+
29
+ def create_migration_file
30
+ migration_template "add_verified_joining_to_organizations.rb.erb",
31
+ File.join(db_migrate_path, "add_verified_joining_to_organizations.rb"),
32
+ migration_version: migration_version
33
+ end
34
+
35
+ def display_post_upgrade_message
36
+ say "\n✅ organizations verified-joining migration created.", :green
37
+ say "\nNext steps:"
38
+ say " 1. Run 'rails db:migrate'."
39
+ say " 2. See the README's \"Verified joining\" section for the new API"
40
+ say " (domains, join codes, allowlists, join requests)."
41
+ end
42
+
43
+ private
44
+
45
+ def migration_version
46
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
47
+ end
48
+ end
49
+ end
50
+ end