pg_multitenant_schemas 0.2.2 → 0.3.6

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.agent.md +54 -0
  3. data/.rubocop.yml +7 -1
  4. data/.ruby-version +1 -1
  5. data/CHANGELOG.md +58 -2
  6. data/DELIVERY_SUMMARY.md +318 -0
  7. data/GITHUB_ACTIONS_QUICK_START.md +110 -0
  8. data/GITHUB_ACTIONS_SETUP.md +313 -0
  9. data/GITHUB_ACTIONS_SUMMARY.md +231 -0
  10. data/LICENSE.txt +1 -1
  11. data/LOCAL_TESTING_SUMMARY.md +30 -13
  12. data/README.md +4 -4
  13. data/READY_TO_RELEASE.md +308 -0
  14. data/RELEASE_NOTES.md +194 -0
  15. data/RELEASE_STEPS.md +122 -0
  16. data/WORKFLOW_FIXES.md +188 -0
  17. data/docs/index.html +929 -0
  18. data/docs/testing_rails_tasks.md +128 -0
  19. data/lib/pg_multitenant_schemas/configuration.rb +7 -1
  20. data/lib/pg_multitenant_schemas/context.rb +92 -3
  21. data/lib/pg_multitenant_schemas/migration_schema_operations.rb +99 -5
  22. data/lib/pg_multitenant_schemas/migrator.rb +66 -9
  23. data/lib/pg_multitenant_schemas/rails/controller_concern.rb +81 -6
  24. data/lib/pg_multitenant_schemas/rails/generators/install_generator.rb +27 -0
  25. data/lib/pg_multitenant_schemas/rails/generators/tenant_migration_generator.rb +30 -0
  26. data/lib/pg_multitenant_schemas/rails/model_concern.rb +3 -3
  27. data/lib/pg_multitenant_schemas/rails/railtie.rb +31 -1
  28. data/lib/pg_multitenant_schemas/schema_switcher.rb +107 -7
  29. data/lib/pg_multitenant_schemas/tasks/tenant_tasks.rake +0 -3
  30. data/lib/pg_multitenant_schemas/tenant_resolver.rb +3 -3
  31. data/lib/pg_multitenant_schemas/ui/app/assets/stylesheets/pg_multitenant_schemas/ui/application.css +94 -0
  32. data/lib/pg_multitenant_schemas/ui/app/controllers/pg_multitenant_schemas/ui/tenants_controller.rb +130 -0
  33. data/lib/pg_multitenant_schemas/ui/app/views/layouts/pg_multitenant_schemas/ui/application.html.erb +29 -0
  34. data/lib/pg_multitenant_schemas/ui/app/views/pg_multitenant_schemas/ui/tenants/index.html.erb +85 -0
  35. data/lib/pg_multitenant_schemas/ui/app/views/pg_multitenant_schemas/ui/tenants/migration_status.html.erb +45 -0
  36. data/lib/pg_multitenant_schemas/ui/app/views/pg_multitenant_schemas/ui/tenants/new.html.erb +50 -0
  37. data/lib/pg_multitenant_schemas/ui/config/routes.rb +20 -0
  38. data/lib/pg_multitenant_schemas/ui/engine.rb +49 -0
  39. data/lib/pg_multitenant_schemas/ui.rb +9 -0
  40. data/lib/pg_multitenant_schemas/version.rb +1 -1
  41. data/lib/pg_multitenant_schemas.rb +155 -29
  42. data/pg_multitenant_schemas.gemspec +18 -12
  43. data/pre-release-check.sh +46 -0
  44. metadata +44 -17
  45. data/lib/pg_multitenant_schemas/tasks/pg_multitenant_schemas.rake +0 -65
@@ -62,18 +62,18 @@ module PgMultitenantSchemas
62
62
 
63
63
  # Log schema operations
64
64
  def log_schema_operation(message)
65
- Rails.logger.info "PgMultitenantSchemas: #{message}"
65
+ ::Rails.logger.info "PgMultitenantSchemas: #{message}"
66
66
  end
67
67
 
