From 217f9a0de030c38991626e78a8f18ab9af22ea0f Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 9 Mar 2026 15:39:41 +0100 Subject: [PATCH 001/107] Remove colored container around card in perma --- app/assets/stylesheets/native.css | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index 591ce8890..064db7527 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -59,16 +59,26 @@ /* ------------------------------------------------------------------------ */ .card-perma { - margin-block-start: 0; + margin-block-start: var(--block-space-half); &:not(:has(.card-perma__notch-new-card-buttons)) .card-perma__bg { - padding-block: clamp(0.25rem, 2vw, var(--padding-block)); + padding-block-end: clamp(0.25rem, 2vw, var(--padding-block)); + } + + .card { + background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color)); + box-shadow: unset; + } + + .card__board { + border-radius: 0 var(--border-radius) var(--border-radius) 0; } } .card-perma__bg { - border-start-start-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block))); - border-start-end-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block))); + padding-inline: 0; + padding-block-start: 0; + background-color: unset; } .card-perma__closure-message { @@ -77,21 +87,10 @@ } .card-perma--draft { - .card-perma__bg { - padding-inline: 0; - padding-block-start: 0; - background-color: unset; - } - .card { - background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color)); box-shadow: 0 101vh 0 100vh var(--card-bg-color); } - .card__board { - border-radius: 0 var(--border-radius) var(--border-radius) 0; - } - .card-perma__notch--bottom { z-index: 1; } From a892d52ee09f955562fd0390b7673964adb4d0ba Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 9 Mar 2026 15:40:30 +0100 Subject: [PATCH 002/107] Move stamp back in the footer by default (narrow viewport) --- app/assets/stylesheets/card-perma.css | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 3dc16d5a2..755dd8f9a 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -34,16 +34,6 @@ } } - @media (max-width: 639px) { - &:has(.card__closed) { - padding-block-start: 4.5rem; - - .card-perma__bg { - box-shadow: 0 -2rem 0 0 var(--color-container); - } - } - } - @media (max-width: 799px) { --half-btn-height: 1.25rem; --padding-inline: 1.5ch; @@ -225,8 +215,7 @@ .card__closed { @media (max-width: 639px) { - inset: auto 0 100% auto; - translate: 0 30%; + inset: auto 0 3rem auto; scale: 75%; } } From 078597f132b0648235d106b055b21f3706e12b13 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 9 Mar 2026 15:40:45 +0100 Subject: [PATCH 003/107] Add bridge component --- .../controllers/bridge/stamp_controller.js | 20 +++++++++++++++++++ .../initializers/bridge/bridge_element.js | 16 +++++++++++++++ .../cards/display/common/_stamp.html.erb | 18 +++++++++++++---- 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 app/javascript/controllers/bridge/stamp_controller.js diff --git a/app/javascript/controllers/bridge/stamp_controller.js b/app/javascript/controllers/bridge/stamp_controller.js new file mode 100644 index 000000000..42810d49a --- /dev/null +++ b/app/javascript/controllers/bridge/stamp_controller.js @@ -0,0 +1,20 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "stamp" + + connect() { + super.connect() + this.notifyBridgeOfConnect() + } + + disconnect() { + super.disconnect() + this.send("disconnect") + } + + notifyBridgeOfConnect() { + console.log(this.bridgeElement.getStamp()) + this.send("connect", this.bridgeElement.getStamp()) + } +} diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index 438cf22e1..954a8f652 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -9,6 +9,14 @@ BridgeElement.prototype.getButton = function() { } } +BridgeElement.prototype.getStamp = function() { + return { + title: this.title, + date: this.getDate(), + by: this.getBy() + } +} + BridgeElement.prototype.getIcon = function() { const url = this.bridgeAttribute(`icon-url`) @@ -19,6 +27,14 @@ BridgeElement.prototype.getIcon = function() { return null } +BridgeElement.prototype.getDate = function() { + return this.bridgeAttribute(`date`) ?? null +} + +BridgeElement.prototype.getBy = function() { + return this.bridgeAttribute(`by`) ?? null +} + BridgeElement.prototype.getDisplayTitle = function() { return !!this.bridgeAttribute(`display-title`) } diff --git a/app/views/cards/display/common/_stamp.html.erb b/app/views/cards/display/common/_stamp.html.erb index 629e4cf24..e8f7e6148 100644 --- a/app/views/cards/display/common/_stamp.html.erb +++ b/app/views/cards/display/common/_stamp.html.erb @@ -1,15 +1,25 @@ <% if card.postponed? %> -
"> + <%= tag.div class: token_list("card__closed", "card__closed--system": card.postponed_by&.system?), data: { + controller: "bridge--stamp", + bridge_title: "Not Now", + bridge_date: card.postponed_at.strftime("%b %d, %Y"), + bridge_by: card.postponed_by&.familiar_name + } do %> Not Now <%= card.postponed_at.strftime("%b %d, %Y") %> -
+ <% end %> <% end %> <% if card.closed? %> -
+ <%= tag.div class: "card__closed", data: { + controller: "bridge--stamp", + bridge_title: "Done", + bridge_date: card.closed_at.strftime("%b %d, %Y"), + bridge_by: card.closed_by.familiar_name + } do %> Done <%= card.closed_at.strftime("%b %d, %Y") %> -
+ <% end %> <% end %> From 38ecacc2b037b46cd8b3eb94186c12fbe939ade3 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 9 Mar 2026 15:43:05 +0100 Subject: [PATCH 004/107] Hide web stamp --- app/assets/stylesheets/native.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index 064db7527..e636eae14 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -130,3 +130,9 @@ display: none; } } + +[data-bridge-components~=stamp] { + [data-controller~=bridge--stamp] { + display: none; + } +} From d746b86e700c4f41e39533e9f890f32ae99f83fd Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 9 Mar 2026 15:43:58 +0100 Subject: [PATCH 005/107] Remove debug line --- app/javascript/controllers/bridge/stamp_controller.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/javascript/controllers/bridge/stamp_controller.js b/app/javascript/controllers/bridge/stamp_controller.js index 42810d49a..6c04d3140 100644 --- a/app/javascript/controllers/bridge/stamp_controller.js +++ b/app/javascript/controllers/bridge/stamp_controller.js @@ -14,7 +14,6 @@ export default class extends BridgeComponent { } notifyBridgeOfConnect() { - console.log(this.bridgeElement.getStamp()) this.send("connect", this.bridgeElement.getStamp()) } } From 5a9fd6b975d953f93b6f78c14739258689c52daf Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 9 Mar 2026 16:38:22 +0100 Subject: [PATCH 006/107] Coding style --- app/javascript/initializers/bridge/bridge_element.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index 954a8f652..6692a8a22 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -18,7 +18,7 @@ BridgeElement.prototype.getStamp = function() { } BridgeElement.prototype.getIcon = function() { - const url = this.bridgeAttribute(`icon-url`) + const url = this.bridgeAttribute("icon-url") if (url) { return { url } @@ -28,17 +28,17 @@ BridgeElement.prototype.getIcon = function() { } BridgeElement.prototype.getDate = function() { - return this.bridgeAttribute(`date`) ?? null + return this.bridgeAttribute("date") ?? null } BridgeElement.prototype.getBy = function() { - return this.bridgeAttribute(`by`) ?? null + return this.bridgeAttribute("by") ?? null } BridgeElement.prototype.getDisplayTitle = function() { - return !!this.bridgeAttribute(`display-title`) + return !!this.bridgeAttribute("display-title") } BridgeElement.prototype.getDisplayAsPrimaryAction = function() { - return !!this.bridgeAttribute(`display-as-primary-action`) + return !!this.bridgeAttribute("display-as-primary-action") } From 7974629e20e0f4721bb3dae11ac66ca90228505f Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 9 Mar 2026 16:42:59 +0100 Subject: [PATCH 007/107] Rename attribute --- app/javascript/initializers/bridge/bridge_element.js | 6 +++--- app/views/cards/display/common/_stamp.html.erb | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index 6692a8a22..b118c9265 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -13,7 +13,7 @@ BridgeElement.prototype.getStamp = function() { return { title: this.title, date: this.getDate(), - by: this.getBy() + closedBy: this.getClosedBy() } } @@ -31,8 +31,8 @@ BridgeElement.prototype.getDate = function() { return this.bridgeAttribute("date") ?? null } -BridgeElement.prototype.getBy = function() { - return this.bridgeAttribute("by") ?? null +BridgeElement.prototype.getClosedBy = function() { + return this.bridgeAttribute("closed-by") ?? null } BridgeElement.prototype.getDisplayTitle = function() { diff --git a/app/views/cards/display/common/_stamp.html.erb b/app/views/cards/display/common/_stamp.html.erb index e8f7e6148..75c11ad4a 100644 --- a/app/views/cards/display/common/_stamp.html.erb +++ b/app/views/cards/display/common/_stamp.html.erb @@ -3,7 +3,7 @@ controller: "bridge--stamp", bridge_title: "Not Now", bridge_date: card.postponed_at.strftime("%b %d, %Y"), - bridge_by: card.postponed_by&.familiar_name + bridge_closed_by: card.postponed_by&.familiar_name } do %> Not Now <%= card.postponed_at.strftime("%b %d, %Y") %> @@ -16,7 +16,7 @@ controller: "bridge--stamp", bridge_title: "Done", bridge_date: card.closed_at.strftime("%b %d, %Y"), - bridge_by: card.closed_by.familiar_name + bridge_closed_by: card.closed_by.familiar_name } do %> Done <%= card.closed_at.strftime("%b %d, %Y") %> 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 008/107] 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 009/107] 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 010/107] 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 011/107] 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 012/107] 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 013/107] 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 014/107] 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| %>