From fe8b71c2c94c5818a75bba5230967c81872322b6 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 1 Jul 2025 15:32:13 -0400 Subject: [PATCH 01/57] SQLiteBackupsJob explicitly uses db_config.database_path because using db_config.database will give us the sqlite file URL with query params, and that's not what we mean. This should restore nightly backups. --- app/jobs/sqlite_backups_job.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/jobs/sqlite_backups_job.rb b/app/jobs/sqlite_backups_job.rb index 6e7559d80..10b0efe37 100644 --- a/app/jobs/sqlite_backups_job.rb +++ b/app/jobs/sqlite_backups_job.rb @@ -92,7 +92,7 @@ class SQLiteBackupsJob < ApplicationJob end def db_path(tenant) - db_config.database + db_config.database_path end def db_config From 6905198b661b384a5a4dd5b16aabd28a4a4a0a7c Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 26 Jun 2025 15:03:03 -0400 Subject: [PATCH 02/57] dep: Use the patched AR::Tenanted branch --- Gemfile | 2 +- Gemfile.lock | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 2f56fa81d..61515a304 100644 --- a/Gemfile +++ b/Gemfile @@ -10,7 +10,7 @@ gem "stimulus-rails" gem "turbo-rails" # Deployment and drivers -gem "active_record-tenanted", bc: "active_record-tenanted" +gem "active_record-tenanted", bc: "active_record-tenanted", branch: "flavorjones/path-based-tenanting" gem "bootsnap", require: false gem "kamal", bc: "kamal", ref: "344e2d79", require: false gem "puma", ">= 5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 9c79b7482..4aaa342b5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,7 +7,8 @@ GIT GIT remote: https://github.com/basecamp/active_record-tenanted - revision: 81a4d02a363fa749de517c90e19588365f934913 + revision: df260821fcc6fa7aee355623c70cdd4fe21d2d71 + branch: flavorjones/path-based-tenanting specs: active_record-tenanted (0.1.0) activerecord (>= 8.1.alpha) From f43a5ff22cba49c2b572ec75ca9422cc7539b790 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 26 Jun 2025 15:04:53 -0400 Subject: [PATCH 03/57] Use a tenant resolver that recognizes the account slug and sets the script name accordingly --- config/initializers/tenanting.rb | 44 ++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/config/initializers/tenanting.rb b/config/initializers/tenanting.rb index 85ef97ba7..a63b37caa 100644 --- a/config/initializers/tenanting.rb +++ b/config/initializers/tenanting.rb @@ -1,19 +1,37 @@ -Rails.application.config.after_initialize do - # in production and staging, we're using a two-level subdomain like "tenant.fizzy.37signals.com". - # in development and beta it's only a single-level subdomain. - Rails.application.config.active_record_tenanted.tenant_resolver = ->(request) do - tld_length = [ "37signals.com", "37signals-staging.com" ].include?(request.domain) ? 2 : 1 +module AccountSlug + PATTERN = /(\d{7,})/ + FORMAT = "%07d" + PATH_INFO_MATCH = /\A(\/#{AccountSlug::PATTERN})/ - if request.path == "/up" - nil - elsif subdomain = request.subdomain(tld_length).presence - subdomain - elsif request.path =~ %r{^/(queenbee|signup)\b} - nil - else - "return-404" # this is a quick fix for now + # We're using account id prefixes in the URL path. Rather than namespace + # all our routes, we're "mounting" the Rails app at this URL prefix. + def self.extract(request) + # $1, $2, $' == script_name, slug, path_info + if request.script_name && request.script_name =~ PATH_INFO_MATCH + # Likely due to restarting the action cable connection after upgrade + AccountSlug.decode($2) + elsif request.path_info =~ PATH_INFO_MATCH + # Yanks the prefix off PATH_INFO and move it to SCRIPT_NAME + request.engine_script_name = request.script_name = $1 + request.path_info = $'.empty? ? "/" : $' + + # Limit session cookies to the slug path. + # TODO TEST ME + request.env["rack.session.options"][:path] = $1 + + # Return the account id for tenanting. + AccountSlug.decode($2) end end + + def self.decode(slug) slug.to_i end + def self.encode(id) FORMAT % id end +end + +Rails.application.config.after_initialize do + Rails.application.config.active_record_tenanted.tenant_resolver = ->(request) do + AccountSlug.extract(request) + end end ActiveSupport.on_load(:action_controller_base) do From 33d5eccb700318ca1fe1b67fbff3a3d86b1888ba Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 26 Jun 2025 15:03:39 -0400 Subject: [PATCH 04/57] Use the new AR::Tenanted convention for action cable connections --- app/channels/application_cable/connection.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index b39e7a216..0f936d05b 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -2,8 +2,9 @@ module ApplicationCable class Connection < ActionCable::Connection::Base identified_by :current_user - tenanted_connection do - set_current_user || reject_unauthorized_connection + def connect + super + ApplicationRecord.with_tenant(current_tenant) { set_current_user || reject_unauthorized_connection } end private From e68e2eb97111ddfd60bc989e3012977f57829069 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 26 Jun 2025 15:05:33 -0400 Subject: [PATCH 05/57] Patch Turbo to use the request script name so that the URLs generated during view rendering are correct --- config/initializers/tenanting.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/config/initializers/tenanting.rb b/config/initializers/tenanting.rb index a63b37caa..2c635a6bc 100644 --- a/config/initializers/tenanting.rb +++ b/config/initializers/tenanting.rb @@ -37,3 +37,22 @@ end ActiveSupport.on_load(:action_controller_base) do before_action { logger.struct tenant: ApplicationRecord.current_tenant } end + +module TurboStreamsJobExtensions + extend ActiveSupport::Concern + + class_methods do + def render_format(format, **rendering) + if ApplicationRecord.current_tenant + script_name = "/#{ApplicationRecord.current_tenant}" + ApplicationController.renderer.new(script_name: script_name).render(formats: [ format ], **rendering) + else + super + end + end + end +end + +Rails.application.config.after_initialize do + Turbo::StreamsChannel.prepend TurboStreamsJobExtensions +end From e378c5a45ed31a426cec4074b817ba31414c082e Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 30 Jun 2025 17:12:47 -0400 Subject: [PATCH 06/57] Extract pieces from the tenanting initializer into: - tenanting/account_slug - tenanting/logging - tenanting/turbo --- .../account_slug.rb} | 23 ------------------- config/initializers/tenanting/logging.rb | 3 +++ config/initializers/tenanting/turbo.rb | 18 +++++++++++++++ 3 files changed, 21 insertions(+), 23 deletions(-) rename config/initializers/{tenanting.rb => tenanting/account_slug.rb} (65%) create mode 100644 config/initializers/tenanting/logging.rb create mode 100644 config/initializers/tenanting/turbo.rb diff --git a/config/initializers/tenanting.rb b/config/initializers/tenanting/account_slug.rb similarity index 65% rename from config/initializers/tenanting.rb rename to config/initializers/tenanting/account_slug.rb index 2c635a6bc..21043a428 100644 --- a/config/initializers/tenanting.rb +++ b/config/initializers/tenanting/account_slug.rb @@ -33,26 +33,3 @@ Rails.application.config.after_initialize do AccountSlug.extract(request) end end - -ActiveSupport.on_load(:action_controller_base) do - before_action { logger.struct tenant: ApplicationRecord.current_tenant } -end - -module TurboStreamsJobExtensions - extend ActiveSupport::Concern - - class_methods do - def render_format(format, **rendering) - if ApplicationRecord.current_tenant - script_name = "/#{ApplicationRecord.current_tenant}" - ApplicationController.renderer.new(script_name: script_name).render(formats: [ format ], **rendering) - else - super - end - end - end -end - -Rails.application.config.after_initialize do - Turbo::StreamsChannel.prepend TurboStreamsJobExtensions -end diff --git a/config/initializers/tenanting/logging.rb b/config/initializers/tenanting/logging.rb new file mode 100644 index 000000000..d6675942e --- /dev/null +++ b/config/initializers/tenanting/logging.rb @@ -0,0 +1,3 @@ +ActiveSupport.on_load(:action_controller_base) do + before_action { logger.struct tenant: ApplicationRecord.current_tenant } +end diff --git a/config/initializers/tenanting/turbo.rb b/config/initializers/tenanting/turbo.rb new file mode 100644 index 000000000..d740ff8f6 --- /dev/null +++ b/config/initializers/tenanting/turbo.rb @@ -0,0 +1,18 @@ +module TurboStreamsJobExtensions + extend ActiveSupport::Concern + + class_methods do + def render_format(format, **rendering) + if ApplicationRecord.current_tenant + script_name = "/#{ApplicationRecord.current_tenant}" + ApplicationController.renderer.new(script_name: script_name).render(formats: [ format ], **rendering) + else + super + end + end + end +end + +Rails.application.config.after_initialize do + Turbo::StreamsChannel.prepend TurboStreamsJobExtensions +end From 98e6bd8cd7822689bb7f74647025d87c88937c9b Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 26 Jun 2025 15:04:03 -0400 Subject: [PATCH 07/57] Point Action Cable at the slugged cable endpoint I proposed this upstream in https://github.com/rails/rails/pull/55250, but it's not clear if the presence of script_name should _always_ prefix generated URLs, so for now it's local to Fizzy. --- app/helpers/tenanting_helper.rb | 7 +++++++ app/views/layouts/shared/_head.html.erb | 1 + 2 files changed, 8 insertions(+) create mode 100644 app/helpers/tenanting_helper.rb diff --git a/app/helpers/tenanting_helper.rb b/app/helpers/tenanting_helper.rb new file mode 100644 index 000000000..762e12fa0 --- /dev/null +++ b/app/helpers/tenanting_helper.rb @@ -0,0 +1,7 @@ +module TenantingHelper + def tenanted_action_cable_meta_tag + tag "meta", + name: "action-cable-url", + content: [ request.script_name, ActionCable.server.config.mount_path ].join("/") + end +end diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 90f80df4e..4b342fd70 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -15,6 +15,7 @@ <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> + <%= tenanted_action_cable_meta_tag %> <%= yield :head %> From 3844dc9ff3959b3a92406bd40e263a3643a1d251 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 30 Jun 2025 17:26:20 -0400 Subject: [PATCH 08/57] Patch Active Storage controllers to generate slugged URLs This really only impacts the Disk service in development. I proposed this upstream in https://github.com/rails/rails/pull/55248, but it's not clear if the presence of script_name should _always_ prefix generated URLs, so for now it's local to Fizzy. --- .../initializers/tenanting/active_storage.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 config/initializers/tenanting/active_storage.rb diff --git a/config/initializers/tenanting/active_storage.rb b/config/initializers/tenanting/active_storage.rb new file mode 100644 index 000000000..278b92de8 --- /dev/null +++ b/config/initializers/tenanting/active_storage.rb @@ -0,0 +1,19 @@ +module ActiveStorageControllerExtensions + extend ActiveSupport::Concern + + included do + before_action do + # Add script_name so that Disk Service will generate correct URLs for uploads + ActiveStorage::Current.url_options = { + protocol: request.protocol, + host: request.host, + port: request.port, + script_name: request.script_name + } + end + end +end + +Rails.application.config.after_initialize do + ActiveStorage::BaseController.include ActiveStorageControllerExtensions +end From 3d39bdc9f39a34f7f4e07e60d63aed7926876938 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 26 Jun 2025 15:06:40 -0400 Subject: [PATCH 09/57] Update bin/dev to point at the new tenant URLs --- bin/dev | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bin/dev b/bin/dev index e5fc62f6c..106cdeb6d 100755 --- a/bin/dev +++ b/bin/dev @@ -1,7 +1,8 @@ #!/usr/bin/env sh -echo "Access with david@37signals.com / secret123456 on http://37signals.fizzy.localhost:3006" -echo "Access with david@37signals.com / secret123456 on http://honcho.fizzy.localhost:3006" +echo "Login with david@37signals.com / secret123456 to:" +echo " - 37signals: http://fizzy.localhost:3006/735464785" +echo " - Honcho: http://fizzy.localhost:3006/175932900" if [ -f tmp/solid-queue.txt ]; then export SOLID_QUEUE_IN_PUMA=1 From b8368de002231417baf2cc52cab06f48676213b9 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 30 Jun 2025 22:35:35 -0400 Subject: [PATCH 10/57] Update the Command classes to handle paths with account slugs --- app/controllers/commands_controller.rb | 2 +- app/models/command/ai/parser.rb | 3 +- app/models/command/parser.rb | 3 +- app/models/command/parser/context.rb | 8 +++-- app/models/command/visit_url.rb | 16 +++++++++- test/models/command/ai/translator_test.rb | 10 +++---- test/models/command/visit_url_test.rb | 36 +++++++++++++++++++++++ test/test_helpers/command_test_helper.rb | 2 +- 8 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 test/models/command/visit_url_test.rb diff --git a/app/controllers/commands_controller.rb b/app/controllers/commands_controller.rb index adae98eb1..a5f6b90f0 100644 --- a/app/controllers/commands_controller.rb +++ b/app/controllers/commands_controller.rb @@ -29,7 +29,7 @@ class CommandsController < ApplicationController end def parsing_context - @parsing_context ||= Command::Parser::Context.new(Current.user, url: request.referrer) + @parsing_context ||= Command::Parser::Context.new(Current.user, url: request.referrer, script_name: request.script_name) end def confirmed?(command) diff --git a/app/models/command/ai/parser.rb b/app/models/command/ai/parser.rb index 6e881449e..9d5bb9eb0 100644 --- a/app/models/command/ai/parser.rb +++ b/app/models/command/ai/parser.rb @@ -7,6 +7,7 @@ class Command::Ai::Parser def initialize(context) @context = context + self.default_url_options[:script_name] = context.script_name end def parse(query) @@ -59,7 +60,7 @@ class Command::Ai::Parser def context_from_query(query_json) if context_properties = query_json[:context].presence url = cards_path(**context_properties) - Command::Parser::Context.new(user, url: url) + Command::Parser::Context.new(user, url: url, script_name: context.script_name) end end end diff --git a/app/models/command/parser.rb b/app/models/command/parser.rb index d45cd69ed..1949658d2 100644 --- a/app/models/command/parser.rb +++ b/app/models/command/parser.rb @@ -1,7 +1,7 @@ class Command::Parser attr_reader :context - delegate :user, :cards, :filter, to: :context + delegate :user, :cards, :filter, :script_name, to: :context def initialize(context) @context = context @@ -12,6 +12,7 @@ class Command::Parser command.user = user command.line ||= string command.context ||= context + command.default_url_options[:script_name] = script_name end end diff --git a/app/models/command/parser/context.rb b/app/models/command/parser/context.rb index 401efbd98..ca146f049 100644 --- a/app/models/command/parser/context.rb +++ b/app/models/command/parser/context.rb @@ -1,9 +1,10 @@ class Command::Parser::Context - attr_reader :user, :url + attr_reader :user, :url, :script_name - def initialize(user, url:) + def initialize(user, url:, script_name: "") @user = user @url = url + @script_name = script_name extract_url_components end @@ -52,7 +53,8 @@ class Command::Parser::Context def extract_url_components uri = URI.parse(url || "") - route = Rails.application.routes.recognize_path(uri.path) + path = uri.path.delete_prefix(script_name) + route = Rails.application.routes.recognize_path(path) @controller = route[:controller] @action = route[:action] @params = ActionController::Parameters.new(Rack::Utils.parse_nested_query(uri.query).merge(route.except(:controller, :action))) diff --git a/app/models/command/visit_url.rb b/app/models/command/visit_url.rb index 6066ea0ed..dabd9f52d 100644 --- a/app/models/command/visit_url.rb +++ b/app/models/command/visit_url.rb @@ -6,6 +6,20 @@ class Command::VisitUrl < Command end def execute - redirect_to url + redirect_to real_url end + + private + def real_url + case url + when String + if url.start_with?(context.script_name) + url + else + [ context&.script_name, url ].compact.join + end + else + url + end + end end diff --git a/test/models/command/ai/translator_test.rb b/test/models/command/ai/translator_test.rb index 74d3722d4..feed1606d 100644 --- a/test/models/command/ai/translator_test.rb +++ b/test/models/command/ai/translator_test.rb @@ -108,10 +108,10 @@ class Command::Ai::TranslatorTest < ActionDispatch::IntegrationTest end test "visit screens" do - assert_command({ commands: [ "/visit #{user_path(@user)}" ] }, "my profile") - assert_command({ commands: [ "/visit #{edit_user_path(@user)}" ] }, "edit my profile") - assert_command({ commands: [ "/visit #{account_settings_path}" ] }, "manage users") - assert_command({ commands: [ "/visit #{account_settings_path}" ] }, "account settings") + assert_command({ commands: [ "/visit #{user_path(@user, script_name: nil)}" ] }, "my profile") + assert_command({ commands: [ "/visit #{edit_user_path(@user, script_name: nil)}" ] }, "edit my profile") + assert_command({ commands: [ "/visit #{account_settings_path(script_name: nil)}" ] }, "manage users") + assert_command({ commands: [ "/visit #{account_settings_path(script_name: nil)}" ] }, "account settings") end test "create cards" do @@ -146,7 +146,7 @@ class Command::Ai::TranslatorTest < ActionDispatch::IntegrationTest def translate(query, user: @user, context: :list) raise "Context must be :card or _list" unless context.in?(%i[ card list ]) url = context == :card ? card_url(cards(:logo)) : cards_url - context = Command::Parser::Context.new(user, url: url) + context = Command::Parser::Context.new(user, url: url, script_name: integration_session.default_url_options[:script_name]) translator = Command::Ai::Translator.new(context) translator.translate(query) end diff --git a/test/models/command/visit_url_test.rb b/test/models/command/visit_url_test.rb new file mode 100644 index 000000000..12d89035a --- /dev/null +++ b/test/models/command/visit_url_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +class Command::VisitUrlTest < ActionDispatch::IntegrationTest + setup do + @user = users(:david) + @card = cards(:logo) + @script_name = integration_session.default_url_options[:script_name] + end + + test "visit a path without the account slug" do + result = visit_command("/foo/1234/bar").execute + + assert_kind_of Command::Result::Redirection, result + assert_equal "#{@script_name}/foo/1234/bar", result.url + end + + test "visit a path with the account slug" do + result = visit_command("#{@script_name}/foo/1234/bar").execute + + assert_kind_of Command::Result::Redirection, result + assert_equal "#{@script_name}/foo/1234/bar", result.url + end + + test "visit an object path" do + result = visit_command(@card).execute + + assert_kind_of Command::Result::Redirection, result + assert_equal @card, result.url + end + + private + def visit_command(url) + context = Command::Parser::Context.new(@user, url: collection_card_url(@card.collection, @card), script_name: integration_session.default_url_options[:script_name]) + Command::VisitUrl.new(url: url, context: context) + end +end diff --git a/test/test_helpers/command_test_helper.rb b/test/test_helpers/command_test_helper.rb index a0d760e8b..7492bc4cd 100644 --- a/test/test_helpers/command_test_helper.rb +++ b/test/test_helpers/command_test_helper.rb @@ -4,7 +4,7 @@ module CommandTestHelper end def parse_command(string, context_url: nil, user: users(:david)) - context = Command::Parser::Context.new(user, url: context_url) + context = Command::Parser::Context.new(user, url: context_url, script_name: integration_session.default_url_options[:script_name]) parser = Command::Parser.new(context) parser.parse(string).tap do |command| command.user = user if command From f8cbf51f71abf6aa36a623ded80528a8f5218e14 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 25 Jun 2025 14:06:35 -0400 Subject: [PATCH 11/57] Update the signups tests to work with untenanted paths --- .../controllers/signup/accounts_controller_test.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/controllers/signup/accounts_controller_test.rb b/test/controllers/signup/accounts_controller_test.rb index 8c14e4326..78a1c1af5 100644 --- a/test/controllers/signup/accounts_controller_test.rb +++ b/test/controllers/signup/accounts_controller_test.rb @@ -1,14 +1,14 @@ require "test_helper" class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest - test "new under a tenanted domain redirects to the root" do + test "new under a tenanted URL redirects to the root" do get new_signup_account_url assert_redirected_to root_url end - test "new under an untenanted domain is OK" do - integration_session.host = "example.com" # no subdomain + test "new under an untenanted URL is OK" do + integration_session.default_url_options[:script_name] = "" # no tenant get new_signup_account_url, headers: auth_headers @@ -16,7 +16,7 @@ class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest end test "create with invalid params" do - integration_session.host = "example.com" # no subdomain + integration_session.default_url_options[:script_name] = "" # no tenant post signup_accounts_url, headers: auth_headers, @@ -27,7 +27,7 @@ class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest end test "create for a new " do - integration_session.host = "example.com" # no subdomain + integration_session.default_url_options[:script_name] = "" # no tenant assert_difference -> { SignalId::Identity.count }, +1 do assert_difference -> { SignalId::Account.count }, +1 do @@ -48,7 +48,7 @@ class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest end test "create for an existing identity" do - integration_session.host = "example.com" # no subdomain + integration_session.default_url_options[:script_name] = "" # no tenant identity = signal_identities(:david) @@ -72,7 +72,7 @@ class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest end test "actions require HTTP basic authentication while we're in internal-only mode" do - integration_session.host = "example.com" # no subdomain + integration_session.default_url_options[:script_name] = "" # no tenant get new_signup_account_url From 444b7aa69f7d0018d82270b9c78e22e21015f842 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 24 Jun 2025 09:53:10 -0400 Subject: [PATCH 12/57] Script to rename tenants after the QB account id --- script/rename-tenants-to-qb-account-id.rb | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100755 script/rename-tenants-to-qb-account-id.rb diff --git a/script/rename-tenants-to-qb-account-id.rb b/script/rename-tenants-to-qb-account-id.rb new file mode 100755 index 000000000..83401107e --- /dev/null +++ b/script/rename-tenants-to-qb-account-id.rb @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" + +tenant_names = [] + +ApplicationRecord.with_each_tenant do |tenant| + next if tenant == "#{Rails.env}-tenant" + + account = Account.first + queenbee_id = account.queenbee_id + tenant_names << { from: tenant, to: queenbee_id } + + ApplicationRecord.remove_connection +end + +pp [ "Tenant name changes:", tenant_names ] + +root_config = ApplicationRecord.tenanted_root_config +tenant_names.each do |name| + from_db_path = root_config.database_path_for(name[:from]) + to_db_path = root_config.database_path_for(name[:to]) + + from_path = from_db_path.split("/").take(4).join("/") + to_path = to_db_path.split("/").take(4).join("/") + + unless from_path == to_path + FileUtils.move from_path, to_path, verbose: true + end +end + +puts +pp [ "Tenants after renaming:", ApplicationRecord.tenants ] From 9c59a85abd184b4f30f1455fc937c3e219c6f433 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 26 Jun 2025 15:22:18 -0400 Subject: [PATCH 13/57] Make sure seeds are creating sluggable tenants --- db/seeds.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/seeds.rb b/db/seeds.rb index a04ee160e..1a38c8310 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -9,7 +9,7 @@ end def create_tenant(signal_account_name) signal_account = SignalId::Account.find_by_product_and_name!("fizzy", signal_account_name) - tenant_name = signal_account.subdomain + tenant_name = signal_account.queenbee_id ApplicationRecord.destroy_tenant tenant_name ApplicationRecord.create_tenant(tenant_name) do From 8e666a5247d26a0931d217738105d0cee5544041 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 1 Jul 2025 09:39:19 -0400 Subject: [PATCH 14/57] Script to update URLs and attachments in cards and comments --- script/migrate-content-to-slugged-urls.rb | 70 +++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100755 script/migrate-content-to-slugged-urls.rb diff --git a/script/migrate-content-to-slugged-urls.rb b/script/migrate-content-to-slugged-urls.rb new file mode 100755 index 000000000..5022858ec --- /dev/null +++ b/script/migrate-content-to-slugged-urls.rb @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" + +domains = { + "production" => "fizzy.37signals.com", + "beta" => "fizzy-beta.37signals.com", + "staging" => "fizzy.37signals-staging.com" +} + +def fix_attachments(rich_text) + if rich_text.body + rich_text.body.send(:attachment_nodes).each do |node| + sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME) + if sgid + puts "Fixing attachment node: #{node.to_html}" + model = sgid.model_class.find(sgid.model_id) + node["sgid"] = model.attachable_sgid + else + puts "Skipping attachment node without valid sgid: #{node.to_html}" + end + end + rich_text.save! + end +end + +ApplicationRecord.with_each_tenant do |tenant| + account_id = Account.first.queenbee_id + + unless account_id + puts "Skipping URL fixup for tenant: #{tenant}" + next + end + + puts "\n## Processing tenant: #{tenant}\n" + + domain = domains[Rails.env] || domains["production"] + regex = %r{://\w+\.#{domain}/} + + pp [ Account.first.name, account_id, domain, regex ] + puts + + Card.find_each do |card| + puts "### Processing card #{card.id} in #{Rails.application.routes.url_helpers.collection_card_path(card.collection, card)}" + fix_attachments(card.description) + card.reload + + old_body = card.description.body.to_s + if match = regex.match(old_body) + puts "URL found in card #{card.id} in #{Rails.application.routes.url_helpers.collection_card_path(card.collection, card)}" + new_body = old_body.gsub(regex, "://#{domain}/#{account_id}/") + + card.description.update(body: new_body) || raise("Failed to update card description for card #{card.id}") + end + end + + Comment.find_each do |comment| + puts "### Processing comment #{comment.id} in #{Rails.application.routes.url_helpers.collection_card_path(comment.card.collection, comment.card)}" + fix_attachments(comment.body) + comment.reload + + old_body = comment.body.body.to_s + if match = regex.match(old_body) + puts "URL found in comment #{comment.id} in #{Rails.application.routes.url_helpers.collection_card_path(comment.card.collection, comment.card)}" + new_body = old_body.gsub(regex, "://#{domain}/#{account_id}/") + + comment.body.update(body: new_body) || raise("Failed to update comment body for comment #{comment.id}") + end + end +end From 7089fbe0d41c9e90cd486d60eaea7652f3c1a81f Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 1 Jul 2025 14:23:16 -0400 Subject: [PATCH 15/57] Configure Mission Control to skip tenanted authentication In 29644ba1 we configured HTTP basic auth to secure it for now. --- config/initializers/mission_control.rb | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 config/initializers/mission_control.rb diff --git a/config/initializers/mission_control.rb b/config/initializers/mission_control.rb new file mode 100644 index 000000000..c2098204e --- /dev/null +++ b/config/initializers/mission_control.rb @@ -0,0 +1,5 @@ +Rails.application.config.before_initialize do + # We don't want normal tenanted authentication on mission control. + # Note that we're using HTTP basic auth configured via credentials. + MissionControl::Jobs.base_controller_class = "ActionController::Base" +end From 84ed83a2c677fcea711bf211244db91ce2832a8e Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 1 Jul 2025 17:41:54 -0400 Subject: [PATCH 16/57] Script to create Users for existing 37id accounts intended for staging --- script/create-accounts-in-staging.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100755 script/create-accounts-in-staging.rb diff --git a/script/create-accounts-in-staging.rb b/script/create-accounts-in-staging.rb new file mode 100755 index 000000000..4ba31e6f1 --- /dev/null +++ b/script/create-accounts-in-staging.rb @@ -0,0 +1,21 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" + +ApplicationRecord.with_each_tenant do |tenant| + account = Account.first + signal_account = account.signal_account + + signal_users = SignalId::User.where(account_id: signal_account.id) + + signal_users.each do |signal_user| + unless User.find_by(signal_user_id: signal_user.id) + User.create!( + name: signal_user.identity.name, + email_address: signal_user.identity.email_address, + signal_user_id: signal_user.id, + password: SecureRandom.hex(36) # TODO: remove password column? + ) + end + end +end From f02215c5b7db7891bd4dda60127443638a6f4285 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 1 Jul 2025 19:56:29 -0500 Subject: [PATCH 17/57] Add steps to cards --- app/assets/stylesheets/card-columns.css | 6 +- app/assets/stylesheets/cards.css | 4 +- app/assets/stylesheets/icons.css | 1 + app/assets/stylesheets/steps.css | 98 +++++++++++++++++++ app/assets/stylesheets/trays.css | 1 + app/controllers/cards/steps_controller.rb | 35 +++++++ app/models/card.rb | 2 +- app/models/card/multistep.rb | 11 +++ app/models/step.rb | 7 ++ app/views/cards/_container.html.erb | 2 + app/views/cards/display/_preview.html.erb | 1 + app/views/cards/display/perma/_steps.html.erb | 10 ++ .../cards/display/preview/_steps.html.erb | 8 ++ app/views/cards/steps/_step.html.erb | 8 ++ app/views/cards/steps/edit.html.erb | 18 ++++ app/views/cards/steps/show.html.erb | 3 + config/routes.rb | 1 + .../20250625120000_add_steps_to_cards.rb | 13 +++ db/schema.rb | 13 ++- db/schema_cache.yml | 82 +++++++++++++--- .../cards/steps_controller_test.rb | 55 +++++++++++ 21 files changed, 363 insertions(+), 16 deletions(-) create mode 100644 app/assets/stylesheets/steps.css create mode 100644 app/controllers/cards/steps_controller.rb create mode 100644 app/models/card/multistep.rb create mode 100644 app/models/step.rb create mode 100644 app/views/cards/display/perma/_steps.html.erb create mode 100644 app/views/cards/display/preview/_steps.html.erb create mode 100644 app/views/cards/steps/_step.html.erb create mode 100644 app/views/cards/steps/edit.html.erb create mode 100644 app/views/cards/steps/show.html.erb create mode 100644 db/migrate/20250625120000_add_steps_to_cards.rb create mode 100644 test/controllers/cards/steps_controller_test.rb diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index bdbad0bcc..90f6859c6 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -108,6 +108,10 @@ } } } + + .card__steps { + display: flex; + } } /* Column elements @@ -197,7 +201,7 @@ .card__header { color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); - margin-inline-start: calc(-0.5 * var(--card-padding-inline)); + margin-inline: calc(-0.5 * var(--card-padding-inline)) calc(-0.25 * var(--card-padding-inline)); } .card__people-label { diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 9595b98ee..4462dfb6e 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -119,8 +119,8 @@ flex-wrap: nowrap; gap: var(--inline-space); margin-block-start: calc(-1 * var(--card-padding-block)); - margin-inline-start: calc(-1 * var(--card-padding-inline)); - max-inline-size: 100%; + margin-inline: calc(-1 * var(--card-padding-inline)) calc(-0.5 * var(--card-padding-inline)); + max-inline-size: unset; min-inline-size: 0; .workflow-stage { diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index fd12630d9..2870088c2 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -30,6 +30,7 @@ .icon--camera { --svg: url("camera.svg "); } .icon--caret-down { --svg: url("caret-down.svg "); } .icon--check { --svg: url("check.svg "); } + .icon--check-all { --svg: url("check-all.svg "); } .icon--clipboard { --svg: url("clipboard.svg "); } .icon--close { --svg: url("close.svg "); } .icon--collection { --svg: url("collection.svg "); } diff --git a/app/assets/stylesheets/steps.css b/app/assets/stylesheets/steps.css new file mode 100644 index 000000000..2f526662c --- /dev/null +++ b/app/assets/stylesheets/steps.css @@ -0,0 +1,98 @@ +@layer components { + .step { + display: grid; + grid-template-columns: 1em auto auto; + gap: calc(var(--inline-space) * 2/3); + inline-size: auto; + } + + .step__checkbox { + --hover-color: var(--card-color); + + appearance: none; + background-color: var(--color-canvas); + block-size: 1.1em; + border: 1px solid currentColor; + border-radius: 0.15em; + color: currentColor; + display: grid; + font: inherit; + inline-size: 1.1em; + margin: 0; + place-content: center; + transform: translateY(0.1em); + + &::before { + background-color: CanvasText; + block-size: 0.65em; + box-shadow: inset 1em 1em currentColor; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + content: ""; + inline-size: 0.65em; + transform: scale(0); + transform-origin: center; + transition: 150ms transform ease-in-out; + } + + &:checked::before { + transform: scale(1) rotate(10deg); + } + + &:where([disabled]):not(:hover):not(:active) { + filter: none; + opacity: 0.5; + } + } + + .step__content { + --input-border-radius: 0; + --input-border-size: 0; + --input-padding: 0; + + border-bottom: 1px solid transparent; + color: currentColor; + font-weight: 500; + margin-block-end: calc(var(--block-space) * 1/3); + + &:is(a, input[type=text]) { + --hover-size: 0; + } + + .step:has(:checked) & { + opacity: 0.7; + text-decoration: line-through; + } + + &::placeholder { + color: var(--card-color); + } + } + + .step__content--edit { + border-bottom-color: currentColor; + } + + .steps { + contain: inline-size; + display: grid; + list-style: none; + margin: 0; + max-inline-size: 100%; + padding: 0; + } + + .steps__icon { + background-color: var(--card-color); + block-size: 1.3em; + border-radius: 50%; + display: grid; + inline-size: 1.3em; + place-content: center; + + .icon { + background-color: var(--color-ink-inverted); + block-size: 0.8em; + inline-size: 0.8em; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index d90c56ea8..48ae50b31 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -315,6 +315,7 @@ .card__meta .btn, .card__meta-item:not(.card__meta-item--updated), .card__stages, + .card__steps, .card__closed { display: none; } diff --git a/app/controllers/cards/steps_controller.rb b/app/controllers/cards/steps_controller.rb new file mode 100644 index 000000000..8821289b7 --- /dev/null +++ b/app/controllers/cards/steps_controller.rb @@ -0,0 +1,35 @@ +class Cards::StepsController < ApplicationController + include CardScoped + + before_action :set_step, only: %i[ show edit update destroy ] + + def create + @step = @card.steps.create!(step_params) + render_card_replacement + end + + def show + end + + def edit + end + + def update + @step.update!(step_params) + render_card_replacement + end + + def destroy + @step.destroy! + render_card_replacement + end + + private + def set_step + @step = @card.steps.find(params[:id]) + end + + def step_params + params.require(:step).permit(:content, :completed) + end +end diff --git a/app/models/card.rb b/app/models/card.rb index 94ee043d2..c83c1325b 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,6 +1,6 @@ class Card < ApplicationRecord include Assignable, Colored, Engageable, Entropic, Eventable, - Golden, Mentions, Pinnable, Closeable, Readable, Searchable, + Golden, Mentions, Multistep, Pinnable, Closeable, Readable, Searchable, Staged, Stallable, Statuses, Taggable, Watchable belongs_to :collection, touch: true diff --git a/app/models/card/multistep.rb b/app/models/card/multistep.rb new file mode 100644 index 000000000..ccd3a9786 --- /dev/null +++ b/app/models/card/multistep.rb @@ -0,0 +1,11 @@ +module Card::Multistep + extend ActiveSupport::Concern + + included do + has_many :steps, dependent: :destroy + end + + def completed_steps_count + steps.where(completed: true).count + end +end diff --git a/app/models/step.rb b/app/models/step.rb new file mode 100644 index 000000000..7451832c9 --- /dev/null +++ b/app/models/step.rb @@ -0,0 +1,7 @@ +class Step < ApplicationRecord + belongs_to :card, touch: true + + def completed? + completed + end +end diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb index f43466a2b..df7d6c917 100644 --- a/app/views/cards/_container.html.erb +++ b/app/views/cards/_container.html.erb @@ -19,6 +19,8 @@ <%= render "cards/stagings/stages", card: card if card.doing? %> + <%= render "cards/display/perma/steps", card: card %> +