68
68
  # Handle schema operation errors
69
69
  def handle_schema_error(error, operation)
70
- Rails.logger.error "PgMultitenantSchemas: Failed to #{operation} schema '#{subdomain}': #{error.message}"
70
+ ::Rails.logger.error "PgMultitenantSchemas: Failed to #{operation} schema '#{subdomain}': #{error.message}"
71
71
  raise error unless development_fallback_enabled?
72
72
  end
73
73
 
74
74
  # Check if development fallback is enabled
75
75
  def development_fallback_enabled?
76
- PgMultitenantSchemas.configuration.development_fallback && Rails.env.development?
76
+ PgMultitenantSchemas.configuration.development_fallback && ::Rails.env.development?
77
77
  end
78
78
 
79
79
  class_methods do
@@ -12,9 +12,39 @@ module PgMultitenantSchemas
12
12
  end
13
13
  end
14
14
 
15
+ initializer "pg_multitenant_schemas.middleware" do |app|
16
+ # Add cookies middleware for API-only apps in development
17
+ # Needed for cookie-based schema switching between dashboard and main app
18
+ if ::Rails.env.development?
19
+ # Insert cookies middleware at the beginning
20
+ app.config.middleware.insert_before 0, ActionDispatch::Cookies
21
+ # Session store for reading cookies properly
22
+ app.config.middleware.insert_before 1, ActionDispatch::Session::CookieStore,
23
+ key: "_pg_multitenant_session"
24
+ # Flash support for notices/alerts
25
+ app.config.middleware.insert_before 2, ActionDispatch::Flash
26
+ end
27
+ end
28
+
29
+ initializer "pg_multitenant_schemas.controller_integration" do
30
+ # Automatically extend ApplicationController with ControllerConcern
31
+ ActiveSupport.on_load(:action_controller) do
32
+ include PgMultitenantSchemas::Rails::ControllerConcern
33
+ end
34
+ end
35
+
36
+ initializer "pg_multitenant_schemas.ui.routes" do |app|
37
+ app.routes.append do
38
+ mount PgMultitenantSchemas::UI::Engine, at: "/pg_multitenant_schemas"
39
+ end
40
+ end
41
+
15
42
  # Add rake tasks
16
43
  rake_tasks do
17
- load File.expand_path("../tasks/pg_multitenant_schemas.rake", __dir__)
44
+ # Load all task files
45
+ Dir[File.expand_path("../tasks/*.rake", __dir__)].each do |task_file|
46
+ load task_file
47
+ end
18
48
  end
19
49
 
20
50
  # Add generators
@@ -1,16 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgMultitenantSchemas
4
- # Core schema switching functionality
4
+ # Low-level PostgreSQL schema switching and management
5
+ #
6
+ # The SchemaSwitcher class provides direct access to PostgreSQL schema operations.
7
+ # It handles both Rails and raw PG connections transparently.
8
+ #
9
+ # @example Switching schemas
10
+ # PgMultitenantSchemas::SchemaSwitcher.switch_schema('tenant_123')
11
+ #
12
+ # @example Creating a schema
13
+ # PgMultitenantSchemas::SchemaSwitcher.create_schema('new_tenant')
14
+ #
15
+ # @example Listing all schemas
16
+ # PgMultitenantSchemas::SchemaSwitcher.list_schemas
17
+ # #=> ["public", "tenant_123", "tenant_456"]
5
18
  class SchemaSwitcher
6
19
  class << self
7
20
  # Initialize connection management
21
+ #
22
+ # Called by the Railtie when Rails starts. No special initialization needed.
23
+ #
24
+ # @return [void]
8
25
  def initialize_connection
9
26
  # This is called by the Railtie when Rails starts
10
27
  # No special initialization needed as we use the configured connection class
11
28
  end
12
29
 
13
30
  # Get the database connection from the configured connection class
31
+ #
32
+ # @return [ActiveRecord::ConnectionAdapters::AbstractAdapter, PG::Connection]
33
+ # @raise [PgMultitenantSchemas::ConnectionError] if connection cannot be established
14
34
  def connection
