diff --git a/.kamal/secrets.production b/.kamal/secrets.production index 5326c7214..9ebe90ef6 100644 --- a/.kamal/secrets.production +++ b/.kamal/secrets.production @@ -1,6 +1,12 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) +MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) +MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) +MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) +MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) +MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) +MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) diff --git a/.kamal/secrets.staging b/.kamal/secrets.staging index 46eccf3fd..da2952efa 100644 --- a/.kamal/secrets.staging +++ b/.kamal/secrets.staging @@ -1,6 +1,12 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) +MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) +MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) +MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) +MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) +MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) +MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) diff --git a/.rubocop.yml b/.rubocop.yml index f9d86d4a5..88329d008 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,3 +6,7 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml } # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` # Layout/SpaceInsideArrayLiteralBrackets: # Enabled: false + +AllCops: + Exclude: + - 'db/migrate/**/*' diff --git a/Dockerfile b/Dockerfile index 466fd82d0..6341f3fc1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,3 @@ -# syntax = docker/dockerfile:1 - # Make sure RUBY_VERSION matches the Ruby version in .ruby-version ARG RUBY_VERSION=3.4.7 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base diff --git a/Gemfile b/Gemfile index 17e33abd2..a74c6ed43 100644 --- a/Gemfile +++ b/Gemfile @@ -10,7 +10,6 @@ gem "stimulus-rails" gem "turbo-rails" # Deployment and drivers -gem "activerecord-tenanted", bc: "activerecord-tenanted" gem "bootsnap", require: false gem "kamal", require: false gem "puma", ">= 5.0" @@ -19,7 +18,7 @@ gem "solid_cache", "~> 1.0" gem "solid_queue", "~> 1.2" gem "sqlite3", ">= 2.0" gem "thruster", require: false -gem "beamer-rails", "~> 0.1.0.beta4" +gem "trilogy", "~> 2.9" # Features gem "bcrypt", "~> 3.1.7" @@ -34,6 +33,7 @@ gem "platform_agent" gem "aws-sdk-s3", require: false gem "web-push" gem "net-http-persistent" +gem "mittens" # Telemetry, logging, and operations gem "mission_control-jobs" @@ -54,12 +54,13 @@ gem "autotuner" gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings group :development, :test do - gem "debug" - gem "bundler-audit", require: false gem "brakeman", require: false - gem "rubocop-rails-omakase", require: false - gem "letter_opener" + gem "bundler-audit", require: false + gem "debug" gem "faker" + gem "letter_opener" + gem "rack-mini-profiler" + gem "rubocop-rails-omakase", require: false end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 16162cb8b..d7a3121dc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,12 +1,3 @@ -GIT - remote: https://github.com/basecamp/activerecord-tenanted - revision: 1e92cb34d0b7b02875040826330c1918abb06e9d - specs: - activerecord-tenanted (0.6.0) - activerecord (>= 8.1.beta) - railties (>= 8.1.beta) - zeitwerk - GIT remote: https://github.com/basecamp/queenbee-plugin revision: eb01c697de1ad028afc65cc7d9b5345a7a8e849f @@ -36,7 +27,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: bc41ad696a76a151db56259938dccf64ef87b501 + revision: 077c3ad60db4c43cccb8f4637b53b8d8eb7a3c19 branch: main specs: actioncable (8.2.0.alpha) @@ -161,10 +152,10 @@ GEM anyway_config (2.7.2) ruby-next-core (~> 1.0) ast (2.4.3) - autotuner (1.1.0) + autotuner (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1181.0) - aws-sdk-core (3.236.0) + aws-partitions (1.1175.0) + aws-sdk-core (3.234.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -172,10 +163,10 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.117.0) + aws-sdk-kms (1.115.0) aws-sdk-core (~> 3, >= 3.234.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.203.0) + aws-sdk-s3 (1.201.0) aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -184,22 +175,12 @@ GEM base64 (0.3.0) bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) - beamer-rails (0.1.0.beta4-arm64-darwin) - sqlite3 (>= 2.0) - beamer-rails (0.1.0.beta4-arm64-linux-gnu) - sqlite3 (>= 2.0) - beamer-rails (0.1.0.beta4-x86_64-darwin) - sqlite3 (>= 2.0) - beamer-rails (0.1.0.beta4-x86_64-linux-gnu) - sqlite3 (>= 2.0) - beamer-rails (0.1.0.beta4-x86_64-linux-musl) - sqlite3 (>= 2.0) benchmark (0.5.0) bigdecimal (3.3.1) bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.1.1) + brakeman (7.1.0) racc builder (3.3.0) bundler-audit (0.9.2) @@ -231,7 +212,7 @@ GEM drb (2.2.3) dry-initializer (3.2.0) ed25519 (1.4.0) - erb (5.1.3) + erb (6.0.0) erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -320,6 +301,7 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails + mittens (0.3.0) mocha (2.7.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -402,6 +384,8 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.2.4) + rack-mini-profiler (4.0.1) + rack (>= 1.2.0) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -477,10 +461,10 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (6.1.0) + sentry-rails (6.0.0) railties (>= 5.2.0) - sentry-ruby (~> 6.1.0) - sentry-ruby (6.1.0) + sentry-ruby (~> 6.0.0) + sentry-ruby (6.0.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) sniffer (0.5.0) @@ -491,23 +475,23 @@ GEM activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_cache (1.0.10) + solid_cache (1.0.8) activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.2.4) + solid_queue (1.2.2) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.8.0) + sqlite3 (2.7.4) mini_portile2 (~> 2.8.0) - sqlite3 (2.8.0-arm64-darwin) - sqlite3 (2.8.0-x86_64-darwin) - sqlite3 (2.8.0-x86_64-linux-gnu) - sqlite3 (2.8.0-x86_64-linux-musl) + sqlite3 (2.7.4-arm64-darwin) + sqlite3 (2.7.4-x86_64-darwin) + sqlite3 (2.7.4-x86_64-linux-gnu) + sqlite3 (2.7.4-x86_64-linux-musl) sshkit (1.24.0) base64 logger @@ -517,13 +501,14 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.7) + stringio (3.1.8) thor (1.4.0) thruster (0.1.16) thruster (0.1.16-arm64-darwin) thruster (0.1.16-x86_64-darwin) thruster (0.1.16-x86_64-linux) timeout (0.4.4) + trilogy (2.9.0) tsort (0.2.0) turbo-rails (2.0.20) actionpack (>= 7.1.0) @@ -545,7 +530,7 @@ GEM web-push (3.0.2) jwt (~> 3.0) openssl (~> 3.0) - webmock (3.26.1) + webmock (3.26.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -595,12 +580,10 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - activerecord-tenanted! activeresource autotuner aws-sdk-s3 bcrypt (~> 3.1.7) - beamer-rails (~> 0.1.0.beta4) benchmark bootsnap brakeman @@ -617,6 +600,7 @@ DEPENDENCIES letter_opener lexxy mission_control-jobs + mittens mocha net-http-persistent platform_agent @@ -624,6 +608,7 @@ DEPENDENCIES propshaft puma (>= 5.0) queenbee! + rack-mini-profiler rails! rails_structured_logging! redcarpet @@ -639,6 +624,7 @@ DEPENDENCIES sqlite3 (>= 2.0) stimulus-rails thruster + trilogy (~> 2.9) turbo-rails vcr web-console diff --git a/Procfile.beamer-testbed b/Procfile.beamer-testbed deleted file mode 100644 index 05fe908af..000000000 --- a/Procfile.beamer-testbed +++ /dev/null @@ -1,3 +0,0 @@ -web: bin/thrust bin/rails server -beamer: bin/beamer --debug --directory ./storage/ run --primary ${BEAMER_PRIMARY:-$(hostname)} -migrations: bin/beamer-testbed run-migrations --debug --directory ./storage/ diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 6a758e76e..0419e0548 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -133,7 +133,7 @@ background-color: var(--color-container); border-radius: 0.2em; grid-area: card; - padding: clamp(2rem, 4vw, var(--padding-block)) clamp(16px, 4vw, var(--padding-inline)); + padding: clamp(2rem, 4vw, var(--padding-block)); } .card-perma__actions { diff --git a/app/assets/stylesheets/header.css b/app/assets/stylesheets/header.css index 46a345d3c..59d94a0f3 100644 --- a/app/assets/stylesheets/header.css +++ b/app/assets/stylesheets/header.css @@ -82,13 +82,35 @@ .header__logo { color: var(--color-ink); + font-size: 1.2rem; inline-size: auto; margin-block-start: 0.1em; - img { + span { + background: var(--color-ink-lightest); block-size: auto; - inline-size: 1.15em; - margin-inline-end: 0.8ch; + border-radius: 0.3125em; + box-shadow: + 0 0 0 1px oklch(var(--lch-ink-darkest) / 0.1), + 0 0.1em 0.2em -0.1em oklch(var(--lch-ink-darkest) / 0.05), + 0 0.2em 0.4em -0.2em oklch(var(--lch-ink-darkest) / 0.05), + 0 0.3em 0.6em -0.3em oklch(var(--lch-ink-darkest) / 0.05) + ; + display: grid; + height: 1.5em; + inline-size: 1.5em; + padding: 0.325em 0.275em 0.225em 0.275em; + place-content: center; + width: 1.5em; + } + + svg { + height: 100%; + margin-inline-start: 0.4125em; + margin-inline-end: 0.5375em; + max-height: 0.8625em; + overflow: visible; + width: auto; } } diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 7d9903996..c8b935d7c 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -48,6 +48,10 @@ margin: 0; } } + + @supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) { + font-size: max(16px, 1em) !important; + } } .input--file { diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index fda877c20..3a8836e84 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -42,11 +42,19 @@ display: grid; height: 1.5em; inline-size: 1.5em; - margin-inline-end: 0.8ch; padding: 0.325em 0.275em 0.225em 0.275em; place-content: center; width: 1.5em; } + + svg { + height: 100%; + margin-inline-start: 0.4125em; + margin-inline-end: 0.5375em; + max-height: 0.8625em; + overflow: visible; + width: auto; + } } /* Dialog diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 6ad6b2089..1c61680f1 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -49,6 +49,8 @@ .contain { contain: inline-size; } + .display-inline { display: inline; } + .flex { display: flex; } .flex-inline { display: inline-flex; } .flex-column { flex-direction: column; } diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index f1e997503..1c11cdda6 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -3,15 +3,15 @@ module ApplicationCable identified_by :current_user def connect - super set_current_user || reject_unauthorized_connection end private def set_current_user if session = find_session_by_cookie - membership = session.identity.memberships.find_by!(tenant: current_tenant) - self.current_user = membership.user if membership.user.active? + account = Account.find_by(external_account_id: request.env["fizzy.external_account_id"]) + Current.account = account + self.current_user = session.identity.users.find_by!(account: account) if account end end diff --git a/app/controllers/account/entropies_controller.rb b/app/controllers/account/entropies_controller.rb index 00d195584..9876e99d8 100644 --- a/app/controllers/account/entropies_controller.rb +++ b/app/controllers/account/entropies_controller.rb @@ -2,7 +2,7 @@ class Account::EntropiesController < ApplicationController before_action :ensure_admin def update - Account.sole.entropy.update!(entropy_params) + Current.account.entropy.update!(entropy_params) redirect_to account_settings_path, notice: "Account updated" end diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb index 562ffaa0d..fba2ccfe9 100644 --- a/app/controllers/account/join_codes_controller.rb +++ b/app/controllers/account/join_codes_controller.rb @@ -20,7 +20,7 @@ class Account::JoinCodesController < ApplicationController private def set_join_code - @join_code = Account::JoinCode.sole + @join_code = Current.account.join_code end def join_code_params diff --git a/app/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb index 42a6a60f7..a076479cb 100644 --- a/app/controllers/account/settings_controller.rb +++ b/app/controllers/account/settings_controller.rb @@ -3,7 +3,7 @@ class Account::SettingsController < ApplicationController before_action :set_account def show - @users = User.active.alphabetically + @users = @account.users.active.alphabetically end def update @@ -13,7 +13,7 @@ class Account::SettingsController < ApplicationController private def set_account - @account = Account.sole + @account = Current.account end def account_params diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 235b9c2a0..de3bd535a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,13 +1,12 @@ class ApplicationController < ActionController::Base - include LoadBalancerRouting, WriterAffinity include Authentication include Authorization include CurrentRequest, CurrentTimezone, SetPlatform include TurboFlash, ViewTransitions include Saas + include RoutingHeaders, WriterAffinity + etag { "v1" } stale_when_importmap_changes allow_browser versions: :modern - - etag { "v1" } # 2025-11-05 @todo: To invalidate HTTP cache after big renaming. To remove after a few days. end diff --git a/app/controllers/boards/columns/closeds_controller.rb b/app/controllers/boards/columns/closeds_controller.rb index b9aad40b3..95e0c7126 100644 --- a/app/controllers/boards/columns/closeds_controller.rb +++ b/app/controllers/boards/columns/closeds_controller.rb @@ -2,7 +2,7 @@ class Boards::Columns::ClosedsController < ApplicationController include BoardScoped def show - set_page_and_extract_portion_from @board.cards.closed.recently_closed_first + set_page_and_extract_portion_from @board.cards.closed.recently_closed_first.preloaded fresh_when etag: @page.records end end diff --git a/app/controllers/boards/columns/not_nows_controller.rb b/app/controllers/boards/columns/not_nows_controller.rb index a65f538c4..6cb765979 100644 --- a/app/controllers/boards/columns/not_nows_controller.rb +++ b/app/controllers/boards/columns/not_nows_controller.rb @@ -2,7 +2,7 @@ class Boards::Columns::NotNowsController < ApplicationController include BoardScoped def show - set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first + set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first.preloaded fresh_when etag: @page.records end end diff --git a/app/controllers/boards/columns/streams_controller.rb b/app/controllers/boards/columns/streams_controller.rb index 03b48d900..d2c7d0795 100644 --- a/app/controllers/boards/columns/streams_controller.rb +++ b/app/controllers/boards/columns/streams_controller.rb @@ -2,7 +2,7 @@ class Boards::Columns::StreamsController < ApplicationController include BoardScoped def show - set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first + set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded fresh_when etag: @page.records end end diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb index 1648da401..fa4f3e072 100644 --- a/app/controllers/boards/columns_controller.rb +++ b/app/controllers/boards/columns_controller.rb @@ -4,7 +4,7 @@ class Boards::ColumnsController < ApplicationController before_action :set_column, only: %i[ show update destroy ] def show - set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first + set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded fresh_when etag: @page.records end diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 70383e139..258bffc1a 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -24,7 +24,7 @@ class BoardsController < ApplicationController def edit selected_user_ids = @board.users.pluck :id @selected_users, @unselected_users = \ - User.active.alphabetically.partition { |user| selected_user_ids.include? user.id } + @board.account.users.active.alphabetically.partition { |user| selected_user_ids.include? user.id } end def update @@ -64,7 +64,8 @@ class BoardsController < ApplicationController end def show_columns - set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first + cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded + set_page_and_extract_portion_from cards fresh_when etag: [ @board, @page.records, @user_filtering ] end @@ -73,7 +74,7 @@ class BoardsController < ApplicationController end def grantees - User.active.where id: grantee_ids + @board.account.users.active.where id: grantee_ids end def revokees diff --git a/app/controllers/cards/boards_controller.rb b/app/controllers/cards/boards_controller.rb index dfbc522a4..4a8e503ab 100644 --- a/app/controllers/cards/boards_controller.rb +++ b/app/controllers/cards/boards_controller.rb @@ -16,6 +16,6 @@ class Cards::BoardsController < ApplicationController private def set_card - @card = Current.user.accessible_cards.find(params[:card_id]) + @card = Current.user.accessible_cards.find_by!(number: params[:card_id]) end end diff --git a/app/controllers/cards/columns_controller.rb b/app/controllers/cards/columns_controller.rb index 3e32b1cb8..54a69f036 100644 --- a/app/controllers/cards/columns_controller.rb +++ b/app/controllers/cards/columns_controller.rb @@ -1,6 +1,6 @@ class Cards::ColumnsController < ApplicationController def edit - @card = Current.user.accessible_cards.find(params[:card_id]) + @card = Current.user.accessible_cards.find_by!(number: params[:card_id]) @columns = @card.board.columns.sorted fresh_when etag: [ @card, @columns ] diff --git a/app/controllers/cards/taggings_controller.rb b/app/controllers/cards/taggings_controller.rb index 16227011d..d388fae22 100644 --- a/app/controllers/cards/taggings_controller.rb +++ b/app/controllers/cards/taggings_controller.rb @@ -2,7 +2,7 @@ class Cards::TaggingsController < ApplicationController include CardScoped def new - @tags = Tag.all.alphabetically + @tags = Current.account.tags.all.alphabetically fresh_when etag: [ @tags, @card.tags ] end diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 12d530717..a8762a9cb 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -21,10 +21,7 @@ class CardsController < ApplicationController end def update - suppressing_broadcasts_unless_published(@card) do - @card.update! card_params - end - + @card.update! card_params redirect_to @card end @@ -39,21 +36,13 @@ class CardsController < ApplicationController end def set_card - @card = Current.user.accessible_cards.find params[:id] + @card = Current.user.accessible_cards.find_by!(number: params[:id]) end def ensure_permission_to_administer_card head :forbidden unless Current.user.can_administer_card?(@card) end - def suppressing_broadcasts_unless_published(card, &block) - if card.published? - yield - else - Board.suppressing_turbo_broadcasts(&block) - end - end - def card_params params.expect(card: [ :status, :title, :description, :image, tag_ids: [] ]) end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 5afbcccf8..f4ad6a013 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -2,10 +2,7 @@ module Authentication extend ActiveSupport::Concern included do - # Checking for tenant must happen first so we redirect before trying to access the db. - before_action :require_tenant - prepend_before_action :clear_old_scoped_session_cookies - + before_action :require_account # Checking and setting account must happen first before_action :require_authentication helper_method :authenticated? @@ -26,8 +23,8 @@ module Authentication allow_unauthorized_access **options end - def require_untenanted_access(**options) - skip_before_action :require_tenant, **options + def disallow_account_scope(**options) + skip_before_action :require_account, **options before_action :redirect_tenanted_request, **options end end @@ -37,8 +34,8 @@ module Authentication Current.session.present? end - def require_tenant - if ApplicationRecord.current_tenant.blank? + def require_account + unless Current.account.present? redirect_to session_menu_url(script_name: nil) end end @@ -53,19 +50,12 @@ module Authentication end end - # FIXME: Remove before launch - def clear_old_scoped_session_cookies - if request.script_name.present? && cookies.signed[:session_token].present? && !find_session_by_cookie - cookies.signed[:session_token] = { value: "invalid-session-token", path: request.script_name, expires: 1.hour.ago } - end - end - def find_session_by_cookie Session.find_signed(cookies.signed[:session_token]) end def request_authentication - if ApplicationRecord.current_tenant.present? + if Current.account.present? session[:return_to_after_authenticating] = request.url end @@ -81,7 +71,7 @@ module Authentication end def redirect_tenanted_request - redirect_to root_url if ApplicationRecord.current_tenant + redirect_to root_url if Current.account.present? end def start_new_session_for(identity) diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 6d31f844e..9a3a8279a 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -2,7 +2,7 @@ module Authorization extend ActiveSupport::Concern included do - before_action :ensure_can_access_account, if: -> { ApplicationRecord.current_tenant && authenticated? } + before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? } end class_methods do @@ -26,13 +26,7 @@ module Authorization end def ensure_can_access_account - if Current.membership.blank? - redirect_to session_menu_url(script_name: nil) - elsif Current.user.nil? && Current.membership.join_code.present? - redirect_to new_users_join_path - elsif !Current.user&.active? - redirect_to unlink_membership_url(script_name: nil, membership_id: Current.membership.signed_id(purpose: :unlinking)) - end + redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active? end def redirect_existing_user diff --git a/app/controllers/concerns/card_scoped.rb b/app/controllers/concerns/card_scoped.rb index 77496e160..52311809e 100644 --- a/app/controllers/concerns/card_scoped.rb +++ b/app/controllers/concerns/card_scoped.rb @@ -7,7 +7,7 @@ module CardScoped private def set_card - @card = Current.user.accessible_cards.find(params[:card_id]) + @card = Current.user.accessible_cards.find_by!(number: params[:card_id]) end def set_board diff --git a/app/controllers/concerns/column_scoped.rb b/app/controllers/concerns/column_scoped.rb index ea002b95a..92d0715fa 100644 --- a/app/controllers/concerns/column_scoped.rb +++ b/app/controllers/concerns/column_scoped.rb @@ -7,6 +7,6 @@ module ColumnScoped private def set_column - @column = Column.find(params[:column_id]) + @column = Current.account.columns.find(params[:column_id]) end end diff --git a/app/controllers/concerns/load_balancer_routing.rb b/app/controllers/concerns/load_balancer_routing.rb deleted file mode 100644 index 5ddc1cfa9..000000000 --- a/app/controllers/concerns/load_balancer_routing.rb +++ /dev/null @@ -1,39 +0,0 @@ -module LoadBalancerRouting - extend ActiveSupport::Concern - - ALLOWED_PRAGMAS = %w[ beamer_primary beamer_last_txn ] - - included do - before_action :set_target_header, :set_writer_header - after_action :set_transaction_cookie - end - - private - def set_target_header - response.headers["X-Kamal-Target"] = request.headers["X-Kamal-Target"] - end - - def set_writer_header - if ApplicationRecord.current_tenant.present? - response.headers["X-Writer"] = pragma("beamer_primary") - end - end - - def set_transaction_cookie - unless safe_request? - if ApplicationRecord.current_tenant.present? && Account.sole.present? - cookies[:last_transaction] = { value: pragma("beamer_last_txn"), path: Account.sole.slug } - end - end - end - - def pragma(name) - if ALLOWED_PRAGMAS.include?(name) - ApplicationRecord.connection.execute("pragma #{name}").first&.values&.first - end - end - - def safe_request? - request.get? || request.head? - end -end diff --git a/app/controllers/concerns/routing_headers.rb b/app/controllers/concerns/routing_headers.rb new file mode 100644 index 000000000..195f9bdc7 --- /dev/null +++ b/app/controllers/concerns/routing_headers.rb @@ -0,0 +1,12 @@ +module RoutingHeaders + extend ActiveSupport::Concern + + included do + before_action :set_target_header + end + + private + def set_target_header + response.headers["X-Kamal-Target"] = request.headers["X-Kamal-Target"] + end +end diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb index a4b6768b9..6d790c232 100644 --- a/app/controllers/join_codes_controller.rb +++ b/app/controllers/join_codes_controller.rb @@ -1,43 +1,38 @@ class JoinCodesController < ApplicationController - require_untenanted_access allow_unauthenticated_access + before_action :set_join_code before_action :ensure_join_code_is_valid layout "public" def new - @account_name = ApplicationRecord.with_tenant(tenant) { Account.sole.name } end def create - Identity.transaction do - identity = Identity.find_or_create_by!(email_address: params.expect(:email_address)) - identity.memberships.find_or_create_by!(tenant: tenant) do |membership| - membership.join_code = code - end + identity = Identity.find_or_create_by!(email_address: params.expect(:email_address)) + + @join_code.redeem { |account| identity.join(account) } unless identity.member_of?(@join_code.account) + + if identity == Current.identity + redirect_to landing_url(script_name: @join_code.account.slug) + else + terminate_session if Current.identity + magic_link = identity.send_magic_link flash[:magic_link_code] = magic_link&.code if Rails.env.development? - end - session[:return_to_after_authenticating] = landing_url(script_name: "/#{tenant}") - redirect_to session_magic_link_path + session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug) + redirect_to session_magic_link_url(script_name: nil) + end end private + def set_join_code + @join_code ||= Account::JoinCode.active.find_by(code: params.expect(:code), account: Current.account) + end + def ensure_join_code_is_valid head :not_found unless @join_code&.active? end - - def set_join_code - @join_code ||= ApplicationRecord.with_tenant(tenant) { Account::JoinCode.active.find_by(code: code) } - end - - def tenant - params.expect(:tenant) - end - - def code - params.expect(:code) - end end diff --git a/app/controllers/memberships/email_addresses/confirmations_controller.rb b/app/controllers/memberships/email_addresses/confirmations_controller.rb deleted file mode 100644 index b53fdc135..000000000 --- a/app/controllers/memberships/email_addresses/confirmations_controller.rb +++ /dev/null @@ -1,28 +0,0 @@ -class Memberships::EmailAddresses::ConfirmationsController < ApplicationController - require_untenanted_access - allow_unauthenticated_access - - before_action :set_membership - rate_limit to: 5, within: 1.hour, only: :create - - def show - end - - def create - membership = Membership.change_email_address_using_token(token) - - terminate_session if Current.session - start_new_session_for membership.reload.identity - - redirect_to edit_user_url(script_name: "/#{@membership.tenant}", id: @membership.user) - end - - private - def set_membership - @membership = Membership.find(params[:membership_id]) - end - - def token - params.expect :email_address_token - end -end diff --git a/app/controllers/memberships/email_addresses_controller.rb b/app/controllers/memberships/email_addresses_controller.rb deleted file mode 100644 index 0870410a9..000000000 --- a/app/controllers/memberships/email_addresses_controller.rb +++ /dev/null @@ -1,31 +0,0 @@ -class Memberships::EmailAddressesController < ApplicationController - require_untenanted_access - - layout "public" - - before_action :set_membership - rate_limit to: 5, within: 1.hour, only: :create - - def new - end - - def create - identity = Identity.find_by_email_address(new_email_address) - - if identity&.memberships&.exists?(tenant: @membership.tenant) - flash[:alert] = "You already have a user in this account with that email address" - redirect_to new_email_address_path - else - @membership.send_email_address_change_confirmation(new_email_address) - end - end - - private - def set_membership - @membership = Current.identity.memberships.find(params[:membership_id]) - end - - def new_email_address - params.expect :email_address - end -end diff --git a/app/controllers/memberships/unlink_controller.rb b/app/controllers/memberships/unlink_controller.rb deleted file mode 100644 index 63664aca8..000000000 --- a/app/controllers/memberships/unlink_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -class Memberships::UnlinkController < ApplicationController - require_untenanted_access - before_action :set_membership - - def show - end - - def create - @membership.destroy - redirect_to session_menu_path - end - - private - def set_membership - @membership = Current.identity.memberships.find_signed!(params[:membership_id], purpose: :unlinking) - end -end diff --git a/app/controllers/my/menus_controller.rb b/app/controllers/my/menus_controller.rb index a2c1c96eb..7a5754593 100644 --- a/app/controllers/my/menus_controller.rb +++ b/app/controllers/my/menus_controller.rb @@ -2,8 +2,8 @@ class My::MenusController < ApplicationController def show @filters = Current.user.filters.all @boards = Current.user.boards.ordered_by_recently_accessed - @tags = Tag.all.alphabetically - @users = User.active.alphabetically + @tags = Current.account.tags.all.alphabetically + @users = Current.account.users.active.alphabetically fresh_when etag: [ @filters, @boards, @tags, @users ] end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index d188e3a1e..8adb04b3c 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -1,7 +1,7 @@ class NotificationsController < ApplicationController def index - @unread = Current.user.notifications.unread.ordered unless current_page_param - set_page_and_extract_portion_from Current.user.notifications.read.ordered + @unread = Current.user.notifications.unread.ordered.preloaded unless current_page_param + set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded respond_to do |format| format.turbo_stream if current_page_param # Allows read-all action to side step pagination diff --git a/app/controllers/prompts/cards_controller.rb b/app/controllers/prompts/cards_controller.rb index 77a64178f..4bfb36484 100644 --- a/app/controllers/prompts/cards_controller.rb +++ b/app/controllers/prompts/cards_controller.rb @@ -20,7 +20,7 @@ class Prompts::CardsController < ApplicationController def search_cards published_cards - .mentioning(params[:filter]) + .mentioning(params[:filter], user: Current.user) .reverse_chronologically .limit(MAX_RESULTS) end @@ -30,7 +30,7 @@ class Prompts::CardsController < ApplicationController end def prepending_exact_matches_by_id(cards) - if card_by_id = Current.user.accessible_cards.find_by_id(params[:filter]) + if card_by_id = Current.user.accessible_cards.find_by(number: params[:filter]) [ card_by_id ] + cards else cards diff --git a/app/controllers/prompts/tags_controller.rb b/app/controllers/prompts/tags_controller.rb index 5a90b23fb..3d32a071e 100644 --- a/app/controllers/prompts/tags_controller.rb +++ b/app/controllers/prompts/tags_controller.rb @@ -1,6 +1,6 @@ class Prompts::TagsController < ApplicationController def index - @tags = Tag.all.alphabetically + @tags = Current.account.tags.all.alphabetically if stale? etag: @tags render layout: false diff --git a/app/controllers/prompts/users_controller.rb b/app/controllers/prompts/users_controller.rb index c926e2de4..6f5028eec 100644 --- a/app/controllers/prompts/users_controller.rb +++ b/app/controllers/prompts/users_controller.rb @@ -1,6 +1,6 @@ class Prompts::UsersController < ApplicationController def index - @users = User.active.alphabetically + @users = Current.account.users.active.alphabetically if stale? etag: @users render layout: false diff --git a/app/controllers/public/base_controller.rb b/app/controllers/public/base_controller.rb index 28a1bf463..b4c33e95b 100644 --- a/app/controllers/public/base_controller.rb +++ b/app/controllers/public/base_controller.rb @@ -11,7 +11,7 @@ class Public::BaseController < ApplicationController end def set_card - @card = @board.cards.find(params[:id]) if params[:board_id] && params[:id] + @card = @board.cards.find_by!(number: params[:id]) if params[:board_id] && params[:id] end def set_public_cache_expiration diff --git a/app/controllers/pwa_controller.rb b/app/controllers/pwa_controller.rb index bd6127182..ab8f605a7 100644 --- a/app/controllers/pwa_controller.rb +++ b/app/controllers/pwa_controller.rb @@ -1,5 +1,5 @@ class PwaController < ApplicationController - require_untenanted_access + disallow_account_scope skip_forgery_protection # We need a stable URL at the root, so we can't use the regular asset path here. diff --git a/app/controllers/qr_codes_controller.rb b/app/controllers/qr_codes_controller.rb index 42917d64b..aa8ca8334 100644 --- a/app/controllers/qr_codes_controller.rb +++ b/app/controllers/qr_codes_controller.rb @@ -3,6 +3,11 @@ class QrCodesController < ApplicationController def show expires_in 1.year, public: true - render svg: RQRCode::QRCode.new(QrCodeLink.from_signed(params[:id]).url).as_svg(viewbox: true, fill: :white, color: :black) + + qr_code_svg = RQRCode::QRCode + .new(QrCodeLink.from_signed(params[:id]).url) + .as_svg(viewbox: true, fill: :white, color: :black) + + render svg: qr_code_svg end end diff --git a/app/controllers/searches_controller.rb b/app/controllers/searches_controller.rb index b36d0b0d7..5176cea02 100644 --- a/app/controllers/searches_controller.rb +++ b/app/controllers/searches_controller.rb @@ -5,13 +5,8 @@ class SearchesController < ApplicationController if card = Current.user.accessible_cards.find_by_id(params[:q]) @card = card else - perform_search - end - end - - private - def perform_search set_page_and_extract_portion_from Current.user.search(params[:q]) @recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10) end + end end diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 3b836e445..40de894df 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -1,5 +1,5 @@ class Sessions::MagicLinksController < ApplicationController - require_untenanted_access + disallow_account_scope require_unauthenticated_access rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Try again in 15 minutes." } diff --git a/app/controllers/sessions/menus_controller.rb b/app/controllers/sessions/menus_controller.rb index 7ad3b6a09..984e83a43 100644 --- a/app/controllers/sessions/menus_controller.rb +++ b/app/controllers/sessions/menus_controller.rb @@ -1,24 +1,13 @@ class Sessions::MenusController < ApplicationController - require_untenanted_access - - before_action(if: :render_as_menu_section?) { request.variant = :menu_section } + disallow_account_scope layout "public" def show - @memberships = Current.identity.memberships + @accounts = Current.identity.accounts - if params[:without] - @memberships = @memberships.where.not(tenant: params[:without]) - end - - if @memberships.one? && !render_as_menu_section? - redirect_to root_url(script_name: "/#{@memberships.first.tenant}") + if @accounts.one? + redirect_to root_url(script_name: @accounts.first.slug) end end - - private - def render_as_menu_section? - params[:menu_section] - end end diff --git a/app/controllers/sessions/transfers_controller.rb b/app/controllers/sessions/transfers_controller.rb index b3b7ae773..0c28e1fe3 100644 --- a/app/controllers/sessions/transfers_controller.rb +++ b/app/controllers/sessions/transfers_controller.rb @@ -1,5 +1,5 @@ class Sessions::TransfersController < ApplicationController - require_untenanted_access + disallow_account_scope require_unauthenticated_access def show diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 8f8222ed9..36d30d6fe 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,10 +1,14 @@ class SessionsController < ApplicationController # FIXME: Remove this before launch! - SIGNUP_USERNAME = Rails.env.local? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name - SIGNUP_PASSWORD = Rails.env.local? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password - http_basic_authenticate_with name: SIGNUP_USERNAME, password: SIGNUP_PASSWORD, realm: "Fizzy Signup", only: :create, unless: -> { Identity.exists?(email_address: email_address) } + if Rails.env.remote? + http_basic_authenticate_with \ + name: Rails.application.credentials.account_signup_http_basic_auth.name, + password: Rails.application.credentials.account_signup_http_basic_auth.password, + realm: "Fizzy Signup", + only: :create, unless: -> { Identity.exists?(email_address: email_address) } + end - require_untenanted_access + disallow_account_scope require_unauthenticated_access except: :destroy rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } @@ -20,7 +24,7 @@ class SessionsController < ApplicationController redirect_to session_magic_link_path elsif signups_allowed? Signup.new(email_address: email_address).create_identity - session[:return_to_after_authenticating] = saas.new_signup_membership_path + session[:return_to_after_authenticating] = saas.new_signup_completion_path redirect_to session_magic_link_path end end diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index 8be049cc3..425c194b1 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -19,11 +19,11 @@ class Users::AvatarsController < ApplicationController private def set_user - @user = User.find(params[:user_id]) + @user = Current.account.users.find(params[:user_id]) end def ensure_permission_to_administer_user - head :forbidden unless Current.user.admin? || Current.user == @user + head :forbidden unless Current.user.can_change?(@user) end def render_avatar_or_initials diff --git a/app/controllers/users/email_addresses/confirmations_controller.rb b/app/controllers/users/email_addresses/confirmations_controller.rb new file mode 100644 index 000000000..dad998671 --- /dev/null +++ b/app/controllers/users/email_addresses/confirmations_controller.rb @@ -0,0 +1,27 @@ +class Users::EmailAddresses::ConfirmationsController < ApplicationController + allow_unauthenticated_access + + before_action :set_user + rate_limit to: 5, within: 1.hour, only: :create + + def show + end + + def create + user = @user.change_email_address_using_token(token) + + terminate_session if Current.session + start_new_session_for user.identity + + redirect_to edit_user_url(script_name: user.account.slug, id: user) + end + + private + def set_user + @user = Current.account.users.active.find(params[:user_id]) + end + + def token + params.expect :email_address_token + end +end diff --git a/app/controllers/users/email_addresses_controller.rb b/app/controllers/users/email_addresses_controller.rb new file mode 100644 index 000000000..69957b1f6 --- /dev/null +++ b/app/controllers/users/email_addresses_controller.rb @@ -0,0 +1,27 @@ +class Users::EmailAddressesController < ApplicationController + before_action :set_user + rate_limit to: 5, within: 1.hour, only: :create + + def new + end + + def create + identity = Identity.find_by_email_address(new_email_address) + + if identity&.users&.exists?(account: @user.account) + flash[:alert] = "You already have a user in this account with that email address" + redirect_to new_user_email_address_path(@user) + else + @user.send_email_address_change_confirmation(new_email_address) + end + end + + private + def set_user + @user = Current.identity.users.find(params[:user_id]) + end + + def new_email_address + params.expect :email_address + end +end diff --git a/app/controllers/users/events_controller.rb b/app/controllers/users/events_controller.rb index 2f1e3afb0..e310ed9a4 100644 --- a/app/controllers/users/events_controller.rb +++ b/app/controllers/users/events_controller.rb @@ -12,7 +12,7 @@ class Users::EventsController < ApplicationController private def set_user - @user = User.active.find(params[:user_id]) + @user = Current.account.users.active.find(params[:user_id]) end def day_param diff --git a/app/controllers/users/joins_controller.rb b/app/controllers/users/joins_controller.rb index 73a9ae296..6baa888ae 100644 --- a/app/controllers/users/joins_controller.rb +++ b/app/controllers/users/joins_controller.rb @@ -1,32 +1,15 @@ class Users::JoinsController < ApplicationController - require_access_without_a_user - - before_action :set_join_code, :ensure_join_code_is_valid - layout "public" def new end def create - @join_code.redeem do - User.create!(user_params.merge(membership: Current.membership)) - end - + Current.user.update!(user_params) redirect_to landing_path end private - def set_join_code - @join_code = Account::JoinCode.active.find_by(code: Current.membership.join_code) - end - - def ensure_join_code_is_valid - unless @join_code&.active? - redirect_to unlink_membership_url(script_name: nil, membership_id: Current.membership.signed_id(purpose: :unlinking)) - end - end - def user_params params.expect(user: [ :name, :avatar ]) end diff --git a/app/controllers/users/roles_controller.rb b/app/controllers/users/roles_controller.rb index f9eb7d232..b881bdcfc 100644 --- a/app/controllers/users/roles_controller.rb +++ b/app/controllers/users/roles_controller.rb @@ -9,7 +9,7 @@ class Users::RolesController < ApplicationController private def set_user - @user = User.active.find(params[:user_id]) + @user = Current.account.users.active.find(params[:user_id]) end def ensure_permission_to_administer_user diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 2ba535fef..ae800c75d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,11 +1,11 @@ class UsersController < ApplicationController - before_action :set_user, only: %i[ show edit update destroy ] + before_action :set_user before_action :ensure_permission_to_change_user, only: %i[ update destroy ] - def edit + def show end - def show + def edit end def update @@ -20,7 +20,7 @@ class UsersController < ApplicationController private def set_user - @user = User.active.find(params[:id]) + @user = Current.account.users.active.find(params[:id]) end def ensure_permission_to_change_user diff --git a/app/controllers/webhooks/activations_controller.rb b/app/controllers/webhooks/activations_controller.rb index 87b05cf91..cbe3e9e32 100644 --- a/app/controllers/webhooks/activations_controller.rb +++ b/app/controllers/webhooks/activations_controller.rb @@ -2,7 +2,7 @@ class Webhooks::ActivationsController < ApplicationController before_action :ensure_admin def create - webhook = Webhook.find(params[:webhook_id]) + webhook = Current.account.webhooks.find(params[:webhook_id]) webhook.activate redirect_to webhook diff --git a/app/helpers/accesses_helper.rb b/app/helpers/accesses_helper.rb index 01aa57b7c..e52aa8ede 100644 --- a/app/helpers/accesses_helper.rb +++ b/app/helpers/accesses_helper.rb @@ -27,7 +27,7 @@ module AccessesHelper end def board_watchers_list(board) - watchers = board.watchers + watchers = board.watchers.with_avatars displayed_watchers = watchers.limit(MAX_DISPLAYED_WATCHERS) overflow_count = watchers.count - MAX_DISPLAYED_WATCHERS diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e4f5b4945..cefd6fd37 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,7 +1,7 @@ module ApplicationHelper def page_title_tag - account_name = if ApplicationRecord.current_tenant && Current.session&.identity&.memberships&.many? - Account.sole&.name + account_name = if Current.account && Current.session&.identity&.users&.many? + Current.account&.name end tag.title [ @page_title, account_name, "Fizzy" ].compact.join(" | ") end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index f572434ec..8fb42448a 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -38,6 +38,6 @@ module AvatarsHelper end def avatar_image_tag(user, **options) - image_tag user_avatar_url(user), aria: { hidden: "true" }, size: 48, title: user.name, **options + image_tag user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options end end diff --git a/app/helpers/cards_helper.rb b/app/helpers/cards_helper.rb index 56df2f54d..574f6375f 100644 --- a/app/helpers/cards_helper.rb +++ b/app/helpers/cards_helper.rb @@ -41,7 +41,7 @@ module CardsHelper end def button_to_remove_card_image(card) - button_to(card_image_path(card), method: :delete, class: "btn", data: { controller: "tooltip" }) do + button_to(card_image_path(card), method: :delete, class: "btn", data: { controller: "tooltip", action: "dialog#close" }) do icon_tag("trash") + tag.span("Remove background image", class: "for-screen-reader") end end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 501abf2dd..a29085d44 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -9,7 +9,7 @@ module EventsHelper "comment" when "card_title_changed" "rename" - when "card_board_changed" + when "card_board_changed", "card_triaged", "card_postponed" "move" else "person" diff --git a/app/helpers/hotkeys_helper.rb b/app/helpers/hotkeys_helper.rb index 062f6ca2d..3e75081f9 100644 --- a/app/helpers/hotkeys_helper.rb +++ b/app/helpers/hotkeys_helper.rb @@ -5,7 +5,9 @@ module HotkeysHelper if key == "ctrl" && platform.mac? "⌘" elsif key == "enter" - platform.mac? ? "return" : "enter" + "⏎" + elsif key == "shift" + "⇧" else key end.capitalize diff --git a/app/helpers/my/menu_helper.rb b/app/helpers/my/menu_helper.rb index 7924c4d97..fd1acd456 100644 --- a/app/helpers/my/menu_helper.rb +++ b/app/helpers/my/menu_helper.rb @@ -25,7 +25,7 @@ module My::MenuHelper def my_menu_tag_item(the_tag) my_menu_item("tag", tag) do - link_to(tag.span("#{the_tag.title} (#{the_tag.cards_count})", class: "overflow-ellipsis"), cards_path(tag_ids: [ the_tag ]), class: "popup__btn btn") + link_to(tag.span(the_tag.title, class: "overflow-ellipsis"), cards_path(tag_ids: [ the_tag ]), class: "popup__btn btn") end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index f09153cff..029c328bb 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -86,6 +86,6 @@ module NotificationsHelper end def card_notification_title(card) - card.title.presence || "Card #{card.id}" + card.title.presence || "Card #{card.number}" end end diff --git a/app/javascript/controllers/frame_reloader_controller.js b/app/javascript/controllers/frame_reloader_controller.js new file mode 100644 index 000000000..c978293b5 --- /dev/null +++ b/app/javascript/controllers/frame_reloader_controller.js @@ -0,0 +1,21 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + reloadInterval: { type: Number, default: 10 * 60 } // 10 minutes + } + + connect() { + this.freshSince = Date.now() + } + + reload() { + const now = Date.now() + const reloadIntervalMs = this.reloadIntervalValue * 1000 + + if ((now - this.freshSince) >= reloadIntervalMs) { + this.freshSince = now + this.element.reload() + } + } +} diff --git a/app/javascript/controllers/magic_link_controller.js b/app/javascript/controllers/magic_link_controller.js new file mode 100644 index 000000000..b2e487489 --- /dev/null +++ b/app/javascript/controllers/magic_link_controller.js @@ -0,0 +1,19 @@ +import { Controller } from "@hotwired/stimulus" +import { onNextEventLoopTick } from "helpers/timing_helpers" + +export default class extends Controller { + static targets = [ "input" ] + + submit(event) { + onNextEventLoopTick(() => { + this.inputTarget.disabled = true + }) + } + + paste(event) { + onNextEventLoopTick(() => { + this.element.submit() + this.inputTarget.disabled = true + }) + } +} diff --git a/app/javascript/controllers/multi_selection_combobox_controller.js b/app/javascript/controllers/multi_selection_combobox_controller.js index 40f7c3459..716420839 100644 --- a/app/javascript/controllers/multi_selection_combobox_controller.js +++ b/app/javascript/controllers/multi_selection_combobox_controller.js @@ -40,7 +40,7 @@ export default class extends Controller { return this.noSelectionLabelValue } - const labels = this.#selectedItems().map(item => item.dataset.multiSelectionComboboxLabel) + const labels = this.#selectedItems.map(item => item.dataset.multiSelectionComboboxLabel) const sentence = toSentence(labels, { two_words_connector: " or ", last_word_connector: ", or " @@ -55,13 +55,28 @@ export default class extends Controller { if (isSelected) { item.setAttribute(this.selectPropertyNameValue, "false") } else { + if (this.isAnExclusiveSelectionItemInvolved(item)) { + this.#deselectAll() + } + item.setAttribute(this.selectPropertyNameValue, "true") } this.#updateHiddenFields() + if (item.dataset.multiSelectionFieldName) { + this.#renameHiddenFields(item.dataset.multiSelectionFieldName) + } this.labelTarget.textContent = this.#selectedLabel } + isAnExclusiveSelectionItemInvolved(item) { + return this.#isExclusiveSelection(item) || Array.from(this.#selectedItems).some((item) => this.#isExclusiveSelection(item)) + } + + #isExclusiveSelection(item) { + return item.dataset.multiSelectionExclusive === "true" + } + #updateHiddenFields() { this.#clearHiddenFields() this.#addHiddenFields() @@ -73,22 +88,32 @@ export default class extends Controller { }) } - #selectedItems() { + get #selectedItems() { return this.itemTargets.filter(item => item.getAttribute(this.selectPropertyNameValue) === "true" ) } #selectedValues() { - return this.#selectedItems().map(item => item.dataset.multiSelectionComboboxValue) + return this.#selectedItems.map(item => item.dataset.multiSelectionComboboxValue) } #clearHiddenFields() { - this.element.querySelectorAll('input[type="hidden"]').forEach(field => { + this.#hiddenFields.forEach(field => { field.remove() }) } + #renameHiddenFields(fieldName) { + this.#hiddenFields.forEach(field => { + field.setAttribute("name", fieldName) + }) + } + + get #hiddenFields() { + return this.element.querySelectorAll("input[type='hidden']") + } + #addHiddenFields() { this.#selectedValues().forEach(value => { const [ field ] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children diff --git a/app/javascript/controllers/reaction_delete_controller.js b/app/javascript/controllers/reaction_delete_controller.js index 1e8144669..d7823f0e3 100644 --- a/app/javascript/controllers/reaction_delete_controller.js +++ b/app/javascript/controllers/reaction_delete_controller.js @@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static classes = [ "deleteable", "reveal", "perform" ] static targets = [ "button", "content" ] - static values = { reacterId: Number } + static values = { reacterId: String } connect() { if (this.#currentUserIsReacter) { diff --git a/app/javascript/initializers/current.js b/app/javascript/initializers/current.js index 96ace04b5..ddfdc90d1 100644 --- a/app/javascript/initializers/current.js +++ b/app/javascript/initializers/current.js @@ -3,7 +3,7 @@ class Current { const currentUserId = this.#extractContentFromMetaTag("current-user-id") if (currentUserId) { - return { id: parseInt(currentUserId) } + return { id: currentUserId } } } diff --git a/app/jobs/card/auto_postpone_all_due_job.rb b/app/jobs/card/auto_postpone_all_due_job.rb deleted file mode 100644 index 2b75f0736..000000000 --- a/app/jobs/card/auto_postpone_all_due_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Card::AutoPostponeAllDueJob < ApplicationJob - def perform - ApplicationRecord.with_each_tenant do |tenant| - Card.auto_postpone_all_due - end - end -end diff --git a/app/jobs/delete_unused_tags_job.rb b/app/jobs/delete_unused_tags_job.rb index a733a0e75..375984ba6 100644 --- a/app/jobs/delete_unused_tags_job.rb +++ b/app/jobs/delete_unused_tags_job.rb @@ -1,9 +1,7 @@ class DeleteUnusedTagsJob < ApplicationJob def perform - ApplicationRecord.with_each_tenant do |tenant| - Tag.unused.find_each do |tag| - tag.destroy! - end + Tag.unused.find_each do |tag| + tag.destroy! end end end diff --git a/app/jobs/notification/bundle/deliver_all_job.rb b/app/jobs/notification/bundle/deliver_all_job.rb index 250b53b6e..79aeaed16 100644 --- a/app/jobs/notification/bundle/deliver_all_job.rb +++ b/app/jobs/notification/bundle/deliver_all_job.rb @@ -2,8 +2,6 @@ class Notification::Bundle::DeliverAllJob < ApplicationJob queue_as :backend def perform - ApplicationRecord.with_each_tenant do |tenant| - Notification::Bundle.deliver_all - end + Notification::Bundle.deliver_all end end diff --git a/app/jobs/sqlite_backups_job.rb b/app/jobs/sqlite_backups_job.rb deleted file mode 100644 index 5daea366c..000000000 --- a/app/jobs/sqlite_backups_job.rb +++ /dev/null @@ -1,155 +0,0 @@ -require "find" - -# -# This job backs up all the tenant databases using the SQLite Backup API, which should allow the -# application to continue running against the database while it is backed up. -# -# ref: https://www.sqlite.org/c3ref/backup_finish.html -# -# It will keep N files around, like this: -# -# storage/tenants/development/12345678/db/ -# ├─ main.sqlite3 -# ├─ main.sqlite3.1 -# ├─ main.sqlite3.2 -# ├─ main.sqlite3.3 -# ├─ main.sqlite3.4 -# └─ main.sqlite3.5 -# -# On some systems, notably in production, we have an NFS-mounted filesystem into which the -# application copies the backup files for disaster recovery. We copy into an environment- and -# tenant-specific directory. The file, when copied, will be renamed with the timestamp of the file -# creation time. For example: -# -# /backups/production/12345678/main.sqlite3.20251014194804 -# -# It will also clean up old backups in the NFS-mounted filesystem following our data retention policy. -# -class SQLiteBackupsJob < ApplicationJob - DEFAULT_NUMBER_OF_BACKUPS = 5 - DEFAULT_STEP_PAGES = 1024 - DEFAULT_SWEEP_DIR = "/backups" - DEFAULT_SWEEP_RETENTION = 30.days # https://37signals.com/policies/privacy - - def perform(keep: DEFAULT_NUMBER_OF_BACKUPS, step: DEFAULT_STEP_PAGES, sweep_dir: DEFAULT_SWEEP_DIR, sweep_retention: DEFAULT_SWEEP_RETENTION) - @failures = [] - - ApplicationRecord.with_each_tenant do |tenant| - perform_file_rollover(tenant, keep:) - perform_backup(tenant, step:) - perform_sweep(tenant, sweep_dir:) - enforce_retention(tenant, sweep_dir:, sweep_retention:) - end - - if @failures.present? - raise "SQLiteBackupsJob: failed to backup tenants: #{@failures.join(", ")}" - end - end - - private - def perform_file_rollover(tenant, keep:) - keep.downto(2) do |j| - fresher = backup_path(tenant, j - 1) - staler = backup_path(tenant, j) - - if j == keep && File.exist?(staler) - FileUtils.rm(staler) - end - - if File.exist?(fresher) - # TODO: It may be worth benchmarking whether backing up into the previous backup is faster - # than backing up into an empty file. - FileUtils.mv(fresher, staler) - end - end - end - - def perform_backup(tenant, step:) - ApplicationRecord.with_connection do |conn| - current_adapter = conn.raw_connection - backup_db = backup_path(tenant, 1) - backup_adapter = SQLite3::Database.new(backup_db) - backup = SQLite3::Backup.new(backup_adapter, "main", current_adapter, "main") - - pages = 0 - elapsed = ActiveSupport::Benchmark.realtime(:float_millisecond) do - loop do - status = backup.step(step) - case status - when SQLite3::Constants::ErrorCode::DONE - break - when SQLite3::Constants::ErrorCode::OK - total = backup.pagecount - progress = total - backup.remaining - log(tenant, :debug) { "Wrote #{progress} of #{total} pages." } - when SQLite3::Constants::ErrorCode::BUSY, SQLite3::Constants::ErrorCode::LOCKED - log(tenant, :debug) { "Busy, retrying." } - else - log(tenant, :error) { "Failed with status #{status}." } - @failures << tenant - end - end - - pages = backup.pagecount - backup.finish - end - - log(tenant) { sprintf("Backup complete in %.1f ms. Wrote %{pages} pages to %{path}", path: backup_db.inspect, pages: pages, elapsed: elapsed) } - end - end - - def perform_sweep(tenant, sweep_dir:) - unless File.directory?(sweep_dir) && File.writable?(sweep_dir) - log(tenant, :warn) { "Skipping sweep, #{sweep_dir.inspect} does not exist or is not writable." } - return - end - - backup_file = backup_path(tenant, 1) - sweep_path = File.join(sweep_dir, Rails.env, tenant) - FileUtils.mkdir_p(sweep_path) - - if File.exist?(backup_file) - timestamp = File.ctime(backup_file).utc.strftime("%Y%m%d%H%M%S") - swept_file = File.join(sweep_path, File.basename(db_path(tenant)) + ".#{timestamp}") - FileUtils.cp(backup_file, swept_file) - log(tenant) { "Swept backup to #{swept_file.inspect}." } - else - log(tenant, :warn) { "No backup file found at #{backup_file.inspect} to sweep." } - end - end - - def enforce_retention(tenant, sweep_dir:, sweep_retention:) - unless File.directory?(sweep_dir) && File.writable?(sweep_dir) - log(tenant, :warn) { "Skipping retention enforcement, #{sweep_dir.inspect} does not exist or is not writable." } - return - end - - sweep_path = File.join(sweep_dir, Rails.env, tenant) - if File.directory?(sweep_path) - cutoff_time = Time.now - sweep_retention - Find.find(sweep_path) do |path| - if File.file?(path) && File.ctime(path) < cutoff_time - FileUtils.rm(path) - log(tenant) { "Removed old swept backup #{path.inspect}." } - end - end - end - end - - def backup_path(tenant, index) - db_path(tenant) + ".#{index}" - end - - def db_path(tenant) - db_config.config_adapter.database_path - end - - def db_config - ApplicationRecord.connection_pool.db_config - end - - def log(tenant, level = :info, &block) - message = block.call - Rails.logger.send(level, "[tenant=#{tenant}] SQLiteBackupsJob: #{message}") - end -end diff --git a/app/jobs/webhook/cleanup_deliveries_job.rb b/app/jobs/webhook/cleanup_deliveries_job.rb deleted file mode 100644 index 63f87ead5..000000000 --- a/app/jobs/webhook/cleanup_deliveries_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Webhook::CleanupDeliveriesJob < ApplicationJob - def perform - ApplicationRecord.with_each_tenant do - Webhook::Delivery.cleanup - end - end -end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index abeb3ae09..7fd915dba 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -7,8 +7,8 @@ class ApplicationMailer < ActionMailer::Base private def default_url_options - if ApplicationRecord.current_tenant - super.merge(script_name: Account.sole.slug) + if Current.account + super.merge(script_name: Current.account.slug) else super end diff --git a/app/mailers/identity_mailer.rb b/app/mailers/identity_mailer.rb deleted file mode 100644 index 58ac5d0b6..000000000 --- a/app/mailers/identity_mailer.rb +++ /dev/null @@ -1,7 +0,0 @@ -class IdentityMailer < ApplicationMailer - def email_change_confirmation(email_address:, token:, membership:) - @token = token - @membership = membership - mail to: email_address, subject: "Confirm your new email address" - end -end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 000000000..f0716c4d1 --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,7 @@ +class UserMailer < ApplicationMailer + def email_change_confirmation(email_address:, token:, user:) + @token = token + @user = user + mail to: email_address, subject: "Confirm your new email address" + end +end diff --git a/app/models/access.rb b/app/models/access.rb index 27026fb87..afdd6312f 100644 --- a/app/models/access.rb +++ b/app/models/access.rb @@ -1,4 +1,5 @@ class Access < ApplicationRecord + belongs_to :account, default: -> { user.account } belongs_to :board, touch: true belongs_to :user, touch: true diff --git a/app/models/account.rb b/app/models/account.rb index 0c730a937..0e7708847 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,6 +1,14 @@ class Account < ApplicationRecord include Entropic, Seedeable + has_one :join_code + has_many :users, dependent: :destroy + has_many :boards, dependent: :destroy + has_many :cards, dependent: :destroy + has_many :webhooks, dependent: :destroy + has_many :tags, dependent: :destroy + has_many :columns, dependent: :destroy + has_many_attached :uploads after_create :create_join_code @@ -9,24 +17,18 @@ class Account < ApplicationRecord class << self def create_with_admin_user(account:, owner:) - create!(**account).tap do - User.system - User.create!(**owner.reverse_merge(role: "admin")) + create!(**account).tap do |account| + account.users.create!(role: :system, name: "System") + account.users.create!(**owner.reverse_merge(role: "admin")) end end end - # To use the account as a generic card container. See +Entropy+. - def cards - Card.all - end - def slug - "/#{tenant}" + "/#{external_account_id}" end - private - def create_join_code - Account::JoinCode.create! - end + def account + self + end end diff --git a/app/models/account/entropic.rb b/app/models/account/entropic.rb index e54af1432..b1303e0e2 100644 --- a/app/models/account/entropic.rb +++ b/app/models/account/entropic.rb @@ -5,6 +5,6 @@ module Account::Entropic included do has_one :entropy, as: :container, dependent: :destroy - after_create -> { create_entropy!(auto_postpone_period: DEFAULT_ENTROPY_PERIOD) } + after_create -> { create_entropy!(auto_postpone_period: DEFAULT_ENTROPY_PERIOD, account: self) } end end diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb index 69d681197..49fe9af22 100644 --- a/app/models/account/join_code.rb +++ b/app/models/account/join_code.rb @@ -1,6 +1,8 @@ class Account::JoinCode < ApplicationRecord CODE_LENGTH = 12 + belongs_to :account + scope :active, -> { where("usage_count < usage_limit") } before_create :generate_code, if: -> { code.blank? } @@ -8,7 +10,7 @@ class Account::JoinCode < ApplicationRecord def redeem transaction do increment!(:usage_count) - yield if block_given? + yield account if block_given? end end diff --git a/app/models/account/seedeable.rb b/app/models/account/seedeable.rb index 7e080d6ba..f0592020a 100644 --- a/app/models/account/seedeable.rb +++ b/app/models/account/seedeable.rb @@ -2,6 +2,6 @@ module Account::Seedeable extend ActiveSupport::Concern def setup_customer_template - Account::Seeder.new(self, User.active.first).seed + Account::Seeder.new(self, users.active.where(role: :admin).first).seed end end diff --git a/app/models/account/seeder.rb b/app/models/account/seeder.rb index 28835041c..f7f7cf60b 100644 --- a/app/models/account/seeder.rb +++ b/app/models/account/seeder.rb @@ -7,7 +7,7 @@ class Account::Seeder end def seed - Current.set session: session do + Current.set(user: creator, account: account) do populate end end @@ -20,15 +20,11 @@ class Account::Seeder end private - def session - creator.identity.sessions.last - end - def populate # --------------- # Playground Board # --------------- - playground = Board.create! name: "Playground", creator: creator, all_access: true + playground = account.boards.create! name: "Playground", creator: creator, all_access: true # Cards playground.cards.create! creator: creator, title: "Finally, watch this Fizzy orientation video", status: "published", description: <<~HTML @@ -104,8 +100,8 @@ class Account::Seeder end def delete_everything - Current.set session: session do - Board.destroy_all + Current.set(user: creator, account: account) do + account.boards.destroy_all end end end diff --git a/app/models/ai.rb b/app/models/ai.rb deleted file mode 100644 index 4b4c17743..000000000 --- a/app/models/ai.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Ai - def self.table_name_prefix - "ai_" - end -end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index cb75f5d41..a043d384f 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,4 +1,7 @@ class ApplicationRecord < ActiveRecord::Base primary_abstract_class - tenanted + + connects_to database: { writing: :primary, reading: :replica } + + attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate } end diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 312bc3bf7..f60eb974d 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -1,4 +1,5 @@ class Assignment < ApplicationRecord + belongs_to :account, default: -> { card.account } belongs_to :card, touch: true belongs_to :assignee, class_name: "User" diff --git a/app/models/board.rb b/app/models/board.rb index 71ff693fb..9f1bcd5d7 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -2,6 +2,7 @@ class Board < ApplicationRecord include Accessible, AutoPostponing, Broadcastable, Cards, Entropic, Filterable, Publishable, Triageable belongs_to :creator, class_name: "User", default: -> { Current.user } + belongs_to :account, default: -> { creator.account } has_rich_text :public_description diff --git a/app/models/board/accessible.rb b/app/models/board/accessible.rb index 75b2e982e..e4eb26e32 100644 --- a/app/models/board/accessible.rb +++ b/app/models/board/accessible.rb @@ -11,7 +11,7 @@ module Board::Accessible end def grant_to(users) - Access.insert_all Array(users).collect { |user| { board_id: proxy_association.owner.id, user_id: user.id } } + Access.insert_all Array(users).collect { |user| { id: ActiveRecord::Type::Uuid.generate, board_id: proxy_association.owner.id, user_id: user.id, account_id: proxy_association.owner.account.id } } end def revoke_from(users) @@ -58,7 +58,7 @@ module Board::Accessible end def grant_access_to_everyone - accesses.grant_to(User.active) if all_access_previously_changed?(to: true) + accesses.grant_to(account.users.active) if all_access_previously_changed?(to: true) end def mentions_for_user(user) @@ -66,11 +66,14 @@ module Board::Accessible # # 1. Mention->Card # 2. Mention->Comment->Card + uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy) + board_id_binary = uuid_type.serialize(id) + user.mentions .joins("LEFT JOIN cards ON mentions.source_id = cards.id AND mentions.source_type = 'Card'") .joins("LEFT JOIN comments ON mentions.source_id = comments.id AND mentions.source_type = 'Comment'") .joins("LEFT JOIN cards AS comment_cards ON comments.card_id = comment_cards.id") - .where("(mentions.source_type = 'Card' AND cards.board_id = ?) OR (mentions.source_type = 'Comment' AND comment_cards.board_id = ?)", id, id) + .where("(mentions.source_type = 'Card' AND cards.board_id = ?) OR (mentions.source_type = 'Comment' AND comment_cards.board_id = ?)", board_id_binary, board_id_binary) end def notifications_for_user(user) @@ -81,6 +84,9 @@ module Board::Accessible # # Notification->Event->Mention->Card and Notification->Event->Mention->Comment->Card are # handled by destroying mentions_for_user. + uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy) + board_id_binary = uuid_type.serialize(id) + user.notifications .joins("LEFT JOIN events ON notifications.source_id = events.id AND notifications.source_type = 'Event'") .joins("LEFT JOIN cards AS event_cards ON events.eventable_id = event_cards.id AND events.eventable_type = 'Card'") @@ -88,7 +94,7 @@ module Board::Accessible .joins("LEFT JOIN cards AS event_comment_cards ON event_comments.card_id = event_comment_cards.id") .where("(notifications.source_type = 'Event' AND events.eventable_type = 'Card' AND event_cards.board_id = ?) OR (notifications.source_type = 'Event' AND events.eventable_type = 'Comment' AND event_comment_cards.board_id = ?)", - id, id) + board_id_binary, board_id_binary) end def watches_for(user) diff --git a/app/models/board/entropic.rb b/app/models/board/entropic.rb index b29d5ea42..beee4883b 100644 --- a/app/models/board/entropic.rb +++ b/app/models/board/entropic.rb @@ -7,7 +7,7 @@ module Board::Entropic end def entropy - super || Account.sole.entropy + super || account.entropy end def auto_postpone_period=(new_value) diff --git a/app/models/board/publication.rb b/app/models/board/publication.rb index a21fb00b6..bd480eecc 100644 --- a/app/models/board/publication.rb +++ b/app/models/board/publication.rb @@ -1,4 +1,5 @@ class Board::Publication < ApplicationRecord + belongs_to :account, default: -> { board.account } belongs_to :board has_secure_token :key diff --git a/app/models/card.rb b/app/models/card.rb index b609dc029..33a993f71 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -3,7 +3,8 @@ class Card < ApplicationRecord Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Taggable, Triageable, Watchable - belongs_to :board, touch: true + belongs_to :account, default: -> { board.account } + belongs_to :board belongs_to :creator, class_name: "User", default: -> { Current.user } has_many :comments, dependent: :destroy @@ -12,17 +13,22 @@ class Card < ApplicationRecord has_rich_text :description before_save :set_default_title, if: :published? + before_create :assign_number + + after_save -> { board.touch }, if: :published? after_update :handle_board_change, if: :saved_change_to_board_id? scope :reverse_chronologically, -> { order created_at: :desc, id: :desc } scope :chronologically, -> { order created_at: :asc, id: :asc } scope :latest, -> { order last_active_at: :desc, id: :desc } + scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) } + scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy ], not_now: [ :user ]).with_rich_text_description_and_embeds } scope :indexed_by, ->(index) do case index when "stalled" then stalled when "postponing_soon" then postponing_soon - when "closed" then closed.recently_closed_first + when "closed" then closed when "not_now" then postponed.latest when "golden" then golden when "draft" then drafted @@ -45,6 +51,10 @@ class Card < ApplicationRecord self end + def to_param + number.to_s + end + def move_to(new_board) transaction do card.update!(board: new_board) @@ -62,7 +72,7 @@ class Card < ApplicationRecord end def handle_board_change - old_board = Board.find_by(id: board_id_before_last_save) + old_board = account.boards.find_by(id: board_id_before_last_save) transaction do update! column: nil @@ -80,4 +90,8 @@ class Card < ApplicationRecord def grant_access_to_assignees board.accesses.grant_to(assignees) end + + def assign_number + self.number ||= account.increment!(:cards_count).cards_count + end end diff --git a/app/models/card/activity_spike.rb b/app/models/card/activity_spike.rb index cbfbe906a..9bf9c29c6 100644 --- a/app/models/card/activity_spike.rb +++ b/app/models/card/activity_spike.rb @@ -1,3 +1,4 @@ class Card::ActivitySpike < ApplicationRecord + belongs_to :account, default: -> { card.account } belongs_to :card, touch: true end diff --git a/app/models/card/activity_spike/detector.rb b/app/models/card/activity_spike/detector.rb index 63f246ca0..b589e0c26 100644 --- a/app/models/card/activity_spike/detector.rb +++ b/app/models/card/activity_spike/detector.rb @@ -55,6 +55,6 @@ class Card::ActivitySpike::Detector end def last_event - card.events.last + card.events.order(created_at: :desc).first end end diff --git a/app/models/card/engagement.rb b/app/models/card/engagement.rb index e9a46428f..2f45543dc 100644 --- a/app/models/card/engagement.rb +++ b/app/models/card/engagement.rb @@ -1,4 +1,5 @@ class Card::Engagement < ApplicationRecord + belongs_to :account, default: -> { card.account } belongs_to :card, class_name: "::Card", touch: true validates :status, presence: true, inclusion: { in: %w[doing on_deck] } diff --git a/app/models/card/entropic.rb b/app/models/card/entropic.rb index 478d4bc52..4b11a2099 100644 --- a/app/models/card/entropic.rb +++ b/app/models/card/entropic.rb @@ -4,16 +4,19 @@ module Card::Entropic included do scope :due_to_be_postponed, -> do active + .joins(board: :account) .left_outer_joins(board: :entropy) - .where("last_active_at <= DATETIME('now', '-' || COALESCE(entropies.auto_postpone_period, ?) || ' seconds')", - Account.sole.entropy.auto_postpone_period) + .joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id") + .where("last_active_at <= DATE_SUB(NOW(), INTERVAL COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) SECOND)") end scope :postponing_soon, -> do active + .joins(board: :account) .left_outer_joins(board: :entropy) - .where("last_active_at > DATETIME('now', '-' || COALESCE(entropies.auto_postpone_period, ?) || ' seconds')", Account.sole.entropy.auto_postpone_period) - .where("last_active_at <= DATETIME('now', '-' || CAST(COALESCE(entropies.auto_postpone_period, ?) * 0.75 AS INTEGER) || ' seconds')", Account.sole.entropy.auto_postpone_period) + .joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id") + .where("last_active_at > DATE_SUB(NOW(), INTERVAL COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) SECOND)") + .where("last_active_at <= DATE_SUB(NOW(), INTERVAL CAST(COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) * 0.75 AS SIGNED) SECOND)") end delegate :auto_postpone_period, to: :board diff --git a/app/models/card/golden.rb b/app/models/card/golden.rb index 4f2f52a60..6427bd0a0 100644 --- a/app/models/card/golden.rb +++ b/app/models/card/golden.rb @@ -5,7 +5,7 @@ module Card::Golden has_one :goldness, dependent: :destroy, class_name: "Card::Goldness" scope :golden, -> { joins(:goldness) } - scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order("card_goldnesses.id IS NULL") } + scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order("card_goldnesses.id IS NULL").preload(:goldness) } end def golden? diff --git a/app/models/card/goldness.rb b/app/models/card/goldness.rb index fc2697328..96bf426af 100644 --- a/app/models/card/goldness.rb +++ b/app/models/card/goldness.rb @@ -1,3 +1,4 @@ class Card::Goldness < ApplicationRecord + belongs_to :account, default: -> { card.account } belongs_to :card, touch: true end diff --git a/app/models/card/not_now.rb b/app/models/card/not_now.rb index dcd16a86d..f99aaee9c 100644 --- a/app/models/card/not_now.rb +++ b/app/models/card/not_now.rb @@ -1,4 +1,5 @@ class Card::NotNow < ApplicationRecord + belongs_to :account, default: -> { card.account } belongs_to :card, class_name: "::Card", touch: true belongs_to :user, optional: true end diff --git a/app/models/card/promptable.rb b/app/models/card/promptable.rb index e30816023..4608d3e4b 100644 --- a/app/models/card/promptable.rb +++ b/app/models/card/promptable.rb @@ -24,7 +24,7 @@ module Card::Promptable * Board id: #{board_id} * Board name: #{board.name} * Number of comments: #{comments.count} - * Path: #{card_path(self, script_name: Account.sole.slug)} + * Path: #{card_path(self, script_name: account.slug)} END OF CARD #{id} PROMPT diff --git a/app/models/card/searchable.rb b/app/models/card/searchable.rb index e0c3ced20..81e704e43 100644 --- a/app/models/card/searchable.rb +++ b/app/models/card/searchable.rb @@ -4,19 +4,27 @@ module Card::Searchable included do include ::Searchable - searchable_by :title, :description, using: :cards_search_index + scope :mentioning, ->(query, user:) do + search_records = Search::Record.for_account(user.account_id) - scope :mentioning, ->(query) do - cards = Card.search(query).select(:id).to_sql - comments = Comment.search(query).select(:id).to_sql - - left_joins(:comments).where("cards.id in (#{cards}) or comments.id in (#{comments})").distinct + joins(search_records.card_join) + .merge(search_records.for_query(query: Search::Query.wrap(query), user: user)) end end - private - # TODO: Temporary until we stabilize the search API - def title_and_description - [ title, description.to_plain_text ].join(" ") - end + def search_title + title + end + + def search_content + description.to_plain_text + end + + def search_card_id + id + end + + def search_board_id + board_id + end end diff --git a/app/models/card/statuses.rb b/app/models/card/statuses.rb index f26896b21..2639d88b7 100644 --- a/app/models/card/statuses.rb +++ b/app/models/card/statuses.rb @@ -4,6 +4,7 @@ module Card::Statuses included do enum :status, %w[ drafted published ].index_by(&:itself) + before_save :update_created_at_on_publication after_create -> { track_event :published }, if: :published? scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) } @@ -15,4 +16,11 @@ module Card::Statuses track_event :published end end + + private + def update_created_at_on_publication + if will_save_change_to_status? && status_in_database.inquiry.drafted? + self.created_at = Time.now + end + end end diff --git a/app/models/closure.rb b/app/models/closure.rb index ec0a812fc..512de6667 100644 --- a/app/models/closure.rb +++ b/app/models/closure.rb @@ -1,4 +1,5 @@ class Closure < ApplicationRecord + belongs_to :account, default: -> { card.account } belongs_to :card, touch: true belongs_to :user, optional: true end diff --git a/app/models/column.rb b/app/models/column.rb index bf136969c..4c8358871 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -1,6 +1,7 @@ class Column < ApplicationRecord include Colored, Positioned + belongs_to :account, default: -> { board.account } belongs_to :board, touch: true has_many :cards, dependent: :nullify diff --git a/app/models/comment.rb b/app/models/comment.rb index 4428c8e48..a42b205fc 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,12 +1,12 @@ class Comment < ApplicationRecord include Attachments, Eventable, Mentions, Promptable, Searchable - belongs_to :card, touch: true + belongs_to :account, default: -> { card.account } + belongs_to :card, touch: true belongs_to :creator, class_name: "User", default: -> { Current.user } has_many :reactions, dependent: :delete_all has_rich_text :body - searchable_by :body, using: :comments_search_index scope :chronologically, -> { order created_at: :asc, id: :desc } scope :by_system, -> { joins(:creator).where(creator: { role: "system" }) } diff --git a/app/models/comment/promptable.rb b/app/models/comment/promptable.rb index 93c931432..80488f76a 100644 --- a/app/models/comment/promptable.rb +++ b/app/models/comment/promptable.rb @@ -16,11 +16,11 @@ module Comment::Promptable #### Metadata * Id: #{id} - * Card id: #{card.id} + * Card id: #{card.number} * Card title: #{card.title} * Created by: #{creator.name}} * Created at: #{created_at}} - * Path: #{card_path(card, anchor: ActionView::RecordIdentifier.dom_id(self), script_name: Account.sole.slug)} + * Path: #{card_path(card, anchor: ActionView::RecordIdentifier.dom_id(self), script_name: account.slug)} END OF COMMENT #{id} PROMPT end diff --git a/app/models/comment/searchable.rb b/app/models/comment/searchable.rb new file mode 100644 index 000000000..626d2be64 --- /dev/null +++ b/app/models/comment/searchable.rb @@ -0,0 +1,23 @@ +module Comment::Searchable + extend ActiveSupport::Concern + + included do + include ::Searchable + end + + def search_title + nil + end + + def search_content + body.to_plain_text + end + + def search_card_id + card_id + end + + def search_board_id + card.board_id + end +end diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb index f1a7ada96..17e965298 100644 --- a/app/models/concerns/searchable.rb +++ b/app/models/concerns/searchable.rb @@ -1,39 +1,10 @@ module Searchable extend ActiveSupport::Concern - class_methods do - def searchable_by(*fields, using:) - define_method :search_fields do - fields - end - - define_method :search_values do - fields.map do - value = send(it) - value.respond_to?(:to_plain_text) ? value.to_plain_text : value - end - end - - define_method :search_table do - using - end - - after_create_commit :create_in_search_index - after_update_commit :update_in_search_index - after_destroy_commit :remove_from_search_index - - scope :search, ->(query) do - query = Search::Query.wrap(query) - - base = joins("join #{using} idx on #{table_name}.id = idx.rowid") - - if query.valid? - base.where("#{using} match ?", query.to_s) - else - base.none - end - end - end + included do + after_create_commit :create_in_search_index + after_update_commit :update_in_search_index + after_destroy_commit :remove_from_search_index end def reindex @@ -42,36 +13,40 @@ module Searchable private def create_in_search_index - fields_sql = [ "rowid", *search_fields ].join(", ") - placeholders = ([ "?" ] * (search_fields.size + 1)).join(", ") - values = [ id, *search_values ] - - execute_sql_with_binds( - "insert into #{search_table}(#{fields_sql}) values (#{placeholders})", - *values - ) + Search::Record.for_account(account_id).create!(search_record_attributes) end def update_in_search_index - transaction do - set_clause = search_fields.map { |field| "#{field} = ?" }.join(", ") - binds = search_values + [ id ] - - updated = execute_sql_with_binds( - "update #{search_table} set #{set_clause} where rowid = ?", - *binds - ) - - create_in_search_index unless updated - end + Search::Record.for_account(account_id).upsert_all( + [ search_record_attributes.merge(id: ActiveRecord::Type::Uuid.generate) ], + update_only: [ :card_id, :board_id, :title, :content, :created_at ] + ) end def remove_from_search_index - execute_sql_with_binds "delete from #{search_table} where rowid = ?", id + Search::Record.for_account(account_id).where( + searchable_type: self.class.name, + searchable_id: id + ).delete_all end - def execute_sql_with_binds(*statement) - self.class.connection.execute self.class.sanitize_sql(statement) - self.class.connection.raw_connection.changes.nonzero? + def search_record_attributes + { + account_id: account_id, + searchable_type: self.class.name, + searchable_id: id, + card_id: search_card_id, + board_id: search_board_id, + title: Search::Stemmer.stem(search_title), + content: Search::Stemmer.stem(search_content), + created_at: created_at + } end + + # Models must implement these methods: + # - account_id: returns the account id + # - search_title: returns title string or nil + # - search_content: returns content string + # - search_card_id: returns the card id (self.id for cards, card_id for comments) + # - search_board_id: returns the board id end diff --git a/app/models/current.rb b/app/models/current.rb index a3ba812a6..7fb4d7c8e 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,15 +1,30 @@ class Current < ActiveSupport::CurrentAttributes - attribute :session, :membership + attribute :session, :user, :account attribute :http_method, :request_id, :user_agent, :ip_address, :referrer delegate :identity, to: :session, allow_nil: true - delegate :user, to: :membership, allow_nil: true def session=(value) super(value) - unless value.nil? - self.membership = identity.memberships.find_by(tenant: ApplicationRecord.current_tenant) + if value.present? && account.present? + self.user = identity.users.find_by(account: account) end end + + def with_account(value) + @old_account = self.account + self.account = value + yield + ensure + self.account = @old_account + end + + def without_account + @old_account = self.account + self.account = nil + yield + ensure + self.account = @old_account + end end diff --git a/app/models/entropy.rb b/app/models/entropy.rb index 6be6675ee..902e7732f 100644 --- a/app/models/entropy.rb +++ b/app/models/entropy.rb @@ -1,4 +1,5 @@ class Entropy < ApplicationRecord + belongs_to :account, default: -> { container.account } belongs_to :container, polymorphic: true after_commit -> { container.cards.touch_all } diff --git a/app/models/event.rb b/app/models/event.rb index 74a3bb20f..c759c18ee 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,6 +1,7 @@ class Event < ApplicationRecord include Notifiable, Particulars, Promptable + belongs_to :account, default: -> { board.account } belongs_to :board belongs_to :creator, class_name: "User" belongs_to :eventable, polymorphic: true diff --git a/app/models/filter.rb b/app/models/filter.rb index 459d18621..f579c6bf0 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -2,7 +2,7 @@ class Filter < ApplicationRecord include Fields, Params, Resources, Summarized belongs_to :creator, class_name: "User", default: -> { Current.user } - has_one :account, through: :creator + belongs_to :account, default: -> { creator.account } class << self def from_params(params) @@ -33,7 +33,7 @@ class Filter < ApplicationRecord result = result.closed_at_window(closure_window) if closure_window result = result.closed_by(closers) if closers.present? result = terms.reduce(result) do |result, term| - result.mentioning(term) + result.mentioning(term, user: creator) end result @@ -66,7 +66,7 @@ class Filter < ApplicationRecord private def include_closed_cards? - only_closed? || card_ids.present? || creator_ids.present? + only_closed? || card_ids.present? end def include_not_now_cards? diff --git a/app/models/filter/fields.rb b/app/models/filter/fields.rb index c44dff996..0e1c62e0a 100644 --- a/app/models/filter/fields.rb +++ b/app/models/filter/fields.rb @@ -18,7 +18,9 @@ module Filter::Fields def indexed_by_human_name(index) case index when "postponing_soon" - "Auto-closing soon" + "Closing soon" + when "closed" + "Done" when "all" "Open" else diff --git a/app/models/filter/resources.rb b/app/models/filter/resources.rb index f7045f38e..3521b43e3 100644 --- a/app/models/filter/resources.rb +++ b/app/models/filter/resources.rb @@ -12,13 +12,14 @@ module Filter::Resources def resource_removed(resource) kind = resource.class.model_name.plural send "#{kind}=", send(kind).without(resource) + @boards = nil empty? ? destroy! : save! rescue ActiveRecord::RecordNotUnique destroy! end def boards - creator.boards.where id: super.ids + @boards ||= creator.boards.where id: super.ids end def board_titles diff --git a/app/models/identity.rb b/app/models/identity.rb index 99c90271f..fc1c12bcd 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,9 +1,14 @@ -class Identity < UntenantedRecord - include Transferable +class Identity < ApplicationRecord + include Joinable, Transferable - has_many :memberships, dependent: :destroy has_many :magic_links, dependent: :destroy has_many :sessions, dependent: :destroy + has_many :users, dependent: :nullify + has_many :accounts, through: :users + + has_one_attached :avatar + + before_destroy :deactivate_users normalizes :email_address, with: ->(value) { value.strip.downcase.presence } @@ -16,4 +21,9 @@ class Identity < UntenantedRecord def staff? email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com") end + + private + def deactivate_users + users.find_each(&:deactivate) + end end diff --git a/app/models/identity/joinable.rb b/app/models/identity/joinable.rb new file mode 100644 index 000000000..4a6eabcd4 --- /dev/null +++ b/app/models/identity/joinable.rb @@ -0,0 +1,15 @@ +module Identity::Joinable + extend ActiveSupport::Concern + + def join(account, **attributes) + attributes[:name] ||= email_address + + transaction do + account.users.create!(**attributes, identity: self) + end + end + + def member_of?(account) + account.users.exists?(identity: self) + end +end diff --git a/app/models/magic_link.rb b/app/models/magic_link.rb index f596a2bd3..69961e9a5 100644 --- a/app/models/magic_link.rb +++ b/app/models/magic_link.rb @@ -1,4 +1,4 @@ -class MagicLink < UntenantedRecord +class MagicLink < ApplicationRecord CODE_LENGTH = 6 EXPIRATION_TIME = 15.minutes diff --git a/app/models/membership.rb b/app/models/membership.rb deleted file mode 100644 index ac6e99c50..000000000 --- a/app/models/membership.rb +++ /dev/null @@ -1,29 +0,0 @@ -class Membership < UntenantedRecord - include EmailAddressChangeable - - belongs_to :identity, touch: true - - class << self - def change_email_address(from:, to:, tenant:) - identity = Identity.find_by(email_address: from) - membership = find_by(tenant: tenant, identity: identity) - - if membership - new_identity = Identity.find_or_create_by!(email_address: to) - membership.update!(identity: new_identity) - end - end - end - - def account_name - ApplicationRecord.with_tenant(tenant) { Account.sole.name } - rescue ActiveRecord::Tenanted::TenantDoesNotExistError, ActiveRecord::RecordNotFound - nil - end - - def user - ApplicationRecord.with_tenant(tenant) { User.find_by(membership_id: id) } - rescue ActiveRecord::Tenanted::TenantDoesNotExistError - nil - end -end diff --git a/app/models/mention.rb b/app/models/mention.rb index 54a20e0ce..5491bfd9f 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -1,6 +1,7 @@ class Mention < ApplicationRecord include Notifiable + belongs_to :account, default: -> { source.account } belongs_to :source, polymorphic: true belongs_to :mentioner, class_name: "User" belongs_to :mentionee, class_name: "User", inverse_of: :mentions diff --git a/app/models/notification.rb b/app/models/notification.rb index 0fa2350ee..416506783 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,6 +1,7 @@ class Notification < ApplicationRecord include PushNotifiable + belongs_to :account, default: -> { user.account } belongs_to :user belongs_to :creator, class_name: "User" belongs_to :source, polymorphic: true @@ -13,6 +14,8 @@ class Notification < ApplicationRecord after_destroy_commit :broadcast_read after_create :bundle + scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator ]) } + delegate :notifiable_target, to: :source delegate :card, to: :source diff --git a/app/models/notification/bundle.rb b/app/models/notification/bundle.rb index 93110f87b..a4d87e72d 100644 --- a/app/models/notification/bundle.rb +++ b/app/models/notification/bundle.rb @@ -1,4 +1,5 @@ class Notification::Bundle < ApplicationRecord + belongs_to :account, default: -> { user.account } belongs_to :user enum :status, %i[ pending processing delivered ] diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index 0938954b0..acb5603a1 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -88,7 +88,7 @@ class NotificationPusher { title: "New notification", body: "You have a new notification", - path: "#{Account.sole.slug}#{notifications_path}" + path: notifications_path(script_name: notification.account.slug) } end @@ -102,7 +102,7 @@ class NotificationPusher end def card_notification_title(card) - card.title.presence || "Card #{card.id}" + card.title.presence || "Card #{card.number}" end def comment_notification_body(event) @@ -110,10 +110,14 @@ class NotificationPusher end def card_path(card) - "#{Account.sole.slug}#{Rails.application.routes.url_helpers.card_path(card)}" + Rails.application.routes.url_helpers.card_path(card, script_name: notification.account.slug) end def card_path_with_comment_anchor(comment) - "#{Account.sole.slug}#{Rails.application.routes.url_helpers.card_path(comment.card, anchor: ActionView::RecordIdentifier.dom_id(comment))}" + Rails.application.routes.url_helpers.card_path( + comment.card, + anchor: ActionView::RecordIdentifier.dom_id(comment), + script_name: notification.account.slug + ) end end diff --git a/app/models/pin.rb b/app/models/pin.rb index 425a504bf..41bb28f99 100644 --- a/app/models/pin.rb +++ b/app/models/pin.rb @@ -1,4 +1,5 @@ class Pin < ApplicationRecord + belongs_to :account, default: -> { user.account } belongs_to :card belongs_to :user diff --git a/app/models/push/subscription.rb b/app/models/push/subscription.rb index ebc12c70d..0b273b563 100644 --- a/app/models/push/subscription.rb +++ b/app/models/push/subscription.rb @@ -1,7 +1,14 @@ class Push::Subscription < ApplicationRecord + belongs_to :account, default: -> { user.account } belongs_to :user def notification(**params) - WebPush::Notification.new(**params, badge: user.notifications.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key) + WebPush::Notification.new( + **params, + badge: user.notifications.unread.count, + endpoint: endpoint, + p256dh_key: p256dh_key, + auth_key: auth_key + ) end end diff --git a/app/models/qr_code_link.rb b/app/models/qr_code_link.rb index 81a65fa25..8fc5a2b4d 100644 --- a/app/models/qr_code_link.rb +++ b/app/models/qr_code_link.rb @@ -1,6 +1,21 @@ class QrCodeLink attr_reader :url + class << self + def from_signed(signed) + new verifier.verify(signed, purpose: :qr_code) + end + + def verifier + ActiveSupport::MessageVerifier.new(secret, url_safe: true) + end + + private + def secret + Rails.application.key_generator.generate_key("qr_codes") + end + end + def initialize(url) @url = url end @@ -8,19 +23,4 @@ class QrCodeLink def signed self.class.verifier.generate(@url, purpose: :qr_code) end - - def self.from_signed(signed) - new verifier.verify(signed, purpose: :qr_code) - end - - private - class << self - def verifier - ActiveSupport::MessageVerifier.new(secret, url_safe: true) - end - - def secret - Rails.application.key_generator.generate_key("qr_codes") - end - end end diff --git a/app/models/reaction.rb b/app/models/reaction.rb index d70f8333b..76edf152e 100644 --- a/app/models/reaction.rb +++ b/app/models/reaction.rb @@ -1,4 +1,5 @@ class Reaction < ApplicationRecord + belongs_to :account, default: -> { comment.account } belongs_to :comment, touch: true belongs_to :reacter, class_name: "User", default: -> { Current.user } diff --git a/app/models/search.rb b/app/models/search.rb index 518462094..264194f03 100644 --- a/app/models/search.rb +++ b/app/models/search.rb @@ -1,46 +1,9 @@ -class Search - attr_reader :user, :query - - HIGHLIGHT_OPENING_MARK = "" - HIGHLIGHT_CLOSING_MARK = "" - +module Search def self.table_name_prefix "search_" end - def initialize(user, query) - @user = user - @query = Query.wrap(query) - end - - def results - cards = user.accessible_cards.search(query) - .select([ - "cards.id as card_id", - "null as comment_id", - "highlight(cards_search_index, 0, '#{HIGHLIGHT_OPENING_MARK}', '#{HIGHLIGHT_CLOSING_MARK}') AS card_title", - "snippet(cards_search_index, 1, '#{HIGHLIGHT_OPENING_MARK}', '#{HIGHLIGHT_CLOSING_MARK}', '...', 20) AS card_description", - "null as comment_body", - "boards.name as board_name", - "cards.creator_id", - "cards.created_at", - "bm25(cards_search_index, 10.0, 2.0) AS score" - ].join(",")) - - comments = user.accessible_comments.search(query) - .select([ - "comments.card_id as card_id", - "comments.id as comment_id", - "cards.title AS card_title", - "null AS card_description", - "snippet(comments_search_index, 0, '#{HIGHLIGHT_OPENING_MARK}', '#{HIGHLIGHT_CLOSING_MARK}', '...', 20) AS comment_body", - "boards.name as board_name", - "comments.creator_id", - "comments.created_at", - "bm25(comments_search_index, 1.0) AS score" - ].join(",")) - - union_sql = "(#{cards.to_sql} UNION #{comments.to_sql}) as search_results" - Search::Result.from(union_sql).order(created_at: :desc) + def self.results(query:, user:) + Record.for_account(user.account_id).search(query: Query.wrap(query), user: user) end end diff --git a/app/models/search/highlighter.rb b/app/models/search/highlighter.rb new file mode 100644 index 000000000..d2b53d772 --- /dev/null +++ b/app/models/search/highlighter.rb @@ -0,0 +1,68 @@ +class Search::Highlighter + OPENING_MARK = "" + CLOSING_MARK = "" + ELIPSIS = "..." + + attr_reader :query + + def initialize(query) + @query = query + end + + def highlight(text) + result = text.dup + + terms.each do |term| + result.gsub!(/\b(#{Regexp.escape(term)}\w*)\b/i) do |match| + "#{OPENING_MARK}#{match}#{CLOSING_MARK}" + end + end + + escape_highlight_marks(result) + end + + def snippet(text, max_words: 20) + words = text.split(/\s+/) + match_index = words.index { |word| terms.any? { |term| word.downcase.include?(term.downcase) } } + + if words.length <= max_words + highlight(text) + elsif match_index + start_index = [ 0, match_index - max_words / 2 ].max + end_index = [ words.length - 1, start_index + max_words - 1 ].min + + snippet_text = words[start_index..end_index].join(" ") + snippet_text = "...#{snippet_text}" if start_index > 0 + snippet_text = "#{snippet_text}..." if end_index < words.length - 1 + + highlight(snippet_text) + else + text.truncate_words(max_words, omission: "...") + end + end + + private + def terms + @terms ||= begin + terms = [] + + query.scan(/"([^"]+)"/) do |phrase| + terms << phrase.first + end + + unquoted = query.gsub(/"[^"]+"/, "") + unquoted.split(/\s+/).each do |word| + terms << word if word.present? + end + + terms.uniq + end + end + + def escape_highlight_marks(html) + CGI.escapeHTML(html) + .gsub(CGI.escapeHTML(OPENING_MARK), OPENING_MARK.html_safe) + .gsub(CGI.escapeHTML(CLOSING_MARK), CLOSING_MARK.html_safe) + .html_safe + end +end diff --git a/app/models/search/query.rb b/app/models/search/query.rb index 400e0a48d..d54142f9e 100644 --- a/app/models/search/query.rb +++ b/app/models/search/query.rb @@ -1,4 +1,7 @@ class Search::Query < ApplicationRecord + belongs_to :account, default: -> { user&.account || Current.account } + belongs_to :user, optional: true + validates :terms, presence: true before_validation :sanitize_terms @@ -12,7 +15,9 @@ class Search::Query < ApplicationRecord end end - alias_attribute :to_s, :terms + def to_s + Search::Stemmer.stem(terms.to_s) + end private def sanitize_terms diff --git a/app/models/search/record.rb b/app/models/search/record.rb new file mode 100644 index 000000000..72cf39865 --- /dev/null +++ b/app/models/search/record.rb @@ -0,0 +1,90 @@ +class Search::Record < ApplicationRecord + self.abstract_class = true + + SHARD_COUNT = 16 + + SHARD_CLASSES = SHARD_COUNT.times.map do |shard_id| + Class.new(self) do + self.table_name = "search_records_#{shard_id}" + + def self.name + "Search::Record" + end + end + end.freeze + + belongs_to :searchable, polymorphic: true + belongs_to :card + + # Virtual attributes from search query + attribute :query, :string + + validates :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :created_at, presence: true + + class << self + def for_account(account_id) + SHARD_CLASSES[shard_id_for_account(account_id)] + end + + def shard_id_for_account(account_id) + Zlib.crc32(account_id.to_s) % SHARD_COUNT + end + + def card_join + "INNER JOIN #{table_name} ON #{table_name}.card_id = cards.id" + end + end + + scope :for_query, ->(query:, user:) do + if query.valid? && user.board_ids.any? + matching(query.to_s).for_user(user) + else + none + end + end + + scope :matching, ->(query) do + where("MATCH(#{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", query) + end + + scope :for_user, ->(user) do + where(account_id: user.account_id, board_id: user.board_ids) + end + + scope :search, ->(query:, user:) do + for_query(query: query, user: user) + .includes(:searchable, card: [ :board, :creator ]) + .select(:id, :searchable_type, :searchable_id, :card_id, :board_id, :account_id, :created_at, "#{connection.quote(query.terms)} AS query") + .order(created_at: :desc) + end + + def source + searchable_type == "Comment" ? searchable : card + end + + def comment + searchable if searchable_type == "Comment" + end + + def card_title + highlight(card.title, show: :full) if card_id + end + + def card_description + highlight(card.description.to_plain_text, show: :snippet) if card_id + end + + def comment_body + highlight(comment.body.to_plain_text, show: :snippet) if comment + end + + private + def highlight(text, show:) + if text.present? && attribute?(:query) + highlighter = Search::Highlighter.new(query) + show == :snippet ? highlighter.snippet(text) : highlighter.highlight(text) + else + text + end + end +end diff --git a/app/models/search/result.rb b/app/models/search/result.rb index 28e1f7172..6482aabb1 100644 --- a/app/models/search/result.rb +++ b/app/models/search/result.rb @@ -1,18 +1,22 @@ class Search::Result < ApplicationRecord + attribute :card_id, :uuid + attribute :comment_id, :uuid + attribute :creator_id, :uuid + belongs_to :creator, class_name: "User" belongs_to :card, foreign_key: :card_id, optional: true belongs_to :comment, foreign_key: :comment_id, optional: true def card_title - escape_highlight card_title_in_database + highlight(card.title, show: :full) if card_id end def card_description - escape_highlight card_description_in_database + highlight(card.description.to_plain_text, show: :snippet) if card_id end def comment_body - escape_highlight comment_body_in_database + highlight(comment.body.to_plain_text, show: :snippet) if comment_id end def source @@ -24,14 +28,12 @@ class Search::Result < ApplicationRecord end private - def escape_highlight(html) - if html - CGI.escapeHTML(html) - .gsub(CGI.escapeHTML(Search::HIGHLIGHT_OPENING_MARK), Search::HIGHLIGHT_OPENING_MARK.html_safe) - .gsub(CGI.escapeHTML(Search::HIGHLIGHT_CLOSING_MARK), Search::HIGHLIGHT_CLOSING_MARK.html_safe) - .html_safe + def highlight(text, show:) + if text.present? && attribute?(:query) + highlighter = Search::Highlighter.new(query) + show == :snippet ? highlighter.snippet(text) : highlighter.highlight(text) else - nil + text end end end diff --git a/app/models/search/stemmer.rb b/app/models/search/stemmer.rb new file mode 100644 index 000000000..f6a6c56d4 --- /dev/null +++ b/app/models/search/stemmer.rb @@ -0,0 +1,13 @@ +module Search::Stemmer + extend self + + STEMMER = Mittens::Stemmer.new + + def stem(value) + if value.present? + value.gsub(/[^\w\s]/, "").split(/\s+/).map { |word| STEMMER.stem(word.downcase) }.join(" ") + else + value + end + end +end diff --git a/app/models/session.rb b/app/models/session.rb index 92d0f20b2..63ef5cdd5 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,3 +1,3 @@ -class Session < UntenantedRecord +class Session < ApplicationRecord belongs_to :identity end diff --git a/app/models/step.rb b/app/models/step.rb index c2063dfaa..27d580de1 100644 --- a/app/models/step.rb +++ b/app/models/step.rb @@ -1,4 +1,5 @@ class Step < ApplicationRecord + belongs_to :account, default: -> { card.account } belongs_to :card, touch: true scope :completed, -> { where(completed: true) } diff --git a/app/models/tag.rb b/app/models/tag.rb index 9c6e6c260..192f0b86e 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,6 +1,7 @@ class Tag < ApplicationRecord include Attachable, Filterable + belongs_to :account, default: -> { Current.account } has_many :taggings, dependent: :destroy has_many :cards, through: :taggings diff --git a/app/models/tagging.rb b/app/models/tagging.rb index 73daa0879..0ed8df9f9 100644 --- a/app/models/tagging.rb +++ b/app/models/tagging.rb @@ -1,4 +1,5 @@ class Tagging < ApplicationRecord + belongs_to :account, default: -> { card.account } belongs_to :tag belongs_to :card, touch: true end diff --git a/app/models/untenanted_record.rb b/app/models/untenanted_record.rb deleted file mode 100644 index 94da45603..000000000 --- a/app/models/untenanted_record.rb +++ /dev/null @@ -1,4 +0,0 @@ -class UntenantedRecord < ActiveRecord::Base - self.abstract_class = true - connects_to database: { writing: :untenanted } -end diff --git a/app/models/user.rb b/app/models/user.rb index fabb0224a..4458712a5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,13 +1,12 @@ class User < ApplicationRecord - include Accessor, Assignee, Attachable, Configurable, + include Accessor, Assignee, Attachable, Configurable, EmailAddressChangeable, Mentionable, Named, Notifiable, Role, Searcher, Watcher include Timelined # Depends on Accessor has_one_attached :avatar - belongs_to :membership, optional: true - - has_one :identity, through: :membership, disable_joins: true + belongs_to :account + belongs_to :identity, optional: true has_many :comments, inverse_of: :creator, dependent: :destroy @@ -16,10 +15,14 @@ class User < ApplicationRecord has_many :pins, dependent: :destroy has_many :pinned_cards, through: :pins, source: :card + scope :with_avatars, -> { preload(:account, :avatar_attachment) } + delegate :staff?, to: :identity, allow_nil: true def deactivate - accesses.destroy_all - update! active: false + transaction do + accesses.destroy_all + update! active: false, identity: nil + end end end diff --git a/app/models/user/accessor.rb b/app/models/user/accessor.rb index a7826b0b4..352a9ecb2 100644 --- a/app/models/user/accessor.rb +++ b/app/models/user/accessor.rb @@ -12,6 +12,6 @@ module User::Accessor private def grant_access_to_boards - Access.insert_all Board.all_access.pluck(:id).collect { |board_id| { board_id: board_id, user_id: id } } + Access.insert_all account.boards.all_access.pluck(:id).collect { |board_id| { id: ActiveRecord::Type::Uuid.generate, board_id: board_id, user_id: id, account_id: account.id } } end end diff --git a/app/models/user/day_timeline.rb b/app/models/user/day_timeline.rb index a5f98cb8a..623433126 100644 --- a/app/models/user/day_timeline.rb +++ b/app/models/user/day_timeline.rb @@ -70,6 +70,7 @@ class User::DayTimeline def timelineable_events Event + .includes(:creator, :board, :eventable) .where(board: boards) .where(action: TIMELINEABLE_ACTIONS) end diff --git a/app/models/membership/email_address_changeable.rb b/app/models/user/email_address_changeable.rb similarity index 53% rename from app/models/membership/email_address_changeable.rb rename to app/models/user/email_address_changeable.rb index 5ec2b554a..ce18d46d0 100644 --- a/app/models/membership/email_address_changeable.rb +++ b/app/models/user/email_address_changeable.rb @@ -1,26 +1,21 @@ -module Membership::EmailAddressChangeable +module User::EmailAddressChangeable EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address" EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes extend ActiveSupport::Concern - class_methods do - def change_email_address_using_token(token) - parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE) - membership = parsed_token&.find + def change_email_address_using_token(token) + parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE) - if parsed_token.nil? - raise ArgumentError, "The token is invalid" - elsif membership.nil? - raise ArgumentError, "The membership no longer exists" - elsif membership.identity.email_address != parsed_token.params.fetch("old_email_address") - raise ArgumentError, "The token was generated for a different email address" - else - new_email_address = parsed_token.params.fetch("new_email_address") - membership.change_email_address(new_email_address) - end - - membership + if parsed_token.nil? + raise ArgumentError, "The token is invalid" + elsif parsed_token.find != self + raise ArgumentError, "The token was generated for a different user" + elsif identity.email_address != parsed_token.params.fetch("old_email_address") + raise ArgumentError, "The token was generated for a different email address" + else + new_email_address = parsed_token.params.fetch("new_email_address") + change_email_address(new_email_address) end end @@ -30,10 +25,10 @@ module Membership::EmailAddressChangeable expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION ) - IdentityMailer.email_change_confirmation( + UserMailer.email_change_confirmation( email_address: new_email_address, token: token, - membership: self + user: self ).deliver_later end @@ -42,6 +37,8 @@ module Membership::EmailAddressChangeable new_identity = Identity.find_or_create_by!(email_address: new_email_address) update!(identity: new_identity) end + + self end private diff --git a/app/models/user/filtering.rb b/app/models/user/filtering.rb index 4ac9cf86a..1b4f3070e 100644 --- a/app/models/user/filtering.rb +++ b/app/models/user/filtering.rb @@ -21,15 +21,15 @@ class User::Filtering end def tags - @tags ||= Tag.all.alphabetically + @tags ||= account.tags.all.alphabetically end def users - @users ||= User.active.alphabetically + @users ||= account.users.active.alphabetically end def filters - @filters ||= Current.user.filters.all + @filters ||= user.filters.all end def expanded? @@ -77,4 +77,9 @@ class User::Filtering def cache_key ActiveSupport::Cache.expand_cache_key([ user, filter, expanded?, boards, tags, users, filters ], "user-filtering") end + + private + def account + user.account + end end diff --git a/app/models/user/searcher.rb b/app/models/user/searcher.rb index 8c3489bd0..951717082 100644 --- a/app/models/user/searcher.rb +++ b/app/models/user/searcher.rb @@ -6,7 +6,7 @@ module User::Searcher end def search(terms) - Search.new(self, terms).results + Search.results(query: terms, user: self) end def remember_search(terms) diff --git a/app/models/user/settings.rb b/app/models/user/settings.rb index a381acae3..1651e9a64 100644 --- a/app/models/user/settings.rb +++ b/app/models/user/settings.rb @@ -1,4 +1,5 @@ class User::Settings < ApplicationRecord + belongs_to :account, default: -> { user.account } belongs_to :user enum :bundle_email_frequency, %i[ never every_few_hours daily weekly ], diff --git a/app/models/watch.rb b/app/models/watch.rb index 7b88a7ec4..9739075ec 100644 --- a/app/models/watch.rb +++ b/app/models/watch.rb @@ -1,4 +1,5 @@ class Watch < ApplicationRecord + belongs_to :account, default: -> { user.account } belongs_to :user belongs_to :card, touch: true diff --git a/app/models/webhook.rb b/app/models/webhook.rb index f66cc6942..3b34af041 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -25,6 +25,7 @@ class Webhook < ApplicationRecord has_many :deliveries, dependent: :delete_all has_one :delinquency_tracker, dependent: :delete + belongs_to :account, default: -> { board.account } belongs_to :board serialize :subscribed_actions, type: Array, coder: JSON @@ -48,7 +49,7 @@ class Webhook < ApplicationRecord end def renderer - @renderer ||= ApplicationController.renderer.new(script_name: "/#{tenant}", https: !Rails.env.local?) + @renderer ||= ApplicationController.renderer.new(script_name: account.slug, https: !Rails.env.local?) end def for_basecamp? diff --git a/app/models/webhook/delinquency_tracker.rb b/app/models/webhook/delinquency_tracker.rb index 1824db418..df6df706b 100644 --- a/app/models/webhook/delinquency_tracker.rb +++ b/app/models/webhook/delinquency_tracker.rb @@ -2,6 +2,7 @@ class Webhook::DelinquencyTracker < ApplicationRecord DELINQUENCY_THRESHOLD = 10 DELINQUENCY_DURATION = 1.hour + belongs_to :account, default: -> { webhook.account } belongs_to :webhook def record_delivery_of(delivery) diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index e90a8decb..05768e04a 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -10,6 +10,7 @@ class Webhook::Delivery < ApplicationRecord IPAddr.new("0.0.0.0/8") ].freeze + belongs_to :account, default: -> { webhook.account } belongs_to :webhook belongs_to :event diff --git a/app/views/account/join_codes/edit.html.erb b/app/views/account/join_codes/edit.html.erb index 14958a44c..44854c6d0 100644 --- a/app/views/account/join_codes/edit.html.erb +++ b/app/views/account/join_codes/edit.html.erb @@ -11,7 +11,7 @@ <% end %> -
+

