From 9b47269349ff28f4a481d1de3cd5d01210b237a3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 7 Jan 2026 10:05:46 -0500 Subject: [PATCH 01/34] Document closed and column fields in card API response Add missing fields to the GET card endpoint documentation: - closed: indicates whether the card is in "Done" state - column: present only when card is triaged into a column --- docs/API.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/API.md b/docs/API.md index ee1820f28..c43d23477 100644 --- a/docs/API.md +++ b/docs/API.md @@ -574,6 +574,7 @@ __Response:__ "description_html": "

Hello, World!

", "image_url": null, "tags": ["programming"], + "closed": false, "golden": false, "last_active_at": "2025-12-05T19:38:48.553Z", "created_at": "2025-12-05T19:38:48.540Z", @@ -594,6 +595,15 @@ __Response:__ "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" } }, + "column": { + "id": "03f5v9zkft4hj9qq0lsn9ohcn", + "name": "In Progress", + "color": { + "name": "Lime", + "value": "var(--color-card-4)" + }, + "created_at": "2025-12-05T19:36:35.534Z" + }, "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", @@ -619,6 +629,8 @@ __Response:__ } ``` +> **Note:** The `closed` field indicates whether the card is in the "Done" state. The `column` field is only present when the card has been triaged into a column; cards in "Maybe?", "Not Now" or "Done" will not have this field. + ### `POST /:account_slug/boards/:board_id/cards` Creates a new card in a board. From 7de65be756a8a5c4e4eabfb718578bdc72014eb3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 7 Jan 2026 10:12:03 -0500 Subject: [PATCH 02/34] Document goldness endpoints for marking cards as golden Add POST and DELETE endpoints for /:account_slug/cards/:card_number/goldness --- docs/API.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/API.md b/docs/API.md index c43d23477..0fe71404a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -779,6 +779,22 @@ __Response:__ Returns `204 No Content` on success. +### `POST /:account_slug/cards/:card_number/goldness` + +Marks a card as golden. + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/cards/:card_number/goldness` + +Removes golden status from a card. + +__Response:__ + +Returns `204 No Content` on success. + ## Comments Comments are attached to cards and support rich text. From 562d340b84131c95771bc9b3b409f06a1199a64c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 7 Jan 2026 10:17:34 -0500 Subject: [PATCH 03/34] Document DELETE endpoint for removing card header image --- docs/API.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/API.md b/docs/API.md index 0fe71404a..bd4488127 100644 --- a/docs/API.md +++ b/docs/API.md @@ -695,6 +695,14 @@ __Response:__ Returns `204 No Content` on success. +### `DELETE /:account_slug/cards/:card_number/image` + +Removes the header image from a card. + +__Response:__ + +Returns `204 No Content` on success. + ### `POST /:account_slug/cards/:card_number/closure` Closes a card. From a84530a97fdba18a40faf073589161065e3f9b33 Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Wed, 7 Jan 2026 21:52:20 +0200 Subject: [PATCH 04/34] Add rel noopener to external links --- app/views/my/menus/show.html.erb | 2 +- app/views/public/_footer.html.erb | 2 +- app/views/sessions/_footer.html.erb | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/my/menus/show.html.erb b/app/views/my/menus/show.html.erb index cb3e6310e..5bff2f387 100644 --- a/app/views/my/menus/show.html.erb +++ b/app/views/my/menus/show.html.erb @@ -9,6 +9,6 @@ <% end %> <% end %> diff --git a/app/views/public/_footer.html.erb b/app/views/public/_footer.html.erb index a9b5d1853..a68dce8ed 100644 --- a/app/views/public/_footer.html.erb +++ b/app/views/public/_footer.html.erb @@ -1,3 +1,3 @@
- <%= icon_tag "fizzy", class: "v-align-middle" %> Fizzy™ is designed, built, and backed by <%= icon_tag "37signals", class: "v-align-middle" %> 37signals. + <%= icon_tag "fizzy", class: "v-align-middle" %> Fizzy™ is designed, built, and backed by <%= icon_tag "37signals", class: "v-align-middle" %> 37signals.
diff --git a/app/views/sessions/_footer.html.erb b/app/views/sessions/_footer.html.erb index e538532d7..d4a00acab 100644 --- a/app/views/sessions/_footer.html.erb +++ b/app/views/sessions/_footer.html.erb @@ -1,4 +1,4 @@
- <%= icon_tag "fizzy", class: "v-align-middle" %> Fizzy™ is designed, built, and backed by <%= icon_tag "37signals", class: "v-align-middle" %> 37signals™. + <%= icon_tag "fizzy", class: "v-align-middle" %> Fizzy™ is designed, built, and backed by <%= icon_tag "37signals", class: "v-align-middle" %> 37signals™. Need help? <%= mail_to "support@fizzy.do", "Send us an email", class: "txt-link" %>. -
\ No newline at end of file + From d3258b3c7f9076ad8b1002983cfa89d95e385aea Mon Sep 17 00:00:00 2001 From: Jeroen Versteeg Date: Fri, 9 Jan 2026 16:31:47 +0100 Subject: [PATCH 05/34] Replace use of instance variable with local The partial `boards/edit/auto_close` used both `@board` as well as `board`. --- app/views/boards/edit/_auto_close.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/boards/edit/_auto_close.html.erb b/app/views/boards/edit/_auto_close.html.erb index c32c1c050..8993c132e 100644 --- a/app/views/boards/edit/_auto_close.html.erb +++ b/app/views/boards/edit/_auto_close.html.erb @@ -1,7 +1,7 @@ -<%= turbo_frame_tag @board, :entropy do %> +<%= turbo_frame_tag board, :entropy do %>

Auto close

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