15
35
  connection_class = PgMultitenantSchemas.configuration.connection_class
16
36
  if connection_class.is_a?(String)
@@ -22,7 +42,15 @@ module PgMultitenantSchemas
22
42
  raise ConnectionError, "Failed to get database connection: #{e.message}"
23
43
  end
24
44
 
25
- # Switches the search_path to the specified schema
45
+ # Switch the current PostgreSQL schema
46
+ #
47
+ # Sets the search_path to the specified schema using SET search_path.
48
+ #
49
+ # @param schema_name [String] The schema name to switch to
50
+ # @return [void]
51
+ # @raise [ArgumentError] if schema name is blank
52
+ # @example
53
+ # PgMultitenantSchemas::SchemaSwitcher.switch_schema('tenant_123')
26
54
  def switch_schema(schema_name)
27
55
  raise ArgumentError, "Schema name cannot be empty" if schema_name.nil? || schema_name.strip.empty?
28
56
 
@@ -31,13 +59,25 @@ module PgMultitenantSchemas
31
59
  execute_sql(conn, "SET search_path TO #{quoted_schema};")
32
60
  end
33
61
 
34
- # Reset to default schema
62
+ # Reset to the default PostgreSQL schema
63
+ #
64
+ # Sets search_path back to 'public'.
65
+ #
66
+ # @return [void]
35
67
  def reset_schema
36
68
  conn = connection
37
69
  execute_sql(conn, "SET search_path TO public;")
38
70
  end
39
71
 
40
- # Create a new schema
72
+ # Create a new PostgreSQL schema
73
+ #
74
+ # Uses CREATE SCHEMA IF NOT EXISTS for idempotency.
75
+ #
76
+ # @param schema_name [String] The schema name to create
77
+ # @return [void]
78
+ # @raise [ArgumentError] if schema name is blank
79
+ # @example
80
+ # PgMultitenantSchemas::SchemaSwitcher.create_schema('tenant_123')
41
81
  def create_schema(schema_name)
42
82
  raise ArgumentError, "Schema name cannot be empty" if schema_name.nil? || schema_name.strip.empty?
43
83
 
@@ -46,7 +86,16 @@ module PgMultitenantSchemas
46
86
  execute_sql(conn, "CREATE SCHEMA IF NOT EXISTS #{quoted_schema};")
47
87
  end
48
88
 
49
- # Drop a schema
89
+ # Drop a PostgreSQL schema
90
+ #
91
+ # @param schema_name [String] The schema name to drop
92
+ # @param cascade [Boolean] Whether to drop dependent objects (default: true)
93
+ # @return [void]
94
+ # @raise [ArgumentError] if schema name is blank
95
+ # @example
96
+ # PgMultitenantSchemas::SchemaSwitcher.drop_schema('old_tenant')
97
+ # @example With CASCADE option
98
+ # PgMultitenantSchemas::SchemaSwitcher.drop_schema('old_tenant', cascade: true)
50
99
  def drop_schema(schema_name, cascade: true)
51
100
  raise ArgumentError, "Schema name cannot be empty" if schema_name.nil? || schema_name.strip.empty?
52
101
 
@@ -56,7 +105,14 @@ module PgMultitenantSchemas
56
105
  execute_sql(conn, "DROP SCHEMA IF EXISTS #{quoted_schema} #{cascade_option};")
57
106
  end
58
107
 
59
- # Check if schema exists
108
+ # Check if a schema exists in the database
109
+ #
110
+ # @param schema_name [String] The schema name to check
111
+ # @return [Boolean] true if the schema exists, false otherwise
112
+ # @example
113
+ # if PgMultitenantSchemas::SchemaSwitcher.schema_exists?('tenant_123')
114
+ # # Schema exists
115
+ # end
60
116
  def schema_exists?(schema_name)
61
117
  return false if schema_name.nil? || schema_name.strip.empty?
62
118
 
@@ -73,15 +129,59 @@ module PgMultitenantSchemas
73
129
  [true, "t", "true"].include?(value)
74
130
  end
