diff --git a/Gemfile b/Gemfile index 52ad3538..1723662e 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ gem "rails", github: "rails/rails" # Drivers gem "sqlite3", "~> 2.5" +gem "activerecord-tenanted", github: "basecamp/activerecord-tenanted" gem "redis", ">= 4.0.1" # Deployment diff --git a/Gemfile.lock b/Gemfile.lock index 470b27fe..e1a45b49 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,12 @@ +GIT + remote: https://github.com/basecamp/activerecord-tenanted.git + revision: 30a888a1c890f180f7a1a92b7743c19e344536f3 + specs: + activerecord-tenanted (0.6.0) + activerecord (>= 8.1.beta) + railties (>= 8.1.beta) + zeitwerk + GIT remote: https://github.com/basecamp/useragent.git revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e @@ -375,6 +384,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + activerecord-tenanted! bcrypt (~> 3.1.7) brakeman capybara diff --git a/app/models/application_record.rb b/app/models/application_record.rb index b63caeb8..cb75f5d4 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,4 @@ class ApplicationRecord < ActiveRecord::Base primary_abstract_class + tenanted end diff --git a/bin/dev b/bin/dev index b7f3f669..879f4533 100755 --- a/bin/dev +++ b/bin/dev @@ -1,5 +1,5 @@ #!/usr/bin/env sh -echo "Starting Writebook on http://localhost:3007" +echo "Starting Writebook on http://writebook.localhost:3007" exec ./bin/rails server -p 3007 diff --git a/config/database.yml b/config/database.yml index 6dec11e4..7c883bc0 100644 --- a/config/database.yml +++ b/config/database.yml @@ -7,14 +7,17 @@ default: &default development: primary: <<: *default - database: storage/db/development.sqlite3 + database: storage/tenants/%{tenant}/db/development.sqlite3 + tenanted: true test: primary: <<: *default - database: storage/db/test.sqlite3 + database: storage/tenants/%{tenant}/db/test.sqlite3 + tenanted: true production: primary: <<: *default - database: storage/db/production.sqlite3 + database: storage/tenants/%{tenant}/db/production.sqlite3 + tenanted: true diff --git a/config/environments/development.rb b/config/environments/development.rb index e1c3d90d..5ad48413 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -3,6 +3,9 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. + # Allow tenant subdomains in development + config.hosts << /.*\.writebook\.localhost/ + # In the development environment your application's code is reloaded any time # it changes. This slows down response time but is perfect for development # since you don't have to restart the web server when you make code changes. diff --git a/db/schema_cache.yml b/db/schema_cache.yml new file mode 100644 index 00000000..ff1a0505 --- /dev/null +++ b/db/schema_cache.yml @@ -0,0 +1,969 @@ +--- !ruby/object:ActiveRecord::ConnectionAdapters::SchemaCache +columns: + accesses: + - &20 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: book_id + cast_type: &1 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer + precision: + scale: + limit: + max: 9223372036854775808 + min: -9223372036854775808 + sql_type_metadata: &2 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: INTEGER + type: :integer + limit: + precision: + scale: + 'null': false + default: + default_function: + collation: + comment: + - &5 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: created_at + cast_type: &3 !ruby/object:ActiveRecord::Type::DateTime + precision: 6 + scale: + limit: + timezone: + sql_type_metadata: &4 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: datetime(6) + type: :datetime + limit: + precision: 6 + scale: + 'null': false + default: + default_function: + collation: + comment: + - &6 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: true + name: id + cast_type: *1 + sql_type_metadata: *2 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: level + cast_type: &7 !ruby/object:ActiveModel::Type::String + true: t + false: f + precision: + scale: + limit: + sql_type_metadata: &8 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: varchar + type: :string + limit: + precision: + scale: + 'null': false + default: + default_function: + collation: + comment: + - &9 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: updated_at + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: + default_function: + collation: + comment: + - &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: user_id + cast_type: *1 + sql_type_metadata: *2 + 'null': false + default: + default_function: + collation: + comment: + accounts: + - *5 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: custom_styles + cast_type: &10 !ruby/object:ActiveRecord::Type::Text + true: t + false: f + precision: + scale: + limit: + sql_type_metadata: &11 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: TEXT + type: :text + limit: + precision: + scale: + 'null': true + default: + default_function: + collation: + comment: + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: join_code + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - &12 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: name + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *9 + action_text_markdowns: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: content + cast_type: *10 + sql_type_metadata: *11 + 'null': false + default: '' + default_function: + collation: + comment: + - *5 + - *6 + - *12 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: record_id + cast_type: *1 + sql_type_metadata: *2 + 'null': false + default: + default_function: + collation: + comment: + - &15 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: record_type + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *9 + active_storage_attachments: + - &16 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: blob_id + cast_type: &13 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer + precision: + scale: + limit: + max: 9223372036854775808 + min: -9223372036854775808 + sql_type_metadata: &14 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: bigint + type: :integer + limit: + precision: + scale: + 'null': false + default: + default_function: + collation: + comment: + - *5 + - *6 + - *12 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: record_id + cast_type: *13 + sql_type_metadata: *14 + 'null': false + default: + default_function: + collation: + comment: + - *15 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: slug + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + active_storage_blobs: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: byte_size + cast_type: *13 + sql_type_metadata: *14 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: checksum + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: content_type + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *5 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: filename + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *6 + - &17 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: key + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: metadata + cast_type: *10 + sql_type_metadata: *11 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: service_name + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + active_storage_variant_records: + - *16 + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: variation_digest + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + ar_internal_metadata: + - *5 + - *17 + - *9 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: value + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + books: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: author + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *5 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: everyone_access + cast_type: &18 !ruby/object:ActiveModel::Type::Boolean + precision: + scale: + limit: + sql_type_metadata: &19 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: boolean + type: :boolean + limit: + precision: + scale: + 'null': false + default: true + default_function: + collation: + comment: + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: published + cast_type: *18 + sql_type_metadata: *19 + 'null': false + default: false + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: slug + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: subtitle + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: theme + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: blue + default_function: + collation: + comment: + - &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: title + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *9 + edits: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: action + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *5 + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: leaf_id + cast_type: *1 + sql_type_metadata: *2 + 'null': false + default: + default_function: + collation: + comment: + - &21 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: leafable_id + cast_type: *1 + sql_type_metadata: *2 + 'null': false + default: + default_function: + collation: + comment: + - &22 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: leafable_type + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *9 + leaves: + - *20 + - *5 + - *6 + - *21 + - *22 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: position_score + cast_type: !ruby/object:ActiveModel::Type::Float + precision: + scale: + limit: + sql_type_metadata: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: float + type: :float + limit: + precision: + scale: + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: status + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *23 + - *9 + pages: + - *5 + - *6 + - *9 + pictures: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: caption + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *5 + - *6 + - *9 + schema_migrations: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: version + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + sections: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: body + cast_type: *10 + sql_type_metadata: *11 + 'null': true + default: + default_function: + collation: + comment: + - *5 + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: theme + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *9 + sessions: + - *5 + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: ip_address + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: last_active_at + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: token + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *9 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: user_agent + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *24 + users: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: active + cast_type: *18 + sql_type_metadata: *19 + 'null': true + default: true + default_function: + collation: + comment: + - *5 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: email_address + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *6 + - *12 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: password_digest + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: role + cast_type: *1 + sql_type_metadata: *2 + 'null': false + default: + default_function: + collation: + comment: + - *9 +primary_keys: + accesses: id + accounts: id + action_text_markdowns: id + active_storage_attachments: id + active_storage_blobs: id + active_storage_variant_records: id + ar_internal_metadata: key + books: id + edits: id + leaves: id + pages: id + pictures: id + schema_migrations: version + sections: id + sessions: id + users: id +data_sources: + accesses: true + accounts: true + action_text_markdowns: true + active_storage_attachments: true + active_storage_blobs: true + active_storage_variant_records: true + ar_internal_metadata: true + books: true + edits: true + leaves: true + pages: true + pictures: true + schema_migrations: true + sections: true + sessions: true + users: true +indexes: + accesses: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: accesses + name: index_accesses_on_book_id + unique: false + columns: + - book_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: accesses + name: index_accesses_on_user_id + unique: false + columns: + - user_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: accesses + name: index_accesses_on_user_id_and_book_id + unique: true + columns: + - user_id + - book_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + accounts: [] + action_text_markdowns: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: action_text_markdowns + name: index_action_text_markdowns_on_record + unique: false + columns: + - record_type + - record_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + active_storage_attachments: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: active_storage_attachments + name: index_active_storage_attachments_on_blob_id + unique: false + columns: + - blob_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: active_storage_attachments + name: index_active_storage_attachments_on_slug + unique: true + columns: + - slug + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: active_storage_attachments + name: index_active_storage_attachments_uniqueness + unique: true + columns: + - record_type + - record_id + - name + - blob_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + active_storage_blobs: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: active_storage_blobs + name: index_active_storage_blobs_on_key + unique: true + columns: + - key + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + active_storage_variant_records: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: active_storage_variant_records + name: index_active_storage_variant_records_uniqueness + unique: true + columns: + - blob_id + - variation_digest + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + ar_internal_metadata: [] + books: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: books + name: index_books_on_published + unique: false + columns: + - published + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + edits: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: edits + name: index_edits_on_leaf_id + unique: false + columns: + - leaf_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: edits + name: index_edits_on_leafable + unique: false + columns: + - leafable_type + - leafable_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + leaves: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: leaves + name: index_leafs_on_leafable + unique: false + columns: + - leafable_type + - leafable_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: leaves + name: index_leaves_on_book_id + unique: false + columns: + - book_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + pages: [] + pictures: [] + schema_migrations: [] + sections: [] + sessions: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: sessions + name: index_sessions_on_token + unique: true + columns: + - token + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: sessions + name: index_sessions_on_user_id + unique: false + columns: + - user_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + users: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: users + name: index_users_on_email_address + unique: true + columns: + - email_address + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: users + name: index_users_on_name + unique: true + columns: + - name + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true +version: 20240928005927 diff --git a/script/admin/create-tenant b/script/admin/create-tenant new file mode 100755 index 00000000..ee55e85a --- /dev/null +++ b/script/admin/create-tenant @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby +require_relative "../../config/environment" + +tenant_name = ARGV[0] + +if tenant_name.nil? || tenant_name.empty? + abort "Usage: script/admin/create-tenant TENANT_NAME" +end + +if ApplicationRecord.tenant_exist?(tenant_name) + abort "Error: Tenant '#{tenant_name}' already exists" +end + +ApplicationRecord.create_tenant(tenant_name) do + Account.create!(name: tenant_name) +end + +puts "Created tenant '#{tenant_name}'" +puts "Visit https://#{tenant_name}.writebook.localhost:3007 to complete setup" diff --git a/script/admin/migrate-to-tenant b/script/admin/migrate-to-tenant new file mode 100755 index 00000000..588d0a16 --- /dev/null +++ b/script/admin/migrate-to-tenant @@ -0,0 +1,74 @@ +#!/usr/bin/env ruby +# +# Migrate an existing single-tenant Writebook production deployment to multi-tenant. +# +# Usage: script/admin/migrate-to-tenant TENANT_NAME +# +# This script is designed to be run during a brief maintenance window: +# 1. Stop the app +# 2. Run this script +# 3. Update DNS (add subdomain for this tenant) +# 4. Start the app +# + +require "fileutils" + +STORAGE_DIR = File.expand_path("../../storage", __dir__) +OLD_DB_PATH = File.join(STORAGE_DIR, "db", "production.sqlite3") + +tenant_name = ARGV[0] + +if tenant_name.nil? || tenant_name.empty? + abort "Usage: script/admin/migrate-to-tenant TENANT_NAME" +end + +unless File.exist?(OLD_DB_PATH) + abort "Error: Source database not found at #{OLD_DB_PATH}" +end + +tenant_dir = File.join(STORAGE_DIR, "tenants", tenant_name) + +if File.exist?(tenant_dir) + abort "Error: Destination tenant directory already exists: #{tenant_dir}" +end + +# Create tenant directory structure +tenant_db_dir = File.join(tenant_dir, "db") +tenant_files_dir = File.join(tenant_dir, "files") + +FileUtils.mkdir_p(tenant_db_dir) +FileUtils.mkdir_p(tenant_files_dir) + +# Move the database file +new_db_path = File.join(tenant_db_dir, "production.sqlite3") +FileUtils.mv(OLD_DB_PATH, new_db_path) +%w[-wal -shm].each do |suffix| + old_file = "#{OLD_DB_PATH}#{suffix}" + FileUtils.mv(old_file, "#{new_db_path}#{suffix}") if File.exist?(old_file) +end +puts "Moved database to #{new_db_path}" + +# Move Active Storage blob files (directories matching ??/??) +# These are two-character hex directories like ab/cd/ +blob_dirs_moved = 0 +Dir.glob(File.join(STORAGE_DIR, "files", "[0-9a-f][0-9a-f]")).each do |first_level_dir| + next unless File.directory?(first_level_dir) + + dir_name = File.basename(first_level_dir) + dest_dir = File.join(tenant_files_dir, dir_name) + FileUtils.mv(first_level_dir, dest_dir) + blob_dirs_moved += 1 +end + +if blob_dirs_moved > 0 + puts "Moved #{blob_dirs_moved} Active Storage blob directories to #{tenant_files_dir}" +else + puts "No Active Storage blob directories found to move" +end + +puts +puts "Migration complete!" +puts +puts "Next steps:" +puts " 1. Update DNS to add subdomain: #{tenant_name}.yourdomain.com" +puts " 2. Start the app"