From e0625070ed5c4cc4d230ed54d98d673dde924368 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Wed, 19 Nov 2025 11:48:58 -0600 Subject: [PATCH 01/54] Don't show mini bubbles in Maybe column --- app/assets/stylesheets/card-columns.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 351d37eda..f17fe8f37 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -673,7 +673,6 @@ /* -------------------------------------------------------------------------- */ /* Surface a mini bubble if there are cards with bubbles inside */ - .cards--considering:has(.bubble:not([hidden])), .cards--doing.is-collapsed:has(.bubble:not([hidden])) { .cards__transition-container { --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); @@ -690,7 +689,7 @@ inline-size: 1em; inset: 0 0 auto auto; position: absolute; - translate: 0 0; + translate: 20% -20%; z-index: 1; } } From 47c05e6d6a84228ce874854deb3abc9a6657b1bc Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 19 Nov 2025 14:12:59 -0500 Subject: [PATCH 02/54] Restore basic auth on signup broken in 6e304958 --- app/controllers/sessions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 36d30d6fe..8c623b6ef 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,6 +1,6 @@ class SessionsController < ApplicationController # FIXME: Remove this before launch! - if Rails.env.remote? + unless Rails.env.local? http_basic_authenticate_with \ name: Rails.application.credentials.account_signup_http_basic_auth.name, password: Rails.application.credentials.account_signup_http_basic_auth.password, From 4636b31f06158e033b42d68ddcb714e6e67926d0 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 19 Nov 2025 13:22:41 -0500 Subject: [PATCH 03/54] Authorization `ensure_staff` uses identity.staff for use on untenanted routes --- app/controllers/concerns/authorization.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 9a3a8279a..46769992e 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -22,7 +22,7 @@ module Authorization end def ensure_staff - head :forbidden unless Current.user.staff? + head :forbidden unless Current.identity.staff? end def ensure_can_access_account From 362be963c0b517198bb6ecc05bb494fbbedf7e8c Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 19 Nov 2025 13:43:02 -0500 Subject: [PATCH 04/54] Add a very rudimentary admin dashboard. --- app/controllers/admin/stats_controller.rb | 19 ++++++ app/views/admin/stats/show.html.erb | 76 +++++++++++++++++++++++ config/routes.rb | 1 + 3 files changed, 96 insertions(+) create mode 100644 app/controllers/admin/stats_controller.rb create mode 100644 app/views/admin/stats/show.html.erb diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb new file mode 100644 index 000000000..68fc3f9e4 --- /dev/null +++ b/app/controllers/admin/stats_controller.rb @@ -0,0 +1,19 @@ +class Admin::StatsController < AdminController + disallow_account_scope + + layout "public" + + def show + @accounts_last_7_days = Account.where(created_at: 7.days.ago..).count + @accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count + + @identities_last_7_days = Identity.where(created_at: 7.days.ago..).count + @identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count + + @top_accounts = Account + .where("cards_count > 0") + .order(cards_count: :desc) + .limit(10) + .includes(users: :identity) + end +end diff --git a/app/views/admin/stats/show.html.erb b/app/views/admin/stats/show.html.erb new file mode 100644 index 000000000..678e78e71 --- /dev/null +++ b/app/views/admin/stats/show.html.erb @@ -0,0 +1,76 @@ +<% @page_title = "Account Statistics" %> + +<% content_for :header do %> +

<%= @page_title %>

+<% end %> + +
+
+
+

Recent Activity

+
+ +
+
+
Accounts Created
+
+
+
7 days
+
+ <%= @accounts_last_7_days %> +
+
+
+
24 hours
+
+ <%= @accounts_last_24_hours %> +
+
+
+
+ +
+
Identities Created
+
+
+
7 days
+
+ <%= @identities_last_7_days %> +
+
+
+
24 hours
+
+ <%= @identities_last_24_hours %> +
+
+
+
+
+
+ +
+
+

+ Top 10 Accounts by Card Count +

+
+ +
    + <% @top_accounts.each do |account| %> + <% admin_user = account.users.find { |u| u.role == "admin" } %> +
  • +
    + <%= account.name %> + • #<%= account.external_account_id %> • <%= admin_user&.identity&.email_address || "No admin" %> +
    + +
    + <%= number_with_delimiter(account.cards_count) %> + cards +
    +
  • + <% end %> +
+
+
diff --git a/config/routes.rb b/config/routes.rb index e165802e0..1fd25a50e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -227,5 +227,6 @@ Rails.application.routes.draw do namespace :admin do mount MissionControl::Jobs::Engine, at: "/jobs" + get "stats", to: "stats#show" end end From 2fe578c5e550b1a6d14b6cbf93269297f72c155f Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 19 Nov 2025 14:30:59 -0500 Subject: [PATCH 05/54] Small improvements to the stats dashboard --- app/controllers/admin/stats_controller.rb | 3 +-- app/views/admin/stats/show.html.erb | 9 ++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 68fc3f9e4..d9e64dc27 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -13,7 +13,6 @@ class Admin::StatsController < AdminController @top_accounts = Account .where("cards_count > 0") .order(cards_count: :desc) - .limit(10) - .includes(users: :identity) + .limit(20) end end diff --git a/app/views/admin/stats/show.html.erb b/app/views/admin/stats/show.html.erb index 678e78e71..8c06e9606 100644 --- a/app/views/admin/stats/show.html.erb +++ b/app/views/admin/stats/show.html.erb @@ -52,7 +52,7 @@

- Top 10 Accounts by Card Count + Top 20 Accounts by Card Count

