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,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Controller for managing organizations.
|
|
5
|
+
# Provides CRUD operations for organizations the user owns/manages.
|
|
6
|
+
#
|
|
7
|
+
class OrganizationsController < ApplicationController
|
|
8
|
+
before_action :set_organization, only: [:show, :edit, :update, :destroy]
|
|
9
|
+
before_action :authorize_manage_settings!, only: [:edit, :update]
|
|
10
|
+
before_action :authorize_delete_organization!, only: [:destroy]
|
|
11
|
+
|
|
12
|
+
# GET /organizations
|
|
13
|
+
# List all organizations the user belongs to
|
|
14
|
+
def index
|
|
15
|
+
# Optimized query: preload memberships and use counter cache or subquery for counts
|
|
16
|
+
@memberships = current_user.memberships.includes(:organization)
|
|
17
|
+
|
|
18
|
+
respond_to do |format|
|
|
19
|
+
format.html { @organizations = @memberships.map(&:organization) }
|
|
20
|
+
format.json { render json: organizations_json_optimized(@memberships) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# GET /organizations/:id
|
|
25
|
+
# Show organization details
|
|
26
|
+
def show
|
|
27
|
+
respond_to do |format|
|
|
28
|
+
format.html
|
|
29
|
+
format.json { render json: organization_json(@organization) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# GET /organizations/new
|
|
34
|
+
# Form to create a new organization
|
|
35
|
+
def new
|
|
36
|
+
@organization = Organization.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# POST /organizations
|
|
40
|
+
# Create a new organization
|
|
41
|
+
def create
|
|
42
|
+
begin
|
|
43
|
+
@organization = current_user.create_organization!(organization_params[:name])
|
|
44
|
+
|
|
45
|
+
# Switch to the new organization
|
|
46
|
+
switch_to_organization!(@organization)
|
|
47
|
+
|
|
48
|
+
respond_to do |format|
|
|
49
|
+
format.html { redirect_to organization_path(@organization), notice: "Organization created successfully." }
|
|
50
|
+
format.json { render json: organization_json(@organization), status: :created }
|
|
51
|
+
end
|
|
52
|
+
rescue Organizations::Models::Concerns::HasOrganizations::OrganizationLimitReached => e
|
|
53
|
+
respond_to do |format|
|
|
54
|
+
format.html do
|
|
55
|
+
@organization = Organization.new(organization_params)
|
|
56
|
+
flash.now[:alert] = e.message
|
|
57
|
+
render :new, status: :unprocessable_entity
|
|
58
|
+
end
|
|
59
|
+
format.json { render json: { error: e.message }, status: :unprocessable_entity }
|
|
60
|
+
end
|
|
61
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
62
|
+
respond_to do |format|
|
|
63
|
+
format.html do
|
|
64
|
+
@organization = Organization.new(organization_params)
|
|
65
|
+
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
|
66
|
+
render :new, status: :unprocessable_entity
|
|
67
|
+
end
|
|
68
|
+
format.json { render json: { errors: e.record.errors }, status: :unprocessable_entity }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# GET /organizations/:id/edit
|
|
74
|
+
# Form to edit organization
|
|
75
|
+
def edit
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# PATCH/PUT /organizations/:id
|
|
79
|
+
# Update organization
|
|
80
|
+
def update
|
|
81
|
+
if @organization.update(organization_params)
|
|
82
|
+
respond_to do |format|
|
|
83
|
+
format.html { redirect_to organization_path(@organization), notice: "Organization updated successfully." }
|
|
84
|
+
format.json { render json: organization_json(@organization) }
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
respond_to do |format|
|
|
88
|
+
format.html { render :edit, status: :unprocessable_entity }
|
|
89
|
+
format.json { render json: { errors: @organization.errors }, status: :unprocessable_entity }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# DELETE /organizations/:id
|
|
95
|
+
# Delete organization (owner only)
|
|
96
|
+
def destroy
|
|
97
|
+
@organization.destroy!
|
|
98
|
+
|
|
99
|
+
# Clear session if this was the current organization
|
|
100
|
+
clear_organization_session! if current_organization&.id == @organization.id
|
|
101
|
+
|
|
102
|
+
respond_to do |format|
|
|
103
|
+
format.html { redirect_to organizations_path, notice: "Organization deleted successfully." }
|
|
104
|
+
format.json { head :no_content }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def set_organization
|
|
111
|
+
@organization = current_user.organizations.find(params[:id])
|
|
112
|
+
rescue ActiveRecord::RecordNotFound
|
|
113
|
+
respond_to do |format|
|
|
114
|
+
format.html { redirect_to organizations_path, alert: "Organization not found." }
|
|
115
|
+
format.json { render json: { error: "Organization not found" }, status: :not_found }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def organization_params
|
|
120
|
+
params.require(:organization).permit(:name)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def authorize_manage_settings!
|
|
124
|
+
role = current_user.role_in(@organization)
|
|
125
|
+
return if role && Roles.has_permission?(role, :manage_settings)
|
|
126
|
+
|
|
127
|
+
handle_unauthorized(permission: :manage_settings)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def authorize_delete_organization!
|
|
131
|
+
role = current_user.role_in(@organization)
|
|
132
|
+
return if role && Roles.has_permission?(role, :delete_organization)
|
|
133
|
+
|
|
134
|
+
handle_unauthorized(permission: :delete_organization)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# JSON serialization helpers
|
|
138
|
+
|
|
139
|
+
# Optimized: uses preloaded memberships to avoid N+1
|
|
140
|
+
def organizations_json_optimized(memberships)
|
|
141
|
+
# Batch load member counts for all orgs in one query
|
|
142
|
+
org_ids = memberships.map { |m| m.organization_id }
|
|
143
|
+
counts = Organization.where(id: org_ids)
|
|
144
|
+
.joins(:memberships)
|
|
145
|
+
.group(:id)
|
|
146
|
+
.count("memberships.id")
|
|
147
|
+
|
|
148
|
+
memberships.map do |membership|
|
|
149
|
+
org = membership.organization
|
|
150
|
+
{
|
|
151
|
+
id: org.id,
|
|
152
|
+
name: org.name,
|
|
153
|
+
slug: org.slug,
|
|
154
|
+
member_count: counts[org.id] || 0,
|
|
155
|
+
role: membership.role,
|
|
156
|
+
created_at: org.created_at,
|
|
157
|
+
updated_at: org.updated_at
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def organizations_json(organizations)
|
|
163
|
+
organizations.map { |org| organization_json(org) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def organization_json(org)
|
|
167
|
+
membership = current_user.memberships.find_by(organization_id: org.id)
|
|
168
|
+
{
|
|
169
|
+
id: org.id,
|
|
170
|
+
name: org.name,
|
|
171
|
+
slug: org.slug,
|
|
172
|
+
member_count: org.member_count,
|
|
173
|
+
role: membership&.role,
|
|
174
|
+
created_at: org.created_at,
|
|
175
|
+
updated_at: org.updated_at
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Controller for switching between organizations.
|
|
5
|
+
# Handles the POST /organizations/switch/:id route.
|
|
6
|
+
#
|
|
7
|
+
# @example Using the switch route
|
|
8
|
+
# POST /organizations/switch/123
|
|
9
|
+
# # Redirects to root_path with new current_organization set
|
|
10
|
+
#
|
|
11
|
+
class SwitchController < ApplicationController
|
|
12
|
+
# Switch to a different organization
|
|
13
|
+
# POST /organizations/switch/:id
|
|
14
|
+
def create
|
|
15
|
+
org = current_user.organizations.find_by(id: params[:id])
|
|
16
|
+
|
|
17
|
+
if org
|
|
18
|
+
switch_to_organization!(org)
|
|
19
|
+
|
|
20
|
+
respond_to do |format|
|
|
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, slug: org.slug } } }
|
|
23
|
+
end
|
|
24
|
+
else
|
|
25
|
+
respond_to do |format|
|
|
26
|
+
format.html { redirect_back fallback_location: main_app.root_path, alert: "Organization not found or you're not a member" }
|
|
27
|
+
format.json { render json: { error: "Organization not found" }, status: :not_found }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def after_switch_path
|
|
35
|
+
main_app.respond_to?(:root_path) ? main_app.root_path : "/"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Mailer for sending organization invitation emails.
|
|
5
|
+
# Can be customized via Organizations.configuration.invitation_mailer
|
|
6
|
+
#
|
|
7
|
+
# If the goodmail gem is installed, it will automatically use goodmail
|
|
8
|
+
# for beautiful transactional emails.
|
|
9
|
+
#
|
|
10
|
+
# @example Sending an invitation email
|
|
11
|
+
# InvitationMailer.invitation_email(invitation).deliver_later
|
|
12
|
+
#
|
|
13
|
+
class InvitationMailer < ActionMailer::Base
|
|
14
|
+
default from: -> { default_from_address }
|
|
15
|
+
|
|
16
|
+
# Invitation email
|
|
17
|
+
# @param invitation [Organizations::Invitation] The invitation to send
|
|
18
|
+
# @return [Mail::Message]
|
|
19
|
+
def invitation_email(invitation)
|
|
20
|
+
@invitation = invitation
|
|
21
|
+
@organization = invitation.organization
|
|
22
|
+
@inviter = invitation.invited_by
|
|
23
|
+
@accept_url = invitation_accept_url(invitation)
|
|
24
|
+
|
|
25
|
+
mail(
|
|
26
|
+
to: invitation.email,
|
|
27
|
+
subject: "#{inviter_name} invited you to join #{@organization.name}"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def inviter_name
|
|
34
|
+
return "The team" unless @inviter
|
|
35
|
+
|
|
36
|
+
if @inviter.respond_to?(:name) && @inviter.name.present?
|
|
37
|
+
@inviter.name
|
|
38
|
+
else
|
|
39
|
+
@inviter.email
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def invitation_accept_url(invitation)
|
|
44
|
+
if defined?(Rails) && Rails.application&.routes
|
|
45
|
+
# Try to use the engine routes
|
|
46
|
+
begin
|
|
47
|
+
Organizations::Engine.routes.url_helpers.invitation_url(
|
|
48
|
+
invitation.token,
|
|
49
|
+
host: default_host
|
|
50
|
+
)
|
|
51
|
+
rescue StandardError
|
|
52
|
+
# Fallback to basic URL construction
|
|
53
|
+
"#{default_host}/invitations/#{invitation.token}"
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
"/invitations/#{invitation.token}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def default_from_address
|
|
61
|
+
if defined?(Rails) && Rails.application&.config&.action_mailer&.default_options
|
|
62
|
+
Rails.application.config.action_mailer.default_options[:from] || "noreply@example.com"
|
|
63
|
+
else
|
|
64
|
+
"noreply@example.com"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def default_host
|
|
69
|
+
if defined?(Rails) && Rails.application&.config&.action_mailer&.default_url_options
|
|
70
|
+
options = Rails.application.config.action_mailer.default_url_options
|
|
71
|
+
protocol = options[:protocol] || "https"
|
|
72
|
+
host = options[:host] || "localhost"
|
|
73
|
+
port = options[:port]
|
|
74
|
+
|
|
75
|
+
if port && port != 80 && port != 443
|
|
76
|
+
"#{protocol}://#{host}:#{port}"
|
|
77
|
+
else
|
|
78
|
+
"#{protocol}://#{host}"
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
"http://localhost:3000"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>You're invited to join <%= @organization.name %></title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
10
|
+
line-height: 1.6;
|
|
11
|
+
color: #333;
|
|
12
|
+
max-width: 600px;
|
|
13
|
+
margin: 0 auto;
|
|
14
|
+
padding: 20px;
|
|
15
|
+
}
|
|
16
|
+
.header {
|
|
17
|
+
text-align: center;
|
|
18
|
+
padding: 20px 0;
|
|
19
|
+
border-bottom: 1px solid #eee;
|
|
20
|
+
}
|
|
21
|
+
.content {
|
|
22
|
+
padding: 30px 0;
|
|
23
|
+
}
|
|
24
|
+
.button {
|
|
25
|
+
display: inline-block;
|
|
26
|
+
background-color: #4F46E5;
|
|
27
|
+
color: #ffffff !important;
|
|
28
|
+
text-decoration: none;
|
|
29
|
+
padding: 12px 24px;
|
|
30
|
+
border-radius: 6px;
|
|
31
|
+
font-weight: 600;
|
|
32
|
+
margin: 20px 0;
|
|
33
|
+
}
|
|
34
|
+
.button:hover {
|
|
35
|
+
background-color: #4338CA;
|
|
36
|
+
}
|
|
37
|
+
.footer {
|
|
38
|
+
padding-top: 20px;
|
|
39
|
+
border-top: 1px solid #eee;
|
|
40
|
+
font-size: 14px;
|
|
41
|
+
color: #666;
|
|
42
|
+
}
|
|
43
|
+
.role-badge {
|
|
44
|
+
display: inline-block;
|
|
45
|
+
background-color: #E0E7FF;
|
|
46
|
+
color: #4338CA;
|
|
47
|
+
padding: 4px 12px;
|
|
48
|
+
border-radius: 12px;
|
|
49
|
+
font-size: 14px;
|
|
50
|
+
font-weight: 500;
|
|
51
|
+
}
|
|
52
|
+
</style>
|
|
53
|
+
</head>
|
|
54
|
+
<body>
|
|
55
|
+
<div class="header">
|
|
56
|
+
<h1>You're invited!</h1>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="content">
|
|
60
|
+
<p>Hi there,</p>
|
|
61
|
+
|
|
62
|
+
<p>
|
|
63
|
+
<% if @inviter %>
|
|
64
|
+
<strong><%= @inviter.respond_to?(:name) && @inviter.name.present? ? @inviter.name : @inviter.email %></strong>
|
|
65
|
+
has invited you to join <strong><%= @organization.name %></strong>.
|
|
66
|
+
<% else %>
|
|
67
|
+
You've been invited to join <strong><%= @organization.name %></strong>.
|
|
68
|
+
<% end %>
|
|
69
|
+
</p>
|
|
70
|
+
|
|
71
|
+
<p>
|
|
72
|
+
You'll be joining as:
|
|
73
|
+
<span class="role-badge"><%= @invitation.role.to_s.capitalize %></span>
|
|
74
|
+
</p>
|
|
75
|
+
|
|
76
|
+
<p style="text-align: center;">
|
|
77
|
+
<a href="<%= @accept_url %>" class="button">Accept Invitation</a>
|
|
78
|
+
</p>
|
|
79
|
+
|
|
80
|
+
<p>
|
|
81
|
+
Or copy and paste this link into your browser:<br>
|
|
82
|
+
<a href="<%= @accept_url %>"><%= @accept_url %></a>
|
|
83
|
+
</p>
|
|
84
|
+
|
|
85
|
+
<% if @invitation.expires_at %>
|
|
86
|
+
<p style="color: #666; font-size: 14px;">
|
|
87
|
+
This invitation will expire on <%= @invitation.expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>.
|
|
88
|
+
</p>
|
|
89
|
+
<% end %>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div class="footer">
|
|
93
|
+
<p>
|
|
94
|
+
If you weren't expecting this invitation, you can safely ignore this email.
|
|
95
|
+
</p>
|
|
96
|
+
</div>
|
|
97
|
+
</body>
|
|
98
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
You're invited to join <%= @organization.name %>!
|
|
2
|
+
|
|
3
|
+
Hi there,
|
|
4
|
+
|
|
5
|
+
<% if @inviter %><%= @inviter.respond_to?(:name) && @inviter.name.present? ? @inviter.name : @inviter.email %> has invited you to join <%= @organization.name %>.<% else %>You've been invited to join <%= @organization.name %>.<% end %>
|
|
6
|
+
|
|
7
|
+
You'll be joining as: <%= @invitation.role.to_s.capitalize %>
|
|
8
|
+
|
|
9
|
+
Accept your invitation here:
|
|
10
|
+
<%= @accept_url %>
|
|
11
|
+
|
|
12
|
+
<% if @invitation.expires_at %>
|
|
13
|
+
This invitation will expire on <%= @invitation.expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>.
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
If you weren't expecting this invitation, you can safely ignore this email.
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Organizations::Engine.routes.draw do
|
|
4
|
+
# Organization switching
|
|
5
|
+
# POST /organizations/switch/:id
|
|
6
|
+
post "organizations/switch/:id", to: "switch#create", as: :switch_organization
|
|
7
|
+
|
|
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
|
+
|
|
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
|
+
end
|
|
19
|
+
|
|
20
|
+
# Invitation management (scoped to current_organization)
|
|
21
|
+
# These are flat routes - the organization is determined by session, not URL
|
|
22
|
+
# NOTE: Must come BEFORE token-based routes so /invitations/new doesn't match /:token
|
|
23
|
+
resources :invitations, only: [:index, :new, :create, :destroy], as: :organization_invitations do
|
|
24
|
+
member do
|
|
25
|
+
post :resend
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Invitation acceptance (public routes with token)
|
|
30
|
+
# GET /invitations/:token → View invitation details
|
|
31
|
+
# POST /invitations/:token/accept → Accept the invitation
|
|
32
|
+
# NOTE: These must come AFTER resourceful routes to avoid matching "new" as a token
|
|
33
|
+
get "invitations/:token", to: "invitations#show", as: :invitation
|
|
34
|
+
post "invitations/:token/accept", to: "invitations#accept", as: :accept_invitation
|
|
35
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
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
|