75
131
 
76
- # Get current schema
132
+ # Get the current PostgreSQL schema
133
+ #
134
+ # @return [String] The current schema name
135
+ # @example
136
+ # PgMultitenantSchemas::SchemaSwitcher.current_schema
137
+ # #=> "tenant_123"
77
138
  def current_schema
78
139
  conn = connection
79
140
  result = execute_sql(conn, "SELECT current_schema()")
80
141
  get_result_value(result, 0, 0)
81
142
  end
82
143
 
144
+ # List all schemas in the database
145
+ #
146
+ # @return [Array<String>] Array of schema names
147
+ # @example
148
+ # PgMultitenantSchemas::SchemaSwitcher.list_schemas
149
+ # #=> ["public", "tenant_123", "tenant_456"]
150
+ def list_schemas
151
+ conn = connection
152
+ result = execute_sql(conn, <<~SQL)
153
+ SELECT schema_name FROM information_schema.schemata#{" "}
154
+ ORDER BY schema_name
155
+ SQL
156
+
157
+ extract_schemas_from_result(result)
158
+ end
159
+
83
160
  private
84
161
 
162
+ def extract_schemas_from_result(result)
163
+ schemas = []
164
+
165
+ if result.respond_to?(:rows)
166
+ # Rails ActiveRecord::Result
167
+ result.rows.each { |row| schemas << row[0] }
168
+ elsif result.respond_to?(:each)
169
+ # Raw PG::Result
170
+ result.each { |row| schemas << row["schema_name"] }
171
+ else
172
+ # Fallback for other result types
173
+ extract_schemas_fallback(result, schemas)
174
+ end
175
+
176
+ schemas
177
+ end
178
+
179
+ def extract_schemas_fallback(result, schemas)
180
+ (0...result.ntuples).each do |i|
181
+ schemas << get_result_value(result, i, 0)
182
+ end
183
+ end
184
+
85
185
  # Execute SQL - handles both Rails connections and raw PG connections
86
186
  def execute_sql(conn, sql)
87
187
  if conn.respond_to?(:execute)
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Load individual task files
4
- Dir[File.join(File.dirname(__FILE__), "*.rake")].each { |file| load file unless file == __FILE__ }
5
-
6
3
  require_relative "../tenant_task_helpers"
7
4
 
8
5
  namespace :tenants do
@@ -63,7 +63,7 @@ module PgMultitenantSchemas
63
63
  tenant_model.find_by(subdomain: subdomain, status: "active")
64
64
  end
65
65
  rescue StandardError => e
66
- Rails.logger.error "PgMultitenantSchemas: Error finding tenant '#{subdomain}': #{e.message}"
66
+ ::Rails.logger.error "PgMultitenantSchemas: Error finding tenant '#{subdomain}': #{e.message}"
67
67
  nil
68
68
  end
69
69
 
@@ -80,8 +80,8 @@ module PgMultitenantSchemas
80
80
  tenant = resolve_tenant_from_request(request)
81
81
 
82
82
  # If no tenant found and development fallback is enabled
83
- if tenant.nil? && PgMultitenantSchemas.configuration.development_fallback && Rails.env.development?
84
- Rails.logger.info "PgMultitenantSchemas: No tenant found, using development fallback"
83
+ if tenant.nil? && PgMultitenantSchemas.configuration.development_fallback && ::Rails.env.development?
84
+ ::Rails.logger.info "PgMultitenantSchemas: No tenant found, using development fallback"
85
85
  return nil # This will cause switch to default schema
86
86
  end
87
87
 