<%= @page_title %>

How many times can this link be used to join the account?

diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb index c528bb07d..d16006019 100644 --- a/app/views/account/join_codes/show.html.erb +++ b/app/views/account/join_codes/show.html.erb @@ -11,13 +11,13 @@ <% end %> -
+

<%= @page_title %>

Share the link below to invite people to this account

- <% url = join_url(code: @join_code.code, tenant: @join_code.tenant, script_name: nil) %> + <% url = join_url(code: @join_code.code, script_name: Current.account.slug) %>
diff --git a/app/views/cards/comments/_new.html.erb b/app/views/cards/comments/_new.html.erb index 5d80fed78..8d1426f5a 100644 --- a/app/views/cards/comments/_new.html.erb +++ b/app/views/cards/comments/_new.html.erb @@ -17,13 +17,10 @@ <%= form.button class: "comment__submit btn btn--reversed", data: { form_target: "submit" }, disabled: true do %> - Post this comment + Post + <%= hotkey_label([ "ctrl", "enter" ]) %> <% end %> - - <%= hotkey_label([ "ctrl", "enter" ]) %> - - <% end %>
diff --git a/app/views/cards/container/footer/_draft.html.erb b/app/views/cards/container/footer/_draft.html.erb index 30e9aa02b..dbc95a6da 100644 --- a/app/views/cards/container/footer/_draft.html.erb +++ b/app/views/cards/container/footer/_draft.html.erb @@ -1,19 +1,15 @@
-
- <%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn", - form: { data: { controller: "form" } }, - data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %> - Create card - <% end %> - <%= hotkey_label([ "ctrl", "enter" ]) %> -
+ <%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn", + form: { data: { controller: "form" } }, + data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %> + Create card + <%= hotkey_label([ "ctrl", "enter" ]) %> + <% end %> -
- <%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another", - title: "Create and add another (ctrl+shift+enter)", form: { data: { controller: "form" } }, - data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %> - Create and add another - <% end %> - <%= hotkey_label([ "ctrl", "shift", "enter" ]) %> -
+ <%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another", + title: "Create and add another (ctrl+shift+enter)", form: { data: { controller: "form" } }, + data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %> + Create and add another + <%= hotkey_label([ "ctrl", "shift", "enter" ]) %> + <% end %>
diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index 5f6d37eb1..7a66d3c78 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -1,6 +1,6 @@ <% draggable = local_assigns.fetch(:draggable, false) && card.published? %> -<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.id, drag_and_drop_target: "item" } do %> +<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item" } do %>
<%= render "cards/display/preview/board", card: card %> diff --git a/app/views/cards/display/_public_preview.html.erb b/app/views/cards/display/_public_preview.html.erb index d3b36e229..edf73d3de 100644 --- a/app/views/cards/display/_public_preview.html.erb +++ b/app/views/cards/display/_public_preview.html.erb @@ -4,7 +4,7 @@
Card number - <%= card.id %> + <%= card.number %> <%= card.board.name %> diff --git a/app/views/cards/display/common/_board.html.erb b/app/views/cards/display/common/_board.html.erb index 96c2e191d..3e91a0d78 100644 --- a/app/views/cards/display/common/_board.html.erb +++ b/app/views/cards/display/common/_board.html.erb @@ -1,7 +1,7 @@
<%= form_with url: session_start_path, method: :post, data: { controller: "form auto-submit" } do |form| %> diff --git a/app/views/mailers/identity_mailer/email_change_confirmation.html.erb b/app/views/user_mailer/email_change_confirmation.html.erb similarity index 64% rename from app/views/mailers/identity_mailer/email_change_confirmation.html.erb rename to app/views/user_mailer/email_change_confirmation.html.erb index e07fdedf1..8ab21b43b 100644 --- a/app/views/mailers/identity_mailer/email_change_confirmation.html.erb +++ b/app/views/user_mailer/email_change_confirmation.html.erb @@ -1,6 +1,6 @@