@@ -60,9 +60,12 @@ <% @top_accounts.each do |account| %> <% admin_user = account.users.find { |u| u.role == "admin" } %>
  • -
    +
    <%= account.name %> - • #<%= account.external_account_id %> • <%= admin_user&.identity&.email_address || "No admin" %> + + • #<%= account.external_account_id %> • + <%= admin_user&.identity&.email_address || "No admin" %> +
    From 775a339e3af7d2f90344408c31fe3ddd78de73af Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 19 Nov 2025 14:36:46 -0500 Subject: [PATCH 06/54] Small tweaks to dashboard --- app/views/admin/stats/show.html.erb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/views/admin/stats/show.html.erb b/app/views/admin/stats/show.html.erb index 8c06e9606..cfa71997d 100644 --- a/app/views/admin/stats/show.html.erb +++ b/app/views/admin/stats/show.html.erb @@ -59,11 +59,15 @@
      <% @top_accounts.each do |account| %> <% admin_user = account.users.find { |u| u.role == "admin" } %> -
    • -
      +
    • +
      <%= account.name %> +
      - • #<%= account.external_account_id %> • + #<%= account.external_account_id %> • <%= admin_user&.identity&.email_address || "No admin" %>
      From c53b56c1db95ae85b64d5231a51d866d299390ca Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 19 Nov 2025 15:49:58 -0500 Subject: [PATCH 07/54] Display a helpful error message when join code is exhausted ref: https://app.fizzy.do/5986089/cards/3015 --- app/controllers/join_codes_controller.rb | 8 ++++++-- app/views/join_codes/inactive.html.erb | 15 +++++++++++++++ test/controllers/join_codes_controller_test.rb | 3 ++- 3 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 app/views/join_codes/inactive.html.erb diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb index 6d790c232..863522893 100644 --- a/app/controllers/join_codes_controller.rb +++ b/app/controllers/join_codes_controller.rb @@ -29,10 +29,14 @@ class JoinCodesController < ApplicationController private def set_join_code - @join_code ||= Account::JoinCode.active.find_by(code: params.expect(:code), account: Current.account) + @join_code ||= Account::JoinCode.find_by(code: params.expect(:code), account: Current.account) end def ensure_join_code_is_valid - head :not_found unless @join_code&.active? + if @join_code.nil? + head :not_found + elsif !@join_code.active? + render :inactive, status: :gone + end end end diff --git a/app/views/join_codes/inactive.html.erb b/app/views/join_codes/inactive.html.erb new file mode 100644 index 000000000..1bddb6e42 --- /dev/null +++ b/app/views/join_codes/inactive.html.erb @@ -0,0 +1,15 @@ +<% @page_title = "You can't join #{@join_code.account.name} right now." %> + +
      +

      <%= @page_title %>

      + +

      This join code has no invitations left on it.

      + +

      + <%= link_to "Check out Fizzy", "https://www.fizzy.do" %>. +

      +
      + +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/test/controllers/join_codes_controller_test.rb b/test/controllers/join_codes_controller_test.rb index 173e3b5d2..de126abd8 100644 --- a/test/controllers/join_codes_controller_test.rb +++ b/test/controllers/join_codes_controller_test.rb @@ -24,7 +24,8 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest get join_path(code: @join_code.code, script_name: @account.slug) - assert_response :not_found + assert_response :gone + assert_in_body "This join code has no invitations left on it" end test "create" do From 5bab67999e6812f5bde93c26f792bda6cc2c8022 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 19 Nov 2025 15:44:57 -0600 Subject: [PATCH 08/54] Add `Current.account` to cache key --- app/views/my/menus/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/my/menus/show.html.erb b/app/views/my/menus/show.html.erb index ee0f0287b..8a67e1484 100644 --- a/app/views/my/menus/show.html.erb +++ b/app/views/my/menus/show.html.erb @@ -63,7 +63,7 @@ <% accounts = Current.identity.accounts %> <% if accounts.many? %> - <% cache [ Current.identity, accounts ] do %> + <% cache [ Current.identity, accounts, Current.account ] do %> <%= collapsible_nav_section "Accounts" do %> <% accounts.each do |account| %> <%= filter_place_menu_item landing_url(script_name: account.slug), account.name, "marker", new_window: true, current: account == Current.account %> From 221c03c3df27083245e41b7ee08b807c695dbdb5 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Wed, 19 Nov 2025 16:02:25 -0600 Subject: [PATCH 09/54] Remove special Safari hack --- app/assets/stylesheets/inputs.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index c8b935d7c..7d9903996 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -48,10 +48,6 @@ margin: 0; } } - - @supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) { - font-size: max(16px, 1em) !important; - } } .input--file { From 96eec219b34890161813cd9f8422087f78d5b7c3 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Wed, 19 Nov 2025 16:04:11 -0600 Subject: [PATCH 10/54] Actually, keep it, but just target mobile Safari --- app/assets/stylesheets/inputs.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 7d9903996..109bfbb26 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -48,6 +48,13 @@ margin: 0; } } + + /* Target mobile Safari only */ + @supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) { + @media (hover: none) { + font-size: max(16px, 1em) !important; + } + } } .input--file { From 7e008864f2200feddfb87dceeb3b06eae44e7bf5 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 19 Nov 2025 16:04:21 -0600 Subject: [PATCH 11/54] Ensure back buttons collapse to icons on small screens --- app/views/account/join_codes/edit.html.erb | 3 ++- app/views/account/join_codes/show.html.erb | 3 ++- app/views/users/edit.html.erb | 3 ++- app/views/users/email_addresses/new.html.erb | 3 ++- app/views/webhooks/show.html.erb | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/views/account/join_codes/edit.html.erb b/app/views/account/join_codes/edit.html.erb index 44854c6d0..7f22b9318 100644 --- a/app/views/account/join_codes/edit.html.erb +++ b/app/views/account/join_codes/edit.html.erb @@ -2,8 +2,9 @@ <% content_for :header do %>
      - <%= link_to account_join_code_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + <%= link_to account_join_code_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + <%= icon_tag "arrow-left" %> Back to Invite link diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb index d16006019..b20d48b98 100644 --- a/app/views/account/join_codes/show.html.erb +++ b/app/views/account/join_codes/show.html.erb @@ -2,8 +2,9 @@ <% content_for :header do %>
      - <%= link_to account_settings_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + <%= link_to account_settings_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + <%= icon_tag "arrow-left" %> Back to Account Settings diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 5ac5e7807..1646c3944 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -2,8 +2,9 @@ <% content_for :header do %>
      - <%= link_to user_path(@user), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + <%= link_to user_path(@user), class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + <%= icon_tag "arrow-left" %> Back to profile diff --git a/app/views/users/email_addresses/new.html.erb b/app/views/users/email_addresses/new.html.erb index 193911672..8b0f1cbcf 100644 --- a/app/views/users/email_addresses/new.html.erb +++ b/app/views/users/email_addresses/new.html.erb @@ -2,8 +2,9 @@ <% content_for :header do %>
      - <%= link_to edit_user_path(@user, script_name: @user.account.slug), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + <%= link_to edit_user_path(@user, script_name: @user.account.slug), class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + <%= icon_tag "arrow-left" %> Back to My profile diff --git a/app/views/webhooks/show.html.erb b/app/views/webhooks/show.html.erb index 4dbef81d3..673df0df3 100644 --- a/app/views/webhooks/show.html.erb +++ b/app/views/webhooks/show.html.erb @@ -2,8 +2,9 @@ <% content_for :header do %>
      - <%= link_to board_webhooks_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + <%= link_to board_webhooks_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> + <%= icon_tag "arrow-left" %> Back to Webhooks From 0259cb860d488e8195d9d0ba6544e5391742fc29 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 19 Nov 2025 16:16:16 -0600 Subject: [PATCH 12/54] Fix awkward wrapping on mobile --- app/views/account/join_codes/show.html.erb | 2 +- app/views/cards/comments/edit.html.erb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb index b20d48b98..e9fd72d71 100644 --- a/app/views/account/join_codes/show.html.erb +++ b/app/views/account/join_codes/show.html.erb @@ -31,7 +31,7 @@ <% end %>
      -
      +
      <%= tag.button class: "btn btn--link", data: { controller: "copy-to-clipboard", action: "copy-to-clipboard#copy", copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: url } do %> diff --git a/app/views/cards/comments/edit.html.erb b/app/views/cards/comments/edit.html.erb index 5b020de6d..2eafce124 100644 --- a/app/views/cards/comments/edit.html.erb +++ b/app/views/cards/comments/edit.html.erb @@ -11,9 +11,9 @@ <%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %> <%= general_prompts(@card.board) %> <% end %> -
      +
      <%= form.button class: "btn btn--reversed", type: :submit do %> - Save changes + Save <% end %> <%= link_to card_comment_path(@card, @comment), class: "btn", data: { form_target: "cancel" } do %> Cancel From a15043a336a18eeb931c5ce650d871e3faf23586 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 19 Nov 2025 16:23:21 -0600 Subject: [PATCH 13/54] The symbol is hard to read, go back to text labels --- app/helpers/hotkeys_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/hotkeys_helper.rb b/app/helpers/hotkeys_helper.rb index 3e75081f9..810a0f83e 100644 --- a/app/helpers/hotkeys_helper.rb +++ b/app/helpers/hotkeys_helper.rb @@ -5,7 +5,7 @@ module HotkeysHelper if key == "ctrl" && platform.mac? "⌘" elsif key == "enter" - "⏎" + platform.mac? ? "return" : "enter" elsif key == "shift" "⇧" else From 25e2d98c35ce1b12374a3920bea55ee83361cadb Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 19 Nov 2025 16:26:55 -0600 Subject: [PATCH 14/54] Don't label these, they're expected and obvious (I think) --- app/views/cards/comments/_new.html.erb | 1 - app/views/cards/container/footer/_draft.html.erb | 1 - 2 files changed, 2 deletions(-) diff --git a/app/views/cards/comments/_new.html.erb b/app/views/cards/comments/_new.html.erb index 8d1426f5a..1485867f9 100644 --- a/app/views/cards/comments/_new.html.erb +++ b/app/views/cards/comments/_new.html.erb @@ -18,7 +18,6 @@ <%= form.button class: "comment__submit btn btn--reversed", data: { form_target: "submit" }, disabled: true do %> Post - <%= hotkey_label([ "ctrl", "enter" ]) %> <% end %> <% end %> diff --git a/app/views/cards/container/footer/_draft.html.erb b/app/views/cards/container/footer/_draft.html.erb index dbc95a6da..2516a3e21 100644 --- a/app/views/cards/container/footer/_draft.html.erb +++ b/app/views/cards/container/footer/_draft.html.erb @@ -3,7 +3,6 @@ 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", From a5f5b592c29f464ebba48a66006fc3f23e8f431b Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 19 Nov 2025 17:43:40 -0500 Subject: [PATCH 15/54] test: remove redundant session setup --- test/models/filter_test.rb | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/test/models/filter_test.rb b/test/models/filter_test.rb index 8531a7770..775f9448c 100644 --- a/test/models/filter_test.rb +++ b/test/models/filter_test.rb @@ -6,13 +6,11 @@ class FilterTest < ActiveSupport::TestCase end test "cards" do - Current.set session: sessions(:david) do - @new_board = Board.create! name: "Inaccessible Board", creator: users(:david) - @new_card = @new_board.cards.create!(status: "published") + @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") - cards(:logo).comments.create!(body: "I love haggis") - end + cards(:layout).comments.create!(body: "I hate haggis") + cards(:logo).comments.create!(body: "I love haggis") assert_not_includes users(:kevin).filters.new.cards, @new_card @@ -93,10 +91,8 @@ class FilterTest < ActiveSupport::TestCase end assert_nil Filter.find(filter.id).as_params[:tag_ids] - Current.set session: sessions(:david) do - assert_changes "Filter.exists?(filter.id)" do - boards(:writebook).destroy! - end + assert_changes "Filter.exists?(filter.id)" do + boards(:writebook).destroy! end end From 67da7cba7516fdeeda099a9b231d33b3712da6f4 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 19 Nov 2025 16:46:09 -0600 Subject: [PATCH 16/54] Update test --- test/helpers/hotkeys_helper_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/helpers/hotkeys_helper_test.rb b/test/helpers/hotkeys_helper_test.rb index 7201d7566..176c41645 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 "⏎+J", hotkey_label([ "enter", "J" ]) + assert_equal "return+J", hotkey_label([ "enter", "J" ]) end test "linux enter" do emulate_linux - assert_equal "⏎+J", hotkey_label([ "enter", "J" ]) + assert_equal "enter+J", hotkey_label([ "enter", "J" ]) end test "mac hyper" do From f35623b75f7a26781baa9950b9fd7cef6d3a5c50 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 19 Nov 2025 16:47:02 -0600 Subject: [PATCH 17/54] Capitalize --- test/helpers/hotkeys_helper_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/helpers/hotkeys_helper_test.rb b/test/helpers/hotkeys_helper_test.rb index 176c41645..ec96f6c9c 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 "Return+J", hotkey_label([ "enter", "J" ]) end test "linux enter" do emulate_linux - assert_equal "enter+J", hotkey_label([ "enter", "J" ]) + assert_equal "Enter+J", hotkey_label([ "enter", "J" ]) end test "mac hyper" do From 378705a30de6a484e984dd2eeb294b6e8709f125 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 19 Nov 2025 17:51:38 -0500 Subject: [PATCH 18/54] Deduplicate filter results when both a card and that card's comments match the search term, we don't want to show it multiple times. ref: https://app.fizzy.do/5986089/cards/3030 --- app/models/filter.rb | 2 +- test/models/filter/search_test.rb | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 test/models/filter/search_test.rb diff --git a/app/models/filter.rb b/app/models/filter.rb index f579c6bf0..7a398d03d 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -36,7 +36,7 @@ class Filter < ApplicationRecord result.mentioning(term, user: creator) end - result + result.distinct end end diff --git a/test/models/filter/search_test.rb b/test/models/filter/search_test.rb new file mode 100644 index 000000000..6adeebacb --- /dev/null +++ b/test/models/filter/search_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class Filter::SearchTest < ActiveSupport::TestCase + include SearchTestHelper + + test "deduplicate multiple results" do + user = users(:david) + + board = boards(:writebook) + card = board.cards.create!(title: "Duplicate results test", description: "Have you had any haggis today?", creator: user) + card.published! + card.comments.create(body: "I hate haggis.", creator: user) + card.comments.create(body: "I love haggis.", creator: user) + + filter = user.filters.new(terms: [ "haggis" ], indexed_by: "all", sorted_by: "latest") + + assert_equal [ card ], filter.cards.to_a + end +end From 99fe5ff934e16a7c45a3a3237fa5d4bc7029e5d5 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 19 Nov 2025 18:02:36 -0500 Subject: [PATCH 19/54] Make sure that comments show up in search properly when the search term appears in both the comment and the card description --- app/views/searches/_result.html.erb | 36 +++++++++----------- test/controllers/searches_controller_test.rb | 16 +++++++-- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/app/views/searches/_result.html.erb b/app/views/searches/_result.html.erb index 9463e9802..b2b131910 100644 --- a/app/views/searches/_result.html.erb +++ b/app/views/searches/_result.html.erb @@ -1,25 +1,21 @@
    • <%= link_to result.source, class: "search__result", data: { turbo_frame: "_top", action: "bar#reset" } do %> -
      -

      - # <%= result.card.number %> <%= result.card_title %> -

      +
      +

      + # <%= result.card.number %> <%= result.card_title %> +

      - <% if result.card_description.present? %> -
      - <%= result.card_description %> -
      - <% else - if result.comment_body.present? %> -
      - <%= avatar_preview_tag result.card.creator %> -
      - <%= result.comment_body %> -
      -
      - <% end %> - <% end %> -
      -
      <%= result.card.board.name %>
      + <% if result.comment.present? %> +
      + <%= avatar_preview_tag result.card.creator %> +
      <%= result.comment_body %>
      +
      + <% elsif result.card_id.present? %> +
      <%= result.card_description %>
      + <% end %> +
      +
      + <%= result.card.board.name %> +
      <% end %>
    • diff --git a/test/controllers/searches_controller_test.rb b/test/controllers/searches_controller_test.rb index 0cc0bceee..12c191567 100644 --- a/test/controllers/searches_controller_test.rb +++ b/test/controllers/searches_controller_test.rb @@ -5,9 +5,11 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest setup do @board.update!(all_access: true) - @card = @board.cards.create!(title: "Layout is broken", creator: @user) + @card = @board.cards.create!(title: "Layout is broken", description: "Look at this mess.", creator: @user) @comment_card = @board.cards.create!(title: "Some card", creator: @user) @comment_card.comments.create!(body: "overflowing text issue", creator: @user) + @comment2_card = @board.cards.create!(title: "Just haggis", description: "More haggis", creator: @user) + @comment2_card.comments.create!(body: "I love haggis", creator: @user) untenanted { sign_in_as @user } end @@ -15,11 +17,19 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest 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/ + assert_select "li .search__title", text: /Layout is broken/ + assert_select "li .search__excerpt", text: /Look at this mess/ # Searching by comment get search_path(q: "overflowing", script_name: "/#{@account.external_account_id}") - assert_select "li", text: /Some card/ + assert_select "li .search__title", text: /Some card/ + assert_select "li .search__excerpt--comment", text: /overflowing text issue/ + + # Searching for a term that appears in a card and in a comment + get search_path(q: "haggis", script_name: "/#{@account.external_account_id}") + assert_select "li .search__title", text: /Just haggis/, count: 2 # card title shows up in two entries + assert_select "li .search__excerpt", text: /More haggis/ # one entry for the card description + assert_select "li .search__excerpt--comment", text: /I love haggis/ # one entry for the comment # Searching by card id get search_path(q: @card.id, script_name: "/#{@account.external_account_id}") From bc73cf692d25fc5224d2061149ec55c1c94f258b Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 20 Nov 2025 09:20:48 +0000 Subject: [PATCH 20/54] Process notification recipients in consistent order We've seen some cases of deadlock when processing notifications, because locks are gathered on the users in different orders. Let's try sticking to a consistent order instead, which should cause the jobs to serialize rather than deadlock. --- app/models/notifier.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/notifier.rb b/app/models/notifier.rb index 81e69ce07..24c1b3b4e 100644 --- a/app/models/notifier.rb +++ b/app/models/notifier.rb @@ -14,7 +14,8 @@ class Notifier def notify if should_notify? - recipients.map do |recipient| + # Processing recipients in order avoids deadlocks if notifications overlap. + recipients.sort_by(&:id).map do |recipient| Notification.create! user: recipient, source: source, creator: creator end end From cc41a4222152aaaa4bb3cc109b989191f77514ed Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 20 Nov 2025 10:33:42 +0100 Subject: [PATCH 21/54] Fix missing avatars in notification bundle emails --- app/mailers/magic_link_mailer.rb | 5 ----- app/models/notification/bundle.rb | 8 +++++--- ...magic_link_preview.rb => magic_link_mailer_preview.rb} | 2 +- .../previews/notification/bundle_mailer_preview.rb | 5 +++-- test/mailers/previews/user_mailer_preview.rb | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) rename test/mailers/previews/{magic_link_preview.rb => magic_link_mailer_preview.rb} (80%) diff --git a/app/mailers/magic_link_mailer.rb b/app/mailers/magic_link_mailer.rb index f23cc4fbf..1008acfcf 100644 --- a/app/mailers/magic_link_mailer.rb +++ b/app/mailers/magic_link_mailer.rb @@ -5,9 +5,4 @@ class MagicLinkMailer < ApplicationMailer mail to: @identity.email_address, subject: "Your Fizzy verification code" end - - private - def default_url_options - Rails.application.config.action_mailer.default_url_options - end end diff --git a/app/models/notification/bundle.rb b/app/models/notification/bundle.rb index a4d87e72d..e6f63acaa 100644 --- a/app/models/notification/bundle.rb +++ b/app/models/notification/bundle.rb @@ -38,11 +38,13 @@ class Notification::Bundle < ApplicationRecord def deliver user.in_time_zone do - processing! + Current.with_account(user.account) do + processing! - Notification::BundleMailer.notification(self).deliver if deliverable? + Notification::BundleMailer.notification(self).deliver if deliverable? - delivered! + delivered! + end end end diff --git a/test/mailers/previews/magic_link_preview.rb b/test/mailers/previews/magic_link_mailer_preview.rb similarity index 80% rename from test/mailers/previews/magic_link_preview.rb rename to test/mailers/previews/magic_link_mailer_preview.rb index 88e087e5e..387de9507 100644 --- a/test/mailers/previews/magic_link_preview.rb +++ b/test/mailers/previews/magic_link_mailer_preview.rb @@ -1,4 +1,4 @@ -class MagicLinkPreview < ActionMailer::Preview +class MagicLinkMailerPreview < ActionMailer::Preview def magic_link identity = Identity.new email_address: "test@example.com" magic_link = MagicLink.new(identity: identity) diff --git a/test/mailers/previews/notification/bundle_mailer_preview.rb b/test/mailers/previews/notification/bundle_mailer_preview.rb index d1af823e0..4bd9c101c 100644 --- a/test/mailers/previews/notification/bundle_mailer_preview.rb +++ b/test/mailers/previews/notification/bundle_mailer_preview.rb @@ -1,6 +1,7 @@ class Notification::BundleMailerPreview < ActionMailer::Preview def notification - ApplicationRecord.current_tenant = "897362094" - Notification::BundleMailer.notification Notification::Bundle.take! + bundle = Notification::Bundle.all.sample + + Notification::BundleMailer.notification bundle end end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb index b9c601916..bc32d7e3c 100644 --- a/test/mailers/previews/user_mailer_preview.rb +++ b/test/mailers/previews/user_mailer_preview.rb @@ -1,7 +1,7 @@ class UserMailerPreview < ActionMailer::Preview def email_change_confirmation new_email_address = "new.email@example.com" - user = User.all.sample + user = User.active.sample token = user.send(:generate_email_address_change_token, to: new_email_address) UserMailer.email_change_confirmation( From 4fc85883eefe8cfd3bd6755c4b0cbed2dd9aca19 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 20 Nov 2025 09:36:18 +0000 Subject: [PATCH 22/54] Don't open account links in new window This allows switching account in-place inside the same PWA or browser tab. --- app/views/my/menus/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/my/menus/show.html.erb b/app/views/my/menus/show.html.erb index ee0f0287b..69594f0d4 100644 --- a/app/views/my/menus/show.html.erb +++ b/app/views/my/menus/show.html.erb @@ -66,7 +66,7 @@ <% cache [ Current.identity, accounts ] do %> <%= collapsible_nav_section "Accounts" do %> <% accounts.each do |account| %> - <%= filter_place_menu_item landing_url(script_name: account.slug), account.name, "marker", new_window: true, current: account == Current.account %> + <%= filter_place_menu_item landing_url(script_name: account.slug), account.name, "marker", current: account == Current.account %> <% end %> <% end %> <% end %> From 8a1cbdd0aeeb90623153bdbb7e11c19de2e4c81b Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 20 Nov 2025 10:49:29 +0100 Subject: [PATCH 23/54] Fix links in previews --- test/mailers/previews/notification/bundle_mailer_preview.rb | 2 +- test/mailers/previews/user_mailer_preview.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/test/mailers/previews/notification/bundle_mailer_preview.rb b/test/mailers/previews/notification/bundle_mailer_preview.rb index 4bd9c101c..148984576 100644 --- a/test/mailers/previews/notification/bundle_mailer_preview.rb +++ b/test/mailers/previews/notification/bundle_mailer_preview.rb @@ -1,7 +1,7 @@ class Notification::BundleMailerPreview < ActionMailer::Preview def notification bundle = Notification::Bundle.all.sample - + Current.account = bundle.account Notification::BundleMailer.notification bundle end end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb index bc32d7e3c..a4e1ee8c0 100644 --- a/test/mailers/previews/user_mailer_preview.rb +++ b/test/mailers/previews/user_mailer_preview.rb @@ -3,6 +3,7 @@ class UserMailerPreview < ActionMailer::Preview new_email_address = "new.email@example.com" user = User.active.sample token = user.send(:generate_email_address_change_token, to: new_email_address) + Current.account = user.account UserMailer.email_change_confirmation( email_address: new_email_address, From e8a7b19647d01b2cbe13925bc842139b934c789b Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 20 Nov 2025 15:09:43 +0100 Subject: [PATCH 24/54] Retry file analisys on FileNotFoundError --- .../active_storage_analyze_job_retries.rb | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 lib/rails_ext/active_storage_analyze_job_retries.rb diff --git a/lib/rails_ext/active_storage_analyze_job_retries.rb b/lib/rails_ext/active_storage_analyze_job_retries.rb new file mode 100644 index 000000000..1d9f2c9b8 --- /dev/null +++ b/lib/rails_ext/active_storage_analyze_job_retries.rb @@ -0,0 +1,23 @@ +# Avoid sporadic ActiveStorage::FileNotFoundError errors +# +# Our direct-uploads aren't really direct. They first get buffered by CloudFlare +# and then get sent to our storage servers. This can lead to situations where +# a form was submitted, and ActiveStorage::Attachment created, and an +# AnalyzeJob enqueued, but the file associated with the Blob doesn't yet exist +# in our storage service. +# +# A simple olution ot this problem is just to retry the job a few times with +# some backoff. +# +# Discussion: https://app.fizzy.do/5986089/cards/3056 +module ActiveStorageAnalyzeJobRetires + extend ActiveSupport::Concern + + included do + retry_on ActiveStorage::FileNotFoundError, attempts: 15, wait: 2.seconds + end +end + +ActiveSupport.on_load :active_storage_blob do + ActiveStorage::AnalyzeJob.prepend ActiveStorageAnalyzeJobRetires +end From 88750a3498c3bb7d1ede00a617b161462e79c0ce Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 20 Nov 2025 09:13:17 -0500 Subject: [PATCH 25/54] Prefer Time.current to Time.now --- app/models/card/statuses.rb | 2 +- test/models/card/statuses_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/card/statuses.rb b/app/models/card/statuses.rb index 2639d88b7..d339acd17 100644 --- a/app/models/card/statuses.rb +++ b/app/models/card/statuses.rb @@ -20,7 +20,7 @@ module Card::Statuses private def update_created_at_on_publication if will_save_change_to_status? && status_in_database.inquiry.drafted? - self.created_at = Time.now + self.created_at = Time.current end end end diff --git a/test/models/card/statuses_test.rb b/test/models/card/statuses_test.rb index 83179fbc4..ba801bfd9 100644 --- a/test/models/card/statuses_test.rb +++ b/test/models/card/statuses_test.rb @@ -65,6 +65,6 @@ class Card::StatusesTest < ActiveSupport::TestCase card.publish - assert_equal Time.now, card.created_at + assert_equal Time.current, card.created_at end end From 71f2ffbf59aa78a9461e5e4d5409a90547d65549 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 20 Nov 2025 09:32:42 -0500 Subject: [PATCH 26/54] Change "ADDED" to "DRAFTED" when rendering draft cards ref: https://app.fizzy.do/5986089/cards/3062 --- app/helpers/cards_helper.rb | 4 ++++ app/views/cards/display/common/_meta.html.erb | 2 +- app/views/cards/display/preview/_meta.html.erb | 3 +-- app/views/cards/display/public_preview/_meta.html.erb | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/helpers/cards_helper.rb b/app/helpers/cards_helper.rb index 574f6375f..ba61cb94e 100644 --- a/app/helpers/cards_helper.rb +++ b/app/helpers/cards_helper.rb @@ -33,6 +33,10 @@ module CardsHelper title.join(" ") end + def card_drafted_or_added(card) + card.drafted? ? "Drafted" : "Added" + end + def card_social_tags(card) tag.meta(property: "og:title", content: "#{card.title} | #{card.board.name}") + tag.meta(property: "og:description", content: format_excerpt(@card&.description, length: 200)) + diff --git a/app/views/cards/display/common/_meta.html.erb b/app/views/cards/display/common/_meta.html.erb index 27a8c9cf7..521230c0d 100644 --- a/app/views/cards/display/common/_meta.html.erb +++ b/app/views/cards/display/common/_meta.html.erb @@ -4,7 +4,7 @@
    - Added <%= local_datetime_tag(card.created_at, style: :daysago) %> + <%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %> diff --git a/app/views/cards/display/preview/_meta.html.erb b/app/views/cards/display/preview/_meta.html.erb index 8ba8e04d8..c06c9a3e1 100644 --- a/app/views/cards/display/preview/_meta.html.erb +++ b/app/views/cards/display/preview/_meta.html.erb @@ -4,8 +4,7 @@
    - <%= local_datetime_tag(card.created_at, style: :daysago) %> - <% "(Draft)" if card.drafted? %> + <%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %> diff --git a/app/views/cards/display/public_preview/_meta.html.erb b/app/views/cards/display/public_preview/_meta.html.erb index 948a6e73c..0547345f9 100644 --- a/app/views/cards/display/public_preview/_meta.html.erb +++ b/app/views/cards/display/public_preview/_meta.html.erb @@ -4,7 +4,7 @@
  • - Added <%= local_datetime_tag(card.created_at, style: :daysago) %> + <%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %> From 7ef7a8e49b6143a43fcf9f413785767cc511ae12 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 20 Nov 2025 10:19:04 -0500 Subject: [PATCH 27/54] Add a CLAUDE.md and a STYLE.md The CLAUDE.md file is a stripped-down version of a file originally generated by Claude. The STYLE.md file is inspired by an internal 37signals style guide. --- CLAUDE.md | 142 +++++++++++++++++++++++++++++++++++ README.md | 2 +- STYLE.md | 217 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 STYLE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a7141f6a6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,142 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What is Fizzy? + +Fizzy is a collaborative project management and issue tracking application built by 37signals/Basecamp. It's a kanban-style tool for teams to create and manage cards (tasks/issues) across boards, organize work into columns representing workflow stages, and collaborate via comments, mentions, and assignments. + +## Development Commands + +### Setup and Server +```bash +bin/setup # Initial setup (installs gems, creates DB, loads schema) +bin/dev # Start development server (runs on port 3006) +``` + +Development URL: http://fizzy.localhost:3006 +Login with: david@37signals.com (development fixtures), password will appear in the browser console + +### Testing +```bash +bin/rails test # Run unit tests (fast) +bin/rails test test/path/file_test.rb # Run single test file +bin/rails test:system # Run system tests (Capybara + Selenium) +bin/ci # Run full CI suite (style, security, tests) + +# For parallel test execution issues, use: +PARALLEL_WORKERS=1 bin/rails test +``` + +CI pipeline (`bin/ci`) runs: +1. Rubocop (style) +2. Bundler audit (gem security) +3. Importmap audit +4. Brakeman (security scan) +5. Application tests +6. System tests + +### Database +```bash +bin/rails db:fixtures:load # Load fixture data +bin/rails db:migrate # Run migrations +bin/rails db:reset # Drop, create, and load schema +``` + +### Other Utilities +```bash +bin/rails dev:email # Toggle letter_opener for email preview +bin/jobs # Manage Solid Queue jobs +bin/kamal deploy # Deploy (requires 1Password CLI for secrets) +``` + +## Architecture Overview + +### Multi-Tenancy (URL-Based) + +Fizzy uses **URL path-based multi-tenancy**: +- Each Account (tenant) has a unique `external_account_id` (7+ digits) +- URLs are prefixed: `/{account_id}/boards/...` +- Middleware (`AccountSlug::Extractor`) extracts the account ID from the URL and sets `Current.account` +- The slug is moved from `PATH_INFO` to `SCRIPT_NAME`, making Rails think it's "mounted" at that path +- All models include `account_id` for data isolation +- Background jobs automatically serialize and restore account context + +**Key insight**: This architecture allows multi-tenancy without subdomains or separate databases, making local development and testing simpler. + +### Authentication & Authorization + +**Passwordless magic link authentication**: +- Global `Identity` (email-based) can have `Users` in multiple Accounts +- Users belong to an Account and have roles: admin, member, system +- Sessions managed via signed cookies +- Board-level access control via `Access` records + +### Core Domain Models + +**Account** → The tenant/organization +- Has users, boards, cards, tags, webhooks +- Has entropy configuration for auto-postponement + +**Identity** → Global user (email) +- Can have Users in multiple Accounts +- Session management tied to Identity + +**User** → Account membership +- Belongs to Account and Identity +- Has role (admin/member/system) +- Board access via explicit `Access` records + +**Board** → Primary organizational unit +- Has columns for workflow stages +- Can be "all access" or selective +- Can be published publicly with shareable key + +**Card** → Main work item (task/issue) +- Sequential number within each Account +- Rich text description and attachments +- Lifecycle: triage → columns → closed/not_now +- Automatically postpones after inactivity ("entropy") + +**Event** → Records all significant actions +- Polymorphic association to changed object +- Drives activity timeline, notifications, webhooks +- Has JSON `particulars` for action-specific data + +### Entropy System + +Cards automatically "postpone" (move to "not now") after inactivity: +- Account-level default entropy period +- Board-level entropy override +- Prevents endless todo lists from accumulating +- Configurable via Account/Board settings + +### UUID Primary Keys + +All tables use UUIDs (UUIDv7 format, base36-encoded as 25-char strings): +- Custom fixture UUID generation maintains deterministic ordering for tests +- Fixtures are always "older" than runtime records +- `.first`/`.last` work correctly in tests + +### Background Jobs (Solid Queue) + +Database-backed job queue (no Redis): +- Custom `FizzyActiveJobExtensions` prepended to ActiveJob +- Jobs automatically capture/restore `Current.account` +- Mission Control::Jobs for monitoring + +Key recurring tasks (via `config/recurring.yml`): +- Deliver bundled notifications (every 30 min) +- Auto-postpone stale cards (hourly) +- Cleanup jobs for expired links, deliveries + +### Sharded Full-Text Search + +16-shard MySQL full-text search instead of Elasticsearch: +- Shards determined by account ID hash (CRC32) +- Search records denormalized for performance +- Models in `app/models/search/` + +## Coding style + +Please read the separate file `STYLE.md` for some guidance on coding style. diff --git a/README.md b/README.md index d05487602..ff8b48cb9 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ And then run the development server: bin/dev -You'll be able to access the app in development at http://development-tenant.fizzy.localhost:3006 +You'll be able to access the app in development at http://fizzy.localhost:3006 ## Running tests diff --git a/STYLE.md b/STYLE.md new file mode 100644 index 000000000..bf9dd3be3 --- /dev/null +++ b/STYLE.md @@ -0,0 +1,217 @@ + +# Style + +We aim to write code that is a pleasure to read, and we have a lot of opinions about how to do it well. Writing great code is an essential part of our programming culture, and we deliberately set a high bar for every code change anyone contributes. We care about how code reads, how code looks, and how code makes you feel when you read it. + +We love discussing code. If you have questions about how to write something, or if you detect some smell you are not quite sure how to solve, please ask away to other programmers. A Pull Request is a great way to do this. + +When writing new code, unless you are very familiar with our approach, try to find similar code elsewhere to look for inspiration. + +## Conditional returns + +In general, we prefer to use expanded conditionals over guard clauses. + +```ruby +# Bad +def todos_for_new_group + ids = params.require(:todolist)[:todo_ids] + return [] unless ids + @bucket.recordings.todos.find(ids.split(",")) +end + +# Good +def todos_for_new_group + if ids = params.require(:todolist)[:todo_ids] + @bucket.recordings.todos.find(ids.split(",")) + else + [] + end +end +``` + +This is because guard clauses can be hard to read, especially when they are nested. + +As an exception, we sometimes use guard clauses to return early from a method: + +* When the return is right at the beginning of the method. +* When the main method body is not trivial and involves several lines of code. + +```ruby +def after_recorded_as_commit(recording) + return if recording.parent.was_created? + + if recording.was_created? + broadcast_new_column(recording) + else + broadcast_column_change(recording) + end +end +``` + +## Methods ordering + +We order methods in classes in the following order: + +1. `class` methods +2. `public` methods with `initialize` at the top. +3. `private` methods + +## Invocation order + +We order methods vertically based on their invocation order. This helps us to understand the flow of the code. + +```ruby +class SomeClass + def some_method + method_1 + method_2 + end + + private + def method_1 + method_1_1 + method_1_2 + end + + def method_1_1 + # ... + end + + def method_1_2 + # ... + end + + def method_2 + method_2_1 + method_2_2 + end + + def method_2_1 + # ... + end + + def method_2_2 + # ... + end +end +``` + +## To bang or not to bang + +Should I call a method `do_something` or `do_something!`? + +As a general rule, we only use `!` for methods that have a correspondent counterpart without `!`. In particular, we don’t use `!` to flag destructive actions. There are plenty of destructive methods in Ruby and Rails that do not end with `!`. + +## Visibility modifiers + +We don't add a newline under visibility modifiers, and we indent the content under them. + +```ruby +class SomeClass + def some_method + # ... + end + + private + def some_private_method_1 + # ... + end + + def some_private_method_2 + # ... + end +end +``` + +If a module only has private methods, we mark it `private` at the top and add an extra new line after but don't indent. + +```ruby +class SomeModule + private + + def some_private_method + # ... + end +end +``` + +## CRUD operations from controllers + +In general, we favor a vanilla Rails approach to CRUD operations. We create and update models from Rails controllers passing the parameters directly to the model constructor or update method. We do not use services or form objects to handle these operations. + +There are exceptional scenarios where we need to perform more complex operations, and we use form objects or higher-level service methods to handle them. We use the same pattern for both creations and updates. + +Related to this, we prefer to avoid [nested attributes](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html). If you find yourself wanting to use `accepts_nested_attributes_for`, that's a good smell that you might want to consider using a form object instead. + +As an example, you can check how we create and update messages in HEY's: `MessagesController`: + +```ruby +class MessagesController < ApplicationController + def create + @entry = Entry.enter \ + new_message, + on: new_topic, + status: :drafted, + address: entry_addressed_param, + scheduled_delivery_at: entry_scheduled_delivery_at_param, + scheduled_bubble_up_on: entry_scheduled_bubble_up_on_param + + respond_to_saved_entry @entry + end + + def update + previously_scheduled = @entry.scheduled_delivery + + @entry.revise \ + message_params, + status: :drafted, + is_delivery_imminent: !entry_status_param.drafted?, + address: entry_addressed_param, + scheduled_delivery_at: entry_scheduled_delivery_at_param, + scheduled_bubble_up_on: entry_scheduled_bubble_up_on_param + + respond_to_saved_entry(@entry, previously_scheduled: previously_scheduled) + end +end + +class Entry < ApplicationRecord + def self.enter(*args, **kwargs) + Entry::Enter.new(*args, **kwargs).perform + end + + def revise(*args, **kwargs) + Entry::Revise.new(self, *args, **kwargs).perform + end +end +``` + +## Run async operations in jobs + +As a general rule, we write shallow job classes that delegate the logic itself to domain models: + +* We typically use the suffix `_later` to flag methods that enqueue a job. +* A common scenario is having a model class that enqueues a job that, when executed, invokes some method in that same class. In this case, we use the suffix `_now` for the regular synchronous method. + +```ruby +module Event::Relaying + extend ActiveSupport::Concern + + included do + after_create_commit :relay_later + end + + def relay_later + Event::RelayJob.perform_later(self) + end + + def relay_now + # ... + end +end + +class Event::RelayJob < ApplicationJob + def perform(event) + event.relay_now + end +end +``` From c33802d37bfa05c15455482084c5e9a1a9963e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanko=20Krtali=C4=87?= Date: Thu, 20 Nov 2025 17:47:49 +0100 Subject: [PATCH 28/54] Fix attachemnts (#1627) * Load keys from tenanted dbs * Implement sgid swapping * Fix sgid generation * Flip the logic to do lookups by tenant and id * Create missing blobs * Copy over variations * fix-active-storage-links: fix mentions * Fix variant generation * Fix avatars * Fix avatars --------- Co-authored-by: Mike Dalessio --- script/fix-active-storage-links.rb | 277 +++++++++++++++++++++++++++-- 1 file changed, 261 insertions(+), 16 deletions(-) mode change 100644 => 100755 script/fix-active-storage-links.rb diff --git a/script/fix-active-storage-links.rb b/script/fix-active-storage-links.rb old mode 100644 new mode 100755 index d2cb856bf..0cca27db6 --- a/script/fix-active-storage-links.rb +++ b/script/fix-active-storage-links.rb @@ -1,33 +1,278 @@ #!/usr/bin/env ruby require_relative "../config/environment" +require "pathname" 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 +class FixActiveStorage + attr_reader :skipped, :processed, :scope - blobs = rich_text.embeds.map(&:blob) + def initialize(scope = nil) + @scope = scope || ActionText::RichText.all.where("body LIKE '%/rails/active_storage/%'") + @mapping = {} - rich_text.body.send(:attachment_nodes).each do |node| - url = node["url"] - next unless url + @skipped = 0 + @processed = 0 - url_encoded_filename = url.split("/").last - filename = URI.decode_www_form_component(url_encoded_filename) + @users = {} + @memberships = {} + @attachments = {} + @identities = {} + end - counter += 1 - blob = blobs.select { |b| b.filename == filename } - raise "Multiple blobs with filename #{filename}" if blob.size > 1 - blob = blob.first + def ingest_blob_keys(db_path) + models = Models.new(db_path) - if blob - node["sgid"] = blob.attachable_sgid + @mapping[models.accounts.sole.external_account_id.to_s] = models.blobs.all.index_by(&:id) + @attachments[models.accounts.sole.external_account_id.to_s] = models.attachments.all.index_by(&:id) + @users[models.accounts.sole.external_account_id.to_s] = models.users.all.index_by(&:id) + end + + def ingest_untenanted(untenanted_db_path) + untenanted = Models.new(untenanted_db_path) + + @memberships = untenanted.memberships.all.index_by(&:id) + @identities = untenanted.identities.all.index_by(&:id) + end + + def perform + fix_avatars + fix_mentions + fix_attachments + + pp [ @processed, @skipped ] + end + + private + def fix_avatars + User.all.active.preload(:identity).find_each do |user| + tenant = user.account.external_account_id.to_s + email_address = user.identity.email_address + + membership = @memberships.values.find { |m| m.tenant == tenant && @identities[m.identity_id]&.email_address == email_address } + old_user = @users[tenant]&.values&.find { |u| u.membership_id == membership&.id } + + next if user.avatar.attached? || old_user.nil? + + old_avatar_attachment = @attachments[tenant]&.values&.find do |attachment| + attachment.record_type == "User" && attachment.record_id == old_user.id && attachment.name == "avatar" + end + + if old_avatar_attachment.nil? + @skipped += 1 + next + end + + + old_blob = old_avatar_attachment.blob + + if old_blob.nil? + @skipped += 1 + next + end + + new_blob = ActiveStorage::Blob.find_by(key: old_blob.key) + + unless new_blob + new_blob = ActiveStorage::Blob.create!( + account_id: user.account_id, + byte_size: old_blob.byte_size, + checksum: old_blob.checksum, + content_type: old_blob.content_type, + created_at: old_blob.created_at, + filename: old_blob.filename, + key: old_blob.key, + metadata: old_blob.metadata, + service_name: old_blob.service_name + ) + end + + ActiveStorage::Attachment.find_or_create_by!( + account_id: user.account_id, + blob_id: new_blob.id, + name: "avatar", + record: user + ) + + @processed += 1 + end + end + + def fix_mentions + ActionText::RichText.where("body LIKE '%action-text-attachment%'").find_each do |rich_text| + rich_text.body.send(:attachment_nodes).each do |node| + next unless node["content-type"] == "application/vnd.actiontext.mention" + + sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME) + + user = @users.dig(sgid.params[:tenant], sgid.model_id.to_i) + membership = @memberships[user&.membership_id] + unless membership + @skipped += 1 + next + end + identity = @identities[membership&.identity_id] + unless identity + @skipped += 1 + next + end + + new_identity = Identity.find_by(email_address: identity.email_address) + new_account = Account.find_by(external_account_id: sgid.params[:tenant]) + new_user = User.find_by(identity: new_identity, account: new_account) + new_sgid = new_user.attachable_sgid + + node["sgid"] = new_sgid.to_s + end + rich_text.save! + end + end + + def fix_attachments + scope.find_each do |rich_text| + next unless rich_text.body + + rich_text.body.send(:attachment_nodes).each do |node| + sgid = node["sgid"] + url = node["url"] + next if url.blank? || sgid.blank? + + sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME) + old_blob = @mapping.dig(sgid.params[:tenant], sgid.model_id.to_i) + + # There are some old files that got lost in a previous migration + unless old_blob + @skipped += 1 + next + end + + new_blob = ActiveStorage::Blob.find_by(key: old_blob.key) + + unless new_blob + new_blob = ActiveStorage::Blob.create!( + account_id: rich_text.account_id, + byte_size: old_blob.byte_size, + checksum: old_blob.checksum, + content_type: old_blob.content_type, + created_at: old_blob.created_at, + filename: old_blob.filename, + key: old_blob.key, + metadata: old_blob.metadata, + service_name: old_blob.service_name + ) + + ActiveStorage::Attachment.create!( + account_id: rich_text.account_id, + blob_id: new_blob.id, + created_at: old_blob.created_at, + name: "embeds", + record: rich_text + ) + end + + node["sgid"] = new_blob.attachable_sgid + + @processed += 1 + end + + rich_text.save! + rescue ActiveStorage::FileNotFoundError + @skipped += 1 + next + end + 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 - skipped += 1 + @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 accounts + @accounts ||= Class.new(application_record) do + self.table_name = "accounts" end end - rich_text.save! + def blobs + models = self + @blobs ||= Class.new(application_record) do + self.table_name = "active_storage_blobs" + + def attachments + models.attachments.where(blob_id: id) + end + end + end + + def attachments + models = self + @attachments ||= Class.new(application_record) do + self.table_name = "active_storage_attachments" + + def blob + models.blobs.find_by(id: blob_id) + end + end + end + + def users + @users ||= Class.new(application_record) do + self.table_name = "users" + end + end + + def identities + @identities ||= Class.new(application_record) do + self.table_name = "identities" + end + end + + def memberships + @memberships ||= Class.new(application_record) do + self.table_name = "memberships" + end + end end + +# tenanted_db_paths = ARGV +tenanted_db_paths = Dir[Rails.root.join("storage/tenants/production/*/db/main.sqlite3")] +untenanted_db_path = Rails.root.join("storage/untenanted/production.sqlite3") + +if tenanted_db_paths.empty? + $stderr.puts "Error: at least one tenanted database path is required" + $stderr.puts + exit 1 +end + +fix = FixActiveStorage.new + +fix.ingest_untenanted(untenanted_db_path) + +tenanted_db_paths.each_with_index do |db_path, _index| + fix.ingest_blob_keys(db_path) +end + +fix.perform From 7fe2ea203d65a20af849bcfa9fae7cc9707f491a Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Thu, 20 Nov 2025 11:03:10 -0600 Subject: [PATCH 29/54] Add hide-scrollbar utility and use on card titles --- app/assets/stylesheets/card-columns.css | 7 ------- app/assets/stylesheets/utilities.css | 10 ++++++++++ app/views/boards/show/_columns.html.erb | 2 +- app/views/cards/container/_title.html.erb | 2 +- app/views/cards/edit.html.erb | 2 +- app/views/public/boards/show/_columns.html.erb | 2 +- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index f17fe8f37..217f9a5c2 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -18,7 +18,6 @@ --progress-max-cards: 20; --progress-max-height: 50dvh; - -ms-overflow-style: none; /* Hide x-scrollbar on Edge */ container-type: inline-size; display: grid; gap: var(--column-gap); @@ -29,12 +28,6 @@ overflow-y: hidden; padding-block-end: var(--column-width-collapsed); position: relative; - scrollbar-width: none; /* Hide x-scrollbar on FF */ - - /* Hide x-scrollbar on Chrome/Safari/Opera */ - &::-webkit-scrollbar { - display: none; - } /* When it has something expanded */ &:has(.card-columns__left .cards:not(.is-collapsed), .card-columns__right .cards:not(.is-collapsed)) { diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 1c61680f1..5b73e1266 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -249,4 +249,14 @@ display: unset; } } + + .hide-scrollbar { + -ms-overflow-style: none; /* Edge */ + scrollbar-width: none; /* FF */ + + /* Chrome/Safari/Opera */ + &::-webkit-scrollbar { + display: none; + } + } } diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index 31938ef28..2d2553d12 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -1,4 +1,4 @@ -<%= tag.div class: "card-columns", data: { +<%= tag.div class: "card-columns hide-scrollbar", data: { controller: "collapsible-columns drag-and-drop drag-and-strum", drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item", drag_and_drop_hover_container_class: "drag-and-drop__hover-container", diff --git a/app/views/cards/container/_title.html.erb b/app/views/cards/container/_title.html.erb index 19c4727f4..a765364a8 100644 --- a/app/views/cards/container/_title.html.erb +++ b/app/views/cards/container/_title.html.erb @@ -20,7 +20,7 @@

    <%= form.label :title, class: "flex flex-column align-center autoresize__wrapper", data: { autoresize_target: "wrapper", autoresize_clone_value: "" } do %> <%= form.text_area :title, placeholder: "Name it…", - class: "card-field__title input input--textarea hide-focus-ring full-width borderless txt-align-start autoresize__textarea", + class: "card-field__title autoresize__textarea input input--textarea full-width borderless txt-align-start hide-focus-ring hide-scrollbar", autofocus: card.title.blank?, rows: 1, data: { autoresize_target: "textarea", action: "input->autoresize#resize auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent" } %> <% end %> diff --git a/app/views/cards/edit.html.erb b/app/views/cards/edit.html.erb index 99823d540..e243fb2b4 100644 --- a/app/views/cards/edit.html.erb +++ b/app/views/cards/edit.html.erb @@ -3,7 +3,7 @@ data: { controller: "autoresize form local-save", local_save_key_value: "card-#{@card.id}", action: "turbo:submit-end->local-save#submit" } do |form| %>

    <%= form.label :title, class: "flex flex-column align-center autoresize__wrapper", data: { autoresize_target: "wrapper", autoresize_clone_value: "" } do %> - <%= form.text_area :title, class: "card-field__title input input--textarea hide-focus-ring full-width borderless txt-align-start autoresize__textarea", + <%= form.text_area :title, class: "card-field__title autoresize__textarea input input--textarea full-width borderless txt-align-start hide-focus-ring hide-scrollbar", required: true, autofocus: true, placeholder: "Name it…", rows: 1, data: { autoresize_target: "textarea", action: "input->autoresize#resize keydown.enter->form#submit:prevent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel focus->form#select" } %> <% end %> diff --git a/app/views/public/boards/show/_columns.html.erb b/app/views/public/boards/show/_columns.html.erb index 03e4fcfe0..ad0656d2f 100644 --- a/app/views/public/boards/show/_columns.html.erb +++ b/app/views/public/boards/show/_columns.html.erb @@ -1,5 +1,5 @@ <%= turbo_frame_tag :cards_container do %> -
    +
    <%= render "public/boards/show/not_now", board: board %> From 1341830ed23bb60d4c6f7cb213c465cddb37a932 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 20 Nov 2025 14:39:09 -0500 Subject: [PATCH 30/54] Add total counts and restructure admin stats dashboard - Add total counts for accounts and identities alongside 7-day and 24-hour metrics - Change layout from horizontal to vertical stacking Co-Authored-By: Claude --- app/controllers/admin/stats_controller.rb | 2 ++ app/views/admin/stats/show.html.erb | 30 ++++++++++++++++------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index d9e64dc27..25d19ad31 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -4,9 +4,11 @@ class Admin::StatsController < AdminController layout "public" def show + @accounts_total = Account.count @accounts_last_7_days = Account.where(created_at: 7.days.ago..).count @accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count + @identities_total = Identity.count @identities_last_7_days = Identity.where(created_at: 7.days.ago..).count @identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count diff --git a/app/views/admin/stats/show.html.erb b/app/views/admin/stats/show.html.erb index cfa71997d..2fe7ce71a 100644 --- a/app/views/admin/stats/show.html.erb +++ b/app/views/admin/stats/show.html.erb @@ -6,14 +6,18 @@
    -
    -

    Recent Activity

    -
    - -
    -
    -
    Accounts Created
    +
    +
    +
    +

    Accounts Created

    +
    +
    +
    Total
    +
    + <%= @accounts_total %> +
    +
    7 days
    @@ -29,9 +33,17 @@
    -
    -
    Identities Created
    +
    +
    +

    Identities Created

    +
    +
    +
    Total
    +
    + <%= @identities_total %> +
    +
    7 days
    From 6e67a7787a9f6277220967498a88460d6db240cd Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 20 Nov 2025 14:56:23 -0500 Subject: [PATCH 31/54] Drop all foreign key constraints These have been a contributing cause to some deadlocks, but also this makes some operational tasks harder (data loading, data truncation) and has some performance impact on insert/update/delete operations. The team consensus is that we aren't relying on them, and so we're comfortable with the referential integrity risk. --- ...4700_remove_all_foreign_key_constraints.rb | 39 +++++++++++++++++++ db/schema.rb | 38 +----------------- 2 files changed, 40 insertions(+), 37 deletions(-) create mode 100644 db/migrate/20251120194700_remove_all_foreign_key_constraints.rb diff --git a/db/migrate/20251120194700_remove_all_foreign_key_constraints.rb b/db/migrate/20251120194700_remove_all_foreign_key_constraints.rb new file mode 100644 index 000000000..660177bee --- /dev/null +++ b/db/migrate/20251120194700_remove_all_foreign_key_constraints.rb @@ -0,0 +1,39 @@ +class RemoveAllForeignKeyConstraints < ActiveRecord::Migration[8.2] + def change + remove_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + remove_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + remove_foreign_key "board_publications", "boards" + remove_foreign_key "card_activity_spikes", "cards" + remove_foreign_key "card_goldnesses", "cards" + remove_foreign_key "card_not_nows", "cards" + remove_foreign_key "card_not_nows", "users" + remove_foreign_key "cards", "columns" + remove_foreign_key "closures", "cards" + remove_foreign_key "closures", "users" + remove_foreign_key "columns", "boards" + remove_foreign_key "comments", "cards" + remove_foreign_key "events", "boards" + remove_foreign_key "magic_links", "identities" + remove_foreign_key "mentions", "users", column: "mentionee_id" + remove_foreign_key "mentions", "users", column: "mentioner_id" + remove_foreign_key "notification_bundles", "users" + remove_foreign_key "notifications", "users" + remove_foreign_key "notifications", "users", column: "creator_id" + remove_foreign_key "pins", "cards" + remove_foreign_key "pins", "users" + remove_foreign_key "push_subscriptions", "users" + remove_foreign_key "search_queries", "users" + remove_foreign_key "sessions", "identities" + remove_foreign_key "steps", "cards" + remove_foreign_key "taggings", "cards" + remove_foreign_key "taggings", "tags" + remove_foreign_key "user_settings", "users" + remove_foreign_key "users", "identities" + remove_foreign_key "watches", "cards" + remove_foreign_key "watches", "users" + remove_foreign_key "webhook_delinquency_trackers", "webhooks" + remove_foreign_key "webhook_deliveries", "events" + remove_foreign_key "webhook_deliveries", "webhooks" + remove_foreign_key "webhooks", "boards" + end +end diff --git a/db/schema.rb b/db/schema.rb index abb762d69..bf2395905 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_11_17_202517) do +ActiveRecord::Schema[8.2].define(version: 2025_11_20_194700) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -748,40 +748,4 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_17_202517) do t.index ["account_id"], name: "index_webhooks_on_account_id" t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } end - - add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" - add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" - add_foreign_key "board_publications", "boards" - add_foreign_key "card_activity_spikes", "cards" - add_foreign_key "card_goldnesses", "cards" - add_foreign_key "card_not_nows", "cards" - add_foreign_key "card_not_nows", "users" - add_foreign_key "cards", "columns" - add_foreign_key "closures", "cards" - add_foreign_key "closures", "users" - add_foreign_key "columns", "boards" - add_foreign_key "comments", "cards" - add_foreign_key "events", "boards" - add_foreign_key "magic_links", "identities" - add_foreign_key "mentions", "users", column: "mentionee_id" - add_foreign_key "mentions", "users", column: "mentioner_id" - add_foreign_key "notification_bundles", "users" - add_foreign_key "notifications", "users" - add_foreign_key "notifications", "users", column: "creator_id" - add_foreign_key "pins", "cards" - add_foreign_key "pins", "users" - add_foreign_key "push_subscriptions", "users" - add_foreign_key "search_queries", "users" - add_foreign_key "sessions", "identities" - add_foreign_key "steps", "cards" - add_foreign_key "taggings", "cards" - add_foreign_key "taggings", "tags" - add_foreign_key "user_settings", "users" - add_foreign_key "users", "identities" - add_foreign_key "watches", "cards" - add_foreign_key "watches", "users" - add_foreign_key "webhook_delinquency_trackers", "webhooks" - add_foreign_key "webhook_deliveries", "events" - add_foreign_key "webhook_deliveries", "webhooks" - add_foreign_key "webhooks", "boards" end From 93a80ab8c29ecb9f5ade477d837acd888a70661b Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 20 Nov 2025 21:07:55 +0100 Subject: [PATCH 32/54] Add more detail to events --- app/helpers/notifications_helper.rb | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 029c328bb..06652c962 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -10,11 +10,19 @@ module NotificationsHelper name = event.creator.name case event_notification_action(event) - when "card_closed" then "Moved to Done by #{name}" - when "card_reopened" then "Reopened by #{name}" - when "card_published" then "Added by #{name}" - when "comment_created" then comment_notification_body(event) when "card_assigned" then "Assigned to #{event.assignees.none? ? "self" : event.assignees.pluck(:name).to_sentence}" + when "card_unassigned" then "Unassigned by #{name}" + when "card_published" then "Added by #{name}" + when "card_closed" then %(Moved to "Done" by #{name}) + when "card_reopened" then "Reopened by #{name}" + when "card_postponed" then %(Moved to "Not Now" by #{name}) + when "card_auto_postponed" then %(Closed as "Not Now" due to inactivity) + when "card_resumed" then "Resumed by #{name}" + when "card_title_changed" then "Renamed by #{name}" + when "card_board_changed" then "Moved by #{name}" + when "card_triaged" then "Triaged by #{name}" + when "card_sent_back_to_triage" then %(Moved back to "Maybe?" by #{name}) + when "comment_created" then comment_notification_body(event) else name end end From a48aa84f1179a99117bf4ab7463f1aca8b8950af Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 20 Nov 2025 21:11:02 +0100 Subject: [PATCH 33/54] Remove legacy event --- app/helpers/notifications_helper.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 06652c962..6be46ef76 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -17,7 +17,6 @@ module NotificationsHelper when "card_reopened" then "Reopened by #{name}" when "card_postponed" then %(Moved to "Not Now" by #{name}) when "card_auto_postponed" then %(Closed as "Not Now" due to inactivity) - when "card_resumed" then "Resumed by #{name}" when "card_title_changed" then "Renamed by #{name}" when "card_board_changed" then "Moved by #{name}" when "card_triaged" then "Triaged by #{name}" From 7200af879fc8926011a3e6d5ba3a3e6956b3afa2 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 20 Nov 2025 21:12:10 +0100 Subject: [PATCH 34/54] Revert "Add more detail to events summaries" --- app/helpers/notifications_helper.rb | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 6be46ef76..029c328bb 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -10,18 +10,11 @@ module NotificationsHelper name = event.creator.name case event_notification_action(event) - when "card_assigned" then "Assigned to #{event.assignees.none? ? "self" : event.assignees.pluck(:name).to_sentence}" - when "card_unassigned" then "Unassigned by #{name}" - when "card_published" then "Added by #{name}" - when "card_closed" then %(Moved to "Done" by #{name}) + when "card_closed" then "Moved to Done by #{name}" when "card_reopened" then "Reopened by #{name}" - when "card_postponed" then %(Moved to "Not Now" by #{name}) - when "card_auto_postponed" then %(Closed as "Not Now" due to inactivity) - when "card_title_changed" then "Renamed by #{name}" - when "card_board_changed" then "Moved by #{name}" - when "card_triaged" then "Triaged by #{name}" - when "card_sent_back_to_triage" then %(Moved back to "Maybe?" by #{name}) + when "card_published" then "Added by #{name}" when "comment_created" then comment_notification_body(event) + when "card_assigned" then "Assigned to #{event.assignees.none? ? "self" : event.assignees.pluck(:name).to_sentence}" else name end end From 3f21ea232990740f266ddeae0c41884ee4a1e018 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 20 Nov 2025 21:12:41 +0100 Subject: [PATCH 35/54] Revert "Revert "Add more detail to events summaries"" --- app/helpers/notifications_helper.rb | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 029c328bb..6be46ef76 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -10,11 +10,18 @@ module NotificationsHelper name = event.creator.name case event_notification_action(event) - when "card_closed" then "Moved to Done by #{name}" - when "card_reopened" then "Reopened by #{name}" - when "card_published" then "Added by #{name}" - when "comment_created" then comment_notification_body(event) when "card_assigned" then "Assigned to #{event.assignees.none? ? "self" : event.assignees.pluck(:name).to_sentence}" + when "card_unassigned" then "Unassigned by #{name}" + when "card_published" then "Added by #{name}" + when "card_closed" then %(Moved to "Done" by #{name}) + when "card_reopened" then "Reopened by #{name}" + when "card_postponed" then %(Moved to "Not Now" by #{name}) + when "card_auto_postponed" then %(Closed as "Not Now" due to inactivity) + when "card_title_changed" then "Renamed by #{name}" + when "card_board_changed" then "Moved by #{name}" + when "card_triaged" then "Triaged by #{name}" + when "card_sent_back_to_triage" then %(Moved back to "Maybe?" by #{name}) + when "comment_created" then comment_notification_body(event) else name end end From fb742d6d297dfeb5fc2aff91c91519197d9e3afb Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 20 Nov 2025 15:16:26 -0500 Subject: [PATCH 36/54] Make sure the migration to remove FK constraints is robust where we've already removed the constraints. --- ...4700_remove_all_foreign_key_constraints.rb | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/db/migrate/20251120194700_remove_all_foreign_key_constraints.rb b/db/migrate/20251120194700_remove_all_foreign_key_constraints.rb index 660177bee..682df8572 100644 --- a/db/migrate/20251120194700_remove_all_foreign_key_constraints.rb +++ b/db/migrate/20251120194700_remove_all_foreign_key_constraints.rb @@ -1,39 +1,39 @@ class RemoveAllForeignKeyConstraints < ActiveRecord::Migration[8.2] def change - remove_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" - remove_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" - remove_foreign_key "board_publications", "boards" - remove_foreign_key "card_activity_spikes", "cards" - remove_foreign_key "card_goldnesses", "cards" - remove_foreign_key "card_not_nows", "cards" - remove_foreign_key "card_not_nows", "users" - remove_foreign_key "cards", "columns" - remove_foreign_key "closures", "cards" - remove_foreign_key "closures", "users" - remove_foreign_key "columns", "boards" - remove_foreign_key "comments", "cards" - remove_foreign_key "events", "boards" - remove_foreign_key "magic_links", "identities" - remove_foreign_key "mentions", "users", column: "mentionee_id" - remove_foreign_key "mentions", "users", column: "mentioner_id" - remove_foreign_key "notification_bundles", "users" - remove_foreign_key "notifications", "users" - remove_foreign_key "notifications", "users", column: "creator_id" - remove_foreign_key "pins", "cards" - remove_foreign_key "pins", "users" - remove_foreign_key "push_subscriptions", "users" - remove_foreign_key "search_queries", "users" - remove_foreign_key "sessions", "identities" - remove_foreign_key "steps", "cards" - remove_foreign_key "taggings", "cards" - remove_foreign_key "taggings", "tags" - remove_foreign_key "user_settings", "users" - remove_foreign_key "users", "identities" - remove_foreign_key "watches", "cards" - remove_foreign_key "watches", "users" - remove_foreign_key "webhook_delinquency_trackers", "webhooks" - remove_foreign_key "webhook_deliveries", "events" - remove_foreign_key "webhook_deliveries", "webhooks" - remove_foreign_key "webhooks", "boards" + remove_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" rescue nil + remove_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" rescue nil + remove_foreign_key "board_publications", "boards" rescue nil + remove_foreign_key "card_activity_spikes", "cards" rescue nil + remove_foreign_key "card_goldnesses", "cards" rescue nil + remove_foreign_key "card_not_nows", "cards" rescue nil + remove_foreign_key "card_not_nows", "users" rescue nil + remove_foreign_key "cards", "columns" rescue nil + remove_foreign_key "closures", "cards" rescue nil + remove_foreign_key "closures", "users" rescue nil + remove_foreign_key "columns", "boards" rescue nil + remove_foreign_key "comments", "cards" rescue nil + remove_foreign_key "events", "boards" rescue nil + remove_foreign_key "magic_links", "identities" rescue nil + remove_foreign_key "mentions", "users", column: "mentionee_id" rescue nil + remove_foreign_key "mentions", "users", column: "mentioner_id" rescue nil + remove_foreign_key "notification_bundles", "users" rescue nil + remove_foreign_key "notifications", "users" rescue nil + remove_foreign_key "notifications", "users", column: "creator_id" rescue nil + remove_foreign_key "pins", "cards" rescue nil + remove_foreign_key "pins", "users" rescue nil + remove_foreign_key "push_subscriptions", "users" rescue nil + remove_foreign_key "search_queries", "users" rescue nil + remove_foreign_key "sessions", "identities" rescue nil + remove_foreign_key "steps", "cards" rescue nil + remove_foreign_key "taggings", "cards" rescue nil + remove_foreign_key "taggings", "tags" rescue nil + remove_foreign_key "user_settings", "users" rescue nil + remove_foreign_key "users", "identities" rescue nil + remove_foreign_key "watches", "cards" rescue nil + remove_foreign_key "watches", "users" rescue nil + remove_foreign_key "webhook_delinquency_trackers", "webhooks" rescue nil + remove_foreign_key "webhook_deliveries", "events" rescue nil + remove_foreign_key "webhook_deliveries", "webhooks" rescue nil + remove_foreign_key "webhooks", "boards" rescue nil end end From baadd484b49a6d3358cf3b71407b18933359c089 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 20 Nov 2025 21:28:41 +0100 Subject: [PATCH 37/54] Review triage --- app/helpers/notifications_helper.rb | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 6be46ef76..9e5a12869 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -7,22 +7,23 @@ module NotificationsHelper end def event_notification_body(event) - name = event.creator.name + creator = event.creator.name case event_notification_action(event) when "card_assigned" then "Assigned to #{event.assignees.none? ? "self" : event.assignees.pluck(:name).to_sentence}" - when "card_unassigned" then "Unassigned by #{name}" - when "card_published" then "Added by #{name}" - when "card_closed" then %(Moved to "Done" by #{name}) - when "card_reopened" then "Reopened by #{name}" - when "card_postponed" then %(Moved to "Not Now" by #{name}) + when "card_unassigned" then "Unassigned by #{creator}" + when "card_published" then "Added by #{creator}" + when "card_closed" then %(Moved to "Done" by #{creator}) + when "card_reopened" then "Reopened by #{creator}" + when "card_postponed" then %(Moved to "Not Now" by #{creator}) when "card_auto_postponed" then %(Closed as "Not Now" due to inactivity) - when "card_title_changed" then "Renamed by #{name}" - when "card_board_changed" then "Moved by #{name}" - when "card_triaged" then "Triaged by #{name}" - when "card_sent_back_to_triage" then %(Moved back to "Maybe?" by #{name}) + when "card_title_changed" then "Renamed by #{creator}" + when "card_board_changed" then "Moved by #{creator}" + when "card_triaged" + %(Moved to "#{event.particulars.dig("particulars", "column")}" by #{creator}) + when "card_sent_back_to_triage" then %(Moved back to "Maybe?" by #{creator}) when "comment_created" then comment_notification_body(event) - else name + else creator end end From a5b58e47fe16fa49da2032be8c5c3401108ec69b Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 20 Nov 2025 21:32:43 +0100 Subject: [PATCH 38/54] Remove unclosed span --- app/views/notifications/notification/event/_body.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/notifications/notification/event/_body.html.erb b/app/views/notifications/notification/event/_body.html.erb index 25235372f..79f9c7fa2 100644 --- a/app/views/notifications/notification/event/_body.html.erb +++ b/app/views/notifications/notification/event/_body.html.erb @@ -5,5 +5,5 @@
    - <%= event_notification_body(event) %> + <%= event_notification_body(event) %>
    From c052839bd6261702b499a3f23a96bbcba73f0c54 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 20 Nov 2025 21:37:41 +0100 Subject: [PATCH 39/54] Removed quotes since we were not using them --- app/helpers/notifications_helper.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 9e5a12869..e7731127b 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -13,15 +13,14 @@ module NotificationsHelper when "card_assigned" then "Assigned to #{event.assignees.none? ? "self" : event.assignees.pluck(:name).to_sentence}" when "card_unassigned" then "Unassigned by #{creator}" when "card_published" then "Added by #{creator}" - when "card_closed" then %(Moved to "Done" by #{creator}) + when "card_closed" then "Moved to Done by #{creator}" when "card_reopened" then "Reopened by #{creator}" - when "card_postponed" then %(Moved to "Not Now" by #{creator}) - when "card_auto_postponed" then %(Closed as "Not Now" due to inactivity) + when "card_postponed" then "Moved to Not Now by #{creator}" + when "card_auto_postponed" then "Closed as Not Now due to inactivity" when "card_title_changed" then "Renamed by #{creator}" when "card_board_changed" then "Moved by #{creator}" - when "card_triaged" - %(Moved to "#{event.particulars.dig("particulars", "column")}" by #{creator}) - when "card_sent_back_to_triage" then %(Moved back to "Maybe?" by #{creator}) + when "card_triaged" then "Moved to #{event.particulars.dig("particulars", "column")} by #{creator}" + when "card_sent_back_to_triage" then "Moved back to Maybe? by #{creator}" when "comment_created" then comment_notification_body(event) else creator end From 1bbb6d0c037d026af1fa4a8b91883d4fcb547af1 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 20 Nov 2025 11:28:58 -0500 Subject: [PATCH 40/54] Fix race condition in Card::ActivitySpike::DetectionJob The check-then-act pattern in `register_activity_spike` has been replaced with `find_or_create_by!` to eliminate the race condition that could lead to creating multiple activity spikes for a card. To support this change, the `card_id` index on `cards_activity_spikes` has been made unique. ref: https://app.fizzy.do/5986089/cards/3063 --- app/models/card/activity_spike/detector.rb | 6 +----- ..._index_to_card_activity_spikes_on_card_id.rb | 17 +++++++++++++++++ db/schema.rb | 4 ++-- .../models/card/activity_spike/detector_test.rb | 15 +++++++++++++++ 4 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb diff --git a/app/models/card/activity_spike/detector.rb b/app/models/card/activity_spike/detector.rb index b589e0c26..b533318e3 100644 --- a/app/models/card/activity_spike/detector.rb +++ b/app/models/card/activity_spike/detector.rb @@ -21,11 +21,7 @@ class Card::ActivitySpike::Detector def register_activity_spike Card.suppressing_turbo_broadcasts do - if card.activity_spike - card.activity_spike.touch - else - card.create_activity_spike! - end + Card::ActivitySpike.find_or_create_by!(card: card).touch end end diff --git a/db/migrate/20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb b/db/migrate/20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb new file mode 100644 index 000000000..850c8e604 --- /dev/null +++ b/db/migrate/20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb @@ -0,0 +1,17 @@ +class AddUniqueIndexToCardActivitySpikesOnCardId < ActiveRecord::Migration[8.2] + def change + reversible do |dir| + dir.up do + execute <<-SQL + DELETE s1 FROM card_activity_spikes s1 + INNER JOIN card_activity_spikes s2 + WHERE s1.card_id = s2.card_id + AND s1.updated_at < s2.updated_at + SQL + end + end + + remove_index :card_activity_spikes, :card_id + add_index :card_activity_spikes, :card_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index bf2395905..e6a403fee 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_11_20_194700) do +ActiveRecord::Schema[8.2].define(version: 2025_11_20_203100) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -150,7 +150,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_20_194700) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_card_activity_spikes_on_account_id" - t.index ["card_id"], name: "index_card_activity_spikes_on_card_id" + t.index ["card_id"], name: "index_card_activity_spikes_on_card_id", unique: true end create_table "card_engagements", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| diff --git a/test/models/card/activity_spike/detector_test.rb b/test/models/card/activity_spike/detector_test.rb index 0eebe479e..6fe6183b7 100644 --- a/test/models/card/activity_spike/detector_test.rb +++ b/test/models/card/activity_spike/detector_test.rb @@ -39,6 +39,21 @@ class Card::ActivitySpike::DetectorTest < ActiveSupport::TestCase assert @card.reload.activity_spike.updated_at > original_last_spike_at end + test "concurrent spike creation should not create multiple spikes for a card" do + multiple_people_comment_on(@card) + @card.activity_spike&.destroy + + 5.times.map do + Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + Card.find(@card.id).detect_activity_spikes + end + end + end.each(&:join) + + assert_equal 1, Card::ActivitySpike.where(card: @card).count + end + private def assert_activity_spike_detected(card: @card) assert card.activity_spike.blank? From 2c2a1c4b4a429ceb95c844b5f6982d4acc2d236f Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 20 Nov 2025 18:47:42 -0500 Subject: [PATCH 41/54] Make sure jobs are enqueued only after all transactions are committed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This should fix the recurring ActiveStorage::FileNotFoundError errors from ActiveStorage::AnalyzeJob. The issue is that, when images are not direct uploaded (e.g. a card background image or avatar image), the AnalyzeJob has been getting enqueued before the file itself was uploaded to blob storage. Setting ActiveJob::Base.enqueue_after_transaction_commit = true makes sure that jobs are always enqueued only once *all* transactions have been committed. This will be the Rails default at some point, but for now we still need to explicitly set it. Big thanks to @jeremy for the assist. 👏 --- config/initializers/active_job.rb | 1 + .../active_storage_analyze_job_retries.rb | 23 ------------------- 2 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 lib/rails_ext/active_storage_analyze_job_retries.rb diff --git a/config/initializers/active_job.rb b/config/initializers/active_job.rb index 340585f3b..d8eed109d 100644 --- a/config/initializers/active_job.rb +++ b/config/initializers/active_job.rb @@ -6,6 +6,7 @@ module FizzyActiveJobExtensions prepended do attr_reader :account + self.enqueue_after_transaction_commit = true end def initialize(...) diff --git a/lib/rails_ext/active_storage_analyze_job_retries.rb b/lib/rails_ext/active_storage_analyze_job_retries.rb deleted file mode 100644 index 1d9f2c9b8..000000000 --- a/lib/rails_ext/active_storage_analyze_job_retries.rb +++ /dev/null @@ -1,23 +0,0 @@ -# Avoid sporadic ActiveStorage::FileNotFoundError errors -# -# Our direct-uploads aren't really direct. They first get buffered by CloudFlare -# and then get sent to our storage servers. This can lead to situations where -# a form was submitted, and ActiveStorage::Attachment created, and an -# AnalyzeJob enqueued, but the file associated with the Blob doesn't yet exist -# in our storage service. -# -# A simple olution ot this problem is just to retry the job a few times with -# some backoff. -# -# Discussion: https://app.fizzy.do/5986089/cards/3056 -module ActiveStorageAnalyzeJobRetires - extend ActiveSupport::Concern - - included do - retry_on ActiveStorage::FileNotFoundError, attempts: 15, wait: 2.seconds - end -end - -ActiveSupport.on_load :active_storage_blob do - ActiveStorage::AnalyzeJob.prepend ActiveStorageAnalyzeJobRetires -end From ad7eb594d3c9b581f1b075fd3044865c4f833640 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 20 Nov 2025 21:31:40 -0600 Subject: [PATCH 42/54] Match copy coming in #1663 --- app/helpers/notifications_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index e7731127b..355f60550 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -16,7 +16,7 @@ module NotificationsHelper when "card_closed" then "Moved to Done by #{creator}" when "card_reopened" then "Reopened by #{creator}" when "card_postponed" then "Moved to Not Now by #{creator}" - when "card_auto_postponed" then "Closed as Not Now due to inactivity" + when "card_auto_postponed" then "Moved to Not Now due to inactivity" when "card_title_changed" then "Renamed by #{creator}" when "card_board_changed" then "Moved by #{creator}" when "card_triaged" then "Moved to #{event.particulars.dig("particulars", "column")} by #{creator}" From b1762ea6b6efc158ff87a51cb10e0dceb7151281 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 20 Nov 2025 21:45:26 -0600 Subject: [PATCH 43/54] Display code right in the subject line --- app/mailers/magic_link_mailer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/mailers/magic_link_mailer.rb b/app/mailers/magic_link_mailer.rb index 1008acfcf..617286f50 100644 --- a/app/mailers/magic_link_mailer.rb +++ b/app/mailers/magic_link_mailer.rb @@ -3,6 +3,6 @@ class MagicLinkMailer < ApplicationMailer @magic_link = magic_link @identity = @magic_link.identity - mail to: @identity.email_address, subject: "Your Fizzy verification code" + mail to: @identity.email_address, subject: "Your Fizzy code is #{ @magic_link.code }" end end From dca002bd6478970f01784b780333ff977952f6db Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 20 Nov 2025 21:51:07 -0600 Subject: [PATCH 44/54] Update test --- test/mailers/magic_link_mailer_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mailers/magic_link_mailer_test.rb b/test/mailers/magic_link_mailer_test.rb index 7d389587f..2c8d692eb 100644 --- a/test/mailers/magic_link_mailer_test.rb +++ b/test/mailers/magic_link_mailer_test.rb @@ -10,7 +10,7 @@ class MagicLinkMailerTest < ActionMailer::TestCase end assert_equal [ "kevin@37signals.com" ], email.to - assert_equal "Your Fizzy verification code", email.subject + assert_equal "Your Fizzy code is #{ magic_link.code }", email.subject assert_match magic_link.code, email.body.encoded end end From 9bae30f77133bb25c6ad48e594a28d80d17f22f4 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 20 Nov 2025 22:03:54 -0600 Subject: [PATCH 45/54] Show account if you have multiple It's hard to know which account email notifications are coming from --- app/mailers/notification/bundle_mailer.rb | 2 +- .../mailers/notification/bundle_mailer/notification.html.erb | 4 +++- .../mailers/notification/bundle_mailer/notification.text.erb | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/mailers/notification/bundle_mailer.rb b/app/mailers/notification/bundle_mailer.rb index c821a1274..db3c81bc7 100644 --- a/app/mailers/notification/bundle_mailer.rb +++ b/app/mailers/notification/bundle_mailer.rb @@ -11,6 +11,6 @@ class Notification::BundleMailer < ApplicationMailer mail \ to: bundle.user.identity.email_address, - subject: "Latest Activity in Fizzy" + subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: Latest Activity" end end diff --git a/app/views/mailers/notification/bundle_mailer/notification.html.erb b/app/views/mailers/notification/bundle_mailer/notification.html.erb index 616e49a8c..a2353af5d 100644 --- a/app/views/mailers/notification/bundle_mailer/notification.html.erb +++ b/app/views/mailers/notification/bundle_mailer/notification.html.erb @@ -1,7 +1,9 @@

    Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>

    -

    You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %>.

    +

    + You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>. +

    <% @notifications.group_by(&:card).each do |card, notifications| %>

    <%= card.board.name %>

    diff --git a/app/views/mailers/notification/bundle_mailer/notification.text.erb b/app/views/mailers/notification/bundle_mailer/notification.text.erb index 0cd720f94..4a6f84d30 100644 --- a/app/views/mailers/notification/bundle_mailer/notification.text.erb +++ b/app/views/mailers/notification/bundle_mailer/notification.text.erb @@ -1,5 +1,5 @@ Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %> -You have <%= pluralize @notifications.count, "new notification" %>. +You have <%= pluralize @notifications.count, "new notification" %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>. <% @notifications.group_by(&:card).each do |card, notifications| %> -------------------------------------------------------------------------------- From 649888dbd46e9b64c03653d66a134eb9c1006013 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 21 Nov 2025 08:52:10 +0100 Subject: [PATCH 46/54] Making the frame permanent with an id would reconnect the editor causing the dup This is something we need to fix in Lexxy for good, but for now this patch will do it. https://github.com/basecamp/lexxy/issues/263 --- app/views/cards/container/_title.html.erb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/cards/container/_title.html.erb b/app/views/cards/container/_title.html.erb index a765364a8..47a6a0924 100644 --- a/app/views/cards/container/_title.html.erb +++ b/app/views/cards/container/_title.html.erb @@ -1,5 +1,6 @@ <% if card.published? %> - <%= turbo_frame_tag card, :edit, data: { turbo_permanent: true } do %> +
    + <%= turbo_frame_tag card, :edit do %>

    <%= link_to card.title, edit_card_path(card), class: "card__title-link" %>

    @@ -15,6 +16,7 @@ e <% end %> <% end %> +
    <% else %> <%= form_with model: card, id: "card_form", data: { controller: "autoresize auto-save" } do |form| %>

    From e813b7eaf0ca998acb65a9c0a1d6a015ffa6de45 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 21 Nov 2025 09:32:02 +0100 Subject: [PATCH 47/54] Invalidate HTTP caching when the cards change https://app.fizzy.do/5986089/cards/3067 --- app/controllers/my/pins_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/my/pins_controller.rb b/app/controllers/my/pins_controller.rb index 27e14535b..4468c75d5 100644 --- a/app/controllers/my/pins_controller.rb +++ b/app/controllers/my/pins_controller.rb @@ -1,6 +1,6 @@ class My::PinsController < ApplicationController def index @pins = Current.user.pins.includes(:card).ordered.limit(20) - fresh_when @pins + fresh_when etag: [ @pins, @pins.collect(&:card) ] end end From 62d4998f27cb97a4f719702b9acb891363a1fa44 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 21 Nov 2025 09:35:25 +0100 Subject: [PATCH 48/54] We stopped using load_async in the code base --- config/application.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/config/application.rb b/config/application.rb index 4a6f1f378..93b3acabf 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,9 +24,6 @@ module Fizzy # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") - # enable load_async - config.active_record.async_query_executor = :global_thread_pool - # include the tenant in query logs config.active_record.query_log_tags_enabled = true config.active_record.query_log_tags = [ :tenant ] From f66da318aadc743427da148563b26b9e3f081297 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 21 Nov 2025 09:39:33 +0100 Subject: [PATCH 49/54] No longer needed now that we are back to a single DB --- config/application.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/config/application.rb b/config/application.rb index 93b3acabf..a2b45a66a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,10 +24,6 @@ module Fizzy # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") - # include the tenant in query logs - config.active_record.query_log_tags_enabled = true - config.active_record.query_log_tags = [ :tenant ] - # Enable debug mode for Rails event logging so we get SQL query logs. # This was made necessary by the change in https://github.com/rails/rails/pull/55900 config.after_initialize do From 0a88565bd7dee1fae766ec4d9edc11871c329a36 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 21 Nov 2025 09:39:59 +0100 Subject: [PATCH 50/54] Slim down --- config/application.rb | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/config/application.rb b/config/application.rb index a2b45a66a..27177a5cb 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,9 +1,5 @@ require_relative "boot" - require "rails/all" - -# Require the gems listed in Gemfile, including any gems -# you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) module Fizzy @@ -16,14 +12,6 @@ module Fizzy # be reloaded or eager loaded. config.autoload_lib ignore: %w[ assets tasks rails_ext ] - # Configuration for the application, engines, and railties goes here. - # - # These settings can be overridden in specific environments using the files - # in config/environments, which are processed later. - # - # config.time_zone = "Central Time (US & Canada)" - # config.eager_load_paths << Rails.root.join("extras") - # Enable debug mode for Rails event logging so we get SQL query logs. # This was made necessary by the change in https://github.com/rails/rails/pull/55900 config.after_initialize do From ae640198ae7154719c50e687bcaaccee1a31423b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 21 Nov 2025 09:40:50 +0100 Subject: [PATCH 51/54] Stick with the same general retention policy we use for everything --- config/cache.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/config/cache.yml b/config/cache.yml index 45bee2a80..d1716bb58 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -1,8 +1,6 @@ default_options: &default_options store_options: - # Cap age of oldest cache entry to fulfill retention policies - # max_age: <%= 60.days.to_i %> - max_size: <%= 256.megabytes %> + max_age: <%= 60.days.to_i %> namespace: <%= Rails.env %> default_connection: &default_connection From 85b9e3dfd48819d01430f16af2268bdd9bbb58d3 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 21 Nov 2025 09:41:03 +0100 Subject: [PATCH 52/54] Make the notifications tray reload on broadcasted refreshes We can't use the normal refresh: :morph because everything is contained in a turbo permanent section so that the trays persist across regular navigation. --- app/javascript/controllers/frame_controller.js | 4 ++++ app/views/my/pins/_tray.html.erb | 14 +++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/javascript/controllers/frame_controller.js b/app/javascript/controllers/frame_controller.js index 7684a43ed..c905576ba 100644 --- a/app/javascript/controllers/frame_controller.js +++ b/app/javascript/controllers/frame_controller.js @@ -15,4 +15,8 @@ export default class extends Controller { this.element.reload() } } + + reload() { + this.element.reload() + } } diff --git a/app/views/my/pins/_tray.html.erb b/app/views/my/pins/_tray.html.erb index 571018728..20521e2d3 100644 --- a/app/views/my/pins/_tray.html.erb +++ b/app/views/my/pins/_tray.html.erb @@ -2,13 +2,13 @@
    <%= tag.dialog id: "pin-tray", class: "tray__dialog", data: { - action: "keydown->navigable-list#navigate dialog:show@document->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside", - controller: "navigable-list", - dialog_target: "dialog", - navigable_list_actionable_items_value: "true", - navigable_list_reverse_navigation_value: "true" }, - turbo_permanent: true do %> - <%= turbo_frame_tag "pins", src: my_pins_path %> + action: "keydown->navigable-list#navigate dialog:show@document->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside", + controller: "navigable-list", + dialog_target: "dialog", + navigable_list_actionable_items_value: "true", + navigable_list_reverse_navigation_value: "true" }, + turbo_permanent: true do %> + <%= turbo_frame_tag "pins", src: my_pins_path, data: { controller: "frame", action: "turbo:morph@document->frame#reload" } %> <% end %>