@@ -0,0 +1,94 @@
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ body {
4
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
5
+ background: #f5f5f5;
6
+ color: #1a1a1a;
7
+ min-height: 100vh;
8
+ }
9
+
10
+ header {
11
+ background: #1e293b;
12
+ color: #fff;
13
+ padding: 0 1.5rem;
14
+ display: flex;
15
+ align-items: center;
16
+ gap: 1rem;
17
+ height: 56px;
18
+ box-shadow: 0 1px 4px rgba(0,0,0,.3);
19
+ }
20
+ header h1 { font-size: 1rem; font-weight: 600; letter-spacing: .03em; }
21
+ header span { font-size: .75rem; color: #94a3b8; }
22
+
23
+ main { max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
24
+
25
+ .flash { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1.25rem; font-size: .875rem; }
26
+ .flash.notice { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
27
+ .flash.alert { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
28
+
29
+ .card { background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
30
+ .card-header {
31
+ padding: 1rem 1.25rem;
32
+ border-bottom: 1px solid #e5e7eb;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ }
37
+ .card-header h2 { font-size: 1rem; font-weight: 600; }
38
+
39
+ table { width: 100%; border-collapse: collapse; font-size: .875rem; }
40
+ th, td { padding: .65rem 1.25rem; text-align: left; }
41
+ th { font-weight: 600; color: #6b7280; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
42
+ tr:not(:last-child) td { border-bottom: 1px solid #f3f4f6; }
43
+ tr:hover td { background: #fafafa; }
44
+
45
+ .badge {
46
+ display: inline-flex; align-items: center; gap: .3rem;
47
+ padding: .2rem .55rem; border-radius: 999px; font-size: .7rem; font-weight: 600;
48
+ }
49
+ .badge-green { background: #dcfce7; color: #166534; }
50
+ .badge-yellow { background: #fef9c3; color: #854d0e; }
51
+ .badge-red { background: #fee2e2; color: #991b1b; }
52
+ .badge-gray { background: #f3f4f6; color: #6b7280; }
53
+
54
+ .btn {
55
+ display: inline-flex; align-items: center; gap: .35rem;
56
+ padding: .45rem .9rem; border-radius: 6px; font-size: .8rem; font-weight: 500;
57
+ text-decoration: none; border: none; cursor: pointer; transition: filter .15s;
58
+ }
59
+ .btn:hover { filter: brightness(.92); }
60
+ .btn-primary { background: #3b82f6; color: #fff; }
61
+ .btn-green { background: #22c55e; color: #fff; }
62
+ .btn-yellow { background: #f59e0b; color: #fff; }
63
+ .btn-red { background: #ef4444; color: #fff; }
64
+ .btn-ghost { background: #f3f4f6; color: #374151; }
65
+ .btn-sm { padding: .3rem .65rem; font-size: .75rem; }
66
+
67
+ .actions { display: flex; gap: .4rem; flex-wrap: wrap; }
68
+ .toolbar { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
69
+
70
+ .schema-badge {
71
+ font-family: monospace; font-size: .78rem;
72
+ background: #eff6ff; color: #1d4ed8;
73
+ padding: .15rem .45rem; border-radius: 4px;
74
+ }
75
+
76
+ form.inline { display: inline; }
77
+
78
+ /* switch bar */
79
+ .switch-bar {
80
+ margin-bottom: 1.5rem;
81
+ background: #fff;
82
+ border: 1px solid #e5e7eb;
83
+ border-radius: 8px;
84
+ padding: .75rem 1.25rem;
85
+ display: flex; align-items: center; gap: .75rem; font-size: .875rem; flex-wrap: wrap;
86
+ }
87
+ .switch-bar label { font-weight: 500; color: #374151; }
88
+ .switch-bar select {
89
+ flex: 1; min-width: 160px; max-width: 280px;
90
+ border: 1px solid #d1d5db; border-radius: 6px;
91
+ padding: .35rem .6rem; font-size: .875rem;
92
+ }
93
+
94
+ .empty { padding: 2.5rem; text-align: center; color: #9ca3af; font-size: .9rem; }
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgMultitenantSchemas
4
+ module UI
5
+ # Controller backing the UI engine for listing and managing tenants and schemas.
6
+ class TenantsController < ActionController::Base
7
+ layout "pg_multitenant_schemas/ui/application"
8
+ skip_forgery_protection only: :destroy
9
+
10
+ before_action :load_tenant_model
11
+
12
+ def index
13
+ @tenants = tenant_model.all.order(:subdomain)
14
+ @schemas = load_available_schemas
15
+ @current_schema = resolve_selected_schema
16
+ end
17
+
18
+ def new
19
+ @tenant = tenant_model.new
20
+ end
21
+
22
+ def create
23
+ @tenant = tenant_model.new(tenant_params)
24
+
25
+ if @tenant.save
26
+ PgMultitenantSchemas::Migrator.setup_tenant(@tenant.subdomain, verbose: false)
27
+ redirect_to "/pg_multitenant_schemas/tenants",
28
+ notice: "Tenant '#{@tenant.subdomain}' created and schema provisioned."
29
+ else
30
+ render :new, status: :unprocessable_entity
31
+ end
32
+ rescue StandardError => e
33
+ @tenant&.destroy if @tenant&.persisted?
34
+ redirect_to "/pg_multitenant_schemas/tenants",
35
+ alert: "Failed to provision schema: #{e.message}"
36
+ end
37
+
38
+ def destroy
39
+ @tenant = tenant_model.find(params[:id])
40
+ schema = @tenant.subdomain
41
+
42
+ @tenant.destroy
43
+ PgMultitenantSchemas::SchemaSwitcher.drop_schema(schema)
44
+ redirect_to "/pg_multitenant_schemas/tenants",
45
+ notice: "Tenant '#{schema}' and its schema deleted."
46
+ rescue StandardError => e
47
+ redirect_to "/pg_multitenant_schemas/tenants",
48
+ alert: "Error deleting tenant: #{e.message}"
49
+ end
50
+
51
+ def migrate
52
+ @tenant = tenant_model.find(params[:id])
53
+ result = PgMultitenantSchemas::Migrator.migrate_tenant(@tenant.subdomain, verbose: false)
54
+ status = result[:status] == :success ? "notice" : "alert"
55
+ msg = result[:message] || "Migration completed."
56
+ redirect_to "/pg_multitenant_schemas/tenants", status => msg
57
+ rescue StandardError => e
58
+ redirect_to "/pg_multitenant_schemas/tenants", alert: "Migration failed: #{e.message}"
59
+ end
60
+
61
+ def migrate_all
62
+ results = PgMultitenantSchemas::Migrator.migrate_all(verbose: false)
63
+ failed = results.count { |r| r[:status] == :error }
64
+ if failed.zero?
65
+ redirect_to "/pg_multitenant_schemas/tenants",
66
+ notice: "All #{results.size} tenant schemas migrated successfully."
67
+ else
68
+ redirect_to "/pg_multitenant_schemas/tenants",
69
+ alert: "#{failed} of #{results.size} migrations failed."
70
+ end
71
+ end
72
+
73
+ def migration_status
74
+ @tenant = tenant_model.find(params[:id])
75
+ statuses = PgMultitenantSchemas::Migrator.migration_status(verbose: false)
76
+ @status = statuses.find { |s| s[:schema] == @tenant.subdomain } || {}
77
+ render :migration_status
78
+ end
79
+
80
+ def switch
81
+ schema = params[:schema].to_s.strip
82
+ return redirect_invalid_schema(schema) unless PgMultitenantSchemas::SchemaSwitcher.schema_exists?(schema)
83
+
84
+ PgMultitenantSchemas::Context.current_schema = schema
85
+ store_schema_in_session_and_cookies(schema)
86
+ redirect_to "/pg_multitenant_schemas/tenants",
87
+ notice: "Switched active context to schema '#{schema}'."
88
+ end
89
+
90
+ private
91
+
92
+ def load_available_schemas
93
+ PgMultitenantSchemas::SchemaSwitcher.list_schemas
94
+ rescue StandardError
95
+ []
96
+ end
97
+
98
+ def resolve_selected_schema
99
+ selected = (session[:pg_multitenant_selected_schema] || cookies[:pg_multitenant_selected_schema]).to_s.strip
100
+ return selected if selected.present? && load_available_schemas.include?(selected)
101
+
102
+ PgMultitenantSchemas::Context.current_schema
103
+ end
104
+
105
+ def store_schema_in_session_and_cookies(schema)
106
+ session[:pg_multitenant_selected_schema] = schema
107
+ cookies[:pg_multitenant_selected_schema] = {
108
+ value: schema,
109
+ expires: 24.hours.from_now,
110
+ path: "/",
111
+ same_site: :lax,
112
+ http_only: false
113
+ }
114
+ end
115
+
116
+ def redirect_invalid_schema(schema)
117
+ redirect_to "/pg_multitenant_schemas/tenants",
118
+ alert: "Schema '#{schema}' does not exist."
119
+ end
120
+
121
+ def load_tenant_model
122
+ @tenant_model_name = PgMultitenantSchemas.configuration.tenant_model_class
123
+ end
124
+
125
+ def tenant_model = @tenant_model_name.constantize
126
+
127
+ def tenant_params = params.require(:tenant).permit(:subdomain, :name, :status)
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Tenant Manager · PgMultitenantSchemas</title>
7
+ <%= csrf_meta_tags %>
8
+ <style>
9
+ <%= File.read(File.expand_path("../../../../assets/stylesheets/pg_multitenant_schemas/ui/application.css", __dir__)).html_safe %>
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <header>
14
+ <h1>🏢 Tenant Manager</h1>
15
+ <span>pg_multitenant_schemas</span>
16
+ </header>
17
+
18
+ <main>
19
+ <% if flash[:notice] %>
20
+ <div class="flash notice"><%= flash[:notice] %></div>
21
+ <% end %>
22
+ <% if flash[:alert] %>
23
+ <div class="flash alert"><%= flash[:alert] %></div>
24
+ <% end %>
25
+
26
+ <%= yield %>
27
+ </main>
28
+ </body>
29
+ </html>
@@ -0,0 +1,85 @@
1
+ <%# Switch active schema context %>
2
+ <div class="switch-bar">
3
+ <label>Active schema:</label>
4
+ <span class="schema-badge" id="current-schema-badge"><%= @current_schema %></span>
5
+
6
+ <%= form_with url: "/pg_multitenant_schemas/tenants/switch", method: :post, local: true do |f| %>
7
+ <%= f.select :schema,
8
+ options_for_select(@schemas, @current_schema),
9
+ { include_blank: false },
10
+ class: "",
11
+ id: "schema-select" %>
12
+ <%= f.submit "Switch →", class: "btn btn-ghost btn-sm" %>
13
+ <% end %>
14
+ </div>
15
+
16
+ <script>
17
+ // Update badge when selection changes
18
+ document.getElementById('schema-select')?.addEventListener('change', function(e) {
19
+ document.getElementById('current-schema-badge').textContent = e.target.value;
20
+ });
21
+ </script>
22
+
23
+ <%# Toolbar %>
24
+ <div class="toolbar" style="margin-bottom:1rem;">
25
+ <%= link_to "+ New Tenant", "/pg_multitenant_schemas/tenants/new", class: "btn btn-primary" %>
26
+
27
+ <%= form_with url: "/pg_multitenant_schemas/tenants/migrate_all", method: :post, local: true do |f| %>
28
+ <%= f.submit "⚡ Migrate All", class: "btn btn-yellow",
29
+ data: { confirm: "Run pending migrations on all tenant schemas?" } %>
30
+ <% end %>
31
+ </div>
32
+
33
+ <%# Tenant table %>
34
+ <div class="card">
35
+ <div class="card-header">
36
+ <h2>Tenants <span style="font-weight:400;color:#9ca3af;">(<%= @tenants.count %>)</span></h2>
37
+ </div>
38
+
39
+ <% if @tenants.empty? %>
40
+ <div class="empty">No tenants yet. Create your first one above.</div>
41
+ <% else %>
42
+ <table>
43
+ <thead>
44
+ <tr>
45
+ <th>Subdomain</th>
46
+ <th>Name</th>
47
+ <th>Status</th>
48
+ <th>Actions</th>
49
+ </tr>
50
+ </thead>
51
+ <tbody>
52
+ <% @tenants.each do |tenant| %>
53
+ <tr>
54
+ <td><span class="schema-badge"><%= tenant.subdomain %></span></td>
55
+ <td><%= tenant.try(:name) || "—" %></td>
56
+ <td>
57
+ <% status = tenant.try(:status).to_s %>
58
+ <% badge_class = { "active" => "badge-green", "trial" => "badge-yellow",
59
+ "inactive" => "badge-gray", "suspended" => "badge-red" }[status] || "badge-gray" %>
60
+ <span class="badge <%= badge_class %>"><%= status.presence || "—" %></span>
61
+ </td>
62
+ <td>
63
+ <div class="actions">
64
+ <%= link_to "📊 Status",
65
+ "/pg_multitenant_schemas/tenants/#{tenant.id}/migration_status",
66
+ class: "btn btn-ghost btn-sm" %>
67
+
68
+ <%= form_with url: "/pg_multitenant_schemas/tenants/#{tenant.id}/migrate",
69
+ method: :post, local: true, style: "display:inline" do |f| %>
70
+ <%= f.submit "▶ Migrate", class: "btn btn-green btn-sm" %>
71
+ <% end %>
72
+
73
+ <%= form_with url: "/pg_multitenant_schemas/tenants/#{tenant.id}",
74
+ method: :delete, local: true, style: "display:inline" do |f| %>
75
+ <%= f.submit "✕ Delete", class: "btn btn-red btn-sm",
76
+ data: { confirm: "Delete tenant '#{tenant.subdomain}' and drop its schema? This is irreversible." } %>
77
+ <% end %>
78
+ </div>
79
+ </td>
80
+ </tr>
81
+ <% end %>
82
+ </tbody>
83
+ </table>
84
+ <% end %>
85
+ </div>
@@ -0,0 +1,45 @@
1
+ <%= link_to "← Back to Tenants", "/pg_multitenant_schemas/tenants", class: "btn btn-ghost btn-sm", style: "margin-bottom:1rem;display:inline-flex;" %>
2
+
3
+ <div class="card" style="max-width:560px;">
4
+ <div class="card-header">
5
+ <h2>Migration Status — <span class="schema-badge"><%= @tenant.subdomain %></span></h2>
6
+ </div>
7
+
8
+ <div style="padding:1.25rem;display:flex;flex-direction:column;gap:1rem;">
9
+ <% if @status[:error] %>
10
+ <div class="flash alert">Error: <%= @status[:error] %></div>
11
+ <% else %>
12
+ <table>
13
+ <tbody>
14
+ <tr>
15
+ <td style="color:#6b7280;font-size:.8rem;font-weight:600;text-transform:uppercase;">Status</td>
16
+ <td>
17
+ <% if @status[:status] == :up_to_date %>
18
+ <span class="badge badge-green">✓ Up to date</span>
19
+ <% elsif @status[:status] == :pending %>
20
+ <span class="badge badge-yellow">⚠ Pending</span>
21
+ <% else %>
22
+ <span class="badge badge-gray"><%= @status[:status] %></span>
23
+ <% end %>
24
+ </td>
25
+ </tr>
26
+ <tr>
27
+ <td style="color:#6b7280;font-size:.8rem;font-weight:600;text-transform:uppercase;">Applied</td>
28
+ <td><strong><%= @status[:applied_count] || 0 %></strong> migrations</td>
29
+ </tr>
30
+ <tr>
31
+ <td style="color:#6b7280;font-size:.8rem;font-weight:600;text-transform:uppercase;">Pending</td>
32
+ <td><strong><%= @status[:pending_count] || 0 %></strong> migrations</td>
33
+ </tr>
34
+ </tbody>
35
+ </table>
36
+
37
+ <% if @status[:status] == :pending %>
38
+ <%= form_with url: "/pg_multitenant_schemas/tenants/#{@tenant.id}/migrate",
39
+ method: :post, local: true do |f| %>
40
+ <%= f.submit "▶ Run Migrations Now", class: "btn btn-green" %>
41
+ <% end %>
42
+ <% end %>
43
+ <% end %>
44
+ </div>
45
+ </div>