From 17112fa0eed19eeec5bf7eeff1ff308168173235 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 9 Mar 2026 08:58:36 -0700
Subject: [PATCH 01/43] Bump brakeman from 8.0.2 to 8.0.4 in the
development-dependencies group (#2669)
* Bump brakeman from 8.0.2 to 8.0.4 in the development-dependencies group
Bumps the development-dependencies group with 1 update: [brakeman](https://github.com/presidentbeef/brakeman).
Updates `brakeman` from 8.0.2 to 8.0.4
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v8.0.2...v8.0.4)
---
updated-dependencies:
- dependency-name: brakeman
dependency-version: 8.0.4
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: development-dependencies
...
Signed-off-by: dependabot[bot]
* Sync Gemfile.saas.lock
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot]
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 4e3653aeb..3f8461b39 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -158,7 +158,7 @@ GEM
bindex (0.8.1)
bootsnap (1.23.0)
msgpack (~> 1.2)
- brakeman (8.0.2)
+ brakeman (8.0.4)
racc
builder (3.3.0)
bundler-audit (0.9.3)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 0e35effac..152d1889c 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -248,7 +248,7 @@ GEM
bindex (0.8.1)
bootsnap (1.23.0)
msgpack (~> 1.2)
- brakeman (8.0.2)
+ brakeman (8.0.4)
racc
builder (3.3.0)
bundler-audit (0.9.3)
From 9585d13f5f04080b0e2875c8d223e823758919ca Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 9 Mar 2026 08:58:45 -0700
Subject: [PATCH 02/43] Bump actions/attest-build-provenance from 3.2.0 to
4.1.0 (#2668)
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 3.2.0 to 4.1.0.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/v3.2.0...v4.1.0)
---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
dependency-version: 4.1.0
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/publish-image.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml
index 953f09b72..9d80d143d 100644
--- a/.github/workflows/publish-image.yml
+++ b/.github/workflows/publish-image.yml
@@ -101,7 +101,7 @@ jobs:
- name: Attest image provenance (per-arch)
if: github.event_name != 'pull_request'
- uses: actions/attest-build-provenance@v3.2.0
+ uses: actions/attest-build-provenance@v4.1.0
with:
subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }}
From a896b0a9e6bfe264c3ba5621038f0a13d327cff9 Mon Sep 17 00:00:00 2001
From: Rob Zolkos
Date: Mon, 9 Mar 2026 13:59:57 -0400
Subject: [PATCH 03/43] Touch board when publication is created or destroyed
(#2674)
Publishing or unpublishing a board didn't bust the `json.cache! board`
fragment cache because `Board::Publication` didn't touch its parent
board. The board's `updated_at` stayed stale, so subsequent GET requests
continued serving cached JSON without `public_url`.
---
app/models/board/publication.rb | 2 +-
test/models/board/publishable_test.rb | 14 ++++++++++++++
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/app/models/board/publication.rb b/app/models/board/publication.rb
index bd480eecc..20dcc40da 100644
--- a/app/models/board/publication.rb
+++ b/app/models/board/publication.rb
@@ -1,6 +1,6 @@
class Board::Publication < ApplicationRecord
belongs_to :account, default: -> { board.account }
- belongs_to :board
+ belongs_to :board, touch: true
has_secure_token :key
end
diff --git a/test/models/board/publishable_test.rb b/test/models/board/publishable_test.rb
index e05f19bd2..8feadfd73 100644
--- a/test/models/board/publishable_test.rb
+++ b/test/models/board/publishable_test.rb
@@ -42,6 +42,20 @@ class Board::PublishableTest < ActiveSupport::TestCase
end
end
+ test "touch board when publication is created" do
+ assert_changes -> { boards(:writebook).reload.updated_at } do
+ boards(:writebook).publish
+ end
+ end
+
+ test "touch board when publication is destroyed" do
+ boards(:writebook).publish
+
+ assert_changes -> { boards(:writebook).reload.updated_at } do
+ boards(:writebook).unpublish
+ end
+ end
+
test "publish doesn't create duplicate publications" do
boards(:writebook).publish
original_publication = boards(:writebook).publication
From 723c8180b4295f387aa170db22582e5f4826cbcf Mon Sep 17 00:00:00 2001
From: Jeremy Daer
Date: Mon, 9 Mar 2026 11:39:49 -0700
Subject: [PATCH 04/43] Add JSON response format for full SDK/CLI coverage
(#2675)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Add JSON response format to void-response controller actions
Add respond_to blocks with JSON format to 14 controllers that
previously only rendered HTML or Turbo Stream responses. JSON
callers get head :no_content (or :created/:ok for push
subscriptions, distinguishing new vs existing).
Covers: board involvements, board/account entropies, column
reordering, card readings, card publishing, user roles, user
avatars, account settings, notification settings, join codes
(with 422 error branch), and push subscriptions.
* Add steps index endpoint with JSON view
Add index action to Cards::StepsController so SDK/CLI callers
can list all steps for a card. Reuses the existing _step.json
partial.
* Add JSON views for paginated card list endpoints
Add show.json.jbuilder for stream, not-now, and closed column
endpoints. No controller changes needed — set_page_and_extract_
portion_from and fresh_when already work format-agnostically.
* Add JSON search endpoint with distinct-card pagination
JSON search paginates distinct Card records (via the existing
Card.mentioning scope with .distinct) rather than Search::Record
objects. This ensures page boundaries, Link headers, and counts
reflect unique cards — no short pages from post-pagination dedup.
ID-match searches return a uniform single-element Card[] array
through the same pagination path.
* Add JSON views for settings and configuration endpoints
Add show.json.jbuilder for account settings (name), join codes
(code, usage_count, usage_limit, url, active), and notification
settings (bundle_email_frequency). Tests for show and update
were added in the void-response commit.
* Add JSON response format to data exports
Create returns 201 with export id, status, and created_at so
callers can poll. Show returns any-status exports for JSON (not
just completed) with a download_url when ready, or 404 for
missing/other-user exports.
* Extend access token JSON API
Add index.json and _access_token.json partial for listing tokens.
Add JSON format to destroy. Include id and created_at in the
create response alongside the existing token/description/permission
fields.
* Fix ambiguous precedence warning in export JSON view
* Assert X-Total-Count pagination header in streams JSON test
* Address review feedback
- Guard steps index against HTML requests (redirect to card)
- Normalize created_at to UTC in access token and export JSON
- Add deterministic ordering (.latest) to search JSON pagination
* Return 406 for HTML requests to steps index instead of redirect
* Add wrap_parameters for flat SDK JSON payload compatibility
Five controllers infer the wrong wrapper key from their class
name, so flat JSON bodies like {"role":"admin"} fail with 400.
Add explicit wrap_parameters declarations:
- Users::RolesController → :user
- Notifications::SettingsController → :user_settings
- Account::JoinCodesController → :account_join_code
- Account::SettingsController → :account
- Boards::EntropiesController → :board
Add integration tests that send flat (unwrapped) JSON payloads
for all seven param-bearing endpoints to prove SDK compatibility.
* Address PR review feedback
- Use 201 Created (not 204) for all create actions: publishes, readings,
left/right positions, push subscriptions
- Simplify push subscription to always return :created (no 200/201 variance)
- Remove superfluous respond_to block from steps#index
- Merge exports#show into single respond_to block
- Move export download_url conditional out of inline args
- Ensure join code active is explicitly boolean
- Remove extra parens from search controller assignment
- Add blank lines between format blocks for readability
* Return JSON bodies on create actions
Create actions for boards, cards, columns, comments, and steps now
render the resource as JSON (via their show views) instead of returning
empty 201 responses with only a Location header. SDKs expect a JSON
body they can deserialize into the created resource.
* Return JSON bodies on reaction create actions
Card and comment reaction creates now render the reaction as JSON
instead of returning empty 201 responses, consistent with the other
resource-creating endpoints.
* Allow access tokens API without account scope
Access tokens are identity-level (not account-scoped), so the
controller needs to work with bearer token auth and no account slug
in the URL. Skip require_account so the bearer token auth path
can proceed.
Also fix involvement test to send params in JSON body (not query
string) to match real SDK usage.
---
.../account/entropies_controller.rb | 6 +-
app/controllers/account/exports_controller.rb | 16 ++++-
.../account/join_codes_controller.rb | 18 ++++-
.../account/settings_controller.rb | 8 ++-
app/controllers/boards/columns_controller.rb | 2 +-
.../boards/entropies_controller.rb | 7 ++
.../boards/involvements_controller.rb | 6 ++
app/controllers/boards_controller.rb | 2 +-
.../cards/comments/reactions_controller.rb | 2 +-
app/controllers/cards/comments_controller.rb | 2 +-
app/controllers/cards/publishes_controller.rb | 16 +++--
app/controllers/cards/reactions_controller.rb | 2 +-
app/controllers/cards/readings_controller.rb | 10 +++
app/controllers/cards/steps_controller.rb | 6 +-
app/controllers/cards_controller.rb | 4 +-
.../columns/left_positions_controller.rb | 5 ++
.../columns/right_positions_controller.rb | 5 ++
.../my/access_tokens_controller.rb | 11 ++-
.../notifications/settings_controller.rb | 8 ++-
app/controllers/searches_controller.rb | 18 ++++-
app/controllers/users/avatars_controller.rb | 6 +-
.../users/push_subscriptions_controller.rb | 13 +++-
app/controllers/users/roles_controller.rb | 8 ++-
app/views/account/exports/show.json.jbuilder | 6 ++
.../account/join_codes/show.json.jbuilder | 3 +
app/views/account/settings/show.json.jbuilder | 1 +
.../boards/columns/closeds/show.json.jbuilder | 1 +
.../columns/not_nows/show.json.jbuilder | 1 +
.../boards/columns/streams/show.json.jbuilder | 1 +
app/views/cards/steps/index.json.jbuilder | 1 +
.../access_tokens/_access_token.json.jbuilder | 2 +
.../my/access_tokens/index.json.jbuilder | 1 +
.../notifications/settings/show.json.jbuilder | 1 +
app/views/reactions/show.json.jbuilder | 1 +
app/views/searches/show.json.jbuilder | 1 +
.../accounts/entropies_controller_test.rb | 7 ++
.../accounts/exports_controller_test.rb | 43 +++++++++++
.../accounts/join_codes_controller_test.rb | 35 +++++++++
.../accounts/settings_controller_test.rb | 14 ++++
test/controllers/api/flat_json_params_test.rb | 71 +++++++++++++++++++
.../boards/columns/closeds_controller_test.rb | 7 ++
.../columns/not_nows_controller_test.rb | 7 ++
.../boards/columns/streams_controller_test.rb | 8 +++
.../boards/columns_controller_test.rb | 1 +
.../boards/entropies_controller_test.rb | 7 ++
.../boards/involvements_controller_test.rb | 11 +++
test/controllers/boards_controller_test.rb | 1 +
.../comments/reactions_controller_test.rb | 1 +
.../cards/comments_controller_test.rb | 1 +
.../cards/publishes_controller_test.rb | 11 +++
.../cards/reactions_controller_test.rb | 1 +
.../cards/readings_controller_test.rb | 12 ++++
.../cards/steps_controller_test.rb | 14 ++++
test/controllers/cards_controller_test.rb | 1 +
.../columns/left_positions_controller_test.rb | 16 +++++
.../right_positions_controller_test.rb | 16 +++++
.../my/access_tokens_controller_test.rb | 32 +++++++++
.../notifications/settings_controller_test.rb | 15 ++++
test/controllers/searches_controller_test.rb | 30 ++++++++
.../users/avatars_controller_test.rb | 5 ++
.../push_subscriptions_controller_test.rb | 36 ++++++++++
.../users/roles_controller_test.rb | 7 ++
62 files changed, 580 insertions(+), 31 deletions(-)
create mode 100644 app/views/account/exports/show.json.jbuilder
create mode 100644 app/views/account/join_codes/show.json.jbuilder
create mode 100644 app/views/account/settings/show.json.jbuilder
create mode 100644 app/views/boards/columns/closeds/show.json.jbuilder
create mode 100644 app/views/boards/columns/not_nows/show.json.jbuilder
create mode 100644 app/views/boards/columns/streams/show.json.jbuilder
create mode 100644 app/views/cards/steps/index.json.jbuilder
create mode 100644 app/views/my/access_tokens/_access_token.json.jbuilder
create mode 100644 app/views/my/access_tokens/index.json.jbuilder
create mode 100644 app/views/notifications/settings/show.json.jbuilder
create mode 100644 app/views/reactions/show.json.jbuilder
create mode 100644 app/views/searches/show.json.jbuilder
create mode 100644 test/controllers/api/flat_json_params_test.rb
diff --git a/app/controllers/account/entropies_controller.rb b/app/controllers/account/entropies_controller.rb
index 9876e99d8..7290c1e4e 100644
--- a/app/controllers/account/entropies_controller.rb
+++ b/app/controllers/account/entropies_controller.rb
@@ -3,7 +3,11 @@ class Account::EntropiesController < ApplicationController
def update
Current.account.entropy.update!(entropy_params)
- redirect_to account_settings_path, notice: "Account updated"
+
+ respond_to do |format|
+ format.html { redirect_to account_settings_path, notice: "Account updated" }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb
index dbe0a11c1..32fb5bd42 100644
--- a/app/controllers/account/exports_controller.rb
+++ b/app/controllers/account/exports_controller.rb
@@ -6,11 +6,20 @@ class Account::ExportsController < ApplicationController
CURRENT_EXPORT_LIMIT = 10
def show
+ respond_to do |format|
+ format.html
+ format.json { @export ? render(:show) : head(:not_found) }
+ end
end
def create
- Current.account.exports.create!(user: Current.user).build_later
- redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready."
+ @export = Current.account.exports.create!(user: Current.user)
+ @export.build_later
+
+ respond_to do |format|
+ format.html { redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." }
+ format.json { render :show, status: :created }
+ end
end
private
@@ -23,6 +32,7 @@ class Account::ExportsController < ApplicationController
end
def set_export
- @export = Current.account.exports.completed.find_by(id: params[:id], user: Current.user)
+ scope = request.format.json? ? Current.account.exports : Current.account.exports.completed
+ @export = scope.find_by(id: params[:id], user: Current.user)
end
end
diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb
index 217ee603c..3df9552db 100644
--- a/app/controllers/account/join_codes_controller.rb
+++ b/app/controllers/account/join_codes_controller.rb
@@ -1,4 +1,6 @@
class Account::JoinCodesController < ApplicationController
+ wrap_parameters :account_join_code
+
before_action :set_join_code
before_action :ensure_admin, only: %i[ update destroy ]
@@ -10,15 +12,25 @@ class Account::JoinCodesController < ApplicationController
def update
if @join_code.update(join_code_params)
- redirect_to account_join_code_path
+ respond_to do |format|
+ format.html { redirect_to account_join_code_path }
+ format.json { head :no_content }
+ end
else
- render :edit, status: :unprocessable_entity
+ respond_to do |format|
+ format.html { render :edit, status: :unprocessable_entity }
+ format.json { render json: @join_code.errors, status: :unprocessable_entity }
+ end
end
end
def destroy
@join_code.reset
- redirect_to account_join_code_path
+
+ respond_to do |format|
+ format.html { redirect_to account_join_code_path }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb
index 27f47ab99..6201ca656 100644
--- a/app/controllers/account/settings_controller.rb
+++ b/app/controllers/account/settings_controller.rb
@@ -1,4 +1,6 @@
class Account::SettingsController < ApplicationController
+ wrap_parameters :account
+
before_action :ensure_admin, only: :update
before_action :set_account
@@ -8,7 +10,11 @@ class Account::SettingsController < ApplicationController
def update
@account.update!(account_params)
- redirect_to account_settings_path
+
+ respond_to do |format|
+ format.html { redirect_to account_settings_path }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb
index 4b71e7f3e..a3cf867ea 100644
--- a/app/controllers/boards/columns_controller.rb
+++ b/app/controllers/boards/columns_controller.rb
@@ -18,7 +18,7 @@ class Boards::ColumnsController < ApplicationController
respond_to do |format|
format.turbo_stream
- format.json { head :created, location: board_column_path(@board, @column, format: :json) }
+ format.json { render :show, status: :created, location: board_column_path(@board, @column, format: :json) }
end
end
diff --git a/app/controllers/boards/entropies_controller.rb b/app/controllers/boards/entropies_controller.rb
index e42631eaf..557adb2d9 100644
--- a/app/controllers/boards/entropies_controller.rb
+++ b/app/controllers/boards/entropies_controller.rb
@@ -1,10 +1,17 @@
class Boards::EntropiesController < ApplicationController
+ wrap_parameters :board
+
include BoardScoped
before_action :ensure_permission_to_admin_board
def update
@board.update!(entropy_params)
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/boards/involvements_controller.rb b/app/controllers/boards/involvements_controller.rb
index a904f2989..271b50262 100644
--- a/app/controllers/boards/involvements_controller.rb
+++ b/app/controllers/boards/involvements_controller.rb
@@ -3,5 +3,11 @@ class Boards::InvolvementsController < ApplicationController
def update
@board.access_for(Current.user).update!(involvement: params[:involvement])
+
+ respond_to do |format|
+ format.html
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
end
diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb
index 604938deb..1e93a5758 100644
--- a/app/controllers/boards_controller.rb
+++ b/app/controllers/boards_controller.rb
@@ -26,7 +26,7 @@ class BoardsController < ApplicationController
respond_to do |format|
format.html { redirect_to board_path(@board) }
- format.json { head :created, location: board_path(@board, format: :json) }
+ format.json { render :show, status: :created, location: board_path(@board, format: :json) }
end
end
diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb
index 0c822360e..32ca9f2f8 100644
--- a/app/controllers/cards/comments/reactions_controller.rb
+++ b/app/controllers/cards/comments/reactions_controller.rb
@@ -22,7 +22,7 @@ class Cards::Comments::ReactionsController < ApplicationController
respond_to do |format|
format.turbo_stream { render "reactions/create" }
- format.json { head :created }
+ format.json { render "reactions/show", status: :created }
end
end
diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb
index f2b318813..6a377bc2c 100644
--- a/app/controllers/cards/comments_controller.rb
+++ b/app/controllers/cards/comments_controller.rb
@@ -14,7 +14,7 @@ class Cards::CommentsController < ApplicationController
respond_to do |format|
format.turbo_stream
- format.json { head :created, location: card_comment_path(@card, @comment, format: :json) }
+ format.json { render :show, status: :created, location: card_comment_path(@card, @comment, format: :json) }
end
end
diff --git a/app/controllers/cards/publishes_controller.rb b/app/controllers/cards/publishes_controller.rb
index a0378eec3..a544e6979 100644
--- a/app/controllers/cards/publishes_controller.rb
+++ b/app/controllers/cards/publishes_controller.rb
@@ -4,11 +4,17 @@ class Cards::PublishesController < ApplicationController
def create
@card.publish
- if add_another_param?
- card = @board.cards.create!(status: :drafted)
- redirect_to card_draft_path(card), notice: "Card added"
- else
- redirect_to @card.board
+ respond_to do |format|
+ format.html do
+ if add_another_param?
+ card = @board.cards.create!(status: :drafted)
+ redirect_to card_draft_path(card), notice: "Card added"
+ else
+ redirect_to @card.board
+ end
+ end
+
+ format.json { head :created }
end
end
diff --git a/app/controllers/cards/reactions_controller.rb b/app/controllers/cards/reactions_controller.rb
index 4105a4121..4f19a1c64 100644
--- a/app/controllers/cards/reactions_controller.rb
+++ b/app/controllers/cards/reactions_controller.rb
@@ -21,7 +21,7 @@ class Cards::ReactionsController < ApplicationController
respond_to do |format|
format.turbo_stream { render "reactions/create" }
- format.json { head :created }
+ format.json { render "reactions/show", status: :created }
end
end
diff --git a/app/controllers/cards/readings_controller.rb b/app/controllers/cards/readings_controller.rb
index 75fdbac19..4b71272f8 100644
--- a/app/controllers/cards/readings_controller.rb
+++ b/app/controllers/cards/readings_controller.rb
@@ -4,11 +4,21 @@ class Cards::ReadingsController < ApplicationController
def create
@notification = @card.read_by(Current.user)
record_board_access
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :created }
+ end
end
def destroy
@notification = @card.unread_by(Current.user)
record_board_access
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/cards/steps_controller.rb b/app/controllers/cards/steps_controller.rb
index 0b788fe36..387dd8e23 100644
--- a/app/controllers/cards/steps_controller.rb
+++ b/app/controllers/cards/steps_controller.rb
@@ -3,12 +3,16 @@ class Cards::StepsController < ApplicationController
before_action :set_step, only: %i[ show edit update destroy ]
+ def index
+ fresh_when etag: @card.steps
+ end
+
def create
@step = @card.steps.create!(step_params)
respond_to do |format|
format.turbo_stream
- format.json { head :created, location: card_step_path(@card, @step, format: :json) }
+ format.json { render :show, status: :created, location: card_step_path(@card, @step, format: :json) }
end
end
diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb
index e007527b8..482d2ff78 100644
--- a/app/controllers/cards_controller.rb
+++ b/app/controllers/cards_controller.rb
@@ -18,8 +18,8 @@ class CardsController < ApplicationController
end
format.json do
- card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
- head :created, location: card_path(card, format: :json)
+ @card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
+ render :show, status: :created, location: card_path(@card, format: :json)
end
end
end
diff --git a/app/controllers/columns/left_positions_controller.rb b/app/controllers/columns/left_positions_controller.rb
index 7161c093f..78e0c0c80 100644
--- a/app/controllers/columns/left_positions_controller.rb
+++ b/app/controllers/columns/left_positions_controller.rb
@@ -4,5 +4,10 @@ class Columns::LeftPositionsController < ApplicationController
def create
@left_column = @column.left_column
@column.move_left
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :created }
+ end
end
end
diff --git a/app/controllers/columns/right_positions_controller.rb b/app/controllers/columns/right_positions_controller.rb
index d43beb662..5d9cc37f9 100644
--- a/app/controllers/columns/right_positions_controller.rb
+++ b/app/controllers/columns/right_positions_controller.rb
@@ -4,5 +4,10 @@ class Columns::RightPositionsController < ApplicationController
def create
@right_column = @column.right_column
@column.move_right
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :created }
+ end
end
end
diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb
index 82cd93842..c9fcf38c3 100644
--- a/app/controllers/my/access_tokens_controller.rb
+++ b/app/controllers/my/access_tokens_controller.rb
@@ -1,4 +1,6 @@
class My::AccessTokensController < ApplicationController
+ skip_before_action :require_account
+
def index
@access_tokens = my_access_tokens.order(created_at: :desc)
end
@@ -24,14 +26,19 @@ class My::AccessTokensController < ApplicationController
format.json do
render status: :created, json: \
- { token: access_token.token, description: access_token.description, permission: access_token.permission }
+ { id: access_token.id, token: access_token.token, description: access_token.description,
+ permission: access_token.permission, created_at: access_token.created_at.utc }
end
end
end
def destroy
my_access_tokens.find(params[:id]).destroy!
- redirect_to my_access_tokens_path
+
+ respond_to do |format|
+ format.html { redirect_to my_access_tokens_path }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/notifications/settings_controller.rb b/app/controllers/notifications/settings_controller.rb
index c4aab7f69..252abec2e 100644
--- a/app/controllers/notifications/settings_controller.rb
+++ b/app/controllers/notifications/settings_controller.rb
@@ -1,4 +1,6 @@
class Notifications::SettingsController < ApplicationController
+ wrap_parameters :user_settings
+
before_action :set_settings
def show
@@ -7,7 +9,11 @@ class Notifications::SettingsController < ApplicationController
def update
@settings.update!(settings_params)
- redirect_to notifications_settings_path, notice: "Settings updated"
+
+ respond_to do |format|
+ format.html { redirect_to notifications_settings_path, notice: "Settings updated" }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/searches_controller.rb b/app/controllers/searches_controller.rb
index b1ec5a0d2..70dfa4d4d 100644
--- a/app/controllers/searches_controller.rb
+++ b/app/controllers/searches_controller.rb
@@ -5,10 +5,22 @@ class SearchesController < ApplicationController
@query = params[:q].blank? ? nil : params[:q]
if card = Current.user.accessible_cards.find_by_id(@query)
- @card = card
+ respond_to do |format|
+ format.html { @card = card }
+ format.json { set_page_and_extract_portion_from Current.user.accessible_cards.where(id: card.id) }
+ end
else
- set_page_and_extract_portion_from Current.user.search(@query)
- @recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)
+ respond_to do |format|
+ format.html do
+ set_page_and_extract_portion_from Current.user.search(@query)
+ @recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)
+ end
+
+ format.json do
+ set_page_and_extract_portion_from \
+ Current.user.accessible_cards.mentioning(@query, user: Current.user).distinct.latest.preloaded
+ end
+ end
end
end
end
diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb
index 55d8f06dd..544398331 100644
--- a/app/controllers/users/avatars_controller.rb
+++ b/app/controllers/users/avatars_controller.rb
@@ -16,7 +16,11 @@ class Users::AvatarsController < ApplicationController
def destroy
@user.avatar.destroy
- redirect_to @user
+
+ respond_to do |format|
+ format.html { redirect_to @user }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/users/push_subscriptions_controller.rb b/app/controllers/users/push_subscriptions_controller.rb
index b511a6b19..91b47264c 100644
--- a/app/controllers/users/push_subscriptions_controller.rb
+++ b/app/controllers/users/push_subscriptions_controller.rb
@@ -5,12 +5,21 @@ class Users::PushSubscriptionsController < ApplicationController
end
def create
- @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params)
+ subscription = @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params)
+
+ respond_to do |format|
+ format.html { head :no_content }
+ format.json { head :created }
+ end
end
def destroy
@push_subscriptions.destroy_by(id: params[:id])
- redirect_to user_push_subscriptions_url
+
+ respond_to do |format|
+ format.html { redirect_to user_push_subscriptions_url }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/users/roles_controller.rb b/app/controllers/users/roles_controller.rb
index 711308d13..fb11d4e08 100644
--- a/app/controllers/users/roles_controller.rb
+++ b/app/controllers/users/roles_controller.rb
@@ -1,10 +1,16 @@
class Users::RolesController < ApplicationController
+ wrap_parameters :user
+
before_action :set_user
before_action :ensure_permission_to_administer_user
def update
@user.update!(role_params)
- redirect_to account_settings_path
+
+ respond_to do |format|
+ format.html { redirect_to account_settings_path }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/views/account/exports/show.json.jbuilder b/app/views/account/exports/show.json.jbuilder
new file mode 100644
index 000000000..2bd9e3d60
--- /dev/null
+++ b/app/views/account/exports/show.json.jbuilder
@@ -0,0 +1,6 @@
+json.(@export, :id, :status)
+json.created_at @export.created_at.utc
+
+if @export.completed? && @export.file.attached?
+ json.download_url rails_blob_url(@export.file, disposition: "attachment")
+end
diff --git a/app/views/account/join_codes/show.json.jbuilder b/app/views/account/join_codes/show.json.jbuilder
new file mode 100644
index 000000000..559e7b6c2
--- /dev/null
+++ b/app/views/account/join_codes/show.json.jbuilder
@@ -0,0 +1,3 @@
+json.(@join_code, :code, :usage_count, :usage_limit)
+json.url join_url(code: @join_code.code, script_name: Current.account.slug)
+json.active !!@join_code.active?
diff --git a/app/views/account/settings/show.json.jbuilder b/app/views/account/settings/show.json.jbuilder
new file mode 100644
index 000000000..9a62dabc6
--- /dev/null
+++ b/app/views/account/settings/show.json.jbuilder
@@ -0,0 +1 @@
+json.(Current.account, :name)
diff --git a/app/views/boards/columns/closeds/show.json.jbuilder b/app/views/boards/columns/closeds/show.json.jbuilder
new file mode 100644
index 000000000..c1dc1dff1
--- /dev/null
+++ b/app/views/boards/columns/closeds/show.json.jbuilder
@@ -0,0 +1 @@
+json.array! @page.records, partial: "cards/card", as: :card
diff --git a/app/views/boards/columns/not_nows/show.json.jbuilder b/app/views/boards/columns/not_nows/show.json.jbuilder
new file mode 100644
index 000000000..c1dc1dff1
--- /dev/null
+++ b/app/views/boards/columns/not_nows/show.json.jbuilder
@@ -0,0 +1 @@
+json.array! @page.records, partial: "cards/card", as: :card
diff --git a/app/views/boards/columns/streams/show.json.jbuilder b/app/views/boards/columns/streams/show.json.jbuilder
new file mode 100644
index 000000000..c1dc1dff1
--- /dev/null
+++ b/app/views/boards/columns/streams/show.json.jbuilder
@@ -0,0 +1 @@
+json.array! @page.records, partial: "cards/card", as: :card
diff --git a/app/views/cards/steps/index.json.jbuilder b/app/views/cards/steps/index.json.jbuilder
new file mode 100644
index 000000000..087790175
--- /dev/null
+++ b/app/views/cards/steps/index.json.jbuilder
@@ -0,0 +1 @@
+json.array! @card.steps, partial: "cards/steps/step", as: :step
diff --git a/app/views/my/access_tokens/_access_token.json.jbuilder b/app/views/my/access_tokens/_access_token.json.jbuilder
new file mode 100644
index 000000000..3e0a08084
--- /dev/null
+++ b/app/views/my/access_tokens/_access_token.json.jbuilder
@@ -0,0 +1,2 @@
+json.(access_token, :id, :description, :permission)
+json.created_at access_token.created_at.utc
diff --git a/app/views/my/access_tokens/index.json.jbuilder b/app/views/my/access_tokens/index.json.jbuilder
new file mode 100644
index 000000000..4abdba5f7
--- /dev/null
+++ b/app/views/my/access_tokens/index.json.jbuilder
@@ -0,0 +1 @@
+json.array! @access_tokens, partial: "my/access_tokens/access_token", as: :access_token
diff --git a/app/views/notifications/settings/show.json.jbuilder b/app/views/notifications/settings/show.json.jbuilder
new file mode 100644
index 000000000..6eaea0893
--- /dev/null
+++ b/app/views/notifications/settings/show.json.jbuilder
@@ -0,0 +1 @@
+json.bundle_email_frequency Current.user.settings.bundle_email_frequency
diff --git a/app/views/reactions/show.json.jbuilder b/app/views/reactions/show.json.jbuilder
new file mode 100644
index 000000000..a61ed5902
--- /dev/null
+++ b/app/views/reactions/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! "reactions/reaction", reaction: @reaction
diff --git a/app/views/searches/show.json.jbuilder b/app/views/searches/show.json.jbuilder
new file mode 100644
index 000000000..c1dc1dff1
--- /dev/null
+++ b/app/views/searches/show.json.jbuilder
@@ -0,0 +1 @@
+json.array! @page.records, partial: "cards/card", as: :card
diff --git a/test/controllers/accounts/entropies_controller_test.rb b/test/controllers/accounts/entropies_controller_test.rb
index ef23851de..5903ed7d0 100644
--- a/test/controllers/accounts/entropies_controller_test.rb
+++ b/test/controllers/accounts/entropies_controller_test.rb
@@ -13,6 +13,13 @@ class Account::EntropiesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to account_settings_path
end
+ test "update as JSON" do
+ put account_entropy_path, params: { entropy: { auto_postpone_period: 2.days } }, as: :json
+
+ assert_response :no_content
+ assert_equal 2.days, entropies("37s_account").reload.auto_postpone_period
+ end
+
test "update requires admin" do
logout_and_sign_in_as :david
diff --git a/test/controllers/accounts/exports_controller_test.rb b/test/controllers/accounts/exports_controller_test.rb
index ebcf26117..fd950f989 100644
--- a/test/controllers/accounts/exports_controller_test.rb
+++ b/test/controllers/accounts/exports_controller_test.rb
@@ -76,6 +76,49 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
assert_select "h2", "Download Expired"
end
+ test "create as JSON" do
+ assert_difference -> { Account::Export.count }, 1 do
+ assert_enqueued_with(job: DataExportJob) do
+ post account_exports_path, as: :json
+ end
+ end
+
+ assert_response :created
+ body = @response.parsed_body
+ assert body["id"].present?
+ assert_equal "pending", body["status"]
+ assert_nil body["download_url"]
+ end
+
+ test "show as JSON with completed export" do
+ export = Account::Export.create!(account: Current.account, user: users(:jason))
+ export.build
+
+ get account_export_path(export), as: :json
+ assert_response :success
+
+ body = @response.parsed_body
+ assert_equal export.id, body["id"]
+ assert_equal "completed", body["status"]
+ assert body["download_url"].present?
+ end
+
+ test "show as JSON with pending export" do
+ export = Account::Export.create!(account: Current.account, user: users(:jason))
+
+ get account_export_path(export), as: :json
+ assert_response :success
+
+ body = @response.parsed_body
+ assert_equal "pending", body["status"]
+ assert_nil body["download_url"]
+ end
+
+ test "show as JSON with missing export" do
+ get account_export_path("nonexistent"), as: :json
+ assert_response :not_found
+ end
+
test "create is forbidden for non-admin members" do
logout_and_sign_in_as :david
diff --git a/test/controllers/accounts/join_codes_controller_test.rb b/test/controllers/accounts/join_codes_controller_test.rb
index 15d41b5ec..a56472c5c 100644
--- a/test/controllers/accounts/join_codes_controller_test.rb
+++ b/test/controllers/accounts/join_codes_controller_test.rb
@@ -24,6 +24,41 @@ class Account::JoinCodesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to account_join_code_path
end
+ test "show as JSON" do
+ get account_join_code_path, as: :json
+ assert_response :success
+
+ body = @response.parsed_body
+ assert body["code"].present?
+ assert body.key?("usage_count")
+ assert body.key?("usage_limit")
+ assert body.key?("url")
+ assert body.key?("active")
+ end
+
+ test "update as JSON" do
+ put account_join_code_path, params: { account_join_code: { usage_limit: 5 } }, as: :json
+
+ assert_response :no_content
+ assert_equal 5, Current.account.join_code.reload.usage_limit
+ end
+
+ test "update as JSON with invalid data" do
+ huge_number = "99999999999999999999999999999999999"
+
+ put account_join_code_path, params: { account_join_code: { usage_limit: huge_number } }, as: :json
+
+ assert_response :unprocessable_entity
+ end
+
+ test "destroy as JSON" do
+ assert_changes -> { Current.account.join_code.reload.code } do
+ delete account_join_code_path, as: :json
+ end
+
+ assert_response :no_content
+ end
+
test "update requires admin" do
logout_and_sign_in_as :david
diff --git a/test/controllers/accounts/settings_controller_test.rb b/test/controllers/accounts/settings_controller_test.rb
index 9f17a74a7..6f3fba5b0 100644
--- a/test/controllers/accounts/settings_controller_test.rb
+++ b/test/controllers/accounts/settings_controller_test.rb
@@ -16,6 +16,20 @@ class Account::SettingsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to account_settings_path
end
+ test "show as JSON" do
+ get account_settings_path, as: :json
+ assert_response :success
+
+ assert_equal Current.account.name, @response.parsed_body["name"]
+ end
+
+ test "update as JSON" do
+ put account_settings_path, params: { account: { name: "New Account Name" } }, as: :json
+
+ assert_response :no_content
+ assert_equal "New Account Name", Current.account.reload.name
+ end
+
test "update requires admin" do
logout_and_sign_in_as :david
diff --git a/test/controllers/api/flat_json_params_test.rb b/test/controllers/api/flat_json_params_test.rb
new file mode 100644
index 000000000..4efba5209
--- /dev/null
+++ b/test/controllers/api/flat_json_params_test.rb
@@ -0,0 +1,71 @@
+require "test_helper"
+
+class FlatJsonParamsTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in_as :kevin
+ end
+
+ test "update user role with flat JSON" do
+ put user_role_path(users(:david)), params: { role: "admin" }, as: :json
+
+ assert_response :no_content
+ assert users(:david).reload.admin?
+ end
+
+ test "update notification settings with flat JSON" do
+ logout_and_sign_in_as :david
+
+ assert_changes -> { users(:david).reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do
+ put notifications_settings_path, params: { bundle_email_frequency: "every_few_hours" }, as: :json
+ end
+
+ assert_response :no_content
+ end
+
+ test "update join code with flat JSON" do
+ put account_join_code_path, params: { usage_limit: 5 }, as: :json
+
+ assert_response :no_content
+ assert_equal 5, Current.account.join_code.reload.usage_limit
+ end
+
+ test "update account settings with flat JSON" do
+ put account_settings_path, params: { name: "New Name" }, as: :json
+
+ assert_response :no_content
+ assert_equal "New Name", Current.account.reload.name
+ end
+
+ test "update board entropy with flat JSON" do
+ board = boards(:writebook)
+
+ put board_entropy_path(board), params: { auto_postpone_period: 99.days }, as: :json
+
+ assert_response :no_content
+ assert_equal 99.days, board.entropy.reload.auto_postpone_period
+ end
+
+ test "update account entropy with flat JSON" do
+ put account_entropy_path, params: { auto_postpone_period: 2.days }, as: :json
+
+ assert_response :no_content
+ assert_equal 2.days, Current.account.entropy.reload.auto_postpone_period
+ end
+
+ test "create push subscription with flat JSON" do
+ stub_dns_resolution("142.250.185.206")
+
+ post user_push_subscriptions_path(users(:kevin)),
+ params: { endpoint: "https://fcm.googleapis.com/fcm/send/abc123", p256dh_key: "key1", auth_key: "key2" },
+ as: :json
+
+ assert_response :created
+ end
+
+ private
+ def stub_dns_resolution(*ips)
+ dns_mock = mock("dns")
+ dns_mock.stubs(:each_address).multiple_yields(*ips)
+ Resolv::DNS.stubs(:open).yields(dns_mock)
+ end
+end
diff --git a/test/controllers/boards/columns/closeds_controller_test.rb b/test/controllers/boards/columns/closeds_controller_test.rb
index f147a2106..90b6c8756 100644
--- a/test/controllers/boards/columns/closeds_controller_test.rb
+++ b/test/controllers/boards/columns/closeds_controller_test.rb
@@ -9,4 +9,11 @@ class Boards::Columns::ClosedsControllerTest < ActionDispatch::IntegrationTest
get board_columns_closed_path(boards(:writebook))
assert_response :success
end
+
+ test "show as JSON" do
+ get board_columns_closed_path(boards(:writebook)), as: :json
+ assert_response :success
+
+ assert_kind_of Array, @response.parsed_body
+ end
end
diff --git a/test/controllers/boards/columns/not_nows_controller_test.rb b/test/controllers/boards/columns/not_nows_controller_test.rb
index 29088728c..6f9efbf3e 100644
--- a/test/controllers/boards/columns/not_nows_controller_test.rb
+++ b/test/controllers/boards/columns/not_nows_controller_test.rb
@@ -9,4 +9,11 @@ class Boards::Columns::NotNowsControllerTest < ActionDispatch::IntegrationTest
get board_columns_not_now_path(boards(:writebook))
assert_response :success
end
+
+ test "show as JSON" do
+ get board_columns_not_now_path(boards(:writebook)), as: :json
+ assert_response :success
+
+ assert_kind_of Array, @response.parsed_body
+ end
end
diff --git a/test/controllers/boards/columns/streams_controller_test.rb b/test/controllers/boards/columns/streams_controller_test.rb
index fec9603cc..0fbc13afd 100644
--- a/test/controllers/boards/columns/streams_controller_test.rb
+++ b/test/controllers/boards/columns/streams_controller_test.rb
@@ -9,4 +9,12 @@ class Boards::Columns::StreamsControllerTest < ActionDispatch::IntegrationTest
get board_columns_stream_path(boards(:writebook))
assert_response :success
end
+
+ test "show as JSON" do
+ get board_columns_stream_path(boards(:writebook)), as: :json
+ assert_response :success
+
+ assert_kind_of Array, @response.parsed_body
+ assert response.headers["X-Total-Count"].present?, "Expected X-Total-Count header"
+ end
end
diff --git a/test/controllers/boards/columns_controller_test.rb b/test/controllers/boards/columns_controller_test.rb
index b9c974321..9fef675f5 100644
--- a/test/controllers/boards/columns_controller_test.rb
+++ b/test/controllers/boards/columns_controller_test.rb
@@ -75,6 +75,7 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest
assert_response :created
assert_equal board_column_path(board, Column.last, format: :json), @response.headers["Location"]
+ assert_equal "New Column", @response.parsed_body["name"]
end
test "update as JSON" do
diff --git a/test/controllers/boards/entropies_controller_test.rb b/test/controllers/boards/entropies_controller_test.rb
index 0c989c3cd..c0ec089a5 100644
--- a/test/controllers/boards/entropies_controller_test.rb
+++ b/test/controllers/boards/entropies_controller_test.rb
@@ -16,6 +16,13 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest
end
end
+ test "update as JSON" do
+ put board_entropy_path(@board), params: { board: { auto_postpone_period: 99.days } }, as: :json
+
+ assert_response :no_content
+ assert_equal 99.days, @board.entropy.reload.auto_postpone_period
+ end
+
test "update requires board admin permission" do
logout_and_sign_in_as :jz
diff --git a/test/controllers/boards/involvements_controller_test.rb b/test/controllers/boards/involvements_controller_test.rb
index 45587176a..4045df743 100644
--- a/test/controllers/boards/involvements_controller_test.rb
+++ b/test/controllers/boards/involvements_controller_test.rb
@@ -15,4 +15,15 @@ class Boards::InvolvementsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
+
+ test "update as JSON" do
+ board = boards(:writebook)
+ board.access_for(users(:kevin)).access_only!
+
+ assert_changes -> { board.access_for(users(:kevin)).involvement }, from: "access_only", to: "watching" do
+ put board_involvement_path(board), params: { involvement: "watching" }, as: :json
+ end
+
+ assert_response :no_content
+ end
end
diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb
index 7dd2b2f06..fedd9d188 100644
--- a/test/controllers/boards_controller_test.rb
+++ b/test/controllers/boards_controller_test.rb
@@ -275,6 +275,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
assert_response :created
assert_equal board_path(Board.last, format: :json), @response.headers["Location"]
+ assert_equal "My new board", @response.parsed_body["name"]
end
test "update as JSON" do
diff --git a/test/controllers/cards/comments/reactions_controller_test.rb b/test/controllers/cards/comments/reactions_controller_test.rb
index 21a94c5f3..eda47536f 100644
--- a/test/controllers/cards/comments/reactions_controller_test.rb
+++ b/test/controllers/cards/comments/reactions_controller_test.rb
@@ -49,6 +49,7 @@ class Cards::Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest
end
assert_response :created
+ assert_equal "👍", @response.parsed_body["content"]
end
test "destroy as JSON" do
diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb
index a6e83cbef..eebd62a9c 100644
--- a/test/controllers/cards/comments_controller_test.rb
+++ b/test/controllers/cards/comments_controller_test.rb
@@ -56,6 +56,7 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
assert_response :created
assert_equal card_comment_path(card, Comment.last, format: :json), @response.headers["Location"]
+ assert_equal Comment.last.id, @response.parsed_body["id"]
end
test "create as JSON with custom created_at" do
diff --git a/test/controllers/cards/publishes_controller_test.rb b/test/controllers/cards/publishes_controller_test.rb
index 537a6f73b..1a6325e2f 100644
--- a/test/controllers/cards/publishes_controller_test.rb
+++ b/test/controllers/cards/publishes_controller_test.rb
@@ -16,6 +16,17 @@ class Cards::PublishesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to card.board
end
+ test "create as JSON" do
+ card = cards(:logo)
+ card.drafted!
+
+ assert_changes -> { card.reload.published? }, from: false, to: true do
+ post card_publish_path(card), as: :json
+ end
+
+ assert_response :created
+ end
+
test "create and add another" do
card = cards(:logo)
card.drafted!
diff --git a/test/controllers/cards/reactions_controller_test.rb b/test/controllers/cards/reactions_controller_test.rb
index 86ab22639..731d606d8 100644
--- a/test/controllers/cards/reactions_controller_test.rb
+++ b/test/controllers/cards/reactions_controller_test.rb
@@ -53,6 +53,7 @@ class Cards::ReactionsControllerTest < ActionDispatch::IntegrationTest
end
assert_response :created
+ assert_equal "👍", @response.parsed_body["content"]
end
test "destroy as JSON" do
diff --git a/test/controllers/cards/readings_controller_test.rb b/test/controllers/cards/readings_controller_test.rb
index 0c9c9aeed..4aaec41d7 100644
--- a/test/controllers/cards/readings_controller_test.rb
+++ b/test/controllers/cards/readings_controller_test.rb
@@ -39,6 +39,18 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
+ test "create as JSON" do
+ post card_reading_url(cards(:logo)), as: :json
+ assert_response :created
+ end
+
+ test "destroy as JSON" do
+ notifications(:logo_assignment_kevin).read
+
+ delete card_reading_url(cards(:logo)), as: :json
+ assert_response :no_content
+ end
+
test "unread notification on destroy" do
notifications(:logo_assignment_kevin).read
diff --git a/test/controllers/cards/steps_controller_test.rb b/test/controllers/cards/steps_controller_test.rb
index 039fbf4c4..011b68812 100644
--- a/test/controllers/cards/steps_controller_test.rb
+++ b/test/controllers/cards/steps_controller_test.rb
@@ -53,6 +53,19 @@ class Cards::StepsControllerTest < ActionDispatch::IntegrationTest
end
end
+ test "index as JSON" do
+ card = cards(:logo)
+ card.steps.create!(content: "Step one")
+ card.steps.create!(content: "Step two", completed: true)
+
+ get card_steps_path(card), as: :json
+ assert_response :success
+
+ body = @response.parsed_body
+ assert_equal 2, body.size
+ assert_equal "Step one", body.first["content"]
+ end
+
test "create as JSON" do
card = cards(:logo)
@@ -62,6 +75,7 @@ class Cards::StepsControllerTest < ActionDispatch::IntegrationTest
assert_response :created
assert_equal card_step_path(card, Step.last, format: :json), @response.headers["Location"]
+ assert_equal "New step", @response.parsed_body["content"]
end
test "show as JSON" do
diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb
index 42e24d927..901a584f9 100644
--- a/test/controllers/cards_controller_test.rb
+++ b/test/controllers/cards_controller_test.rb
@@ -192,6 +192,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
card = Card.last
assert_equal card_path(card, format: :json), @response.headers["Location"]
+ assert_equal "My new card", @response.parsed_body["title"]
assert_equal "My new card", card.title
assert_equal "Big if true", card.description.to_plain_text
diff --git a/test/controllers/columns/left_positions_controller_test.rb b/test/controllers/columns/left_positions_controller_test.rb
index 6c807b0e5..5f1c0cd26 100644
--- a/test/controllers/columns/left_positions_controller_test.rb
+++ b/test/controllers/columns/left_positions_controller_test.rb
@@ -21,6 +21,22 @@ class Columns::LeftPositionsControllerTest < ActionDispatch::IntegrationTest
assert_equal original_position_a, column_b.reload.position
end
+ test "move column left as JSON" do
+ board = boards(:writebook)
+ columns = board.columns.sorted.to_a
+
+ column_a = columns[0]
+ column_b = columns[1]
+ original_position_a = column_a.position
+ original_position_b = column_b.position
+
+ post column_left_position_path(column_b), as: :json
+ assert_response :created
+
+ assert_equal original_position_b, column_a.reload.position
+ assert_equal original_position_a, column_b.reload.position
+ end
+
test "move left refreshes adjacent columns" do
column = columns(:writebook_in_progress)
diff --git a/test/controllers/columns/right_positions_controller_test.rb b/test/controllers/columns/right_positions_controller_test.rb
index c927531be..1ee7a0987 100644
--- a/test/controllers/columns/right_positions_controller_test.rb
+++ b/test/controllers/columns/right_positions_controller_test.rb
@@ -21,6 +21,22 @@ class Columns::RightPositionsControllerTest < ActionDispatch::IntegrationTest
assert_equal original_position_a, column_b.reload.position
end
+ test "move column right as JSON" do
+ board = boards(:writebook)
+ columns = board.columns.sorted.to_a
+
+ column_a = columns[0]
+ column_b = columns[1]
+ original_position_a = column_a.position
+ original_position_b = column_b.position
+
+ post column_right_position_path(column_a), as: :json
+ assert_response :created
+
+ assert_equal original_position_b, column_a.reload.position
+ assert_equal original_position_a, column_b.reload.position
+ end
+
test "move right refreshes adjacent columns" do
column = columns(:writebook_in_progress)
diff --git a/test/controllers/my/access_tokens_controller_test.rb b/test/controllers/my/access_tokens_controller_test.rb
index 176302336..bced907db 100644
--- a/test/controllers/my/access_tokens_controller_test.rb
+++ b/test/controllers/my/access_tokens_controller_test.rb
@@ -25,9 +25,11 @@ class My::AccessTokensControllerTest < ActionDispatch::IntegrationTest
end
assert_response :created
body = @response.parsed_body
+ assert body["id"].present?
assert body["token"].present?
assert_equal "Fizzy CLI", body["description"]
assert_equal "write", body["permission"]
+ assert body["created_at"].present?
end
test "create new token via JSON with bearer token" do
@@ -54,6 +56,36 @@ class My::AccessTokensControllerTest < ActionDispatch::IntegrationTest
assert_response :unauthorized
end
+ test "index as JSON" do
+ get my_access_tokens_path, as: :json
+ assert_response :success
+
+ body = @response.parsed_body
+ assert_kind_of Array, body
+ end
+
+ test "index as JSON with bearer token and no account scope" do
+ sign_out
+ bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" }
+
+ untenanted do
+ get my_access_tokens_path, as: :json, env: bearer_token
+ end
+
+ assert_response :success
+ assert_kind_of Array, @response.parsed_body
+ end
+
+ test "destroy as JSON" do
+ token = identities(:kevin).access_tokens.create!(description: "To delete", permission: "read")
+
+ assert_difference -> { identities(:kevin).access_tokens.count }, -1 do
+ delete my_access_token_path(token), as: :json
+ end
+
+ assert_response :no_content
+ end
+
test "accessing new token after reveal window redirects to index" do
assert_changes -> { identities(:kevin).access_tokens.count }, +1 do
post my_access_tokens_path, params: { access_token: { description: "GitHub", permission: "read" } }
diff --git a/test/controllers/notifications/settings_controller_test.rb b/test/controllers/notifications/settings_controller_test.rb
index d8472bb02..b5b554c34 100644
--- a/test/controllers/notifications/settings_controller_test.rb
+++ b/test/controllers/notifications/settings_controller_test.rb
@@ -13,6 +13,21 @@ class Notifications::SettingsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
+ test "show as JSON" do
+ get notifications_settings_path, as: :json
+ assert_response :success
+
+ assert_equal @user.settings.bundle_email_frequency, @response.parsed_body["bundle_email_frequency"]
+ end
+
+ test "update as JSON" do
+ assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do
+ put notifications_settings_path, params: { user_settings: { bundle_email_frequency: "every_few_hours" } }, as: :json
+ end
+
+ assert_response :no_content
+ end
+
test "update email frequency" do
assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do
put notifications_settings_path, params: { user_settings: { bundle_email_frequency: "every_few_hours" } }
diff --git a/test/controllers/searches_controller_test.rb b/test/controllers/searches_controller_test.rb
index 11bac3bc9..b0172cdf5 100644
--- a/test/controllers/searches_controller_test.rb
+++ b/test/controllers/searches_controller_test.rb
@@ -46,6 +46,36 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
assert_select ".search__blank-slate", text: "No matches"
end
+ test "search as JSON" do
+ get search_path(q: "broken", script_name: "/#{@account.external_account_id}"), as: :json
+ assert_response :success
+
+ body = @response.parsed_body
+ assert_kind_of Array, body
+ assert_equal 1, body.size
+ assert_equal "Layout is broken", body.first["title"]
+ end
+
+ test "search by card ID as JSON returns array" do
+ get search_path(q: @card.id, script_name: "/#{@account.external_account_id}"), as: :json
+ assert_response :success
+
+ body = @response.parsed_body
+ assert_kind_of Array, body
+ assert_equal 1, body.size
+ assert_equal @card.id, body.first["id"]
+ end
+
+ test "search as JSON deduplicates cards with multiple search hits" do
+ get search_path(q: "haggis", script_name: "/#{@account.external_account_id}"), as: :json
+ assert_response :success
+
+ body = @response.parsed_body
+ assert_kind_of Array, body
+ assert_equal 1, body.size
+ assert_equal @comment2_card.id, body.first["id"]
+ end
+
test "search highlights matched terms with proper HTML marks" do
@board.cards.create!(title: "Testing search highlighting", status: "published", creator: @user)
diff --git a/test/controllers/users/avatars_controller_test.rb b/test/controllers/users/avatars_controller_test.rb
index 3ded884b8..ac213e46b 100644
--- a/test/controllers/users/avatars_controller_test.rb
+++ b/test/controllers/users/avatars_controller_test.rb
@@ -49,6 +49,11 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to users(:david)
end
+ test "delete self as JSON" do
+ delete user_avatar_path(users(:david)), as: :json
+ assert_response :no_content
+ end
+
test "unable to delete other" do
delete user_avatar_path(users(:kevin))
assert_response :forbidden
diff --git a/test/controllers/users/push_subscriptions_controller_test.rb b/test/controllers/users/push_subscriptions_controller_test.rb
index 4b4dd0a09..b9f43ff0a 100644
--- a/test/controllers/users/push_subscriptions_controller_test.rb
+++ b/test/controllers/users/push_subscriptions_controller_test.rb
@@ -20,6 +20,42 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
assert_equal "Mozilla/5.0", users(:david).push_subscriptions.last.user_agent
end
+ test "create as JSON" do
+ subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
+
+ post user_push_subscriptions_path(users(:david)),
+ params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" }, as: :json
+
+ assert_response :created
+ end
+
+ test "create as JSON for duplicate subscription" do
+ subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
+
+ users(:david).push_subscriptions.create!(subscription_params)
+
+ assert_no_difference -> { Push::Subscription.count } do
+ post user_push_subscriptions_path(users(:david)),
+ params: { push_subscription: subscription_params }, as: :json
+ end
+
+ assert_response :created
+ end
+
+ test "destroy as JSON" do
+ subscription = users(:david).push_subscriptions.create!(
+ endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
+ p256dh_key: "123",
+ auth_key: "456"
+ )
+
+ assert_difference -> { Push::Subscription.count }, -1 do
+ delete user_push_subscription_path(users(:david), subscription), as: :json
+ end
+
+ assert_response :no_content
+ end
+
test "destroy a push subscription" do
subscription = users(:david).push_subscriptions.create!(
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
diff --git a/test/controllers/users/roles_controller_test.rb b/test/controllers/users/roles_controller_test.rb
index 37d1f2067..d0091bc53 100644
--- a/test/controllers/users/roles_controller_test.rb
+++ b/test/controllers/users/roles_controller_test.rb
@@ -14,6 +14,13 @@ class Users::RolesControllerTest < ActionDispatch::IntegrationTest
assert users(:david).reload.admin?
end
+ test "update as JSON" do
+ put user_role_path(users(:david)), params: { user: { role: "admin" } }, as: :json
+
+ assert_response :no_content
+ assert users(:david).reload.admin?
+ end
+
test "can't promote to special roles" do
assert_no_changes -> { users(:david).reload.role } do
put user_role_path(users(:david)), params: { user: { role: "system" } }
From b6ea558de8e55922d645f3a0da2fca0e62208b95 Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Mon, 9 Mar 2026 15:14:17 -0400
Subject: [PATCH 05/43] Move TrackTrueClientIp middleware into saas engine
(#2677)
The True-Client-IP header is set by Cloudflare and is only trustworthy
when behind a Cloudflare proxy. In non-Cloudflare deployments, this
header is attacker-controlled and can be used to spoof IP addresses.
Moving the middleware into the saas engine ensures it only loads for our
Cloudflare-fronted production deployment, not for self-hosted OSS
instances.
GHSA-cpch-9qg2-x8fq
---
saas/lib/fizzy/saas/engine.rb | 5 +++++
.../initializers => saas/lib/fizzy/saas}/true_client_ip.rb | 2 --
{test => saas/test}/lib/true_client_ip_test.rb | 0
3 files changed, 5 insertions(+), 2 deletions(-)
rename {config/initializers => saas/lib/fizzy/saas}/true_client_ip.rb (87%)
rename {test => saas/test}/lib/true_client_ip_test.rb (100%)
diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb
index b90113544..1ba45df42 100644
--- a/saas/lib/fizzy/saas/engine.rb
+++ b/saas/lib/fizzy/saas/engine.rb
@@ -1,4 +1,5 @@
require_relative "transaction_pinning"
+require_relative "true_client_ip"
require_relative "signup"
require_relative "authorization"
require_relative "gvl_instrumentation"
@@ -61,6 +62,10 @@ module Fizzy
app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware)
end
+ initializer "fizzy_saas.true_client_ip" do |app|
+ app.config.middleware.insert_before ActionDispatch::RemoteIp, TrackTrueClientIp
+ end
+
initializer "fizzy_saas.gvl_instrumentation" do |app|
app.config.middleware.insert_before(Rack::Runtime, GvlInstrumentation)
end
diff --git a/config/initializers/true_client_ip.rb b/saas/lib/fizzy/saas/true_client_ip.rb
similarity index 87%
rename from config/initializers/true_client_ip.rb
rename to saas/lib/fizzy/saas/true_client_ip.rb
index cac696107..27c5f0729 100644
--- a/config/initializers/true_client_ip.rb
+++ b/saas/lib/fizzy/saas/true_client_ip.rb
@@ -20,5 +20,3 @@ class TrackTrueClientIp
@app.call(env)
end
end
-
-Rails.application.config.middleware.insert_before ActionDispatch::RemoteIp, TrackTrueClientIp
diff --git a/test/lib/true_client_ip_test.rb b/saas/test/lib/true_client_ip_test.rb
similarity index 100%
rename from test/lib/true_client_ip_test.rb
rename to saas/test/lib/true_client_ip_test.rb
From 0435db30ba79fd37fb3115b28fad037d09dcbf75 Mon Sep 17 00:00:00 2001
From: Rob Zolkos
Date: Mon, 9 Mar 2026 17:33:30 -0400
Subject: [PATCH 06/43] Add JSON response format to webhooks (#2671)
* Add JSON response format to webhooks
Support JSON responses for all webhook endpoints: index, show, create,
update, destroy, and activation. Adds jbuilder views, updates controllers
to use respond_to, and documents the API in docs/API.md.
* Address PR review feedback
- Use distinct board ID in API docs webhook example
- Use render "webhooks/show" in activations controller for consistency
* Address PR review feedback from flavorjones
- Include webhook.board in cache key to avoid stale board data
- Use _url instead of _path for location header consistency
* Fix webhook create test to match Location header format
Use board_webhook_url instead of board_webhook_path since the controller
returns a full URL in the Location header.
* Fix board ID in Location header example in API docs
Use distinct board ID in the Location URL to match the JSON body example.
---
.../webhooks/activations_controller.rb | 9 +-
app/controllers/webhooks_controller.rb | 34 ++++-
app/views/webhooks/_webhook.json.jbuilder | 8 +
app/views/webhooks/index.json.jbuilder | 1 +
app/views/webhooks/show.json.jbuilder | 1 +
docs/API.md | 118 +++++++++++++++
.../webhooks/activations_controller_test.rb | 32 ++++
test/controllers/webhooks_controller_test.rb | 139 ++++++++++++++++++
8 files changed, 334 insertions(+), 8 deletions(-)
create mode 100644 app/views/webhooks/_webhook.json.jbuilder
create mode 100644 app/views/webhooks/index.json.jbuilder
create mode 100644 app/views/webhooks/show.json.jbuilder
diff --git a/app/controllers/webhooks/activations_controller.rb b/app/controllers/webhooks/activations_controller.rb
index 82166043e..2991be51a 100644
--- a/app/controllers/webhooks/activations_controller.rb
+++ b/app/controllers/webhooks/activations_controller.rb
@@ -4,9 +4,12 @@ class Webhooks::ActivationsController < ApplicationController
before_action :ensure_admin
def create
- webhook = @board.webhooks.find(params[:webhook_id])
- webhook.activate
+ @webhook = @board.webhooks.find(params[:webhook_id])
+ @webhook.activate
- redirect_to webhook
+ respond_to do |format|
+ format.html { redirect_to @webhook }
+ format.json { render "webhooks/show", status: :created }
+ end
end
end
diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb
index 7d79c4587..584c2dc7a 100644
--- a/app/controllers/webhooks_controller.rb
+++ b/app/controllers/webhooks_controller.rb
@@ -16,21 +16,45 @@ class WebhooksController < ApplicationController
end
def create
- webhook = @board.webhooks.create!(webhook_params)
- redirect_to webhook
+ @webhook = @board.webhooks.new(webhook_params)
+
+ if @webhook.save
+ respond_to do |format|
+ format.html { redirect_to @webhook }
+ format.json { render :show, status: :created, location: board_webhook_url(@webhook.board, @webhook, format: :json) }
+ end
+ else
+ respond_to do |format|
+ format.html { render :new, status: :unprocessable_entity }
+ format.json { render json: @webhook.errors, status: :unprocessable_entity }
+ end
+ end
end
def edit
end
def update
- @webhook.update!(webhook_params.except(:url))
- redirect_to @webhook
+ if @webhook.update(webhook_params.except(:url))
+ respond_to do |format|
+ format.html { redirect_to @webhook }
+ format.json { render :show }
+ end
+ else
+ respond_to do |format|
+ format.html { render :edit, status: :unprocessable_entity }
+ format.json { render json: @webhook.errors, status: :unprocessable_entity }
+ end
+ end
end
def destroy
@webhook.destroy!
- redirect_to board_webhooks_path
+
+ respond_to do |format|
+ format.html { redirect_to board_webhooks_path }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/views/webhooks/_webhook.json.jbuilder b/app/views/webhooks/_webhook.json.jbuilder
new file mode 100644
index 000000000..a55257984
--- /dev/null
+++ b/app/views/webhooks/_webhook.json.jbuilder
@@ -0,0 +1,8 @@
+json.cache! [ webhook, webhook.board ] do
+ json.(webhook, :id, :name, :active, :signing_secret, :subscribed_actions)
+ json.payload_url webhook.url
+ json.created_at webhook.created_at.utc
+ json.url board_webhook_url(webhook.board, webhook)
+
+ json.board webhook.board, partial: "boards/board", as: :board
+end
diff --git a/app/views/webhooks/index.json.jbuilder b/app/views/webhooks/index.json.jbuilder
new file mode 100644
index 000000000..c63df9ea0
--- /dev/null
+++ b/app/views/webhooks/index.json.jbuilder
@@ -0,0 +1 @@
+json.array! @page.records, partial: "webhooks/webhook", as: :webhook
diff --git a/app/views/webhooks/show.json.jbuilder b/app/views/webhooks/show.json.jbuilder
new file mode 100644
index 000000000..78a9dcdc7
--- /dev/null
+++ b/app/views/webhooks/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! "webhooks/webhook", webhook: @webhook
diff --git a/docs/API.md b/docs/API.md
index fae64650a..cec58f36e 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -591,6 +591,124 @@ __Response:__
Returns `204 No Content` on success.
+## Webhooks
+
+Webhooks notify another application when something happens on a board. Only account admins can list, view, create, update, delete, or reactivate webhooks.
+
+### `GET /:account_slug/boards/:board_id/webhooks`
+
+Returns a paginated list of webhooks for a board.
+
+__Response:__
+
+```json
+[
+ {
+ "id": "03f5v9zkft4hj9qq0lsn9ohcm",
+ "name": "Production API",
+ "payload_url": "https://api.example.com/webhooks",
+ "active": true,
+ "signing_secret": "p94Bx2HjempCdYB4DTyZkY1b",
+ "subscribed_actions": ["card_published", "card_assigned", "card_closed"],
+ "created_at": "2025-12-05T19:36:35.534Z",
+ "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy/webhooks/03f5v9zkft4hj9qq0lsn9ohcm",
+ "board": {
+ "id": "03f5v9zkft4hj9qq0lsn9ohcy",
+ "name": "Fizzy",
+ "all_access": true,
+ "created_at": "2025-12-05T19:36:35.534Z",
+ "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy",
+ "creator": {
+ "id": "03f5v9zjw7pz8717a4no1h8a7",
+ "name": "David Heinemeier Hansson",
+ "role": "owner",
+ "active": true,
+ "email_address": "david@example.com",
+ "created_at": "2025-12-05T19:36:35.401Z",
+ "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
+ }
+ }
+ }
+]
+```
+
+### `GET /:account_slug/boards/:board_id/webhooks/:id`
+
+Returns a single webhook.
+
+__Response:__
+
+Returns the same webhook shape shown above.
+
+### `POST /:account_slug/boards/:board_id/webhooks`
+
+Creates a webhook.
+
+__Request:__
+
+```json
+{
+ "webhook": {
+ "name": "Production API",
+ "url": "https://api.example.com/webhooks",
+ "subscribed_actions": ["card_published", "card_assigned", "card_closed"]
+ }
+}
+```
+
+`subscribed_actions` accepts any of:
+`card_assigned`, `card_closed`, `card_postponed`, `card_auto_postponed`, `card_board_changed`, `card_published`, `card_reopened`, `card_sent_back_to_triage`, `card_triaged`, `card_unassigned`, `comment_created`
+
+__Response:__
+
+```
+HTTP/1.1 201 Created
+Location: http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy/webhooks/03f5v9zkft4hj9qq0lsn9ohcm.json
+```
+
+Returns the created webhook in the response body.
+
+### `PATCH /:account_slug/boards/:board_id/webhooks/:id`
+
+Updates a webhook.
+
+__Request:__
+
+```json
+{
+ "webhook": {
+ "name": "Production API",
+ "subscribed_actions": ["card_closed"]
+ }
+}
+```
+
+The `url` is immutable after creation and is ignored on update.
+
+__Response:__
+
+Returns the updated webhook.
+
+### `DELETE /:account_slug/boards/:board_id/webhooks/:id`
+
+Deletes a webhook.
+
+__Response:__
+
+Returns `204 No Content` on success.
+
+### `POST /:account_slug/boards/:board_id/webhooks/:id/activation`
+
+Reactivates a deactivated webhook.
+
+__Response:__
+
+```
+HTTP/1.1 201 Created
+```
+
+Returns the reactivated webhook in the response body.
+
## Cards
Cards are tasks or items of work on a board. They can be organized into columns, tagged, assigned to users, and have comments.
diff --git a/test/controllers/webhooks/activations_controller_test.rb b/test/controllers/webhooks/activations_controller_test.rb
index b28fa3924..79a2390aa 100644
--- a/test/controllers/webhooks/activations_controller_test.rb
+++ b/test/controllers/webhooks/activations_controller_test.rb
@@ -32,4 +32,36 @@ class Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest
post board_webhook_activation_path(webhook.board, webhook)
assert_response :forbidden
end
+
+ test "create as JSON" do
+ webhook = webhooks(:inactive)
+
+ assert_not webhook.active?
+
+ assert_changes -> { webhook.reload.active? }, from: false, to: true do
+ post board_webhook_activation_path(webhook.board, webhook), as: :json
+ end
+
+ assert_response :created
+ assert_equal webhook.id, @response.parsed_body["id"]
+ assert_equal true, @response.parsed_body["active"]
+ end
+
+ test "cannot activate webhook on board without access as JSON" do
+ logout_and_sign_in_as :jason
+ webhook = webhooks(:inactive)
+
+ post board_webhook_activation_path(webhook.board, webhook), as: :json
+
+ assert_response :not_found
+ end
+
+ test "non-admin cannot activate webhook as JSON" do
+ logout_and_sign_in_as :jz
+ webhook = webhooks(:active)
+
+ post board_webhook_activation_path(webhook.board, webhook), as: :json
+
+ assert_response :forbidden
+ end
end
diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb
index de336bc88..674be64c1 100644
--- a/test/controllers/webhooks_controller_test.rb
+++ b/test/controllers/webhooks_controller_test.rb
@@ -130,4 +130,143 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
get board_webhooks_path(webhook.board)
assert_response :not_found
end
+
+ test "index as JSON" do
+ board = boards(:writebook)
+
+ get board_webhooks_path(board), as: :json
+
+ assert_response :success
+ assert_kind_of Array, @response.parsed_body
+ assert_equal board.webhooks.count, @response.parsed_body.count
+ assert_equal webhooks(:active).id, @response.parsed_body.first["id"]
+ end
+
+ test "show as JSON" do
+ webhook = webhooks(:active)
+
+ get board_webhook_path(webhook.board, webhook), as: :json
+
+ assert_response :success
+ assert_equal webhook.id, @response.parsed_body["id"]
+ assert_equal webhook.name, @response.parsed_body["name"]
+ assert_equal webhook.url, @response.parsed_body["payload_url"]
+ assert_equal webhook.active?, @response.parsed_body["active"]
+ assert_equal webhook.signing_secret, @response.parsed_body["signing_secret"]
+ assert_equal webhook.subscribed_actions, @response.parsed_body["subscribed_actions"]
+ assert_equal webhook.board.id, @response.parsed_body.dig("board", "id")
+ end
+
+ test "create as JSON" do
+ board = boards(:writebook)
+
+ assert_difference "Webhook.count", 1 do
+ post board_webhooks_path(board), params: {
+ webhook: {
+ name: "Test Webhook",
+ url: "https://example.com/webhook",
+ subscribed_actions: [ "", "card_published", "card_closed" ]
+ }
+ }, as: :json
+ end
+
+ webhook = Webhook.last
+
+ assert_response :created
+ assert_equal board_webhook_url(board, webhook, format: :json), @response.headers["Location"]
+ assert_equal webhook.id, @response.parsed_body["id"]
+ assert_equal "https://example.com/webhook", @response.parsed_body["payload_url"]
+ assert_equal webhook.signing_secret, @response.parsed_body["signing_secret"]
+ end
+
+ test "create with invalid params as JSON" do
+ board = boards(:writebook)
+
+ assert_no_difference "Webhook.count" do
+ post board_webhooks_path(board), params: {
+ webhook: {
+ name: "",
+ url: "invalid-url"
+ }
+ }, as: :json
+ end
+
+ assert_response :unprocessable_entity
+ assert @response.parsed_body["name"].present?
+ assert @response.parsed_body["url"].present?
+ end
+
+ test "update as JSON" do
+ webhook = webhooks(:active)
+
+ patch board_webhook_path(webhook.board, webhook), params: {
+ webhook: {
+ name: "Updated Webhook",
+ subscribed_actions: [ "card_published" ]
+ }
+ }, as: :json
+
+ webhook.reload
+
+ assert_response :success
+ assert_equal "Updated Webhook", webhook.name
+ assert_equal [ "card_published" ], webhook.subscribed_actions
+ assert_equal "Updated Webhook", @response.parsed_body["name"]
+ assert_equal [ "card_published" ], @response.parsed_body["subscribed_actions"]
+ end
+
+ test "update with invalid params as JSON" do
+ webhook = webhooks(:active)
+
+ patch board_webhook_path(webhook.board, webhook), params: {
+ webhook: {
+ name: ""
+ }
+ }, as: :json
+
+ assert_response :unprocessable_entity
+ assert @response.parsed_body["name"].present?
+ end
+
+ test "update does not change url as JSON" do
+ webhook = webhooks(:active)
+
+ assert_no_changes -> { webhook.reload.url } do
+ patch board_webhook_path(webhook.board, webhook), params: {
+ webhook: {
+ name: "Updated Webhook",
+ url: "https://different.com/webhook"
+ }
+ }, as: :json
+ end
+
+ assert_response :success
+ assert_equal webhook.reload.url, @response.parsed_body["payload_url"]
+ end
+
+ test "destroy as JSON" do
+ webhook = webhooks(:active)
+
+ assert_difference "Webhook.count", -1 do
+ delete board_webhook_path(webhook.board, webhook), as: :json
+ end
+
+ assert_response :no_content
+ end
+
+ test "non-admin cannot access webhook endpoints as JSON" do
+ logout_and_sign_in_as :jz
+
+ get board_webhooks_path(boards(:writebook)), as: :json
+
+ assert_response :forbidden
+ end
+
+ test "cannot access webhooks on board without access as JSON" do
+ logout_and_sign_in_as :jason
+
+ get board_webhooks_path(boards(:private)), as: :json
+
+ assert_response :not_found
+ end
end
From 23ac76555bc0ddb3ca5fad07d6c0ba08e67fbdaf Mon Sep 17 00:00:00 2001
From: Rob Zolkos
Date: Mon, 9 Mar 2026 17:37:56 -0400
Subject: [PATCH 07/43] Validate and normalize auto-postpone period to days
(#2672)
* Validate and normalize auto-postpone period to days
- Add Entropy::AUTO_POSTPONE_PERIODS and validate auto_postpone_period against the allowed set
- Introduce auto_postpone_period_in_days for forms and entropy update endpoints
- Fall back to index 0 in knob partial when current value isn't in options
- Remove entropy_auto_close_options helper in favor of model constant
- Update tests to use allowed period values
* Use auto_postpone_period_in_days for JSON entropy updates
The JSON API was accepting auto_postpone_period (seconds) which
bypassed the days normalization and validation. Switch to
auto_postpone_period_in_days consistently and add explicit
wrap_parameters so flat JSON params are wrapped correctly for
the virtual attribute.
* Default to account or 30-day fallback when knob value is invalid
* Address PR review feedback for entropy validation
- Fall back to first knob option (index 0) when persisted value isn't in
the allowed set, preventing nil index errors for legacy values
- Use integer division (1.day.to_i) for consistent day calculations
* Default to account entropy period instead of first option for knob fallback
* Test that default auto-postpone period is in the allowed periods
* Return 422 instead of 500 for invalid entropy auto-postpone values
Rescue ActiveRecord::RecordInvalid in entropy controllers so invalid
auto_postpone_period_in_days values return 422 Unprocessable Entity
instead of raising a 500.
* Fix NoMethodError when board entropy falls back to account default
Use container.account.entropy.auto_postpone_period_in_days instead of
container.account.auto_postpone_period_in_days since Account doesn't
delegate that method.
* Test board entropy fallback to account default for invalid periods
---
.../account/entropies_controller.rb | 6 +++-
.../boards/entropies_controller.rb | 6 ++--
app/controllers/boards_controller.rb | 2 +-
app/helpers/entropy_helper.rb | 4 ---
app/models/board/auto_postponing.rb | 4 +--
app/models/board/entropic.rb | 8 +++--
app/models/entropy.rb | 29 +++++++++++++++++++
app/views/entropy/_auto_close.html.erb | 5 ++--
app/views/entropy/_knob.html.erb | 5 ++--
.../accounts/entropies_controller_test.rb | 19 ++++++++----
test/controllers/api/flat_json_params_test.rb | 8 ++---
.../boards/entropies_controller_test.rb | 19 ++++++++----
test/controllers/boards_controller_test.rb | 14 +++++++--
test/models/card/entropic_test.rb | 10 +++----
test/models/entropy_test.rb | 15 ++++++++--
15 files changed, 113 insertions(+), 41 deletions(-)
diff --git a/app/controllers/account/entropies_controller.rb b/app/controllers/account/entropies_controller.rb
index 7290c1e4e..327098ee2 100644
--- a/app/controllers/account/entropies_controller.rb
+++ b/app/controllers/account/entropies_controller.rb
@@ -1,4 +1,6 @@
class Account::EntropiesController < ApplicationController
+ wrap_parameters :entropy, include: [ :auto_postpone_period_in_days ]
+
before_action :ensure_admin
def update
@@ -8,10 +10,12 @@ class Account::EntropiesController < ApplicationController
format.html { redirect_to account_settings_path, notice: "Account updated" }
format.json { head :no_content }
end
+ rescue ActiveRecord::RecordInvalid
+ head :unprocessable_entity
end
private
def entropy_params
- params.expect(entropy: [ :auto_postpone_period ])
+ params.expect(entropy: [ :auto_postpone_period_in_days ])
end
end
diff --git a/app/controllers/boards/entropies_controller.rb b/app/controllers/boards/entropies_controller.rb
index 557adb2d9..132733aac 100644
--- a/app/controllers/boards/entropies_controller.rb
+++ b/app/controllers/boards/entropies_controller.rb
@@ -1,5 +1,5 @@
class Boards::EntropiesController < ApplicationController
- wrap_parameters :board
+ wrap_parameters :board, include: [ :auto_postpone_period_in_days ]
include BoardScoped
@@ -12,10 +12,12 @@ class Boards::EntropiesController < ApplicationController
format.turbo_stream
format.json { head :no_content }
end
+ rescue ActiveRecord::RecordInvalid
+ head :unprocessable_entity
end
private
def entropy_params
- params.expect(board: [ :auto_postpone_period ])
+ params.expect(board: [ :auto_postpone_period_in_days ])
end
end
diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb
index 1e93a5758..8e63455f9 100644
--- a/app/controllers/boards_controller.rb
+++ b/app/controllers/boards_controller.rb
@@ -88,7 +88,7 @@ class BoardsController < ApplicationController
end
def board_params
- params.expect(board: [ :name, :all_access, :auto_postpone_period, :public_description ])
+ params.expect(board: [ :name, :all_access, :auto_postpone_period_in_days, :public_description ])
end
def grantees
diff --git a/app/helpers/entropy_helper.rb b/app/helpers/entropy_helper.rb
index 4329dab88..7257ba481 100644
--- a/app/helpers/entropy_helper.rb
+++ b/app/helpers/entropy_helper.rb
@@ -1,8 +1,4 @@
module EntropyHelper
- def entropy_auto_close_options
- [ 3, 7, 30, 90, 365, 11 ]
- end
-
def entropy_bubble_options_for(card)
{
daysBeforeReminder: card.entropy.days_before_reminder,
diff --git a/app/models/board/auto_postponing.rb b/app/models/board/auto_postponing.rb
index 4911c5e33..da53692b3 100644
--- a/app/models/board/auto_postponing.rb
+++ b/app/models/board/auto_postponing.rb
@@ -6,9 +6,7 @@ module Board::AutoPostponing
end
private
- DEFAULT_AUTO_POSTPONE_PERIOD = 30.days
-
def set_default_auto_postpone_period
- self.auto_postpone_period ||= DEFAULT_AUTO_POSTPONE_PERIOD unless attribute_present?(:auto_postpone_period)
+ self.auto_postpone_period ||= Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS.days unless attribute_present?(:auto_postpone_period)
end
end
diff --git a/app/models/board/entropic.rb b/app/models/board/entropic.rb
index beee4883b..d3e48d6ea 100644
--- a/app/models/board/entropic.rb
+++ b/app/models/board/entropic.rb
@@ -2,7 +2,7 @@ module Board::Entropic
extend ActiveSupport::Concern
included do
- delegate :auto_postpone_period, to: :entropy
+ delegate :auto_postpone_period, :auto_postpone_period_in_days, to: :entropy
has_one :entropy, as: :container, dependent: :destroy
end
@@ -12,6 +12,10 @@ module Board::Entropic
def auto_postpone_period=(new_value)
entropy ||= association(:entropy).reader || self.build_entropy
- entropy.update auto_postpone_period: new_value
+ entropy.update! auto_postpone_period: new_value
+ end
+
+ def auto_postpone_period_in_days=(value)
+ self.auto_postpone_period = value.to_i.days.to_i
end
end
diff --git a/app/models/entropy.rb b/app/models/entropy.rb
index 9a93ba4e8..c896fc494 100644
--- a/app/models/entropy.rb
+++ b/app/models/entropy.rb
@@ -1,6 +1,35 @@
class Entropy < ApplicationRecord
+ DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS = 30
+ AUTO_POSTPONE_PERIODS_IN_DAYS = [ 3, 7, 30, 90, 365, 11 ].freeze
+ AUTO_POSTPONE_PERIODS_IN_SECONDS = AUTO_POSTPONE_PERIODS_IN_DAYS.map { |n| n.day.in_seconds }.freeze
+
belongs_to :account, default: -> { container.account }
belongs_to :container, polymorphic: true
+ validates :auto_postpone_period, inclusion: { in: AUTO_POSTPONE_PERIODS_IN_SECONDS }
+
after_commit -> { container.cards.touch_all if container }
+
+ def auto_postpone_period_in_days
+ days = auto_postpone_period / 1.day.to_i
+
+ if days.in?(AUTO_POSTPONE_PERIODS_IN_DAYS)
+ days
+ else
+ default_auto_postpone_period_in_days
+ end
+ end
+
+ def auto_postpone_period_in_days=(new_value)
+ self.auto_postpone_period = new_value.to_i.days.to_i
+ end
+
+ private
+ def default_auto_postpone_period_in_days
+ if container.is_a?(Board)
+ container.account.entropy.auto_postpone_period_in_days
+ else
+ DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS
+ end
+ end
end
diff --git a/app/views/entropy/_auto_close.html.erb b/app/views/entropy/_auto_close.html.erb
index 6ccb5d205..a2e3fe0ab 100644
--- a/app/views/entropy/_auto_close.html.erb
+++ b/app/views/entropy/_auto_close.html.erb
@@ -6,8 +6,9 @@
<%= form_with model: model, url: url, data: { controller: "form" } do |form| %>
<%= render "entropy/knob",
form: form,
- name: :auto_postpone_period,
- knob_options: entropy_auto_close_options,
+ name: :auto_postpone_period_in_days,
+ current_value: model.auto_postpone_period_in_days,
+ knob_options: Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS,
label: "Days until auto-close",
disabled: disabled %>
<% end %>
diff --git a/app/views/entropy/_knob.html.erb b/app/views/entropy/_knob.html.erb
index 69bbb5fb0..ba17c6728 100644
--- a/app/views/entropy/_knob.html.erb
+++ b/app/views/entropy/_knob.html.erb
@@ -1,6 +1,5 @@
<%
- period = form.object.send(name)
- current_index = knob_options.index(period.to_i / 1.day)
+ current_index = knob_options.index(current_value) || knob_options.index(Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS)
%>
@@ -8,7 +7,7 @@
<% knob_options.each_with_index do |value, index| %>
<%= form.radio_button name,
- value.days,
+ value,
data: {
action: "change->knob#optionChanged change->form#submit",
index: index,
diff --git a/test/controllers/accounts/entropies_controller_test.rb b/test/controllers/accounts/entropies_controller_test.rb
index 5903ed7d0..d00c0d768 100644
--- a/test/controllers/accounts/entropies_controller_test.rb
+++ b/test/controllers/accounts/entropies_controller_test.rb
@@ -6,24 +6,33 @@ class Account::EntropiesControllerTest < ActionDispatch::IntegrationTest
end
test "update" do
- put account_entropy_path, params: { entropy: { auto_postpone_period: 1.day } }
+ put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }
- assert_equal 1.day, entropies("37s_account").auto_postpone_period
+ assert_equal 7.days, entropies("37s_account").auto_postpone_period
assert_redirected_to account_settings_path
end
test "update as JSON" do
- put account_entropy_path, params: { entropy: { auto_postpone_period: 2.days } }, as: :json
+ put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json
assert_response :no_content
- assert_equal 2.days, entropies("37s_account").reload.auto_postpone_period
+ assert_equal 7.days, entropies("37s_account").reload.auto_postpone_period
end
test "update requires admin" do
logout_and_sign_in_as :david
- put account_entropy_path, params: { entropy: { auto_postpone_period: 1.day } }
+ put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }
assert_response :forbidden
end
+
+ test "update rejects invalid auto_postpone_period" do
+ original_period = entropies("37s_account").auto_postpone_period
+
+ put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 1 } }
+
+ assert_response :unprocessable_entity
+ assert_equal original_period, entropies("37s_account").reload.auto_postpone_period
+ end
end
diff --git a/test/controllers/api/flat_json_params_test.rb b/test/controllers/api/flat_json_params_test.rb
index 4efba5209..22c51c365 100644
--- a/test/controllers/api/flat_json_params_test.rb
+++ b/test/controllers/api/flat_json_params_test.rb
@@ -39,17 +39,17 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest
test "update board entropy with flat JSON" do
board = boards(:writebook)
- put board_entropy_path(board), params: { auto_postpone_period: 99.days }, as: :json
+ put board_entropy_path(board), params: { auto_postpone_period_in_days: 90 }, as: :json
assert_response :no_content
- assert_equal 99.days, board.entropy.reload.auto_postpone_period
+ assert_equal 90.days, board.entropy.reload.auto_postpone_period
end
test "update account entropy with flat JSON" do
- put account_entropy_path, params: { auto_postpone_period: 2.days }, as: :json
+ put account_entropy_path, params: { auto_postpone_period_in_days: 7 }, as: :json
assert_response :no_content
- assert_equal 2.days, Current.account.entropy.reload.auto_postpone_period
+ assert_equal 7.days, Current.account.entropy.reload.auto_postpone_period
end
test "create push subscription with flat JSON" do
diff --git a/test/controllers/boards/entropies_controller_test.rb b/test/controllers/boards/entropies_controller_test.rb
index c0ec089a5..e0fd4fa52 100644
--- a/test/controllers/boards/entropies_controller_test.rb
+++ b/test/controllers/boards/entropies_controller_test.rb
@@ -8,19 +8,19 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest
test "update" do
assert_no_difference -> { Current.account.entropy.reload.auto_postpone_period } do
- put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period: 123.days } }
+ put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period_in_days: 90 } }
- assert_equal 123.days, @board.entropy.reload.auto_postpone_period
+ assert_equal 90.days, @board.entropy.reload.auto_postpone_period
assert_turbo_stream action: :replace, target: dom_id(@board, :entropy)
end
end
test "update as JSON" do
- put board_entropy_path(@board), params: { board: { auto_postpone_period: 99.days } }, as: :json
+ put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 90 } }, as: :json
assert_response :no_content
- assert_equal 99.days, @board.entropy.reload.auto_postpone_period
+ assert_equal 90.days, @board.entropy.reload.auto_postpone_period
end
test "update requires board admin permission" do
@@ -28,9 +28,18 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest
original_period = @board.entropy.auto_postpone_period
- put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period: 1.day } }
+ put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period_in_days: 7 } }
assert_response :forbidden
assert_equal original_period, @board.entropy.reload.auto_postpone_period
end
+
+ test "update rejects invalid auto_postpone_period" do
+ original_period = @board.entropy.auto_postpone_period
+
+ put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period_in_days: 1 } }
+
+ assert_response :unprocessable_entity
+ assert_equal original_period, @board.entropy.reload.auto_postpone_period
+ end
end
diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb
index fedd9d188..915310108 100644
--- a/test/controllers/boards_controller_test.rb
+++ b/test/controllers/boards_controller_test.rb
@@ -41,12 +41,22 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
+ test "edit renders 11-day auto-close option last on the knob" do
+ get edit_board_path(boards(:writebook))
+ assert_response :success
+
+ assert_select "input[type=radio][name='board[auto_postpone_period_in_days]']" do |options|
+ assert_equal Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS.map(&:to_s), options.map { |option| option["value"] }
+ assert_equal "11", options.last["value"]
+ end
+ end
+
test "update" do
patch board_path(boards(:writebook)), params: {
board: {
name: "Writebook bugs",
all_access: false,
- auto_postpone_period: 1.day
+ auto_postpone_period_in_days: 7
},
user_ids: users(:kevin, :jz).pluck(:id)
}
@@ -54,7 +64,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to edit_board_path(boards(:writebook))
assert_equal "Writebook bugs", boards(:writebook).reload.name
assert_equal users(:kevin, :jz).sort, boards(:writebook).users.sort
- assert_equal 1.day, entropies(:writebook_board).auto_postpone_period
+ assert_equal 7.days, entropies(:writebook_board).auto_postpone_period
assert_not boards(:writebook).all_access?
end
diff --git a/test/models/card/entropic_test.rb b/test/models/card/entropic_test.rb
index 9707eabf4..fe5dfd806 100644
--- a/test/models/card/entropic_test.rb
+++ b/test/models/card/entropic_test.rb
@@ -9,17 +9,17 @@ class Card::EntropicTest < ActiveSupport::TestCase
freeze_time
entropies(:writebook_board).destroy
- entropies("37s_account").reload.update! auto_postpone_period: 456.days
+ entropies("37s_account").reload.update! auto_postpone_period: 365.days
cards(:layout).update! last_active_at: 2.day.ago
- assert_equal (456 - 2).days.from_now, cards(:layout).entropy.auto_clean_at
+ assert_equal (365 - 2).days.from_now, cards(:layout).entropy.auto_clean_at
end
test "auto_postpone_at infers the period from the board when present" do
freeze_time
- entropies(:writebook_board).update! auto_postpone_period: 123.days
+ entropies(:writebook_board).update! auto_postpone_period: 90.days
cards(:layout).update! last_active_at: 2.day.ago
- assert_equal (123 - 2).days.from_now, cards(:layout).entropy.auto_clean_at
+ assert_equal (90 - 2).days.from_now, cards(:layout).entropy.auto_clean_at
end
test "setting auto_postpone_period in the board without entropy will create it, without affecting the account entropy" do
@@ -27,7 +27,7 @@ class Card::EntropicTest < ActiveSupport::TestCase
original_period = account_entropy.auto_postpone_period
entropies(:writebook_board).destroy
- boards(:writebook).update! auto_postpone_period: 999.days
+ boards(:writebook).update! auto_postpone_period: 365.days
assert_equal original_period, account_entropy.reload.auto_postpone_period
end
diff --git a/test/models/entropy_test.rb b/test/models/entropy_test.rb
index 0648bbd3b..98abb0af0 100644
--- a/test/models/entropy_test.rb
+++ b/test/models/entropy_test.rb
@@ -3,15 +3,26 @@ require "test_helper"
class Entropy::Test < ActiveSupport::TestCase
test "touch cards when entropy changes for board" do
assert_changes -> { boards(:writebook).cards.first.updated_at } do
- boards(:writebook).entropy.update!(auto_postpone_period: 15.days)
+ boards(:writebook).entropy.update!(auto_postpone_period: 7.days)
end
end
+ test "default auto-postpone period is included in allowed periods" do
+ assert_includes Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS, Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS
+ end
+
+ test "board entropy falls back to account entropy period when value is invalid" do
+ board = boards(:writebook)
+ board.entropy.update_column(:auto_postpone_period, 999.days.to_i)
+
+ assert_equal Current.account.entropy.auto_postpone_period_in_days, board.entropy.auto_postpone_period_in_days
+ end
+
test "touch cards when entropy changes for account container" do
account = Current.account
assert_changes -> { account.cards.first.updated_at } do
- boards(:writebook).entropy.update!(auto_postpone_period: 15.days)
+ boards(:writebook).entropy.update!(auto_postpone_period: 7.days)
end
end
end
From 71a1ee703160945f1844740a68d4d6f23258b044 Mon Sep 17 00:00:00 2001
From: Jeremy Daer
Date: Mon, 9 Mar 2026 17:02:08 -0700
Subject: [PATCH 08/43] Wrap comment params for flat JSON payloads (#2679)
* Wrap comment params for flat JSON payloads
The SDK sends flat JSON ({"body": "..."}) but Rails wrap_parameters
auto-detection misses :body since it's a virtual ActionText attribute,
not in Comment.attribute_names. Explicitly declare wrap_parameters so
params.expect(comment: [...]) works with both wrapped and flat payloads.
* Test comment create and update with flat JSON params
---
app/controllers/cards/comments_controller.rb | 1 +
.../cards/comments_controller_test.rb | 20 +++++++++++++++++++
2 files changed, 21 insertions(+)
diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb
index 6a377bc2c..7aed33c8c 100644
--- a/app/controllers/cards/comments_controller.rb
+++ b/app/controllers/cards/comments_controller.rb
@@ -1,4 +1,5 @@
class Cards::CommentsController < ApplicationController
+ wrap_parameters :comment, include: %i[ body created_at ]
include CardScoped
before_action :set_comment, only: %i[ show edit update destroy ]
diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb
index eebd62a9c..be5831e39 100644
--- a/test/controllers/cards/comments_controller_test.rb
+++ b/test/controllers/cards/comments_controller_test.rb
@@ -84,6 +84,26 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
assert_equal card_comment_url(comment.card, comment), @response.parsed_body["url"]
end
+ test "create as JSON with flat params" do
+ card = cards(:logo)
+
+ assert_difference -> { card.comments.count }, +1 do
+ post card_comments_path(card), params: { body: "Flat comment" }, as: :json
+ end
+
+ assert_response :created
+ assert_equal "Flat comment", Comment.last.body.to_plain_text
+ end
+
+ test "update as JSON with flat params" do
+ comment = comments(:logo_agreement_kevin)
+
+ put card_comment_path(cards(:logo), comment), params: { body: "Flat update" }, as: :json
+
+ assert_response :success
+ assert_equal "Flat update", comment.reload.body.to_plain_text
+ end
+
test "update as JSON" do
comment = comments(:logo_agreement_kevin)
From a52b6f1c878f465acc6097137b361fa62488185c Mon Sep 17 00:00:00 2001
From: Jeremy Daer
Date: Mon, 9 Mar 2026 21:47:05 -0700
Subject: [PATCH 09/43] Add explicit wrap_parameters to all controllers (#2680)
* Add explicit wrap_parameters to controllers for flat JSON API support
Virtual attributes (has_rich_text, has_one_attached, delegated setters,
ActiveModel attrs) are not in Model.attribute_names, so wrap_parameters
auto-detection silently drops them from flat JSON requests. Add explicit
include: lists matching each controller's permitted params.
* Convert short-form wrap_parameters to explicit include: lists
Defense-in-depth: these controllers only have real-column params today,
so auto-detection works, but explicit lists prevent future regressions
if virtual attributes are added.
* Add flat JSON param tests for all wrap_parameters controllers
17 new tests covering every controller with wrap_parameters, verifying
that flat (unwrapped) JSON payloads are correctly wrapped and processed.
Focuses on virtual attributes that would be silently dropped without
explicit include: lists.
---
.../account/join_codes_controller.rb | 2 +-
.../account/settings_controller.rb | 2 +-
app/controllers/boards/columns_controller.rb | 2 +
app/controllers/boards_controller.rb | 2 +
.../cards/comments/reactions_controller.rb | 2 +
app/controllers/cards/reactions_controller.rb | 2 +
app/controllers/cards/steps_controller.rb | 2 +
app/controllers/cards_controller.rb | 2 +
.../my/access_tokens_controller.rb | 2 +
.../notifications/settings_controller.rb | 2 +-
.../signups/completions_controller.rb | 2 +
app/controllers/signups_controller.rb | 2 +
app/controllers/users/joins_controller.rb | 2 +
.../users/push_subscriptions_controller.rb | 2 +
app/controllers/users/roles_controller.rb | 2 +-
app/controllers/users_controller.rb | 2 +
app/controllers/webhooks_controller.rb | 2 +
test/controllers/api/flat_json_params_test.rb | 183 ++++++++++++++++++
18 files changed, 213 insertions(+), 4 deletions(-)
diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb
index 3df9552db..e7306308b 100644
--- a/app/controllers/account/join_codes_controller.rb
+++ b/app/controllers/account/join_codes_controller.rb
@@ -1,5 +1,5 @@
class Account::JoinCodesController < ApplicationController
- wrap_parameters :account_join_code
+ wrap_parameters :account_join_code, include: %i[ usage_limit ]
before_action :set_join_code
before_action :ensure_admin, only: %i[ update destroy ]
diff --git a/app/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb
index 6201ca656..47036d606 100644
--- a/app/controllers/account/settings_controller.rb
+++ b/app/controllers/account/settings_controller.rb
@@ -1,5 +1,5 @@
class Account::SettingsController < ApplicationController
- wrap_parameters :account
+ wrap_parameters :account, include: %i[ name ]
before_action :ensure_admin, only: :update
before_action :set_account
diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb
index a3cf867ea..d0bd13c55 100644
--- a/app/controllers/boards/columns_controller.rb
+++ b/app/controllers/boards/columns_controller.rb
@@ -1,4 +1,6 @@
class Boards::ColumnsController < ApplicationController
+ wrap_parameters :column, include: %i[ name color ]
+
include BoardScoped
before_action :set_column, only: %i[ show update destroy ]
diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb
index 8e63455f9..3d5485f1f 100644
--- a/app/controllers/boards_controller.rb
+++ b/app/controllers/boards_controller.rb
@@ -1,4 +1,6 @@
class BoardsController < ApplicationController
+ wrap_parameters :board, include: %i[ name all_access auto_postpone_period_in_days public_description ]
+
include FilterScoped
before_action :set_board, except: %i[ index new create ]
diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb
index 32ca9f2f8..d6b202bb3 100644
--- a/app/controllers/cards/comments/reactions_controller.rb
+++ b/app/controllers/cards/comments/reactions_controller.rb
@@ -1,4 +1,6 @@
class Cards::Comments::ReactionsController < ApplicationController
+ wrap_parameters :reaction, include: %i[ content ]
+
include CardScoped
before_action :set_comment
diff --git a/app/controllers/cards/reactions_controller.rb b/app/controllers/cards/reactions_controller.rb
index 4f19a1c64..aedb4194f 100644
--- a/app/controllers/cards/reactions_controller.rb
+++ b/app/controllers/cards/reactions_controller.rb
@@ -1,4 +1,6 @@
class Cards::ReactionsController < ApplicationController
+ wrap_parameters :reaction, include: %i[ content ]
+
include CardScoped
before_action :set_reactable
diff --git a/app/controllers/cards/steps_controller.rb b/app/controllers/cards/steps_controller.rb
index 387dd8e23..4843da232 100644
--- a/app/controllers/cards/steps_controller.rb
+++ b/app/controllers/cards/steps_controller.rb
@@ -1,4 +1,6 @@
class Cards::StepsController < ApplicationController
+ wrap_parameters :step, include: %i[ content completed ]
+
include CardScoped
before_action :set_step, only: %i[ show edit update destroy ]
diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb
index 482d2ff78..015ecda1f 100644
--- a/app/controllers/cards_controller.rb
+++ b/app/controllers/cards_controller.rb
@@ -1,4 +1,6 @@
class CardsController < ApplicationController
+ wrap_parameters :card, include: %i[ title description image created_at last_active_at ]
+
include FilterScoped
before_action :set_board, only: %i[ create ]
diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb
index c9fcf38c3..dc7e9826a 100644
--- a/app/controllers/my/access_tokens_controller.rb
+++ b/app/controllers/my/access_tokens_controller.rb
@@ -1,4 +1,6 @@
class My::AccessTokensController < ApplicationController
+ wrap_parameters :access_token, include: %i[ description permission ]
+
skip_before_action :require_account
def index
diff --git a/app/controllers/notifications/settings_controller.rb b/app/controllers/notifications/settings_controller.rb
index 252abec2e..a7ae3d8a6 100644
--- a/app/controllers/notifications/settings_controller.rb
+++ b/app/controllers/notifications/settings_controller.rb
@@ -1,5 +1,5 @@
class Notifications::SettingsController < ApplicationController
- wrap_parameters :user_settings
+ wrap_parameters :user_settings, include: %i[ bundle_email_frequency ]
before_action :set_settings
diff --git a/app/controllers/signups/completions_controller.rb b/app/controllers/signups/completions_controller.rb
index de3d81b63..5c2d9568c 100644
--- a/app/controllers/signups/completions_controller.rb
+++ b/app/controllers/signups/completions_controller.rb
@@ -1,4 +1,6 @@
class Signups::CompletionsController < ApplicationController
+ wrap_parameters :signup, include: %i[ full_name ]
+
layout "public"
disallow_account_scope
diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb
index 56614158f..cf43724f1 100644
--- a/app/controllers/signups_controller.rb
+++ b/app/controllers/signups_controller.rb
@@ -1,4 +1,6 @@
class SignupsController < ApplicationController
+ wrap_parameters :signup, include: %i[ email_address ]
+
disallow_account_scope
allow_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." }
diff --git a/app/controllers/users/joins_controller.rb b/app/controllers/users/joins_controller.rb
index 6baa888ae..bbcb039e6 100644
--- a/app/controllers/users/joins_controller.rb
+++ b/app/controllers/users/joins_controller.rb
@@ -1,4 +1,6 @@
class Users::JoinsController < ApplicationController
+ wrap_parameters :user, include: %i[ name avatar ]
+
layout "public"
def new
diff --git a/app/controllers/users/push_subscriptions_controller.rb b/app/controllers/users/push_subscriptions_controller.rb
index 91b47264c..c52b89541 100644
--- a/app/controllers/users/push_subscriptions_controller.rb
+++ b/app/controllers/users/push_subscriptions_controller.rb
@@ -1,4 +1,6 @@
class Users::PushSubscriptionsController < ApplicationController
+ wrap_parameters :push_subscription, include: %i[ endpoint p256dh_key auth_key ]
+
before_action :set_push_subscriptions
def index
diff --git a/app/controllers/users/roles_controller.rb b/app/controllers/users/roles_controller.rb
index fb11d4e08..61c3262ed 100644
--- a/app/controllers/users/roles_controller.rb
+++ b/app/controllers/users/roles_controller.rb
@@ -1,5 +1,5 @@
class Users::RolesController < ApplicationController
- wrap_parameters :user
+ wrap_parameters :user, include: %i[ role ]
before_action :set_user
before_action :ensure_permission_to_administer_user
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 7062b85e5..fdc528d3e 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,4 +1,6 @@
class UsersController < ApplicationController
+ wrap_parameters :user, include: %i[ name avatar ]
+
before_action :set_user, except: %i[ index ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb
index 584c2dc7a..ab29badaa 100644
--- a/app/controllers/webhooks_controller.rb
+++ b/app/controllers/webhooks_controller.rb
@@ -1,4 +1,6 @@
class WebhooksController < ApplicationController
+ wrap_parameters :webhook, include: %i[ name url subscribed_actions ]
+
include BoardScoped
before_action :ensure_admin
diff --git a/test/controllers/api/flat_json_params_test.rb b/test/controllers/api/flat_json_params_test.rb
index 22c51c365..84c4667b4 100644
--- a/test/controllers/api/flat_json_params_test.rb
+++ b/test/controllers/api/flat_json_params_test.rb
@@ -62,6 +62,189 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest
assert_response :created
end
+ test "create card with flat JSON" do
+ assert_difference -> { Card.count }, +1 do
+ post board_cards_path(boards(:writebook)),
+ params: { title: "Flat card", description: "Flat description
" },
+ as: :json
+ end
+
+ assert_response :created
+ card = Card.last
+ assert_equal "Flat card", card.title
+ assert_equal "Flat description", card.description.to_plain_text
+ end
+
+ test "update card with flat JSON" do
+ card = cards(:logo)
+
+ put card_path(card),
+ params: { title: "Flat update", description: "Updated flat
" },
+ as: :json
+
+ assert_response :success
+ card.reload
+ assert_equal "Flat update", card.title
+ assert_equal "Updated flat", card.description.to_plain_text
+ end
+
+ test "create board with flat JSON" do
+ assert_difference -> { Board.count }, +1 do
+ post boards_path, params: { name: "Flat board" }, as: :json
+ end
+
+ assert_response :created
+ assert_equal "Flat board", Board.last.name
+ end
+
+ test "update board with flat JSON" do
+ board = boards(:writebook)
+
+ put board_path(board),
+ params: { name: "Flat board", auto_postpone_period_in_days: 7, public_description: "Flat public desc
" },
+ as: :json
+
+ assert_response :no_content
+ board.reload
+ assert_equal "Flat board", board.name
+ assert_equal 7.days, board.entropy.auto_postpone_period
+ assert_equal "Flat public desc", board.public_description.to_plain_text
+ end
+
+ test "create column with flat JSON" do
+ board = boards(:writebook)
+
+ assert_difference -> { board.columns.count }, +1 do
+ post board_columns_path(board), params: { name: "Flat Column" }, as: :json
+ end
+
+ assert_response :created
+ assert_equal "Flat Column", Column.last.name
+ end
+
+ test "update column with flat JSON" do
+ column = columns(:writebook_in_progress)
+
+ put board_column_path(column.board, column), params: { name: "Flat Updated" }, as: :json
+
+ assert_response :no_content
+ assert_equal "Flat Updated", column.reload.name
+ end
+
+ test "create step with flat JSON" do
+ card = cards(:logo)
+
+ assert_difference -> { card.steps.count }, +1 do
+ post card_steps_path(card), params: { content: "Flat step" }, as: :json
+ end
+
+ assert_response :created
+ assert_equal "Flat step", Step.last.content
+ end
+
+ test "update step with flat JSON" do
+ card = cards(:logo)
+ step = card.steps.create!(content: "Original")
+
+ put card_step_path(card, step), params: { content: "Flat updated" }, as: :json
+
+ assert_response :success
+ assert_equal "Flat updated", step.reload.content
+ end
+
+ test "create card reaction with flat JSON" do
+ card = cards(:logo)
+
+ assert_difference -> { card.reactions.count }, +1 do
+ post card_reactions_path(card), params: { content: "🎉" }, as: :json
+ end
+
+ assert_response :created
+ end
+
+ test "create comment reaction with flat JSON" do
+ comment = comments(:logo_agreement_kevin)
+
+ assert_difference -> { comment.reactions.count }, +1 do
+ post card_comment_reactions_path(comment.card, comment), params: { content: "👍" }, as: :json
+ end
+
+ assert_response :created
+ end
+
+ test "create access token with flat JSON" do
+ assert_difference -> { identities(:kevin).access_tokens.count }, +1 do
+ post my_access_tokens_path, params: { description: "Flat token", permission: "read" }, as: :json
+ end
+
+ assert_response :created
+ assert_equal "Flat token", @response.parsed_body["description"]
+ end
+
+ test "update user with flat JSON" do
+ put user_path(users(:david)), params: { name: "Flat Name" }, as: :json
+
+ assert_response :no_content
+ assert_equal "Flat Name", users(:david).reload.name
+ end
+
+ test "create webhook with flat JSON" do
+ board = boards(:writebook)
+
+ assert_difference -> { Webhook.count }, +1 do
+ post board_webhooks_path(board),
+ params: { name: "Flat Webhook", url: "https://example.com/flat", subscribed_actions: [ "card_published" ] },
+ as: :json
+ end
+
+ assert_response :created
+ assert_equal "Flat Webhook", Webhook.last.name
+ end
+
+ test "update webhook with flat JSON" do
+ webhook = webhooks(:active)
+
+ patch board_webhook_path(webhook.board, webhook),
+ params: { name: "Flat Updated", subscribed_actions: [ "card_published" ] },
+ as: :json
+
+ assert_response :success
+ assert_equal "Flat Updated", webhook.reload.name
+ end
+
+ test "create signup with flat JSON" do
+ sign_out
+ email = "flatjson-#{SecureRandom.hex(6)}@example.com"
+
+ untenanted do
+ assert_difference -> { Identity.count }, +1 do
+ post signup_path, params: { email_address: email }, as: :json
+ end
+ end
+ end
+
+ test "complete signup with flat JSON" do
+ signup = Signup.new(email_address: "flatjson-#{SecureRandom.hex(6)}@example.com", full_name: "Flat User")
+ signup.create_identity || raise("Failed to create identity")
+ logout_and_sign_in_as signup.identity
+
+ untenanted do
+ assert_difference -> { Account.count }, +1 do
+ post signup_completion_path, params: { full_name: "Flat JSON User" }, as: :json
+ end
+ end
+
+ assert_response :created
+ end
+
+ test "update user via join with flat JSON" do
+ logout_and_sign_in_as :david
+
+ post users_joins_path, params: { name: "Flat Join" }, as: :json
+
+ assert_equal "Flat Join", users(:david).reload.name
+ end
+
private
def stub_dns_resolution(*ips)
dns_mock = mock("dns")
From d33aa8b7578b9bec05df1feb1477e3804b727d51 Mon Sep 17 00:00:00 2001
From: Jeremy Daer
Date: Mon, 9 Mar 2026 21:53:43 -0700
Subject: [PATCH 10/43] Wrap params coverage nits (#2681)
* Add missing assert_response to signup API test
* Add JSON response format to joins#create
---
app/controllers/users/joins_controller.rb | 6 +++++-
test/controllers/api/flat_json_params_test.rb | 3 +++
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/app/controllers/users/joins_controller.rb b/app/controllers/users/joins_controller.rb
index bbcb039e6..402dd34a3 100644
--- a/app/controllers/users/joins_controller.rb
+++ b/app/controllers/users/joins_controller.rb
@@ -8,7 +8,11 @@ class Users::JoinsController < ApplicationController
def create
Current.user.update!(user_params)
- redirect_to landing_path
+
+ respond_to do |format|
+ format.html { redirect_to landing_path }
+ format.json { head :no_content }
+ end
end
private
diff --git a/test/controllers/api/flat_json_params_test.rb b/test/controllers/api/flat_json_params_test.rb
index 84c4667b4..90f55f8fd 100644
--- a/test/controllers/api/flat_json_params_test.rb
+++ b/test/controllers/api/flat_json_params_test.rb
@@ -221,6 +221,8 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest
post signup_path, params: { email_address: email }, as: :json
end
end
+
+ assert_response :created
end
test "complete signup with flat JSON" do
@@ -242,6 +244,7 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest
post users_joins_path, params: { name: "Flat Join" }, as: :json
+ assert_response :no_content
assert_equal "Flat Join", users(:david).reload.name
end
From 5d084a46ce58217bde19302dd1044a693c766177 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 2 Jan 2026 19:25:47 +0100
Subject: [PATCH 11/43] Switch to turbo-rails version with offline mode support
---
Gemfile | 2 +-
Gemfile.lock | 14 ++++++++++----
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/Gemfile b/Gemfile
index 17e7496c0..da66e1473 100644
--- a/Gemfile
+++ b/Gemfile
@@ -8,7 +8,7 @@ gem "rails", github: "rails/rails", branch: "main"
gem "importmap-rails"
gem "propshaft"
gem "stimulus-rails"
-gem "turbo-rails"
+gem "turbo-rails", github: "hotwired/turbo-rails", branch: "offline-cache"
# Deployment and drivers
gem "bootsnap", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 3f8461b39..3a35c35c7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -11,6 +11,15 @@ GIT
specs:
useragent (0.16.11)
+GIT
+ remote: https://github.com/hotwired/turbo-rails.git
+ revision: e3795ef6aac6a3d18039751febe6a301cfe71a7b
+ branch: offline-cache
+ specs:
+ turbo-rails (2.0.20)
+ actionpack (>= 7.1.0)
+ railties (>= 7.1.0)
+
GIT
remote: https://github.com/rails/rails.git
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
@@ -461,9 +470,6 @@ GEM
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
- turbo-rails (2.0.23)
- actionpack (>= 7.1.0)
- railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
@@ -539,7 +545,7 @@ DEPENDENCIES
stimulus-rails
thruster
trilogy (~> 2.10)
- turbo-rails
+ turbo-rails!
useragent!
vcr
web-console!
From ada4688c4e0bc2a6adcdd757821d1779ed4cd570 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Mon, 5 Jan 2026 12:10:57 +0100
Subject: [PATCH 12/43] Take a first stab at offline mode support
---
Gemfile.lock | 4 +-
Gemfile.saas.lock | 14 +++--
app/javascript/initializers/index.js | 1 +
app/javascript/initializers/offline.js | 6 ++
app/views/pwa/service_worker.js | 41 --------------
app/views/pwa/service_worker.js.erb | 76 ++++++++++++++++++++++++++
config/importmap.rb | 1 +
7 files changed, 96 insertions(+), 47 deletions(-)
create mode 100644 app/javascript/initializers/offline.js
delete mode 100644 app/views/pwa/service_worker.js
create mode 100644 app/views/pwa/service_worker.js.erb
diff --git a/Gemfile.lock b/Gemfile.lock
index 3a35c35c7..a710d0f9e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,10 +13,10 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: e3795ef6aac6a3d18039751febe6a301cfe71a7b
+ revision: 49be3f2d43edeeedad48cb492b35242fdb27af74
branch: offline-cache
specs:
- turbo-rails (2.0.20)
+ turbo-rails (2.0.21)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 152d1889c..cec5be07b 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -60,6 +60,15 @@ GIT
rails (>= 6.1)
yabeda (~> 0.6)
+GIT
+ remote: https://github.com/hotwired/turbo-rails.git
+ revision: 49be3f2d43edeeedad48cb492b35242fdb27af74
+ branch: offline-cache
+ specs:
+ turbo-rails (2.0.21)
+ actionpack (>= 7.1.0)
+ railties (>= 7.1.0)
+
GIT
remote: https://github.com/rails/rails.git
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
@@ -633,9 +642,6 @@ GEM
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
- turbo-rails (2.0.23)
- actionpack (>= 7.1.0)
- railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
@@ -752,7 +758,7 @@ DEPENDENCIES
stripe (~> 18.0)
thruster
trilogy (~> 2.10)
- turbo-rails
+ turbo-rails!
useragent!
vcr
web-console!
diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js
index 90ef36a26..1d16ef205 100644
--- a/app/javascript/initializers/index.js
+++ b/app/javascript/initializers/index.js
@@ -1,2 +1,3 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
+import "initializers/offline"
diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js
new file mode 100644
index 000000000..c5eff824e
--- /dev/null
+++ b/app/javascript/initializers/offline.js
@@ -0,0 +1,6 @@
+import { Turbo } from "@hotwired/turbo-rails"
+
+Turbo.offline.start("/service-worker.js", {
+ scope: "/",
+ native: true
+})
diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js
deleted file mode 100644
index 91903e735..000000000
--- a/app/views/pwa/service_worker.js
+++ /dev/null
@@ -1,41 +0,0 @@
-self.addEventListener('fetch', (event) => {
- if (event.request.method !== 'GET') return
-
- if (event.request.destination === 'document') {
- event.respondWith(
- fetch(event.request, { cache: 'no-cache' })
- .catch(() => caches.match(event.request))
- )
- }
-})
-
-self.addEventListener("push", (event) => {
- const data = event.data.json()
- event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
-})
-
-async function showNotification({ title, options }) {
- return self.registration.showNotification(title, options)
-}
-
-async function updateBadgeCount({ data: { badge } }) {
- return self.navigator.setAppBadge?.(badge || 0)
-}
-
-self.addEventListener("notificationclick", (event) => {
- event.notification.close()
-
- const url = new URL(event.notification.data.url, self.location.origin).href
- event.waitUntil(openURL(url))
-})
-
-async function openURL(url) {
- const clients = await self.clients.matchAll({ type: "window" })
- const focused = clients.find((client) => client.focused)
-
- if (focused) {
- await focused.navigate(url)
- } else {
- await self.clients.openWindow(url)
- }
-}
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
new file mode 100644
index 000000000..fb583658c
--- /dev/null
+++ b/app/views/pwa/service_worker.js.erb
@@ -0,0 +1,76 @@
+importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
+
+TurboOffline.addRule({
+ match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,
+ handler: TurboOffline.handlers.cacheFirst({
+ cacheName: "assets",
+ maxAge: 60 * 60 * 24 * 7
+ })
+})
+
+TurboOffline.addRule({
+ match: (request) => {
+ const url = request.url
+ return /\/(boards|cards|users)\//.test(url) && !/\/(edit|pin|watch|new)$/.test(url)
+ },
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "cards",
+ maxAge: 60 * 60 * 24 * 3,
+ networkTimeout: 3
+ })
+})
+
+TurboOffline.addRule({
+ match: /\/rails\/active_storage\//,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "storage",
+ maxAge: 60 * 60 * 24 * 7,
+ networkTimeout: 3
+ })
+})
+
+// Everything else
+TurboOffline.addRule({
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "misc",
+ maxAge: 60 * 60 * 24,
+ networkTimeout: 3
+ })
+})
+
+TurboOffline.start()
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim())
+})
+
+self.addEventListener("push", (event) => {
+ const data = event.data.json()
+ event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
+})
+
+async function showNotification({ title, options }) {
+ return self.registration.showNotification(title, options)
+}
+
+async function updateBadgeCount({ data: { badge } }) {
+ return self.navigator.setAppBadge?.(badge || 0)
+}
+
+self.addEventListener("notificationclick", (event) => {
+ event.notification.close()
+
+ const url = new URL(event.notification.data.url, self.location.origin).href
+ event.waitUntil(openURL(url))
+})
+
+async function openURL(url) {
+ const clients = await self.clients.matchAll({ type: "window" })
+ const focused = clients.find((client) => client.focused)
+
+ if (focused) {
+ await focused.navigate(url)
+ } else {
+ await self.clients.openWindow(url)
+ }
+}
diff --git a/config/importmap.rb b/config/importmap.rb
index 5da84683c..f6910c3e1 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -2,6 +2,7 @@
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
+pin "@hotwired/turbo/offline", to: "turbo-offline.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@hotwired/hotwire-native-bridge", to: "@hotwired--hotwire-native-bridge.js"
From 9fc724572e900953ca500178e3615ec19b7e8602 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Tue, 20 Jan 2026 10:53:40 +0100
Subject: [PATCH 13/43] Use new `except` option for offline rules
---
app/views/pwa/service_worker.js.erb | 10 ++++------
1 file changed, 4 insertions(+), 6 deletions(-)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index fb583658c..0c4ab6ea5 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -9,14 +9,12 @@ TurboOffline.addRule({
})
TurboOffline.addRule({
- match: (request) => {
- const url = request.url
- return /\/(boards|cards|users)\//.test(url) && !/\/(edit|pin|watch|new)$/.test(url)
- },
+ match: /\/(boards|cards|users)\//,
+ except: /\/(edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "cards",
maxAge: 60 * 60 * 24 * 3,
- networkTimeout: 3
+ networkTimeout: 2
})
})
@@ -25,7 +23,7 @@ TurboOffline.addRule({
handler: TurboOffline.handlers.networkFirst({
cacheName: "storage",
maxAge: 60 * 60 * 24 * 7,
- networkTimeout: 3
+ networkTimeout: 2
})
})
From 975968b50f9c8033e278460736e82bbfc2b3dec2 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Tue, 20 Jan 2026 19:16:17 +0100
Subject: [PATCH 14/43] Only enable offline caching for Hotwire Native apps
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This change conditionally renders the TurboOffline caching rules based on
whether the request comes from a Hotwire Native app. Web browsers get a
minimal service worker that only handles push notifications and a simple
document fetch fallback.
Why separate behavior for native vs web?
-----------------------------------------
We want offline caching for Hotwire Native apps (where users expect
app-like offline behavior) but not for regular web browsers.
Why use ERB conditional rendering?
----------------------------------
We explored several approaches to detect Hotwire Native requests in the
service worker:
1. User-Agent detection in fetch handler: Would be ideal, but Android
WebViews override the User-Agent header with the system default when
requests are intercepted by service workers, stripping the custom
"Hotwire Native" identifier.
2. Custom header (X-Hotwire-Native) from native apps: Would require
intercepting ALL requests at the native level using both
WebViewClient.shouldInterceptRequest() and ServiceWorkerClient.
Complex to implement and still incomplete coverage for all request
types (navigation, resources loaded by HTML).
3. In-memory flag with IndexedDB persistence: Service workers can be
terminated when idle and restart with fresh state. Reading from
IndexedDB is async, but the decision to call respondWith() in a
fetch handler must be synchronous.
4. Separate service worker URLs: Same theoretical churn problem as ERB
rendering, with more complexity.
Why ERB conditional rendering works in practice
-----------------------------------------------
The main concern with conditional ERB rendering was "churn" — the service
worker constantly updating as different client types fetch it. However,
this only happens when web and native share storage on the same device.
In practice, this is rare because:
- Android native apps use isolated WebView storage
- iOS doesn't support service workers in WebViews (yet)
- Web browsers have completely separate storage
So each context gets its own stable service worker without churn.
Co-Authored-By: Claude Opus 4.5
---
app/views/pwa/service_worker.js.erb | 73 +++++++++++++++++------------
1 file changed, 43 insertions(+), 30 deletions(-)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index 0c4ab6ea5..b3f59aa60 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -1,44 +1,57 @@
importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
-TurboOffline.addRule({
- match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,
- handler: TurboOffline.handlers.cacheFirst({
- cacheName: "assets",
- maxAge: 60 * 60 * 24 * 7
+<% if hotwire_native_app? %>
+ TurboOffline.addRule({
+ match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,
+ handler: TurboOffline.handlers.cacheFirst({
+ cacheName: "assets",
+ maxAge: 60 * 60 * 24 * 7
+ })
})
-})
-TurboOffline.addRule({
- match: /\/(boards|cards|users)\//,
- except: /\/(edit|pin|watch|new)$/,
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "cards",
- maxAge: 60 * 60 * 24 * 3,
- networkTimeout: 2
+ TurboOffline.addRule({
+ match: /\/(boards|cards|users)\//,
+ except: /\/(edit|pin|watch|new)$/,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "cards",
+ maxAge: 60 * 60 * 24 * 3,
+ networkTimeout: 2
+ })
})
-})
-TurboOffline.addRule({
- match: /\/rails\/active_storage\//,
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "storage",
- maxAge: 60 * 60 * 24 * 7,
- networkTimeout: 2
+ TurboOffline.addRule({
+ match: /\/rails\/active_storage\//,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "storage",
+ maxAge: 60 * 60 * 24 * 7,
+ networkTimeout: 2
+ })
})
-})
-// Everything else
-TurboOffline.addRule({
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "misc",
- maxAge: 60 * 60 * 24,
- networkTimeout: 3
+ // Everything else
+ TurboOffline.addRule({
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "misc",
+ maxAge: 60 * 60 * 24,
+ networkTimeout: 3
+ })
})
-})
-TurboOffline.start()
+ TurboOffline.start()
+<% else %>
+ self.addEventListener("fetch", (event) => {
+ if (event.request.method !== "GET") return
-self.addEventListener('activate', (event) => {
+ if (event.request.destination === "document") {
+ event.respondWith(
+ fetch(event.request, { cache: "no-cache" })
+ .catch(() => caches.match(event.request))
+ )
+ }
+ })
+<% end %>
+
+self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim())
})
From a949f1e3aed45b89e8a8a9caa6f486c858f87acf Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Tue, 27 Jan 2026 09:32:15 +0100
Subject: [PATCH 15/43] Clear offline cache when logging out
Adds a logout Stimulus controller that sends a message to the service
worker to clear all cached content when the user logs out. This ensures
that cached data from one user isn't accessible after logout.
The implementation:
- Adds logout_controller.js that posts { action: "clearCache" } to the
service worker via postMessage
- Updates logout buttons to use the controller on form submission
Also fixes data-turbo placement: moved from button to form element where
it actually takes effect for disabling Turbo Drive form submissions.
Co-Authored-By: Claude Opus 4.5
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
app/javascript/controllers/logout_controller.js | 8 ++++++++
app/views/my/menus/_settings.html.erb | 2 +-
app/views/users/show.html.erb | 2 +-
5 files changed, 12 insertions(+), 4 deletions(-)
create mode 100644 app/javascript/controllers/logout_controller.js
diff --git a/Gemfile.lock b/Gemfile.lock
index a710d0f9e..a17769c07 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 49be3f2d43edeeedad48cb492b35242fdb27af74
+ revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba
branch: offline-cache
specs:
turbo-rails (2.0.21)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index cec5be07b..03c8e9141 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 49be3f2d43edeeedad48cb492b35242fdb27af74
+ revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba
branch: offline-cache
specs:
turbo-rails (2.0.21)
diff --git a/app/javascript/controllers/logout_controller.js b/app/javascript/controllers/logout_controller.js
new file mode 100644
index 000000000..bc0511a7f
--- /dev/null
+++ b/app/javascript/controllers/logout_controller.js
@@ -0,0 +1,8 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ async clearCache() {
+ const registration = await navigator.serviceWorker?.ready
+ registration?.active?.postMessage({ action: "clearCache" })
+ }
+}
diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb
index d1251b7c0..6b711544b 100644
--- a/app/views/my/menus/_settings.html.erb
+++ b/app/views/my/menus/_settings.html.erb
@@ -6,7 +6,7 @@
<%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %>
<%= icon_tag "logout", class: "popup__icon" %>
- <%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
+ <%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", form: { data: { turbo: false, controller: "logout", action: "submit->logout#clearCache" } } do %>
Sign out
<% end %>
<% end %>
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index 58068d2d6..a285a4f5a 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -41,7 +41,7 @@
<% if Current.user == @user %>
- <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %>
+ <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "logout", action: "submit->logout#clearCache" } } do %>
Sign out of Fizzy on this device
<% end %>
<% end %>
From cf8d92afeaf031980296da10340674477c339f4f Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Wed, 28 Jan 2026 21:30:50 +0100
Subject: [PATCH 16/43] Enable offline caching for all web users
Previously, offline caching was conditionally enabled only for Hotwire
Native apps. This removes that restriction and enables offline support
for all users, including PWAs and regular browsers.
This Uses the new `fetchOptions` support in `TurboOffline` handlers
to pass `cache: "no-cache"` for document fetches, which we need
to work around a quite annoying Safari PWA bug
(see https://github.com/basecamp/fizzy/pull/1014).
Also, simplify a bit the cache names and remove the `misc` one.
Co-Authored-By: Claude Opus 4.5
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
app/views/pwa/service_worker.js.erb | 83 +++++++++++++++--------------
3 files changed, 44 insertions(+), 43 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index a17769c07..3cad12c54 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba
+ revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb
branch: offline-cache
specs:
turbo-rails (2.0.21)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 03c8e9141..3517a420e 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba
+ revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb
branch: offline-cache
specs:
turbo-rails (2.0.21)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index b3f59aa60..cfdf5468f 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -1,60 +1,61 @@
importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
-<% if hotwire_native_app? %>
- TurboOffline.addRule({
- match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,
- handler: TurboOffline.handlers.cacheFirst({
- cacheName: "assets",
- maxAge: 60 * 60 * 24 * 7
- })
+// Documents - top-level navigation requests only
+// We need `cache: "no-cache"` here to work around
+// an annoying Safari PWA bug that predates offline mode
+TurboOffline.addRule({
+ match: (request) => request.destination === "document",
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "main",
+ maxAge: 60 * 60 * 24 * 3,
+ networkTimeout: 3,
+ fetchOptions: { cache: "no-cache" }
})
+})
- TurboOffline.addRule({
- match: /\/(boards|cards|users)\//,
- except: /\/(edit|pin|watch|new)$/,
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "cards",
- maxAge: 60 * 60 * 24 * 3,
- networkTimeout: 2
- })
+TurboOffline.addRule({
+ match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,
+ handler: TurboOffline.handlers.cacheFirst({
+ cacheName: "assets",
+ maxAge: 60 * 60 * 24 * 7
})
+})
- TurboOffline.addRule({
- match: /\/rails\/active_storage\//,
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "storage",
- maxAge: 60 * 60 * 24 * 7,
- networkTimeout: 2
- })
+TurboOffline.addRule({
+ match: /\/(boards|cards|users)\//,
+ except: /\/(edit|pin|watch|new)$/,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "main",
+ maxAge: 60 * 60 * 24 * 3,
+ networkTimeout: 2
})
+})
- // Everything else
- TurboOffline.addRule({
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "misc",
- maxAge: 60 * 60 * 24,
- networkTimeout: 3
- })
+TurboOffline.addRule({
+ match: /\/rails\/active_storage\//,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "storage",
+ maxAge: 60 * 60 * 24 * 7,
+ networkTimeout: 2
})
+})
- TurboOffline.start()
-<% else %>
- self.addEventListener("fetch", (event) => {
- if (event.request.method !== "GET") return
-
- if (event.request.destination === "document") {
- event.respondWith(
- fetch(event.request, { cache: "no-cache" })
- .catch(() => caches.match(event.request))
- )
- }
+// Everything else
+TurboOffline.addRule({
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "main",
+ maxAge: 60 * 60 * 24,
+ networkTimeout: 3
})
-<% end %>
+})
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim())
})
+TurboOffline.start()
+
+
self.addEventListener("push", (event) => {
const data = event.data.json()
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
From 90d5a351f0ef3ab712056710f17ff4df65b33ea0 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Thu, 29 Jan 2026 21:03:50 +0100
Subject: [PATCH 17/43] Bump turbo-rails for Range requests support
Cached video and audio files.
---
Gemfile.lock | 4 ++--
Gemfile.saas.lock | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 3cad12c54..405a68bac 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,10 +13,10 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb
+ revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3
branch: offline-cache
specs:
- turbo-rails (2.0.21)
+ turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 3517a420e..2b9f32fd6 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,10 +62,10 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb
+ revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3
branch: offline-cache
specs:
- turbo-rails (2.0.21)
+ turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
From 32134be88216b0c7c6e5407171a5be88da6d4253 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Sun, 1 Feb 2026 15:15:34 +0100
Subject: [PATCH 18/43] Bump turbo-rails for network-first strategy fix on
network error
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 405a68bac..aa14545ca 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3
+ revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 2b9f32fd6..a4033b12b 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3
+ revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550
branch: offline-cache
specs:
turbo-rails (2.0.23)
From 9b264483d11123827ccf556e2eedf91601eae9fe Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Thu, 5 Feb 2026 20:23:36 +0100
Subject: [PATCH 19/43] Bump `turbo-rails` for cache size limits
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index aa14545ca..9dd682dce 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550
+ revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index a4033b12b..8f5c9dd95 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550
+ revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b
branch: offline-cache
specs:
turbo-rails (2.0.23)
From b93808235c058c1f2ed422a713f4f97cf09389ab Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Thu, 5 Feb 2026 21:15:22 +0100
Subject: [PATCH 20/43] Limit cached entry sizes
1MB and no limit for number of entries except for attachments, where we
limit individual entries to 2MB and total of entries to 500. The number
is based on the following percentiles for Active Storage blobs:
```
p50: 97.1044921875 KB
p75: 236.9140625 KB
p90: 917.7548828125 KB
```
---
app/views/pwa/service_worker.js.erb | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index cfdf5468f..84f274673 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -9,7 +9,8 @@ TurboOffline.addRule({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
networkTimeout: 3,
- fetchOptions: { cache: "no-cache" }
+ fetchOptions: { cache: "no-cache" },
+ maxEntrySize: 1024 * 1024
})
})
@@ -17,7 +18,8 @@ TurboOffline.addRule({
match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,
handler: TurboOffline.handlers.cacheFirst({
cacheName: "assets",
- maxAge: 60 * 60 * 24 * 7
+ maxAge: 60 * 60 * 24 * 7,
+ maxEntrySize: 1024 * 1024
})
})
@@ -27,7 +29,8 @@ TurboOffline.addRule({
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
- networkTimeout: 2
+ networkTimeout: 2,
+ maxEntrySize: 1024 * 1024
})
})
@@ -36,7 +39,9 @@ TurboOffline.addRule({
handler: TurboOffline.handlers.networkFirst({
cacheName: "storage",
maxAge: 60 * 60 * 24 * 7,
- networkTimeout: 2
+ networkTimeout: 2,
+ maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
+ maxEntries: 500
})
})
@@ -45,7 +50,8 @@ TurboOffline.addRule({
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24,
- networkTimeout: 3
+ networkTimeout: 3,
+ maxEntrySize: 1024 * 1024
})
})
From 089fcf94f8969b432ba113b18a924cd85836ab05 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 6 Feb 2026 12:37:39 +0100
Subject: [PATCH 21/43] Preload resources after service worker installation
When the service worker is registered for the first time, resources loaded
before it becomes active won't go through the service worker. These resources
may be served from the browser's HTTP cache on subsequent requests, bypassing
the service worker cache entirely.
The new `preload` option accepts a regex pattern. On first visit (when no
controller exists), it waits for the service worker to take control, then
sends a message with URLs from `performance.getEntriesByType("resource")`
that match the pattern. The service worker fetches and caches these resources.
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
app/javascript/initializers/offline.js | 7 ++++---
3 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 9dd682dce..758936188 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b
+ revision: ca62e8bdad88d01914d512ad24424e0670f239c9
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 8f5c9dd95..a3552dacd 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b
+ revision: ca62e8bdad88d01914d512ad24424e0670f239c9
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js
index c5eff824e..446a9671e 100644
--- a/app/javascript/initializers/offline.js
+++ b/app/javascript/initializers/offline.js
@@ -1,6 +1,7 @@
import { Turbo } from "@hotwired/turbo-rails"
-Turbo.offline.start("/service-worker.js", {
- scope: "/",
- native: true
+Turbo.offline.start("/service-worker.js", {
+ scope: "/",
+ native: true,
+ preload: /\/assets\//
})
From 1c2de6ca79bdd5617e5668c6cb37bf525f3c7085 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 20 Feb 2026 11:31:36 +0100
Subject: [PATCH 22/43] Clear offline cache on sign-in as well as sign-out
Rename logout controller to clear-offline-cache and attach it to the
magic link verification form so the service worker cache is cleared
when a different user signs in.
Co-Authored-By: Claude Opus 4.6
---
.../{logout_controller.js => clear_offline_cache_controller.js} | 0
app/views/my/menus/_settings.html.erb | 2 +-
app/views/sessions/magic_links/show.html.erb | 2 +-
app/views/users/show.html.erb | 2 +-
4 files changed, 3 insertions(+), 3 deletions(-)
rename app/javascript/controllers/{logout_controller.js => clear_offline_cache_controller.js} (100%)
diff --git a/app/javascript/controllers/logout_controller.js b/app/javascript/controllers/clear_offline_cache_controller.js
similarity index 100%
rename from app/javascript/controllers/logout_controller.js
rename to app/javascript/controllers/clear_offline_cache_controller.js
diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb
index 6b711544b..bf34b991d 100644
--- a/app/views/my/menus/_settings.html.erb
+++ b/app/views/my/menus/_settings.html.erb
@@ -6,7 +6,7 @@
<%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %>
<%= icon_tag "logout", class: "popup__icon" %>
- <%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", form: { data: { turbo: false, controller: "logout", action: "submit->logout#clearCache" } } do %>
+ <%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
Sign out
<% end %>
<% end %>
diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb
index aba8411f5..968624a10 100644
--- a/app/views/sessions/magic_links/show.html.erb
+++ b/app/views/sessions/magic_links/show.html.erb
@@ -8,7 +8,7 @@
- <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %>
+ <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do |form| %>
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large txt-uppercase",
autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true,
autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code],
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index a285a4f5a..eb3e40c64 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -41,7 +41,7 @@
<% if Current.user == @user %>
- <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "logout", action: "submit->logout#clearCache" } } do %>
+ <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
Sign out of Fizzy on this device
<% end %>
<% end %>
From de6d70cc2efa1aa3f972d2bb1427a1731c9c2710 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 20 Feb 2026 11:45:06 +0100
Subject: [PATCH 23/43] Bump turbo-rails for Turbo.offline.clearCache()
Simplify the clear-offline-cache controller to use the new
Turbo.offline.clearCache() API instead of messaging the service
worker directly.
Co-Authored-By: Claude Opus 4.6
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
.../controllers/clear_offline_cache_controller.js | 6 +++---
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 758936188..8a49b38a5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: ca62e8bdad88d01914d512ad24424e0670f239c9
+ revision: 4104a353cfe728232dc0eed6d54c77466a9849f2
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index a3552dacd..751dc55fb 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: ca62e8bdad88d01914d512ad24424e0670f239c9
+ revision: 4104a353cfe728232dc0eed6d54c77466a9849f2
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/app/javascript/controllers/clear_offline_cache_controller.js b/app/javascript/controllers/clear_offline_cache_controller.js
index bc0511a7f..7caa2e89b 100644
--- a/app/javascript/controllers/clear_offline_cache_controller.js
+++ b/app/javascript/controllers/clear_offline_cache_controller.js
@@ -1,8 +1,8 @@
import { Controller } from "@hotwired/stimulus"
+import { Turbo } from "@hotwired/turbo-rails"
export default class extends Controller {
- async clearCache() {
- const registration = await navigator.serviceWorker?.ready
- registration?.active?.postMessage({ action: "clearCache" })
+ clearCache() {
+ Turbo.offline.clearCache()
}
}
From 0482670fec187f3361531a6b8a4672430b65345f Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 20 Feb 2026 12:07:38 +0100
Subject: [PATCH 24/43] Exclude service worker and edit/pin/watch/new pages
from main cache
We don't need the service worker to cache itself, or pages that won't
work offline anyway.
---
app/javascript/controllers/magic_link_controller.js | 2 +-
app/views/pwa/service_worker.js.erb | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/app/javascript/controllers/magic_link_controller.js b/app/javascript/controllers/magic_link_controller.js
index 3e2e4618c..8217af5aa 100644
--- a/app/javascript/controllers/magic_link_controller.js
+++ b/app/javascript/controllers/magic_link_controller.js
@@ -15,7 +15,7 @@ export default class extends Controller {
submit() {
if (this.inputTarget.disabled) return
- this.element.submit()
+ this.element.requestSubmit()
this.inputTarget.disabled = true
}
}
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index 84f274673..095595f4e 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -5,6 +5,7 @@ importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
// an annoying Safari PWA bug that predates offline mode
TurboOffline.addRule({
match: (request) => request.destination === "document",
+ except: /\/(edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
@@ -47,6 +48,7 @@ TurboOffline.addRule({
// Everything else
TurboOffline.addRule({
+ except: /\/(service-worker\.js|edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24,
From 99382db7203f1598c27d71b612a75230cc8b83b6 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 20 Feb 2026 12:43:18 +0100
Subject: [PATCH 25/43] Only start offline mode when signed in
Gate Turbo.offline.start() behind Current.user so the service worker
is only registered for authenticated users. This avoids errors on
unauthenticated pages where /service-worker.js requires auth.
Co-Authored-By: Claude Opus 4.6
---
app/javascript/initializers/offline.js | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js
index 446a9671e..877ffa0ef 100644
--- a/app/javascript/initializers/offline.js
+++ b/app/javascript/initializers/offline.js
@@ -1,7 +1,9 @@
import { Turbo } from "@hotwired/turbo-rails"
-Turbo.offline.start("/service-worker.js", {
- scope: "/",
- native: true,
- preload: /\/assets\//
-})
+if (Current.user) {
+ Turbo.offline.start("/service-worker.js", {
+ scope: "/",
+ native: true,
+ preload: /\/assets\//
+ })
+}
From 67d629f72219b72936127bd12ae7721c4e7a33bb Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 20 Feb 2026 20:48:01 +0100
Subject: [PATCH 26/43] Use CORS fetch mode for Active Storage to enable
`maxEntrySize` checks
Otherwise, for resources like images loaded via tags, the browser
sets `mode: "no-cors"` (as these aren't CORS requests), so the service
worker gets an opaque response even though the server sends CORS
headers.
We could upgrade all no-cors requests to cors mode, sending a `mode:
"cors"` request to a server that doesn't send CORS headers will fail
entirely: the `fetch` call throws a `TypeError` (network error), and the
browser blocks the response. So the resource wouldn't load at all, not
even as opaque. We wouldn't be able to cache it at all.
By opting in via `fetchOptions`, we can do it only for rules where we
know the server will send CORS headers.
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
app/views/pwa/service_worker.js.erb | 3 ++-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 8a49b38a5..e811a1c2f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 4104a353cfe728232dc0eed6d54c77466a9849f2
+ revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 751dc55fb..af3c102c1 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 4104a353cfe728232dc0eed6d54c77466a9849f2
+ revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index 095595f4e..5ab493e22 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -42,7 +42,8 @@ TurboOffline.addRule({
maxAge: 60 * 60 * 24 * 7,
networkTimeout: 2,
maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
- maxEntries: 500
+ maxEntries: 500,
+ fetchOptions: { mode: "cors", credentials: "omit" }
})
})
From 1855490f805477db7bd0eebdca3fc52da1a8f0c7 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Sat, 21 Feb 2026 09:20:47 +0100
Subject: [PATCH 27/43] Revert CORS fetch mode for Active Storage
CORS mode doesn't work with redirect-based Active Storage URLs
when the storage bucket uses wildcard Access-Control-Allow-Origin.
Relying on maxEntries alone until specific origin CORS is available.
Co-Authored-By: Claude Opus 4.6
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
app/views/pwa/service_worker.js.erb | 3 +--
3 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index e811a1c2f..8db6817dc 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2
+ revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index af3c102c1..37ff08294 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2
+ revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index 5ab493e22..095595f4e 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -42,8 +42,7 @@ TurboOffline.addRule({
maxAge: 60 * 60 * 24 * 7,
networkTimeout: 2,
maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
- maxEntries: 500,
- fetchOptions: { mode: "cors", credentials: "omit" }
+ maxEntries: 500
})
})
From 49a86c7cbf7905d7cb67e1e69e2922040658a279 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Tue, 3 Mar 2026 15:59:25 +0000
Subject: [PATCH 28/43] Enable CORS fetch mode for storage requests
The load balancer now returns a specific `Access-Control-Allow-Origin`
instead of a wildcard, so credentials can be included by default.
This enables `maxEntrySize` enforcement for storage.
Co-Authored-By: Claude Opus 4.6
---
app/views/pwa/service_worker.js.erb | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index 095595f4e..41fac1446 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -42,7 +42,8 @@ TurboOffline.addRule({
maxAge: 60 * 60 * 24 * 7,
networkTimeout: 2,
maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
- maxEntries: 500
+ maxEntries: 500,
+ fetchOptions: { mode: "cors" }
})
})
From 71b99c1e18bf49f19839e088b8abad417a119fbb Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Tue, 10 Mar 2026 11:03:40 -0400
Subject: [PATCH 29/43] Fix path traversal vulnerability in ActiveStorage blob
key import
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BlobRecordSet#import_batch preserved blob keys from exported ZIP files
verbatim. A crafted ZIP with a traversal key like "../../config/deploy.yml"
would create a blob whose key resolves to an arbitrary filesystem path
when served by ActiveStorage's DiskService, allowing an attacker to read
local files.
Fix: generate fresh blob keys on import, discarding whatever key was in
the ZIP. This is safe because nothing else in the import pipeline
references blobs by key — attachments use blob_id, ActionText uses GIDs
based on record IDs, and only FileRecordSet used the old key for file
data lookup.
Update FileRecordSet to build an old_key→blob_id mapping from the blob
JSON metadata in the ZIP, then look up blobs by ID instead of by key.
Both check_record and import_batch are now fail-closed: they raise
IntegrityError for unmapped storage files, missing blobs, and duplicate
keys in the export.
Backfill test coverage for BlobRecordSet and FileRecordSet import, and
add a round-trip test verifying blob data survives export/import with
regenerated keys.
---
.../active_storage/blob_record_set.rb | 1 +
.../active_storage/file_record_set.rb | 38 +++-
test/integration/blob_key_traversal_test.rb | 45 +++++
.../active_storage/blob_record_set_test.rb | 58 ++++++
.../active_storage/file_record_set_test.rb | 177 ++++++++++++++++++
test/models/account/import_test.rb | 46 +++++
6 files changed, 357 insertions(+), 8 deletions(-)
create mode 100644 test/integration/blob_key_traversal_test.rb
create mode 100644 test/models/account/data_transfer/active_storage/blob_record_set_test.rb
create mode 100644 test/models/account/data_transfer/active_storage/file_record_set_test.rb
diff --git a/app/models/account/data_transfer/active_storage/blob_record_set.rb b/app/models/account/data_transfer/active_storage/blob_record_set.rb
index 1637768f3..0c4a553c1 100644
--- a/app/models/account/data_transfer/active_storage/blob_record_set.rb
+++ b/app/models/account/data_transfer/active_storage/blob_record_set.rb
@@ -13,6 +13,7 @@ class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfe
data = load(file)
data.slice(*attributes).merge(
"account_id" => account.id,
+ "key" => ::ActiveStorage::Blob.generate_unique_secure_token(length: ::ActiveStorage::Blob::MINIMUM_TOKEN_LENGTH),
"service_name" => ::ActiveStorage::Blob.service.name
)
end
diff --git a/app/models/account/data_transfer/active_storage/file_record_set.rb b/app/models/account/data_transfer/active_storage/file_record_set.rb
index 15d359353..421a2e520 100644
--- a/app/models/account/data_transfer/active_storage/file_record_set.rb
+++ b/app/models/account/data_transfer/active_storage/file_record_set.rb
@@ -22,9 +22,12 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe
def import_batch(files)
files.each do |file|
- key = File.basename(file)
- blob = ::ActiveStorage::Blob.find_by(key: key, account: account)
- next unless blob
+ old_key = file.delete_prefix("storage/")
+ blob_id = old_key_to_blob_id[old_key]
+ raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export" unless blob_id
+
+ blob = ::ActiveStorage::Blob.find_by(id: blob_id, account: account)
+ raise IntegrityError, "Blob #{blob_id} not found for storage key #{old_key}" unless blob
zip.read(file) do |stream|
blob.upload(stream)
@@ -32,12 +35,31 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe
end
end
- def check_record(file_path)
- key = File.basename(file_path)
+ def old_key_to_blob_id
+ @old_key_to_blob_id ||= build_old_key_to_blob_id
+ end
- unless zip.exists?("data/active_storage_blobs/#{key}.json") || ::ActiveStorage::Blob.exists?(key: key, account: account)
- # File exists without corresponding blob record - could be orphaned or blob not yet imported
- # We allow this since blob metadata is imported before files
+ def build_old_key_to_blob_id
+ zip.glob("data/active_storage_blobs/*.json").each_with_object({}) do |file, map|
+ data = load(file)
+ old_key = data["key"]
+ if map.key?(old_key)
+ raise IntegrityError, "Duplicate blob key in export: #{old_key}"
+ end
+ map[old_key] = data["id"]
+ end
+ end
+
+ def with_zip(zip)
+ @old_key_to_blob_id = nil
+ super
+ end
+
+ def check_record(file_path)
+ old_key = file_path.delete_prefix("storage/")
+
+ unless old_key_to_blob_id.key?(old_key)
+ raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export"
end
end
end
diff --git a/test/integration/blob_key_traversal_test.rb b/test/integration/blob_key_traversal_test.rb
new file mode 100644
index 000000000..31dc8e179
--- /dev/null
+++ b/test/integration/blob_key_traversal_test.rb
@@ -0,0 +1,45 @@
+require "test_helper"
+
+class Account::DataTransfer::ActiveStorage::BlobKeyTraversalTest < ActionDispatch::IntegrationTest
+ test "import with path traversal blob key does not leak local files" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ traversal_key = "../../config/deploy.yml"
+ zip = build_zip_with_blob(id: blob_id, key: traversal_key)
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+ blob = ActiveStorage::Blob.find(blob_id)
+
+ assert_not_equal traversal_key, blob.key
+
+ sign_in_as identities(:david)
+ get rails_blob_path(blob, disposition: "inline")
+
+ assert_response :redirect
+
+ follow_redirect!
+
+ assert_response :not_found
+ end
+
+ private
+ def build_zip_with_blob(id:, key:)
+ tempfile = Tempfile.new([ "malicious", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/active_storage_blobs/#{id}.json", {
+ id: id,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: 32,
+ checksum: "",
+ content_type: "text/plain",
+ created_at: Time.current.iso8601,
+ filename: "traversal.txt",
+ key: key,
+ metadata: {}
+ }.to_json)
+ writer.close
+
+ tempfile.rewind
+ ZipFile::Reader.new(tempfile)
+ end
+end
diff --git a/test/models/account/data_transfer/active_storage/blob_record_set_test.rb b/test/models/account/data_transfer/active_storage/blob_record_set_test.rb
new file mode 100644
index 000000000..82b83f428
--- /dev/null
+++ b/test/models/account/data_transfer/active_storage/blob_record_set_test.rb
@@ -0,0 +1,58 @@
+require "test_helper"
+
+class Account::DataTransfer::ActiveStorage::BlobRecordSetTest < ActiveSupport::TestCase
+ test "import generates fresh keys instead of using exported keys" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ exported_key = "original-exported-key-abc123"
+
+ zip = build_zip_with_blob(id: blob_id, key: exported_key)
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+
+ blob = ActiveStorage::Blob.find(blob_id)
+ assert_not_equal exported_key, blob.key
+ assert_equal 28, blob.key.length
+ end
+
+ test "import preserves blob metadata" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+
+ zip = build_zip_with_blob(
+ id: blob_id,
+ key: "some-key",
+ filename: "report.pdf",
+ content_type: "application/pdf",
+ byte_size: 12345,
+ checksum: "abc123checksum"
+ )
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+
+ blob = ActiveStorage::Blob.find(blob_id)
+ assert_equal "report.pdf", blob.filename.to_s
+ assert_equal "application/pdf", blob.content_type
+ assert_equal 12345, blob.byte_size
+ assert_equal "abc123checksum", blob.checksum
+ end
+
+ private
+ def build_zip_with_blob(id:, key:, filename: "test.txt", content_type: "text/plain", byte_size: 32, checksum: "")
+ tempfile = Tempfile.new([ "test_export", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/active_storage_blobs/#{id}.json", {
+ id: id,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: byte_size,
+ checksum: checksum,
+ content_type: content_type,
+ created_at: Time.current.iso8601,
+ filename: filename,
+ key: key,
+ metadata: {}
+ }.to_json)
+ writer.close
+
+ tempfile.rewind
+ ZipFile::Reader.new(tempfile)
+ end
+end
diff --git a/test/models/account/data_transfer/active_storage/file_record_set_test.rb b/test/models/account/data_transfer/active_storage/file_record_set_test.rb
new file mode 100644
index 000000000..4784754ae
--- /dev/null
+++ b/test/models/account/data_transfer/active_storage/file_record_set_test.rb
@@ -0,0 +1,177 @@
+require "test_helper"
+
+class Account::DataTransfer::ActiveStorage::FileRecordSetTest < ActiveSupport::TestCase
+ test "import uploads file data to blobs with regenerated keys" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ old_key = "original-key-for-file"
+ file_content = "hello world file content"
+
+ zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: file_content)
+
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)
+
+ blob = ActiveStorage::Blob.find(blob_id)
+ assert_not_equal old_key, blob.key
+ assert_equal file_content, blob.download
+ end
+
+ test "import handles keys containing path separators" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ old_key = "folder/subfolder/file-key"
+ file_content = "nested key content"
+
+ zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: file_content)
+
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)
+
+ blob = ActiveStorage::Blob.find(blob_id)
+ assert_not_equal old_key, blob.key
+ assert_equal file_content, blob.download
+ end
+
+ test "import raises IntegrityError for storage file without matching blob metadata" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ old_key = "key-with-metadata"
+ orphan_key = "orphaned-storage-key"
+
+ zip = build_zip_with_orphaned_storage_file(
+ blob_id: blob_id,
+ old_key: old_key,
+ orphan_key: orphan_key
+ )
+
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+
+ assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)
+ end
+ end
+
+ test "import raises IntegrityError when mapped blob is not found in database" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ old_key = "key-for-missing-blob"
+
+ zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: "data")
+
+ # Import file data WITHOUT importing blob metadata first
+ assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)
+ end
+ end
+
+ test "check raises IntegrityError for storage file without matching blob metadata" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ old_key = "key-with-metadata"
+ orphan_key = "orphaned-storage-key"
+
+ zip = build_zip_with_orphaned_storage_file(
+ blob_id: blob_id,
+ old_key: old_key,
+ orphan_key: orphan_key
+ )
+
+ assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).check(from: zip)
+ end
+ end
+
+ test "import raises IntegrityError for duplicate blob keys in export" do
+ blob_id_1 = ActiveRecord::Type::Uuid.generate
+ blob_id_2 = ActiveRecord::Type::Uuid.generate
+ duplicate_key = "same-key-for-both"
+
+ zip = build_zip_with_duplicate_keys(
+ blob_id_1: blob_id_1,
+ blob_id_2: blob_id_2,
+ key: duplicate_key
+ )
+
+ assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)
+ end
+ end
+
+ private
+ def build_zip_with_blob_and_file(blob_id:, old_key:, file_content:)
+ tempfile = Tempfile.new([ "test_export", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/active_storage_blobs/#{blob_id}.json", {
+ id: blob_id,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: file_content.bytesize,
+ checksum: Digest::MD5.base64digest(file_content),
+ content_type: "text/plain",
+ created_at: Time.current.iso8601,
+ filename: "test.txt",
+ key: old_key,
+ metadata: {}
+ }.to_json)
+ writer.add_file("storage/#{old_key}", file_content, compress: false)
+ writer.close
+
+ tempfile.rewind
+ ZipFile::Reader.new(tempfile)
+ end
+
+ def build_zip_with_orphaned_storage_file(blob_id:, old_key:, orphan_key:)
+ tempfile = Tempfile.new([ "test_export", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/active_storage_blobs/#{blob_id}.json", {
+ id: blob_id,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: 10,
+ checksum: "",
+ content_type: "text/plain",
+ created_at: Time.current.iso8601,
+ filename: "test.txt",
+ key: old_key,
+ metadata: {}
+ }.to_json)
+ writer.add_file("storage/#{old_key}", "file data", compress: false)
+ writer.add_file("storage/#{orphan_key}", "orphan data", compress: false)
+ writer.close
+
+ tempfile.rewind
+ ZipFile::Reader.new(tempfile)
+ end
+
+ def build_zip_with_duplicate_keys(blob_id_1:, blob_id_2:, key:)
+ tempfile = Tempfile.new([ "test_export", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/active_storage_blobs/#{blob_id_1}.json", {
+ id: blob_id_1,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: 10,
+ checksum: "",
+ content_type: "text/plain",
+ created_at: Time.current.iso8601,
+ filename: "file1.txt",
+ key: key,
+ metadata: {}
+ }.to_json)
+ writer.add_file("data/active_storage_blobs/#{blob_id_2}.json", {
+ id: blob_id_2,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: 10,
+ checksum: "",
+ content_type: "text/plain",
+ created_at: Time.current.iso8601,
+ filename: "file2.txt",
+ key: key,
+ metadata: {}
+ }.to_json)
+ writer.add_file("storage/#{key}", "file data", compress: false)
+ writer.close
+
+ tempfile.rewind
+ ZipFile::Reader.new(tempfile)
+ end
+end
diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb
index 7bf407041..de384b385 100644
--- a/test/models/account/import_test.rb
+++ b/test/models/account/import_test.rb
@@ -174,6 +174,52 @@ class Account::ImportTest < ActiveSupport::TestCase
export_tempfile&.unlink
end
+ test "export and import round-trip preserves blobs and attachments" do
+ source_account = accounts("37s")
+ exporter = users(:david)
+ identity = exporter.identity
+
+ ActiveStorage::Blob.create_and_upload!(
+ io: StringIO.new("test image data"),
+ filename: "logo.png",
+ content_type: "image/png"
+ )
+
+ source_blob_count = ActiveStorage::Blob.where(account: source_account).count
+ source_blob_keys = ActiveStorage::Blob.where(account: source_account).pluck(:key)
+
+ assert_operator source_blob_count, :>, 0
+
+ export = Account::Export.create!(account: source_account, user: exporter)
+ export.build
+
+ export_tempfile = Tempfile.new([ "export", ".zip" ])
+ export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) }
+
+ ActiveStorage::Blob.where(account: source_account).delete_all
+ source_account.destroy!
+
+ target_account = Account.create_with_owner(account: { name: "Import Test" }, owner: { identity: identity, name: exporter.name })
+ import = Account::Import.create!(identity: identity, account: target_account)
+ Current.set(account: target_account) do
+ import.file.attach(io: File.open(export_tempfile.path), filename: "export.zip", content_type: "application/zip")
+ end
+
+ import.check
+ assert_not import.failed?
+
+ import.process
+ assert import.completed?
+
+ imported_blob = ActiveStorage::Blob.find_by(account: target_account, filename: "logo.png")
+ assert_not_nil imported_blob
+ assert_not_includes source_blob_keys, imported_blob.key
+ assert_equal "test image data", imported_blob.download
+ ensure
+ export_tempfile&.close
+ export_tempfile&.unlink
+ end
+
private
def account_digest(account)
{
From 8c2318e2674cbafed12cf63e9a1faac5b02eecdb Mon Sep 17 00:00:00 2001
From: Rob Zolkos
Date: Tue, 10 Mar 2026 11:31:00 -0400
Subject: [PATCH 30/43] Add JSON response format to entropy endpoints (#2673)
* Add JSON response format to entropy endpoints
Add account settings JSON endpoint returning account info including auto_postpone_period. Add auto_postpone_period to board JSON responses. Add JSON update support to both account and board entropy controllers.
* Add auto_postpone_period to all board response examples in API docs
* Use auto_postpone_period_in_days in JSON responses and API docs
* Use valid period values in permission tests
---
.../account/entropies_controller.rb | 5 +-
.../account/settings_controller.rb | 5 +-
.../boards/entropies_controller.rb | 2 +-
app/views/account/settings/show.json.jbuilder | 4 +-
app/views/boards/_board.json.jbuilder | 1 +
docs/API.md | 78 ++++++++++++++++++-
.../accounts/entropies_controller_test.rb | 19 ++++-
.../accounts/settings_controller_test.rb | 16 ++--
test/controllers/api/flat_json_params_test.rb | 4 +-
.../boards/entropies_controller_test.rb | 29 ++++++-
test/controllers/boards_controller_test.rb | 1 +
11 files changed, 143 insertions(+), 21 deletions(-)
diff --git a/app/controllers/account/entropies_controller.rb b/app/controllers/account/entropies_controller.rb
index 327098ee2..44c9debe5 100644
--- a/app/controllers/account/entropies_controller.rb
+++ b/app/controllers/account/entropies_controller.rb
@@ -4,11 +4,12 @@ class Account::EntropiesController < ApplicationController
before_action :ensure_admin
def update
- Current.account.entropy.update!(entropy_params)
+ @account = Current.account
+ @account.entropy.update!(entropy_params)
respond_to do |format|
format.html { redirect_to account_settings_path, notice: "Account updated" }
- format.json { head :no_content }
+ format.json { render "account/settings/show", status: :ok }
end
rescue ActiveRecord::RecordInvalid
head :unprocessable_entity
diff --git a/app/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb
index 47036d606..e4b949070 100644
--- a/app/controllers/account/settings_controller.rb
+++ b/app/controllers/account/settings_controller.rb
@@ -5,7 +5,10 @@ class Account::SettingsController < ApplicationController
before_action :set_account
def show
- @users = @account.users.active.alphabetically.includes(:identity)
+ respond_to do |format|
+ format.html { @users = @account.users.active.alphabetically.includes(:identity) }
+ format.json
+ end
end
def update
diff --git a/app/controllers/boards/entropies_controller.rb b/app/controllers/boards/entropies_controller.rb
index 132733aac..89e262545 100644
--- a/app/controllers/boards/entropies_controller.rb
+++ b/app/controllers/boards/entropies_controller.rb
@@ -10,7 +10,7 @@ class Boards::EntropiesController < ApplicationController
respond_to do |format|
format.turbo_stream
- format.json { head :no_content }
+ format.json { render "boards/show", status: :ok }
end
rescue ActiveRecord::RecordInvalid
head :unprocessable_entity
diff --git a/app/views/account/settings/show.json.jbuilder b/app/views/account/settings/show.json.jbuilder
index 9a62dabc6..55b623f9d 100644
--- a/app/views/account/settings/show.json.jbuilder
+++ b/app/views/account/settings/show.json.jbuilder
@@ -1 +1,3 @@
-json.(Current.account, :name)
+json.(@account, :id, :name, :cards_count)
+json.created_at @account.created_at.utc
+json.auto_postpone_period_in_days @account.entropy.auto_postpone_period_in_days
diff --git a/app/views/boards/_board.json.jbuilder b/app/views/boards/_board.json.jbuilder
index 7c85a1dc5..0eeff1896 100644
--- a/app/views/boards/_board.json.jbuilder
+++ b/app/views/boards/_board.json.jbuilder
@@ -1,6 +1,7 @@
json.cache! board do
json.(board, :id, :name, :all_access)
json.created_at board.created_at.utc
+ json.auto_postpone_period_in_days board.auto_postpone_period_in_days
json.url board_url(board)
json.creator board.creator, partial: "users/user", as: :user
diff --git a/docs/API.md b/docs/API.md
index cec58f36e..e8b60697c 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -434,6 +434,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
+ "auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -460,6 +461,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
+ "auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -484,7 +486,7 @@ Creates a new Board in the account.
|-----------|------|----------|-------------|
| `name` | string | Yes | The name of the board |
| `all_access` | boolean | No | Whether any user in the account can access this board. Defaults to `true` |
-| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed |
+| `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) |
| `public_description` | string | No | Rich text description shown on the public board page |
__Request:__
@@ -514,7 +516,7 @@ Updates a Board. Only board administrators can update a board.
|-----------|------|----------|-------------|
| `name` | string | No | The name of the board |
| `all_access` | boolean | No | Whether any user in the account can access this board |
-| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed |
+| `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) |
| `public_description` | string | No | Rich text description shown on the public board page |
| `user_ids` | array | No | Array of *all* user IDs who should have access to this board (only applicable when `all_access` is `false`) |
@@ -524,7 +526,7 @@ __Request:__
{
"board": {
"name": "Updated board name",
- "auto_postpone_period": 14,
+ "auto_postpone_period_in_days": 30,
"public_description": "This is a **public** description of the board.",
"all_access": false,
"user_ids": [
@@ -567,6 +569,7 @@ HTTP/1.1 201 Created
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
+ "auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -591,6 +594,72 @@ __Response:__
Returns `204 No Content` on success.
+## Account
+
+### `GET /account/settings`
+
+Returns the current account.
+
+__Response:__
+
+```json
+{
+ "id": "03f5v9zjvypwh0t0e2rfh0h7k",
+ "name": "37signals",
+ "cards_count": 5,
+ "created_at": "2025-12-05T19:36:35.401Z",
+ "auto_postpone_period_in_days": 30
+}
+```
+
+The `auto_postpone_period_in_days` is the account-level default in days (e.g. `30`). Cards are automatically moved to "Not Now" after this period of inactivity. Each board can override this with its own value.
+
+### `PUT /account/entropy`
+
+Updates the account-level default auto close period. Requires admin role.
+
+__Request:__
+
+```json
+{
+ "entropy": {
+ "auto_postpone_period_in_days": 30
+ }
+}
+```
+
+__Response:__
+
+Returns the account object:
+
+```json
+{
+ "id": "03f5v9zjvypwh0t0e2rfh0h7k",
+ "name": "37signals",
+ "cards_count": 5,
+ "created_at": "2025-12-05T19:36:35.401Z",
+ "auto_postpone_period_in_days": 30
+}
+```
+
+### `PUT /:account_slug/boards/:board_id/entropy`
+
+Updates the auto close period for a specific board. Requires board admin permission.
+
+__Request:__
+
+```json
+{
+ "board": {
+ "auto_postpone_period_in_days": 90
+ }
+}
+```
+
+__Response:__
+
+Returns the board object.
+
## Webhooks
Webhooks notify another application when something happens on a board. Only account admins can list, view, create, update, delete, or reactivate webhooks.
@@ -757,6 +826,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
+ "auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -810,6 +880,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
+ "auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -1077,6 +1148,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
+ "auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
diff --git a/test/controllers/accounts/entropies_controller_test.rb b/test/controllers/accounts/entropies_controller_test.rb
index d00c0d768..fa2819d48 100644
--- a/test/controllers/accounts/entropies_controller_test.rb
+++ b/test/controllers/accounts/entropies_controller_test.rb
@@ -16,8 +16,9 @@ class Account::EntropiesControllerTest < ActionDispatch::IntegrationTest
test "update as JSON" do
put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json
- assert_response :no_content
+ assert_response :success
assert_equal 7.days, entropies("37s_account").reload.auto_postpone_period
+ assert_equal 7, @response.parsed_body["auto_postpone_period_in_days"]
end
test "update requires admin" do
@@ -35,4 +36,20 @@ class Account::EntropiesControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
assert_equal original_period, entropies("37s_account").reload.auto_postpone_period
end
+
+ test "update as JSON rejects invalid auto_postpone_period" do
+ original_period = entropies("37s_account").auto_postpone_period
+
+ put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 1 } }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_equal original_period, entropies("37s_account").reload.auto_postpone_period
+ end
+
+ test "update as JSON requires admin" do
+ logout_and_sign_in_as :david
+
+ put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json
+ assert_response :forbidden
+ end
end
diff --git a/test/controllers/accounts/settings_controller_test.rb b/test/controllers/accounts/settings_controller_test.rb
index 6f3fba5b0..88a6ef137 100644
--- a/test/controllers/accounts/settings_controller_test.rb
+++ b/test/controllers/accounts/settings_controller_test.rb
@@ -16,13 +16,6 @@ class Account::SettingsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to account_settings_path
end
- test "show as JSON" do
- get account_settings_path, as: :json
- assert_response :success
-
- assert_equal Current.account.name, @response.parsed_body["name"]
- end
-
test "update as JSON" do
put account_settings_path, params: { account: { name: "New Account Name" } }, as: :json
@@ -36,4 +29,13 @@ class Account::SettingsControllerTest < ActionDispatch::IntegrationTest
put account_settings_path, params: { account: { name: "New Account Name" } }
assert_response :forbidden
end
+
+ test "show as JSON" do
+ get account_settings_path, as: :json
+
+ assert_response :success
+ assert_equal Current.account.name, @response.parsed_body["name"]
+ assert_equal Current.account.cards_count, @response.parsed_body["cards_count"]
+ assert_equal Current.account.entropy.auto_postpone_period_in_days, @response.parsed_body["auto_postpone_period_in_days"]
+ end
end
diff --git a/test/controllers/api/flat_json_params_test.rb b/test/controllers/api/flat_json_params_test.rb
index 90f55f8fd..7a9a5ea5d 100644
--- a/test/controllers/api/flat_json_params_test.rb
+++ b/test/controllers/api/flat_json_params_test.rb
@@ -41,14 +41,14 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest
put board_entropy_path(board), params: { auto_postpone_period_in_days: 90 }, as: :json
- assert_response :no_content
+ assert_response :success
assert_equal 90.days, board.entropy.reload.auto_postpone_period
end
test "update account entropy with flat JSON" do
put account_entropy_path, params: { auto_postpone_period_in_days: 7 }, as: :json
- assert_response :no_content
+ assert_response :success
assert_equal 7.days, Current.account.entropy.reload.auto_postpone_period
end
diff --git a/test/controllers/boards/entropies_controller_test.rb b/test/controllers/boards/entropies_controller_test.rb
index e0fd4fa52..4173694ca 100644
--- a/test/controllers/boards/entropies_controller_test.rb
+++ b/test/controllers/boards/entropies_controller_test.rb
@@ -17,10 +17,13 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest
end
test "update as JSON" do
- put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 90 } }, as: :json
+ assert_no_difference -> { Current.account.entropy.reload.auto_postpone_period } do
+ put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 90 } }, as: :json
- assert_response :no_content
- assert_equal 90.days, @board.entropy.reload.auto_postpone_period
+ assert_response :success
+ assert_equal 90.days, @board.entropy.reload.auto_postpone_period
+ assert_equal 90, @response.parsed_body["auto_postpone_period_in_days"]
+ end
end
test "update requires board admin permission" do
@@ -42,4 +45,24 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
assert_equal original_period, @board.entropy.reload.auto_postpone_period
end
+
+ test "update as JSON rejects invalid auto_postpone_period" do
+ original_period = @board.entropy.auto_postpone_period
+
+ put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 1 } }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_equal original_period, @board.entropy.reload.auto_postpone_period
+ end
+
+ test "update as JSON requires board admin permission" do
+ logout_and_sign_in_as :jz
+
+ original_period = @board.entropy.auto_postpone_period
+
+ put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 7 } }, as: :json
+
+ assert_response :forbidden
+ assert_equal original_period, @board.entropy.reload.auto_postpone_period
+ end
end
diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb
index 915310108..059c38026 100644
--- a/test/controllers/boards_controller_test.rb
+++ b/test/controllers/boards_controller_test.rb
@@ -258,6 +258,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
get board_path(boards(:writebook)), as: :json
assert_response :success
assert_equal boards(:writebook).name, @response.parsed_body["name"]
+ assert_equal boards(:writebook).auto_postpone_period_in_days, @response.parsed_body["auto_postpone_period_in_days"]
end
test "show as JSON includes public_url when published" do
From 66039c92f3c30487551202b79efe035395c8d7d8 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Tue, 10 Mar 2026 15:27:42 +0100
Subject: [PATCH 31/43] Use x_user_agent cookie for platform detection in
Hotwire Native
Turbo's offline/service worker sets an x_user_agent cookie with the
original browser user agent. This ensures platform detection works
correctly for Hotwire Native apps where service worker requests may
not carry the overridden user agent header.
---
app/controllers/concerns/set_platform.rb | 2 +-
.../controllers/concerns/set_platform_test.rb | 21 +++++++++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
create mode 100644 test/controllers/concerns/set_platform_test.rb
diff --git a/app/controllers/concerns/set_platform.rb b/app/controllers/concerns/set_platform.rb
index 54e77d517..66de85f12 100644
--- a/app/controllers/concerns/set_platform.rb
+++ b/app/controllers/concerns/set_platform.rb
@@ -7,6 +7,6 @@ module SetPlatform
private
def platform
- @platform ||= ApplicationPlatform.new(request.user_agent)
+ @platform ||= ApplicationPlatform.new(cookies[:x_user_agent].presence || request.user_agent)
end
end
diff --git a/test/controllers/concerns/set_platform_test.rb b/test/controllers/concerns/set_platform_test.rb
new file mode 100644
index 000000000..26ce9d66a
--- /dev/null
+++ b/test/controllers/concerns/set_platform_test.rb
@@ -0,0 +1,21 @@
+require "test_helper"
+
+class SetPlatformTest < ActionDispatch::IntegrationTest
+ DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ NATIVE_IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Hotwire Native iOS/1.0"
+
+ test "uses the request user agent by default" do
+ sign_in_as :david
+
+ get board_path(boards(:writebook)), headers: { "User-Agent" => DESKTOP_UA }
+ assert_match 'data-platform="desktop web"', response.body
+ end
+
+ test "prefers x_user_agent cookie over request user agent" do
+ sign_in_as :david
+
+ cookies[:x_user_agent] = NATIVE_IOS_UA
+ get board_path(boards(:writebook)), headers: { "User-Agent" => DESKTOP_UA }
+ assert_match 'data-platform="native ios"', response.body
+ end
+end
From 0f5216f4332da48b579b429a5bdd795f0c4303a5 Mon Sep 17 00:00:00 2001
From: Jeffrey Hardy
Date: Tue, 10 Mar 2026 14:44:50 -0400
Subject: [PATCH 32/43] Fix that the "maybe" stream wouldn't be replaced after
triaging a card
The element was originally named "the-stream" and later renamed to "maybe",
but the original references weren't updated.
Refs:
- https://github.com/basecamp/fizzy/pull/2211
- https://github.com/basecamp/fizzy/commit/b3cfae35
---
app/views/boards/show/_stream.html.erb | 2 +-
app/views/cards/closures/create.turbo_stream.erb | 2 +-
app/views/cards/closures/destroy.turbo_stream.erb | 2 +-
app/views/cards/not_nows/create.turbo_stream.erb | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/app/views/boards/show/_stream.html.erb b/app/views/boards/show/_stream.html.erb
index b43309100..f66fb8fa6 100644
--- a/app/views/boards/show/_stream.html.erb
+++ b/app/views/boards/show/_stream.html.erb
@@ -12,7 +12,7 @@
<% if page.used? %>
- <%= with_automatic_pagination "maybe", @page do %>
+ <%= with_automatic_pagination "maybe", page do %>
<%= render "cards/display/previews", cards: page.records, draggable: true %>
<% end %>
<% end %>
diff --git a/app/views/cards/closures/create.turbo_stream.erb b/app/views/cards/closures/create.turbo_stream.erb
index c4898e471..5b76efe7b 100644
--- a/app/views/cards/closures/create.turbo_stream.erb
+++ b/app/views/cards/closures/create.turbo_stream.erb
@@ -3,7 +3,7 @@
<% if @source_column %>
<%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %>
<% elsif @was_in_stream %>
- <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
+ <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
diff --git a/app/views/cards/closures/destroy.turbo_stream.erb b/app/views/cards/closures/destroy.turbo_stream.erb
index 8df408979..31a9e41c3 100644
--- a/app/views/cards/closures/destroy.turbo_stream.erb
+++ b/app/views/cards/closures/destroy.turbo_stream.erb
@@ -3,7 +3,7 @@
<% if @card.column %>
<%= turbo_stream.replace(dom_id(@card.column), partial: "boards/show/column", method: :morph, locals: { column: @card.column }) %>
<% elsif @card.awaiting_triage? %>
- <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
+ <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
diff --git a/app/views/cards/not_nows/create.turbo_stream.erb b/app/views/cards/not_nows/create.turbo_stream.erb
index 9128580e2..945d86ab2 100644
--- a/app/views/cards/not_nows/create.turbo_stream.erb
+++ b/app/views/cards/not_nows/create.turbo_stream.erb
@@ -3,7 +3,7 @@
<% if @source_column %>
<%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %>
<% elsif @was_in_stream %>
- <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
+ <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
From 8fb57ace010e462811a41ad1266bdbf4b699c29b Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Mon, 9 Mar 2026 16:13:18 -0400
Subject: [PATCH 33/43] Validate polymorphic types against importable models
allowlist
Add IMPORTABLE_MODEL_NAMES to RecordSet and verify polymorphic type
columns during import check against this allowlist before calling
constantize. This prevents arbitrary class instantiation from
untrusted import ZIP data.
---
.../account/data_transfer/record_set.rb | 45 +++++++++-
.../account/data_transfer/record_set_test.rb | 89 +++++++++++++++++++
2 files changed, 133 insertions(+), 1 deletion(-)
create mode 100644 test/models/account/data_transfer/record_set_test.rb
diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb
index 8eeafbe10..2d64d2e1c 100644
--- a/app/models/account/data_transfer/record_set.rb
+++ b/app/models/account/data_transfer/record_set.rb
@@ -4,6 +4,41 @@ class Account::DataTransfer::RecordSet
IMPORT_BATCH_SIZE = 100
+ IMPORTABLE_MODEL_NAMES = %w[
+ Access
+ Account
+ ActionText::RichText
+ ActiveStorage::Attachment
+ ActiveStorage::Blob
+ Assignment
+ Board
+ Board::Publication
+ Card
+ Card::ActivitySpike
+ Card::Goldness
+ Card::NotNow
+ Closure
+ Column
+ Comment
+ Entropy
+ Event
+ Filter
+ Mention
+ Notification
+ Notification::Bundle
+ Pin
+ Reaction
+ Step
+ Tag
+ Tagging
+ User
+ User::Settings
+ Watch
+ Webhook
+ Webhook::DelinquencyTracker
+ Webhook::Delivery
+ ].freeze
+
attr_reader :account, :model, :attributes
def initialize(account:, model:, attributes: nil)
@@ -113,7 +148,7 @@ class Account::DataTransfer::RecordSet
def check_association_doesnt_exist(data, association, associated_id)
if association.polymorphic?
type_column = association.foreign_type.to_s
- associated_class = data[type_column].constantize
+ associated_class = verify_model_type(data[type_column])
else
associated_class = association.klass
end
@@ -123,6 +158,14 @@ class Account::DataTransfer::RecordSet
end
end
+ def verify_model_type(type_name)
+ if IMPORTABLE_MODEL_NAMES.include?(type_name)
+ type_name.constantize
+ else
+ raise IntegrityError, "Unrecognized model type: #{type_name}"
+ end
+ end
+
def skip_to(file_list, last_id)
index = file_list.index(last_id)
diff --git a/test/models/account/data_transfer/record_set_test.rb b/test/models/account/data_transfer/record_set_test.rb
new file mode 100644
index 000000000..74b457edf
--- /dev/null
+++ b/test/models/account/data_transfer/record_set_test.rb
@@ -0,0 +1,89 @@
+require "test_helper"
+
+class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase
+ test "check rejects polymorphic type not in the importable models allowlist" do
+ event_data = build_event_data(eventable_type: "Identity")
+
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+
+ error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ record_set.check(from: build_reader(dir: "events", data: event_data))
+ end
+
+ assert_match(/unrecognized.*type/i, error.message)
+ end
+
+ test "check rejects nonexistent polymorphic type" do
+ event_data = build_event_data(eventable_type: "Nonexistent::ClassName")
+
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+
+ error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ record_set.check(from: build_reader(dir: "events", data: event_data))
+ end
+
+ assert_match(/unrecognized.*type/i, error.message)
+ end
+
+ test "check rejects non-ActiveRecord class used as polymorphic type" do
+ event_data = build_event_data(eventable_type: "ActiveSupport::BroadcastLogger")
+
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+
+ error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ record_set.check(from: build_reader(dir: "events", data: event_data))
+ end
+
+ assert_match(/unrecognized.*type/i, error.message)
+ end
+
+ test "check accepts polymorphic type in the importable models allowlist" do
+ event_data = build_event_data(eventable_type: "Card")
+
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+
+ assert_nothing_raised do
+ record_set.check(from: build_reader(dir: "events", data: event_data))
+ end
+ end
+
+ private
+ def importing_account
+ @importing_account ||= Account.create!(name: "Importing Account", external_account_id: 99999999)
+ end
+
+ def build_event_data(eventable_type:)
+ {
+ "id" => "test_event_id_12345678901234",
+ "account_id" => "nonexistent_account_id_1234567",
+ "board_id" => "nonexistent_board_id_12345678",
+ "creator_id" => "nonexistent_user_id_123456789",
+ "eventable_type" => eventable_type,
+ "eventable_id" => "nonexistent_id_1234567890123",
+ "action" => "created",
+ "particulars" => "{}",
+ "created_at" => Time.current.iso8601,
+ "updated_at" => Time.current.iso8601
+ }
+ end
+
+ def build_reader(dir:, data:)
+ tempfile = Tempfile.new([ "import_test", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/#{dir}/#{data['id']}.json", data.to_json)
+ writer.close
+ tempfile.rewind
+
+ @tempfiles ||= []
+ @tempfiles << tempfile
+
+ ZipFile::Reader.new(tempfile)
+ end
+
+ def teardown
+ @tempfiles&.each { |f| f.close; f.unlink }
+ @importing_account&.destroy
+ end
+end
From b4fd22644c7e1d2311431254a26d36d141e711b3 Mon Sep 17 00:00:00 2001
From: "Stanko K.R."
Date: Tue, 10 Mar 2026 09:48:23 +0100
Subject: [PATCH 34/43] Set the list of importable models based on the record
sets in the manifest
---
app/models/account/data_transfer/manifest.rb | 42 +++++++++++++++----
.../account/data_transfer/record_set.rb | 41 ++----------------
.../action_text/rich_text_record_set_test.rb | 1 +
.../account/data_transfer/record_set_test.rb | 12 ++++--
4 files changed, 48 insertions(+), 48 deletions(-)
diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb
index 2159926d5..9de76f9bf 100644
--- a/app/models/account/data_transfer/manifest.rb
+++ b/app/models/account/data_transfer/manifest.rb
@@ -26,20 +26,42 @@ class Account::DataTransfer::Manifest
[
Account::DataTransfer::AccountRecordSet.new(account),
Account::DataTransfer::UserRecordSet.new(account),
- *build_record_sets(::User::Settings, ::Tag, ::Board, ::Column),
+ *build_record_sets(
+ ::User::Settings,
+ ::Tag,
+ ::Board,
+ ::Column
+ ),
Account::DataTransfer::EntropyRecordSet.new(account),
*build_record_sets(
- ::Board::Publication, ::Webhook, ::Access, ::Card, ::Comment, ::Step,
- ::Assignment, ::Tagging, ::Closure, ::Card::Goldness, ::Card::NotNow,
- ::Card::ActivitySpike, ::Watch, ::Pin, ::Reaction, ::Mention,
- ::Filter, ::Webhook::DelinquencyTracker, ::Event,
- ::Notification, ::Notification::Bundle, ::Webhook::Delivery
+ ::Board::Publication,
+ ::Webhook,
+ ::Access,
+ ::Card,
+ ::Comment,
+ ::Step,
+ ::Assignment,
+ ::Tagging,
+ ::Closure,
+ ::Card::Goldness,
+ ::Card::NotNow,
+ ::Card::ActivitySpike,
+ ::Watch,
+ ::Pin,
+ ::Reaction,
+ ::Mention,
+ ::Filter,
+ ::Webhook::DelinquencyTracker,
+ ::Event,
+ ::Notification,
+ ::Notification::Bundle,
+ ::Webhook::Delivery
),
Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account),
*build_record_sets(::ActiveStorage::Attachment),
Account::DataTransfer::ActionText::RichTextRecordSet.new(account),
Account::DataTransfer::ActiveStorage::FileRecordSet.new(account)
- ]
+ ].then { set_importable_model_names(it) }
end
def build_record_sets(*models)
@@ -47,4 +69,10 @@ class Account::DataTransfer::Manifest
Account::DataTransfer::RecordSet.new(account: account, model: model)
end
end
+
+ def set_importable_model_names(record_sets)
+ model_names = record_sets.filter_map { |record_set| record_set.model&.name }
+ record_sets.each { |record_set| record_set.importable_model_names = model_names }
+ record_sets
+ end
end
diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb
index 2d64d2e1c..2fba9402d 100644
--- a/app/models/account/data_transfer/record_set.rb
+++ b/app/models/account/data_transfer/record_set.rb
@@ -4,47 +4,14 @@ class Account::DataTransfer::RecordSet
IMPORT_BATCH_SIZE = 100
- IMPORTABLE_MODEL_NAMES = %w[
- Access
- Account
- ActionText::RichText
- ActiveStorage::Attachment
- ActiveStorage::Blob
- Assignment
- Board
- Board::Publication
- Card
- Card::ActivitySpike
- Card::Goldness
- Card::NotNow
- Closure
- Column
- Comment
- Entropy
- Event
- Filter
- Mention
- Notification
- Notification::Bundle
- Pin
- Reaction
- Step
- Tag
- Tagging
- User
- User::Settings
- Watch
- Webhook
- Webhook::DelinquencyTracker
- Webhook::Delivery
- ].freeze
-
+ attr_accessor :importable_model_names
attr_reader :account, :model, :attributes
- def initialize(account:, model:, attributes: nil)
+ def initialize(account:, model:, attributes: nil, importable_model_names: nil)
@account = account
@model = model
@attributes = (attributes || model.column_names).map(&:to_s)
+ @importable_model_names = importable_model_names || [ model.name ]
end
def export(to:, start: nil)
@@ -159,7 +126,7 @@ class Account::DataTransfer::RecordSet
end
def verify_model_type(type_name)
- if IMPORTABLE_MODEL_NAMES.include?(type_name)
+ if importable_model_names.include?(type_name)
type_name.constantize
else
raise IntegrityError, "Unrecognized model type: #{type_name}"
diff --git a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb
index a28935a3e..2fe2c4b93 100644
--- a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb
+++ b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb
@@ -30,6 +30,7 @@ class Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport::
reader = ZipFile::Reader.new(tempfile)
record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(importing_account)
+ record_set.importable_model_names = %w[ ActionText::RichText Card ]
error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
record_set.check(from: reader)
diff --git a/test/models/account/data_transfer/record_set_test.rb b/test/models/account/data_transfer/record_set_test.rb
index 74b457edf..8c8d75e5e 100644
--- a/test/models/account/data_transfer/record_set_test.rb
+++ b/test/models/account/data_transfer/record_set_test.rb
@@ -1,10 +1,14 @@
require "test_helper"
class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase
+ setup do
+ @importable_model_names = %w[ Card Board Event ]
+ end
+
test "check rejects polymorphic type not in the importable models allowlist" do
event_data = build_event_data(eventable_type: "Identity")
- record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)
error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
record_set.check(from: build_reader(dir: "events", data: event_data))
@@ -16,7 +20,7 @@ class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase
test "check rejects nonexistent polymorphic type" do
event_data = build_event_data(eventable_type: "Nonexistent::ClassName")
- record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)
error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
record_set.check(from: build_reader(dir: "events", data: event_data))
@@ -28,7 +32,7 @@ class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase
test "check rejects non-ActiveRecord class used as polymorphic type" do
event_data = build_event_data(eventable_type: "ActiveSupport::BroadcastLogger")
- record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)
error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
record_set.check(from: build_reader(dir: "events", data: event_data))
@@ -40,7 +44,7 @@ class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase
test "check accepts polymorphic type in the importable models allowlist" do
event_data = build_event_data(eventable_type: "Card")
- record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)
assert_nothing_raised do
record_set.check(from: build_reader(dir: "events", data: event_data))
From 99e5af6f9525172f7c22bbfbc5a1998c062825df Mon Sep 17 00:00:00 2001
From: Adrien Maston
Date: Wed, 11 Mar 2026 13:19:48 +0100
Subject: [PATCH 35/43] Tweak the layout
... left aligning
---
app/assets/stylesheets/card-perma.css | 77 +++++++++++++++++++++------
1 file changed, 60 insertions(+), 17 deletions(-)
diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css
index 3dc16d5a2..5db9e7df7 100644
--- a/app/assets/stylesheets/card-perma.css
+++ b/app/assets/stylesheets/card-perma.css
@@ -156,37 +156,74 @@
@media (max-width: 639px) {
--meta-spacer-block: 0.75ch;
- inline-size: 100%;
- grid-template-areas:
- "avatars-author text-added"
- "avatars-author text-author"
- "text-updated text-updated"
- "text-assignees text-assignees"
- "avatars-assignees avatars-assignees";
- grid-template-columns: auto 1fr;
+ min-inline-size: 0;
+ gap: calc(var(--meta-spacer-block) / 2);
+ display: flex;
+ flex-wrap: wrap;
.card__meta-text {
border: 0;
padding: 0;
}
- .card__meta-text + .card__meta-text {
+ .card__meta-avatars--author {
+ --btn-size: 1.5em;
+
+ display: initial;
+ margin-inline-end: unset;
+ order: 3;
+ }
+
+ .card__meta-text--added {
+ inline-size: 100%;
+ order: 1;
+ }
+
+ .card__meta-text--author {
+ order: 2;
+ }
+
+ .card__meta-text--updated {
border-block-start: var(--card-border);
- margin-block-start: var(--meta-spacer-block);
+ inline-size: 100%;
+ margin-block-start: calc(var(--meta-spacer-block) * 0.5);
+ order: 4;
padding-block-start: var(--meta-spacer-block);
}
+ .card__meta-text--assignees {
+ margin-block-start: calc(var(--meta-spacer-block) * 3);
+ order: 6;
+ white-space: unset !important;
+ }
+
.card__meta-avatars--assignees {
- margin-inline: 0;
- margin-block-start: var(--meta-spacer-block);
+ margin-inline: 0 var(--meta-spacer-inline);
+ margin-block-start: calc(var(--meta-spacer-block) * 3);
+ order: 5;
+
+ .avatar {
+ display: grid;
+ }
}
- .card__meta-avatars--author {
- display: initial;
- }
+ &:has(.card__meta-avatars--assignees .avatar) {
+ .card__meta-text--assignees {
+ order: 5;
+ }
- .card__meta-avatars--assignees .avatar {
- display: grid;
+ .card__meta-avatars--assignees {
+ margin-block-start: var(--meta-spacer-block);
+ order: 6;
+ }
+ }
+ }
+ }
+
+ &:has(.card__closed) .card__meta {
+ @media (max-width: 639px) {
+ .card__meta-avatars--assignees {
+ display: none;
}
}
}
@@ -252,10 +289,16 @@
@media (max-width: 639px) {
display: grid;
font-size: var(--text-x-small);
+ gap: 1ch 0;
grid-template-columns: 1fr auto;
grid-template-areas:
"meta bg-zoom"
"meta reactions";
+
+ &:not(:has(.reaction)),
+ &:has(.card__background) {
+ column-gap: 2ch;
+ }
}
}
From 7a6c905a323b91f6a6f3678692a26e480f089e3c Mon Sep 17 00:00:00 2001
From: Adrien Maston
Date: Wed, 11 Mar 2026 13:20:40 +0100
Subject: [PATCH 36/43] Prevent line breaks in the middle of familiar_name
---
app/models/user/named.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/models/user/named.rb b/app/models/user/named.rb
index d052a79ad..73cc763de 100644
--- a/app/models/user/named.rb
+++ b/app/models/user/named.rb
@@ -20,6 +20,6 @@ module User::Named
def familiar_name
names = name.split
return name if names.length <= 1
- "#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}"
+ "#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}".html_safe
end
end
From 31975be1fa3c737718a90d153f21617fd59d0eb5 Mon Sep 17 00:00:00 2001
From: Adrien Maston
Date: Wed, 11 Mar 2026 13:36:09 +0100
Subject: [PATCH 37/43] Update app/models/user/named.rb
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
app/models/user/named.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/models/user/named.rb b/app/models/user/named.rb
index 73cc763de..804e516ee 100644
--- a/app/models/user/named.rb
+++ b/app/models/user/named.rb
@@ -20,6 +20,6 @@ module User::Named
def familiar_name
names = name.split
return name if names.length <= 1
- "#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}".html_safe
+ "#{names.first}\u00A0#{names[1..].map { |n| "#{n[0]}." }.join}"
end
end
From ab5283441d2309426f91c41a5efc6e7b88c3c3fc Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Wed, 11 Mar 2026 10:11:13 -0400
Subject: [PATCH 38/43] SaaS usage reporting (#2690)
* Add saas:usage_report rake task and extract Subscription.paid scope
Add a rake task to generate a CSV usage report with per-account data:
Queenbee ID, sign up date, paid date, card count, storage used, and
last active date.
Extract the paid subscriptions query from Admin::StatsController into
an Account::Subscription.paid scope so both the controller and the
new rake task can share it.
* Add comped and account name columns to usage report
* Update saas/lib/tasks/fizzy/usage_report.rake
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Preload storage_total to avoid N+1 in usage report
* Batch last_active_at queries to avoid per-account aggregates
* Add tests for Account::Subscription.paid scope
* Update saas/app/models/account/subscription.rb
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Aggregate paid dates in SQL instead of loading all subscriptions
* Fix paid scope to derive plan keys from Plan.all instead of PLANS hash
* Move paid dates and comped lookups into per-batch queries
* Materialize batch IDs to avoid cross-database subquery
SaasRecord models live on a separate database (fizzy_saas) that doesn't
have the accounts table. Using batch.select(:id) generated a subquery
that ran on the saas database, causing a table-not-found error. Using
pluck(:id) materializes the IDs into an array instead.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
Gemfile.saas | 1 +
Gemfile.saas.lock | 2 +
.../app/controllers/admin/stats_controller.rb | 14 ++---
saas/app/models/account/subscription.rb | 6 +++
saas/lib/tasks/fizzy/usage_report.rake | 37 ++++++++++++++
saas/test/models/account/subscription_test.rb | 51 +++++++++++++++++++
6 files changed, 100 insertions(+), 11 deletions(-)
create mode 100644 saas/lib/tasks/fizzy/usage_report.rake
diff --git a/Gemfile.saas b/Gemfile.saas
index 39aa49a71..e57fcf206 100644
--- a/Gemfile.saas
+++ b/Gemfile.saas
@@ -10,6 +10,7 @@ gem "queenbee", bc: "queenbee-plugin"
gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api"
+gem "csv"
# Native push notifications (iOS/Android)
gem "action_push_native"
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 37ff08294..e4ed7406e 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -281,6 +281,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
+ csv (3.3.5)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
@@ -720,6 +721,7 @@ DEPENDENCIES
bundler-audit
capybara
console1984!
+ csv
debug
faker
fizzy-saas!
diff --git a/saas/app/controllers/admin/stats_controller.rb b/saas/app/controllers/admin/stats_controller.rb
index 6b9592fca..287b49024 100644
--- a/saas/app/controllers/admin/stats_controller.rb
+++ b/saas/app/controllers/admin/stats_controller.rb
@@ -6,9 +6,9 @@ class Admin::StatsController < AdminController
@accounts_last_7_days = Account.where(created_at: 7.days.ago..).count
@accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count
- @paid_accounts_total = paid_subscriptions.distinct.count(:account_id)
- @paid_accounts_last_7_days = paid_subscriptions.where(created_at: 7.days.ago..).distinct.count(:account_id)
- @paid_accounts_last_24_hours = paid_subscriptions.where(created_at: 24.hours.ago..).distinct.count(:account_id)
+ @paid_accounts_total = Account::Subscription.paid.distinct.count(:account_id)
+ @paid_accounts_last_7_days = Account::Subscription.paid.where(created_at: 7.days.ago..).distinct.count(:account_id)
+ @paid_accounts_last_24_hours = Account::Subscription.paid.where(created_at: 24.hours.ago..).distinct.count(:account_id)
@identities_total = Identity.count
@identities_last_7_days = Identity.where(created_at: 7.days.ago..).count
@@ -21,12 +21,4 @@ class Admin::StatsController < AdminController
@recent_accounts = Account.order(created_at: :desc).limit(10)
end
-
- private
- def paid_subscriptions
- Account::Subscription
- .where(status: %w[active trialing past_due])
- .where(plan_key: %w[monthly_v1 monthly_extra_storage_v1])
- .where.not(account_id: Account::BillingWaiver.select(:account_id))
- end
end
diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb
index 2ae7e3171..c1ec9e47c 100644
--- a/saas/app/models/account/subscription.rb
+++ b/saas/app/models/account/subscription.rb
@@ -3,6 +3,12 @@ class Account::Subscription < SaasRecord
enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself)
+ scope :paid, -> {
+ where(status: %w[active trialing past_due])
+ .where(plan_key: Plan.all.select(&:paid?).map(&:key))
+ .where.not(account_id: Account::BillingWaiver.select(:account_id))
+ }
+
validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) }
delegate :paid?, to: :plan
diff --git a/saas/lib/tasks/fizzy/usage_report.rake b/saas/lib/tasks/fizzy/usage_report.rake
new file mode 100644
index 000000000..d8b3ba108
--- /dev/null
+++ b/saas/lib/tasks/fizzy/usage_report.rake
@@ -0,0 +1,37 @@
+require "csv"
+
+namespace :saas do
+ desc "Generate a CSV usage report for all active accounts"
+ task usage_report: :environment do
+ output_path = Rails.root.join("tmp/usage_report.csv")
+
+ CSV.open(output_path, "w") do |csv|
+ csv << [ "Queenbee ID", "Account Name", "Sign Up Date", "Paid Date", "Comped", "Card Count", "Storage Used (Bytes)", "Last Active" ]
+
+ Account.active.includes(:storage_total).in_batches do |batch|
+ batch_ids = batch.pluck(:id)
+ paid_dates = Account::Subscription.paid.where(account_id: batch_ids)
+ .group(:account_id).minimum(:created_at)
+ comped_account_ids = Account::BillingWaiver.where(account_id: batch_ids)
+ .pluck(:account_id).to_set
+ last_active_dates = Card.where(account_id: batch_ids)
+ .group(:account_id).maximum(:last_active_at)
+
+ batch.each do |account|
+ csv << [
+ account.external_account_id,
+ account.name,
+ account.created_at.to_date,
+ paid_dates[account.id]&.to_date,
+ comped_account_ids.include?(account.id),
+ account.cards_count,
+ account.bytes_used,
+ last_active_dates[account.id]&.to_date
+ ]
+ end
+ end
+ end
+
+ puts "Report written to #{output_path}"
+ end
+end
diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb
index eaf7e103d..f9f16f2bb 100644
--- a/saas/test/models/account/subscription_test.rb
+++ b/saas/test/models/account/subscription_test.rb
@@ -16,6 +16,57 @@ class Account::SubscriptionTest < ActiveSupport::TestCase
assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid?
end
+ test "paid scope includes active paid subscriptions" do
+ subscription = Account::Subscription.create!(
+ account: accounts(:initech), plan_key: "monthly_v1", status: "active",
+ stripe_customer_id: "cus_paid"
+ )
+
+ assert_includes Account::Subscription.paid, subscription
+ end
+
+ test "paid scope includes trialing and past_due subscriptions" do
+ trialing = Account::Subscription.create!(
+ account: accounts(:initech), plan_key: "monthly_v1", status: "trialing",
+ stripe_customer_id: "cus_trialing"
+ )
+ past_due = Account::Subscription.create!(
+ account: accounts(:acme), plan_key: "monthly_extra_storage_v1", status: "past_due",
+ stripe_customer_id: "cus_past_due"
+ )
+
+ assert_includes Account::Subscription.paid, trialing
+ assert_includes Account::Subscription.paid, past_due
+ end
+
+ test "paid scope excludes free plan subscriptions" do
+ subscription = Account::Subscription.create!(
+ account: accounts(:initech), plan_key: "free_v1", status: "active",
+ stripe_customer_id: "cus_free"
+ )
+
+ assert_not_includes Account::Subscription.paid, subscription
+ end
+
+ test "paid scope excludes canceled subscriptions" do
+ subscription = Account::Subscription.create!(
+ account: accounts(:initech), plan_key: "monthly_v1", status: "canceled",
+ stripe_customer_id: "cus_canceled"
+ )
+
+ assert_not_includes Account::Subscription.paid, subscription
+ end
+
+ test "paid scope excludes comped accounts" do
+ subscription = Account::Subscription.create!(
+ account: accounts(:initech), plan_key: "monthly_v1", status: "active",
+ stripe_customer_id: "cus_comped"
+ )
+ Account::BillingWaiver.create!(account: accounts(:initech))
+
+ assert_not_includes Account::Subscription.paid, subscription
+ end
+
test "pause pauses Stripe subscription with void behavior" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
From d6bf103efd5d284518c656e11e28acfa5dab634d Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Wed, 11 Mar 2026 17:49:04 -0400
Subject: [PATCH 39/43] Configure Lexxy to add extra spacing between block
elements
Updating Lexxy to v0.8.0 for the insert-markdown event.
---
Gemfile | 2 +-
Gemfile.lock | 11 +++--------
Gemfile.saas.lock | 11 +++--------
app/javascript/initializers/index.js | 1 +
app/javascript/initializers/lexxy_markdown_paste.js | 3 +++
5 files changed, 11 insertions(+), 17 deletions(-)
create mode 100644 app/javascript/initializers/lexxy_markdown_paste.js
diff --git a/Gemfile b/Gemfile
index da66e1473..f5e26850a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -27,7 +27,7 @@ gem "geared_pagination", "~> 1.2"
gem "rqrcode"
gem "rouge"
gem "jbuilder"
-gem "lexxy", bc: "lexxy"
+gem "lexxy", "0.8.0.beta"
gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 8db6817dc..37579ff78 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,10 +1,3 @@
-GIT
- remote: https://github.com/basecamp/lexxy
- revision: b7f0f1429df5fa425eac043191ae2b65edee7488
- specs:
- lexxy (0.7.6.beta)
- rails (>= 8.0.2)
-
GIT
remote: https://github.com/basecamp/useragent
revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e
@@ -261,6 +254,8 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
+ lexxy (0.8.0.beta)
+ rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
@@ -523,7 +518,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy!
+ lexxy (= 0.8.0.beta)
mission_control-jobs
mittens
mocha
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index e4ed7406e..dbaaa9df4 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -21,13 +21,6 @@ GIT
rails (>= 7.0)
rainbow
-GIT
- remote: https://github.com/basecamp/lexxy
- revision: b7f0f1429df5fa425eac043191ae2b65edee7488
- specs:
- lexxy (0.7.6.beta)
- rails (>= 8.0.2)
-
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: 14312a940471e20617b38cdec7c092a01567d18b
@@ -375,6 +368,8 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
+ lexxy (0.8.0.beta)
+ rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
@@ -732,7 +727,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy!
+ lexxy (= 0.8.0.beta)
mission_control-jobs
mittens
mocha
diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js
index 1d16ef205..d2c20451e 100644
--- a/app/javascript/initializers/index.js
+++ b/app/javascript/initializers/index.js
@@ -1,3 +1,4 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
import "initializers/offline"
+import "initializers/lexxy_markdown_paste"
diff --git a/app/javascript/initializers/lexxy_markdown_paste.js b/app/javascript/initializers/lexxy_markdown_paste.js
new file mode 100644
index 000000000..cf7600306
--- /dev/null
+++ b/app/javascript/initializers/lexxy_markdown_paste.js
@@ -0,0 +1,3 @@
+document.addEventListener("lexxy:insert-markdown", (event) => {
+ event.detail.addBlockSpacing()
+})
From 3959d91148a4aa1e25406d65b0f7b204ec47b12b Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Thu, 12 Mar 2026 12:24:35 -0400
Subject: [PATCH 40/43] Revert "Configure Lexxy to add extra spacing between
block elements"
This reverts commit d6bf103efd5d284518c656e11e28acfa5dab634d.
---
Gemfile | 2 +-
Gemfile.lock | 11 ++++++++---
Gemfile.saas.lock | 11 ++++++++---
app/javascript/initializers/index.js | 1 -
app/javascript/initializers/lexxy_markdown_paste.js | 3 ---
5 files changed, 17 insertions(+), 11 deletions(-)
delete mode 100644 app/javascript/initializers/lexxy_markdown_paste.js
diff --git a/Gemfile b/Gemfile
index f5e26850a..da66e1473 100644
--- a/Gemfile
+++ b/Gemfile
@@ -27,7 +27,7 @@ gem "geared_pagination", "~> 1.2"
gem "rqrcode"
gem "rouge"
gem "jbuilder"
-gem "lexxy", "0.8.0.beta"
+gem "lexxy", bc: "lexxy"
gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 37579ff78..8db6817dc 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,3 +1,10 @@
+GIT
+ remote: https://github.com/basecamp/lexxy
+ revision: b7f0f1429df5fa425eac043191ae2b65edee7488
+ specs:
+ lexxy (0.7.6.beta)
+ rails (>= 8.0.2)
+
GIT
remote: https://github.com/basecamp/useragent
revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e
@@ -254,8 +261,6 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
- lexxy (0.8.0.beta)
- rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
@@ -518,7 +523,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy (= 0.8.0.beta)
+ lexxy!
mission_control-jobs
mittens
mocha
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index dbaaa9df4..e4ed7406e 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -21,6 +21,13 @@ GIT
rails (>= 7.0)
rainbow
+GIT
+ remote: https://github.com/basecamp/lexxy
+ revision: b7f0f1429df5fa425eac043191ae2b65edee7488
+ specs:
+ lexxy (0.7.6.beta)
+ rails (>= 8.0.2)
+
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: 14312a940471e20617b38cdec7c092a01567d18b
@@ -368,8 +375,6 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
- lexxy (0.8.0.beta)
- rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
@@ -727,7 +732,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy (= 0.8.0.beta)
+ lexxy!
mission_control-jobs
mittens
mocha
diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js
index d2c20451e..1d16ef205 100644
--- a/app/javascript/initializers/index.js
+++ b/app/javascript/initializers/index.js
@@ -1,4 +1,3 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
import "initializers/offline"
-import "initializers/lexxy_markdown_paste"
diff --git a/app/javascript/initializers/lexxy_markdown_paste.js b/app/javascript/initializers/lexxy_markdown_paste.js
deleted file mode 100644
index cf7600306..000000000
--- a/app/javascript/initializers/lexxy_markdown_paste.js
+++ /dev/null
@@ -1,3 +0,0 @@
-document.addEventListener("lexxy:insert-markdown", (event) => {
- event.detail.addBlockSpacing()
-})
From 8f6fca94fa4ac356e56be7260a3fc5e824be2c60 Mon Sep 17 00:00:00 2001
From: "Stanko K.R."
Date: Mon, 16 Mar 2026 17:50:31 +0100
Subject: [PATCH 41/43] Prevent HTML injection through filenames
---
app/javascript/controllers/upload_preview_controller.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/javascript/controllers/upload_preview_controller.js b/app/javascript/controllers/upload_preview_controller.js
index d7b0bd478..7e25d50a3 100644
--- a/app/javascript/controllers/upload_preview_controller.js
+++ b/app/javascript/controllers/upload_preview_controller.js
@@ -15,7 +15,7 @@ export default class extends Controller {
}
#showFileName() {
- this.fileNameTarget.innerHTML = this.#file.name
+ this.fileNameTarget.innerText = this.#file.name
this.fileNameTarget.removeAttribute("hidden")
this.placeholderTarget.setAttribute("hidden", true)
}
From ddc6ce020e127439c0a456844140321bb3567288 Mon Sep 17 00:00:00 2001
From: "Stanko K.R."
Date: Mon, 16 Mar 2026 17:57:26 +0100
Subject: [PATCH 42/43] Conditionally disable peer verification for ZIP
streaming
---
app/models/zip_file.rb | 3 ++-
app/models/zip_file/remote_io.rb | 8 ++++++--
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/app/models/zip_file.rb b/app/models/zip_file.rb
index dc5b7ee8d..3415f5879 100644
--- a/app/models/zip_file.rb
+++ b/app/models/zip_file.rb
@@ -84,7 +84,8 @@ class ZipFile
def read_from_s3(blob)
url = blob.url(expires_in: 6.hour)
- remote_io = RemoteIO.new(url)
+ ssl_verify_peer = blob.service.client.client.config.ssl_verify_peer
+ remote_io = RemoteIO.new(url, ssl_verify_peer: ssl_verify_peer)
reader = Reader.new(remote_io)
yield reader
end
diff --git a/app/models/zip_file/remote_io.rb b/app/models/zip_file/remote_io.rb
index 1cb00831a..59329d811 100644
--- a/app/models/zip_file/remote_io.rb
+++ b/app/models/zip_file/remote_io.rb
@@ -1,4 +1,9 @@
class ZipFile::RemoteIO < ZipKit::RemoteIO
+ def initialize(url, ssl_verify_peer: true)
+ super(url)
+ @ssl_verify_peer = ssl_verify_peer
+ end
+
protected
def request_range(range)
with_http do |http|
@@ -37,8 +42,7 @@ class ZipFile::RemoteIO < ZipKit::RemoteIO
def with_http
http = Net::HTTP.new(@uri.hostname, @uri.port)
http.use_ssl = @uri.scheme == "https"
- # FIXME: Disable SSL verification for now to avoid issues with our self-signed certificates for PureStorage
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless @ssl_verify_peer
http.start { yield http }
end
end
From 964915bc665b4a8be86303200a5f643d1b41cdb2 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Tue, 17 Mar 2026 09:22:04 +0100
Subject: [PATCH 43/43] Remove payment/subscription system and card/storage
limits
Fizzy is now free. Remove the entire Stripe billing system,
subscription management, and card/storage limit enforcement.
Removes from saas/: Plan model, Account::Billing, Account::Subscription,
Account::Limited, Account::OverriddenLimits, Account::BillingWaiver,
all subscription/billing controllers and views, Stripe webhook handler,
card creation/publishing limit enforcement, admin account override UI,
usage report rake task, and all related tests.
Removes from main app: Fizzy.saas? guards for subscription panel,
SaaS card footer override, near-limit notices, and saas.css stylesheet.
Adds migration to drop billing tables from the SaaS database.
Non-billing SaaS features (push notifications, signup, authorization,
telemetry, console1984/audits1984) are preserved.
---
Gemfile.saas | 2 -
Gemfile.saas.lock | 4 -
app/views/account/settings/show.html.erb | 2 -
.../cards/container/footer/_create.html.erb | 2 -
app/views/cards/drafts/_container.html.erb | 6 +-
app/views/layouts/shared/_head.html.erb | 1 -
saas/app/assets/stylesheets/fizzy/saas.css | 70 -----
.../account/billing_portals_controller.rb | 19 --
.../subscriptions/downgrades_controller.rb | 12 -
.../subscriptions/update_plan_controller.rb | 32 ---
.../subscriptions/upgrades_controller.rb | 12 -
.../account/subscriptions_controller.rb | 46 ----
.../admin/account_searches_controller.rb | 5 -
.../controllers/admin/accounts_controller.rb | 27 --
.../admin/billing_waivers_controller.rb | 13 -
.../admin/overridden_limits_controller.rb | 8 -
.../app/controllers/admin/stats_controller.rb | 4 -
.../concerns/admin/account_scoped.rb | 12 -
saas/app/controllers/concerns/card/limited.rb | 8 -
.../concerns/card/limited_creation.rb | 11 -
.../concerns/card/limited_publishing.rb | 9 -
.../controllers/stripe/webhooks_controller.rb | 85 ------
saas/app/helpers/subscriptions_helper.rb | 19 --
.../account/sync_stripe_customer_email_job.rb | 8 -
saas/app/models/account/billing.rb | 50 ----
saas/app/models/account/billing_waiver.rb | 7 -
saas/app/models/account/limited.rb | 56 ----
saas/app/models/account/overridden_limits.rb | 4 -
saas/app/models/account/subscription.rb | 73 ------
saas/app/models/plan.rb | 59 -----
.../user/notifies_account_of_email_change.rb | 23 --
.../account/settings/_free_plan.html.erb | 18 --
.../account/settings/_paid_plan.html.erb | 21 --
.../account/settings/_subscription.html.erb | 20 --
.../settings/_subscription_panel.html.erb | 11 -
.../account/subscriptions/_upgrade.html.erb | 9 -
.../_admin_exceeding_card_limit.html.erb | 4 -
.../_admin_exceeding_storage_limit.html.erb | 4 -
.../_user_exceeding_card_limit.html.erb | 3 -
.../_user_exceeding_storage_limit.html.erb | 3 -
.../views/account/subscriptions/show.html.erb | 9 -
saas/app/views/admin/accounts/edit.html.erb | 63 -----
saas/app/views/admin/accounts/index.html.erb | 13 -
saas/app/views/admin/stats/show.html.erb | 26 --
.../container/footer/saas/_create.html.erb | 5 -
.../footer/saas/_near_notice.html.erb | 9 -
.../_admin_nearing_card_limit.html.erb | 1 -
.../_admin_nearing_storage_limit.html.erb | 1 -
.../notices/_user_nearing_card_limit.html.erb | 1 -
.../_user_nearing_storage_limit.html.erb | 1 -
saas/config/routes.rb | 5 -
.../20260317000000_drop_billing_tables.rb | 7 +
saas/db/saas_schema.rb | 34 +--
saas/exe/stripe-dev | 83 ------
saas/lib/fizzy/saas/engine.rb | 31 ---
saas/lib/tasks/fizzy/usage_report.rake | 37 ---
.../billing_portals_controller_test.rb | 29 ---
.../subscriptions/card_creation_test.rb | 73 ------
.../downgrades_controller_test.rb | 35 ---
.../accounts/subscriptions/settings_test.rb | 62 -----
.../subscriptions/upgrades_controller_test.rb | 35 ---
.../accounts/subscriptions_controller_test.rb | 60 -----
.../admin/accounts_controller_test.rb | 54 ----
.../admin/billing_waivers_controller_test.rb | 32 ---
.../overridden_limits_controller_test.rb | 22 --
.../controllers/card/limited_creation_test.rb | 55 ----
.../card/limited_publishing_test.rb | 25 --
.../stripe/webhooks_controller_test.rb | 100 -------
saas/test/fixtures/account_subscriptions.yml | 11 -
saas/test/models/account/billing_test.rb | 93 -------
saas/test/models/account/limited_test.rb | 122 ---------
saas/test/models/account/subscription_test.rb | 245 ------------------
saas/test/models/plan_test.rb | 18 --
.../notifies_account_of_email_change_test.rb | 51 ----
74 files changed, 9 insertions(+), 2221 deletions(-)
delete mode 100644 saas/app/assets/stylesheets/fizzy/saas.css
delete mode 100644 saas/app/controllers/account/billing_portals_controller.rb
delete mode 100644 saas/app/controllers/account/subscriptions/downgrades_controller.rb
delete mode 100644 saas/app/controllers/account/subscriptions/update_plan_controller.rb
delete mode 100644 saas/app/controllers/account/subscriptions/upgrades_controller.rb
delete mode 100644 saas/app/controllers/account/subscriptions_controller.rb
delete mode 100644 saas/app/controllers/admin/account_searches_controller.rb
delete mode 100644 saas/app/controllers/admin/accounts_controller.rb
delete mode 100644 saas/app/controllers/admin/billing_waivers_controller.rb
delete mode 100644 saas/app/controllers/admin/overridden_limits_controller.rb
delete mode 100644 saas/app/controllers/concerns/admin/account_scoped.rb
delete mode 100644 saas/app/controllers/concerns/card/limited.rb
delete mode 100644 saas/app/controllers/concerns/card/limited_creation.rb
delete mode 100644 saas/app/controllers/concerns/card/limited_publishing.rb
delete mode 100644 saas/app/controllers/stripe/webhooks_controller.rb
delete mode 100644 saas/app/helpers/subscriptions_helper.rb
delete mode 100644 saas/app/jobs/account/sync_stripe_customer_email_job.rb
delete mode 100644 saas/app/models/account/billing.rb
delete mode 100644 saas/app/models/account/billing_waiver.rb
delete mode 100644 saas/app/models/account/limited.rb
delete mode 100644 saas/app/models/account/overridden_limits.rb
delete mode 100644 saas/app/models/account/subscription.rb
delete mode 100644 saas/app/models/plan.rb
delete mode 100644 saas/app/models/user/notifies_account_of_email_change.rb
delete mode 100644 saas/app/views/account/settings/_free_plan.html.erb
delete mode 100644 saas/app/views/account/settings/_paid_plan.html.erb
delete mode 100644 saas/app/views/account/settings/_subscription.html.erb
delete mode 100644 saas/app/views/account/settings/_subscription_panel.html.erb
delete mode 100644 saas/app/views/account/subscriptions/_upgrade.html.erb
delete mode 100644 saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb
delete mode 100644 saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb
delete mode 100644 saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb
delete mode 100644 saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb
delete mode 100644 saas/app/views/account/subscriptions/show.html.erb
delete mode 100644 saas/app/views/admin/accounts/edit.html.erb
delete mode 100644 saas/app/views/admin/accounts/index.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/_create.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/_near_notice.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb
create mode 100644 saas/db/migrate/20260317000000_drop_billing_tables.rb
delete mode 100755 saas/exe/stripe-dev
delete mode 100644 saas/lib/tasks/fizzy/usage_report.rake
delete mode 100644 saas/test/controllers/accounts/billing_portals_controller_test.rb
delete mode 100644 saas/test/controllers/accounts/subscriptions/card_creation_test.rb
delete mode 100644 saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb
delete mode 100644 saas/test/controllers/accounts/subscriptions/settings_test.rb
delete mode 100644 saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb
delete mode 100644 saas/test/controllers/accounts/subscriptions_controller_test.rb
delete mode 100644 saas/test/controllers/admin/accounts_controller_test.rb
delete mode 100644 saas/test/controllers/admin/billing_waivers_controller_test.rb
delete mode 100644 saas/test/controllers/admin/overridden_limits_controller_test.rb
delete mode 100644 saas/test/controllers/card/limited_creation_test.rb
delete mode 100644 saas/test/controllers/card/limited_publishing_test.rb
delete mode 100644 saas/test/controllers/stripe/webhooks_controller_test.rb
delete mode 100644 saas/test/fixtures/account_subscriptions.yml
delete mode 100644 saas/test/models/account/billing_test.rb
delete mode 100644 saas/test/models/account/limited_test.rb
delete mode 100644 saas/test/models/account/subscription_test.rb
delete mode 100644 saas/test/models/plan_test.rb
delete mode 100644 saas/test/models/user/notifies_account_of_email_change_test.rb
diff --git a/Gemfile.saas b/Gemfile.saas
index e57fcf206..384f5b4b7 100644
--- a/Gemfile.saas
+++ b/Gemfile.saas
@@ -5,12 +5,10 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
gem "activeresource", require: "active_resource"
gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing
-gem "stripe", "~> 18.0"
gem "queenbee", bc: "queenbee-plugin"
gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api"
-gem "csv"
# Native push notifications (iOS/Android)
gem "action_push_native"
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index e4ed7406e..6f9b0d088 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -281,7 +281,6 @@ GEM
bigdecimal
rexml
crass (1.0.6)
- csv (3.3.5)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
@@ -632,7 +631,6 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
- stripe (18.0.1)
thor (1.5.0)
thruster (0.1.18)
thruster (0.1.18-aarch64-linux)
@@ -721,7 +719,6 @@ DEPENDENCIES
bundler-audit
capybara
console1984!
- csv
debug
faker
fizzy-saas!
@@ -757,7 +754,6 @@ DEPENDENCIES
sqlite3 (>= 2.0)
stackprof
stimulus-rails
- stripe (~> 18.0)
thruster
trilogy (~> 2.10)
turbo-rails!
diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb
index 91bb85aff..fdb118e57 100644
--- a/app/views/account/settings/show.html.erb
+++ b/app/views/account/settings/show.html.erb
@@ -21,5 +21,3 @@
<%= render "account/settings/cancellation" %>
-
-<%= render "account/settings/subscription_panel" if Fizzy.saas? %>
diff --git a/app/views/cards/container/footer/_create.html.erb b/app/views/cards/container/footer/_create.html.erb
index b55cae344..648beffd1 100644
--- a/app/views/cards/container/footer/_create.html.erb
+++ b/app/views/cards/container/footer/_create.html.erb
@@ -13,7 +13,5 @@
Create and add another
<% end %>
-
- <%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %>
diff --git a/app/views/cards/drafts/_container.html.erb b/app/views/cards/drafts/_container.html.erb
index fa7134b55..9ffc04711 100644
--- a/app/views/cards/drafts/_container.html.erb
+++ b/app/views/cards/drafts/_container.html.erb
@@ -26,9 +26,5 @@
<% end %>
- <% if Fizzy.saas? %>
- <%= render "cards/container/footer/saas/create", card: card %>
- <% else %>
- <%= render "cards/container/footer/create", card: card %>
- <% end %>
+ <%= render "cards/container/footer/create", card: card %>
diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb
index 4be536454..9ef153df2 100644
--- a/app/views/layouts/shared/_head.html.erb
+++ b/app/views/layouts/shared/_head.html.erb
@@ -17,7 +17,6 @@
<%= render "layouts/theme_preference" %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
- <%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %>
<%= javascript_importmap_tags %>
<%= tenanted_action_cable_meta_tag %>
diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css
deleted file mode 100644
index a3fedc53d..000000000
--- a/saas/app/assets/stylesheets/fizzy/saas.css
+++ /dev/null
@@ -1,70 +0,0 @@
-/* Subscriptions
-/* ------------------------------------------------------------------------ */
-:root {
- --settings-subscription-background: linear-gradient(to bottom, var(--color-canvas), oklch(var(--lch-violet-lighter)));
- --settings-subscription-color: oklch(var(--lch-violet-medium));
- --settings-subscription-text-color: oklch(var(--lch-violet-dark));
-
- .settings-subscription__button {
- --btn-background: var(--settings-subscription-color);
- --btn-border-color: var(--color-canvas);
- --btn-color: var(--color-canvas);
- --focus-ring-color: var(--color-ink);
- }
-
- .settings-subscription__divider {
- --divider-color: currentColor;
-
- color: var(--settings-subscription-color);
- margin-block-start: calc(var(--block-space-half) * -1);
- }
-
- .settings-subscription__footer {
- color: var(--settings-subscription-text-color);
-
- &::before {
- border-block-start: 1px solid var(--settings-subscription-text-color);
- content: "";
- display: block;
- inline-size: 8ch;
- margin: 0 auto 1ch;
- }
- }
-
- .settings-subscription__link {
- color: var(--settings-subscription-text-color);
- }
-
- .settings-subscription__notch {
- animation: wiggle 500ms ease;
- background: var(--settings-subscription-background);
- border-radius: 3em;
- box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color);
- color: var(--settings-subscription-text-color);
- margin-inline: auto;
- padding: 0.3em 0.3em 0.3em 1.2em;
- transform: rotate(-1deg);
-
- @media (max-width: 640px) {
- border-radius: 1ch;
- padding-block: 1ch;
- padding-inline: 2ch;
- }
-
- .btn {
- /* margin-block: 0.3em; */
- }
- }
-
- .settings-subscription__panel {
- background: var(--settings-subscription-background);
- }
-
- .settings_subscription__warning {
- color: var(--settings-subscription-text-color);
-
- a {
- color: var(--settings-subscription-text-color);
- }
- }
-}
diff --git a/saas/app/controllers/account/billing_portals_controller.rb b/saas/app/controllers/account/billing_portals_controller.rb
deleted file mode 100644
index 67eaffe6c..000000000
--- a/saas/app/controllers/account/billing_portals_controller.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-class Account::BillingPortalsController < ApplicationController
- before_action :ensure_admin
- before_action :ensure_subscribed_account
-
- def show
- redirect_to create_stripe_billing_portal_session.url, allow_other_host: true
- end
-
- private
- def ensure_subscribed_account
- unless Current.account.subscribed?
- redirect_to account_subscription_path, alert: "No billing information found"
- end
- end
-
- def create_stripe_billing_portal_session
- Stripe::BillingPortal::Session.create(customer: Current.account.subscription.stripe_customer_id, return_url: account_settings_url)
- end
-end
diff --git a/saas/app/controllers/account/subscriptions/downgrades_controller.rb b/saas/app/controllers/account/subscriptions/downgrades_controller.rb
deleted file mode 100644
index d06795841..000000000
--- a/saas/app/controllers/account/subscriptions/downgrades_controller.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-class Account::Subscriptions::DowngradesController < Account::Subscriptions::UpdatePlanController
- before_action :ensure_downgradeable
-
- private
- def target_plan
- Plan.paid
- end
-
- def ensure_downgradeable
- head :bad_request unless subscription.plan == Plan.paid_with_extra_storage
- end
-end
diff --git a/saas/app/controllers/account/subscriptions/update_plan_controller.rb b/saas/app/controllers/account/subscriptions/update_plan_controller.rb
deleted file mode 100644
index c6a665cec..000000000
--- a/saas/app/controllers/account/subscriptions/update_plan_controller.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-class Account::Subscriptions::UpdatePlanController < ApplicationController
- before_action :ensure_admin
-
- def create
- portal_session = Stripe::BillingPortal::Session.create(
- customer: subscription.stripe_customer_id,
- return_url: account_settings_url(anchor: "subscription"),
- flow_data: {
- type: "subscription_update_confirm",
- subscription_update_confirm: {
- subscription: subscription.stripe_subscription_id,
- items: [ { id: stripe_subscription_item_id, price: target_plan.stripe_price_id } ]
- }
- }
- )
-
- redirect_to portal_session.url, allow_other_host: true
- end
-
- private
- def target_plan
- raise NotImplementedError
- end
-
- def subscription
- @subscription ||= Current.account.subscription
- end
-
- def stripe_subscription_item_id
- Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id
- end
-end
diff --git a/saas/app/controllers/account/subscriptions/upgrades_controller.rb b/saas/app/controllers/account/subscriptions/upgrades_controller.rb
deleted file mode 100644
index 1de6d8c6c..000000000
--- a/saas/app/controllers/account/subscriptions/upgrades_controller.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-class Account::Subscriptions::UpgradesController < Account::Subscriptions::UpdatePlanController
- before_action :ensure_upgradeable
-
- private
- def target_plan
- Plan.paid_with_extra_storage
- end
-
- def ensure_upgradeable
- head :bad_request unless subscription.plan == Plan.paid
- end
-end
diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb
deleted file mode 100644
index bbc4ec47a..000000000
--- a/saas/app/controllers/account/subscriptions_controller.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-class Account::SubscriptionsController < ApplicationController
- before_action :ensure_admin
- before_action :set_stripe_session, only: :show
-
- def show
- end
-
- def create
- session = Stripe::Checkout::Session.create \
- customer: find_or_create_stripe_customer,
- mode: "subscription",
- line_items: [ { price: plan_param.stripe_price_id, quantity: 1 } ],
- success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
- cancel_url: account_subscription_url,
- metadata: { account_id: Current.account.id, plan_key: plan_param.key },
- automatic_tax: { enabled: true },
- tax_id_collection: { enabled: true },
- billing_address_collection: "required",
- customer_update: { address: "auto", name: "auto" }
-
- redirect_to session.url, allow_other_host: true
- end
-
- private
- def plan_param
- @plan_param ||= Plan[params[:plan_key]] || Plan.paid
- end
-
- def set_stripe_session
- @stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id]
- end
-
- def find_or_create_stripe_customer
- find_stripe_customer || create_stripe_customer
- end
-
- def find_stripe_customer
- Stripe::Customer.retrieve(Current.account.subscription.stripe_customer_id) if Current.account.subscription&.stripe_customer_id
- end
-
- def create_stripe_customer
- Stripe::Customer.create(email: Current.user.identity.email_address, name: Current.account.name, metadata: { account_id: Current.account.id }).tap do |customer|
- Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: plan_param.key, status: "incomplete")
- end
- end
-end
diff --git a/saas/app/controllers/admin/account_searches_controller.rb b/saas/app/controllers/admin/account_searches_controller.rb
deleted file mode 100644
index db655a9ef..000000000
--- a/saas/app/controllers/admin/account_searches_controller.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-class Admin::AccountSearchesController < AdminController
- def create
- redirect_to saas.edit_admin_account_path(params[:q])
- end
-end
diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb
deleted file mode 100644
index 26a2b9c82..000000000
--- a/saas/app/controllers/admin/accounts_controller.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-class Admin::AccountsController < AdminController
- include Admin::AccountScoped
-
- layout "public"
-
- before_action :set_account, only: %i[ edit update ]
-
- def index
- end
-
- def edit
- end
-
- def update
- @account.override_limits(**overridden_limits_params.to_h.symbolize_keys)
- redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated"
- end
-
- private
- def set_account
- @account = Account.find_by!(external_account_id: params[:id])
- end
-
- def overridden_limits_params
- params.expect(account: [ :card_count, :bytes_used ])
- end
-end
diff --git a/saas/app/controllers/admin/billing_waivers_controller.rb b/saas/app/controllers/admin/billing_waivers_controller.rb
deleted file mode 100644
index 713891069..000000000
--- a/saas/app/controllers/admin/billing_waivers_controller.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-class Admin::BillingWaiversController < AdminController
- include Admin::AccountScoped
-
- def create
- @account.comp
- redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account comped"
- end
-
- def destroy
- @account.uncomp
- redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account uncomped"
- end
-end
diff --git a/saas/app/controllers/admin/overridden_limits_controller.rb b/saas/app/controllers/admin/overridden_limits_controller.rb
deleted file mode 100644
index fff8ea3e3..000000000
--- a/saas/app/controllers/admin/overridden_limits_controller.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-class Admin::OverriddenLimitsController < AdminController
- include Admin::AccountScoped
-
- def destroy
- @account.reset_overridden_limits
- redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Limits reset"
- end
-end
diff --git a/saas/app/controllers/admin/stats_controller.rb b/saas/app/controllers/admin/stats_controller.rb
index 287b49024..6d508fc2c 100644
--- a/saas/app/controllers/admin/stats_controller.rb
+++ b/saas/app/controllers/admin/stats_controller.rb
@@ -6,10 +6,6 @@ class Admin::StatsController < AdminController
@accounts_last_7_days = Account.where(created_at: 7.days.ago..).count
@accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count
- @paid_accounts_total = Account::Subscription.paid.distinct.count(:account_id)
- @paid_accounts_last_7_days = Account::Subscription.paid.where(created_at: 7.days.ago..).distinct.count(:account_id)
- @paid_accounts_last_24_hours = Account::Subscription.paid.where(created_at: 24.hours.ago..).distinct.count(:account_id)
-
@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/saas/app/controllers/concerns/admin/account_scoped.rb b/saas/app/controllers/concerns/admin/account_scoped.rb
deleted file mode 100644
index 314720cd2..000000000
--- a/saas/app/controllers/concerns/admin/account_scoped.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-module Admin::AccountScoped
- extend ActiveSupport::Concern
-
- included do
- before_action :set_account
- end
-
- private
- def set_account
- @account = Account.find_by!(external_account_id: params[:account_id] || params[:id])
- end
-end
diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited.rb
deleted file mode 100644
index 0576c6c4e..000000000
--- a/saas/app/controllers/concerns/card/limited.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-module Card::Limited
- extend ActiveSupport::Concern
-
- private
- def ensure_under_limits
- head :forbidden if Current.account.exceeding_limits?
- end
-end
diff --git a/saas/app/controllers/concerns/card/limited_creation.rb b/saas/app/controllers/concerns/card/limited_creation.rb
deleted file mode 100644
index 0e992008a..000000000
--- a/saas/app/controllers/concerns/card/limited_creation.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-module Card::LimitedCreation
- extend ActiveSupport::Concern
-
- included do
- include Card::Limited
-
- # Only limit API requests. We let you create drafts in the app to actually show the banner, no matter the card count.
- # We limit card publications separately. See +Card::LimitedPublishing+.
- before_action :ensure_under_limits, only: %i[ create ], if: -> { request.format.json? }
- end
-end
diff --git a/saas/app/controllers/concerns/card/limited_publishing.rb b/saas/app/controllers/concerns/card/limited_publishing.rb
deleted file mode 100644
index 8b60a4b53..000000000
--- a/saas/app/controllers/concerns/card/limited_publishing.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-module Card::LimitedPublishing
- extend ActiveSupport::Concern
-
- included do
- include Card::Limited
-
- before_action :ensure_under_limits, only: %i[ create ]
- end
-end
diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb
deleted file mode 100644
index 13dcd4699..000000000
--- a/saas/app/controllers/stripe/webhooks_controller.rb
+++ /dev/null
@@ -1,85 +0,0 @@
-class Stripe::WebhooksController < ApplicationController
- allow_unauthenticated_access
- skip_before_action :require_account
- skip_forgery_protection
-
- def create
- if event = verify_webhook_signature
- dispatch_stripe_event(event)
- head :ok
- else
- head :bad_request
- end
- end
-
- private
- def dispatch_stripe_event(event)
- case event.type
- when "checkout.session.completed"
- sync_new_subscription(event.data.object.subscription, plan_key: event.data.object.metadata["plan_key"]) if event.data.object.mode == "subscription"
- when "customer.subscription.updated", "customer.subscription.deleted"
- sync_subscription(event.data.object.id)
- end
- end
-
- def verify_webhook_signature
- payload = request.body.read
- sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
-
- Stripe::Webhook.construct_event(payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"])
- rescue Stripe::SignatureVerificationError => e
- Rails.logger.error "Stripe webhook signature verification failed: #{e.message}"
- nil
- end
-
- def sync_new_subscription(stripe_subscription_id, plan_key:)
- sync_subscription(stripe_subscription_id) do |subscription_properties|
- subscription_properties[:plan_key] = plan_key if plan_key
- end
- end
-
- # Always fetch fresh subscription data from Stripe to handle out-of-order
- # event delivery. Not relying on payload data.
- def sync_subscription(stripe_subscription_id)
- stripe_subscription = Stripe::Subscription.retrieve(stripe_subscription_id)
-
- if subscription = find_subscription_by_stripe_customer(stripe_subscription.customer)
- subscription_properties = {
- stripe_subscription_id: stripe_subscription.id,
- status: stripe_subscription.status,
- current_period_end: current_period_end_for(stripe_subscription),
- cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil,
- next_amount_due_in_cents: next_amount_due_for(stripe_subscription),
- plan_key: plan_key_for(stripe_subscription)
- }
-
- yield subscription_properties if block_given?
- subscription_properties[:stripe_subscription_id] = nil if stripe_subscription.status == "canceled"
-
- subscription.update!(subscription_properties)
- end
- end
-
- def find_subscription_by_stripe_customer(id)
- Account::Subscription.find_by(stripe_customer_id: id)
- end
-
- def current_period_end_for(stripe_subscription)
- timestamp = stripe_subscription.items.data.first&.current_period_end
- Time.at(timestamp) if timestamp
- end
-
- def next_amount_due_for(stripe_subscription)
- return nil if stripe_subscription.status == "canceled"
-
- preview = Stripe::Invoice.create_preview(customer: stripe_subscription.customer, subscription: stripe_subscription.id)
- preview.amount_due
- rescue Stripe::InvalidRequestError
- nil
- end
-
- def plan_key_for(stripe_subscription)
- price_id = stripe_subscription.items.data.first&.price&.id
- Plan.find_by_price_id(price_id)&.key
- end
-end
diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb
deleted file mode 100644
index fd6b5aa47..000000000
--- a/saas/app/helpers/subscriptions_helper.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-module SubscriptionsHelper
- def storage_to_human_size(bytes)
- number_to_human_size(bytes).delete(" ")
- end
-
- def subscription_period_end_action(subscription)
- if subscription.to_be_canceled?
- "Your Fizzy subscription ends on"
- elsif subscription.canceled?
- "Your Fizzy subscription ended on"
- else
- "Your next payment is #{ format_currency(subscription.next_amount_due) } on".html_safe
- end
- end
-
- def format_currency(amount)
- number_to_currency(amount, precision: (amount % 1).zero? ? 0 : 2)
- end
-end
diff --git a/saas/app/jobs/account/sync_stripe_customer_email_job.rb b/saas/app/jobs/account/sync_stripe_customer_email_job.rb
deleted file mode 100644
index b84eafa76..000000000
--- a/saas/app/jobs/account/sync_stripe_customer_email_job.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-class Account::SyncStripeCustomerEmailJob < ApplicationJob
- queue_as :default
- retry_on Stripe::StripeError, wait: :polynomially_longer
-
- def perform(subscription)
- subscription.sync_customer_email_to_stripe
- end
-end
diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb
deleted file mode 100644
index c124fd64c..000000000
--- a/saas/app/models/account/billing.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-module Account::Billing
- extend ActiveSupport::Concern
-
- 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
- active_subscription&.plan || Plan.free
- end
-
- def subscribed?
- subscription.present?
- end
-
- def comped?
- billing_waiver.present?
- end
-
- def comp
- create_billing_waiver unless billing_waiver
- end
-
- def uncomp
- billing_waiver&.destroy!
- reload_billing_waiver
- end
-
- def owner_email_changed
- Account::SyncStripeCustomerEmailJob.perform_later(subscription) if subscription
- end
-
- private
- def active_subscription
- if comped?
- comped_subscription
- elsif subscription&.active?
- subscription
- end
- end
-
- def comped_subscription
- @comped_subscription ||= billing_waiver&.subscription
- end
-end
diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb
deleted file mode 100644
index a9ec35eb8..000000000
--- a/saas/app/models/account/billing_waiver.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class Account::BillingWaiver < SaasRecord
- belongs_to :account
-
- def subscription
- @subscription ||= Account::Subscription.new(plan: Plan.paid_with_extra_storage)
- end
-end
diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb
deleted file mode 100644
index 2918e90a3..000000000
--- a/saas/app/models/account/limited.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-module Account::Limited
- extend ActiveSupport::Concern
-
- included do
- has_one :overridden_limits, class_name: "Account::OverriddenLimits", dependent: :destroy
- end
-
- NEAR_CARD_LIMIT_THRESHOLD = 100
- NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes
-
- def override_limits(card_count: nil, bytes_used: nil)
- (overridden_limits || build_overridden_limits).update!(card_count:, bytes_used:)
- end
-
- def billed_cards_count
- overridden_limits&.card_count || cards_count
- end
-
- def billed_bytes_used
- overridden_limits&.bytes_used || bytes_used
- end
-
- def nearing_plan_cards_limit?
- plan.limit_cards? && remaining_cards_count <= NEAR_CARD_LIMIT_THRESHOLD
- end
-
- def exceeding_card_limit?
- plan.limit_cards? && billed_cards_count >= plan.card_limit
- end
-
- def nearing_plan_storage_limit?
- remaining_storage < NEAR_STORAGE_LIMIT_THRESHOLD
- end
-
- def exceeding_storage_limit?
- billed_bytes_used > plan.storage_limit
- end
-
- def exceeding_limits?
- exceeding_card_limit? || exceeding_storage_limit?
- end
-
- def reset_overridden_limits
- overridden_limits&.destroy
- reload_overridden_limits
- end
-
- private
- def remaining_cards_count
- plan.card_limit - billed_cards_count
- end
-
- def remaining_storage
- plan.storage_limit - billed_bytes_used
- end
-end
diff --git a/saas/app/models/account/overridden_limits.rb b/saas/app/models/account/overridden_limits.rb
deleted file mode 100644
index cdf3c51ec..000000000
--- a/saas/app/models/account/overridden_limits.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-# To ease testing of limits
-class Account::OverriddenLimits < SaasRecord
- belongs_to :account
-end
diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb
deleted file mode 100644
index c1ec9e47c..000000000
--- a/saas/app/models/account/subscription.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-class Account::Subscription < SaasRecord
- belongs_to :account
-
- enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself)
-
- scope :paid, -> {
- where(status: %w[active trialing past_due])
- .where(plan_key: Plan.all.select(&:paid?).map(&:key))
- .where.not(account_id: Account::BillingWaiver.select(:account_id))
- }
-
- validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) }
-
- delegate :paid?, to: :plan
-
- def plan
- @plan ||= Plan.find(plan_key)
- end
-
- def plan=(plan)
- self.plan_key = plan.key
- end
-
- def to_be_canceled?
- active? && cancel_at.present?
- end
-
- 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
-
- def sync_customer_email_to_stripe
- if stripe_customer_id && (email = owner_email)
- Stripe::Customer.update(stripe_customer_id, email: email)
- end
- rescue Stripe::InvalidRequestError => e
- # Customer already deleted in Stripe - treat as success
- Rails.logger.warn "Stripe customer #{stripe_customer_id} not found during email sync: #{e.message}"
- end
-
- private
- # Account owner email for Stripe customer record. Returns nil when:
- # - No owner exists (ownership being transferred, account in limbo)
- # - Owner has no identity (deactivated user)
- def owner_email
- account.users.owner.first&.identity&.email_address
- end
-end
diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb
deleted file mode 100644
index e77ea81e8..000000000
--- a/saas/app/models/plan.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-class Plan
- PLANS = {
- free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes },
- monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] },
- monthly_extra_storage_v1: { name: "Unlimited + Extra Storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] }
- }
-
- attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id
-
- class << self
- def all
- @all ||= PLANS.map { |key, properties| new(key: key, **properties) }
- end
-
- def free
- @free ||= find(:free_v1)
- end
-
- def paid
- @paid ||= find(:monthly_v1)
- end
-
- def paid_with_extra_storage
- @paid_with_extra_storage ||= find(:monthly_extra_storage_v1)
- end
-
- def find(key)
- @all_by_key ||= all.index_by(&:key).with_indifferent_access
- @all_by_key[key]
- end
-
- def find_by_price_id(price_id)
- all.find { |plan| plan.stripe_price_id == price_id }
- end
-
- alias [] find
- end
-
- def initialize(key:, name:, price:, card_limit:, storage_limit:, stripe_price_id: nil)
- @key = key
- @name = name
- @price = price
- @card_limit = card_limit
- @storage_limit = storage_limit
- @stripe_price_id = stripe_price_id
- end
-
- def free?
- price.zero?
- end
-
- def paid?
- !free?
- end
-
- def limit_cards?
- !card_limit.infinite?
- end
-end
diff --git a/saas/app/models/user/notifies_account_of_email_change.rb b/saas/app/models/user/notifies_account_of_email_change.rb
deleted file mode 100644
index daba4ba3d..000000000
--- a/saas/app/models/user/notifies_account_of_email_change.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-module User::NotifiesAccountOfEmailChange
- extend ActiveSupport::Concern
-
- included do
- after_update :notify_account_of_owner_change, if: :account_owner_changed?
- end
-
- private
- # Account owner changed when:
- # - The current owner changed their email
- # - A user just became the owner (ownership transfer)
- def account_owner_changed?
- owner? && identity && (saved_change_to_identity_id? || became_owner?)
- end
-
- def became_owner?
- saved_change_to_role? && role_before_last_save != "owner"
- end
-
- def notify_account_of_owner_change
- account.owner_email_changed
- end
-end
diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb
deleted file mode 100644
index ca467d8af..000000000
--- a/saas/app/views/account/settings/_free_plan.html.erb
+++ /dev/null
@@ -1,18 +0,0 @@
-
- <% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %>
- You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards and all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage
- <% 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) %> of free storage
- <% else %>
- You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>
- <% 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.
-
-<%= 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 } } %>
-
-Cancel anytime, no contracts, take your data with you whenever.
-
diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb
deleted file mode 100644
index 6ea99a066..000000000
--- a/saas/app/views/account/settings/_paid_plan.html.erb
+++ /dev/null
@@ -1,21 +0,0 @@
-
- <% if Current.account.exceeding_storage_limit? %>
- You’ve run out of storage
- <% elsif Current.account.nearing_plan_storage_limit? %>
- You’ve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> of storage
- <% else %>
- Thank you for buying Fizzy
- <% end %>
-
-
-<% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %>
-
- The <%= Current.account.plan.name %> plan includes <%= storage_to_human_size(Plan.paid.storage_limit) %> . Upgrade to get <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> extra storage for <%= format_currency(Plan.paid_with_extra_storage.price - Plan.paid.price) %>/month more.
-
-
- <%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name} for #{ number_to_currency(Plan.paid_with_extra_storage.price, strip_insignificant_zeros: true) }/month", account_subscription_upgrade_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
-<% end %>
-
-<% if Current.account.subscription %>
- <%= render "account/settings/subscription", subscription: Current.account.subscription %>
-<% end %>
diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb
deleted file mode 100644
index 4eca0ad2d..000000000
--- a/saas/app/views/account/settings/_subscription.html.erb
+++ /dev/null
@@ -1,20 +0,0 @@
-<% if subscription.current_period_end %>
- <%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>
-<% end %>
-
-
- <%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %>
-
-
-
-
diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb
deleted file mode 100644
index 1cfcda4d8..000000000
--- a/saas/app/views/account/settings/_subscription_panel.html.erb
+++ /dev/null
@@ -1,11 +0,0 @@
-<% if Current.user.admin? && !Current.account.comped? %>
-
- Subscription
-
- <% if Current.account.plan.free? %>
- <%= render "account/settings/free_plan" %>
- <% else %>
- <%= render "account/settings/paid_plan" %>
- <% end %>
-
-<% end %>
diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb
deleted file mode 100644
index 449fecf16..000000000
--- a/saas/app/views/account/subscriptions/_upgrade.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
-
- <% if Current.user.admin? %>
- <%= render "account/subscriptions/notices/admin_exceeding_card_limit" if Current.account.exceeding_card_limit? %>
- <%= render "account/subscriptions/notices/admin_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %>
- <% else %>
- <%= render "account/subscriptions/notices/user_exceeding_card_limit" if Current.account.exceeding_card_limit? %>
- <%= render "account/subscriptions/notices/user_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %>
- <% end %>
-
diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb
deleted file mode 100644
index 3dc759d9e..000000000
--- a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb
+++ /dev/null
@@ -1,4 +0,0 @@
-You’ve used your <%= Plan.free.card_limit %> free cards.
-<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
- Upgrade to Unlimited
-<% end %>
diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb
deleted file mode 100644
index 8770c1460..000000000
--- a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb
+++ /dev/null
@@ -1,4 +0,0 @@
-You’ve run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>.
-<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
- Upgrade to get more
-<% end %>
diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb
deleted file mode 100644
index f3f65f3be..000000000
--- a/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-
- This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more.
-
diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb
deleted file mode 100644
index f230443a1..000000000
--- a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-
- This account has run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade to get more.
-
diff --git a/saas/app/views/account/subscriptions/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb
deleted file mode 100644
index 8d29a379e..000000000
--- a/saas/app/views/account/subscriptions/show.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
-<% @page_title = "Thank you" %>
-
-<% if @stripe_session&.payment_status == "paid" %>
-
- Thanks for buying Fizzy!
- Your payment was successful. You’re now on the <%= Current.account.plan.name %> plan.
- <%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %>
-
-<% end %>
diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb
deleted file mode 100644
index 76c23497c..000000000
--- a/saas/app/views/admin/accounts/edit.html.erb
+++ /dev/null
@@ -1,63 +0,0 @@
-<% @page_title = "Account admin" %>
-
-<% content_for :header do %>
-
-<% end %>
-
-
-
-
-
Edit Account <%= @account.external_account_id %>
-
- <%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %>
-
-
- Account Name:
- <%= @account.name %>
-
-
- Subscription plan:
- <%= @account.plan.name %>
-
-
- Actual card count:
- <%= @account.cards_count %> cards
-
-
- Actual bytes used:
- <%= storage_to_human_size(@account.bytes_used) %>
-
-
-
- <%= form.label :card_count, class: "txt-subtle" do %>
- Override card count <%= "(Exceeds plan limit)" if @account.exceeding_card_limit? %>
- <% end %>
-
- <%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %>
-
-
-
- <%= form.label :bytes_used, class: "txt-subtle" do %>
- Override bytes used <%= "(Exceeds plan limit)" if @account.exceeding_storage_limit? %>
- <% end %>
-
- <%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %>
-
-
-
-
Save changes
- <% end %>
-
- <% if @account.overridden_limits %>
- <%= button_to "Reset limits", saas.admin_account_overridden_limits_path(@account.external_account_id), method: :delete, class: "btn btn--negative full-width" %>
- <% end %>
-
- <% if @account.comped? %>
- <%= button_to "Uncomp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :delete, class: "btn btn--plain txt-negative txt-underline" %>
- <% else %>
- <%= button_to "Comp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :post, class: "btn btn--plain txt-positive txt-underline" %>
- <% end %>
-
-
diff --git a/saas/app/views/admin/accounts/index.html.erb b/saas/app/views/admin/accounts/index.html.erb
deleted file mode 100644
index 96bed2338..000000000
--- a/saas/app/views/admin/accounts/index.html.erb
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
Find an account
-
- <%= form_with url: saas.admin_account_search_path, class: "flex flex-column gap" do |form| %>
- <%= form.text_field :q, placeholder: "Account ID", autofocus: true, class: "input" %>
- <%= form.submit "Search", class: "btn btn--link" %>
- <% end %>
-
-
-
-
-
diff --git a/saas/app/views/admin/stats/show.html.erb b/saas/app/views/admin/stats/show.html.erb
index f93bd3eba..c2b1e1d5d 100644
--- a/saas/app/views/admin/stats/show.html.erb
+++ b/saas/app/views/admin/stats/show.html.erb
@@ -33,32 +33,6 @@
-
-
-
-
-
Total
-
- <%= @paid_accounts_total %>
-
-
-
-
7 days
-
- <%= @paid_accounts_last_7_days %>
-
-
-
-
24 hours
-
- <%= @paid_accounts_last_24_hours %>
-
-
-
-
-
Identities Created
diff --git a/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb
deleted file mode 100644
index 8b0edd308..000000000
--- a/saas/app/views/cards/container/footer/saas/_create.html.erb
+++ /dev/null
@@ -1,5 +0,0 @@
-<% if Current.account.exceeding_limits? %>
- <%= render "account/subscriptions/upgrade" %>
-<% else %>
- <%= render "cards/container/footer/create", card: card %>
-<% end %>
diff --git a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb
deleted file mode 100644
index 4cf480f3c..000000000
--- a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
-
- <% if Current.user.admin? %>
- <%= render "cards/container/footer/saas/notices/admin_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %>
- <%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %>
- <% else %>
- <%= render "cards/container/footer/saas/notices/user_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %>
- <%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %>
- <% end %>
-
diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb
deleted file mode 100644
index caa7bd958..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %> .
diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb
deleted file mode 100644
index ab1a3ca34..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-You’ve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. <%= link_to "Upgrade to get more", account_settings_path(anchor: "subscription") %> .
diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb
deleted file mode 100644
index 8ab80e162..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon.
diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb
deleted file mode 100644
index 25428980a..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade soon.
diff --git a/saas/config/routes.rb b/saas/config/routes.rb
index 581a2ae02..3eafab92f 100644
--- a/saas/config/routes.rb
+++ b/saas/config/routes.rb
@@ -8,10 +8,5 @@ Fizzy::Saas::Engine.routes.draw do
namespace :admin do
mount Audits1984::Engine, at: "/console"
get "stats", to: "stats#show"
- resource :account_search, only: :create
- resources :accounts do
- resource :overridden_limits, only: :destroy
- resource :billing_waiver, only: [ :create, :destroy ]
- end
end
end
diff --git a/saas/db/migrate/20260317000000_drop_billing_tables.rb b/saas/db/migrate/20260317000000_drop_billing_tables.rb
new file mode 100644
index 000000000..2ac9a808d
--- /dev/null
+++ b/saas/db/migrate/20260317000000_drop_billing_tables.rb
@@ -0,0 +1,7 @@
+class DropBillingTables < ActiveRecord::Migration[8.2]
+ def change
+ drop_table :account_subscriptions
+ drop_table :account_overridden_limits
+ drop_table :account_billing_waivers
+ end
+end
diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb
index 298cc033a..5ae786764 100644
--- a/saas/db/saas_schema.rb
+++ b/saas/db/saas_schema.rb
@@ -10,39 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.2].define(version: 2026_01_26_230838) do
- create_table "account_billing_waivers", 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.datetime "updated_at", null: false
- t.index ["account_id"], name: "index_account_billing_waivers_on_account_id", unique: true
- end
-
- create_table "account_overridden_limits", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
- t.uuid "account_id", null: false
- t.bigint "bytes_used"
- t.integer "card_count"
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.index ["account_id"], name: "index_account_overridden_limits_on_account_id", unique: true
- end
-
- create_table "account_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
- t.uuid "account_id", null: false
- t.datetime "cancel_at"
- t.datetime "created_at", null: false
- t.datetime "current_period_end"
- t.integer "next_amount_due_in_cents"
- t.string "plan_key"
- t.string "status"
- t.string "stripe_customer_id", null: false
- t.string "stripe_subscription_id"
- t.datetime "updated_at", null: false
- t.index ["account_id"], name: "index_account_subscriptions_on_account_id"
- t.index ["stripe_customer_id"], name: "index_account_subscriptions_on_stripe_customer_id", unique: true
- t.index ["stripe_subscription_id"], name: "index_account_subscriptions_on_stripe_subscription_id", unique: true
- end
-
+ActiveRecord::Schema[8.2].define(version: 2026_03_17_000000) do
create_table "action_push_native_devices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "name"
diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev
deleted file mode 100755
index d3e63a754..000000000
--- a/saas/exe/stripe-dev
+++ /dev/null
@@ -1,83 +0,0 @@
-#!/usr/bin/env ruby
-#
-# Fetches Stripe development environment variables from 1Password and starts
-# the Stripe CLI webhook listener.
-#
-# Usage: eval "$(bundle exec stripe-dev)"
-
-require "json"
-require "fileutils"
-
-LOG_FILE = "log/stripe.development.log"
-PID_FILE = "tmp/stripe.tunnel.pid"
-
-# Ensure directories exist
-FileUtils.mkdir_p("log")
-FileUtils.mkdir_p("tmp")
-
-# Kill any existing stripe tunnel process
-if File.exist?(PID_FILE)
- old_pid = File.read(PID_FILE).strip.to_i
- if old_pid > 0
- Process.kill("TERM", old_pid) rescue nil
- end
- File.delete(PID_FILE)
-end
-
-# Fetch secrets from 1Password
-secrets_escaped = `kamal secrets fetch \
- --adapter 1password \
- --account 23QPQDKZC5BKBIIG7UGT5GR5RM \
- --from "Deploy/Fizzy" \
- "Development/STRIPE_SECRET_KEY" \
- "Development/STRIPE_MONTHLY_V1_PRICE_ID" \
- "Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID" 2>/dev/null`
-
-secrets_json = secrets_escaped.gsub(/\\(.)/, '\1')
-secrets = JSON.parse(secrets_json)
-
-stripe_secret_key = secrets["Deploy/Fizzy/Development/STRIPE_SECRET_KEY"]
-stripe_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_V1_PRICE_ID"]
-stripe_extra_storage_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"]
-
-# Clear previous log file
-File.write(LOG_FILE, "")
-
-# Start stripe listen in background
-pid = spawn(
- "stripe", "listen", "--forward-to", "localhost:3006/stripe/webhooks",
- out: [ LOG_FILE, "a" ],
- err: [ LOG_FILE, "a" ]
-)
-Process.detach(pid)
-
-# Save PID for cleanup
-File.write(PID_FILE, pid.to_s)
-
-# Wait for the webhook secret to appear in logs
-webhook_secret = nil
-20.times do
- sleep 0.5
- if File.exist?(LOG_FILE)
- content = File.read(LOG_FILE)
- if match = content.match(/webhook signing secret is (whsec_\w+)/)
- webhook_secret = match[1]
- break
- end
- end
-end
-
-if webhook_secret.nil?
- warn "Warning: Could not capture webhook secret from stripe listen"
-end
-
-# Output export statements
-puts %Q(export STRIPE_SECRET_KEY="#{stripe_secret_key}")
-puts %Q(export STRIPE_MONTHLY_V1_PRICE_ID="#{stripe_price_id}")
-puts %Q(export STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID="#{stripe_extra_storage_price_id}")
-puts %Q(export STRIPE_WEBHOOK_SECRET="#{webhook_secret}") if webhook_secret
-
-# Informational message to stderr (won't be eval'd)
-warn ""
-warn "Stripe CLI listening (PID: #{pid})"
-warn "Logs: #{LOG_FILE}"
diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb
index 1ba45df42..dea321cfc 100644
--- a/saas/lib/fizzy/saas/engine.rb
+++ b/saas/lib/fizzy/saas/engine.rb
@@ -16,10 +16,6 @@ module Fizzy
connects_to database: { writing: :saas, reading: :saas }
end
- initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app|
- app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com"
- end
-
initializer "fizzy_saas.assets" do |app|
app.config.assets.paths << root.join("app/assets/stylesheets")
end
@@ -33,25 +29,6 @@ module Fizzy
app.paths["config/push"].unshift(root.join("config/push.yml").to_s)
end
- initializer "fizzy.saas.routes", after: :add_routing_paths do |app|
- # Routes that rely on the implicit account tenant should go here instead of in +routes.rb+.
- app.routes.prepend do
- namespace :account do
- resource :billing_portal, only: :show
- resource :subscription do
- scope module: :subscriptions do
- resource :upgrade, only: :create
- resource :downgrade, only: :create
- end
- end
- end
-
- namespace :stripe do
- resource :webhooks, only: :create
- end
- end
- end
-
initializer "fizzy.saas.mount" do |app|
app.routes.append do
mount Fizzy::Saas::Engine => "/", as: "saas"
@@ -98,10 +75,6 @@ module Fizzy
end
end
- initializer "fizzy_saas.stripe" do
- Stripe.api_key = ENV["STRIPE_SECRET_KEY"]
- end
-
initializer "fizzy_saas.sentry" do
if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank?
Sentry.init do |config|
@@ -160,14 +133,10 @@ module Fizzy
end
config.to_prepare do
- ::Account.include Account::Billing, Account::Limited
- ::User.include User::NotifiesAccountOfEmailChange
::Identity.include Authorization::Identity, Identity::Devices
::Session.include Session::Devices
::Signup.prepend Signup
ApplicationController.include Authorization::Controller
- CardsController.include(Card::LimitedCreation)
- Cards::PublishesController.include(Card::LimitedPublishing)
Notification.register_push_target(:native)
diff --git a/saas/lib/tasks/fizzy/usage_report.rake b/saas/lib/tasks/fizzy/usage_report.rake
deleted file mode 100644
index d8b3ba108..000000000
--- a/saas/lib/tasks/fizzy/usage_report.rake
+++ /dev/null
@@ -1,37 +0,0 @@
-require "csv"
-
-namespace :saas do
- desc "Generate a CSV usage report for all active accounts"
- task usage_report: :environment do
- output_path = Rails.root.join("tmp/usage_report.csv")
-
- CSV.open(output_path, "w") do |csv|
- csv << [ "Queenbee ID", "Account Name", "Sign Up Date", "Paid Date", "Comped", "Card Count", "Storage Used (Bytes)", "Last Active" ]
-
- Account.active.includes(:storage_total).in_batches do |batch|
- batch_ids = batch.pluck(:id)
- paid_dates = Account::Subscription.paid.where(account_id: batch_ids)
- .group(:account_id).minimum(:created_at)
- comped_account_ids = Account::BillingWaiver.where(account_id: batch_ids)
- .pluck(:account_id).to_set
- last_active_dates = Card.where(account_id: batch_ids)
- .group(:account_id).maximum(:last_active_at)
-
- batch.each do |account|
- csv << [
- account.external_account_id,
- account.name,
- account.created_at.to_date,
- paid_dates[account.id]&.to_date,
- comped_account_ids.include?(account.id),
- account.cards_count,
- account.bytes_used,
- last_active_dates[account.id]&.to_date
- ]
- end
- end
- end
-
- puts "Report written to #{output_path}"
- end
-end
diff --git a/saas/test/controllers/accounts/billing_portals_controller_test.rb b/saas/test/controllers/accounts/billing_portals_controller_test.rb
deleted file mode 100644
index 00f16e4df..000000000
--- a/saas/test/controllers/accounts/billing_portals_controller_test.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-require "test_helper"
-require "ostruct"
-
-class Account::BillingPortalsControllerTest < ActionDispatch::IntegrationTest
- setup do
- sign_in_as :kevin
- end
-
- test "redirects to stripe billing portal" do
- Current.account.subscription.update!(stripe_customer_id: "cus_test123")
-
- session = OpenStruct.new(url: "https://billing.stripe.com/session123")
- Stripe::BillingPortal::Session.expects(:create)
- .with(customer: "cus_test123", return_url: account_settings_url)
- .returns(session)
-
- get account_billing_portal_path
-
- assert_redirected_to "https://billing.stripe.com/session123"
- end
-
- test "requires admin" do
- logout_and_sign_in_as :david
-
- get account_billing_portal_path
-
- assert_response :forbidden
- end
-end
diff --git a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb
deleted file mode 100644
index 1ddc4bc1c..000000000
--- a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-require "test_helper"
-
-class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest
- setup do
- sign_in_as :mike
- end
-
- # Nearing limits - shown in card creation footer
-
- test "admin sees nearing card limit notice" do
- accounts(:initech).update_column(:cards_count, 950)
-
- get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
-
- assert_response :success
- assert_match /upgrade to unlimited/i, response.body
- end
-
- test "admin sees nearing storage limit notice" do
- Account.any_instance.stubs(:bytes_used).returns(600.megabytes)
-
- get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
-
- assert_response :success
- assert_match /upgrade to get more/i, response.body
- end
-
- # Exceeding limits - shown instead of create buttons
-
- test "admin sees exceeding card limit notice" do
- accounts(:initech).update_column(:cards_count, 1001)
-
- get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
-
- assert_response :success
- assert_match /you’ve used your.*free cards/i, response.body
- end
-
- test "admin sees exceeding storage limit notice" do
- Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
-
- get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
-
- assert_response :success
- assert_match /you’ve run out of.*free storage/i, response.body
- end
-
- # Paid accounts under limits - no notices
-
- test "paid account under limits sees no notices" do
- logout_and_sign_in_as :kevin
-
- accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
-
- get card_path(cards(:layout), script_name: accounts(:"37s").slug)
-
- assert_response :success
- assert_no_match /upgrade/i, response.body
- assert_no_match /you’ve used your/i, response.body
- end
-
- # Comped accounts under limits - no notices
-
- test "comped account under limits sees no notices" do
- accounts(:initech).comp
-
- get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
-
- assert_response :success
- assert_no_match /upgrade/i, response.body
- assert_no_match /you’ve used your/i, response.body
- end
-end
diff --git a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb
deleted file mode 100644
index e4680a1f8..000000000
--- a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-require "test_helper"
-require "ostruct"
-
-class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::IntegrationTest
- setup do
- sign_in_as :kevin
- accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid_with_extra_storage)
- end
-
- test "downgrade redirects to stripe billing portal" do
- stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]))
- portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123")
-
- Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription)
- Stripe::BillingPortal::Session.stubs(:create).returns(portal_session)
-
- post account_subscription_downgrade_path
-
- assert_redirected_to "https://billing.stripe.com/session/abc123"
- end
-
- test "downgrade requires admin" do
- logout_and_sign_in_as :david
-
- post account_subscription_downgrade_path
- assert_response :forbidden
- end
-
- test "downgrade requires downgradeable plan" do
- accounts(:"37s").subscription.update!(plan: Plan.paid)
-
- post account_subscription_downgrade_path
- assert_response :bad_request
- end
-end
diff --git a/saas/test/controllers/accounts/subscriptions/settings_test.rb b/saas/test/controllers/accounts/subscriptions/settings_test.rb
deleted file mode 100644
index 63feac2c5..000000000
--- a/saas/test/controllers/accounts/subscriptions/settings_test.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-require "test_helper"
-
-class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest
- test "free users see current usage" do
- sign_in_as :mike
-
- accounts(:initech).update_column(:cards_count, 3)
-
- get account_settings_path(script_name: accounts(:initech).slug)
-
- assert_response :success
- assert_match /You’ve used.*3.*free cards out of 1,000/i, response.body
- end
-
- test "paid users see thank you message" do
- sign_in_as :kevin
-
- accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
-
- get account_settings_path(script_name: accounts(:"37s").slug)
-
- assert_response :success
- assert_select "h3", text: "Thank you for buying Fizzy"
- end
-
- test "regular plan users see upgrade option" do
- sign_in_as :kevin
-
- accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
-
- get account_settings_path(script_name: accounts(:"37s").slug)
-
- assert_response :success
- assert_select "button", text: /upgrade/i
- assert_select "button", text: /downgrade/i, count: 0
- end
-
- test "extra storage plan users see downgrade option" do
- sign_in_as :kevin
-
- accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage, status: :active)
-
- get account_settings_path(script_name: accounts(:"37s").slug)
-
- assert_response :success
- assert_select "button", text: /downgrade/i
- end
-
- test "comped accounts see no subscription panel" do
- sign_in_as :mike
-
- accounts(:initech).comp
-
- get account_settings_path(script_name: accounts(:initech).slug)
-
- assert_response :success
- assert_no_match /thank you for buying/i, response.body
- assert_no_match /free cards out of/i, response.body
- assert_no_match /upgrade/i, response.body
- assert_no_match /downgrade/i, response.body
- end
-end
diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb
deleted file mode 100644
index 65e268af1..000000000
--- a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-require "test_helper"
-require "ostruct"
-
-class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::IntegrationTest
- setup do
- sign_in_as :kevin
- accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid)
- end
-
- test "upgrade redirects to stripe billing portal" do
- stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]))
- portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123")
-
- Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription)
- Stripe::BillingPortal::Session.stubs(:create).returns(portal_session)
-
- post account_subscription_upgrade_path
-
- assert_redirected_to "https://billing.stripe.com/session/abc123"
- end
-
- test "upgrade requires admin" do
- logout_and_sign_in_as :david
-
- post account_subscription_upgrade_path
- assert_response :forbidden
- end
-
- test "upgrade requires upgradeable plan" do
- accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage)
-
- post account_subscription_upgrade_path
- assert_response :bad_request
- end
-end
diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb
deleted file mode 100644
index 9e086df39..000000000
--- a/saas/test/controllers/accounts/subscriptions_controller_test.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-require "test_helper"
-require "ostruct"
-
-class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest
- setup do
- sign_in_as :kevin
- end
-
- test "show" do
- get account_subscription_path
- assert_response :success
- end
-
- test "show with session_id retrieves stripe session" do
- Stripe::Checkout::Session.stubs(:retrieve).with("sess_123").returns(OpenStruct.new(id: "sess_123"))
-
- get account_subscription_path(session_id: "sess_123")
- assert_response :success
- end
-
- test "show requires admin" do
- logout_and_sign_in_as :david
-
- get account_subscription_path
- assert_response :forbidden
- end
-
- test "create redirects to stripe checkout" do
- customer = OpenStruct.new(id: "cus_test_37signals")
- session = OpenStruct.new(url: "https://checkout.stripe.com/session123")
-
- Stripe::Customer.stubs(:retrieve).returns(customer)
- Stripe::Checkout::Session.stubs(:create).returns(session)
-
- post account_subscription_path
-
- assert_redirected_to "https://checkout.stripe.com/session123"
- end
-
- test "create requires admin" do
- logout_and_sign_in_as :david
-
- post account_subscription_path
- assert_response :forbidden
- end
-
- test "create with custom plan_key redirects to stripe checkout" do
- customer = OpenStruct.new(id: "cus_test_37signals")
- session = OpenStruct.new(url: "https://checkout.stripe.com/session123")
-
- Stripe::Customer.stubs(:retrieve).returns(customer)
- Stripe::Checkout::Session.stubs(:create).with do |params|
- params[:metadata][:plan_key] == :monthly_extra_storage_v1
- end.returns(session)
-
- post account_subscription_path(plan_key: :monthly_extra_storage_v1)
-
- assert_redirected_to "https://checkout.stripe.com/session123"
- end
-end
diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb
deleted file mode 100644
index 3849c07a5..000000000
--- a/saas/test/controllers/admin/accounts_controller_test.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-require "test_helper"
-
-class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest
- test "staff can access index" do
- sign_in_as :david
-
- untenanted do
- get saas.admin_accounts_path
- end
-
- assert_response :success
- end
-
- test "search account" do
- sign_in_as :david
-
- untenanted do
- post saas.admin_account_search_path, params: { q: accounts(:"37s").external_account_id }
- assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id)
- end
- end
-
- test "staff can edit account" do
- sign_in_as :david
-
- untenanted do
- get saas.edit_admin_account_path(accounts(:"37s").external_account_id)
- end
-
- assert_response :success
- end
-
- test "staff can override card count" do
- sign_in_as :david
-
- untenanted do
- patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { card_count: 500 } }
- assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id)
- end
-
- assert_equal 500, accounts(:"37s").reload.billed_cards_count
- end
-
- test "non-staff cannot access accounts" do
- sign_in_as :jz
-
- untenanted do
- patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 9999 } }
- end
-
- assert_response :forbidden
- assert_not_equal 9999, accounts(:"37s").reload.cards_count
- end
-end
diff --git a/saas/test/controllers/admin/billing_waivers_controller_test.rb b/saas/test/controllers/admin/billing_waivers_controller_test.rb
deleted file mode 100644
index 8f7d14199..000000000
--- a/saas/test/controllers/admin/billing_waivers_controller_test.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-require "test_helper"
-
-class Admin::BillingWaiversControllerTest < ActionDispatch::IntegrationTest
- test "staff can comp an account" do
- sign_in_as :david
- account = accounts(:"37s")
-
- assert_not account.comped?
-
- untenanted do
- post saas.admin_account_billing_waiver_path(account.external_account_id)
- assert_redirected_to saas.edit_admin_account_path(account.external_account_id)
- end
-
- assert account.reload.comped?
- end
-
- test "staff can uncomp an account" do
- sign_in_as :david
- account = accounts(:"37s")
- account.comp
-
- assert account.comped?
-
- untenanted do
- delete saas.admin_account_billing_waiver_path(account.external_account_id)
- assert_redirected_to saas.edit_admin_account_path(account.external_account_id)
- end
-
- assert_not account.reload.comped?
- end
-end
diff --git a/saas/test/controllers/admin/overridden_limits_controller_test.rb b/saas/test/controllers/admin/overridden_limits_controller_test.rb
deleted file mode 100644
index b660e08b0..000000000
--- a/saas/test/controllers/admin/overridden_limits_controller_test.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-require "test_helper"
-
-class Admin::OverriddenLimitsControllerTest < ActionDispatch::IntegrationTest
- test "staff can reset overridden limits" do
- sign_in_as :david
- account = accounts(:"37s")
-
- # First set an override
- account.override_limits(card_count: 500)
- assert_equal 500, account.reload.billed_cards_count
-
- # Then reset it
- untenanted do
- delete saas.admin_account_overridden_limits_path(account.external_account_id)
- assert_redirected_to saas.edit_admin_account_path(account.external_account_id)
- end
-
- # Verify override was removed
- assert_nil account.reload.overridden_limits
- assert_equal account.cards_count, account.billed_cards_count
- end
-end
diff --git a/saas/test/controllers/card/limited_creation_test.rb b/saas/test/controllers/card/limited_creation_test.rb
deleted file mode 100644
index 4105091be..000000000
--- a/saas/test/controllers/card/limited_creation_test.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-require "test_helper"
-
-class Card::LimitedCreationTest < ActionDispatch::IntegrationTest
- test "cannot create cards via JSON when card limit exceeded" do
- sign_in_as :mike
-
- accounts(:initech).update_column(:cards_count, 1001)
-
- assert_no_difference -> { Card.count } do
- post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug, format: :json)
- end
-
- assert_response :forbidden
- end
-
- test "can create cards via HTML when card limit exceeded but they are drafts" do
- sign_in_as :mike
-
- accounts(:initech).update_column(:cards_count, 1001)
- boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all
-
- assert_difference -> { Card.count } do
- post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug)
- end
-
- assert_response :redirect
- assert Card.last.drafted?
- end
-
- test "cannot force published status via HTML when card limit exceeded" do
- sign_in_as :mike
-
- accounts(:initech).update_column(:cards_count, 1001)
- boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all
-
- assert_difference -> { Card.count } do
- post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug), params: { card: { status: "published" } }
- end
-
- assert_response :redirect
- assert Card.last.drafted?
- end
-
- test "cannot create cards via JSON when storage limit exceeded" do
- sign_in_as :mike
-
- Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
-
- assert_no_difference -> { Card.count } do
- post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug, format: :json)
- end
-
- assert_response :forbidden
- end
-end
diff --git a/saas/test/controllers/card/limited_publishing_test.rb b/saas/test/controllers/card/limited_publishing_test.rb
deleted file mode 100644
index 90e4e5696..000000000
--- a/saas/test/controllers/card/limited_publishing_test.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-require "test_helper"
-
-class Card::LimitedPublishingTest < ActionDispatch::IntegrationTest
- test "cannot publish cards when card limit exceeded" do
- sign_in_as :mike
-
- accounts(:initech).update_column(:cards_count, 1001)
-
- post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
-
- assert_response :forbidden
- assert cards(:unfinished_thoughts).reload.drafted?
- end
-
- test "cannot publish cards when storage limit exceeded" do
- sign_in_as :mike
-
- Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
-
- post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
-
- assert_response :forbidden
- assert cards(:unfinished_thoughts).reload.drafted?
- end
-end
diff --git a/saas/test/controllers/stripe/webhooks_controller_test.rb b/saas/test/controllers/stripe/webhooks_controller_test.rb
deleted file mode 100644
index 92f6b7508..000000000
--- a/saas/test/controllers/stripe/webhooks_controller_test.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-require "test_helper"
-require "ostruct"
-
-class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest
- setup do
- @account = Account.create!(name: "Test")
- @subscription = @account.create_subscription! \
- plan_key: "monthly_v1",
- status: "incomplete",
- stripe_customer_id: "cus_test123"
- end
-
- test "invalid signature returns bad request" do
- Stripe::Webhook.stubs(:construct_event).raises(Stripe::SignatureVerificationError.new("invalid", "sig"))
-
- post stripe_webhooks_path
- assert_response :bad_request
- end
-
- test "checkout session completed activates subscription" do
- stripe_sub = OpenStruct.new(id: "sub_123", customer: "cus_test123", status: "active", cancel_at: nil, items: stub_items(1.month.from_now.to_i))
-
- event = stripe_event("checkout.session.completed",
- mode: "subscription",
- customer: "cus_test123",
- subscription: "sub_123",
- metadata: { "plan_key" => "monthly_v1" }
- )
-
- Stripe::Webhook.stubs(:construct_event).returns(event)
- Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
- Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999))
-
- post stripe_webhooks_path
-
- assert_response :ok
- @subscription.reload
- assert_equal "sub_123", @subscription.stripe_subscription_id
- assert_equal "active", @subscription.status
- end
-
- test "subscription updated changes status and syncs next amount due" do
- @subscription.update!(stripe_subscription_id: "sub_123", status: "active")
-
- stripe_sub = OpenStruct.new(
- id: "sub_123",
- customer: "cus_test123",
- status: "past_due",
- cancel_at: nil,
- items: stub_items(1.month.from_now.to_i)
- )
-
- event = stripe_event("customer.subscription.updated", id: "sub_123")
-
- Stripe::Webhook.stubs(:construct_event).returns(event)
- Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
- Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999))
-
- post stripe_webhooks_path
-
- assert_response :ok
- @subscription.reload
- assert_equal "past_due", @subscription.status
- assert_equal 1999, @subscription.next_amount_due_in_cents
- end
-
- test "subscription deleted cancels subscription" do
- @subscription.update!(stripe_subscription_id: "sub_123", status: "active")
-
- stripe_sub = OpenStruct.new(
- id: "sub_123",
- customer: "cus_test123",
- status: "canceled",
- cancel_at: nil,
- items: stub_items(1.month.from_now.to_i)
- )
-
- event = stripe_event("customer.subscription.deleted", id: "sub_123")
-
- Stripe::Webhook.stubs(:construct_event).returns(event)
- Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
-
- post stripe_webhooks_path
-
- assert_response :ok
- @subscription.reload
- assert_equal "canceled", @subscription.status
- assert_nil @subscription.stripe_subscription_id
- assert_nil @subscription.next_amount_due_in_cents
- end
-
- private
- def stripe_event(type, **attributes)
- OpenStruct.new(type: type, data: OpenStruct.new(object: OpenStruct.new(attributes)))
- end
-
- def stub_items(current_period_end)
- OpenStruct.new(data: [ OpenStruct.new(current_period_end: current_period_end) ])
- end
-end
diff --git a/saas/test/fixtures/account_subscriptions.yml b/saas/test/fixtures/account_subscriptions.yml
deleted file mode 100644
index a18ff961b..000000000
--- a/saas/test/fixtures/account_subscriptions.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-_fixture:
- model_class: Account::Subscription
-
-signals_monthly:
- id: <%= ActiveRecord::FixtureSet.identify("signals_monthly", :uuid) %>
- account_id: <%= ActiveRecord::FixtureSet.identify("37s", :uuid) %>
- plan_key: monthly_v1
- status: active
- stripe_customer_id: cus_test_37signals
- created_at: <%= Time.current %>
- updated_at: <%= Time.current %>
diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb
deleted file mode 100644
index 0e70d37a5..000000000
--- a/saas/test/models/account/billing_test.rb
+++ /dev/null
@@ -1,93 +0,0 @@
-require "test_helper"
-
-class Account::BillingTest < ActiveSupport::TestCase
- test "plan reflects active subscription" do
- account = accounts(:initech)
-
- # No subscription
- assert_equal Plan.free, account.plan
-
- # Subscription but it is not active
- account.create_subscription!(plan_key: "monthly_v1", status: "canceled", stripe_customer_id: "cus_test")
- assert_equal Plan.free, account.plan
-
- # Active subscription exists
- account.subscription.update!(status: "active")
- assert_equal Plan.paid, account.plan
- end
-
- test "comped account" do
- account = accounts(:"37s")
-
- assert_not account.comped?
-
- account.comp
- assert account.comped?
-
- # Calling comp again does not create duplicate
- 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
-
- test "owner_email_changed enqueues sync job when subscription exists" do
- account = accounts(:"37s")
- account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- assert_enqueued_with(job: Account::SyncStripeCustomerEmailJob, args: [ account.subscription ]) do
- account.owner_email_changed
- end
- end
-
- test "owner_email_changed does nothing without subscription" do
- account = accounts(:initech)
- account.subscription&.destroy
-
- assert_no_enqueued_jobs do
- account.owner_email_changed
- end
- end
-end
diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb
deleted file mode 100644
index fff154451..000000000
--- a/saas/test/models/account/limited_test.rb
+++ /dev/null
@@ -1,122 +0,0 @@
-require "test_helper"
-
-class Account::LimitedTest < ActiveSupport::TestCase
- test "detect nearing card limit" do
- # Paid plans are never limited
- accounts(:"37s").update_column(:cards_count, 1_000_000)
- assert_not accounts(:"37s").nearing_plan_cards_limit?
-
- # Free plan not near limit
- accounts(:initech).update_column(:cards_count, 899)
- assert_not accounts(:initech).nearing_plan_cards_limit?
-
- # Free plan at exactly the threshold (remaining = 100)
- accounts(:initech).update_column(:cards_count, 900)
- assert accounts(:initech).nearing_plan_cards_limit?
-
- # Free plan over the threshold
- accounts(:initech).update_column(:cards_count, 901)
- assert accounts(:initech).nearing_plan_cards_limit?
- end
-
- test "detect exceeding card limit" do
- # Paid plans are never limited
- accounts(:"37s").update_column(:cards_count, 1_000_000)
- assert_not accounts(:"37s").exceeding_card_limit?
-
- # Free plan under limit
- accounts(:initech).update_column(:cards_count, 999)
- assert_not accounts(:initech).exceeding_card_limit?
-
- # Free plan at exactly the limit
- accounts(:initech).update_column(:cards_count, 1000)
- assert accounts(:initech).exceeding_card_limit?
-
- # Free plan over limit
- accounts(:initech).update_column(:cards_count, 1001)
- assert accounts(:initech).exceeding_card_limit?
- end
-
- test "override limits" do
- account = accounts(:initech)
- account.update_column(:cards_count, 1001)
-
- assert account.exceeding_card_limit?
- assert_equal 1001, account.billed_cards_count
-
- account.override_limits card_count: 500
- assert_not account.exceeding_card_limit?
- assert_equal 500, account.billed_cards_count
- assert_equal 1001, account.cards_count # original unchanged
-
- account.reset_overridden_limits
- assert account.exceeding_card_limit?
- assert_equal 1001, account.billed_cards_count
- end
-
- test "comped accounts are never limited" do
- account = accounts(:initech)
- account.update_column(:cards_count, 1_000_000)
-
- assert account.exceeding_card_limit?
- assert account.nearing_plan_cards_limit?
-
- account.comp
-
- assert_not account.exceeding_card_limit?
- assert_not account.nearing_plan_cards_limit?
- end
-
- test "uncomping an account restores limits" do
- account = accounts(:initech)
- account.update_column(:cards_count, 1_000_000)
- account.comp
-
- assert_not account.exceeding_card_limit?
-
- account.uncomp
-
- assert account.exceeding_card_limit?
- end
-
- test "detect nearing storage limit" do
- # Paid plans have large storage limits
- accounts(:"37s").stubs(:bytes_used).returns(4.gigabytes)
- assert_not accounts(:"37s").nearing_plan_storage_limit?
-
- # Free plan not near limit
- accounts(:initech).stubs(:bytes_used).returns(400.megabytes)
- assert_not accounts(:initech).nearing_plan_storage_limit?
-
- # Free plan near limit
- accounts(:initech).stubs(:bytes_used).returns(600.megabytes)
- assert accounts(:initech).nearing_plan_storage_limit?
- end
-
- test "detect exceeding storage limit" do
- # Free plan under limit
- accounts(:initech).stubs(:bytes_used).returns(900.megabytes)
- assert_not accounts(:initech).exceeding_storage_limit?
-
- # Free plan over limit
- accounts(:initech).stubs(:bytes_used).returns(1.1.gigabytes)
- assert accounts(:initech).exceeding_storage_limit?
- end
-
- test "override bytes_used limits" do
- account = accounts(:initech)
- account.stubs(:bytes_used).returns(1.1.gigabytes)
-
- assert account.exceeding_storage_limit?
- assert_equal 1.1.gigabytes, account.billed_bytes_used
-
- account.override_limits bytes_used: 500.megabytes
- assert_not account.exceeding_storage_limit?
- assert_equal 500.megabytes, account.billed_bytes_used
- assert_equal 1.1.gigabytes, account.bytes_used # original unchanged
-
- account.reset_overridden_limits
- assert account.exceeding_storage_limit?
- assert_equal 1.1.gigabytes, account.billed_bytes_used
- end
-end
diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb
deleted file mode 100644
index f9f16f2bb..000000000
--- a/saas/test/models/account/subscription_test.rb
+++ /dev/null
@@ -1,245 +0,0 @@
-require "test_helper"
-
-class Account::SubscriptionTest < ActiveSupport::TestCase
- test "get the account plan" do
- subscription = Account::Subscription.new(plan_key: "free_v1")
- assert_equal Plan[:free_v1], subscription.plan
- end
-
- test "check if account is active" do
- subscription = Account::Subscription.new(status: "active")
- assert subscription.active?
- end
-
- test "check if account is paid" do
- 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 "paid scope includes active paid subscriptions" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "active",
- stripe_customer_id: "cus_paid"
- )
-
- assert_includes Account::Subscription.paid, subscription
- end
-
- test "paid scope includes trialing and past_due subscriptions" do
- trialing = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "trialing",
- stripe_customer_id: "cus_trialing"
- )
- past_due = Account::Subscription.create!(
- account: accounts(:acme), plan_key: "monthly_extra_storage_v1", status: "past_due",
- stripe_customer_id: "cus_past_due"
- )
-
- assert_includes Account::Subscription.paid, trialing
- assert_includes Account::Subscription.paid, past_due
- end
-
- test "paid scope excludes free plan subscriptions" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "free_v1", status: "active",
- stripe_customer_id: "cus_free"
- )
-
- assert_not_includes Account::Subscription.paid, subscription
- end
-
- test "paid scope excludes canceled subscriptions" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "canceled",
- stripe_customer_id: "cus_canceled"
- )
-
- assert_not_includes Account::Subscription.paid, subscription
- end
-
- test "paid scope excludes comped accounts" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "active",
- stripe_customer_id: "cus_comped"
- )
- Account::BillingWaiver.create!(account: accounts(:initech))
-
- assert_not_includes Account::Subscription.paid, subscription
- 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
-
- test "sync_customer_email_to_stripe updates Stripe customer with owner email" do
- account = accounts(:"37s")
- owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).with("cus_test", email: owner.identity.email_address).once
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe does nothing without stripe_customer_id" do
- account = accounts(:"37s")
- subscription = account.build_subscription(
- stripe_customer_id: nil,
- plan_key: "free_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).never
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe does nothing without owner" do
- account = accounts(:"37s")
- account.users.update_all(role: :member)
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).never
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe does nothing when owner has no identity" do
- account = accounts(:"37s")
- owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
- owner.update_column(:identity_id, nil)
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).never
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe treats deleted customer as success" do
- account = accounts(:"37s")
- account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_deleted",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.stubs(:update).raises(
- Stripe::InvalidRequestError.new("No such customer", {})
- )
-
- assert_nothing_raised do
- subscription.sync_customer_email_to_stripe
- end
- end
-end
diff --git a/saas/test/models/plan_test.rb b/saas/test/models/plan_test.rb
deleted file mode 100644
index 54ed279d6..000000000
--- a/saas/test/models/plan_test.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-require "test_helper"
-
-class PlanTest < ActiveSupport::TestCase
- test "free plan is free" do
- assert Plan[:free_v1].free?
- end
-
- test "monthly plan is not free" do
- assert_not Plan[:monthly_v1].free?
- end
-
- test "find plan by its price id" do
- Plan.paid.stubs(:stripe_price_id).returns("price_monthly_v1")
-
- assert_equal Plan.paid, Plan.find_by_price_id("price_monthly_v1")
- assert_nil Plan.find_by_price_id("unknown_price_id")
- end
-end
diff --git a/saas/test/models/user/notifies_account_of_email_change_test.rb b/saas/test/models/user/notifies_account_of_email_change_test.rb
deleted file mode 100644
index 442a89fff..000000000
--- a/saas/test/models/user/notifies_account_of_email_change_test.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-require "test_helper"
-
-class User::NotifiesAccountOfEmailChangeTest < ActiveSupport::TestCase
- setup do
- @account = accounts(:"37s")
- @owner = @account.users.find_by(role: :owner) || @account.users.first.tap { |u| u.update!(role: :owner) }
- @member = @account.users.where.not(id: @owner.id).first || @account.users.create!(
- name: "Member",
- identity: Identity.create!(email_address: "member@example.com"),
- role: :member
- )
- end
-
- test "notifies account when owner changes email" do
- @account.expects(:owner_email_changed).once
-
- new_identity = Identity.create!(email_address: "new-owner@example.com")
- @owner.update!(identity: new_identity)
- end
-
- test "does not notify account when non-owner changes email" do
- @account.expects(:owner_email_changed).never
-
- new_identity = Identity.create!(email_address: "new-member@example.com")
- @member.update!(identity: new_identity)
- end
-
- test "does not notify account when owner is deactivated" do
- @account.expects(:owner_email_changed).never
-
- @owner.update!(identity: nil)
- end
-
- test "does not notify account when identity unchanged" do
- @account.expects(:owner_email_changed).never
-
- @owner.update!(name: "New Name")
- end
-
- test "notifies account when user becomes owner" do
- @account.expects(:owner_email_changed).once
-
- @member.update!(role: :owner)
- end
-
- test "does not notify account when owner becomes member" do
- @account.expects(:owner_email_changed).never
-
- @owner.update!(role: :member)
- end
-end