Confirm your email address change

-<%= link_to "Yes use use this email address", email_address_confirmation_url(membership_id: @membership.id, email_address_token: @token), class: "btn" %> +<%= link_to "Yes use use this email address", user_email_address_confirmation_url(script_name: @user.account.slug, user_id: @user.id, email_address_token: @token), class: "btn" %>

If you didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.

diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index c3e3aa09a..5ac5e7807 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -40,7 +40,7 @@
<%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true, readonly: true, value: @user.identity.email_address %> - <%= link_to "Change email", new_email_address_url(script_name: nil, membership_id: Current.membership.id), class: "btn btn--plain txt-link txt-small txt-nowrap" %> + <%= link_to "Change email", new_user_email_address_path(user_id: Current.user.id), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
- <%= form_with url: email_address_confirmation_path(membership_id: @membership.id), method: :post, data: { controller: "form auto-submit" } do |form| %> + <%= form_with url: user_email_address_confirmation_path(user_id: @user.id), method: :post, data: { controller: "form auto-submit" } do |form| %> <%= form.hidden_field :email_address_token, value: params[:email_address_token] %> <% end %> diff --git a/gems/fizzy-saas/app/views/signup/memberships/new.html.erb b/gems/fizzy-saas/app/views/signup/memberships/new.html.erb deleted file mode 100644 index dc60fd8a8..000000000 --- a/gems/fizzy-saas/app/views/signup/memberships/new.html.erb +++ /dev/null @@ -1,33 +0,0 @@ -<% @page_title = "Complete your sign-up" %> - -
"> -