- <%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(@board) %> + <%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(board) %>
<% end %> From 0aca39a133695238b9ecf3e585d7464933cd6f73 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 9 Jan 2026 10:11:33 -0600 Subject: [PATCH 06/34] Transparent lexxy toolbar when inside a golden card --- app/assets/stylesheets/golden-effect.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/golden-effect.css b/app/assets/stylesheets/golden-effect.css index 423286779..c12af8575 100644 --- a/app/assets/stylesheets/golden-effect.css +++ b/app/assets/stylesheets/golden-effect.css @@ -14,5 +14,9 @@ 0 0 0 1px color-mix(in oklch, var(--color-golden) 100%, transparent), 0 0 0.2em 0.2em color-mix(in oklch, var(--color-golden) 25%, transparent), 0 0 1em 0.5em color-mix(in oklch, var(--color-golden) 25%, transparent); + + lexxy-toolbar { + --lexxy-bg-color: transparent; + } } } From 03ce900c2e8518fc354a4d82cf83277c2891d047 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 9 Jan 2026 10:47:39 -0600 Subject: [PATCH 07/34] Tweak comment history button on mobile --- app/assets/stylesheets/buttons.css | 4 ++-- app/assets/stylesheets/comments.css | 28 ++++++++++++++++------ app/views/cards/comments/_comment.html.erb | 2 +- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index 64ea77611..ac897184f 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -110,7 +110,7 @@ @media (max-width: 639px) { --btn-size: 3em; - --icon-size: 70%; + --icon-size: 75%; } } @@ -119,7 +119,7 @@ .btn--circle-mobile { --btn-size: 3em; --btn-padding: 0; - --icon-size: 70%; + --icon-size: 75%; aspect-ratio: 1; inline-size: var(--btn-size); diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 2ae664dcd..a8956a98e 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -74,10 +74,6 @@ .comments--system-expanded .comment-by-system & { --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); } - - .comment__history { - background-color: var(--stripe-color); - } } .reactions { @@ -136,11 +132,29 @@ background-color: var(--color-ink-lightest); } + .comment__permalink-title { + color: currentColor; + opacity: 0.66; + text-decoration: none; + text-transform: capitalize; + + @media (max-width: 639px) { + line-height: 1.5lh; + } + } + .comment__history { - background-color: var(--color-ink-lightest); + background-color: transparent; display: none; - inset: calc(var(--comment-padding-inline) / 2.5) calc(var(--comment-padding-inline) / 1.5) auto auto; margin-inline-end: calc(var(--comment-padding-inline) / -2.5); + inset: var(--comment-padding-block) var(--comment-padding-block) auto auto; + translate: 2px -2px; /* Align baseline with time stamp */ position: absolute; + + @media (any-hover: hover) { + &:hover { + background-color: var(--stripe-color); + } + } } .comment-by-system { @@ -158,7 +172,7 @@ display: contents; .comment__history { - display: grid; + display: inline-flex; } } diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index d693761ea..199bde108 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -14,7 +14,7 @@ <%= link_to comment.creator.name, comment.creator, class: "txt-ink btn btn--plain fill-transparent", data: { turbo_frame: "_top" } %> - <%= link_to comment, class: "txt-undecorated txt-ink translucent txt-normal txt-capitalize", data: { turbo_frame: "_top" } do %> + <%= link_to comment, class: "comment__permalink-title", data: { turbo_frame: "_top" } do %> <%= local_datetime_tag comment.created_at, style: :agoorweekday %>, <%= local_datetime_tag comment.created_at, style: :time %> <% end %> From 509f0ca305fd70ef794ecccadc1be406db66edb5 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 9 Jan 2026 11:57:45 -0600 Subject: [PATCH 08/34] Consolidate footer text into a shared partial --- app/assets/stylesheets/utilities.css | 4 +++- app/views/layouts/shared/_colophon.html.erb | 9 +++++++++ app/views/my/menus/show.html.erb | 2 +- app/views/public/_footer.html.erb | 2 +- app/views/sessions/_footer.html.erb | 2 +- 5 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 app/views/layouts/shared/_colophon.html.erb diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 62304d020..aa19f0f58 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -13,6 +13,7 @@ .txt-align-start { text-align: start; } .txt-align-end { text-align: end; } + .txt-current { color: currentColor; } .txt-ink { color: var(--color-ink); } .txt-reversed { color: var(--color-ink-inverted); } .txt-negative { color: var(--color-negative); } @@ -29,8 +30,9 @@ .txt-capitalize-first-letter::first-letter { text-transform: capitalize; } .txt-link { color: var(--color-link); text-decoration: underline; } - .font-weight-black { font-weight: 900; } .font-weight-normal { font-weight: normal; } + .font-weight-bold { font-weight: bold; } + .font-weight-black { font-weight: 900; } /* Flexbox and Grid */ .justify-end { justify-content: end; } diff --git a/app/views/layouts/shared/_colophon.html.erb b/app/views/layouts/shared/_colophon.html.erb new file mode 100644 index 000000000..a6acb8a3b --- /dev/null +++ b/app/views/layouts/shared/_colophon.html.erb @@ -0,0 +1,9 @@ +<%= link_to "https://www.fizzy.do", class: "txt-current font-weight-bold txt-nowrap", target: "_blank", rel: "noopener noreferrer" do %> + <%= icon_tag "fizzy", class: "v-align-middle" %> + Fizzy™ +<% end %> +is designed, built, and backed by +<%= link_to "https://37signals.com", class: "txt-current font-weight-bold txt-nowrap", target: "_blank", rel: "noopener noreferrer" do %> + <%= icon_tag "37signals", class: "v-align-middle" %> + 37signals™ +<% end %> diff --git a/app/views/my/menus/show.html.erb b/app/views/my/menus/show.html.erb index 5bff2f387..a942c2af7 100644 --- a/app/views/my/menus/show.html.erb +++ b/app/views/my/menus/show.html.erb @@ -9,6 +9,6 @@ <% end %> <% end %> diff --git a/app/views/public/_footer.html.erb b/app/views/public/_footer.html.erb index a68dce8ed..ab2856821 100644 --- a/app/views/public/_footer.html.erb +++ b/app/views/public/_footer.html.erb @@ -1,3 +1,3 @@
- <%= icon_tag "fizzy", class: "v-align-middle" %> Fizzy™ is designed, built, and backed by <%= icon_tag "37signals", class: "v-align-middle" %> 37signals. + <%= render "layouts/shared/colophon" %>
diff --git a/app/views/sessions/_footer.html.erb b/app/views/sessions/_footer.html.erb index d4a00acab..89055e0b3 100644 --- a/app/views/sessions/_footer.html.erb +++ b/app/views/sessions/_footer.html.erb @@ -1,4 +1,4 @@
- <%= icon_tag "fizzy", class: "v-align-middle" %> Fizzy™ is designed, built, and backed by <%= icon_tag "37signals", class: "v-align-middle" %> 37signals™. + <%= render "layouts/shared/colophon" %>. Need help? <%= mail_to "support@fizzy.do", "Send us an email", class: "txt-link" %>.
From eceb6c9b433b13c496b4ef3b07d46bc573003811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanko=20Krtali=C4=87?= Date: Mon, 12 Jan 2026 14:21:05 +0100 Subject: [PATCH 09/34] Add self-service account deletion (#2246) * Add self-service account deletion * Disable access to cancelled accounts & implement Stripe interactions * Add tests * Fix failing tests * Remove cancelled accounts from lists * Fix incorrect redirect * Use _path instead of _url for consistency * Don't track how far the inicieration job got We still want the step tracking so that the job can be interrupted. But, since the scope is idempotent, we don't need to track how far it got. * Specify the exact time when the data will be deleted * Fix crash due to unadvancable cursor * Rename up_for_incineration to due_for_incineration * Regenrate the schema * Fix incorrect path check * Migrate the SQLite schema * Only show the cancel button on cancellable accounts * Check that a subscirption method exists before calling it * Ignore job failures due to missing records when an account gets deleted * Skip sending notifications on cancelled accounts * Use collbacks to integrate * Add a blank line between queue_as and discard_on * Break checks into methods * Inline methods * Rename Account::IncinerateJob * Run migrations * Migrate SQLite --- .../account/cancellations_controller.rb | 13 +++ app/controllers/concerns/authorization.rb | 8 +- app/controllers/my/menus_controller.rb | 2 +- app/controllers/public/base_controller.rb | 5 + app/controllers/sessions/menus_controller.rb | 2 +- app/jobs/account/incinerate_due_job.rb | 14 +++ app/jobs/board/clean_inaccessible_data_job.rb | 2 + app/jobs/card/activity_spike/detection_job.rb | 2 + .../remove_inaccessible_notifications_job.rb | 2 + app/jobs/event/webhook_dispatch_job.rb | 2 + app/jobs/export_account_data_job.rb | 2 + app/jobs/mention/create_job.rb | 2 + .../notification/bundle/deliver_all_job.rb | 2 + app/jobs/notification/bundle/deliver_job.rb | 2 + app/jobs/notify_recipients_job.rb | 2 + app/jobs/push_notification_job.rb | 2 + app/jobs/storage/reconcile_job.rb | 1 + app/jobs/webhook/delivery_job.rb | 2 + app/mailers/account_mailer.rb | 11 ++ app/models/account.rb | 4 +- app/models/account/cancellable.rb | 46 ++++++++ app/models/account/cancellation.rb | 4 + app/models/account/incineratable.rb | 17 +++ app/models/notification/bundle.rb | 2 +- app/models/notification_pusher.rb | 3 +- app/models/webhook/triggerable.rb | 2 +- .../account/settings/_cancellation.html.erb | 26 +++++ app/views/account/settings/show.html.erb | 1 + .../account_mailer/cancellation.html.erb | 14 +++ .../account_mailer/cancellation.text.erb | 13 +++ config/recurring.yml | 3 + config/routes.rb | 1 + ...1224092315_create_account_cancellations.rb | 10 ++ db/schema.rb | 10 +- db/schema_sqlite.rb | 12 ++- saas/app/models/account/billing.rb | 4 + saas/app/models/account/subscription.rb | 25 +++++ saas/test/models/account/billing_test.rb | 40 +++++++ saas/test/models/account/subscription_test.rb | 102 ++++++++++++++++++ .../account/cancellations_controller_test.rb | 46 ++++++++ test/controllers/my/menus_controller_test.rb | 16 +++ .../sessions/menus_controller_test.rb | 20 ++++ test/fixtures/accounts.yml | 6 ++ test/jobs/account/incinerate_due_job_test.rb | 67 ++++++++++++ test/mailers/account_mailer_test.rb | 57 ++++++++++ test/models/account/cancellable_test.rb | 79 ++++++++++++++ test/models/account/cancellation_test.rb | 7 ++ test/models/account/incineratable_test.rb | 26 +++++ test/models/notification/bundle_test.rb | 17 +++ test/models/notification_pusher_test.rb | 32 ++++++ test/models/webhook/triggerable_test.rb | 58 ++++++++++ 51 files changed, 836 insertions(+), 12 deletions(-) create mode 100644 app/controllers/account/cancellations_controller.rb create mode 100644 app/jobs/account/incinerate_due_job.rb create mode 100644 app/mailers/account_mailer.rb create mode 100644 app/models/account/cancellable.rb create mode 100644 app/models/account/cancellation.rb create mode 100644 app/models/account/incineratable.rb create mode 100644 app/views/account/settings/_cancellation.html.erb create mode 100644 app/views/mailers/account_mailer/cancellation.html.erb create mode 100644 app/views/mailers/account_mailer/cancellation.text.erb create mode 100644 db/migrate/20251224092315_create_account_cancellations.rb create mode 100644 test/controllers/account/cancellations_controller_test.rb create mode 100644 test/jobs/account/incinerate_due_job_test.rb create mode 100644 test/mailers/account_mailer_test.rb create mode 100644 test/models/account/cancellable_test.rb create mode 100644 test/models/account/cancellation_test.rb create mode 100644 test/models/account/incineratable_test.rb create mode 100644 test/models/notification_pusher_test.rb create mode 100644 test/models/webhook/triggerable_test.rb diff --git a/app/controllers/account/cancellations_controller.rb b/app/controllers/account/cancellations_controller.rb new file mode 100644 index 000000000..b6ac82ab4 --- /dev/null +++ b/app/controllers/account/cancellations_controller.rb @@ -0,0 +1,13 @@ +class Account::CancellationsController < ApplicationController + before_action :ensure_owner + + def create + Current.account.cancel + redirect_to session_menu_path(script_name: nil), notice: "Account deleted" + end + + private + def ensure_owner + head :forbidden unless Current.user.owner? + end +end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index f30d518cc..a6456a050 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -2,7 +2,7 @@ module Authorization extend ActiveSupport::Concern included do - before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? } + before_action :ensure_can_access_account, if: :authenticated_account_access? end class_methods do @@ -25,8 +25,12 @@ module Authorization head :forbidden unless Current.identity.staff? end + def authenticated_account_access? + Current.account.present? && authenticated? + end + def ensure_can_access_account - if Current.user.blank? || !Current.user.active? + unless Current.account.active? && Current.user&.active? respond_to do |format| format.html { redirect_to session_menu_path(script_name: nil) } format.json { head :forbidden } diff --git a/app/controllers/my/menus_controller.rb b/app/controllers/my/menus_controller.rb index 32b3da004..24fa2b085 100644 --- a/app/controllers/my/menus_controller.rb +++ b/app/controllers/my/menus_controller.rb @@ -4,7 +4,7 @@ class My::MenusController < ApplicationController @boards = Current.user.boards.ordered_by_recently_accessed @tags = Current.account.tags.all.alphabetically @users = Current.account.users.active.alphabetically - @accounts = Current.identity.accounts + @accounts = Current.identity.accounts.active fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ] end diff --git a/app/controllers/public/base_controller.rb b/app/controllers/public/base_controller.rb index 018b1ab36..503c914e4 100644 --- a/app/controllers/public/base_controller.rb +++ b/app/controllers/public/base_controller.rb @@ -2,6 +2,7 @@ class Public::BaseController < ApplicationController allow_unauthenticated_access before_action :set_board, :set_card, :set_public_cache_expiration + before_action :ensure_board_accessible layout "public" @@ -17,4 +18,8 @@ class Public::BaseController < ApplicationController def set_public_cache_expiration expires_in 30.seconds, public: true end + + def ensure_board_accessible + raise ActionController::RoutingError, "Not Found" if @board&.account&.cancelled? + end end diff --git a/app/controllers/sessions/menus_controller.rb b/app/controllers/sessions/menus_controller.rb index 984e83a43..5fd885c45 100644 --- a/app/controllers/sessions/menus_controller.rb +++ b/app/controllers/sessions/menus_controller.rb @@ -4,7 +4,7 @@ class Sessions::MenusController < ApplicationController layout "public" def show - @accounts = Current.identity.accounts + @accounts = Current.identity.accounts.active if @accounts.one? redirect_to root_url(script_name: @accounts.first.slug) diff --git a/app/jobs/account/incinerate_due_job.rb b/app/jobs/account/incinerate_due_job.rb new file mode 100644 index 000000000..5bb7b35eb --- /dev/null +++ b/app/jobs/account/incinerate_due_job.rb @@ -0,0 +1,14 @@ +class Account::IncinerateDueJob < ApplicationJob + include ActiveJob::Continuable + + queue_as :incineration + + def perform + step :incineration do |step| + Account.due_for_incineration.find_each do |account| + account.incinerate + step.checkpoint! + end + end + end +end diff --git a/app/jobs/board/clean_inaccessible_data_job.rb b/app/jobs/board/clean_inaccessible_data_job.rb index b2cdff085..5aa1eb0eb 100644 --- a/app/jobs/board/clean_inaccessible_data_job.rb +++ b/app/jobs/board/clean_inaccessible_data_job.rb @@ -1,4 +1,6 @@ class Board::CleanInaccessibleDataJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(user, board) board.clean_inaccessible_data_for(user) end diff --git a/app/jobs/card/activity_spike/detection_job.rb b/app/jobs/card/activity_spike/detection_job.rb index 2f5142d0d..82278e5d7 100644 --- a/app/jobs/card/activity_spike/detection_job.rb +++ b/app/jobs/card/activity_spike/detection_job.rb @@ -1,4 +1,6 @@ class Card::ActivitySpike::DetectionJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(card) card.detect_activity_spikes end diff --git a/app/jobs/card/remove_inaccessible_notifications_job.rb b/app/jobs/card/remove_inaccessible_notifications_job.rb index db7dec4e3..018c6cf8d 100644 --- a/app/jobs/card/remove_inaccessible_notifications_job.rb +++ b/app/jobs/card/remove_inaccessible_notifications_job.rb @@ -1,4 +1,6 @@ class Card::RemoveInaccessibleNotificationsJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(card) card.remove_inaccessible_notifications end diff --git a/app/jobs/event/webhook_dispatch_job.rb b/app/jobs/event/webhook_dispatch_job.rb index 1df418cc8..96f01ba50 100644 --- a/app/jobs/event/webhook_dispatch_job.rb +++ b/app/jobs/event/webhook_dispatch_job.rb @@ -5,6 +5,8 @@ class Event::WebhookDispatchJob < ApplicationJob queue_as :webhooks + discard_on ActiveJob::DeserializationError + def perform(event) step :dispatch do |step| Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook| diff --git a/app/jobs/export_account_data_job.rb b/app/jobs/export_account_data_job.rb index 82b5071cb..c0286c736 100644 --- a/app/jobs/export_account_data_job.rb +++ b/app/jobs/export_account_data_job.rb @@ -1,6 +1,8 @@ class ExportAccountDataJob < ApplicationJob queue_as :backend + discard_on ActiveJob::DeserializationError + def perform(export) export.build end diff --git a/app/jobs/mention/create_job.rb b/app/jobs/mention/create_job.rb index 816782754..4376ac493 100644 --- a/app/jobs/mention/create_job.rb +++ b/app/jobs/mention/create_job.rb @@ -1,4 +1,6 @@ class Mention::CreateJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(record, mentioner:) record.create_mentions(mentioner:) end diff --git a/app/jobs/notification/bundle/deliver_all_job.rb b/app/jobs/notification/bundle/deliver_all_job.rb index 79aeaed16..d3e2cb738 100644 --- a/app/jobs/notification/bundle/deliver_all_job.rb +++ b/app/jobs/notification/bundle/deliver_all_job.rb @@ -1,6 +1,8 @@ class Notification::Bundle::DeliverAllJob < ApplicationJob queue_as :backend + discard_on ActiveJob::DeserializationError + def perform Notification::Bundle.deliver_all end diff --git a/app/jobs/notification/bundle/deliver_job.rb b/app/jobs/notification/bundle/deliver_job.rb index 11f6bcda5..b4059a779 100644 --- a/app/jobs/notification/bundle/deliver_job.rb +++ b/app/jobs/notification/bundle/deliver_job.rb @@ -3,6 +3,8 @@ class Notification::Bundle::DeliverJob < ApplicationJob queue_as :backend + discard_on ActiveJob::DeserializationError + def perform(bundle) bundle.deliver end diff --git a/app/jobs/notify_recipients_job.rb b/app/jobs/notify_recipients_job.rb index 6083622fd..bba5898da 100644 --- a/app/jobs/notify_recipients_job.rb +++ b/app/jobs/notify_recipients_job.rb @@ -1,4 +1,6 @@ class NotifyRecipientsJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(notifiable) notifiable.notify_recipients end diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb index 124366967..c912e141d 100644 --- a/app/jobs/push_notification_job.rb +++ b/app/jobs/push_notification_job.rb @@ -1,4 +1,6 @@ class PushNotificationJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(notification) NotificationPusher.new(notification).push end diff --git a/app/jobs/storage/reconcile_job.rb b/app/jobs/storage/reconcile_job.rb index cd3bf3803..43574f428 100644 --- a/app/jobs/storage/reconcile_job.rb +++ b/app/jobs/storage/reconcile_job.rb @@ -5,6 +5,7 @@ class Storage::ReconcileJob < ApplicationJob limits_concurrency to: 1, key: ->(owner) { owner } discard_on ActiveJob::DeserializationError + retry_on ReconcileAborted, wait: 1.minute, attempts: 3 def perform(owner) diff --git a/app/jobs/webhook/delivery_job.rb b/app/jobs/webhook/delivery_job.rb index 95102b9d6..a6e324985 100644 --- a/app/jobs/webhook/delivery_job.rb +++ b/app/jobs/webhook/delivery_job.rb @@ -1,6 +1,8 @@ class Webhook::DeliveryJob < ApplicationJob queue_as :webhooks + discard_on ActiveJob::DeserializationError + def perform(delivery) delivery.deliver end diff --git a/app/mailers/account_mailer.rb b/app/mailers/account_mailer.rb new file mode 100644 index 000000000..5cb6616f8 --- /dev/null +++ b/app/mailers/account_mailer.rb @@ -0,0 +1,11 @@ +class AccountMailer < ApplicationMailer + def cancellation(cancellation) + @account = cancellation.account + @user = cancellation.initiated_by + + mail( + to: @user.identity.email_address, + subject: "Your Fizzy account was cancelled" + ) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index f9b2589c0..34b63b688 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,7 +1,7 @@ class Account < ApplicationRecord - include Account::Storage, Entropic, MultiTenantable, Seedeable + include Account::Storage, Cancellable, Entropic, Incineratable, MultiTenantable, Seedeable - has_one :join_code + has_one :join_code, dependent: :destroy has_many :users, dependent: :destroy has_many :boards, dependent: :destroy has_many :cards, dependent: :destroy diff --git a/app/models/account/cancellable.rb b/app/models/account/cancellable.rb new file mode 100644 index 000000000..f10624245 --- /dev/null +++ b/app/models/account/cancellable.rb @@ -0,0 +1,46 @@ +module Account::Cancellable + extend ActiveSupport::Concern + + included do + has_one :cancellation, dependent: :destroy + + scope :active, -> { where.missing(:cancellation) } + + define_callbacks :cancel + define_callbacks :reactivate + end + + def cancel(initiated_by: Current.user) + with_lock do + if cancellable? && active? + run_callbacks :cancel do + create_cancellation!(initiated_by: initiated_by) + end + + AccountMailer.cancellation(cancellation).deliver_later + end + end + end + + def reactivate + with_lock do + if cancelled? + run_callbacks :reactivate do + cancellation.destroy + end + end + end + end + + def active? + !cancelled? + end + + def cancelled? + cancellation.present? + end + + def cancellable? + Account.accepting_signups? + end +end diff --git a/app/models/account/cancellation.rb b/app/models/account/cancellation.rb new file mode 100644 index 000000000..ba8e73bed --- /dev/null +++ b/app/models/account/cancellation.rb @@ -0,0 +1,4 @@ +class Account::Cancellation < ApplicationRecord + belongs_to :account + belongs_to :initiated_by, class_name: "User" +end diff --git a/app/models/account/incineratable.rb b/app/models/account/incineratable.rb new file mode 100644 index 000000000..c734c0768 --- /dev/null +++ b/app/models/account/incineratable.rb @@ -0,0 +1,17 @@ +module Account::Incineratable + extend ActiveSupport::Concern + + INCINERATION_GRACE_PERIOD = 30.days + + included do + scope :due_for_incineration, -> { joins(:cancellation).where(account_cancellations: { created_at: ...INCINERATION_GRACE_PERIOD.ago }) } + + define_callbacks :incinerate + end + + def incinerate + run_callbacks :incinerate do + account.destroy + end + end +end diff --git a/app/models/notification/bundle.rb b/app/models/notification/bundle.rb index 8617a0f99..fb57a4fd0 100644 --- a/app/models/notification/bundle.rb +++ b/app/models/notification/bundle.rb @@ -74,7 +74,7 @@ class Notification::Bundle < ApplicationRecord end def deliverable? - user.settings.bundling_emails? && notifications.any? + user.settings.bundling_emails? && notifications.any? && account.active? end def overlapping_bundles diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index acb5603a1..d6425e561 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -20,7 +20,8 @@ class NotificationPusher def should_push? notification.user.push_subscriptions.any? && !notification.creator.system? && - notification.user.active? + notification.user.active? && + notification.account.active? end def build_payload diff --git a/app/models/webhook/triggerable.rb b/app/models/webhook/triggerable.rb index 443c801fd..7f7ab5771 100644 --- a/app/models/webhook/triggerable.rb +++ b/app/models/webhook/triggerable.rb @@ -7,6 +7,6 @@ module Webhook::Triggerable end def trigger(event) - deliveries.create!(event: event) + deliveries.create!(event: event) unless account.cancelled? end end diff --git a/app/views/account/settings/_cancellation.html.erb b/app/views/account/settings/_cancellation.html.erb new file mode 100644 index 000000000..5c493edd5 --- /dev/null +++ b/app/views/account/settings/_cancellation.html.erb @@ -0,0 +1,26 @@ +<% if Current.account.cancellable? && Current.user.owner? %> +
+

Cancel account

+

Delete your Fizzy account

+
+ +
+ + + +

Delete your account?

+
    +
  • All users, including you, will lose access
  • + <% if Current.account.try(:active_subscription) %> +
  • Your subscription will be canceled
  • + <% end %> +
  • After 30 days your data will be permanently deleted
  • +
+ +
+ + <%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %> +
+
+
+<% end %> diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index fc5e799b3..74e274414 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -18,6 +18,7 @@
<%= render "account/settings/entropy", account: @account %> <%= render "account/settings/export" %> + <%= render "account/settings/cancellation" %>
diff --git a/app/views/mailers/account_mailer/cancellation.html.erb b/app/views/mailers/account_mailer/cancellation.html.erb new file mode 100644 index 000000000..831a1cdab --- /dev/null +++ b/app/views/mailers/account_mailer/cancellation.html.erb @@ -0,0 +1,14 @@ +

Your Fizzy account <%= @account.name %> was cancelled.

+ +

What happens now?

+ +
    +
  • No one can access the account anymore
  • + <% if @account.try(:subscription) %> +
  • We won't charge you anymore
  • + <% end %> +
  • Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %>
  • +
+ +Changed your mind? +

If you want to cancel this deletion and restore your account, send us an email to support@fizzy.do as soon as possible.

diff --git a/app/views/mailers/account_mailer/cancellation.text.erb b/app/views/mailers/account_mailer/cancellation.text.erb new file mode 100644 index 000000000..3cfaf2689 --- /dev/null +++ b/app/views/mailers/account_mailer/cancellation.text.erb @@ -0,0 +1,13 @@ +Your Fizzy account "<%= @account.name %>" was cancelled. + +WHAT HAPPENS NOW? + +- No one can access the account anymore +<% if @account.try(:subscription) %> +- We won't charge you anymore +<% end %> +- Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %> + +CHANGED YOUR MIND? + +If you want to cancel this deletion and restore your account, send us an email to support@fizzy.do as soon as possible. diff --git a/config/recurring.yml b/config/recurring.yml index bc1d069cc..b044a4978 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -27,6 +27,9 @@ production: &production cleanup_exports: command: "Account::Export.cleanup" schedule: every hour at minute 20 + incineration: + job: Account::IncinerateDueJob + schedule: every 8 hours at minute 16 <% if Fizzy.saas? %> # Metrics diff --git a/config/routes.rb b/config/routes.rb index 0fd75fd98..97e34290c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,7 @@ Rails.application.routes.draw do root "events#index" namespace :account do + resource :cancellation, only: [ :create ] resource :entropy resource :join_code resource :settings diff --git a/db/migrate/20251224092315_create_account_cancellations.rb b/db/migrate/20251224092315_create_account_cancellations.rb new file mode 100644 index 000000000..cf12286f0 --- /dev/null +++ b/db/migrate/20251224092315_create_account_cancellations.rb @@ -0,0 +1,10 @@ +class CreateAccountCancellations < ActiveRecord::Migration[8.2] + def change + create_table :account_cancellations, id: :uuid do |t| + t.uuid :account_id, null: false, index: { unique: true } + t.uuid :initiated_by_id, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c120a87ae..86b9f2937 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_12_19_120755) do +ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) 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 @@ -25,6 +25,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_cancellations", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "initiated_by_id", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true + end + create_table "account_exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "completed_at" diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 68b3ea422..b76f99865 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.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_12_19_120755) do +ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -25,6 +25,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_cancellations", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "initiated_by_id", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true + end + create_table "account_exports", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "completed_at" @@ -469,7 +477,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do t.string "operation", limit: 255, null: false t.uuid "recordable_id" t.string "recordable_type", limit: 255 - t.string "request_id" + t.string "request_id", limit: 255 t.uuid "user_id" t.index ["account_id"], name: "index_storage_entries_on_account_id" t.index ["blob_id"], name: "index_storage_entries_on_blob_id" diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index dada43721..ab4b75a06 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -4,6 +4,10 @@ module Account::Billing included do has_one :subscription, class_name: "Account::Subscription", dependent: :destroy has_one :billing_waiver, class_name: "Account::BillingWaiver", dependent: :destroy + + set_callback :incinerate, :before, -> { subscription&.cancel } + set_callback :cancel, :after, -> { subscription&.pause } + set_callback :reactivate, :before, -> { subscription&.resume } end def plan diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index dd7833dfc..6a8bea0ef 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -22,4 +22,29 @@ class Account::Subscription < SaasRecord def next_amount_due next_amount_due_in_cents ? next_amount_due_in_cents / 100.0 : plan.price end + + def pause + if stripe_subscription_id.present? + Stripe::Subscription.update( + stripe_subscription_id, + pause_collection: { behavior: "void" } + ) + end + end + + def resume + if stripe_subscription_id.present? + Stripe::Subscription.update( + stripe_subscription_id, + pause_collection: "" + ) + end + end + + def cancel + Stripe::Subscription.cancel(stripe_subscription_id) if stripe_subscription_id.present? + rescue Stripe::InvalidRequestError => e + # Subscription already deleted/canceled in Stripe - treat as success + Rails.logger.warn "Stripe subscription #{stripe_subscription_id} not found during cancel: #{e.message}" + end end diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb index 7283f47da..2fe923c1a 100644 --- a/saas/test/models/account/billing_test.rb +++ b/saas/test/models/account/billing_test.rb @@ -28,4 +28,44 @@ class Account::BillingTest < ActiveSupport::TestCase account.comp assert_equal 1, Account::BillingWaiver.where(account: account).count end + + test "cancel callback pauses subscription" do + account = accounts(:"37s") + user = users(:david) + + subscription = mock("subscription") + subscription.expects(:pause).once + account.stubs(:subscription).returns(subscription) + + account.cancel(initiated_by: user) + end + + test "reactivate callback resumes subscription" do + account = accounts(:"37s") + user = users(:david) + + # First cancel with a subscription mock + subscription_for_cancel = mock("subscription") + subscription_for_cancel.expects(:pause).once + account.stubs(:subscription).returns(subscription_for_cancel) + + account.cancel(initiated_by: user) + + # Now stub for reactivation + subscription_for_reactivate = mock("subscription") + subscription_for_reactivate.expects(:resume).once + account.stubs(:subscription).returns(subscription_for_reactivate) + + account.reactivate + end + + test "incinerate callback cancels subscription before destroying account" do + account = accounts(:"37s") + + subscription = mock("subscription") + subscription.expects(:cancel).once + account.stubs(:subscription).returns(subscription) + + account.incinerate + end end diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb index 99612ecea..ff5b0837a 100644 --- a/saas/test/models/account/subscription_test.rb +++ b/saas/test/models/account/subscription_test.rb @@ -15,4 +15,106 @@ class Account::SubscriptionTest < ActiveSupport::TestCase assert Account::Subscription.new(plan_key: "monthly_v1", status: "active").paid? assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid? end + + test "pause pauses Stripe subscription with void behavior" do + subscription = Account::Subscription.new(stripe_subscription_id: "sub_123") + + Stripe::Subscription.expects(:update).with( + "sub_123", + pause_collection: { behavior: "void" } + ).returns(true) + + subscription.pause + end + + test "pause does nothing when no stripe_subscription_id" do + subscription = Account::Subscription.new(stripe_subscription_id: nil) + + Stripe::Subscription.expects(:update).never + + subscription.pause + end + + test "pause raises on Stripe errors" do + subscription = Account::Subscription.new(stripe_subscription_id: "sub_123") + + Stripe::Subscription.stubs(:update).raises( + Stripe::APIConnectionError.new("Network error") + ) + + assert_raises Stripe::APIConnectionError do + subscription.pause + end + end + + test "resume resumes Stripe subscription" do + subscription = Account::Subscription.new(stripe_subscription_id: "sub_123") + + Stripe::Subscription.expects(:update).with( + "sub_123", + pause_collection: "" + ).returns(true) + + subscription.resume + end + + test "resume does nothing when no stripe_subscription_id" do + subscription = Account::Subscription.new(stripe_subscription_id: nil) + + Stripe::Subscription.expects(:update).never + + subscription.resume + end + + test "resume raises on Stripe errors" do + subscription = Account::Subscription.new(stripe_subscription_id: "sub_123") + + Stripe::Subscription.stubs(:update).raises( + Stripe::AuthenticationError.new("Invalid API key") + ) + + assert_raises Stripe::AuthenticationError do + subscription.resume + end + end + + test "cancel cancels Stripe subscription" do + subscription = Account::Subscription.new(stripe_subscription_id: "sub_123") + + Stripe::Subscription.expects(:cancel).with("sub_123").returns(true) + + subscription.cancel + end + + test "cancel does nothing when no stripe_subscription_id" do + subscription = Account::Subscription.new(stripe_subscription_id: nil) + + Stripe::Subscription.expects(:cancel).never + + subscription.cancel + end + + test "cancel treats 404 as success" do + subscription = Account::Subscription.new(stripe_subscription_id: "sub_deleted") + + Stripe::Subscription.stubs(:cancel).raises( + Stripe::InvalidRequestError.new("No such subscription", {}) + ) + + assert_nothing_raised do + subscription.cancel + end + end + + test "cancel raises on other Stripe errors" do + subscription = Account::Subscription.new(stripe_subscription_id: "sub_123") + + Stripe::Subscription.stubs(:cancel).raises( + Stripe::RateLimitError.new("Rate limit exceeded") + ) + + assert_raises Stripe::RateLimitError do + subscription.cancel + end + end end diff --git a/test/controllers/account/cancellations_controller_test.rb b/test/controllers/account/cancellations_controller_test.rb new file mode 100644 index 000000000..2e35534a6 --- /dev/null +++ b/test/controllers/account/cancellations_controller_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class Account::CancellationsControllerTest < ActionDispatch::IntegrationTest + setup do + @account = accounts(:"37s") + @user = users(:jason) + sign_in_as @user + + if @account.respond_to?(:subscription) + Account.any_instance.stubs(:subscription).returns(nil) + end + end + + test "an owner can cancel the account" do + assert_difference -> { Account::Cancellation.count }, 1 do + assert_enqueued_emails 1 do + post account_cancellation_url + end + end + + assert_redirected_to session_menu_path(script_name: nil) + assert_equal "Account deleted", flash[:notice] + assert @account.reload.cancelled? + assert_equal @user, @account.cancellation.initiated_by + end + + test "non-owner cannot cancel the account" do + logout_and_sign_in_as users(:david) + + assert_no_difference -> { Account::Cancellation.count } do + post account_cancellation_url + end + + assert_response :forbidden + end + + test "cancelling an account while in single-tenant mode does nothing" do + with_multi_tenant_mode(false) do + assert_no_difference -> { Account::Cancellation.count } do + post account_cancellation_url + end + + assert_not @account.reload.cancelled? + end + end +end diff --git a/test/controllers/my/menus_controller_test.rb b/test/controllers/my/menus_controller_test.rb index bbd560764..189752fe4 100644 --- a/test/controllers/my/menus_controller_test.rb +++ b/test/controllers/my/menus_controller_test.rb @@ -78,4 +78,20 @@ class My::MenusControllerTest < ActionDispatch::IntegrationTest get my_menu_path, headers: { "If-None-Match" => etag } assert_response :not_modified end + + test "show excludes cancelled accounts" do + # Create another account for the same identity + another_account = Account.create!(external_account_id: 9999996, name: "Cancelled Account") + another_user = @user.identity.users.create!(account: another_account, name: "Kevin", role: "owner") + + # Cancel the other account + another_account.cancel(initiated_by: another_user) + + get my_menu_path + assert_response :success + + # The response should include active account but not cancelled one + assert_select "a[href*='#{@account.slug}']" + assert_select "a[href*='#{another_account.slug}']", count: 0 + end end diff --git a/test/controllers/sessions/menus_controller_test.rb b/test/controllers/sessions/menus_controller_test.rb index 4be2dd415..701de848d 100644 --- a/test/controllers/sessions/menus_controller_test.rb +++ b/test/controllers/sessions/menus_controller_test.rb @@ -47,4 +47,24 @@ class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest assert_response :success end + + test "show excludes cancelled accounts" do + sign_in_as @identity + @identity.users.delete_all + account1 = Account.create!(external_account_id: 9999994, name: "Active Account") + account2 = Account.create!(external_account_id: 9999995, name: "Cancelled Account") + user1 = @identity.users.create!(account: account1, name: "Kevin", role: "owner") + user2 = @identity.users.create!(account: account2, name: "Kevin", role: "owner") + + # Cancel one account + account2.cancel(initiated_by: user2) + + untenanted do + get session_menu_url + end + + # Should redirect to the only active account + assert_response :redirect + assert_redirected_to root_url(script_name: account1.slug) + end end diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index c1c83ee51..5ce9567d4 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -9,3 +9,9 @@ initech: name: Initech LLC external_account_id: <%= ActiveRecord::FixtureSet.identify("initech") %> cards_count: 0 + +acme: + id: <%= ActiveRecord::FixtureSet.identify("acme", :uuid) %> + name: ACME + external_account_id: <%= ActiveRecord::FixtureSet.identify("acme") %> + cards_count: 0 diff --git a/test/jobs/account/incinerate_due_job_test.rb b/test/jobs/account/incinerate_due_job_test.rb new file mode 100644 index 000000000..6c53857a7 --- /dev/null +++ b/test/jobs/account/incinerate_due_job_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class Account::IncinerateDueJobTest < ActiveJob::TestCase + setup do + @account = accounts(:"37s") + @user = users(:david) + + # Stub Stripe methods only in SaaS mode + if defined?(Stripe::Subscription) + Stripe::Subscription.stubs(:update).returns(true) + Stripe::Subscription.stubs(:cancel).returns(true) + end + end + + test "finds accounts up for incineration" do + @account.cancel(initiated_by: @user) + @account.cancellation.update!(created_at: 31.days.ago) + + Account.any_instance.expects(:incinerate).once + + Account::IncinerateDueJob.perform_now + end + + test "incinerates each old cancelled account" do + # Cancel the test account + @account.cancel(initiated_by: @user) + @account.cancellation.update!(created_at: 31.days.ago) + + # Just verify it gets incinerated + assert_difference -> { Account.count }, -1 do + Account::IncinerateDueJob.perform_now + end + end + + test "skips recent cancellations" do + @account.cancel(initiated_by: @user) + @account.cancellation.update!(created_at: 29.days.ago) + + assert_no_difference -> { Account.count } do + Account::IncinerateDueJob.perform_now + end + end + + test "handles cursor pagination correctly" do + # Cancel and age the account + @account.cancel(initiated_by: @user) + @account.cancellation.update!(created_at: 31.days.ago) + + # Verify it processes accounts in the scope + assert_difference -> { Account.count }, -1 do + Account::IncinerateDueJob.perform_now + end + end + + test "only incinerates accounts past grace period" do + # Account at 29 days (within grace period - should not be incinerated) + @account.cancel(initiated_by: @user) + @account.cancellation.update!(created_at: 29.days.ago) + + assert_no_difference -> { Account.count } do + Account::IncinerateDueJob.perform_now + end + + # The account should still exist + assert Account.exists?(@account.id) + end +end diff --git a/test/mailers/account_mailer_test.rb b/test/mailers/account_mailer_test.rb new file mode 100644 index 000000000..8d41ddf95 --- /dev/null +++ b/test/mailers/account_mailer_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class AccountMailerTest < ActionMailer::TestCase + setup do + @account = accounts(:"37s") + @user = users(:david) + @account.cancel(initiated_by: @user) + @cancellation = @account.cancellation + end + + test "cancellation sends to initiating user" do + email = AccountMailer.cancellation(@cancellation) + + assert_emails 1 do + email.deliver_now + end + + assert_equal [ @user.identity.email_address ], email.to + end + + test "cancellation includes account name" do + email = AccountMailer.cancellation(@cancellation) + + assert_match @account.name, email.body.encoded + end + + test "cancellation includes support email" do + email = AccountMailer.cancellation(@cancellation) + + assert_match "support@fizzy.do", email.body.encoded + end + + test "cancellation has correct subject" do + email = AccountMailer.cancellation(@cancellation) + + assert_equal "Your Fizzy account was cancelled", email.subject + end + + test "cancellation has both HTML and text parts" do + email = AccountMailer.cancellation(@cancellation) + + assert email.html_part.present?, "Email should have HTML part" + assert email.text_part.present?, "Email should have text part" + end + + test "cancellation mentions account access is removed" do + email = AccountMailer.cancellation(@cancellation) + + assert_match /no one can access/i, email.body.encoded + end + + test "cancellation mentions data will be deleted" do + email = AccountMailer.cancellation(@cancellation) + + assert_match /deleted/i, email.body.encoded + end +end diff --git a/test/models/account/cancellable_test.rb b/test/models/account/cancellable_test.rb new file mode 100644 index 000000000..beb63a057 --- /dev/null +++ b/test/models/account/cancellable_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Account::CancellableTest < ActiveSupport::TestCase + setup do + @account = accounts(:"37s") + @user = users(:david) + end + + test "cancel" do + assert_difference -> { Account::Cancellation.count }, 1 do + assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do + @account.cancel(initiated_by: @user) + end + end + + assert @account.cancelled? + assert_equal @user, @account.cancellation.initiated_by + end + + test "cancel does nothing if already cancelled" do + @account.cancel(initiated_by: @user) + + assert_no_changes -> { @account.cancellation.reload.created_at } do + @account.cancel(initiated_by: @user) + end + end + + test "cancel does nothing when in single-tenant mode" do + Account.stubs(:accepting_signups?).returns(false) + + assert_no_difference -> { Account::Cancellation.count } do + @account.cancel(initiated_by: @user) + end + + assert_not @account.cancelled? + end + + test "cancelled? returns true when cancellation exists" do + assert_not @account.cancelled? + + @account.cancel(initiated_by: @user) + + assert @account.cancelled? + end + + test "reactivate" do + @account.cancel(initiated_by: @user) + + assert @account.cancelled? + + @account.reactivate + @account.reload + + assert_not @account.cancelled? + assert_nil @account.cancellation + end + + test "reactivate does nothing if not cancelled" do + assert_not @account.cancelled? + + assert_nothing_raised do + @account.reactivate + end + + assert_not @account.cancelled? + end + + test "active scope excludes cancelled accounts" do + account2 = accounts(:initech) + + initial_active_count = Account.active.count + + @account.cancel(initiated_by: @user) + + assert_equal initial_active_count - 1, Account.active.count + assert_not_includes Account.active, @account + assert_includes Account.active, account2 + end +end diff --git a/test/models/account/cancellation_test.rb b/test/models/account/cancellation_test.rb new file mode 100644 index 000000000..ac88d46df --- /dev/null +++ b/test/models/account/cancellation_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Account::CancellationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/account/incineratable_test.rb b/test/models/account/incineratable_test.rb new file mode 100644 index 000000000..021f751ba --- /dev/null +++ b/test/models/account/incineratable_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class Account::IncineratableTest < ActiveSupport::TestCase + setup do + @account = accounts(:"37s") + @user = users(:david) + end + + test "incinerate destroys account" do + assert_difference -> { Account.count }, -1 do + @account.incinerate + end + + assert_not Account.exists?(@account.id) + end + + test "due_for_incineration finds old cancellations" do + @account.cancel(initiated_by: @user) + + @account.cancellation.update!(created_at: 31.days.ago) + assert_equal [ @account ], Account.due_for_incineration + + @account.cancellation.update!(created_at: 29.days.ago) + assert Account.due_for_incineration.empty? + end +end diff --git a/test/models/notification/bundle_test.rb b/test/models/notification/bundle_test.rb index 612087682..2a8b242af 100644 --- a/test/models/notification/bundle_test.rb +++ b/test/models/notification/bundle_test.rb @@ -1,6 +1,8 @@ require "test_helper" class Notification::BundleTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + setup do @user = users(:david) @user.settings.bundle_email_every_few_hours! @@ -160,4 +162,19 @@ class Notification::BundleTest < ActiveSupport::TestCase assert_includes @user.notification_bundles.last.notifications, first_notification assert_includes @user.notification_bundles.last.notifications, second_notification end + + test "deliver does not send email for cancelled accounts" do + @user.notifications.create!(source: events(:logo_published), creator: @user) + bundle = @user.notification_bundles.pending.last + + @user.account.cancel(initiated_by: @user) + + assert_no_emails do + deliver_enqueued_emails do + bundle.deliver + end + end + + assert bundle.delivered?, "Bundle should be marked as delivered even if not sent" + end end diff --git a/test/models/notification_pusher_test.rb b/test/models/notification_pusher_test.rb new file mode 100644 index 000000000..21b9e202b --- /dev/null +++ b/test/models/notification_pusher_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class NotificationPusherTest < ActiveSupport::TestCase + setup do + @user = users(:david) + @notification = @user.notifications.create!( + source: events(:logo_published), + creator: users(:jason) + ) + @pusher = NotificationPusher.new(@notification) + + @user.push_subscriptions.create!( + endpoint: "https://fcm.googleapis.com/fcm/send/test123", + p256dh_key: "test_key", + auth_key: "test_auth" + ) + end + + test "push does not send notifications for cancelled accounts" do + @user.account.cancel(initiated_by: @user) + + result = @pusher.push + + assert_nil result, "Should not push notifications for cancelled accounts" + end + + test "push sends notifications for active accounts with subscriptions" do + result = @pusher.push + + assert_not_nil result, "Should push notifications for active accounts with subscriptions" + end +end diff --git a/test/models/webhook/triggerable_test.rb b/test/models/webhook/triggerable_test.rb new file mode 100644 index 000000000..bd6d4b0a8 --- /dev/null +++ b/test/models/webhook/triggerable_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class Webhook::TriggerableTest < ActiveSupport::TestCase + setup do + @account = accounts(:"37s") + @board = boards(:writebook) + @card = @board.cards.first + @webhook = @board.webhooks.create!( + name: "Test Webhook", + url: "https://example.com/webhook", + subscribed_actions: [ "card_published" ] + ) + # Create a test event + @event = @board.events.create!( + creator: users(:david), + eventable: @card, + action: "card_published" + ) + @user = users(:david) + end + + test "trigger creates delivery for active accounts" do + assert_difference -> { Webhook::Delivery.count }, 1 do + @webhook.trigger(@event) + end + + delivery = Webhook::Delivery.last + assert_equal @event, delivery.event + assert_equal @webhook, delivery.webhook + end + + test "trigger skips cancelled accounts" do + @account.cancel(initiated_by: @user) + + assert_no_difference -> { Webhook::Delivery.count } do + @webhook.trigger(@event) + end + end + + test "triggered_by scope finds webhooks for event" do + other_webhook = @board.webhooks.create!( + name: "Other Webhook", + url: "https://example.com/other", + subscribed_actions: [ "card_closed" ] + ) + + matching_webhooks = Webhook.triggered_by(@event) + + assert_includes matching_webhooks, @webhook + assert_not_includes matching_webhooks, other_webhook + end + + test "active scope only returns active webhooks" do + @webhook.update!(active: false) + + assert_not_includes Webhook.active, @webhook + end +end From ae78f25b06787923e402d859fb6be3ba5ed1d52d Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 12 Jan 2026 14:55:31 +0100 Subject: [PATCH 10/34] Fix incorrect recurring job config --- config/recurring.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/recurring.yml b/config/recurring.yml index b044a4978..bb527e891 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -28,7 +28,7 @@ production: &production command: "Account::Export.cleanup" schedule: every hour at minute 20 incineration: - job: Account::IncinerateDueJob + class_name: Account::IncinerateDueJob schedule: every 8 hours at minute 16 <% if Fizzy.saas? %> From 74c7ed30811939829b0a6ac7194409f96a1fb19c Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 12 Jan 2026 14:56:51 +0100 Subject: [PATCH 11/34] Explicitly define incineration priority --- config/queue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/queue.yml b/config/queue.yml index 722da40b1..50a127210 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -3,7 +3,7 @@ default: &default - polling_interval: 1 batch_size: 500 workers: - - queues: [ "default", "solid_queue_recurring", "backend", "webhooks", "*" ] + - queues: [ "default", "solid_queue_recurring", "backend", "webhooks", "incineration", "*" ] threads: 3 processes: <%= Integer(ENV.fetch("JOB_CONCURRENCY") { Concurrent.physical_processor_count }) %> polling_interval: 0.1 From 5f11ee154fb99621e9ccaac450aaf7f6b4b585ff Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 12 Jan 2026 15:05:00 +0100 Subject: [PATCH 12/34] Fix attribute name --- config/recurring.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/recurring.yml b/config/recurring.yml index bb527e891..8aba572ca 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -28,7 +28,7 @@ production: &production command: "Account::Export.cleanup" schedule: every hour at minute 20 incineration: - class_name: Account::IncinerateDueJob + class: "Account::IncinerateDueJob" schedule: every 8 hours at minute 16 <% if Fizzy.saas? %> From 7c7b4eb6218d6abaf7e98082707e77d523e3d838 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 12 Jan 2026 15:13:46 +0100 Subject: [PATCH 13/34] Remove explicit queue definition --- config/queue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/queue.yml b/config/queue.yml index 50a127210..722da40b1 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -3,7 +3,7 @@ default: &default - polling_interval: 1 batch_size: 500 workers: - - queues: [ "default", "solid_queue_recurring", "backend", "webhooks", "incineration", "*" ] + - queues: [ "default", "solid_queue_recurring", "backend", "webhooks", "*" ] threads: 3 processes: <%= Integer(ENV.fetch("JOB_CONCURRENCY") { Concurrent.physical_processor_count }) %> polling_interval: 0.1 From 6895135977e3d4fbe4493b1a5593aa4f115f4020 Mon Sep 17 00:00:00 2001 From: secretpray Date: Mon, 12 Jan 2026 18:44:35 +0200 Subject: [PATCH 14/34] Fix deprecation warnings for skip_before_action :verify_authenticity_token Replace deprecated `skip_before_action :verify_authenticity_token` with `skip_forgery_protection` to resolve Rails deprecation warnings. Changes: - app/controllers/notifications/unsubscribes_controller.rb - saas/app/controllers/stripe/webhooks_controller.rb --- app/controllers/notifications/unsubscribes_controller.rb | 2 +- saas/app/controllers/stripe/webhooks_controller.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/notifications/unsubscribes_controller.rb b/app/controllers/notifications/unsubscribes_controller.rb index f7486d91e..d1842b169 100644 --- a/app/controllers/notifications/unsubscribes_controller.rb +++ b/app/controllers/notifications/unsubscribes_controller.rb @@ -1,6 +1,6 @@ class Notifications::UnsubscribesController < ApplicationController allow_unauthenticated_access - skip_before_action :verify_authenticity_token + skip_forgery_protection before_action :set_user diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index cdfb6bcec..13dcd4699 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -1,7 +1,7 @@ class Stripe::WebhooksController < ApplicationController allow_unauthenticated_access skip_before_action :require_account - skip_before_action :verify_authenticity_token + skip_forgery_protection def create if event = verify_webhook_signature From 006e61ea5898ad470a7ef295895ca8b83e60a1ef Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 12 Jan 2026 10:30:18 -0800 Subject: [PATCH 15/34] Allow ActionText embed reuse and safe purge (#2346) --- .../initializers/active_storage_no_reuse.rb | 35 +++++---- ...active_storage_purge_on_last_attachment.rb | 40 ++++++++++ script/migrations/backfill-storage-ledger.rb | 6 +- test/models/storage/no_reuse_test.rb | 77 +++++++++++++++++++ 4 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 config/initializers/active_storage_purge_on_last_attachment.rb diff --git a/config/initializers/active_storage_no_reuse.rb b/config/initializers/active_storage_no_reuse.rb index 2def087fe..0bd057cde 100644 --- a/config/initializers/active_storage_no_reuse.rb +++ b/config/initializers/active_storage_no_reuse.rb @@ -16,6 +16,8 @@ # attached to an untracked type (avatar/export) could theoretically be reused in a tracked # context. This is acceptable - user-accessible blob IDs from untracked contexts are # basically nonexistent in practice. +# +# Exception: ActionText embeds are allowed to reuse blobs to support copy/paste. ActiveSupport.on_load(:active_storage_attachment) do validate :blob_account_matches_record, on: :create @@ -29,30 +31,31 @@ ActiveSupport.on_load(:active_storage_attachment) do # bypass tenancy checks via try(:account) returning nil - this is intentional as # these classes don't participate in storage tracking. def blob_account_matches_record - return unless record&.try(:account).present? - return if whitelisted_for_cross_account? - - unless blob&.account_id == record.account.id - errors.add(:blob_id, "blob account must match record account") + if record&.try(:account).present? && !whitelisted_for_cross_account? + unless blob&.account_id == record.account.id + errors.add(:blob_id, "blob account must match record account") + end end end # Ledger integrity: blob can only have one tracked attachment def no_tracked_blob_reuse tracked_record = record&.try(:storage_tracked_record) - return unless tracked_record.present? - return if whitelisted_for_cross_account? + if tracked_record.present? && + !whitelisted_for_cross_account? && + !(record_type == "ActionText::RichText" && name == "embeds") - # Check for existing attachment of this blob in tracked contexts - # Uses Storage::TRACKED_RECORD_TYPES constant to stay generic - existing = ActiveStorage::Attachment - .where(blob_id: blob_id) - .where(record_type: Storage::TRACKED_RECORD_TYPES) - .where.not(id: id) - .exists? + # Check for existing attachment of this blob in tracked contexts + # Uses Storage::TRACKED_RECORD_TYPES constant to stay generic + existing = ActiveStorage::Attachment + .where(blob_id: blob_id) + .where(record_type: Storage::TRACKED_RECORD_TYPES) + .where.not(id: id) + .exists? - if existing - errors.add(:blob_id, "cannot reuse blob in tracked storage context") + if existing + errors.add(:blob_id, "cannot reuse blob in tracked storage context") + end end end diff --git a/config/initializers/active_storage_purge_on_last_attachment.rb b/config/initializers/active_storage_purge_on_last_attachment.rb new file mode 100644 index 000000000..cbe0f0f13 --- /dev/null +++ b/config/initializers/active_storage_purge_on_last_attachment.rb @@ -0,0 +1,40 @@ +# Fizzy-specific override: ActiveStorage's default purge path uses `delete`, +# which skips attachment callbacks. We need `destroy` so storage ledger detaches +# are recorded and reused blobs (ActionText embeds) aren't purged until the +# last attachment is gone. Keep this local to Fizzy; it's not a Rails default. +module ActiveStorage + module PurgeOnLastAttachment + def purge + @purge_mode = :purge + destroy + purge_blob_if_last(:purge) if destroyed? + ensure + @purge_mode = nil + end + + def purge_later + @purge_mode = :purge_later + destroy + purge_blob_if_last(:purge_later) if destroyed? + ensure + @purge_mode = nil + end + + private + def purge_dependent_blob_later + if dependent == :purge_later && !@purge_mode + purge_blob_if_last(:purge_later) + end + end + + def purge_blob_if_last(mode) + if blob && !blob.attachments.exists? + mode == :purge ? blob.purge : blob.purge_later + end + end + end +end + +ActiveSupport.on_load(:active_storage_attachment) do + prepend ActiveStorage::PurgeOnLastAttachment +end diff --git a/script/migrations/backfill-storage-ledger.rb b/script/migrations/backfill-storage-ledger.rb index 5d89342fc..aa3ec793f 100644 --- a/script/migrations/backfill-storage-ledger.rb +++ b/script/migrations/backfill-storage-ledger.rb @@ -10,17 +10,19 @@ # # Safe to re-run: skips attachments that already have entries (by blob_id + recordable). # -# IMPORTANT: Before running in production, verify zero historic blob reuse in tracked contexts: +# OPTIONAL: If you want to enforce no-reuse for direct attachments, verify there are +# no existing violations (ActionText embeds may legitimately reuse blobs): # # ActiveStorage::Attachment # .joins(:blob) # .where(record_type: Storage::TRACKED_RECORD_TYPES) +# .where.not(record_type: "ActionText::RichText") # .where.not(active_storage_blobs: { account_id: Storage::TEMPLATE_ACCOUNT_ID }) # .select(:blob_id) # .group(:blob_id) # .having("COUNT(*) > 1") # .count -# # Should return empty hash if no reuse exists in tracked contexts +# # Should return empty hash if no direct-attachment reuse exists # # If reuse exists (excluding template blobs), fix the data first. class BackfillStorageLedger diff --git a/test/models/storage/no_reuse_test.rb b/test/models/storage/no_reuse_test.rb index 5af07511f..e876fe50c 100644 --- a/test/models/storage/no_reuse_test.rb +++ b/test/models/storage/no_reuse_test.rb @@ -33,6 +33,83 @@ class Storage::NoReuseTest < ActiveSupport::TestCase assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count end + test "allows reuse for ActionText embeds" do + file = file_fixture("moon.jpg") + blob = ActiveStorage::Blob.create_and_upload! \ + io: file.open, + filename: "embed.jpg", + content_type: "image/jpeg" + + embed_html = ActionText::Attachment.from_attachable(blob).to_html + + card1 = @board.cards.create!(title: "Card 1", creator: users(:david)) + card1.update!(description: "

#{embed_html}

") + card1.reload + + card2 = @board.cards.create!(title: "Card 2", creator: users(:david)) + card2.update!(description: "

#{embed_html}

") + card2.reload + + assert_equal 2, ActiveStorage::Attachment.where( + record_type: "ActionText::RichText", + name: "embeds", + blob_id: blob.id + ).count + end + + test "purge_later does not purge blob when still attached elsewhere" do + file = file_fixture("moon.jpg") + blob = ActiveStorage::Blob.create_and_upload! \ + io: file.open, + filename: "embed.jpg", + content_type: "image/jpeg" + + embed_html = ActionText::Attachment.from_attachable(blob).to_html + + card1 = @board.cards.create!(title: "Card 1", creator: users(:david)) + card1.update!(description: "

#{embed_html}

") + + card2 = @board.cards.create!(title: "Card 2", creator: users(:david)) + card2.update!(description: "

#{embed_html}

") + + attachment = ActiveStorage::Attachment.find_by( + record: card1.rich_text_description, + name: "embeds", + blob_id: blob.id + ) + + assert_no_enqueued_jobs only: ActiveStorage::PurgeJob do + attachment.purge_later + end + + assert ActiveStorage::Blob.exists?(blob.id) + assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count + end + + test "purge_later enqueues purge when last attachment is removed" do + file = file_fixture("moon.jpg") + blob = ActiveStorage::Blob.create_and_upload! \ + io: file.open, + filename: "embed.jpg", + content_type: "image/jpeg" + + embed_html = ActionText::Attachment.from_attachable(blob).to_html + + card = @board.cards.create!(title: "Card", creator: users(:david)) + card.update!(description: "

#{embed_html}

") + card.reload + + attachment = ActiveStorage::Attachment.find_by( + record: card.rich_text_description, + name: "embeds", + blob_id: blob.id + ) + + assert_enqueued_with job: ActiveStorage::PurgeJob, args: [ blob ] do + attachment.purge_later + end + end + test "rejects cross-account blob attachment" do other_account = Account.create!(name: "Other") other_board = other_account.boards.create!(name: "Other Board", creator: users(:david)) From 99e89606b45da36bd83be41fd636625f877a0e50 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 12 Jan 2026 13:06:30 -0600 Subject: [PATCH 16/34] Keep strong tags words on same line --- saas/app/views/account/settings/_free_plan.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb index dacb7e150..edb5bb57a 100644 --- a/saas/app/views/account/settings/_free_plan.html.erb +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -10,7 +10,7 @@ <% end %> -

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= format_currency(Plan.paid.price) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

+

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= format_currency(Plan.paid.price) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

<%= button_to "Upgrade to #{Plan.paid.name} for #{ format_currency(Plan.paid.price) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> From af3fa177aa36a4082bf59c1613c918b466bedbf6 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 12 Jan 2026 13:07:34 -0600 Subject: [PATCH 17/34] Phrasing tweak for exceeding storage limit --- saas/app/views/account/settings/_free_plan.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb index edb5bb57a..ca467d8af 100644 --- a/saas/app/views/account/settings/_free_plan.html.erb +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -4,7 +4,7 @@ <% elsif Current.account.exceeding_card_limit? %> You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards <% elsif Current.account.exceeding_storage_limit? %> - You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> free storage + You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage <% else %> You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %> <% end %> From 8bb043bef817c6a66a13e025824b2216fb720b4b Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 12 Jan 2026 13:14:37 -0600 Subject: [PATCH 18/34] Move Undo form inside closure message element --- app/assets/stylesheets/card-perma.css | 4 ++++ app/views/cards/container/_closure.html.erb | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 240dadb4b..8d1340c23 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -343,5 +343,9 @@ grid-area: closure-message; margin-block-start: 0.5ch; padding-inline: 1ch; + + form { + display: inline; + } } } diff --git a/app/views/cards/container/_closure.html.erb b/app/views/cards/container/_closure.html.erb index 09b7a4ef4..8aa190c78 100644 --- a/app/views/cards/container/_closure.html.erb +++ b/app/views/cards/container/_closure.html.erb @@ -2,10 +2,12 @@ <% if card.closed? %>
- Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>. - <%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %> - Undo - <% end %> + + Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>. + <%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %> + Undo + <% end %> +
<% else %> From ca5bca979628f9a81a393023d9148583078e301a Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 12 Jan 2026 13:18:39 -0600 Subject: [PATCH 19/34] Bump boost size up a tick on mobile --- app/assets/stylesheets/comments.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index a8956a98e..8fef15ebb 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -4,7 +4,7 @@ --comment-padding-block: var(--block-space-half); --comment-padding-inline: var(--inline-space-double); --comment-max: 70ch; - --reaction-size: 2rem; + --reaction-size: 2.25rem; display: flex; flex-direction: column; @@ -130,6 +130,10 @@ .comment__edit { background-color: var(--color-ink-lightest); + + &:hover { + z-index: 1; + } } .comment__permalink-title { From 025d70dd130702be957497a5a8a0985095246c5e Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 7 Jan 2026 14:13:15 +0100 Subject: [PATCH 20/34] Use separate cache namespaces for each beta instance Otherwise we end up with views including URLs from all the betas, leading to some confusion and problems with the CSP. --- config/cache.yml | 2 +- saas/config/deploy.beta.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/config/cache.yml b/config/cache.yml index d1716bb58..302a4f878 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -1,7 +1,7 @@ default_options: &default_options store_options: max_age: <%= 60.days.to_i %> - namespace: <%= Rails.env %> + namespace: <%= "#{Rails.env}#{"-#{ENV["CACHE_NAMESPACE"]}" if ENV["CACHE_NAMESPACE"]}" %> default_connection: &default_connection database: cache diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index 14bf89698..d5fce9d32 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -75,6 +75,7 @@ ssh: env: clear: APP_FQDN: beta<%= @beta_number %>.fizzy-beta.com + CACHE_NAMESPACE: <%= @beta_number %> RAILS_ENV: beta RAILS_LOG_LEVEL: fatal # suppress unstructured log lines MYSQL_DATABASE_HOST: fizzy-mysql-primary From df7df74752d6663337558a64ebfd13ab6f48bb5d Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 12 Jan 2026 13:31:36 -0600 Subject: [PATCH 21/34] Brighten mini bubbles in dark mode --- app/assets/stylesheets/card-columns.css | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 6aaacaf8b..0cf3df345 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -749,11 +749,12 @@ .cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container { --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%; + --bubble-opacity: 50%; &:before { background: radial-gradient( - color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%, - color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100% + color-mix(in srgb, var(--bubble-color) calc(var(--bubble-opacity) / 5), var(--color-canvas)) 50%, + color-mix(in srgb, var(--bubble-color) var(--bubble-opacity), var(--color-canvas)) 100% ); block-size: 1em; border-radius: var(--bubble-shape); @@ -777,6 +778,22 @@ z-index: -1; } } + + @media (max-width: 639px) { + &.cards__expander-title:before { + display: none; + } + } + + html[data-theme="dark"] & { + --bubble-opacity: 100%; + } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + --bubble-opacity: 100%; + } + } } /* Card column indicators From 8288e85df6d26a61e554a5913afc617a477d7835 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 12 Jan 2026 13:34:30 -0600 Subject: [PATCH 22/34] More sturated mini bubbles in light mode --- app/assets/stylesheets/card-columns.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 0cf3df345..f60e8da73 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -748,8 +748,8 @@ .cards--maybe.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container, .cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container { --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); + --bubble-opacity: 75%; --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%; - --bubble-opacity: 50%; &:before { background: radial-gradient( From 4dedfce39f9879aeff3089418f5ceb8012a6533b Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 12 Jan 2026 14:00:37 -0600 Subject: [PATCH 23/34] Add dialog manager to events page --- app/views/events/index.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 1279e4dac..5e0744d26 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -6,7 +6,7 @@ <% content_for :header do %> <%= render "events/index/add_card_button", user_filtering: @user_filtering %> -

+

<% if @user_filtering.boards.many? %> Activity <%= @user_filtering.filter.boards.any? ? "in" : "across" %> <% else %> From 64fdbf6b8fef564a7d7e47cebb6cf732fe208251 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Mon, 12 Jan 2026 15:37:06 -0600 Subject: [PATCH 24/34] Clean up blank slates --- app/assets/stylesheets/blank-slates.css | 51 +++++-------------- app/assets/stylesheets/card-columns.css | 28 +++++++++- app/assets/stylesheets/notifications.css | 2 +- app/assets/stylesheets/search.css | 2 +- .../columns/_empty_placeholder.html.erb | 6 +-- .../boards/show/_filtered_cards.html.erb | 2 +- app/views/cards/index.html.erb | 2 +- app/views/my/menus/_jump.html.erb | 2 +- .../index/_unread_notifications.html.erb | 2 +- .../boards/columns/closeds/show.html.erb | 2 +- .../boards/columns/not_nows/show.html.erb | 2 +- app/views/public/boards/columns/show.html.erb | 2 +- .../boards/columns/streams/show.html.erb | 2 +- app/views/searches/_results.html.erb | 2 +- test/controllers/searches_controller_test.rb | 2 +- 15 files changed, 54 insertions(+), 55 deletions(-) diff --git a/app/assets/stylesheets/blank-slates.css b/app/assets/stylesheets/blank-slates.css index aa221c452..90532c33c 100644 --- a/app/assets/stylesheets/blank-slates.css +++ b/app/assets/stylesheets/blank-slates.css @@ -1,46 +1,21 @@ +/* Styles for the blank slate. To manage when they are shown/hidden, do so in context */ + @layer components { - .blank-slate--drag { - box-shadow: none !important; - color: color-mix(in srgb, var(--card-color) 70%, var(--color-canvas)); - - p { - font-size: var(--text-small); - overflow: hidden; - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; - } - - .cards--maybe & { - background-color: var(--card-bg-color) !important; - } - - .cards--grid & { - display: none; - } - } - - .blank-slate--empty { - border: 2px dashed var(--color-ink-light); + .blank-slate { border-radius: 1ch; + border: 2px dashed var(--color-ink-lighter); color: var(--color-ink-dark); - margin-block-start: 2dvh; - padding: 1ch 2ch; - rotate: -3deg; font-weight: 500; - - .cards:has(.card) & { - display: none; - } + margin-block-start: 2dvh; + padding: 1.5ch 2ch; + rotate: -3deg; } - .blank-slate--filters { - .cards--grid:has(.card) & { - display: none; - } - } - - .cards__list:has(> :not(.blank-slate)) .card--hide-unless-empty { - display: none; + .blank-slate--drag { + background-color: color-mix(in srgb, transparent, var(--card-color) 5%); + border-color: color-mix(in srgb, transparent, var(--card-color) 10%); + color: color-mix(in srgb, transparent, var(--card-color) 75%); + margin-block-start: 0; + rotate: 0deg; } } diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index f60e8da73..4edb06c64 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -255,6 +255,27 @@ } } } + + &:has(.card) { + .blank-slate { + display: none; + } + } + + /* Use the default blank-slate on small viewports since drag-and-drop isn't available */ + [data-controller~="drag-and-drop"] & { + @media (max-width: 639px) { + .blank-slate--drag { + display: none; + } + } + + @media (min-width: 640px) { + .blank-slate--default { + display: none; + } + } + } } .cards__new-column { @@ -315,6 +336,10 @@ inline-size: fit-content; margin: 0 0 0 auto; } + + .blank-slate--drag { + display: none; + } } /* Column Elements @@ -731,7 +756,8 @@ .cards--on-deck { --card-color: var(--color-ink-light) !important; - .card { + .card, + .blank-slate { --card-color: var(--color-card-complete) !important; } diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css index ada7324d2..91ff5750d 100644 --- a/app/assets/stylesheets/notifications.css +++ b/app/assets/stylesheets/notifications.css @@ -30,7 +30,7 @@ } &:has(.card--notification) { - .notifications-list__empty-message { + .notifications-list__blank-slate { display: none; } } diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css index fcd4563ca..5691ac5e5 100644 --- a/app/assets/stylesheets/search.css +++ b/app/assets/stylesheets/search.css @@ -71,7 +71,7 @@ summary { text-align: start; } - .search__empty { + .search__blank-slate { margin-block: 3em; margin-inline: auto; inline-size: fit-content; diff --git a/app/views/boards/columns/_empty_placeholder.html.erb b/app/views/boards/columns/_empty_placeholder.html.erb index d9da8a896..21e709b84 100644 --- a/app/views/boards/columns/_empty_placeholder.html.erb +++ b/app/views/boards/columns/_empty_placeholder.html.erb @@ -1,4 +1,2 @@ -
-

Drag cards here

-
-
No cards here
+
No cards here
+
Drag cards here
diff --git a/app/views/boards/show/_filtered_cards.html.erb b/app/views/boards/show/_filtered_cards.html.erb index 4e4d8f749..53b25f80f 100644 --- a/app/views/boards/show/_filtered_cards.html.erb +++ b/app/views/boards/show/_filtered_cards.html.erb @@ -3,6 +3,6 @@ <%= with_automatic_pagination :filtered_cards_paginated_container, page do %> <%= render "cards/display/previews", cards: page.records, draggable: true %> <% end %> -
No cards match this filter
+
No cards match this filter
diff --git a/app/views/cards/index.html.erb b/app/views/cards/index.html.erb index a7e4769f5..20bbfac98 100644 --- a/app/views/cards/index.html.erb +++ b/app/views/cards/index.html.erb @@ -23,7 +23,7 @@ <%= with_automatic_pagination :cards_paginated_container, @page do %> <%= render "cards/display/previews", cards: @page.records, draggable: true %> <% end %> -
No cards match this filter
+
No cards match this filter
<% end %> diff --git a/app/views/my/menus/_jump.html.erb b/app/views/my/menus/_jump.html.erb index 1f1ea70c9..f22bedf23 100644 --- a/app/views/my/menus/_jump.html.erb +++ b/app/views/my/menus/_jump.html.erb @@ -26,7 +26,7 @@ <%= yield %> - diff --git a/app/views/notifications/index/_unread_notifications.html.erb b/app/views/notifications/index/_unread_notifications.html.erb index b8b9618d1..4890a4822 100644 --- a/app/views/notifications/index/_unread_notifications.html.erb +++ b/app/views/notifications/index/_unread_notifications.html.erb @@ -5,7 +5,7 @@ <%= button_to "Mark all as read", bulk_reading_path, class: "btn txt-small", form: { data: { turbo: false } }, data: { action: "badge#clear" } %> <% else %> -
+
Nothing new for you
<% end %> diff --git a/app/views/public/boards/columns/closeds/show.html.erb b/app/views/public/boards/columns/closeds/show.html.erb index 21977743e..61ce3887d 100644 --- a/app/views/public/boards/columns/closeds/show.html.erb +++ b/app/views/public/boards/columns/closeds/show.html.erb @@ -18,7 +18,7 @@ <%= render "cards/display/public_previews", cards: @page.records %> <% end %> <% else %> -
No cards here
+
No cards here
<% end %>
<% end %> diff --git a/app/views/public/boards/columns/not_nows/show.html.erb b/app/views/public/boards/columns/not_nows/show.html.erb index 786cb7afb..3dad8bcfe 100644 --- a/app/views/public/boards/columns/not_nows/show.html.erb +++ b/app/views/public/boards/columns/not_nows/show.html.erb @@ -18,7 +18,7 @@ <%= render "cards/display/public_previews", cards: @page.records %> <% end %> <% else %> -
No cards here
+
No cards here
<% end %> <% end %> diff --git a/app/views/public/boards/columns/show.html.erb b/app/views/public/boards/columns/show.html.erb index 6f0d2542f..a8a86451b 100644 --- a/app/views/public/boards/columns/show.html.erb +++ b/app/views/public/boards/columns/show.html.erb @@ -18,7 +18,7 @@ <%= render "cards/display/public_previews", cards: @page.records %> <% end %> <% else %> -
No cards here
+
No cards here
<% end %> <% end %> diff --git a/app/views/public/boards/columns/streams/show.html.erb b/app/views/public/boards/columns/streams/show.html.erb index 4df8c2b12..5566a7955 100644 --- a/app/views/public/boards/columns/streams/show.html.erb +++ b/app/views/public/boards/columns/streams/show.html.erb @@ -18,7 +18,7 @@ <%= render "cards/display/public_previews", cards: @page.records %> <% end %> <% else %> -
No cards here
+
No cards here
<% end %> <% end %> diff --git a/app/views/searches/_results.html.erb b/app/views/searches/_results.html.erb index 602878c65..0b622e95d 100644 --- a/app/views/searches/_results.html.erb +++ b/app/views/searches/_results.html.erb @@ -1,7 +1,7 @@