From d3258b3c7f9076ad8b1002983cfa89d95e385aea Mon Sep 17 00:00:00 2001 From: Jeroen Versteeg Date: Fri, 9 Jan 2026 16:31:47 +0100 Subject: [PATCH 01/14] Replace use of instance variable with local The partial `boards/edit/auto_close` used both `@board` as well as `board`. --- app/views/boards/edit/_auto_close.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/boards/edit/_auto_close.html.erb b/app/views/boards/edit/_auto_close.html.erb index c32c1c050..8993c132e 100644 --- a/app/views/boards/edit/_auto_close.html.erb +++ b/app/views/boards/edit/_auto_close.html.erb @@ -1,7 +1,7 @@ -<%= turbo_frame_tag @board, :entropy do %> +<%= turbo_frame_tag board, :entropy do %>

Auto close

Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not now” if no one updates, comments, or moves a card for…

- <%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(@board) %> + <%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(board) %>
<% end %> From 589ae1ab36846b73170122002042ff4b86660932 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 12 Jan 2026 16:23:20 -0600 Subject: [PATCH 02/14] Move handleDesktop inside restoreColumnsDisablingTransitions --- .../controllers/collapsible_columns_controller.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js index 1a9ba4174..e432a054f 100644 --- a/app/javascript/controllers/collapsible_columns_controller.js +++ b/app/javascript/controllers/collapsible_columns_controller.js @@ -14,13 +14,12 @@ export default class extends Controller { } async connect() { - await this.#restoreColumnsDisablingTransitions() - this.#setupIntersectionObserver() - this.mediaQuery = window.matchMedia(this.desktopBreakpointValue) this.handleDesktop = this.#handleDesktop.bind(this) this.mediaQuery.addEventListener("change", this.handleDesktop) - this.handleDesktop(this.mediaQuery) + + await this.#restoreColumnsDisablingTransitions() + this.#setupIntersectionObserver() } disconnect() { @@ -57,6 +56,7 @@ export default class extends Controller { async #restoreColumnsDisablingTransitions() { this.#disableTransitions() this.#restoreColumns() + this.#handleDesktop() await nextFrame() this.#enableTransitions() From 603e4d10b3cc7e5183733a0b2fc028d75cada920 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Tue, 13 Jan 2026 09:50:37 +0100 Subject: [PATCH 03/14] Add `.txt-balance` utility --- app/assets/stylesheets/utilities.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index aa19f0f58..76dedfe12 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -24,6 +24,7 @@ .txt-underline { text-decoration: underline; } .txt-tight-lines { line-height: 1.2; } .txt-nowrap { white-space: nowrap; } + .txt-balance { text-wrap: balance; } .txt-break { word-break: break-word; } .txt-uppercase { text-transform: uppercase; } .txt-capitalize { text-transform: capitalize; } From 6c6a7be6076b10d51c732b7f79a9d6c92900ec28 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Tue, 13 Jan 2026 09:51:35 +0100 Subject: [PATCH 04/14] Balance magic link instructions --- app/views/sessions/magic_links/show.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index c4940e146..aba8411f5 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -3,7 +3,7 @@
">

<%= @page_title %>

-

+

Then enter the verification code included in the email below:

@@ -15,7 +15,7 @@ data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %> <% end %> -

The code sent to <%= email_address_pending_authentication %> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

+

The code sent to <%= email_address_pending_authentication %> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

<% if Rails.env.development? && flash[:magic_link_code].present? %> From 40a5d6211e308e48917b2b8b9834387f1325c910 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 13 Jan 2026 11:41:49 +0100 Subject: [PATCH 05/14] Update development dependencies --- Gemfile.lock | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4b3ce6e44..ad8f7fffd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -149,11 +149,11 @@ GEM bindex (0.8.1) bootsnap (1.19.0) msgpack (~> 1.2) - brakeman (7.1.1) + brakeman (7.1.2) racc builder (3.3.0) - bundler-audit (0.9.2) - bundler (>= 1.2.0, < 3) + bundler-audit (0.9.3) + bundler (>= 1.2.0) thor (~> 1.0) capybara (3.40.0) addressable @@ -174,7 +174,7 @@ GEM rexml crass (1.0.6) date (3.5.1) - debug (1.11.0) + debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) dotenv (3.1.8) @@ -184,7 +184,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - faker (3.5.2) + faker (3.5.3) i18n (>= 1.8.11, < 2) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) @@ -271,7 +271,7 @@ GEM stimulus-rails turbo-rails mittens (0.3.1) - mocha (2.8.2) + mocha (3.0.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) net-http-persistent (4.0.6) @@ -400,7 +400,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.38.0) + selenium-webdriver (4.39.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -440,7 +440,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) - thor (1.4.0) + thor (1.5.0) thruster (0.1.17) thruster (0.1.17-aarch64-linux) thruster (0.1.17-arm64-darwin) @@ -458,8 +458,7 @@ GEM unicode-emoji (~> 4.1) unicode-emoji (4.1.0) uri (1.1.1) - vcr (6.3.1) - base64 + vcr (6.4.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) From f6557856f7d0b8725ea2789c07277a231acf80f3 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 13 Jan 2026 11:42:22 +0100 Subject: [PATCH 06/14] Update runtime dependencies Updated kamal, bootsnap, rqrcode, net-http-persistent, web-push, aws-sdk-s3, bcrypt & rouge --- Gemfile.lock | 44 ++++++++++++++++++++++---------------------- Gemfile.saas.lock | 46 +++++++++++++++++++++++----------------------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ad8f7fffd..0e48985b0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -123,8 +123,8 @@ GEM ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1197.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1203.0) + aws-sdk-core (3.241.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -132,22 +132,22 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.120.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.208.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.211.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) - bcrypt (3.1.20) - bcrypt_pbkdf (1.1.1) + bcrypt (3.1.21) + bcrypt_pbkdf (1.1.2) benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.19.0) + bootsnap (1.20.1) msgpack (~> 1.2) brakeman (7.1.2) racc @@ -168,7 +168,7 @@ GEM logger (~> 1.5) chunky_png (1.4.0) concurrent-ruby (1.3.6) - connection_pool (2.5.5) + connection_pool (3.0.2) crack (1.0.1) bigdecimal rexml @@ -177,7 +177,7 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) - dotenv (3.1.8) + dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) erb (6.0.1) @@ -224,7 +224,7 @@ GEM json (2.18.0) jwt (3.1.2) base64 - kamal (2.9.0) + kamal (2.10.1) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) @@ -274,8 +274,8 @@ GEM mocha (3.0.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) - net-http-persistent (4.0.6) - connection_pool (~> 2.2, >= 2.2.4) + net-http-persistent (4.0.8) + connection_pool (>= 2.2.4, < 4) net-imap (0.6.2) date net-protocol @@ -307,7 +307,7 @@ GEM racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-musl) racc (~> 1.4) - openssl (3.3.2) + openssl (4.0.0) ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.0) @@ -319,7 +319,7 @@ GEM pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.6.0) + prism (1.8.0) propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -360,11 +360,11 @@ GEM reline (0.6.3) io-console (~> 0.5) rexml (3.4.4) - rouge (4.6.1) - rqrcode (3.1.0) + rouge (4.7.0) + rqrcode (3.2.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) - rqrcode_core (2.0.0) + rqrcode_core (2.1.0) rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -430,7 +430,7 @@ GEM sqlite3 (2.8.0-x86_64-darwin) sqlite3 (2.8.0-x86_64-linux-gnu) sqlite3 (2.8.0-x86_64-linux-musl) - sshkit (1.24.0) + sshkit (1.25.0) base64 logger net-scp (>= 1.1.2) @@ -464,9 +464,9 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - web-push (3.0.2) + web-push (3.1.0) jwt (~> 3.0) - openssl (~> 3.0) + openssl (>= 3.0) webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 98cf833bc..37cabc12a 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -201,8 +201,8 @@ GEM ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1197.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1203.0) + aws-sdk-core (3.241.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -210,22 +210,22 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.120.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.208.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.211.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) - bcrypt (3.1.20) - bcrypt_pbkdf (1.1.1) + bcrypt (3.1.21) + bcrypt_pbkdf (1.1.2) benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.19.0) + bootsnap (1.20.1) msgpack (~> 1.2) brakeman (7.1.1) racc @@ -246,7 +246,7 @@ GEM logger (~> 1.5) chunky_png (1.4.0) concurrent-ruby (1.3.6) - connection_pool (2.5.5) + connection_pool (3.0.2) crack (1.0.1) bigdecimal rexml @@ -255,7 +255,7 @@ GEM debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) - dotenv (3.1.8) + dotenv (3.2.0) drb (2.2.3) dry-initializer (3.2.0) ed25519 (1.4.0) @@ -302,7 +302,7 @@ GEM json (2.18.0) jwt (3.1.2) base64 - kamal (2.9.0) + kamal (2.10.1) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) @@ -352,8 +352,8 @@ GEM mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) - net-http-persistent (4.0.6) - connection_pool (~> 2.2, >= 2.2.4) + net-http-persistent (4.0.8) + connection_pool (>= 2.2.4, < 4) net-imap (0.6.2) date net-protocol @@ -383,7 +383,7 @@ GEM racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-musl) racc (~> 1.4) - openssl (3.3.2) + openssl (4.0.0) ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.0) @@ -395,7 +395,7 @@ GEM pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.6.0) + prism (1.8.0) prometheus-client-mmap (1.4.0) base64 bigdecimal @@ -470,11 +470,11 @@ GEM io-console (~> 0.5) rexml (3.4.4) rinku (2.0.6) - rouge (4.6.1) - rqrcode (3.1.0) + rouge (4.7.0) + rqrcode (3.2.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) - rqrcode_core (2.0.0) + rqrcode_core (2.1.0) rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -549,7 +549,7 @@ GEM sqlite3 (2.8.0-arm64-darwin) sqlite3 (2.8.0-x86_64-linux-gnu) sqlite3 (2.8.0-x86_64-linux-musl) - sshkit (1.24.0) + sshkit (1.25.0) base64 logger net-scp (>= 1.1.2) @@ -560,7 +560,7 @@ GEM railties (>= 6.0.0) stringio (3.2.0) stripe (18.0.1) - thor (1.4.0) + thor (1.5.0) thruster (0.1.17) thruster (0.1.17-aarch64-linux) thruster (0.1.17-arm64-darwin) @@ -584,9 +584,9 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - web-push (3.0.2) + web-push (3.1.0) jwt (~> 3.0) - openssl (~> 3.0) + openssl (>= 3.0) webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) From d5f270fe3a9a8838f29da94a4e8dbde81fc9f794 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 13 Jan 2026 12:06:43 +0100 Subject: [PATCH 07/14] Update development dependencies in SAAS lockfile --- Gemfile.saas.lock | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 37cabc12a..f340b4ae4 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -227,11 +227,11 @@ GEM bindex (0.8.1) bootsnap (1.20.1) msgpack (~> 1.2) - brakeman (7.1.1) + brakeman (7.1.2) racc builder (3.3.0) - bundler-audit (0.9.2) - bundler (>= 1.2.0, < 3) + bundler-audit (0.9.3) + bundler (>= 1.2.0) thor (~> 1.0) capybara (3.40.0) addressable @@ -252,7 +252,7 @@ GEM rexml crass (1.0.6) date (3.5.1) - debug (1.11.0) + debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) dotenv (3.2.0) @@ -263,7 +263,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - faker (3.5.2) + faker (3.5.3) i18n (>= 1.8.11, < 2) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) @@ -349,7 +349,7 @@ GEM stimulus-rails turbo-rails mittens (0.3.1) - mocha (2.8.2) + mocha (3.0.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) net-http-persistent (4.0.8) @@ -511,7 +511,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.38.0) + selenium-webdriver (4.39.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -577,8 +577,7 @@ GEM unicode-emoji (~> 4.1) unicode-emoji (4.1.0) uri (1.1.1) - vcr (6.3.1) - base64 + vcr (6.4.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) From 5e099c4c0647342d66df6f2adb961088811be115 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 13 Jan 2026 13:12:22 +0100 Subject: [PATCH 08/14] Delete pins belonging to cards in a board with revoked access As part of cleaning up inaccessible data in the board after revoking access. --- app/models/board/accessible.rb | 5 +++++ test/models/access_test.rb | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/app/models/board/accessible.rb b/app/models/board/accessible.rb index 2f668ae4f..e5b86dbe1 100644 --- a/app/models/board/accessible.rb +++ b/app/models/board/accessible.rb @@ -46,6 +46,7 @@ module Board::Accessible mentions_for_user(user).destroy_all notifications_for_user(user).destroy_all watches_for(user).destroy_all + pins_for(user).destroy_all end def watchers @@ -99,4 +100,8 @@ module Board::Accessible def watches_for(user) Watch.where(card: cards, user: user) end + + def pins_for(user) + Pin.where(card: cards, user: user) + end end diff --git a/test/models/access_test.rb b/test/models/access_test.rb index 10787e5d3..b4580420d 100644 --- a/test/models/access_test.rb +++ b/test/models/access_test.rb @@ -80,4 +80,27 @@ class AccessTest < ActiveSupport::TestCase assert_not card.watched_by?(kevin) end + + test "pins are destroyed when access is lost" do + kevin = users(:kevin) + board = boards(:writebook) + card = cards(:logo) # Kevin has pinned this card + + other_board = boards(:miltons_wish_list) + other_card = cards(:radio) + other_board.accesses.grant_to(kevin) + other_card.pin_by(kevin) + + assert card.pinned_by?(kevin) + assert other_card.pinned_by?(kevin) + + kevin_access = accesses(:writebook_kevin) + + perform_enqueued_jobs only: Board::CleanInaccessibleDataJob do + kevin_access.destroy + end + + assert_not card.pinned_by?(kevin) + assert other_card.pinned_by?(kevin), "Pin on other board should not be destroyed" + end end From fe57d9d01397596275a1160e9d22ba107cfc9b43 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 13 Jan 2026 11:55:01 -0600 Subject: [PATCH 09/14] Prevent comment content from overlapping with reaction button --- app/assets/stylesheets/comments.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 8fef15ebb..006cbb985 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -109,6 +109,13 @@ &:not:has(lexxy-editor) { padding-inline-end: var(--reaction-size); } + + /* Add an empty space so the last line of text doesn't overlap with the reaction button */ + .action-text-content > p:last-child::after { + content: ""; + display: inline-block; + inline-size: var(--reaction-size); + } } .comment__content { From c8059dea9042b98484a643c19f69af605b4195d5 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 13 Jan 2026 16:15:33 -0600 Subject: [PATCH 10/14] Disable card column buttons when active --- app/assets/stylesheets/card-columns.css | 1 + app/helpers/columns_helper.rb | 1 + app/views/cards/columns/edit.html.erb | 5 ++++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 4edb06c64..2c0c67ef9 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -849,6 +849,7 @@ --btn-background: var(--card-color); color: var(--color-ink-inverted); + opacity: 1 !important; @media (hover: hover) { &:hover { diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index a92fb2824..b3e98b30e 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -5,6 +5,7 @@ module ColumnsHelper card_triage_path(card, column_id: column), method: :post, class: [ "card__column-name btn", { "card__column-name--current": column == card.column && card.open? } ], + disabled: column == card.column, style: "--column-color: #{column.color}", form_class: "flex gap-half", data: { turbo_frame: "_top", scroll_to_target: column == card.column && card.open? ? "target" : nil } diff --git a/app/views/cards/columns/edit.html.erb b/app/views/cards/columns/edit.html.erb index 586fac20f..162bfbbeb 100644 --- a/app/views/cards/columns/edit.html.erb +++ b/app/views/cards/columns/edit.html.erb @@ -5,6 +5,7 @@ <%= button_to "Not now", card_not_now_path(@card), class: [ "card__column-name btn", { "card__column-name--current": @card.postponed? } ], style: "--column-color: var(--color-card-complete)", + disabled: @card.postponed?, role: "radio", aria: { checked: @card.postponed? }, data: { scroll_to_target: @card.postponed? ? "target" : nil }, @@ -12,7 +13,8 @@ <%= button_to "Maybe?", card_triage_path(@card), method: :delete, class: [ "card__column-name card__column-name--stream btn", { "card__column-name--current": @card.awaiting_triage? } ], - style: "--column-color: var(--color-card-default)", + style: "--column-color: var(--color-card-default)", + disabled: @card.awaiting_triage?, role: "radio", aria: { checked: @card.awaiting_triage? }, data: { scroll_to_target: @card.awaiting_triage? ? "target" : nil }, @@ -25,6 +27,7 @@ <%= button_to "Done", card_closure_path(@card), class: [ "card__column-name btn", { "card__column-name--current": @card.closed? } ], style: "--column-color: var(--color-card-complete)", + disabled: @card.closed?, role: "radio", aria: { checked: @card.closed? }, data: { scroll_to_target: @card.closed? ? "target" : nil }, From 235890e666be6303e9267ac238c0d8802c0e8bbe Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 7 Jan 2026 18:42:06 +0100 Subject: [PATCH 11/14] Use relative URLs for avatars and rich text attachments These URLs can be problematic or inconsistent, as we might end up with rich text content with beta URLs for attachments being used in production, or viceversa. It'll also be problematic when importing or exporting data between a self-hosted instance and the SaaS version. --- app/helpers/avatars_helper.rb | 2 +- config/initializers/active_storage.rb | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 7c064a650..8ae4346ac 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -42,6 +42,6 @@ module AvatarsHelper end def avatar_image_tag(user, **options) - image_tag user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options + image_tag user_avatar_path(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options end end diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index 3edd75cc8..586c292a1 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -8,6 +8,17 @@ ActiveSupport.on_load(:active_storage_blob) do end end +ActiveSupport.on_load(:action_text_content) do + # Install our extensions after ActionText::Engine's + ActiveSupport.on_load(:active_storage_blob) do + # Ensure all s have a "url" attribute that's a relative + # path (for portability across host name changes, beta environments, etc). + def to_rich_text_attributes(*) + super.merge url: Rails.application.routes.url_helpers.polymorphic_url(self, only_path: true) + end + end +end + # Don't configure replica connections for ActiveStorage::Record. # When ActiveStorage uses `connects_to`, it creates a separate connection pool # from ApplicationRecord. This causes after_commit callbacks to fire in From 527ffc42b98ffebeaad741999af758a01ed49594 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 7 Jan 2026 19:15:36 +0100 Subject: [PATCH 12/14] Use relative URLs where possible across all the app Skipping API responses, where we need absolute URLs, and those that are intended for sharing or external use: - account/join_codes/show.html.erb - Join code URL for sharing - boards/edit/_publication.html.erb - Publication URL for sharing - public/* views - Public page URLs and og:url meta tags - pwa/manifest.json.erb - PWA manifest needs absolute URLs For this, we had to replace `url_for` used with Active Storage variants and previews with the specific path helper (for Active Storage representations). --- app/controllers/users/avatars_controller.rb | 2 +- .../active_storage/blobs/web/_representation.html.erb | 8 ++++---- app/views/events/event/attachments/_attachment.html.erb | 4 ++-- app/views/my/menus/_accounts.html.erb | 2 +- app/views/my/menus/_settings.html.erb | 2 +- app/views/prompts/cards/_card.html.erb | 2 +- app/views/sessions/menus/show.html.erb | 2 +- app/views/users/show.html.erb | 2 +- app/views/webhooks/event.html.erb | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index 420dbe5b6..55d8f06dd 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -8,7 +8,7 @@ class Users::AvatarsController < ApplicationController if @user.system? redirect_to view_context.image_path("system_user.png") elsif @user.avatar.attached? - redirect_to rails_blob_url(@user.avatar_thumbnail, disposition: "inline") + redirect_to rails_blob_path(@user.avatar_thumbnail, disposition: "inline") elsif stale? @user, cache_control: cache_control render_initials end diff --git a/app/views/active_storage/blobs/web/_representation.html.erb b/app/views/active_storage/blobs/web/_representation.html.erb index ca934f469..82a1a2d96 100644 --- a/app/views/active_storage/blobs/web/_representation.html.erb +++ b/app/views/active_storage/blobs/web/_representation.html.erb @@ -12,14 +12,14 @@ height: height %> <% elsif blob.audio? %> <% elsif blob.variable? %> - <%= link_to url_for(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %> - <%= image_tag url_for(blob.variant(variant)), width: width, height: height %> + <%= link_to rails_representation_path(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %> + <%= image_tag rails_representation_path(blob.variant(variant)), width: width, height: height %> <% end %> <% elsif blob.previewable? %> - <%= image_tag url_for(blob.preview(variant)), width: width, height: height %> + <%= image_tag rails_representation_path(blob.preview(variant)), width: width, height: height %> <% else %> <%= blob.filename.extension&.downcase.presence || "unknown" %> <% end %> diff --git a/app/views/events/event/attachments/_attachment.html.erb b/app/views/events/event/attachments/_attachment.html.erb index c1eb254d0..fdb3b871b 100644 --- a/app/views/events/event/attachments/_attachment.html.erb +++ b/app/views/events/event/attachments/_attachment.html.erb @@ -3,9 +3,9 @@ <% height = attachment.metadata["height"] %> <% if attachment.previewable? %> - <%= image_tag url_for(attachment.preview(variant)), class: "attachment attachment--image", width: width, height: height %> + <%= image_tag rails_representation_path(attachment.preview(variant)), class: "attachment attachment--image", width: width, height: height %> <% elsif attachment.variable? %> - <%= image_tag url_for(attachment.variant(variant)), class: "attachment attachment--image", width: width, height: height %> + <%= image_tag rails_representation_path(attachment.variant(variant)), class: "attachment attachment--image", width: width, height: height %> <% else %>
<%= attachment.filename.extension&.downcase.presence || "unknown" %> diff --git a/app/views/my/menus/_accounts.html.erb b/app/views/my/menus/_accounts.html.erb index 2fdf9d8a5..6a25fb233 100644 --- a/app/views/my/menus/_accounts.html.erb +++ b/app/views/my/menus/_accounts.html.erb @@ -3,7 +3,7 @@ <%= collapsible_nav_section "Accounts" do %> <%# Bust cache 1 Dec 2025 %> <% accounts.each do |account| %> - <%= filter_place_menu_item landing_url(script_name: account.slug), account.name, "marker", current: account == Current.account, turbo: false %> + <%= filter_place_menu_item landing_path(script_name: account.slug), account.name, "marker", current: account == Current.account, turbo: false %> <% end %> <% end %> <% end %> diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb index 150b6794a..d1251b7c0 100644 --- a/app/views/my/menus/_settings.html.erb +++ b/app/views/my/menus/_settings.html.erb @@ -6,7 +6,7 @@ <%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %> <%= icon_tag "logout", class: "popup__icon" %> - <%= button_to session_url(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %> + <%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %> Sign out <% end %> <% end %> diff --git a/app/views/prompts/cards/_card.html.erb b/app/views/prompts/cards/_card.html.erb index 2308bc0c7..1dd4494c4 100644 --- a/app/views/prompts/cards/_card.html.erb +++ b/app/views/prompts/cards/_card.html.erb @@ -3,6 +3,6 @@ #<%= card.number %> <%= card.title %> diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb index 9c6867740..09383b72a 100644 --- a/app/views/sessions/menus/show.html.erb +++ b/app/views/sessions/menus/show.html.erb @@ -13,7 +13,7 @@ <% @accounts.each do |account| %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 7cf5bdf7c..771a8e924 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -41,7 +41,7 @@ <% if Current.user == @user %> - <%= button_to session_url(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %> + <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %> Sign out of Fizzy on this device <% end %> <% end %> diff --git a/app/views/webhooks/event.html.erb b/app/views/webhooks/event.html.erb index f2920e8d0..7766020a1 100644 --- a/app/views/webhooks/event.html.erb +++ b/app/views/webhooks/event.html.erb @@ -1,4 +1,4 @@ <%= @event.description_for(Current.user).to_plain_text %> <% if @event.eventable %> -<%= link_to "↗︎", polymorphic_url(@event.eventable) %> +<%= link_to "↗︎", polymorphic_url(@event.eventable, only_path: true) %> <% end %> From bf6fe75b7c5a2098b591e8563aa36c5ebd637880 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 12 Jan 2026 19:49:06 +0100 Subject: [PATCH 13/14] Add a script to migrate existing rich texts to relative URLs --- ...rt-absolute-attachment-urls-to-relative.rb | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 script/migrations/convert-absolute-attachment-urls-to-relative.rb diff --git a/script/migrations/convert-absolute-attachment-urls-to-relative.rb b/script/migrations/convert-absolute-attachment-urls-to-relative.rb new file mode 100644 index 000000000..009b6c4cb --- /dev/null +++ b/script/migrations/convert-absolute-attachment-urls-to-relative.rb @@ -0,0 +1,132 @@ +#!/usr/bin/env ruby + +# Convert absolute attachment URLs in rich text content to relative paths. +# This fixes URLs that were stored with full hostnames (e.g., https://app.fizzy.do/...) +# making them portable across beta environments and host changes. +# +# MUST BE RUN AFTER `decrypt!` when using ActiveRecord Encryption +# +# Run locally: +# bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help +# +# Run via Kamal: +# kamal app exec -d -p --reuse "bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help" +# +# Safe to re-run: won't modify already-relative URLs + +class ConvertAbsoluteAttachmentUrlsToRelative + # Match absolute URLs pointing to Active Storage routes, keeping the account slug + ABSOLUTE_URL_PATTERN = %r{https?://[^/]+(/\d+/rails/active_storage/[^"']+)} + + attr_reader :account, :dry_run + + def initialize(account_id: nil, dry_run: false) + @account = Account.find_by(external_account_id: account_id) + @dry_run = dry_run + end + + def run + puts "Converting absolute attachment URLs to relative paths" + puts dry_run ? "DRY RUN MODE - no changes will be saved" : "LIVE MODE - changes will be saved" + puts account ? "Only account: #{account.external_account_id} - #{account.name}" : "For **ALL ACCOUNTS**" + + puts "\nPress ENTER to continue running or CTRL-C to bail..." + gets + + puts "\nRunning..." + + # Suppress SQL logs + Rails.event.debug_mode = false + + seconds = Benchmark.realtime do + suppressing_turbo_broadcasts do + convert_urls + end + end + + puts "\n\n" + puts "Finished in %.2f seconds." % seconds + end + + private + def suppressing_turbo_broadcasts + Board.suppressing_turbo_broadcasts do + Card.suppressing_turbo_broadcasts do + yield + end + end + end + + def convert_urls + scanned = 0 + fixed = 0 + urls_converted = 0 + + action_texts_scope.find_each do |rich_text| + scanned += 1 + + body = rich_text.body + + edited = false + conversions = 0 + + body.send(:attachment_nodes).each do |node| + url = node["url"] + next unless url + + if url.match?(ABSOLUTE_URL_PATTERN) + node["url"] = url.gsub(ABSOLUTE_URL_PATTERN, '\1') + edited = true + conversions += 1 + end + end + + if edited + record = rich_text.record + puts " - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)" + + unless dry_run + rich_text.update! body: body.fragment.to_html + end + + fixed += 1 + urls_converted += conversions + end + end + + puts "\n\nConversion complete!" + puts " Rich texts examined: #{scanned}" + puts " Rich texts modified: #{fixed}" + puts " URLs converted: #{urls_converted}" + end + + def action_texts_scope + # Only examine rich texts that have embedded attachments + scope = ActionText::RichText.joins(:embeds_attachments) + scope = scope.where(account: account) if account + scope + end +end + +require "optparse" + +options = { account_id: nil, dry_run: true } + +OptionParser.new do |opts| + opts.banner = "Usage: bin/rails runner #{__FILE__} [options]" + + opts.on("-a", "--account ACCOUNT_ID", "Restrict to a specific account (external_account_id)") do |id| + options[:account_id] = id + end + + opts.on("--[no-]dry-run", "Run in dry-run mode (default: --dry-run)") do |v| + options[:dry_run] = v + end + + opts.on("-h", "--help", "Show this help message") do + puts opts + exit + end +end.parse! + +ConvertAbsoluteAttachmentUrlsToRelative.new(**options).run From bf224b19f52185f28fad9732a7e8838e13f19a64 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 14 Jan 2026 10:47:52 +0100 Subject: [PATCH 14/14] Add rollback script to convert relative URLs back to absolute --- ...rt-relative-attachment-urls-to-absolute.rb | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 script/migrations/convert-relative-attachment-urls-to-absolute.rb diff --git a/script/migrations/convert-relative-attachment-urls-to-absolute.rb b/script/migrations/convert-relative-attachment-urls-to-absolute.rb new file mode 100644 index 000000000..4b201f34f --- /dev/null +++ b/script/migrations/convert-relative-attachment-urls-to-absolute.rb @@ -0,0 +1,127 @@ +#!/usr/bin/env ruby + +# Rollback script: Convert relative attachment URLs back to absolute URLs. +# Use this if you need to rollback the relative URL changes and want to +# convert rich texts created during the rollout back to absolute URLs. +# +# Run locally: +# bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help +# +# Run via Kamal: +# kamal app exec -d -p --reuse "bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help" + +class ConvertRelativeAttachmentUrlsToAbsolute + # Match relative URLs pointing to Active Storage routes (with account slug) + RELATIVE_URL_PATTERN = %r{\A(/\d+/rails/active_storage/[^"']+)\z} + + attr_reader :host, :since + + def initialize(host:, since:) + @host = host + @since = since + end + + def run + puts "Converting relative attachment URLs to absolute URLs" + puts "Host: #{host}" + puts "Processing rich texts created since: #{since}" + + puts "\nPress ENTER to continue running or CTRL-C to bail..." + gets + + puts "\nRunning..." + + seconds = Benchmark.realtime do + suppressing_turbo_broadcasts do + convert_urls + end + end + + puts "\n\n" + puts "Finished in %.2f seconds." % seconds + end + + private + def suppressing_turbo_broadcasts + Board.suppressing_turbo_broadcasts do + Card.suppressing_turbo_broadcasts do + yield + end + end + end + + def convert_urls + scanned = 0 + fixed = 0 + urls_converted = 0 + + action_texts_scope.find_each do |rich_text| + scanned += 1 + + body = rich_text.body + + edited = false + conversions = 0 + + body.send(:attachment_nodes).each do |node| + url = node["url"] + next unless url + + if url.match?(RELATIVE_URL_PATTERN) + node["url"] = "#{host}#{url}" + edited = true + conversions += 1 + end + end + + if edited + record = rich_text.record + puts " - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)" + + rich_text.update! body: body.fragment.to_html + + fixed += 1 + urls_converted += conversions + end + end + + puts "\n\nConversion complete!" + puts " Rich texts examined: #{scanned}" + puts " Rich texts modified: #{fixed}" + puts " URLs converted: #{urls_converted}" + end + + def action_texts_scope + ActionText::RichText.joins(:embeds_attachments).where("action_text_rich_texts.created_at >= ?", since) + end +end + +require "optparse" +require "time" + +options = {} + +OptionParser.new do |opts| + opts.banner = "Usage: bin/rails runner #{__FILE__} [options]" + + opts.on("--host HOST", "Host to prepend (e.g., https://app.fizzy.do)") do |host| + options[:host] = host + end + + opts.on("--since TIME", "Process rich texts created since this time (ISO 8601 format)") do |time| + options[:since] = Time.parse(time) + end + + opts.on("-h", "--help", "Show this help message") do + puts opts + exit + end +end.parse! + +if options[:host].nil? || options[:since].nil? + puts "Error: --host and --since are required" + puts "Example: bin/rails runner #{__FILE__} --host https://app.fizzy.do --since 2026-01-14T10:00:00Z" + exit 1 +end + +ConvertRelativeAttachmentUrlsToAbsolute.new(**options).run