<%= @page_title %>

- - <%= form_with model: @signup, url: saas.signup_membership_path, scope: "signup", class: "flex flex-column gap", data: { controller: "form" } do |form| %> - <%= form.hidden_field :new_user %> - - <%= form.text_field :full_name, class: "input txt-large", autocomplete: "name", placeholder: "Enter your full name…", autofocus: true, required: true %> - -

You’re one step away. Just enter your name to get your own Fizzy account.

- - - <% if @signup.errors.any? %> -
-
    - <% @signup.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
- <% end %> - - - <% end %> -
- -<% content_for :footer do %> - <%= render "sessions/footer" %> -<% end %> diff --git a/gems/fizzy-saas/config/routes.rb b/gems/fizzy-saas/config/routes.rb index b99f172ff..2b9639edb 100644 --- a/gems/fizzy-saas/config/routes.rb +++ b/gems/fizzy-saas/config/routes.rb @@ -2,7 +2,6 @@ Fizzy::Saas::Engine.routes.draw do get "/signup/new", to: redirect("/session/new") namespace :signup do - resource :membership, only: %i[ new create ] resource :completion, only: %i[ new create ] end diff --git a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb index aa18721fd..49628a188 100644 --- a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb +++ b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb @@ -7,16 +7,11 @@ class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest @signup.create_identity || raise("Failed to create identity") sign_in_as @signup.identity - - @signup.create_membership || raise("Failed to create membership") end test "new" do untenanted do - get saas.new_signup_completion_path(signup: { - membership_id: @signup.membership_id, - full_name: @signup.full_name, - account_name: @signup.account_name }) + get saas.new_signup_completion_path end assert_response :success @@ -26,21 +21,19 @@ class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest untenanted do post saas.signup_completion_path, params: { signup: { - membership_id: @signup.membership_id, - full_name: @signup.full_name, - account_name: @signup.account_name + full_name: @signup.full_name } } end - assert_redirected_to landing_path(script_name: "/#{@signup.tenant}"), "Successful completion should redirect to root in new tenant" + assert_response :redirect, "Valid params should redirect" + end + test "create with invalid params" do untenanted do post saas.signup_completion_path, params: { signup: { - membership_id: @membership_id, - full_name: "", - account_name: "" + full_name: "" } } end diff --git a/gems/fizzy-saas/test/controllers/signups/memberships_controller_test.rb b/gems/fizzy-saas/test/controllers/signups/memberships_controller_test.rb deleted file mode 100644 index 57010d2bd..000000000 --- a/gems/fizzy-saas/test/controllers/signups/memberships_controller_test.rb +++ /dev/null @@ -1,67 +0,0 @@ -require "test_helper" - -class Signup::MembershipsControllerTest < ActionDispatch::IntegrationTest - setup do - @identity = Identity.create!(email_address: "newuser@example.com") - magic_link = @identity.send_magic_link - - untenanted do - post session_magic_link_url, params: { code: magic_link.code } - assert_response :redirect, "Magic link should succeed" - - cookie = cookies.get_cookie "session_token" - assert_not_nil cookie, "Expected session_token cookie to be set after magic link consumption" - end - end - - test "new" do - untenanted do - get saas.new_signup_membership_path - - assert_response :success - end - end - - test "new with new_user param" do - untenanted do - get saas.new_signup_membership_path(signup: { new_user: true }) - - assert_response :success - end - end - - test "create" do - untenanted do - assert_difference -> { Membership.count }, 1 do - post saas.signup_membership_path, params: { - signup: { - full_name: "New User" - } - } - end - - membership = Membership.last - assert_redirected_to saas.new_signup_completion_path( - signup: { - membership_id: membership.signed_id(purpose: :account_creation), - full_name: "New User", - account_name: "New's Fizzy" - } - ) - end - end - - test "create with invalid params" do - untenanted do - assert_no_difference -> { Membership.count } do - post saas.signup_membership_path, params: { - signup: { - full_name: "" - } - } - end - - assert_response :unprocessable_entity, "Invalid params should return unprocessable entity" - end - end -end diff --git a/gems/fizzy-saas/test/models/signup/account_name_generator_test.rb b/gems/fizzy-saas/test/models/signup/account_name_generator_test.rb index fdaac96ce..d22922b29 100644 --- a/gems/fizzy-saas/test/models/signup/account_name_generator_test.rb +++ b/gems/fizzy-saas/test/models/signup/account_name_generator_test.rb @@ -11,34 +11,49 @@ class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase account_name = @generator.generate assert_equal "Newart's Fizzy", account_name, "The 1st account doesn't have 1st in the name" - first_membership = @identity.memberships.create(tenant: "1st") - first_membership.stubs(:account_name).returns(account_name) + first_account = Account.create!(external_account_id: "1st", name: account_name) + Current.without_account do + @identity.users.create!(account: first_account, name: @name) + @identity.reload + end account_name = @generator.generate assert_equal "Newart's 2nd Fizzy", account_name - second_membership = @identity.memberships.create(tenant: "2nd") - second_membership.stubs(:account_name).returns(account_name) + second_account = Account.create!(external_account_id: "2nd", name: account_name) + Current.without_account do + @identity.users.create!(account: second_account, name: @name) + @identity.reload + end account_name = @generator.generate assert_equal "Newart's 3rd Fizzy", account_name - third_membership = @identity.memberships.create(tenant: "3nd") - third_membership.stubs(:account_name).returns(account_name) + third_account = Account.create!(external_account_id: "3rd", name: account_name) + Current.without_account do + @identity.users.create!(account: third_account, name: @name) + @identity.reload + end account_name = @generator.generate assert_equal "Newart's 4th Fizzy", account_name - fourth_membership = @identity.memberships.create(tenant: "4th") - fourth_membership.stubs(:account_name).returns(account_name) + fourth_account = Account.create!(external_account_id: "4th", name: account_name) + Current.without_account do + @identity.users.create!(account: fourth_account, name: @name) + @identity.reload + end account_name = @generator.generate assert_equal "Newart's 5th Fizzy", account_name end test "generate continues from the previous highest index" do - membership = @identity.memberships.create(tenant: "12th") - membership.stubs(:account_name).returns("Newart's 12th Fizzy") + account = Account.create!(external_account_id: "12th", name: "Newart's 12th Fizzy") + Current.without_account do + @identity.users.create!(account: account, name: @name) + @identity.reload + end account_name = @generator.generate assert_equal "Newart's 13th Fizzy", account_name diff --git a/gems/fizzy-saas/test/models/signup_test.rb b/gems/fizzy-saas/test/models/signup_test.rb index e5364c141..69c0e699f 100644 --- a/gems/fizzy-saas/test/models/signup_test.rb +++ b/gems/fizzy-saas/test/models/signup_test.rb @@ -1,10 +1,6 @@ require "test_helper" class SignupTest < ActiveSupport::TestCase - setup do - @starting_tenants = ApplicationRecord.tenants - end - test "#create_identity" do signup = Signup.new(email_address: "brian@example.com") @@ -32,59 +28,26 @@ class SignupTest < ActiveSupport::TestCase end end - test "#create_membership" do - signup = Signup.new( - full_name: "Kevin", - identity: identities(:kevin) - ) - - assert_difference -> { Membership.count }, 1 do - assert signup.create_membership - end - - assert signup.tenant - assert signup.membership - assert signup.membership_id - - signup_invalid = Signup.new( - full_name: "", - identity: identities(:kevin) - ) - assert_not signup_invalid.create_membership, "Create membership should fail with invalid params" - assert_not_empty signup_invalid.errors[:full_name], "Should have validation error for full_name" - end - test "#complete" do Account.any_instance.expects(:setup_customer_template).once + Current.without_account do + signup = Signup.new( + full_name: "Kevin", + identity: identities(:kevin) + ) - signup_for_membership = Signup.new( - full_name: "Kevin", - identity: identities(:kevin) - ) - signup_for_membership.create_membership + assert signup.complete - signup = Signup.new( - full_name: "Kevin", - account_name: "37signals", - membership_id: signup_for_membership.membership_id, - identity: identities(:kevin) - ) + assert signup.account + assert signup.user + assert_equal "Kevin", signup.user.name - assert signup.complete - - assert signup.tenant - assert signup.account - assert signup.user - assert_equal "Kevin", signup.user.name - assert_equal "37signals", signup.account.name - - signup_invalid = Signup.new( - full_name: "", - account_name: "37signals", - membership_id: signup_for_membership.membership_id, - identity: identities(:kevin) - ) - assert_not signup_invalid.complete, "Complete should fail with invalid params" - assert_not_empty signup_invalid.errors[:full_name], "Should have validation error for full_name" + signup_invalid = Signup.new( + full_name: "", + identity: identities(:kevin) + ) + assert_not signup_invalid.complete + assert_not_empty signup_invalid.errors[:full_name] + end end end diff --git a/lib/deployment.rb b/lib/deployment.rb new file mode 100644 index 000000000..8d2d6ef37 --- /dev/null +++ b/lib/deployment.rb @@ -0,0 +1 @@ +require_relative "deployment/database_resolver.rb" diff --git a/lib/deployment/database_resolver.rb b/lib/deployment/database_resolver.rb new file mode 100644 index 000000000..c52447c17 --- /dev/null +++ b/lib/deployment/database_resolver.rb @@ -0,0 +1,18 @@ +module Deployment + class DatabaseResolver < ActiveRecord::Middleware::DatabaseSelector::Resolver + def self.in_primary_datacenter? + ENV["PRIMARY_DATACENTER"].present? || Rails.env.local? + end + + def reading_request?(request) + # Disables writes (and so primary-DB stickiness) in non-primary datacenters + super || !DatabaseResolver.in_primary_datacenter? + end + + private + def read_from_primary? + # Only the primary datacenter can read from the primary database, non-primary DCs have local DB replicas + super && DatabaseResolver.in_primary_datacenter? + end + end +end diff --git a/lib/rails_ext/active_record_uuid_type.rb b/lib/rails_ext/active_record_uuid_type.rb new file mode 100644 index 000000000..3aca09762 --- /dev/null +++ b/lib/rails_ext/active_record_uuid_type.rb @@ -0,0 +1,43 @@ +# Custom UUID attribute type for MySQL binary storage with base36 string representation +module ActiveRecord + module Type + class Uuid < Binary + BASE36_LENGTH = 25 # 36^25 > 2^128 + + def self.generate + uuid = SecureRandom.uuid_v7 + hex = uuid.delete("-") + normalize_base36(hex.to_i(16)) + end + + def self.normalize_base36(integer) + integer.to_s(36).rjust(BASE36_LENGTH, "0") + end + + def serialize(value) + return unless value + + binary = hex(value).scan(/../).map(&:hex).pack("C*") + super(binary) + end + + def hex(value) + value.to_s.to_i(36).to_s(16).rjust(32, "0") + end + + def deserialize(value) + return unless value + + hex = value.to_s.unpack1("H*") + Uuid.normalize_base36(hex.to_i(16)) + end + + def cast(value) + value + end + end + end +end + +# Register the UUID type for Trilogy adapter +ActiveRecord::Type.register(:uuid, ActiveRecord::Type::Uuid, adapter: :trilogy) diff --git a/lib/tasks/search.rake b/lib/tasks/search.rake new file mode 100644 index 000000000..0bb3591ee --- /dev/null +++ b/lib/tasks/search.rake @@ -0,0 +1,21 @@ +namespace :search do + desc "Reindex all cards and comments in the search index" + task reindex: :environment do + puts "Clearing search records..." + Search::Record::SHARD_COUNT.times do |shard_id| + ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}") + end + + puts "Reindexing cards..." + Card.find_each do |card| + card.reindex + end + + puts "Reindexing comments..." + Comment.find_each do |comment| + comment.reindex + end + + puts "Done! Reindexed #{Card.count} cards and #{Comment.count} comments." + end +end diff --git a/lib/tasks/seed.rake b/lib/tasks/seed.rake index 71483eaf8..e6ae37ba6 100644 --- a/lib/tasks/seed.rake +++ b/lib/tasks/seed.rake @@ -1,11 +1,12 @@ namespace :seed do desc "Seed customer data for a specific account (e.g: rails seed:customer ARTENANT=1234)" task :customer, [ :tenant_id ] => "db:tenant" do |t, args| - raise "Please provide a tenant ID: rails seed:customer ARTENANT=1234" unless ApplicationRecord.current_tenant + raise "TODO:PLANB: Need to re-implement this task for untenanted context" + # raise "Please provide a tenant ID: rails seed:customer ARTENANT=1234" unless ApplicationRecord.current_tenant - account = Account.sole - Account::Seeder.new(account, User.active.first).seed! + # account = Account.sole + # Account::Seeder.new(account, User.active.first).seed! - puts "✓ Seeded account #{account.name} (tenant: #{account.id})" + # puts "✓ Seeded account #{account.name} (tenant: #{account.id})" end end diff --git a/script/configure-lb-production.sh b/script/configure-lb-production.sh index a6bf7fd24..f961adb4a 100755 --- a/script/configure-lb-production.sh +++ b/script/configure-lb-production.sh @@ -4,68 +4,45 @@ set -e # fizzy-lb-101.df-iad-int.37signals.com # -# Service Host Path Target State TLS -# fizzy app.box-car.com,app.fizzy.do / fizzy-app-101.df-iad-int.37signals.com,fizzy-app-102.df-iad-int.37signals.com running yes -# fizzy-admin app.box-car.com,app.fizzy.do /admin fizzy-app-101.df-iad-int.37signals.com running yes ssh app@fizzy-lb-101.df-iad-int.37signals.com \ docker exec fizzy-load-balancer \ - kamal-proxy deploy boxcar \ + kamal-proxy deploy fizzy \ + --force \ --tls \ - --host=app.box-car.com,app.fizzy.do \ + --host=app.fizzy.do \ + --writer-affinity-timeout=1s \ + --tls-acme-cache-path=/certificates \ --target=fizzy-app-101.df-iad-int.37signals.com \ - --read-target=fizzy-app-102.df-iad-int.37signals.com \ - --tls-acme-cache-path=/certificates - -ssh app@fizzy-lb-101.df-iad-int.37signals.com \ - docker exec fizzy-load-balancer \ - kamal-proxy deploy boxcar-admin \ - --host=app.box-car.com,app.fizzy.do \ - --path-prefix /admin \ - --strip-path-prefix=false \ - --target=fizzy-app-101.df-iad-int.37signals.com + --target=fizzy-app-102.df-iad-int.37signals.com # fizzy-lb-01.sc-chi-int.37signals.com # -# Service Host Path Target State TLS -# fizzy app.box-car.com,app.fizzy.do / fizzy-app-101.df-iad-int.37signals.com,fizzy-app-02.sc-chi-int.37signals.com running yes -# fizzy-admin app.box-car.com,app.fizzy.do /admin fizzy-app-101.df-iad-int.37signals.com running yes ssh app@fizzy-lb-01.sc-chi-int.37signals.com \ docker exec fizzy-load-balancer \ - kamal-proxy deploy boxcar \ + kamal-proxy deploy fizzy \ + --force \ --tls \ - --host=app.box-car.com,app.fizzy.do \ + --host=app.fizzy.do \ + --writer-affinity-timeout=1s \ + --tls-acme-cache-path=/certificates \ --target=fizzy-app-101.df-iad-int.37signals.com \ - --read-target=fizzy-app-02.sc-chi-int.37signals.com \ - --tls-acme-cache-path=/certificates - -ssh app@fizzy-lb-01.sc-chi-int.37signals.com \ - docker exec fizzy-load-balancer \ - kamal-proxy deploy boxcar-admin \ - --host=app.box-car.com,app.fizzy.do \ - --path-prefix /admin \ - --strip-path-prefix=false \ - --target=fizzy-app-101.df-iad-int.37signals.com + --target=fizzy-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-app-01.sc-chi-int.37signals.com \ + --read-target=fizzy-app-02.sc-chi-int.37signals.com # fizzy-lb-401.df-ams-int.37signals.com # -# Service Host Path Target State TLS -# fizzy app.box-car.com,app.fizzy.do / fizzy-app-101.df-iad-int.37signals.com,fizzy-app-402.df-ams-int.37signals.com running yes -# fizzy-admin app.box-car.com,app.fizzy.do /admin fizzy-app-101.df-iad-int.37signals.com running yes ssh app@fizzy-lb-401.df-ams-int.37signals.com \ docker exec fizzy-load-balancer \ - kamal-proxy deploy boxcar \ + kamal-proxy deploy fizzy \ + --force \ --tls \ - --host=app.box-car.com,app.fizzy.do \ + --host=app.fizzy.do \ + --writer-affinity-timeout=1s \ + --tls-acme-cache-path=/certificates \ --target=fizzy-app-101.df-iad-int.37signals.com \ - --read-target=fizzy-app-402.df-ams-int.37signals.com \ - --tls-acme-cache-path=/certificates - -ssh app@fizzy-lb-401.df-ams-int.37signals.com \ - docker exec fizzy-load-balancer \ - kamal-proxy deploy boxcar-admin \ - --host=app.box-car.com,app.fizzy.do \ - --path-prefix /admin \ - --strip-path-prefix=false \ - --target=fizzy-app-101.df-iad-int.37signals.com + --target=fizzy-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-app-401.df-ams-int.37signals.com \ + --read-target=fizzy-app-402.df-ams-int.37signals.com diff --git a/script/configure-lb-staging.sh b/script/configure-lb-staging.sh index 17ae50494..789865d16 100755 --- a/script/configure-lb-staging.sh +++ b/script/configure-lb-staging.sh @@ -4,66 +4,44 @@ set -e # fizzy-staging-lb-01.sc-chi-int.37signals.com # -# Service Host Path Target State TLS -# fizzy fizzy.37signals-staging.com / fizzy-staging-app-01.sc-chi-int.37signals.com,fizzy-staging-app-02.sc-chi-int.37signals.com running yes -# fizzy-admin fizzy.37signals-staging.com /admin fizzy-staging-app-01.sc-chi-int.37signals.com running yes ssh app@fizzy-staging-lb-01.sc-chi-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ + --force \ --tls \ --host=fizzy.37signals-staging.com \ - --target=fizzy-staging-app-01.sc-chi-int.37signals.com \ - --read-target=fizzy-staging-app-02.sc-chi-int.37signals.com \ - --tls-acme-cache-path=/certificates - -ssh app@fizzy-staging-lb-01.sc-chi-int.37signals.com \ - docker exec fizzy-load-balancer \ - kamal-proxy deploy fizzy-admin \ - --host=fizzy.37signals-staging.com \ - --path-prefix /admin \ - --strip-path-prefix=false \ - --target=fizzy-staging-app-01.sc-chi-int.37signals.com + --writer-affinity-timeout=1s \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-staging-app-101.df-iad-int.37signals.com \ + --target=fizzy-staging-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-staging-app-01.sc-chi-int.37signals.com \ + --read-target=fizzy-staging-app-02.sc-chi-int.37signals.com # fizzy-staging-lb-101.df-iad-int.37signals.com # -# Service Host Path Target State TLS -# fizzy fizzy.37signals-staging.com / fizzy-staging-app-01.sc-chi-int.37signals.com,fizzy-staging-app-102.df-iad-int.37signals.com running yes -# fizzy-admin fizzy.37signals-staging.com /admin fizzy-staging-app-01.sc-chi-int.37signals.com running yes ssh app@fizzy-staging-lb-101.df-iad-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ + --force \ --tls \ --host=fizzy.37signals-staging.com \ - --target=fizzy-staging-app-01.sc-chi-int.37signals.com \ - --read-target=fizzy-staging-app-102.df-iad-int.37signals.com \ - --tls-acme-cache-path=/certificates - -ssh app@fizzy-staging-lb-101.df-iad-int.37signals.com \ - docker exec fizzy-load-balancer \ - kamal-proxy deploy fizzy-admin \ - --host=fizzy.37signals-staging.com \ - --path-prefix /admin \ - --strip-path-prefix=false \ - --target=fizzy-staging-app-01.sc-chi-int.37signals.com + --writer-affinity-timeout=1s \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-staging-app-101.df-iad-int.37signals.com \ + --target=fizzy-staging-app-102.df-iad-int.37signals.com # fizzy-staging-lb-401.df-ams-int.37signals.com # -# Service Host Path Target State TLS -# fizzy fizzy.37signals-staging.com / fizzy-staging-app-01.sc-chi-int.37signals.com,fizzy-staging-app-402.df-ams-int.37signals.com running yes -# fizzy-admin fizzy.37signals-staging.com /admin fizzy-staging-app-01.sc-chi-int.37signals.com running yes ssh app@fizzy-staging-lb-401.df-ams-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ + --force \ --tls \ --host=fizzy.37signals-staging.com \ - --target=fizzy-staging-app-01.sc-chi-int.37signals.com \ - --read-target=fizzy-staging-app-402.df-ams-int.37signals.com \ - --tls-acme-cache-path=/certificates + --writer-affinity-timeout=1s \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-staging-app-101.df-iad-int.37signals.com \ + --target=fizzy-staging-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-staging-app-401.df-ams-int.37signals.com \ + --read-target=fizzy-staging-app-402.df-ams-int.37signals.com -ssh app@fizzy-staging-lb-401.df-ams-int.37signals.com \ - docker exec fizzy-load-balancer \ - kamal-proxy deploy fizzy-admin \ - --host=fizzy.37signals-staging.com \ - --path-prefix /admin \ - --strip-path-prefix=false \ - --target=fizzy-staging-app-01.sc-chi-int.37signals.com diff --git a/script/create-account.rb b/script/create-account.rb index 1a3a7cca4..a46f4310f 100755 --- a/script/create-account.rb +++ b/script/create-account.rb @@ -83,7 +83,7 @@ Current.set( # Step 3: Get or create join code ApplicationRecord.with_tenant(tenant_id) do - account = Account.sole + account = Current.account join_code = account.join_code puts "✓ Join code ready" diff --git a/script/fix-active-storage-links.rb b/script/fix-active-storage-links.rb new file mode 100644 index 000000000..d2cb856bf --- /dev/null +++ b/script/fix-active-storage-links.rb @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "uri" +require "base64" +require "json" + +ActionText::RichText.all.where("body LIKE '%/rails/active_storage/%'").find_each do |rich_text| + next unless rich_text.body + + blobs = rich_text.embeds.map(&:blob) + + rich_text.body.send(:attachment_nodes).each do |node| + url = node["url"] + next unless url + + url_encoded_filename = url.split("/").last + filename = URI.decode_www_form_component(url_encoded_filename) + + counter += 1 + blob = blobs.select { |b| b.filename == filename } + raise "Multiple blobs with filename #{filename}" if blob.size > 1 + blob = blob.first + + if blob + node["sgid"] = blob.attachable_sgid + else + skipped += 1 + end + end + + rich_text.save! +end diff --git a/script/import-sqlite-database.rb b/script/import-sqlite-database.rb new file mode 100755 index 000000000..ac5a5f200 --- /dev/null +++ b/script/import-sqlite-database.rb @@ -0,0 +1,1368 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "pathname" +require "optparse" + +class AccountExistsError < StandardError; end + +class Import + FIX_LINK_HOSTS = { + "fizzy.37signals.com" => "app.fizzy.do", + "box-car.com" => "app.fizzy.do", + "app.box-car.com" => "app.fizzy.do" + }.freeze + + attr_reader :db_path, :untenanted_db_path, :skip_already_imported + attr_reader :account, :tenant, :mapping + + def initialize(db_path, untenanted_db_path, skip_already_imported: false) + @db_path = Pathname(db_path) + @untenanted_db_path = Pathname(untenanted_db_path) + @skip_already_imported = skip_already_imported + @mapping = nil + end + + def import_database + raise "The given database file doesn't exist" unless db_path.exist? + + @mapping = {} + + duration = ActiveSupport::Benchmark.realtime do + ApplicationRecord.transaction do + setup_account + + ActiveRecord::Base.no_touching do + Current.with(account: account) do + begin + Webhook.skip_callback(:create, :after, :create_delinquency_tracker!) + Comment.skip_callback(:commit, :after, :watch_card_by_creator) + Comment.skip_callback(:commit, :after, :track_creation) + Mention.skip_callback(:commit, :after, :watch_source_by_mentionee) + Notification.skip_callback(:commit, :after, :broadcast_unread) + Notification.skip_callback(:create, :after, :bundle) + Reaction.skip_callback(:create, :after, :register_card_activity) + Card.skip_callback(:save, :before, :set_default_title) + Card.skip_callback(:update, :after, :handle_board_change) + ActiveStorage::Blob.skip_callback(:update, :after, :touch_attachments) + ActiveStorage::Blob.skip_callback(:commit, :after, :update_service_metadata) + ActiveStorage::Attachment.skip_callback(:commit, :after, :mirror_blob_later) + ActiveStorage::Attachment.skip_callback(:commit, :after, :analyze_blob_later) + ActiveStorage::Attachment.skip_callback(:commit, :after, :transform_variants_later) + ActiveStorage::Attachment.skip_callback(:commit, :after, :purge_dependent_blob_later) + rescue => e + puts "⚠️ Warning: Could not skip some callbacks: #{e.message}" + end + + Event.suppress do + copy_users + copy_boards + copy_accesses + copy_columns + copy_cards + copy_steps + copy_comments + copy_mentions + copy_reactions + copy_tags + copy_watches + copy_pins + end + + copy_events + + Event.suppress do + copy_webhooks + copy_push_subscriptions + copy_filters + copy_entropies + end + + copy_notifications + copy_notification_bundles + + fix_links + + unless Rails.env.production? + # Don't spam real webhooks + Webhook.all.update_all(active: false) + # Don't send emails to real users + User::Settings.all.update_all(bundle_email_frequency: :never) + end + end + end + end + end + + puts "🎉 Import complete! (#{duration.round(2)}s)" + rescue AccountExistsError => e + raise e unless skip_already_imported + end + + private + def step(start_message, completion_message) + puts "⏩ #{start_message}" + + result = nil + duration = ActiveSupport::Benchmark.realtime do + result = yield + end + + interpolations = { duration: "#{duration.round(2)}s" } + interpolations.merge!(result) if result.is_a?(Hash) + completion_text = completion_message % interpolations + puts "✅ #{completion_text}" + + result + end + + def generate_uuid + ActiveRecord::Type::Uuid.generate + end + + def setup_account + step("Setting up account", "Account set up in %{duration}") do + oldest_admin = import.users.order(id: :asc).where(role: :admin, active: true).first + raise "No admin user found in the database" unless oldest_admin + + membership = untenanted.memberships.find(oldest_admin.membership_id) + account = import.accounts.sole + + new_identity = Identity.find_or_create_by!(email_address: membership.identity.email_address) + + if Account.all.exists?(external_account_id: account.external_account_id) + raise AccountExistsError, "Account already exists" + else + @account = Account.create_with_admin_user( + account: { + external_account_id: account.external_account_id, + name: account.name.truncate(255, omission: "") + }, + owner: { + name: oldest_admin.name.truncate(255, omission: ""), + identity: new_identity + } + ) + @tenant = @account.external_account_id + @admin = @account.users.find_by(role: :admin) + end + + old_join_code = import.account_join_codes.sole + + attributes = { + usage_count: old_join_code.usage_count, + usage_limit: old_join_code.usage_limit + } + attributes[:code] = old_join_code.code unless Account::JoinCode.all.exists?(code: old_join_code.code) + + @account.join_code.update_columns(**attributes) + end + end + + def copy_users + step("Copying users", "Copied %{count} users in %{duration}") do + mapping[:users] ||= {} + import.users.find_each do |old_user| + new_identity = nil + + if old_user.membership_id && old_user.active? + membership = untenanted.memberships.find(old_user.membership_id) + new_identity = Identity.find_or_create_by!(email_address: membership.identity.email_address) + end + + new_user = if new_identity == @admin.identity + @admin + else + User.create!( + account: account, + identity: new_identity, + name: old_user.name.truncate(255, omission: ""), + role: old_user.role, + active: old_user.active, + ) + end + + old_settings = old_user.settings + if old_settings + User::Settings.create!( + user: new_user, + bundle_email_frequency: old_settings.bundle_email_frequency, + timezone_name: old_settings.timezone_name + ) + end + + mapping[:users][old_user.id] = new_user.id + end + + { count: mapping[:users].size } + end + end + + def copy_boards + step("Copying boards", "Copied %{count} boards in %{duration}") do + mapping[:boards] ||= {} + import.boards.find_each do |old_board| + new_board = Board.create!( + account_id: account.id, + creator_id: mapping[:users][old_board.creator_id], + name: old_board.name.truncate(255, omission: ""), + all_access: old_board.all_access, + created_at: old_board.created_at, + updated_at: old_board.updated_at + ) + + old_publication = old_board.publication + if old_publication + Board::Publication.create!( + board_id: new_board.id, + key: old_publication.key, + created_at: old_publication.created_at, + updated_at: old_publication.updated_at + ) + end + + mapping[:boards][old_board.id] = new_board.id + end + + { count: mapping[:boards].size } + end + end + + def copy_columns + step("Copying columns", "Copied %{count} columns in %{duration}") do + mapping[:columns] ||= {} + + import.columns.find_each do |old_column| + new_column = Column.create!( + account_id: account.id, + board_id: mapping[:boards][old_column.board_id], + name: old_column.name.truncate(255, omission: ""), + color: old_column.color, + position: old_column.position, + created_at: old_column.created_at, + updated_at: old_column.updated_at + ) + + mapping[:columns][old_column.id] = new_column.id + end + + { count: mapping[:columns].size } + end + end + + def copy_cards + step("Copying cards", "Copied %{count} cards in %{duration}") do + mapping[:cards] ||= {} + account.update_columns(cards_count: import.cards.maximum(:id) || 0) + + activity_spikes_to_insert = [] + engagements_to_insert = [] + goldnesses_to_insert = [] + not_nows_to_insert = [] + assignments_to_insert = [] + closures_to_insert = [] + + import.cards.in_batches(of: 1000) do |batch| + cards_to_insert = [] + + batch.each do |old_card| + new_id = generate_uuid + mapping[:cards][old_card.id] = new_id + + # Map old 'creating' status to 'drafted' since it's no longer a valid enum value + status = old_card.status == "creating" ? "drafted" : old_card.status + + cards_to_insert << { + id: new_id, + number: old_card.id, + account_id: account.id, + board_id: mapping[:boards][old_card.board_id], + column_id: old_card.column_id ? mapping[:columns][old_card.column_id] : nil, + creator_id: mapping[:users][old_card.creator_id], + title: old_card.title, + status: status, + due_on: old_card.due_on, + last_active_at: old_card.last_active_at, + created_at: old_card.created_at, + updated_at: old_card.updated_at + } + + old_activity_spike = old_card.activity_spike + if old_activity_spike + activity_spikes_to_insert << { + id: generate_uuid, + account_id: account.id, + card_id: new_id, + created_at: old_activity_spike.created_at, + updated_at: old_activity_spike.updated_at + } + end + + old_engagement = old_card.engagement + if old_engagement + engagements_to_insert << { + id: generate_uuid, + account_id: account.id, + card_id: new_id, + status: old_engagement.status, + created_at: old_engagement.created_at, + updated_at: old_engagement.updated_at + } + end + + old_goldness = old_card.goldness + if old_goldness + goldnesses_to_insert << { + id: generate_uuid, + account_id: account.id, + card_id: new_id, + created_at: old_goldness.created_at, + updated_at: old_goldness.updated_at + } + end + + old_not_now = old_card.not_now + if old_not_now + not_nows_to_insert << { + id: generate_uuid, + account_id: account.id, + card_id: new_id, + user_id: old_not_now.user_id ? mapping[:users][old_not_now.user_id] : nil, + created_at: old_not_now.created_at, + updated_at: old_not_now.updated_at + } + end + + old_card.assignments.each do |old_assignment| + assignments_to_insert << { + id: generate_uuid, + account_id: account.id, + card_id: new_id, + assignee_id: mapping[:users][old_assignment.assignee_id], + assigner_id: mapping[:users][old_assignment.assigner_id], + created_at: old_assignment.created_at, + updated_at: old_assignment.updated_at + } + end + + old_closure = old_card.closure + if old_closure + closures_to_insert << { + id: generate_uuid, + account_id: account.id, + card_id: new_id, + user_id: old_closure.user_id ? mapping[:users][old_closure.user_id] : nil, + created_at: old_closure.created_at, + updated_at: old_closure.updated_at + } + end + end + + Card.insert_all(cards_to_insert) + end + + Card::ActivitySpike.insert_all(activity_spikes_to_insert) if activity_spikes_to_insert.any? + Card::Engagement.insert_all(engagements_to_insert) if engagements_to_insert.any? + Card::Goldness.insert_all(goldnesses_to_insert) if goldnesses_to_insert.any? + Card::NotNow.insert_all(not_nows_to_insert) if not_nows_to_insert.any? + Assignment.insert_all(assignments_to_insert) if assignments_to_insert.any? + Closure.insert_all(closures_to_insert) if closures_to_insert.any? + + import.cards.find_each do |old_card| + new_card_id = mapping[:cards][old_card.id] + new_card = Card.find(new_card_id) + copy_rich_text(old_card, new_card, "Card", "description") + copy_attachment(old_card, new_card, "Card", "image") + end + + { count: mapping[:cards].size } + end + end + + def copy_steps + step("Copying steps", "Copied steps in %{duration}") do + import.steps.in_batches(of: 1000) do |batch| + steps_to_insert = [] + + batch.each do |old_step| + steps_to_insert << { + id: generate_uuid, + account_id: account.id, + card_id: mapping[:cards][old_step.card_id], + content: old_step.content, + completed: old_step.completed, + created_at: old_step.created_at, + updated_at: old_step.updated_at + } + end + + Step.insert_all(steps_to_insert) + end + end + end + + def copy_comments + step("Copying comments", "Copied %{count} comments in %{duration}") do + mapping[:comments] ||= {} + + import.comments.in_batches(of: 1000) do |batch| + comments_to_insert = [] + + batch.each do |old_comment| + new_id = generate_uuid + mapping[:comments][old_comment.id] = new_id + + comments_to_insert << { + id: new_id, + account_id: account.id, + card_id: mapping[:cards][old_comment.card_id], + creator_id: mapping[:users][old_comment.creator_id], + created_at: old_comment.created_at, + updated_at: old_comment.updated_at + } + end + + Comment.insert_all(comments_to_insert) + end + + import.comments.find_each do |old_comment| + new_comment_id = mapping[:comments][old_comment.id] + new_comment = Comment.find(new_comment_id) + copy_rich_text(old_comment, new_comment, "Comment", "body") + end + + { count: mapping[:comments].size } + end + end + + def copy_mentions + step("Copying mentions", "Copied %{count} mentions in %{duration}") do + mapping[:mentions] ||= {} + + import.mentions.find_each do |old_mention| + new_mention = Mention.create!( + source_type: old_mention.source_type, + source_id: mapping[old_mention.source_type.tableize.to_sym][old_mention.source_id], + mentioner_id: mapping[:users][old_mention.mentioner_id], + mentionee_id: mapping[:users][old_mention.mentionee_id], + created_at: old_mention.created_at, + updated_at: old_mention.updated_at + ) + + mapping[:mentions][old_mention.id] = new_mention.id + end + + { count: mapping[:mentions].size } + end + end + + def copy_accesses + step("Copying accesses", "Copied %{count} accesses in %{duration}") do + mapping[:accesses] ||= {} + + import.accesses.in_batches(of: 1000) do |batch| + accesses_to_insert = [] + + batch.each do |old_access| + new_id = generate_uuid + mapping[:accesses][old_access.id] = new_id + + accesses_to_insert << { + id: new_id, + account_id: account.id, + board_id: mapping[:boards][old_access.board_id], + user_id: mapping[:users][old_access.user_id], + involvement: old_access.involvement, + accessed_at: old_access.accessed_at, + created_at: old_access.created_at, + updated_at: old_access.updated_at + } + end + + Access.insert_all(accesses_to_insert) + end + + { count: mapping[:accesses].size } + end + end + + def copy_notifications + step("Copying notifications", "Copied %{count} notifications in %{duration}") do + mapping[:notifications] ||= {} + + import.notifications.in_batches(of: 1000) do |batch| + notifications_to_insert = [] + + batch.each do |old_notification| + new_id = generate_uuid + mapping[:notifications][old_notification.id] = new_id + + notifications_to_insert << { + id: new_id, + account_id: account.id, + user_id: mapping[:users][old_notification.user_id], + creator_id: old_notification.creator_id ? mapping[:users][old_notification.creator_id] : nil, + source_type: old_notification.source_type, + source_id: mapping.fetch(old_notification.source_type.tableize.to_sym)[old_notification.source_id], + read_at: old_notification.read_at, + created_at: old_notification.created_at, + updated_at: old_notification.updated_at + } + end + + Notification.insert_all(notifications_to_insert) + end + + { count: mapping[:notifications].size } + end + end + + def copy_notification_bundles + step("Copying notification bundles", "Copied %{count} notification bundles in %{duration}") do + mapping[:notification_bundles] ||= {} + + import.notification_bundles.in_batches(of: 1000) do |batch| + bundles_to_insert = [] + + batch.each do |old_bundle| + new_id = generate_uuid + mapping[:notification_bundles][old_bundle.id] = new_id + + bundles_to_insert << { + id: new_id, + account_id: account.id, + user_id: mapping[:users][old_bundle.user_id], + status: old_bundle.status, + starts_at: old_bundle.starts_at, + ends_at: old_bundle.ends_at, + created_at: old_bundle.created_at, + updated_at: old_bundle.updated_at + } + end + + Notification::Bundle.insert_all(bundles_to_insert) + end + + { count: mapping[:notification_bundles].size } + end + end + + def copy_entropies + step("Copying entropies", "Copied entropies in %{duration}") do + import.entropies.find_each do |old_entropy| + container_id = case old_entropy.container_type + when "Account" then account.id + when "Board" then mapping[:boards][old_entropy.container_id] + when "Card" then mapping[:cards][old_entropy.container_id] + else next + end + + Entropy.find_or_create_by!(account_id: account.id, container_type: old_entropy.container_type, container_id: container_id) do |entropy| + entropy.auto_postpone_period = old_entropy.auto_postpone_period || 0 + entropy.created_at = old_entropy.created_at + entropy.updated_at = old_entropy.updated_at + end + end + end + end + + def copy_filters + step("Copying filters", "Copied %{count} filters in %{duration}") do + mapping[:filters] ||= {} + + # First, insert all filters + import.filters.in_batches(of: 1000) do |batch| + filters_to_insert = [] + + batch.each do |old_filter| + new_id = generate_uuid + mapping[:filters][old_filter.id] = new_id + + filters_to_insert << { + id: new_id, + account_id: account.id, + creator_id: mapping[:users][old_filter.creator_id], + params_digest: old_filter.params_digest, + fields: old_filter.fields, + created_at: old_filter.created_at, + updated_at: old_filter.updated_at + } + end + + Filter.insert_all(filters_to_insert) + end + + # Then, copy HABTM associations for each filter + import.filters.find_each do |old_filter| + new_filter = Filter.find(mapping[:filters][old_filter.id]) + + # Copy HABTM associations by finding valid mapped IDs first + assignee_ids = import.assignees_filters.where(filter_id: old_filter.id) + .filter_map { |join| mapping[:users][join.assignee_id] } + new_filter.assignee_ids = assignee_ids if assignee_ids.any? + + creator_ids = import.creators_filters.where(filter_id: old_filter.id) + .filter_map { |join| mapping[:users][join.creator_id] } + new_filter.creator_ids = creator_ids if creator_ids.any? + + closer_ids = import.closers_filters.where(filter_id: old_filter.id) + .filter_map { |join| mapping[:users][join.closer_id] } + new_filter.closer_ids = closer_ids if closer_ids.any? + + board_ids = import.boards_filters.where(filter_id: old_filter.id) + .filter_map { |join| mapping[:boards][join.board_id] } + new_filter.board_ids = board_ids if board_ids.any? + + tag_ids = import.filters_tags.where(filter_id: old_filter.id) + .filter_map { |join| mapping[:tags][join.tag_id] } + new_filter.tag_ids = tag_ids if tag_ids.any? + end + + { count: mapping[:filters].size } + end + end + + def copy_events + step("Copying events", "Copied %{count} events in %{duration}") do + mapping[:events] ||= {} + + import.events.in_batches(of: 1000) do |batch| + events_to_insert = [] + + batch.each do |old_event| + new_id = generate_uuid + mapping[:events][old_event.id] = new_id + + events_to_insert << { + id: new_id, + account_id: account.id, + board_id: mapping[:boards][old_event.board_id], + creator_id: mapping[:users][old_event.creator_id], + eventable_type: old_event.eventable_type, + eventable_id: mapping[old_event.eventable_type.tableize.to_sym][old_event.eventable_id], + action: old_event.action, + particulars: old_event.particulars, + created_at: old_event.created_at, + updated_at: old_event.updated_at + } + end + + Event.insert_all(events_to_insert) + end + + { count: mapping[:events].size } + end + end + + def copy_rich_text(old_record, new_record, record_type, name) + old_rich_text = import.rich_texts.find_by(record_type: record_type, record_id: old_record.id, name: name) + return unless old_rich_text + + new_rich_text = ActionText::RichText.create!( + record: new_record, + name: name, + body: old_rich_text.body, + created_at: old_rich_text.created_at, + updated_at: old_rich_text.updated_at + ) + + mapping[:rich_text] ||= {} + mapping[:rich_text][old_rich_text.id] = new_rich_text.id + + import.attachments.where(record_type: "ActionText::RichText", record_id: old_rich_text.id).each do |old_attachment| + copy_attachment(old_rich_text, new_rich_text, "ActionText::RichText", old_attachment.name) + end + end + + def copy_attachment(old_record, new_record, record_type, name) + old_attachment = import.attachments.find_by(record_type: record_type, record_id: old_record.id, name: name) + return unless old_attachment + + old_blob = import.blobs.find(old_attachment.blob_id) + + new_blob = ActiveStorage::Blob.find_or_create_by!(key: old_blob.key) do |blob| + blob.filename = old_blob.filename + blob.content_type = old_blob.content_type + blob.metadata = old_blob.metadata + blob.service_name = old_blob.service_name + blob.byte_size = old_blob.byte_size + blob.checksum = old_blob.checksum + blob.created_at = old_blob.created_at + end + + mapping[:blobs] ||= {} + mapping[:blobs][old_blob.id] = new_blob.id + + # Copy variant records to prevent ActiveStorage from regenerating them + copy_variant_records(old_blob, new_blob) + + new_attachment = ActiveStorage::Attachment.find_or_create_by!( + name: name, + record: new_record, + blob: new_blob, + created_at: old_attachment.created_at + ) + + mapping[:attachments] ||= {} + mapping[:attachments][old_attachment.id] = new_attachment.id + end + + def copy_variant_records(old_blob, new_blob) + import.variant_records.where(blob_id: old_blob.id).each do |old_variant_record| + old_variant_blob = import.blobs.find_by(id: old_variant_record.id) + next unless old_variant_blob + + new_variant_blob = ActiveStorage::Blob.find_or_create_by!(key: old_variant_blob.key) do |blob| + blob.filename = old_variant_blob.filename + blob.content_type = old_variant_blob.content_type + blob.metadata = old_variant_blob.metadata + blob.service_name = old_variant_blob.service_name + blob.byte_size = old_variant_blob.byte_size + blob.checksum = old_variant_blob.checksum + blob.created_at = old_variant_blob.created_at + end + + mapping[:blobs] ||= {} + mapping[:blobs][old_variant_blob.id] = new_variant_blob.id + + ActiveStorage::VariantRecord.find_or_create_by!( + id: new_variant_blob.id, + account_id: account.id, + blob_id: new_blob.id, + variation_digest: old_variant_record.variation_digest + ) + end + end + + def copy_reactions + step("Copying reactions", "Copied %{count} reactions in %{duration}") do + mapping[:reactions] ||= {} + import.reactions.find_each do |old_reaction| + # Truncate content to 16 characters to match current column limit + content = old_reaction.content.truncate(16, omission: "") + + new_reaction = Reaction.create!( + comment_id: mapping[:comments][old_reaction.comment_id], + reacter_id: mapping[:users][old_reaction.reacter_id], + content: content, + created_at: old_reaction.created_at, + updated_at: old_reaction.updated_at + ) + + mapping[:reactions][old_reaction.id] = new_reaction.id + end + + { count: mapping[:reactions].size } + end + end + + def copy_tags + step("Copying tags", "Copied %{tags} tags and %{taggings} taggings in %{duration}") do + mapping[:tags] ||= {} + mapping[:taggings] ||= {} + + import.tags.find_each do |old_tag| + new_tag = Tag.find_or_create_by!(title: old_tag.title) do |t| + t.account_id = account.id + t.created_at = old_tag.created_at + t.updated_at = old_tag.updated_at + end + + mapping[:tags][old_tag.id] = new_tag.id + end + + import.taggings.find_each do |old_tagging| + new_tagging = Tagging.create!( + tag_id: mapping[:tags][old_tagging.tag_id], + card_id: mapping[:cards][old_tagging.card_id], + created_at: old_tagging.created_at, + updated_at: old_tagging.updated_at + ) + + mapping[:taggings][old_tagging.id] = new_tagging.id + end + + { tags: mapping[:tags].size, taggings: mapping[:taggings].size } + end + end + + def copy_watches + step("Copying watches", "Copied %{count} watches in %{duration}") do + mapping[:watches] ||= {} + + import.watches.in_batches(of: 1000) do |batch| + watches_to_insert = [] + + batch.each do |old_watch| + new_id = generate_uuid + mapping[:watches][old_watch.id] = new_id + + watches_to_insert << { + id: new_id, + account_id: account.id, + user_id: mapping[:users][old_watch.user_id], + card_id: mapping[:cards][old_watch.card_id], + watching: old_watch.watching, + created_at: old_watch.created_at, + updated_at: old_watch.updated_at + } + end + + Watch.insert_all(watches_to_insert) + end + + { count: mapping[:watches].size } + end + end + + def copy_pins + step("Copying pins", "Copied %{count} pins in %{duration}") do + mapping[:pins] ||= {} + + import.pins.in_batches(of: 1000) do |batch| + pins_to_insert = [] + + batch.each do |old_pin| + new_id = generate_uuid + mapping[:pins][old_pin.id] = new_id + + pins_to_insert << { + id: new_id, + account_id: account.id, + user_id: mapping[:users][old_pin.user_id], + card_id: mapping[:cards][old_pin.card_id], + created_at: old_pin.created_at, + updated_at: old_pin.updated_at + } + end + + Pin.insert_all(pins_to_insert) + end + + { count: mapping[:pins].size } + end + end + + def copy_webhooks + step("Copying webhooks", "Copied %{webhooks} webhooks and %{deliveries} deliveries in %{duration}") do + mapping[:webhooks] ||= {} + mapping[:webhook_deliveries] ||= {} + + import.webhooks.find_each do |old_webhook| + subscribed_actions = old_webhook.subscribed_actions + subscribed_actions = JSON.parse(subscribed_actions) if subscribed_actions.is_a?(String) + + new_webhook = Webhook.create!( + account_id: account.id, + board_id: mapping[:boards][old_webhook.board_id], + name: old_webhook.name.truncate(255, omission: ""), + url: old_webhook.url, + signing_secret: old_webhook.signing_secret, + subscribed_actions: subscribed_actions, + active: old_webhook.active, + created_at: old_webhook.created_at, + updated_at: old_webhook.updated_at + ) + + mapping[:webhooks][old_webhook.id] = new_webhook.id + + old_tracker = import.webhook_delinquency_trackers.find_by(webhook_id: old_webhook.id) + if old_tracker + Webhook::DelinquencyTracker.find_or_create_by!(webhook_id: new_webhook.id) do |tracker| + tracker.consecutive_failures_count = old_tracker.consecutive_failures_count + tracker.first_failure_at = old_tracker.first_failure_at + tracker.created_at = old_tracker.created_at + tracker.updated_at = old_tracker.updated_at + end + end + end + + import.webhook_deliveries.find_each do |old_delivery| + new_delivery = Webhook::Delivery.create!( + webhook_id: mapping[:webhooks][old_delivery.webhook_id], + event_id: mapping[:events][old_delivery.event_id], + state: old_delivery.state, + request: old_delivery.request, + response: old_delivery.response, + created_at: old_delivery.created_at, + updated_at: old_delivery.updated_at + ) + + mapping[:webhook_deliveries][old_delivery.id] = new_delivery.id + end + + { webhooks: mapping[:webhooks].size, deliveries: mapping[:webhook_deliveries].size } + end + end + + def copy_push_subscriptions + step("Copying push subscriptions", "Copied %{count} push subscriptions in %{duration}") do + mapping[:push_subscriptions] ||= {} + + import.push_subscriptions.find_each do |old_subscription| + new_subscription = Push::Subscription.create!( + account_id: account.id, + user_id: mapping[:users][old_subscription.user_id], + endpoint: old_subscription.endpoint, + p256dh_key: old_subscription.p256dh_key, + auth_key: old_subscription.auth_key, + user_agent: old_subscription.user_agent, + created_at: old_subscription.created_at, + updated_at: old_subscription.updated_at + ) + + mapping[:push_subscriptions][old_subscription.id] = new_subscription.id + end + + { count: mapping[:push_subscriptions].size } + end + end + + def fix_links + step("Fixing links", "Fixed %{count} links in %{duration}") do + mapping[:fixed_links] ||= {} + + ActionText::RichText.where(id: mapping[:rich_text]&.values).find_each do |rich_text| + fragment = rich_text.body.fragment + fixed_link = false + + fragment.find_all("a[href]").each do |link| + url = link["href"] + uri = URI.parse(url) rescue nil + + if uri + uri.host = FIX_LINK_HOSTS[uri.host] if uri.absolute? && FIX_LINK_HOSTS.key?(uri.host) + params = Rails.application.routes.recognize_path(uri.path) rescue {} + + if params[:controller] == "cards" && params[:action] == "show" && params[:id] && mapping[:cards][params[:id].to_i] + uri.path = Rails.application.routes.url_helpers.card_path(mapping[:cards][params[:id].to_i]) + elsif params[:controller] == "boards" && params[:action] == "show" && params[:id] && mapping[:boards][params[:id].to_i] + uri.path = Rails.application.routes.url_helpers.board_path(mapping[:boards][params[:id].to_i]) + end + + link["href"] = uri.to_s + mapping[:fixed_links][url] = link["href"] + fixed_link = true + end + end + + rich_text.update!(body: fragment.to_html) if fixed_link + end + + { count: mapping[:fixed_links].size } + end + end + + def import + @import ||= Models.new(db_path) + rescue => e + $stderr.puts e.backtrace.join("\n") if ENV["DEBUG"] + raise "Couldn't open the given database: #{e}" + end + + def untenanted + @untenanted ||= Models.new(untenanted_db_path) + rescue => e + $stderr.puts e.backtrace.join("\n") if ENV["DEBUG"] + raise "Couldn't open the given untenanted database: #{e}" + end +end + +class Models + attr_reader :application_record + + def initialize(db_path) + const_name = "ImportBase#{db_path.hash.abs}" + + if self.class.const_defined?(const_name) + @application_record = self.class.const_get(const_name) + else + @application_record = Class.new(ActiveRecord::Base) do + self.abstract_class = true + + def self.models + const_get("MODELS") + end + + delegate :models, to: :class + end + self.class.const_set(const_name, @application_record) + end + + @application_record.establish_connection adapter: "sqlite3", database: db_path + @application_record.const_set("MODELS", self) + end + + def identities + @identities ||= Class.new(application_record) do + self.table_name = "identities" + end + end + + def memberships + @memberships ||= begin + models = self + Class.new(application_record) do + self.table_name = "memberships" + + def identity + @identity ||= models.identities.find_by(id: identity_id) + end + end + end + end + + def accounts + @accounts ||= Class.new(application_record) do + self.table_name = "accounts" + end + end + + def account_join_codes + @account_join_codes ||= Class.new(application_record) do + self.table_name = "account_join_codes" + end + end + + def users + @users ||= begin + models = self + Class.new(application_record) do + self.table_name = "users" + + def settings + @settings ||= models.user_settings.find_by(user_id: id) + end + end + end + end + + def boards + @boards ||= begin + models = self + Class.new(application_record) do + self.table_name = "boards" + + def publication + @publication ||= models.board_publications.find_by(board_id: id) + end + end + end + end + + def columns + @columns ||= Class.new(application_record) do + self.table_name = "columns" + end + end + + def cards + @cards ||= begin + models = self + Class.new(application_record) do + self.table_name = "cards" + + def activity_spike + @activity_spike ||= models.card_activity_spikes.find_by(card_id: id) + end + + def engagement + @engagement ||= models.card_engagements.find_by(card_id: id) + end + + def goldness + @goldness ||= models.card_goldnesses.find_by(card_id: id) + end + + def not_now + @not_now ||= models.card_not_nows.find_by(card_id: id) + end + + def assignments + models.assignments.where(card_id: id) + end + + def closure + @closure ||= models.closures.find_by(card_id: id) + end + end + end + end + + def comments + @comments ||= Class.new(application_record) do + self.table_name = "comments" + end + end + + def steps + @steps ||= Class.new(application_record) do + self.table_name = "steps" + end + end + + def reactions + @reactions ||= Class.new(application_record) do + self.table_name = "reactions" + end + end + + def tags + @tags ||= Class.new(application_record) do + self.table_name = "tags" + end + end + + def taggings + @taggings ||= Class.new(application_record) do + self.table_name = "taggings" + end + end + + def watches + @watches ||= Class.new(application_record) do + self.table_name = "watches" + end + end + + def pins + @pins ||= Class.new(application_record) do + self.table_name = "pins" + end + end + + def webhooks + @webhooks ||= Class.new(application_record) do + self.table_name = "webhooks" + end + end + + def webhook_deliveries + @webhook_deliveries ||= Class.new(application_record) do + self.table_name = "webhook_deliveries" + end + end + + def webhook_delinquency_trackers + @webhook_delinquency_trackers ||= Class.new(application_record) do + self.table_name = "webhook_delinquency_trackers" + end + end + + def push_subscriptions + @push_subscriptions ||= Class.new(application_record) do + self.table_name = "push_subscriptions" + end + end + + def assignments + @assignments ||= Class.new(application_record) do + self.table_name = "assignments" + end + end + + def closures + @closures ||= Class.new(application_record) do + self.table_name = "closures" + end + end + + def accesses + @accesses ||= Class.new(application_record) do + self.table_name = "accesses" + end + end + + def events + @events ||= Class.new(application_record) do + self.table_name = "events" + end + end + + def rich_texts + @rich_texts ||= Class.new(application_record) do + self.table_name = "action_text_rich_texts" + end + end + + def attachments + @attachments ||= Class.new(application_record) do + self.table_name = "active_storage_attachments" + end + end + + def blobs + @blobs ||= Class.new(application_record) do + self.table_name = "active_storage_blobs" + end + end + + def variant_records + @variant_records ||= Class.new(application_record) do + self.table_name = "active_storage_variant_records" + end + end + + def user_settings + @user_settings ||= Class.new(application_record) do + self.table_name = "user_settings" + end + end + + def board_publications + @board_publications ||= Class.new(application_record) do + self.table_name = "board_publications" + end + end + + def card_activity_spikes + @card_activity_spikes ||= Class.new(application_record) do + self.table_name = "card_activity_spikes" + end + end + + def card_engagements + @card_engagements ||= Class.new(application_record) do + self.table_name = "card_engagements" + end + end + + def card_goldnesses + @card_goldnesses ||= Class.new(application_record) do + self.table_name = "card_goldnesses" + end + end + + def card_not_nows + @card_not_nows ||= Class.new(application_record) do + self.table_name = "card_not_nows" + end + end + + def mentions + @mentions ||= Class.new(application_record) do + self.table_name = "mentions" + end + end + + def notifications + @notifications ||= Class.new(application_record) do + self.table_name = "notifications" + end + end + + def notification_bundles + @notification_bundles ||= Class.new(application_record) do + self.table_name = "notification_bundles" + end + end + + def entropies + @entropies ||= Class.new(application_record) do + self.table_name = "entropies" + end + end + + def filters + @filters ||= Class.new(application_record) do + self.table_name = "filters" + end + end + + def assignees_filters + @assignees_filters ||= Class.new(application_record) do + self.table_name = "assignees_filters" + end + end + + def assigners_filters + @assigners_filters ||= Class.new(application_record) do + self.table_name = "assigners_filters" + end + end + + def boards_filters + @boards_filters ||= Class.new(application_record) do + self.table_name = "boards_filters" + end + end + + def closers_filters + @closers_filters ||= Class.new(application_record) do + self.table_name = "closers_filters" + end + end + + def creators_filters + @creators_filters ||= Class.new(application_record) do + self.table_name = "creators_filters" + end + end + + def filters_tags + @filters_tags ||= Class.new(application_record) do + self.table_name = "filters_tags" + end + end +end + +options = { + skip_already_imported: false +} + +parser = OptionParser.new do |parser| + parser.banner = "Usage: #{$PROGRAM_NAME} [options] ..." + + parser.on("--untenanted-db-path PATH", "Path to the untenanted database") do |path| + options[:untenanted_db_path] = path + end + + parser.on("--skip-already-imported", "Skip import if account already exists") do + options[:skip_already_imported] = true + end + + parser.on("-h", "--help", "Show this help message") do + puts parser + exit + end +end + +parser.parse! + +untenanted_db_path = options[:untenanted_db_path] +tenanted_db_paths = ARGV + +if untenanted_db_path.nil? + $stderr.puts "Error: --untenanted-db-path is required" + $stderr.puts + $stderr.puts parser + exit 1 +end + +if tenanted_db_paths.empty? + $stderr.puts "Error: at least one tenanted database path is required" + $stderr.puts + $stderr.puts parser + exit 1 +end + +total_imported = 0 + +duration = ActiveSupport::Benchmark.realtime do + tenanted_db_paths.each_with_index do |db_path, index| + puts + puts "="*80 + puts "Processing database #{index + 1}/#{tenanted_db_paths.size}: #{db_path}" + puts "="*80 + + Import.new(db_path, untenanted_db_path, skip_already_imported: options[:skip_already_imported]).import_database + total_imported += 1 + end +end + +puts +puts "="*80 +puts "Summary:" +puts " Imported: #{total_imported}" +puts " Total time: #{duration.round(2)} seconds" +puts "="*80 diff --git a/script/load-prod-db-in-dev.rb b/script/load-prod-db-in-dev.rb index d9ca56dbe..0bf17c6e7 100755 --- a/script/load-prod-db-in-dev.rb +++ b/script/load-prod-db-in-dev.rb @@ -35,7 +35,7 @@ require_relative "../config/environment" tenant = ActiveRecord::FixtureSet.identify(identifier) ApplicationRecord.with_tenant(tenant) do |tenant| - Account.sole.destroy! + Current.account.destroy! Account.create_with_admin_user \ account: { name: "Company #{identifier}" }, @@ -48,6 +48,6 @@ ApplicationRecord.with_tenant(tenant) do |tenant| board.accesses.grant_to(user) end - url = Rails.application.routes.url_helpers.root_url(Rails.application.config.action_controller.default_url_options.merge(script_name: Account.sole.slug)) + url = Rails.application.routes.url_helpers.root_url(Rails.application.config.action_controller.default_url_options.merge(script_name: Current.account.slug)) puts "\n\nLogin to #{url} as #{user.email_address} / secret123456" end diff --git a/script/migrations/20250924-populate-identities.rb b/script/migrations/20250924-populate-identities.rb index 8fab8d3f6..c961963f2 100755 --- a/script/migrations/20250924-populate-identities.rb +++ b/script/migrations/20250924-populate-identities.rb @@ -15,13 +15,13 @@ ApplicationRecord.with_each_tenant do |tenant| # Create a new Identity Identity.transaction do identity = Identity.create! - user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Account.sole.name) + user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Current.account.name) puts "Created identity #{identity.id} for user #{user.id} (#{user.email_address})" end else # Merge this User's Membership into the existing Identity identity = memberships.first.identity - user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Account.sole.name) + user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Current.account.name) puts "Merged membership for user #{user.id} (#{user.email_address}) into identity #{identity.id}" end end diff --git a/script/migrations/migrate-content-to-slugged-urls.rb b/script/migrations/migrate-content-to-slugged-urls.rb index 1309499d3..9253d5363 100755 --- a/script/migrations/migrate-content-to-slugged-urls.rb +++ b/script/migrations/migrate-content-to-slugged-urls.rb @@ -25,7 +25,7 @@ def fix_attachments(rich_text) end ApplicationRecord.with_each_tenant do |tenant| - account_id = Account.sole.queenbee_id + account_id = Current.account.queenbee_id unless account_id puts "Skipping URL fixup for tenant: #{tenant}" @@ -37,7 +37,7 @@ ApplicationRecord.with_each_tenant do |tenant| domain = domains[Rails.env] || domains["production"] regex = %r{://\w+\.#{domain}/} - pp [ Account.sole.name, account_id, domain, regex ] + pp [ Current.account.name, account_id, domain, regex ] puts Card.find_each do |card| diff --git a/script/populate.rb b/script/populate.rb index d19f75f84..ed219d1b5 100644 --- a/script/populate.rb +++ b/script/populate.rb @@ -1,22 +1,38 @@ require_relative "../config/environment" require "faker" +ACCOUNT = Account.find_by(name: "cleanslate") CARDS_COUNT = ARGV.first&.to_i || 10_000 -BOARDS_COUNT = ARGV.second&.to_i || 1 +BOARDS_COUNT = ARGV.second&.to_i || 100 +TAGS_COUNT = ARGV.third&.to_i || 500 +USERS_COUNT = ARGV.fourth&.to_i || 1000 -ApplicationRecord.current_tenant = ApplicationRecord.tenants.first -Current.session = Session.first +Current.account = ACCOUNT +Current.session = ACCOUNT.users.last.identity.sessions.first -puts "Creating #{CARDS_COUNT} cards across #{BOARDS_COUNT} board(s)" +puts "Creating #{CARDS_COUNT} cards with #{TAGS_COUNT} tags across #{BOARDS_COUNT} board(s)" -BOARDS_COUNT.times do - Board.create! name: Faker::Company.buzzword, all_access: true - print "." -end - -CARDS_COUNT.times do - card = Board.take.cards.create! \ - title: Faker::Company.bs, description: Faker::Hacker.say_something_smart, status: :published - - print "." +Board.suppressing_turbo_broadcasts do + Card.suppressing_turbo_broadcasts do + BOARDS_COUNT.times do + ACCOUNT.boards.create! name: Faker::Company.buzzword, all_access: true + print "." + end + + CARDS_COUNT.times do + card = ACCOUNT.boards.take.cards.create! \ + title: Faker::Company.bs, description: Faker::Hacker.say_something_smart, status: :published + + print "." + end + + TAGS_COUNT.times do + ACCOUNT.cards.take.toggle_tag_with Faker::Game.title + print "." + end + + USERS_COUNT.times do + ACCOUNT.users.create! name: Faker::FunnyName + end + end end diff --git a/script/remove-lb-admin-production.sh b/script/remove-lb-admin-production.sh new file mode 100755 index 000000000..33e37e053 --- /dev/null +++ b/script/remove-lb-admin-production.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -e + +ssh app@fizzy-lb-101.df-iad-int.37signals.com \ + docker exec fizzy-load-balancer kamal-proxy rm fizzy-admin + +ssh app@fizzy-lb-01.sc-chi-int.37signals.com \ + docker exec fizzy-load-balancer kamal-proxy rm fizzy-admin + +ssh app@fizzy-lb-401.df-ams-int.37signals.com \ + docker exec fizzy-load-balancer kamal-proxy rm fizzy-admin diff --git a/test/controllers/accounts/join_codes_controller_test.rb b/test/controllers/accounts/join_codes_controller_test.rb index ee80e4193..33c118386 100644 --- a/test/controllers/accounts/join_codes_controller_test.rb +++ b/test/controllers/accounts/join_codes_controller_test.rb @@ -9,7 +9,7 @@ class Account::JoinCodesControllerTest < ActionDispatch::IntegrationTest get account_join_code_path assert_response :success - assert_changes -> { Account::JoinCode.sole.code } do + assert_changes -> { Current.account.join_code.reload.code } do delete account_join_code_path assert_redirected_to account_join_code_path end @@ -20,7 +20,7 @@ class Account::JoinCodesControllerTest < ActionDispatch::IntegrationTest assert_response :success put account_join_code_path, params: { account_join_code: { usage_limit: 5 } } - assert_equal 5, Account::JoinCode.sole.usage_limit + assert_equal 5, Current.account.join_code.reload.usage_limit assert_redirected_to account_join_code_path end diff --git a/test/controllers/accounts/settings_controller_test.rb b/test/controllers/accounts/settings_controller_test.rb index 50078dd32..9f17a74a7 100644 --- a/test/controllers/accounts/settings_controller_test.rb +++ b/test/controllers/accounts/settings_controller_test.rb @@ -12,7 +12,7 @@ class Account::SettingsControllerTest < ActionDispatch::IntegrationTest test "update" do put account_settings_path, params: { account: { name: "New Account Name" } } - assert_equal "New Account Name", Account.sole.name + assert_equal "New Account Name", Current.account.reload.name assert_redirected_to account_settings_path end diff --git a/test/controllers/boards/entropies_controller_test.rb b/test/controllers/boards/entropies_controller_test.rb index 3d04f44dd..0c989c3cd 100644 --- a/test/controllers/boards/entropies_controller_test.rb +++ b/test/controllers/boards/entropies_controller_test.rb @@ -7,7 +7,7 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest end test "update" do - assert_no_difference -> { Account.sole.entropy.reload.auto_postpone_period } do + assert_no_difference -> { Current.account.entropy.reload.auto_postpone_period } do put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period: 123.days } } assert_equal 123.days, @board.entropy.reload.auto_postpone_period diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index 41cbb557e..3729f09c7 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -77,7 +77,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest end test "update all access" do - board = Current.set(session: sessions(:kevin)) do + board = Current.set(account: accounts("37s"), session: sessions(:kevin), user: users(:kevin)) do Board.create! name: "New board", all_access: false end assert_equal [ users(:kevin) ], board.users @@ -86,7 +86,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to edit_board_path(board) assert board.reload.all_access? - assert_equal User.active.sort, board.users.sort + assert_equal accounts("37s").users.active.sort, board.users.sort end test "destroy" do diff --git a/test/controllers/cards/publishes_controller_test.rb b/test/controllers/cards/publishes_controller_test.rb index 2a7c16363..4de6d20bb 100644 --- a/test/controllers/cards/publishes_controller_test.rb +++ b/test/controllers/cards/publishes_controller_test.rb @@ -26,7 +26,8 @@ class Cards::PublishesControllerTest < ActionDispatch::IntegrationTest end end - assert Card.last.drafted? - assert_redirected_to Card.last + new_card = Card.last + assert new_card.drafted? + assert_redirected_to new_card end end diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index bc1a82e4b..ca60a714a 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -20,8 +20,9 @@ class CardsControllerTest < ActionDispatch::IntegrationTest post board_cards_path(boards(:writebook)) end - assert Card.last.drafted? - assert_redirected_to Card.last + card = Card.last + assert card.drafted? + assert_redirected_to card end test "create resumes existing draft if it exists" do diff --git a/test/controllers/join_codes_controller_test.rb b/test/controllers/join_codes_controller_test.rb index 070662b5a..173e3b5d2 100644 --- a/test/controllers/join_codes_controller_test.rb +++ b/test/controllers/join_codes_controller_test.rb @@ -2,23 +2,19 @@ require "test_helper" class JoinCodesControllerTest < ActionDispatch::IntegrationTest setup do - @tenant = ApplicationRecord.current_tenant - @join_code = account_join_codes(:sole) + @account = accounts("37s") + @join_code = account_join_codes(:"37s") end test "new" do - untenanted do - get join_path(tenant: @tenant, code: @join_code.code) - end + get join_path(code: @join_code.code, script_name: @account.slug) assert_response :success assert_in_body "37signals" end test "new with an invalid code" do - untenanted do - get join_path(tenant: @tenant, code: "INVALID-CODE") - end + get join_path(code: "INVALID-CODE", script_name: @account.slug) assert_response :not_found end @@ -26,39 +22,32 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest test "new with an inactive code" do @join_code.update!(usage_count: @join_code.usage_limit) - untenanted do - get join_path(tenant: @tenant, code: @join_code.code) - end + get join_path(code: @join_code.code, script_name: @account.slug) assert_response :not_found end test "create" do - untenanted do - assert_difference -> { Identity.count }, 1 do - assert_difference -> { Membership.count }, 1 do - post join_path(tenant: @tenant, code: @join_code.code), params: { email_address: "new_user@example.com" } - end + assert_difference -> { Identity.count }, 1 do + assert_difference -> { User.count }, 1 do + post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "new_user@example.com" } end - - assert_equal @join_code.code, Membership.last.join_code - - assert_redirected_to session_magic_link_path - assert_equal landing_url(script_name: "/#{@tenant}"), session[:return_to_after_authenticating] end + + assert_redirected_to session_magic_link_url(script_name: nil) + assert_equal new_users_join_url(script_name: @account.slug), session[:return_to_after_authenticating] end test "create for existing identity" do identity = identities(:jz) + sign_in_as :jz - untenanted do - assert_no_difference -> { Identity.count } do - assert_no_difference -> { Membership.count } do - post join_path(tenant: @tenant, code: @join_code.code), params: { email_address: identity.email_address } - end + assert_no_difference -> { Identity.count } do + assert_no_difference -> { User.count } do + post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: identity.email_address } end - - assert_redirected_to session_magic_link_path end + + assert_redirected_to landing_url(script_name: @account.slug) end end diff --git a/test/controllers/memberships/email_addresses/confirmations_controller_test.rb b/test/controllers/memberships/email_addresses/confirmations_controller_test.rb deleted file mode 100644 index 86c5ff171..000000000 --- a/test/controllers/memberships/email_addresses/confirmations_controller_test.rb +++ /dev/null @@ -1,42 +0,0 @@ -require "test_helper" - -class Memberships::EmailAddresses::ConfirmationsControllerTest < ActionDispatch::IntegrationTest - test "show" do - untenanted do - membership = memberships(:kevin_in_37signals) - - get email_address_confirmation_path( - membership_id: membership.id, - email_address_token: "dummy_token" - ) - - assert_response :success - end - end - - test "create" do - untenanted do - membership = memberships(:kevin_in_37signals) - old_identity = membership.identity - new_email = "updated@example.com" - - # Generate a real token - token = membership.send(:generate_email_address_change_token, to: new_email) - - assert_difference -> { Identity.count }, 1 do - post email_address_confirmation_path( - membership_id: membership.id, - email_address_token: token - ), - params: { email_address_token: token } - end - - membership.reload - assert_equal new_email, membership.identity.email_address - assert_not_equal old_identity.id, membership.identity_id - - assert cookies[:session_token].present?, "Should have started new session" - assert_redirected_to edit_user_url(script_name: "/#{membership.tenant}", id: membership.user) - end - end -end diff --git a/test/controllers/memberships/email_addresses_controller_test.rb b/test/controllers/memberships/email_addresses_controller_test.rb deleted file mode 100644 index ef88bfaa1..000000000 --- a/test/controllers/memberships/email_addresses_controller_test.rb +++ /dev/null @@ -1,43 +0,0 @@ -require "test_helper" - -class Memberships::EmailAddressesControllerTest < ActionDispatch::IntegrationTest - test "new" do - untenanted do - sign_in_as :kevin - - membership = memberships(:kevin_in_37signals) - - get new_email_address_path(membership_id: membership.id) - assert_response :success - end - end - - test "create" do - untenanted do - sign_in_as :kevin - - membership = memberships(:kevin_in_37signals) - - assert_enqueued_emails 1 do - post email_addresses_path(membership_id: membership.id), - params: { email_address: "newemail@example.com" } - end - - assert_response :success - end - end - - test "create with an email for someone already in the account" do - untenanted do - sign_in_as :kevin - - membership = memberships(:kevin_in_37signals) - - post email_addresses_path(membership_id: membership.id), - params: { email_address: identities(:david).email_address } - - assert_redirected_to new_email_address_path - assert_equal "You already have a user in this account with that email address", flash[:alert] - end - end -end diff --git a/test/controllers/memberships/unlink_controller_test.rb b/test/controllers/memberships/unlink_controller_test.rb deleted file mode 100644 index 2020a2eb2..000000000 --- a/test/controllers/memberships/unlink_controller_test.rb +++ /dev/null @@ -1,30 +0,0 @@ -require "test_helper" - -class Memberships::UnlinkControllerTest < ActionDispatch::IntegrationTest - test "show" do - untenanted do - sign_in_as :kevin - - membership = memberships(:kevin_in_37signals) - signed_id = membership.signed_id(purpose: :unlinking) - - get unlink_membership_path(membership_id: signed_id) - assert_response :success - end - end - - test "create" do - untenanted do - sign_in_as :kevin - - membership = memberships(:kevin_in_37signals) - signed_id = membership.signed_id(purpose: :unlinking) - - assert_difference -> { Membership.count }, -1 do - post unlink_membership_path(membership_id: signed_id) - end - - assert_redirected_to session_menu_path - end - end -end diff --git a/test/controllers/prompts/boards/users_controller_test.rb b/test/controllers/prompts/boards/users_controller_test.rb index 2254bf432..992df99f0 100644 --- a/test/controllers/prompts/boards/users_controller_test.rb +++ b/test/controllers/prompts/boards/users_controller_test.rb @@ -9,5 +9,6 @@ class Prompts::Boards::UsersControllerTest < ActionDispatch::IntegrationTest test "index" do get prompts_board_users_path(@board) assert_response :success + assert_select "lexxy-prompt-item", count: 3 end end diff --git a/test/controllers/searches_controller_test.rb b/test/controllers/searches_controller_test.rb index 68fdccc11..0cc0bceee 100644 --- a/test/controllers/searches_controller_test.rb +++ b/test/controllers/searches_controller_test.rb @@ -1,28 +1,32 @@ require "test_helper" class SearchesControllerTest < ActionDispatch::IntegrationTest + include SearchTestHelper + setup do - Card.all.each(&:reindex) - Comment.all.each(&:reindex) + @board.update!(all_access: true) + @card = @board.cards.create!(title: "Layout is broken", creator: @user) + @comment_card = @board.cards.create!(title: "Some card", creator: @user) + @comment_card.comments.create!(body: "overflowing text issue", creator: @user) - sign_in_as :kevin + untenanted { sign_in_as @user } end - test "show" do - get search_path(q: "broken") - + test "search" do + # Searching by card title + get search_path(q: "broken", script_name: "/#{@account.external_account_id}") assert_select "li", text: /Layout is broken/ - end - test "show with card id" do - get search_path(q: cards(:logo).id) + # Searching by comment + get search_path(q: "overflowing", script_name: "/#{@account.external_account_id}") + assert_select "li", text: /Some card/ + # Searching by card id + get search_path(q: @card.id, script_name: "/#{@account.external_account_id}") assert_select "form[data-controller='auto-submit']" - end - - test "show with non-existent card id" do - get search_path(q: "999999") + # Searching with non-existent card id + get search_path(q: "999999", script_name: "/#{@account.external_account_id}") assert_select "form[data-controller='auto-submit']", count: 0 assert_select ".search__empty", text: "No matches" end diff --git a/test/controllers/sessions/menus_controller_test.rb b/test/controllers/sessions/menus_controller_test.rb index c6b64d316..4be2dd415 100644 --- a/test/controllers/sessions/menus_controller_test.rb +++ b/test/controllers/sessions/menus_controller_test.rb @@ -5,9 +5,9 @@ class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest @identity = identities(:kevin) end - test "show with no memberships" do + test "show with no account" do sign_in_as @identity - @identity.memberships.delete_all + @identity.users.delete_all untenanted do get session_menu_url @@ -16,24 +16,30 @@ class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest assert_response :success, "Renders an empty menu" end - test "show with exactly one membership" do + test "show with exactly one account" do sign_in_as @identity - @identity.memberships.delete_all - @identity.memberships.create(tenant: "37signals") + + Current.without_account do + @identity.users.delete_all + account = Account.create!(external_account_id: 9999991, name: "Test Account") + @identity.users.create!(account: account, name: "Kevin") + end untenanted do get session_menu_url end assert_response :redirect - assert_redirected_to root_url(script_name: "/37signals") + assert_redirected_to root_url(script_name: "/9999991") end - test "show with multiple memeberships" do + test "show with multiple accounts" do sign_in_as @identity - @identity.memberships.delete_all - @identity.memberships.create(tenant: "37signals") - @identity.memberships.create(tenant: "acme") + @identity.users.delete_all + account1 = Account.create!(external_account_id: 9999992, name: "37signals") + account2 = Account.create!(external_account_id: 9999993, name: "Acme") + @identity.users.create!(account: account1, name: "Kevin") + @identity.users.create!(account: account2, name: "Kevin") untenanted do get session_menu_url @@ -41,29 +47,4 @@ class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest assert_response :success end - - test "show renders as a menu section" do - sign_in_as @identity - @identity.memberships.delete_all - @identity.memberships.create(tenant: "37signals") - @identity.memberships.create(tenant: "acme") - - untenanted do - get session_menu_url menu_section: true - end - - assert_response :success - end - - test "show doesn't redirect when rendered as a menu section" do - sign_in_as @identity - @identity.memberships.delete_all - @identity.memberships.create(tenant: "37signals") - - untenanted do - get session_menu_url menu_section: true - end - - assert_response :success - end end diff --git a/test/controllers/users/email_addresses/confirmations_controller_test.rb b/test/controllers/users/email_addresses/confirmations_controller_test.rb new file mode 100644 index 000000000..b16b6ea7c --- /dev/null +++ b/test/controllers/users/email_addresses/confirmations_controller_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class Users::EmailAddresses::ConfirmationsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:david) + @new_email = "newemail@example.com" + @token = @user.send(:generate_email_address_change_token, to: @new_email) + end + + test "show" do + get user_email_address_confirmation_path(user_id: @user.id, email_address_token: @token, script_name: @user.account.slug) + assert_response :success + end + + test "create" do + old_email = @user.identity.email_address + + post user_email_address_confirmation_path(user_id: @user.id, email_address_token: @token, script_name: @user.account.slug) + + assert_equal @new_email, @user.reload.identity.email_address + assert_redirected_to edit_user_url(script_name: @user.account.slug, id: @user) + end + + test "create with invalid token" do + assert_raises(ArgumentError) do + post user_email_address_confirmation_path(user_id: @user.id, email_address_token: "invalid", script_name: @user.account.slug) + end + end +end diff --git a/test/controllers/users/email_addresses_controller_test.rb b/test/controllers/users/email_addresses_controller_test.rb new file mode 100644 index 000000000..d9b973d88 --- /dev/null +++ b/test/controllers/users/email_addresses_controller_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class Users::EmailAddressesControllerTest < ActionDispatch::IntegrationTest + include ActionMailer::TestHelper + + setup do + sign_in_as :david + @user = users(:david) + end + + test "new" do + get new_user_email_address_path(@user, script_name: @user.account.slug) + assert_response :success + end + + test "create" do + assert_emails 1 do + post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: "newemail@example.com" } + end + assert_response :success + end + + test "create with existing email in same account" do + existing_user = users(:kevin) + existing_email = existing_user.identity.email_address + + post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: existing_email } + assert_redirected_to new_user_email_address_path(@user) + assert_equal "You already have a user in this account with that email address", flash[:alert] + end + + test "create for other user" do + other_user = users(:kevin) + + assert_no_emails do + post user_email_addresses_path(other_user, script_name: @user.account.slug), params: { email_address: "newemail@example.com" } + end + assert_response :not_found + end +end diff --git a/test/controllers/users/joins_controller_test.rb b/test/controllers/users/joins_controller_test.rb index 4fe3ec218..552707236 100644 --- a/test/controllers/users/joins_controller_test.rb +++ b/test/controllers/users/joins_controller_test.rb @@ -2,31 +2,21 @@ require "test_helper" class Users::JoinsControllerTest < ActionDispatch::IntegrationTest test "new" do - identity = Identity.create!(email_address: "new.user@example.com") - identity.memberships.create(tenant: ApplicationRecord.current_tenant, join_code: Account::JoinCode.sole.code) - sign_in_as identity + sign_in_as :david get new_users_join_path assert_response :ok end - test "new with invalid params" do - identity = Identity.create!(email_address: "new.user@example.com") - membership = identity.memberships.create(tenant: ApplicationRecord.current_tenant, join_code: "PHONY") - sign_in_as identity - - get new_users_join_path - assert_redirected_to unlink_membership_url(script_name: nil, membership_id: membership.signed_id(purpose: :unlinking)) - end - test "create" do - identity = Identity.create!(email_address: "newart.userbaum@example.com") - identity.memberships.create(tenant: ApplicationRecord.current_tenant, join_code: Account::JoinCode.sole.code) - sign_in_as identity + user = users(:david) + sign_in_as user - assert_difference -> { User.count }, +1 do - post users_joins_path, params: { user: { name: "Newart Userbaum" } } + assert_no_difference -> { User.count } do + post users_joins_path, params: { user: { name: "David Updated" } } assert_redirected_to landing_path end + + assert_equal "David Updated", user.reload.name end end diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb index f88615f43..2cd427b5b 100644 --- a/test/controllers/webhooks_controller_test.rb +++ b/test/controllers/webhooks_controller_test.rb @@ -35,7 +35,7 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest } end - webhook = Webhook.order(id: :desc).first + webhook = Webhook.last assert_redirected_to board_webhook_path(webhook.board, webhook) assert_equal board, webhook.board diff --git a/test/fixtures/accesses.yml b/test/fixtures/accesses.yml index bdf523630..25800f47b 100644 --- a/test/fixtures/accesses.yml +++ b/test/fixtures/accesses.yml @@ -1,15 +1,23 @@ writebook_david: - board: writebook - user: david + id: <%= ActiveRecord::FixtureSet.identify("writebook_david", :uuid) %> + account: 37s_uuid + board: writebook_uuid + user: david_uuid writebook_jz: - board: writebook - user: jz + id: <%= ActiveRecord::FixtureSet.identify("writebook_jz", :uuid) %> + account: 37s_uuid + board: writebook_uuid + user: jz_uuid writebook_kevin: - board: writebook - user: kevin + id: <%= ActiveRecord::FixtureSet.identify("writebook_kevin", :uuid) %> + account: 37s_uuid + board: writebook_uuid + user: kevin_uuid private_kevin: - board: private - user: kevin + id: <%= ActiveRecord::FixtureSet.identify("private_kevin", :uuid) %> + account: 37s_uuid + board: private_uuid + user: kevin_uuid diff --git a/test/fixtures/account/join_codes.yml b/test/fixtures/account/join_codes.yml index daba73e66..491388a2f 100644 --- a/test/fixtures/account/join_codes.yml +++ b/test/fixtures/account/join_codes.yml @@ -1,4 +1,13 @@ -sole: - code: 1234-5678-9XYZ +37s: + id: <%= ActiveRecord::FixtureSet.identify("37s_join_code", :uuid) %> + code: 37S0-5678-9XYZ usage_count: 0 usage_limit: 10 + account: 37s_uuid + +initech: + id: <%= ActiveRecord::FixtureSet.identify("initech_join_code", :uuid) %> + code: INIT-5678-9XYZ + usage_count: 10 + usage_limit: 10 + account: initech_uuid diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index ced04edf6..c1c83ee51 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -1,2 +1,11 @@ 37s: + id: <%= ActiveRecord::FixtureSet.identify("37s", :uuid) %> name: 37signals + external_account_id: <%= ActiveRecord::FixtureSet.identify("37signals") %> + cards_count: 5 + +initech: + id: <%= ActiveRecord::FixtureSet.identify("initech", :uuid) %> + name: Initech LLC + external_account_id: <%= ActiveRecord::FixtureSet.identify("initech") %> + cards_count: 0 diff --git a/test/fixtures/action_text/rich_texts.yml b/test/fixtures/action_text/rich_texts.yml index 28fe7c3e0..82f5cd20b 100644 --- a/test/fixtures/action_text/rich_texts.yml +++ b/test/fixtures/action_text/rich_texts.yml @@ -1,29 +1,20 @@ logo_agreement_jz: - record: logo_agreement_jz (Comment) + id: <%= ActiveRecord::FixtureSet.identify("logo_agreement_jz_rich_text", :uuid) %> + account: 37s_uuid + record: logo_agreement_jz_uuid (Comment) name: body body: I agree. logo_agreement_kevin: - record: logo_agreement_kevin (Comment) + id: <%= ActiveRecord::FixtureSet.identify("logo_agreement_kevin_rich_text", :uuid) %> + account: 37s_uuid + record: logo_agreement_kevin_uuid (Comment) name: body - body: Same, let’s do it. + body: Same, let's do it. layout_overflowing_david: - record: layout_overflowing_david (Comment) + id: <%= ActiveRecord::FixtureSet.identify("layout_overflowing_david_rich_text", :uuid) %> + account: 37s_uuid + record: layout_overflowing_david_uuid (Comment) name: body body: The text is overflowing the container. - -kevins_conversation_question: - record: kevins_question (Conversation::Message) - name: content - body: "Who is on a hot-streak?" - -kevins_conversation_answer: - record: kevins_answer (Conversation::Message) - name: content - body: "David is on a hot-streak. He has recently added many cards to Writebook." - -davids_conversation_question: - record: davids_question (Conversation::Message) - name: content - body: "What is the meaning of life, the Universe, and everything else?" diff --git a/test/fixtures/assignees_filters.yml b/test/fixtures/assignees_filters.yml new file mode 100644 index 000000000..55dbd035b --- /dev/null +++ b/test/fixtures/assignees_filters.yml @@ -0,0 +1,3 @@ +jz_assignments_jz: + assignee_id: <%= ActiveRecord::FixtureSet.identify("jz", :uuid) %> + filter_id: <%= ActiveRecord::FixtureSet.identify("jz_assignments", :uuid) %> diff --git a/test/fixtures/assignments.yml b/test/fixtures/assignments.yml index 81e778f62..0777f70a2 100644 --- a/test/fixtures/assignments.yml +++ b/test/fixtures/assignments.yml @@ -1,16 +1,22 @@ logo_jz: - assigner: david - assignee: jz - card: logo + id: <%= ActiveRecord::FixtureSet.identify("logo_jz", :uuid) %> + account: 37s_uuid + assigner: david_uuid + assignee: jz_uuid + card: logo_uuid created_at: <%= 1.week.ago %> logo_kevin: - assigner: david - assignee: kevin - card: logo + id: <%= ActiveRecord::FixtureSet.identify("logo_kevin", :uuid) %> + account: 37s_uuid + assigner: david_uuid + assignee: kevin_uuid + card: logo_uuid created_at: <%= 1.day.ago %> layout_jz: - assigner: david - assignee: jz - card: layout + id: <%= ActiveRecord::FixtureSet.identify("layout_jz", :uuid) %> + account: 37s_uuid + assigner: david_uuid + assignee: jz_uuid + card: layout_uuid diff --git a/test/fixtures/boards.yml b/test/fixtures/boards.yml index d66e9b964..ef3db1eda 100644 --- a/test/fixtures/boards.yml +++ b/test/fixtures/boards.yml @@ -1,9 +1,20 @@ writebook: + id: <%= ActiveRecord::FixtureSet.identify("writebook", :uuid) %> name: Writebook - creator: david + creator: david_uuid all_access: true + account: 37s_uuid private: + id: <%= ActiveRecord::FixtureSet.identify("private", :uuid) %> name: Private board - creator: kevin + creator: kevin_uuid all_access: false + account: 37s_uuid + +miltons_wish_list: + id: <%= ActiveRecord::FixtureSet.identify("miltons_wish_list", :uuid) %> + name: Milton's Wish List + creator: mike_uuid + all_access: true + account: initech_uuid diff --git a/test/fixtures/card/engagements.yml b/test/fixtures/card/engagements.yml index 77657385b..8c96bd4b5 100644 --- a/test/fixtures/card/engagements.yml +++ b/test/fixtures/card/engagements.yml @@ -1,2 +1,4 @@ logo: - card: logo + id: <%= ActiveRecord::FixtureSet.identify("logo_engagement", :uuid) %> + account: 37s_uuid + card: logo_uuid diff --git a/test/fixtures/card/goldnesses.yml b/test/fixtures/card/goldnesses.yml index 77657385b..f47389d60 100644 --- a/test/fixtures/card/goldnesses.yml +++ b/test/fixtures/card/goldnesses.yml @@ -1,2 +1,4 @@ logo: - card: logo + id: <%= ActiveRecord::FixtureSet.identify("logo_goldness", :uuid) %> + account: 37s_uuid + card: logo_uuid diff --git a/test/fixtures/cards.yml b/test/fixtures/cards.yml index 94ef3e0ad..5bfa77e6d 100644 --- a/test/fixtures/cards.yml +++ b/test/fixtures/cards.yml @@ -1,45 +1,81 @@ logo: - board: writebook - creator: david - column: writebook_triage + id: <%= ActiveRecord::FixtureSet.identify("logo", :uuid) %> + number: 1 + board: writebook_uuid + creator: david_uuid + column: writebook_triage_uuid title: The logo isn't big enough due_on: <%= 3.days.from_now %> created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> + account: 37s_uuid layout: - board: writebook - creator: david - column: writebook_triage + id: <%= ActiveRecord::FixtureSet.identify("layout", :uuid) %> + number: 2 + board: writebook_uuid + creator: david_uuid + column: writebook_triage_uuid title: Layout is broken created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> + account: 37s_uuid text: - board: writebook - creator: kevin - column: writebook_in_progress + id: <%= ActiveRecord::FixtureSet.identify("text", :uuid) %> + number: 3 + board: writebook_uuid + creator: kevin_uuid + column: writebook_in_progress_uuid title: The text is too small created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> + account: 37s_uuid shipping: - board: writebook - creator: kevin - column: writebook_triage + id: <%= ActiveRecord::FixtureSet.identify("shipping", :uuid) %> + number: 4 + board: writebook_uuid + creator: kevin_uuid + column: writebook_triage_uuid title: We need to ship the app created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> + account: 37s_uuid buy_domain: - board: writebook - creator: david + id: <%= ActiveRecord::FixtureSet.identify("buy_domain", :uuid) %> + number: 5 + board: writebook_uuid + creator: david_uuid title: Buy domain created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> + account: 37s_uuid +radio: + id: <%= ActiveRecord::FixtureSet.identify("radio", :uuid) %> + number: 1 + board: miltons_wish_list_uuid + creator: mike_uuid + title: I want to play my radio at a reasonable volume + created_at: <%= 1.week.ago %> + status: published + last_active_at: <%= 1.week.ago %> + account: initech_uuid + +paycheck: + id: <%= ActiveRecord::FixtureSet.identify("paycheck", :uuid) %> + number: 2 + board: miltons_wish_list_uuid + creator: mike_uuid + title: I haven't received my paycheck + created_at: <%= 1.week.ago %> + status: published + last_active_at: <%= 1.week.ago %> + account: initech_uuid diff --git a/test/fixtures/closures.yml b/test/fixtures/closures.yml index cf9593601..573e526c0 100644 --- a/test/fixtures/closures.yml +++ b/test/fixtures/closures.yml @@ -1,3 +1,5 @@ shipping: - card: shipping - user: kevin \ No newline at end of file + id: <%= ActiveRecord::FixtureSet.identify("shipping_closure", :uuid) %> + account: 37s_uuid + card: shipping_uuid + user: kevin_uuid diff --git a/test/fixtures/columns.yml b/test/fixtures/columns.yml index f10ad1138..ff9cfd133 100644 --- a/test/fixtures/columns.yml +++ b/test/fixtures/columns.yml @@ -1,24 +1,32 @@ # Columns for writebook board (which has qa workflow) writebook_triage: + id: <%= ActiveRecord::FixtureSet.identify("writebook_triage", :uuid) %> name: Triage color: "var(--color-card-4)" - board: writebook + board: writebook_uuid position: 0 + account: 37s_uuid writebook_in_progress: + id: <%= ActiveRecord::FixtureSet.identify("writebook_in_progress", :uuid) %> name: In progress color: "var(--color-card-2)" - board: writebook + board: writebook_uuid position: 1 + account: 37s_uuid writebook_on_hold: + id: <%= ActiveRecord::FixtureSet.identify("writebook_on_hold", :uuid) %> name: On Hold color: "var(--color-card-4)" - board: writebook + board: writebook_uuid position: 2 + account: 37s_uuid writebook_review: + id: <%= ActiveRecord::FixtureSet.identify("writebook_review", :uuid) %> name: Review color: "var(--color-card-3)" - board: writebook + board: writebook_uuid position: 3 + account: 37s_uuid diff --git a/test/fixtures/comments.yml b/test/fixtures/comments.yml index ede5f327e..7040fce69 100644 --- a/test/fixtures/comments.yml +++ b/test/fixtures/comments.yml @@ -1,40 +1,58 @@ logo_1: - card: logo - creator: system + id: <%= ActiveRecord::FixtureSet.identify("logo_1", :uuid) %> + card: logo_uuid + creator: system_uuid created_at: <%= 1.week.ago %> + account: 37s_uuid logo_agreement_jz: - card: logo - creator: jz + id: <%= ActiveRecord::FixtureSet.identify("logo_agreement_jz", :uuid) %> + card: logo_uuid + creator: jz_uuid created_at: <%= 2.days.ago %> + account: 37s_uuid logo_3: - card: logo - creator: system + id: <%= ActiveRecord::FixtureSet.identify("logo_3", :uuid) %> + card: logo_uuid + creator: system_uuid created_at: <%= 1.day.ago %> + account: 37s_uuid logo_agreement_kevin: - card: logo - creator: kevin + id: <%= ActiveRecord::FixtureSet.identify("logo_agreement_kevin", :uuid) %> + card: logo_uuid + creator: kevin_uuid created_at: <%= 2.hours.ago %> + account: 37s_uuid logo_5: - card: logo - creator: system + id: <%= ActiveRecord::FixtureSet.identify("logo_5", :uuid) %> + card: logo_uuid + creator: system_uuid created_at: <%= 1.hour.ago %> + account: 37s_uuid layout_1: - card: layout - creator: system + id: <%= ActiveRecord::FixtureSet.identify("layout_1", :uuid) %> + card: layout_uuid + creator: system_uuid + account: 37s_uuid layout_overflowing_david: - card: layout - creator: david + id: <%= ActiveRecord::FixtureSet.identify("layout_overflowing_david", :uuid) %> + card: layout_uuid + creator: david_uuid + account: 37s_uuid text_1: - card: text - creator: system + id: <%= ActiveRecord::FixtureSet.identify("text_1", :uuid) %> + card: text_uuid + creator: system_uuid + account: 37s_uuid shipping_1: - card: shipping - creator: system + id: <%= ActiveRecord::FixtureSet.identify("shipping_1", :uuid) %> + card: shipping_uuid + creator: system_uuid + account: 37s_uuid diff --git a/test/fixtures/entropies.yml b/test/fixtures/entropies.yml index 712214176..53edb7cc4 100644 --- a/test/fixtures/entropies.yml +++ b/test/fixtures/entropies.yml @@ -1,11 +1,29 @@ 37s_account: - container: 37s (Account) + id: <%= ActiveRecord::FixtureSet.identify("37s_account", :uuid) %> + account: 37s_uuid + container: 37s_uuid (Account) auto_postpone_period: <%= 30.days.to_i %> writebook_board: - container: writebook (Board) + id: <%= ActiveRecord::FixtureSet.identify("writebook_board", :uuid) %> + account: 37s_uuid + container: writebook_uuid (Board) auto_postpone_period: <%= 90.days.to_i %> private_board: - container: private (Board) + id: <%= ActiveRecord::FixtureSet.identify("private_board", :uuid) %> + account: 37s_uuid + container: private_uuid (Board) auto_postpone_period: <%= 30.days.to_i %> + +initech_account: + id: <%= ActiveRecord::FixtureSet.identify("initech_account", :uuid) %> + account: initech_uuid + container: initech_uuid (Account) + auto_postpone_period: <%= 30.days.to_i %> + +miltons_wish_list_board: + id: <%= ActiveRecord::FixtureSet.identify("miltons_wish_list_board", :uuid) %> + account: initech_uuid + container: miltons_wish_list_uuid (Board) + auto_postpone_period: <%= 90.days.to_i %> diff --git a/test/fixtures/events.yml b/test/fixtures/events.yml index f8dc7dc38..ea4f2f465 100644 --- a/test/fixtures/events.yml +++ b/test/fixtures/events.yml @@ -1,73 +1,93 @@ logo_published: - creator: david - board: writebook - eventable: logo (Card) + id: <%= ActiveRecord::FixtureSet.identify("logo_published", :uuid) %> + creator: david_uuid + board: writebook_uuid + eventable: logo_uuid (Card) action: card_published created_at: <%= 1.week.ago %> + account: 37s_uuid logo_assignment_jz: - creator: david - board: writebook - eventable: logo (Card) + id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_jz", :uuid) %> + creator: david_uuid + board: writebook_uuid + eventable: logo_uuid (Card) action: card_assigned - particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }.to_json %> + particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify("jz", :uuid) ] }.to_json %> created_at: <%= 1.week.ago + 1.hour %> + account: 37s_uuid logo_assignment_david: - creator: david - board: writebook - eventable: logo (Card) + id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_david", :uuid) %> + creator: david_uuid + board: writebook_uuid + eventable: logo_uuid (Card) action: card_assigned - particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:david) ] }.to_json %> + particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify("david", :uuid) ] }.to_json %> created_at: <%= 1.week.ago + 1.hour %> + account: 37s_uuid logo_assignment_km: - creator: david - board: writebook - eventable: logo (Card) + id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_km", :uuid) %> + creator: david_uuid + board: writebook_uuid + eventable: logo_uuid (Card) action: card_assigned - particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:kevin) ] }.to_json %> + particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify("kevin", :uuid) ] }.to_json %> created_at: <%= 1.day.ago %> + account: 37s_uuid layout_published: - creator: david - board: writebook - eventable: layout (Card) + id: <%= ActiveRecord::FixtureSet.identify("layout_published", :uuid) %> + creator: david_uuid + board: writebook_uuid + eventable: layout_uuid (Card) action: card_published created_at: <%= 1.week.ago %> + account: 37s_uuid layout_commented: - creator: david - board: writebook - eventable: layout_overflowing_david (Comment) + id: <%= ActiveRecord::FixtureSet.identify("layout_commented", :uuid) %> + creator: david_uuid + board: writebook_uuid + eventable: layout_overflowing_david_uuid (Comment) action: comment_created created_at: <%= 1.week.ago %> + account: 37s_uuid layout_assignment_jz: - creator: david - board: writebook - eventable: layout (Card) + id: <%= ActiveRecord::FixtureSet.identify("layout_assignment_jz", :uuid) %> + creator: david_uuid + board: writebook_uuid + eventable: layout_uuid (Card) action: card_assigned - particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }.to_json %> + particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify("jz", :uuid) ] }.to_json %> created_at: <%= 1.hour.ago %> + account: 37s_uuid text_published: - creator: kevin - board: writebook - eventable: text (Card) + id: <%= ActiveRecord::FixtureSet.identify("text_published", :uuid) %> + creator: kevin_uuid + board: writebook_uuid + eventable: text_uuid (Card) action: card_published created_at: <%= 1.week.ago %> + account: 37s_uuid shipping_published: - creator: kevin - board: writebook - eventable: shipping (Card) + id: <%= ActiveRecord::FixtureSet.identify("shipping_published", :uuid) %> + creator: kevin_uuid + board: writebook_uuid + eventable: shipping_uuid (Card) action: card_published created_at: <%= 1.week.ago %> + account: 37s_uuid shipping_closed: - creator: kevin - board: writebook - eventable: shipping (Card) + id: <%= ActiveRecord::FixtureSet.identify("shipping_closed", :uuid) %> + creator: kevin_uuid + board: writebook_uuid + eventable: shipping_uuid (Card) action: card_closed created_at: <%= 2.days.ago %> + account: 37s_uuid diff --git a/test/fixtures/filters.yml b/test/fixtures/filters.yml index 18d91f458..5363f7168 100644 --- a/test/fixtures/filters.yml +++ b/test/fixtures/filters.yml @@ -1,6 +1,6 @@ jz_assignments: - creator: david - tags: mobile - assignees: jz + id: <%= ActiveRecord::FixtureSet.identify("jz_assignments", :uuid) %> + creator: david_uuid fields: <%= { indexed_by: :all, sorted_by: :newest }.to_json %> - params_digest: <%= Filter.digest_params({ indexed_by: :all, sorted_by: :newest, tag_ids: [ ActiveRecord::FixtureSet.identify(:mobile) ], assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }) %> + params_digest: <%= Filter.digest_params({ indexed_by: :all, sorted_by: :newest, tag_ids: [ ActiveRecord::FixtureSet.identify("mobile", :uuid) ], assignee_ids: [ ActiveRecord::FixtureSet.identify("jz", :uuid) ] }) %> + account: 37s_uuid diff --git a/test/fixtures/filters_tags.yml b/test/fixtures/filters_tags.yml new file mode 100644 index 000000000..11c9f7ff2 --- /dev/null +++ b/test/fixtures/filters_tags.yml @@ -0,0 +1,3 @@ +jz_assignments_mobile: + filter_id: <%= ActiveRecord::FixtureSet.identify("jz_assignments", :uuid) %> + tag_id: <%= ActiveRecord::FixtureSet.identify("mobile", :uuid) %> diff --git a/test/fixtures/identities.yml b/test/fixtures/identities.yml index c9dbc7c09..a014e801c 100644 --- a/test/fixtures/identities.yml +++ b/test/fixtures/identities.yml @@ -6,3 +6,6 @@ jz: kevin: email_address: kevin@37signals.com + +mike: + email_address: mike@37signals.com diff --git a/test/fixtures/memberships.yml b/test/fixtures/memberships.yml deleted file mode 100644 index 598f7374f..000000000 --- a/test/fixtures/memberships.yml +++ /dev/null @@ -1,11 +0,0 @@ -david_in_37signals: - identity: david - tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %> - -jz_in_37signals: - identity: jz - tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %> - -kevin_in_37signals: - identity: kevin - tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %> diff --git a/test/fixtures/mentions.yml b/test/fixtures/mentions.yml index 77963954c..aa54dd720 100644 --- a/test/fixtures/mentions.yml +++ b/test/fixtures/mentions.yml @@ -1,9 +1,13 @@ logo_card_david_mention_by_jz: - source: logo (Card) - mentioner: jz - mentionee: david + id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_mention_by_jz", :uuid) %> + account: 37s_uuid + source: logo_uuid (Card) + mentioner: jz_uuid + mentionee: david_uuid logo_comment_david_mention_by_jz: - source: logo_agreement_jz (Comment) - mentioner: jz - mentionee: david + id: <%= ActiveRecord::FixtureSet.identify("logo_comment_david_mention_by_jz", :uuid) %> + account: 37s_uuid + source: logo_agreement_jz_uuid (Comment) + mentioner: jz_uuid + mentionee: david_uuid diff --git a/test/fixtures/notifications.yml b/test/fixtures/notifications.yml index 8c88d23d0..9fe7da317 100644 --- a/test/fixtures/notifications.yml +++ b/test/fixtures/notifications.yml @@ -1,29 +1,39 @@ logo_published_kevin: - user: kevin - source: logo_published (Event) + id: <%= ActiveRecord::FixtureSet.identify("logo_published_kevin", :uuid) %> + user: kevin_uuid + source: logo_published_uuid (Event) created_at: <%= 1.week.ago %> - creator: david + creator: david_uuid + account: 37s_uuid logo_assignment_kevin: - user: kevin - source: logo_assignment_km (Event) + id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_kevin", :uuid) %> + user: kevin_uuid + source: logo_assignment_km_uuid (Event) created_at: <%= 1.week.ago %> - creator: david + creator: david_uuid + account: 37s_uuid layout_commented_kevin: - user: kevin - source: layout_commented (Event) + id: <%= ActiveRecord::FixtureSet.identify("layout_commented_kevin", :uuid) %> + user: kevin_uuid + source: layout_commented_uuid (Event) created_at: <%= 1.week.ago %> - creator: david + creator: david_uuid + account: 37s_uuid logo_card_david_mention_by_jz: - user: david - source: logo_card_david_mention_by_jz (Mention) + id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_mention_by_jz_notif", :uuid) %> + user: david_uuid + source: logo_card_david_mention_by_jz_uuid (Mention) created_at: <%= 1.week.ago %> - creator: david + creator: david_uuid + account: 37s_uuid logo_comment_david_mention_by_jz: - user: david - source: logo_comment_david_mention_by_jz (Mention) + id: <%= ActiveRecord::FixtureSet.identify("logo_comment_david_mention_by_jz_notif", :uuid) %> + user: david_uuid + source: logo_comment_david_mention_by_jz_uuid (Mention) created_at: <%= 1.week.ago %> - creator: david + creator: david_uuid + account: 37s_uuid diff --git a/test/fixtures/pins.yml b/test/fixtures/pins.yml index d73e0cfb3..15de01498 100644 --- a/test/fixtures/pins.yml +++ b/test/fixtures/pins.yml @@ -1,7 +1,11 @@ logo_kevin: - card: logo - user: kevin + id: <%= ActiveRecord::FixtureSet.identify("logo_kevin_pin", :uuid) %> + account: 37s_uuid + card: logo_uuid + user: kevin_uuid shipping_kevin: - card: shipping - user: kevin \ No newline at end of file + id: <%= ActiveRecord::FixtureSet.identify("shipping_kevin_pin", :uuid) %> + account: 37s_uuid + card: shipping_uuid + user: kevin_uuid diff --git a/test/fixtures/reactions.yml b/test/fixtures/reactions.yml index cfaa1a11d..16714c601 100644 --- a/test/fixtures/reactions.yml +++ b/test/fixtures/reactions.yml @@ -1,9 +1,13 @@ kevin: - content: "👍" - comment: logo_agreement_jz - reacter: kevin + id: <%= ActiveRecord::FixtureSet.identify("kevin_reaction", :uuid) %> + account: 37s_uuid + content: "👍" + comment: logo_agreement_jz_uuid + reacter: kevin_uuid david: - content: "👍" - comment: logo_agreement_jz - reacter: david + id: <%= ActiveRecord::FixtureSet.identify("david_reaction", :uuid) %> + account: 37s_uuid + content: "👍" + comment: logo_agreement_jz_uuid + reacter: david_uuid diff --git a/test/fixtures/sessions.yml b/test/fixtures/sessions.yml index 7cb1e8415..a062f53fd 100644 --- a/test/fixtures/sessions.yml +++ b/test/fixtures/sessions.yml @@ -3,3 +3,9 @@ david: kevin: identity: kevin + +jz: + identity: jz + +jason: + identity: jason diff --git a/test/fixtures/taggings.yml b/test/fixtures/taggings.yml index 23e17dd61..70b82ad8f 100644 --- a/test/fixtures/taggings.yml +++ b/test/fixtures/taggings.yml @@ -1,15 +1,23 @@ logo_web: - card: logo - tag: web + id: <%= ActiveRecord::FixtureSet.identify("logo_web_tagging", :uuid) %> + account: 37s_uuid + card: logo_uuid + tag: web_uuid layout_web: - card: layout - tag: web + id: <%= ActiveRecord::FixtureSet.identify("layout_web_tagging", :uuid) %> + account: 37s_uuid + card: layout_uuid + tag: web_uuid layout_mobile: - card: layout - tag: mobile + id: <%= ActiveRecord::FixtureSet.identify("layout_mobile_tagging", :uuid) %> + account: 37s_uuid + card: layout_uuid + tag: mobile_uuid text_mobile: - card: text - tag: mobile + id: <%= ActiveRecord::FixtureSet.identify("text_mobile_tagging", :uuid) %> + account: 37s_uuid + card: text_uuid + tag: mobile_uuid diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml index a0c771fab..c0f64bf13 100644 --- a/test/fixtures/tags.yml +++ b/test/fixtures/tags.yml @@ -1,5 +1,9 @@ web: + id: <%= ActiveRecord::FixtureSet.identify("web", :uuid) %> title: web + account: 37s_uuid mobile: + id: <%= ActiveRecord::FixtureSet.identify("mobile", :uuid) %> title: mobile + account: 37s_uuid diff --git a/test/fixtures/user/settings.yml b/test/fixtures/user/settings.yml index 29765f196..ecfc80065 100644 --- a/test/fixtures/user/settings.yml +++ b/test/fixtures/user/settings.yml @@ -2,17 +2,25 @@ _fixture: model_class: User::Settings david_settings: - user: david + id: <%= ActiveRecord::FixtureSet.identify("david_settings", :uuid) %> + account: 37s_uuid + user: david_uuid bundle_email_frequency: never jz_settings: - user: jz + id: <%= ActiveRecord::FixtureSet.identify("jz_settings", :uuid) %> + account: 37s_uuid + user: jz_uuid bundle_email_frequency: never kevin_settings: - user: kevin + id: <%= ActiveRecord::FixtureSet.identify("kevin_settings", :uuid) %> + account: 37s_uuid + user: kevin_uuid bundle_email_frequency: never system_settings: - user: system + id: <%= ActiveRecord::FixtureSet.identify("system_settings", :uuid) %> + account: 37s_uuid + user: system_uuid bundle_email_frequency: never diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 93a70eea6..c9e9dfd72 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,18 +1,33 @@ david: + id: <%= ActiveRecord::FixtureSet.identify("david", :uuid) %> name: David role: member - membership: david_in_37signals + identity: david + account: 37s_uuid jz: + id: <%= ActiveRecord::FixtureSet.identify("jz", :uuid) %> name: JZ role: member - membership: jz_in_37signals + identity: jz + account: 37s_uuid kevin: + id: <%= ActiveRecord::FixtureSet.identify("kevin", :uuid) %> name: Kevin role: admin - membership: kevin_in_37signals + identity: kevin + account: 37s_uuid system: + id: <%= ActiveRecord::FixtureSet.identify("system", :uuid) %> name: System role: system + account: 37s_uuid + +mike: + id: <%= ActiveRecord::FixtureSet.identify("mike", :uuid) %> + name: Mike + role: admin + identity: mike + account: initech_uuid diff --git a/test/fixtures/watches.yml b/test/fixtures/watches.yml index 40bf28619..e274dc362 100644 --- a/test/fixtures/watches.yml +++ b/test/fixtures/watches.yml @@ -1,44 +1,62 @@ logo_david: - card: logo - user: david + id: <%= ActiveRecord::FixtureSet.identify("logo_david_watch", :uuid) %> + account: 37s_uuid + card: logo_uuid + user: david_uuid watching: true logo_kevin: - card: logo - user: kevin + id: <%= ActiveRecord::FixtureSet.identify("logo_kevin_watch", :uuid) %> + account: 37s_uuid + card: logo_uuid + user: kevin_uuid watching: true layout_david: - card: layout - user: david + id: <%= ActiveRecord::FixtureSet.identify("layout_david_watch", :uuid) %> + account: 37s_uuid + card: layout_uuid + user: david_uuid watching: true layout_kevin: - card: layout - user: kevin + id: <%= ActiveRecord::FixtureSet.identify("layout_kevin_watch", :uuid) %> + account: 37s_uuid + card: layout_uuid + user: kevin_uuid watching: true text_david: - card: text - user: david + id: <%= ActiveRecord::FixtureSet.identify("text_david_watch", :uuid) %> + account: 37s_uuid + card: text_uuid + user: david_uuid watching: true text_jz: - card: text - user: jz + id: <%= ActiveRecord::FixtureSet.identify("text_jz_watch", :uuid) %> + account: 37s_uuid + card: text_uuid + user: jz_uuid watching: true shipping_david: - card: shipping - user: david + id: <%= ActiveRecord::FixtureSet.identify("shipping_david_watch", :uuid) %> + account: 37s_uuid + card: shipping_uuid + user: david_uuid watching: true shipping_jz: - card: shipping - user: jz + id: <%= ActiveRecord::FixtureSet.identify("shipping_jz_watch", :uuid) %> + account: 37s_uuid + card: shipping_uuid + user: jz_uuid watching: true shipping_kevin: - card: shipping - user: kevin + id: <%= ActiveRecord::FixtureSet.identify("shipping_kevin_watch", :uuid) %> + account: 37s_uuid + card: shipping_uuid + user: kevin_uuid watching: true diff --git a/test/fixtures/webhook/delinquency_trackers.yml b/test/fixtures/webhook/delinquency_trackers.yml index 4a5086e69..ea3fe284f 100644 --- a/test/fixtures/webhook/delinquency_trackers.yml +++ b/test/fixtures/webhook/delinquency_trackers.yml @@ -1,11 +1,15 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html active_webhook_tracker: - webhook: active + id: <%= ActiveRecord::FixtureSet.identify("active_webhook_tracker", :uuid) %> + account: 37s_uuid + webhook: active_uuid consecutive_failures_count: 1 first_failure_at: <%= 1.hour.ago %> inactive_webhook_tracker: - webhook: inactive + id: <%= ActiveRecord::FixtureSet.identify("inactive_webhook_tracker", :uuid) %> + account: 37s_uuid + webhook: inactive_uuid consecutive_failures_count: 1 first_failure_at: <%= 1.hour.ago %> diff --git a/test/fixtures/webhook/deliveries.yml b/test/fixtures/webhook/deliveries.yml index ffe55d9ad..b30f93daf 100644 --- a/test/fixtures/webhook/deliveries.yml +++ b/test/fixtures/webhook/deliveries.yml @@ -1,40 +1,50 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html successfully_completed: - webhook: active - event: logo_published + id: <%= ActiveRecord::FixtureSet.identify("successfully_completed_delivery", :uuid) %> + account: 37s_uuid + webhook: active_uuid + event: logo_published_uuid state: completed request: '<%= { headers: {} }.to_json %>' response: '<%= { code: 200, headers: {} }.to_json %>' created_at: <%= 1.week.ago %> unsuccessfully_completed: - webhook: active - event: logo_assignment_jz + id: <%= ActiveRecord::FixtureSet.identify("unsuccessfully_completed_delivery", :uuid) %> + account: 37s_uuid + webhook: active_uuid + event: logo_assignment_jz_uuid state: completed request: '<%= { headers: {} }.to_json %>' response: '<%= { code: 422, headers: {} }.to_json %>' created_at: <%= 1.week.ago + 1.hour %> errored: - webhook: active - event: layout_published + id: <%= ActiveRecord::FixtureSet.identify("errored_delivery", :uuid) %> + account: 37s_uuid + webhook: active_uuid + event: layout_published_uuid state: errored request: '<%= { headers: {} }.to_json %>' response: '<%= { error: "destination_unreachable" }.to_json %>' created_at: <%= 1.week.ago %> pending: - webhook: active - event: shipping_closed + id: <%= ActiveRecord::FixtureSet.identify("pending_delivery", :uuid) %> + account: 37s_uuid + webhook: active_uuid + event: shipping_closed_uuid state: pending request: null response: null created_at: <%= 2.day.ago %> in_progress: - webhook: active - event: logo_assignment_km + id: <%= ActiveRecord::FixtureSet.identify("in_progress_delivery", :uuid) %> + account: 37s_uuid + webhook: active_uuid + event: logo_assignment_km_uuid state: in_progress request: null response: null diff --git a/test/fixtures/webhooks.yml b/test/fixtures/webhooks.yml index d70c919f4..42e3f8427 100644 --- a/test/fixtures/webhooks.yml +++ b/test/fixtures/webhooks.yml @@ -1,15 +1,19 @@ active: + id: <%= ActiveRecord::FixtureSet.identify("active", :uuid) %> active: true name: Production API url: https://api.example.com/webhooks signing_secret: p94Bx2HjempCdYB4DTyZkY1b subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>' - board: writebook + board: writebook_uuid + account: 37s_uuid inactive: + id: <%= ActiveRecord::FixtureSet.identify("inactive", :uuid) %> active: false name: Test Webhook url: https://test.example.com/webhooks signing_secret: H8ms8ADcV92v2x17hnLEiL5m subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>' - board: private + board: private_uuid + account: 37s_uuid diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index fc48e48a1..9518718c5 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -6,42 +6,43 @@ class ApplicationHelperTest < ActionView::TestCase end test "page_title_tag on untenanted page" do - ApplicationRecord.without_tenant do - assert_select parse(page_title_tag), "title", text: "Fizzy" - end + Current.account = nil + + assert_select parse(page_title_tag), "title", text: "Fizzy" end test "page_title_tag on untenanted page with a page title" do @page_title = "Holodeck" + Current.account = nil - ApplicationRecord.without_tenant do - assert_select parse(page_title_tag), "title", text: "Holodeck | Fizzy" - end + assert_select parse(page_title_tag), "title", text: "Holodeck | Fizzy" end - test "page_title_tag on tenanted page when user has a single membership" do + test "page_title_tag on tenanted page when user has a single account" do Current.session = sessions(:david) assert_select parse(page_title_tag), "title", text: "Fizzy" end - test "page_title_tag on tenanted page when user has multiple memberships" do + test "page_title_tag on tenanted page when user has multiple accounts" do Current.session = sessions(:david) - identities(:david).memberships.create!(tenant: "dangling-tenant") + other_account = Account.create!(external_account_id: "dangling-tenant", name: "Other Account") + identities(:david).users.create!(account: other_account, name: "David") assert_select parse(page_title_tag), "title", text: "37signals | Fizzy" end - test "page_title_tag on tenanted page with a page title when user has a single membership" do + test "page_title_tag on tenanted page with a page title when user has a single account" do Current.session = sessions(:david) @page_title = "Holodeck" assert_select parse(page_title_tag), "title", text: "Holodeck | Fizzy" end - test "page_title_tag on tenanted page with a page title when user has multiple memberships" do + test "page_title_tag on tenanted page with a page title when user has multiple account" do Current.session = sessions(:david) - identities(:david).memberships.create!(tenant: "dangling-tenant") + other_account = Account.create!(external_account_id: "dangling-tenant", name: "Other Account") + identities(:david).users.create!(account: other_account, name: "David") @page_title = "Holodeck" assert_select parse(page_title_tag), "title", text: "Holodeck | 37signals | Fizzy" diff --git a/test/helpers/hotkeys_helper_test.rb b/test/helpers/hotkeys_helper_test.rb index ec96f6c9c..7201d7566 100644 --- a/test/helpers/hotkeys_helper_test.rb +++ b/test/helpers/hotkeys_helper_test.rb @@ -18,13 +18,13 @@ class HotkeysHelperTest < ActionView::TestCase test "mac enter" do emulate_mac - assert_equal "Return+J", hotkey_label([ "enter", "J" ]) + assert_equal "⏎+J", hotkey_label([ "enter", "J" ]) end test "linux enter" do emulate_linux - assert_equal "Enter+J", hotkey_label([ "enter", "J" ]) + assert_equal "⏎+J", hotkey_label([ "enter", "J" ]) end test "mac hyper" do diff --git a/test/lib/rails_ext/active_record_uuid_type_test.rb b/test/lib/rails_ext/active_record_uuid_type_test.rb new file mode 100644 index 000000000..f168fc3c0 --- /dev/null +++ b/test/lib/rails_ext/active_record_uuid_type_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class ActiveRecordUuidTypeTest < ActiveSupport::TestCase + setup do + @type = ActiveRecord::Type::Uuid.new + @sample_uuid = "01jcqzx8h0000000000000000" # base36 UUID + end + + test "cast nil returns nil" do + assert_nil @type.cast(nil) + end + + test "cast returns value as-is" do + result = @type.cast(@sample_uuid) + assert_equal @sample_uuid, result + end + + test "serialize returns binary Data object" do + result = @type.serialize(@sample_uuid) + + assert_instance_of ActiveModel::Type::Binary::Data, result + assert_equal 16, result.to_s.bytesize + assert_equal Encoding::BINARY, result.to_s.encoding + end + + test "serialize nil returns nil" do + assert_nil @type.serialize(nil) + end + + test "deserialize converts binary to base36" do + binary_data = @type.serialize(@sample_uuid) + + result = @type.deserialize(binary_data) + + assert_equal @sample_uuid, result + end + + test "deserialize handles raw binary string" do + binary_data = @type.serialize(@sample_uuid) + raw_binary = binary_data.to_s + + result = @type.deserialize(raw_binary) + + assert_equal @sample_uuid, result + end + + test "deserialize nil returns nil" do + assert_nil @type.deserialize(nil) + end +end diff --git a/test/mailers/previews/identity_mailer_preview.rb b/test/mailers/previews/identity_mailer_preview.rb deleted file mode 100644 index 8d29667bc..000000000 --- a/test/mailers/previews/identity_mailer_preview.rb +++ /dev/null @@ -1,17 +0,0 @@ -class IdentityMailerPreview < ActionMailer::Preview - def email_change_confirmation - ApplicationRecord.current_tenant = "897362094" - - identity = Identity.find_by(email_address: "david@37signals.com") - membership = identity&.memberships&.find_by(tenant: ApplicationRecord.current_tenant) - - new_email_address = "david.new@example.com" - token = membership.send(:generate_email_address_change_token, to: new_email_address) - - IdentityMailer.email_change_confirmation( - email_address: new_email_address, - token: token, - membership: membership - ) - end -end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb new file mode 100644 index 000000000..b9c601916 --- /dev/null +++ b/test/mailers/previews/user_mailer_preview.rb @@ -0,0 +1,13 @@ +class UserMailerPreview < ActionMailer::Preview + def email_change_confirmation + new_email_address = "new.email@example.com" + user = User.all.sample + token = user.send(:generate_email_address_change_token, to: new_email_address) + + UserMailer.email_change_confirmation( + email_address: new_email_address, + token: token, + user: user + ) + end +end diff --git a/test/models/access_test.rb b/test/models/access_test.rb index 1b9ea2d12..10787e5d3 100644 --- a/test/models/access_test.rb +++ b/test/models/access_test.rb @@ -68,7 +68,7 @@ class AccessTest < ActiveSupport::TestCase test "watches are destroyed when access is lost" do kevin = users(:kevin) board = boards(:writebook) - card = board.cards.first + card = cards(:logo) # Kevin watches this card assert card.watched_by?(kevin) diff --git a/test/models/account/join_code_test.rb b/test/models/account/join_code_test.rb index b37f219a2..4247ce7ec 100644 --- a/test/models/account/join_code_test.rb +++ b/test/models/account/join_code_test.rb @@ -2,7 +2,7 @@ require "test_helper" class Account::JoinCodeTest < ActiveSupport::TestCase test "generate code" do - join_code = Account::JoinCode.create! + join_code = Account::JoinCode.create!(account: Current.account) assert join_code.code.present? @@ -11,7 +11,7 @@ class Account::JoinCodeTest < ActiveSupport::TestCase end test "redeem" do - join_code = account_join_codes(:sole) + join_code = account_join_codes(:"37s") assert_difference -> { join_code.reload.usage_count }, 1 do join_code.redeem @@ -19,7 +19,7 @@ class Account::JoinCodeTest < ActiveSupport::TestCase end test "reset" do - join_code = account_join_codes(:sole) + join_code = account_join_codes(:"37s") original_code = join_code.code join_code.reset diff --git a/test/models/account/seedeable_test.rb b/test/models/account/seedeable_test.rb index 961db2f41..34c0532a7 100644 --- a/test/models/account/seedeable_test.rb +++ b/test/models/account/seedeable_test.rb @@ -2,7 +2,7 @@ require "test_helper" class Account::SedeableTest < ActiveSupport::TestCase setup do - @account = Account.sole + @account = Current.account end test "setup_customer_template adds boards, cards, and comments" do diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 785799c90..d47ce6f22 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -8,36 +8,36 @@ class AccountTest < ActiveSupport::TestCase end test "slug" do - account = Account.sole - assert_equal "/#{ApplicationRecord.current_tenant}", account.slug + account = accounts("37s") + assert_equal "/#{account.external_account_id}", account.slug end test ".create_with_admin_user creates a new local account" do - membership = memberships(:david_in_37signals) + Current.without_account do + identity = identities(:david) + account = nil + + assert_changes -> { Account.count }, +1 do + assert_changes -> { User.count }, +2 do + account = Account.create_with_admin_user( + account: { + external_account_id: ActiveRecord::FixtureSet.identify("account-create-with-admin-user-test"), + name: "Account Create With Admin" + }, + owner: { + name: "David", + identity: identity + } + ) + end + end - ApplicationRecord.create_tenant("account-create-with-dependents") do - account = Account.create_with_admin_user( - account: { - external_account_id: ActiveRecord::FixtureSet.identify("account-create-with-admin-user-test"), - name: "Account Create With Admin" - }, - owner: { - name: "David", - membership: membership - } - ) assert_not_nil account assert account.persisted? - assert_equal 1, Account.count assert_equal ActiveRecord::FixtureSet.identify("account-create-with-admin-user-test"), account.external_account_id assert_equal "Account Create With Admin", account.name - assert_equal 2, User.count - - system = User.find_by(role: "system") - assert system - - admin = User.find_by(role: "admin") + admin = account.users.find_by(role: "admin") assert_equal "David", admin.name assert_equal "david@37signals.com", admin.identity.email_address assert_equal "admin", admin.role diff --git a/test/models/board/accessible_test.rb b/test/models/board/accessible_test.rb index 8c3beef5e..79def5994 100644 --- a/test/models/board/accessible_test.rb +++ b/test/models/board/accessible_test.rb @@ -5,7 +5,7 @@ class Board::AccessibleTest < ActiveSupport::TestCase boards(:writebook).update! all_access: false boards(:writebook).accesses.revise granted: users(:david, :jz), revoked: users(:kevin) - assert_equal users(:david, :jz), boards(:writebook).users + assert_equal users(:david, :jz).to_set, boards(:writebook).users.to_set boards(:writebook).accesses.grant_to users(:kevin) assert_includes boards(:writebook).users.reload, users(:kevin) @@ -15,20 +15,20 @@ class Board::AccessibleTest < ActiveSupport::TestCase end test "grants access to everyone after creation" do - board = Current.set(session: sessions(:david)) do + board = Current.set(session: sessions(:david), user: users(:david)) do Board.create! name: "New board", all_access: true end - assert_equal User.active.sort, board.users.sort + assert_equal accounts("37s").users.active.sort, board.users.sort end test "grants access to everyone after update" do - board = Current.set(session: sessions(:david)) do + board = Current.set(session: sessions(:david), user: users(:david)) do Board.create! name: "New board" end assert_equal [ users(:david) ], board.users board.update! all_access: true - assert_equal User.active.sort, board.users.reload.sort + assert_equal accounts("37s").users.active.sort, board.users.reload.sort end test "board watchers" do diff --git a/test/models/card/entropic_test.rb b/test/models/card/entropic_test.rb index c39d1df7f..892678b2f 100644 --- a/test/models/card/entropic_test.rb +++ b/test/models/card/entropic_test.rb @@ -68,4 +68,18 @@ class Card::EntropicTest < ActiveSupport::TestCase assert_includes Card.postponing_soon, cards(:logo) assert_not_includes Card.postponing_soon, cards(:shipping) end + + test "due_to_be_postponed scope works properly cross-account" do + cards(:logo).update!(last_active_at: entropies(:writebook_board).auto_postpone_period.seconds.ago - 2.days) + cards(:radio).update!(last_active_at: entropies(:miltons_wish_list_board).auto_postpone_period.seconds.ago - 2.days) + + assert_equal(cards(:logo, :radio).to_set, Card.due_to_be_postponed.to_set) + end + + test "postponing_soon scope works properly cross-account" do + cards(:logo).update!(last_active_at: entropies(:writebook_board).auto_postpone_period.seconds.ago + 2.days) + cards(:radio).update!(last_active_at: entropies(:miltons_wish_list_board).auto_postpone_period.seconds.ago + 2.days) + + assert_equal(cards(:logo, :radio).to_set, Card.postponing_soon.to_set) + end end diff --git a/test/models/card/eventable/system_commenter_test.rb b/test/models/card/eventable/system_commenter_test.rb index fa8e9ed0a..57bef880b 100644 --- a/test/models/card/eventable/system_commenter_test.rb +++ b/test/models/card/eventable/system_commenter_test.rb @@ -45,8 +45,9 @@ class Card::Eventable::SystemCommenterTest < ActiveSupport::TestCase def assert_system_comment(expected_comment) assert_difference -> { @card.comments.count }, 1 do yield - assert @card.comments.last.creator.system? - assert_match Regexp.new(expected_comment.strip, Regexp::IGNORECASE), @card.comments.last.body.to_plain_text.strip + comment = @card.comments.last + assert comment.creator.system? + assert_match Regexp.new(expected_comment.strip, Regexp::IGNORECASE), comment.body.to_plain_text.strip end end end diff --git a/test/models/card/searchable_test.rb b/test/models/card/searchable_test.rb index a0415739a..18845ab7a 100644 --- a/test/models/card/searchable_test.rb +++ b/test/models/card/searchable_test.rb @@ -1,24 +1,34 @@ require "test_helper" class Card::SearchableTest < ActiveSupport::TestCase - setup do - Card.all.each(&:reindex) - Comment.all.each(&:reindex) - end + include SearchTestHelper - test "searching by title" do - assert_includes Card.mentioning("layout is broken"), cards(:layout) - end + test "card search" do + # Searching by title + card = @board.cards.create!(title: "layout is broken", creator: @user) + results = Card.mentioning("layout", user: @user) + assert_includes results, card - test "searching by comment" do - assert_includes Card.mentioning("overflowing"), cards(:layout) - end + # Searching by comment + card_with_comment = @board.cards.create!(title: "Some card", creator: @user) + card_with_comment.comments.create!(body: "overflowing text", creator: @user) + results = Card.mentioning("overflowing", user: @user) + assert_includes results, card_with_comment - test "sanitizing search query" do - assert_includes Card.mentioning("broken \""), cards(:layout) - end + # Sanitizing search query + card_broken = @board.cards.create!(title: "broken layout", creator: @user) + results = Card.mentioning("broken \"", user: @user) + assert_includes results, card_broken - test "a search with no valid terms returns empty results" do - assert_empty Card.mentioning("\"") + # Empty query returns no results + assert_empty Card.mentioning("\"", user: @user) + + # Filtering by board_ids + other_board = Board.create!(name: "Other Board", account: @account, creator: @user) + card_in_board = @board.cards.create!(title: "searchable content", creator: @user) + card_in_other_board = other_board.cards.create!(title: "searchable content", creator: @user) + results = Card.mentioning("searchable", user: @user) + assert_includes results, card_in_board + assert_not_includes results, card_in_other_board end end diff --git a/test/models/card/statuses_test.rb b/test/models/card/statuses_test.rb index 4b3e41c51..83179fbc4 100644 --- a/test/models/card/statuses_test.rb +++ b/test/models/card/statuses_test.rb @@ -34,8 +34,9 @@ class Card::StatusesTest < ActiveSupport::TestCase @card = boards(:writebook).cards.create! creator: users(:kevin), title: "Published Card", status: :published end - assert_equal @card, Event.last.eventable - assert_equal "card_published", Event.last.action + event = Event.last + assert_equal @card, event.eventable + assert_equal "card_published", event.action end test "an event is created when a card is published" do @@ -46,7 +47,24 @@ class Card::StatusesTest < ActiveSupport::TestCase card.publish end - assert_equal card, Event.last.eventable - assert_equal "card_published", Event.last.action + event = Event.last + assert_equal card, event.eventable + assert_equal "card_published", event.action + end + + test "created_at is updated when the card is published" do + Current.session = sessions(:david) + freeze_time + + card = travel_to 1.week.ago do + boards(:writebook).cards.create! creator: users(:kevin), title: "Newly created card" + end + + assert card.drafted? + assert_equal 1.week.ago, card.created_at + + card.publish + + assert_equal Time.now, card.created_at end end diff --git a/test/models/card_test.rb b/test/models/card_test.rb index 4d995100c..4858e7203 100644 --- a/test/models/card_test.rb +++ b/test/models/card_test.rb @@ -5,6 +5,19 @@ class CardTest < ActiveSupport::TestCase Current.session = sessions(:david) end + test "create assigns a number to the card" do + user = users(:david) + board = boards(:writebook) + account = board.account + card = nil + + assert_difference -> { account.reload.cards_count }, +1 do + card = Card.create!(title: "Test", board: board, creator: user) + end + + assert_equal account.reload.cards_count, card.number + end + test "capturing messages" do assert_difference -> { cards(:logo).comments.count }, +1 do cards(:logo).comments.create!(body: "Agreed.") @@ -25,15 +38,17 @@ class CardTest < ActiveSupport::TestCase cards(:logo).toggle_assignment users(:kevin) end assert_not cards(:logo).assigned_to?(users(:kevin)) - assert_equal "card_unassigned", Event.last.action - assert_equal [ users(:kevin) ], Event.last.assignees + unassign_event = Event.last + assert_equal "card_unassigned", unassign_event.action + assert_equal [ users(:kevin) ], unassign_event.assignees assert_difference %w[ cards(:logo).assignees.count Event.count ], +1 do cards(:logo).toggle_assignment users(:kevin) end assert cards(:logo).assigned_to?(users(:kevin)) - assert_equal "card_assigned", Event.last.action - assert_equal [ users(:kevin) ], Event.last.assignees + assign_event = Event.last + assert_equal "card_assigned", assign_event.action + assert_equal [ users(:kevin) ], assign_event.assignees end test "tagged states" do @@ -60,35 +75,30 @@ class CardTest < ActiveSupport::TestCase assert_equal "prioritized", cards(:logo).taggings.last.tag.title end - test "searchable by title" do - card = boards(:writebook).cards.create! title: "Insufficient haggis", creator: users(:kevin) - - assert_includes Card.search("haggis"), card - end - test "closed" do assert_equal [ cards(:shipping) ], Card.closed end test "open" do - assert_equal cards(:logo, :layout, :text, :buy_domain), Card.open + assert_equal cards(:logo, :layout, :text, :buy_domain).to_set, accounts("37s").cards.open.to_set + assert_equal cards(:radio, :paycheck).to_set, accounts("initech").cards.open.to_set end test "card_unassigned" do - assert_equal cards(:shipping, :text, :buy_domain), Card.unassigned + assert_equal cards(:shipping, :text, :buy_domain).to_set, accounts("37s").cards.unassigned.to_set end test "assigned to" do - assert_equal cards(:logo, :layout), Card.assigned_to(users(:jz)) + assert_equal cards(:logo, :layout).to_set, Card.assigned_to(users(:jz)).to_set end test "assigned by" do - assert_equal cards(:layout, :logo), Card.assigned_by(users(:david)) + assert_equal cards(:layout, :logo).to_set, Card.assigned_by(users(:david)).to_set end test "in board" do new_board = Board.create! name: "New Board", creator: users(:david) - assert_equal cards(:logo, :shipping, :layout, :text, :buy_domain), Card.where(board: boards(:writebook)) + assert_equal cards(:logo, :shipping, :layout, :text, :buy_domain).to_set, Card.where(board: boards(:writebook)).to_set assert_empty Card.where(board: new_board) end @@ -96,20 +106,6 @@ class CardTest < ActiveSupport::TestCase assert_equal cards(:layout, :text), Card.tagged_with(tags(:mobile)) end - test "mentioning" do - card = boards(:writebook).cards.create! title: "Insufficient haggis", creator: users(:kevin) - cards(:logo).comments.create!(body: "I hate haggis") - cards(:text).comments.create!(body: "I love haggis") - - assert_equal [ card, cards(:logo), cards(:text) ].sort, Card.mentioning("haggis").sort - end - - test "cache key includes the tenant name" do - card = cards(:logo) - - assert_includes card.cache_key, ApplicationRecord.current_tenant, "cache key must always include the tenant" - end - test "for published cards, it should set the default title 'Untitiled' when not provided" do card = boards(:writebook).cards.create! assert_nil card.title diff --git a/test/models/comment/searchable_test.rb b/test/models/comment/searchable_test.rb new file mode 100644 index 000000000..a8e52bfc2 --- /dev/null +++ b/test/models/comment/searchable_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class Comment::SearchableTest < ActiveSupport::TestCase + include SearchTestHelper + + setup do + @card = @board.cards.create!(title: "Test Card", creator: @user) + end + + test "comment search" do + # Comment is indexed on create + comment = @card.comments.create!(body: "searchable comment text", creator: @user) + record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment.id) + assert_not_nil record + + # Comment is updated in index + comment.update!(body: "updated text") + record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment.id) + assert_match /updat/, record.content + + # Comment is removed from index on destroy + comment_id = comment.id + comment.destroy + record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: comment_id) + assert_nil record + + # Finding cards via comment search + card_with_comment = @board.cards.create!(title: "Card One", creator: @user) + card_with_comment.comments.create!(body: "unique searchable phrase", creator: @user) + card_without_comment = @board.cards.create!(title: "Card Two", creator: @user) + results = Card.mentioning("searchable", user: @user) + assert_includes results, card_with_comment + assert_not_includes results, card_without_comment + + # Comment stores parent card_id and board_id + new_comment = @card.comments.create!(body: "test comment", creator: @user) + record = Search::Record.for_account(@account.id).find_by(searchable_type: "Comment", searchable_id: new_comment.id) + assert_equal @card.id, record.card_id + assert_equal @board.id, record.board_id + end +end diff --git a/test/models/comment_test.rb b/test/models/comment_test.rb index 7af13ed36..c519f8fbd 100644 --- a/test/models/comment_test.rb +++ b/test/models/comment_test.rb @@ -4,10 +4,4 @@ class CommentTest < ActiveSupport::TestCase setup do Current.session = sessions(:david) end - - test "searchable by body" do - comment = cards(:logo).comments.create!(body: "I'd prefer something more rustic") - - assert_includes Comment.search("something rustic"), comment - end end diff --git a/test/models/entropy_test.rb b/test/models/entropy_test.rb index 9a3ddd577..0648bbd3b 100644 --- a/test/models/entropy_test.rb +++ b/test/models/entropy_test.rb @@ -8,7 +8,7 @@ class Entropy::Test < ActiveSupport::TestCase end test "touch cards when entropy changes for account container" do - account = Account.sole + account = Current.account assert_changes -> { account.cards.first.updated_at } do boards(:writebook).entropy.update!(auto_postpone_period: 15.days) diff --git a/test/models/filter_test.rb b/test/models/filter_test.rb index 091580ec3..8531a7770 100644 --- a/test/models/filter_test.rb +++ b/test/models/filter_test.rb @@ -7,7 +7,7 @@ class FilterTest < ActiveSupport::TestCase test "cards" do Current.set session: sessions(:david) do - @new_board = Board.create! name: "Inaccessible Board" + @new_board = Board.create! name: "Inaccessible Board", creator: users(:david) @new_card = @new_board.cards.create!(status: "published") cards(:layout).comments.create!(body: "I hate haggis") diff --git a/test/models/identity_test.rb b/test/models/identity_test.rb index 26e84fd51..277c0cc1e 100644 --- a/test/models/identity_test.rb +++ b/test/models/identity_test.rb @@ -18,4 +18,21 @@ class IdentityTest < ActiveSupport::TestCase assert Identity.new(email_address: "test@basecamp.com").staff? assert_not Identity.new(email_address: "test@example.com").staff? end + + test "join" do + identity = identities(:david) + account = accounts(:initech) + + Current.without_account do + assert_difference "User.count", 1 do + identity.join(account) + end + + user = account.users.find_by!(identity: identity) + + assert_not_nil user + assert_equal identity, user.identity + assert_equal identity.email_address, user.name + end + end end diff --git a/test/models/membership/email_address_changeable_test.rb b/test/models/membership/email_address_changeable_test.rb deleted file mode 100644 index fbc61bbb1..000000000 --- a/test/models/membership/email_address_changeable_test.rb +++ /dev/null @@ -1,59 +0,0 @@ -require "test_helper" - -class Membership::EmailAddressChangeableTest < ActiveSupport::TestCase - include ActionMailer::TestHelper - - setup do - @identity = identities(:kevin) - @tenant = ApplicationRecord.current_tenant - @membership = @identity.memberships.find_by!(tenant: @tenant) - @new_email = "newart@example.com" - end - - test "send_email_address_change_confirmation" do - assert_emails 1 do - @membership.send_email_address_change_confirmation(@new_email) - end - end - - test "change_email_address" do - old_identity = @identity - - assert_difference -> { Identity.count }, +1 do - @membership.change_email_address(@new_email) - end - - assert_equal @new_email, @membership.reload.identity.email_address - assert_not old_identity.reload.memberships.exists?(id: @membership.id) - assert_equal @new_email, @membership.reload.identity.email_address - - # Make sure that a prior membership doesn't exist - identities(:david).memberships.where(tenant: @tenant).delete_all - - assert_no_difference -> { Identity.count } do - @membership.change_email_address(identities(:david).email_address) - end - assert_equal identities(:david).email_address, @membership.reload.identity.email_address - end - - test "change_email_address_using_token" do - token = @membership.send(:generate_email_address_change_token, to: @new_email) - - Membership.change_email_address_using_token(token) - - assert_equal @new_email, @membership.reload.identity.email_address - end - - test "change_email_address_using_token with invalid token" do - assert_raises(ArgumentError, match: /invalid/) do - Membership.change_email_address_using_token("invalid_token") - end - - token = @membership.send(:generate_email_address_change_token, to: @new_email) - @identity.update!(email_address: "different@example.com") - - assert_raises(ArgumentError, match: /different email address/) do - Membership.change_email_address_using_token(token) - end - end -end diff --git a/test/models/membership_test.rb b/test/models/membership_test.rb deleted file mode 100644 index d455b0e70..000000000 --- a/test/models/membership_test.rb +++ /dev/null @@ -1,18 +0,0 @@ -require "test_helper" - -class MembershipTest < ActiveSupport::TestCase - test "change_email_address" do - tenant = ApplicationRecord.current_tenant - old_identity = identities(:kevin) - new_email = "kevin.new@37signals.com" - - assert_difference -> { Identity.count }, 1 do - Membership.change_email_address(from: old_identity.email_address, to: new_email, tenant: tenant) - end - - new_identity = Identity.find_by(email_address: new_email) - assert new_identity - assert new_identity.memberships.exists?(tenant: tenant) - assert_not old_identity.reload.memberships.exists?(tenant: tenant) - end -end diff --git a/test/models/search/highlighter_test.rb b/test/models/search/highlighter_test.rb new file mode 100644 index 000000000..a065c4da1 --- /dev/null +++ b/test/models/search/highlighter_test.rb @@ -0,0 +1,89 @@ +require "test_helper" + +class Search::HighlighterTest < ActiveSupport::TestCase + test "highlight simple word match" do + highlighter = Search::Highlighter.new("hello") + result = highlighter.highlight("Hello world") + + assert_equal "#{mark('Hello')} world", result + end + + test "highlight multiple occurrences" do + highlighter = Search::Highlighter.new("test") + result = highlighter.highlight("This is a test and another test") + + assert_equal "This is a #{mark('test')} and another #{mark('test')}", result + end + + test "highlight case insensitive" do + highlighter = Search::Highlighter.new("ruby") + result = highlighter.highlight("Ruby is great and RUBY rocks") + + assert_equal "#{mark('Ruby')} is great and #{mark('RUBY')} rocks", result + end + + test "highlight quoted phrases" do + highlighter = Search::Highlighter.new('"hello world"') + result = highlighter.highlight("Say hello world to everyone") + + assert_equal "Say #{mark('hello world')} to everyone", result + end + + test "snippet returns full text with highlights when under max words" do + highlighter = Search::Highlighter.new("ruby") + result = highlighter.snippet("Ruby is great", max_words: 20) + + assert_equal "#{mark('Ruby')} is great", result + end + + test "snippet creates excerpt around match" do + highlighter = Search::Highlighter.new("match") + text = "word " * 10 + "match " + "word " * 10 + result = highlighter.snippet(text, max_words: 10) + + assert result.start_with?("...") + assert result.end_with?("...") + assert_includes result, mark("match") + end + + test "snippet adds leading ellipsis when match is not at start" do + highlighter = Search::Highlighter.new("middle") + text = "word " * 20 + "middle" + result = highlighter.snippet(text, max_words: 10) + + assert result.start_with?("...") + assert_not result.end_with?("...") + assert_includes result, mark("middle") + end + + test "snippet adds trailing ellipsis when text continues after excerpt" do + highlighter = Search::Highlighter.new("start") + text = "start " + "word " * 30 + result = highlighter.snippet(text, max_words: 10) + + assert result.end_with?("...") + assert_not result.start_with?("...") + assert_includes result, mark("start") + end + + test "snippet falls back to truncation when no match found" do + highlighter = Search::Highlighter.new("nomatch") + text = "This text does not contain the search term " + "word " * 50 + result = highlighter.snippet(text, max_words: 10) + + assert_includes result, "..." + assert_not_includes result, Search::Highlighter::OPENING_MARK + end + + test "highlight escapes HTML and preserves marks" do + highlighter = Search::Highlighter.new("test") + result = highlighter.highlight("") + + assert_equal "<script>#{mark('test')}</script>", result + end + + private + def mark(text) + "#{Search::Highlighter::OPENING_MARK}#{text}#{Search::Highlighter::CLOSING_MARK}" + end +end diff --git a/test/models/search/stemmer_test.rb b/test/models/search/stemmer_test.rb new file mode 100644 index 000000000..858ed2dca --- /dev/null +++ b/test/models/search/stemmer_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class Search::StemmerTest < ActiveSupport::TestCase + test "stem single word" do + result = Search::Stemmer.stem("running") + + assert_equal "run", result + end + + test "stem multiple words" do + result = Search::Stemmer.stem("test, running JUMPING & walking") + + assert_equal "test run jump walk", result + end +end diff --git a/test/models/search_test.rb b/test/models/search_test.rb index c62764584..e27bcbdfe 100644 --- a/test/models/search_test.rb +++ b/test/models/search_test.rb @@ -1,23 +1,33 @@ require "test_helper" class SearchTest < ActiveSupport::TestCase - setup do - Card.all.each(&:reindex) - Comment.all.each(&:reindex) + include SearchTestHelper - @user = users(:kevin) - end + test "search" do + # Search cards and comments + card = @board.cards.create!(title: "layout design", creator: @user) + comment_card = @board.cards.create!(title: "Some card", creator: @user) + comment_card.comments.create!(body: "overflowing text", creator: @user) - test "search cards and comments" do - assert @user.search("layout").find { it.card == cards(:layout) } - assert @user.search("overflowing").find { it.comment == comments(:layout_overflowing_david) } - end + results = Search.results(query: "layout", user: @user) + assert results.find { |it| it.card_id == card.id } - test "don't include innaccessible" do - boards(:writebook).update! all_access: false - boards(:writebook).accesses.revoke_from(@user) + results = Search.results(query: "overflowing", user: @user) + assert results.find { |it| it.card_id == comment_card.id && it.searchable_type == "Comment" } - assert_not @user.search("layout").find { it.card == cards(:layout) } - assert_not @user.search("overflowing").find { it.comment == comments(:layout_overflowing_david) } + # Don't include inaccessible boards + other_user = User.create!(name: "Other User", account: @account) + inaccessible_board = Board.create!(name: "Inaccessible Board", account: @account, creator: other_user) + accessible_card = @board.cards.create!(title: "searchable content", creator: @user) + inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", creator: other_user) + + results = Search.results(query: "searchable", user: @user) + assert results.find { |it| it.card_id == accessible_card.id } + assert_not results.find { |it| it.card_id == inaccessible_card.id } + + # Empty board_ids returns no results + user_without_access = User.create!(name: "No Access User", account: @account) + results = Search.results(query: "anything", user: user_without_access) + assert_empty results end end diff --git a/test/models/user/accessor_test.rb b/test/models/user/accessor_test.rb index f83935883..95cb84edb 100644 --- a/test/models/user/accessor_test.rb +++ b/test/models/user/accessor_test.rb @@ -2,14 +2,14 @@ require "test_helper" class User::AccessorTest < ActiveSupport::TestCase test "new users get added to all_access boards on creation" do - user = User.create!(name: "Jorge") + user = User.create!(account: accounts("37s"), name: "Jorge") assert_includes user.boards, boards(:writebook) - assert_equal Board.all_access.count, user.boards.count + assert_equal user.account.boards.all_access.count, user.boards.count end test "system user does not get added to boards on creation" do - system_user = User.create!(role: "system", name: "Test System User") + system_user = User.create!(account: accounts("37s"), role: "system", name: "Test System User") assert_empty system_user.boards end end diff --git a/test/models/user/configurable_test.rb b/test/models/user/configurable_test.rb index b49b78c1b..d5f467f7c 100644 --- a/test/models/user/configurable_test.rb +++ b/test/models/user/configurable_test.rb @@ -2,7 +2,7 @@ require "test_helper" class User::ConfigurableTest < ActiveSupport::TestCase test "should create settings for new users" do - user = User.create! name: "Some new user" + user = User.create! account: accounts("37s"), name: "Some new user" assert user.settings.present? end end diff --git a/test/models/user/email_address_changeable_test.rb b/test/models/user/email_address_changeable_test.rb new file mode 100644 index 000000000..5e1158cf3 --- /dev/null +++ b/test/models/user/email_address_changeable_test.rb @@ -0,0 +1,56 @@ +require "test_helper" + +class User::EmailAddressChangeableTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + + setup do + @identity = identities(:kevin) + @user = @identity.users.find_by!(account: accounts("37s")) + @new_email = "newart@example.com" + end + + test "send_email_address_change_confirmation" do + assert_emails 1 do + @user.send_email_address_change_confirmation(@new_email) + end + end + + test "change_email_address" do + old_identity = @identity + new_identity = identities(:mike) + + assert_difference -> { Identity.count }, +1 do + @user.change_email_address(@new_email) + end + + assert_equal @new_email, @user.reload.identity.email_address + assert_not old_identity.reload.users.exists?(id: @user.id) + assert_equal @new_email, @user.reload.identity.email_address + + assert_no_difference -> { Identity.count } do + @user.change_email_address(new_identity.email_address) + end + assert_equal new_identity.email_address, @user.reload.identity.email_address + end + + test "change_email_address_using_token" do + token = @user.send(:generate_email_address_change_token, to: @new_email) + + @user.change_email_address_using_token(token) + + assert_equal @new_email, @user.reload.identity.email_address + end + + test "change_email_address_using_token with invalid token" do + assert_raises(ArgumentError, match: /invalid/) do + @user.change_email_address_using_token("invalid_token") + end + + token = @user.send(:generate_email_address_change_token, to: @new_email) + @identity.update!(email_address: "different@example.com") + + assert_raises(ArgumentError, match: /different email address/) do + @user.change_email_address_using_token(token) + end + end +end diff --git a/test/models/user/searcher_test.rb b/test/models/user/searcher_test.rb index 20cc54109..35a95cd48 100644 --- a/test/models/user/searcher_test.rb +++ b/test/models/user/searcher_test.rb @@ -13,10 +13,6 @@ class User::SearcherTest < ActiveSupport::TestCase assert_equal "broken", @user.search_queries.last.terms end - test "return an empty functional relation when no query" do - assert Card.where(id: @user.search("").select(:card_id)).none? - end - test "don't duplicate repeated searches but touch the existing match" do search_result = @user.remember_search("broken") original_updated_at = search_result.updated_at diff --git a/test/models/user_test.rb b/test/models/user_test.rb index e074e01f5..8098e2190 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -2,25 +2,27 @@ require "test_helper" class UserTest < ActiveSupport::TestCase test "create" do - user = User.create! \ + user = User.create!( + account: accounts("37s"), role: "member", name: "Victor Cooper" + ) assert_equal [ boards(:writebook) ], user.boards assert user.settings.present? end test "creation gives access to all_access boards" do - user = User.create! \ + user = User.create!( + account: accounts("37s"), role: "member", name: "Victor Cooper" + ) assert_equal [ boards(:writebook) ], user.boards end test "deactivate" do - tenant = ApplicationRecord.current_tenant - assert_changes -> { users(:jz).active? }, from: true, to: false do assert_changes -> { users(:jz).accesses.count }, from: 1, to: 0 do users(:jz).deactivate diff --git a/test/system/smoke_test.rb b/test/system/smoke_test.rb index 92cb287fd..e4bc9cae9 100644 --- a/test/system/smoke_test.rb +++ b/test/system/smoke_test.rb @@ -38,7 +38,7 @@ class SmokeTest < ApplicationSystemTestCase test "dismissing notifications" do sign_in_as(users(:david)) - notif = notifications(:logo_comment_david_mention_by_jz) + notif = notifications(:logo_card_david_mention_by_jz) assert_selector "div##{dom_id(notif)}" @@ -47,6 +47,24 @@ class SmokeTest < ApplicationSystemTestCase assert_no_selector "div##{dom_id(notif)}" end + test "dragging card to a new column" do + sign_in_as(users(:david)) + + card = Card.find("03axhd1h3qgnsffqplkyf28fv") + assert_nil(card.column) + + visit board_url(boards(:writebook)) + + card_el = page.find("#article_card_03axhd1h3qgnsffqplkyf28fv") + column_el = page.find("#column_03axmcferfmbnv4qg816nw6bg") + cards_count = column_el.find(".cards__expander-count").text.to_i + + card_el.drag_to(column_el) + + column_el.find(".cards__expander-count", text: cards_count + 1) + assert_equal("Triage", card.reload.column.name) + end + private def sign_in_as(user) visit session_transfer_url(user.identity.transfer_id, script_name: nil) diff --git a/test/test_helper.rb b/test/test_helper.rb index 94949d567..d370c51ae 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,13 +1,11 @@ ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" -# there's no fixture for this, we're just generating a stable integer -Rails.application.config.active_record_tenanted.default_tenant = ActiveRecord::FixtureSet.identify "37s_fizzy" - require "rails/test_help" require "webmock/minitest" require "vcr" require "mocha/minitest" +require "turbo/broadcastable/test_helper" WebMock.allow_net_connect! @@ -47,21 +45,116 @@ module ActiveSupport include ActiveJob::TestHelper include ActionTextTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper + include Turbo::Broadcastable::TestHelper + + setup do + # TODO:PLANB: this is hacky, we should sort through the `Current` dependencies and figure out + # how to set Current.user without needing both a session *and* an account + Current.account = accounts("37s") + end + + teardown do + Current.clear_all + end end end class ActionDispatch::IntegrationTest setup do - integration_session.default_url_options[:script_name] = "/#{ApplicationRecord.current_tenant}" + integration_session.default_url_options[:script_name] = "/#{ActiveRecord::FixtureSet.identify("37signals")}" end end class ActionDispatch::SystemTestCase setup do - self.default_url_options[:script_name] = "/#{ApplicationRecord.current_tenant}" + self.default_url_options[:script_name] = "/#{ActiveRecord::FixtureSet.identify("37signals")}" end end +module FixturesTestHelper + extend ActiveSupport::Concern + + class_methods do + def identify(label, column_type = :integer) + if label.to_s.end_with?("_uuid") + column_type = :uuid + label = label.to_s.delete_suffix("_uuid") + end + + # Rails passes :string for varchar columns, so handle both :uuid and :string + return super(label, column_type) unless column_type.in?([ :uuid, :string ]) + generate_fixture_uuid(label) + end + + private + + def generate_fixture_uuid(label) + # Generate deterministic UUIDv7 for fixtures that sorts by fixture ID + # This allows .first/.last to work as expected in tests + # Use the same CRC32 algorithm as Rails' default fixture ID generation + # so that UUIDs sort in the same order as integer IDs + fixture_int = Zlib.crc32("fixtures/#{label}") % (2**30 - 1) + + # Translate the deterministic order into times in the past, so that records + # created during test runs are also always newer than the fixtures. + base_time = Time.utc(2024, 1, 1, 0, 0, 0) + timestamp = base_time + (fixture_int / 1000.0) + + uuid_v7_with_timestamp(timestamp, label) + end + + def uuid_v7_with_timestamp(time, seed_string) + # Generate UUIDv7 with custom timestamp and deterministic random bits + # Format: 48-bit timestamp_ms | 12-bit sub_ms_precision | 4-bit version | 62-bit random + + time_ms = time.to_f * 1000 + timestamp_ms = time_ms.to_i + + # 48-bit timestamp (milliseconds since epoch) + bytes = [] + bytes[0] = (timestamp_ms >> 40) & 0xff + bytes[1] = (timestamp_ms >> 32) & 0xff + bytes[2] = (timestamp_ms >> 24) & 0xff + bytes[3] = (timestamp_ms >> 16) & 0xff + bytes[4] = (timestamp_ms >> 8) & 0xff + bytes[5] = timestamp_ms & 0xff + + # Use the 12-bit rand_a field for sub-millisecond precision + # Extract fractional milliseconds and convert to 12-bit value (0-4095) + # This gives us ~0.244 microsecond precision + frac_ms = time_ms - timestamp_ms + sub_ms_precision = (frac_ms * 4096).to_i & 0xfff + + # Derive deterministic "random" bits from seed_string for the remaining random bits + hash = Digest::MD5.hexdigest(seed_string) + + # 12-bit sub-ms precision + 4-bit version (0111 for v7) + bytes[6] = ((sub_ms_precision >> 8) & 0x0f) | 0x70 # version 7 + bytes[7] = sub_ms_precision & 0xff + + # 2-bit variant (10) + 62-bit random + rand_b = hash[3...19].to_i(16) & ((2**62) - 1) + bytes[8] = ((rand_b >> 56) & 0x3f) | 0x80 # variant 10 + bytes[9] = (rand_b >> 48) & 0xff + bytes[10] = (rand_b >> 40) & 0xff + bytes[11] = (rand_b >> 32) & 0xff + bytes[12] = (rand_b >> 24) & 0xff + bytes[13] = (rand_b >> 16) & 0xff + bytes[14] = (rand_b >> 8) & 0xff + bytes[15] = rand_b & 0xff + + # Format as UUID string and convert to base36 (25 chars) + uuid = "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x" % bytes + hex = uuid.delete("-") + hex.to_i(16).to_s(36).rjust(25, "0") + end + end +end + +ActiveSupport.on_load(:active_record_fixture_set) do + prepend(FixturesTestHelper) +end + unless Rails.application.config.x.oss_config load File.expand_path("../gems/fizzy-saas/test/test_helper.rb", __dir__) end diff --git a/test/test_helpers/search_test_helper.rb b/test/test_helpers/search_test_helper.rb new file mode 100644 index 000000000..f35f1c2a8 --- /dev/null +++ b/test/test_helpers/search_test_helper.rb @@ -0,0 +1,35 @@ +module SearchTestHelper + extend ActiveSupport::Concern + + included do + self.use_transactional_tests = false + + setup :setup_search_test + teardown :teardown_search_test + end + + def setup_search_test + clear_search_records + Account.find_by(name: "Search Test")&.destroy + Identity.find_by(email_address: "test@example.com")&.destroy + + @account = Account.create!(name: "Search Test", external_account_id: ActiveRecord::FixtureSet.identify("search_test")) + Current.account = @account + @identity = Identity.create!(email_address: "test@example.com") + @user = User.create!(name: "Test User", account: @account, identity: @identity) + @board = Board.create!(name: "Test Board", account: @account, creator: @user) + end + + def teardown_search_test + clear_search_records + Account.find_by(name: "Search Test")&.destroy + Identity.find_by(email_address: "test@example.com")&.destroy + end + + private + def clear_search_records + Search::Record::SHARD_COUNT.times do |shard_id| + ActiveRecord::Base.connection.execute("DELETE FROM search_records_#{shard_id}") + end + end +end diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb index 8936b3548..927123bcf 100644 --- a/test/test_helpers/session_test_helper.rb +++ b/test/test_helpers/session_test_helper.rb @@ -41,10 +41,13 @@ module SessionTestHelper def with_current_user(user) user = users(user) unless user.is_a? User - Current.session = Session.new(identity: user.identity) - yield - ensure - Current.clear_all + @old_session = Current.session + begin + Current.session = Session.new(identity: user.identity) + yield + ensure + Current.session = @old_session + end end def untenanted(&block)