From ea697b71433818dd0313bd25537661a8ab7f2c50 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 1 Dec 2025 14:01:34 +0100 Subject: [PATCH 01/78] Add API to boards --- app/controllers/boards_controller.rb | 6 +++++- app/views/boards/_board.json.jbuilder | 1 + app/views/boards/index.json.jbuilder | 1 + app/views/boards/show.json.jbuilder | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 app/views/boards/index.json.jbuilder create mode 100644 app/views/boards/show.json.jbuilder diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index ff7c35ec0..d37a5358e 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -1,9 +1,13 @@ class BoardsController < ApplicationController include FilterScoped - before_action :set_board, except: %i[ new create ] + before_action :set_board, except: %i[ index new create ] before_action :ensure_permission_to_admin_board, only: %i[ update destroy ] + def index + @boards = Current.user.boards + end + def show if @filter.used?(ignore_boards: true) show_filtered_cards diff --git a/app/views/boards/_board.json.jbuilder b/app/views/boards/_board.json.jbuilder index ce2ef0451..7723f6aae 100644 --- a/app/views/boards/_board.json.jbuilder +++ b/app/views/boards/_board.json.jbuilder @@ -1,6 +1,7 @@ json.cache! board do json.(board, :id, :name, :all_access) json.created_at board.created_at.utc + json.url board_url(board) json.creator do json.partial! "users/user", user: board.creator diff --git a/app/views/boards/index.json.jbuilder b/app/views/boards/index.json.jbuilder new file mode 100644 index 000000000..fe042c91c --- /dev/null +++ b/app/views/boards/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @boards, partial: "boards/board", as: :board diff --git a/app/views/boards/show.json.jbuilder b/app/views/boards/show.json.jbuilder new file mode 100644 index 000000000..a6916c467 --- /dev/null +++ b/app/views/boards/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "boards/board", board: @board From a81c681a8df2ab3e9b6c88d9b00dfb1f556eaa2d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 1 Dec 2025 16:24:18 +0100 Subject: [PATCH 02/78] Add access token authentication via HTTP AUTHORIZATION bearer header --- app/controllers/concerns/authentication.rb | 28 +++++++++++++++---- app/models/identity.rb | 1 + app/models/identity/access_token.rb | 17 +++++++++++ ...201132341_create_identity_access_tokens.rb | 14 ++++++++++ db/schema.rb | 10 +++++++ db/schema_sqlite.rb | 10 +++++++ test/controllers/api_test.rb | 18 ++++++++++++ test/fixtures/identity/access_tokens.yml | 5 ++++ test/models/identity/access_token_test.rb | 13 +++++++++ 9 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 app/models/identity/access_token.rb create mode 100644 db/migrate/20251201132341_create_identity_access_tokens.rb create mode 100644 test/controllers/api_test.rb create mode 100644 test/fixtures/identity/access_tokens.yml create mode 100644 test/models/identity/access_token_test.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 4cdbbdb69..b01bc8c0d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -46,7 +46,7 @@ module Authentication end def resume_session - if session = find_session_by_cookie + if session = find_session_by_cookie || find_or_start_session_by_bearer_token set_current_session session end end @@ -55,12 +55,30 @@ module Authentication Session.find_signed(cookies.signed[:session_token]) end - def request_authentication - if Current.account.present? - session[:return_to_after_authenticating] = request.url + def find_or_start_session_by_bearer_token + if request_authorized_by_bearer_token? + Identity::AccessToken.find_by(token: authorization_bearer_token)&.session end + end - redirect_to_login_url + def request_authorized_by_bearer_token? + request.authorization.to_s.starts_with? "Bearer" + end + + def authorization_bearer_token + request.authorization.to_s.split(" ", 2).second + end + + def request_authentication + if request_authorized_by_bearer_token? + head :unauthorized + else + if Current.account.present? + session[:return_to_after_authenticating] = request.url + end + + redirect_to_login_url + end end def after_authentication_url diff --git a/app/models/identity.rb b/app/models/identity.rb index bb69734b4..f6dd7d433 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,6 +1,7 @@ class Identity < ApplicationRecord include Joinable, Transferable + has_many :access_tokens, dependent: :destroy has_many :magic_links, dependent: :destroy has_many :sessions, dependent: :destroy has_many :users, dependent: :nullify diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb new file mode 100644 index 000000000..c5e3d6705 --- /dev/null +++ b/app/models/identity/access_token.rb @@ -0,0 +1,17 @@ +class Identity::AccessToken < ApplicationRecord + belongs_to :identity + + has_secure_token + enum :permission, %w[ read write ].index_by(&:itself), default: :read + + def session + identity.sessions.find_or_create_by! user_agent: session_user_agent + end + + private + # Overload the user_agent identification for access token session reuse. + # This allows us to easily reuse a single session record per access token. + def session_user_agent + "access-token-#{id}" + end +end diff --git a/db/migrate/20251201132341_create_identity_access_tokens.rb b/db/migrate/20251201132341_create_identity_access_tokens.rb new file mode 100644 index 000000000..0e455104d --- /dev/null +++ b/db/migrate/20251201132341_create_identity_access_tokens.rb @@ -0,0 +1,14 @@ +class CreateIdentityAccessTokens < ActiveRecord::Migration[8.2] + def change + create_table :identity_access_tokens, id: :uuid do |t| + t.uuid :identity_id, null: false + t.string :token + t.string :permission + t.text :description + + t.timestamps + + t.index ["identity_id"], name: "index_access_token_on_identity_id" + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 42458db17..85a8f8064 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -321,6 +321,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.index ["email_address"], name: "index_identities_on_email_address", unique: true end + create_table "identity_access_tokens", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.uuid "identity_id", null: false + t.string "permission" + t.string "token" + t.datetime "updated_at", null: false + t.index ["identity_id"], name: "index_access_token_on_identity_id" + end + create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "code", null: false t.datetime "created_at", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 1dd2b3e00..e2f65dccc 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -321,6 +321,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.index ["email_address"], name: "index_identities_on_email_address", unique: true end + create_table "identity_access_tokens", id: :uuid, force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description", limit: 65535 + t.uuid "identity_id", null: false + t.string "permission", limit: 255 + t.string "token", limit: 255 + t.datetime "updated_at", null: false + t.index ["identity_id"], name: "index_access_token_on_identity_id" + end + create_table "magic_links", id: :uuid, force: :cascade do |t| t.string "code", limit: 255, null: false t.datetime "created_at", null: false diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb new file mode 100644 index 000000000..51d0367f2 --- /dev/null +++ b/test/controllers/api_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class ApiTest < ActionDispatch::IntegrationTest + test "authenticate with valid access token" do + get boards_path(format: :json), env: bearer_token_env(identity_access_tokens(:jasons_api_token).token) + assert_response :success + end + + test "fail to authenticate with invalid access token" do + get boards_path(format: :json), env: bearer_token_env("nonsense") + assert_response :unauthorized + end + + private + def bearer_token_env(token) + { "HTTP_AUTHORIZATION" => "Bearer #{token}" } + end +end diff --git a/test/fixtures/identity/access_tokens.yml b/test/fixtures/identity/access_tokens.yml new file mode 100644 index 000000000..17089b6c9 --- /dev/null +++ b/test/fixtures/identity/access_tokens.yml @@ -0,0 +1,5 @@ +jasons_api_token: + identity: jason + token: 018cf1425682700098f24f0799e3fe20 + description: My Superscript + permission: read diff --git a/test/models/identity/access_token_test.rb b/test/models/identity/access_token_test.rb new file mode 100644 index 000000000..49f1c57ce --- /dev/null +++ b/test/models/identity/access_token_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class Identity::AccessTokenTest < ActiveSupport::TestCase + test "only one session at the time" do + assert_changes -> { Session.count }, +1 do + identity_access_tokens(:jasons_api_token).session + end + + assert_no_changes -> { Session.count } do + identity_access_tokens(:jasons_api_token).session + end + end +end From d384971ea266a634debc397720e52a7d0f26328d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 1 Dec 2025 16:50:33 +0100 Subject: [PATCH 03/78] Test the boards API --- test/controllers/api_test.rb | 14 +++++++++++++- test/fixtures/identity/access_tokens.yml | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index 51d0367f2..162f9d86a 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -1,8 +1,12 @@ require "test_helper" class ApiTest < ActionDispatch::IntegrationTest + setup do + @davids_bearer_token = bearer_token_env(identity_access_tokens(:davids_api_token).token) + end + test "authenticate with valid access token" do - get boards_path(format: :json), env: bearer_token_env(identity_access_tokens(:jasons_api_token).token) + get boards_path(format: :json), env: @davids_bearer_token assert_response :success end @@ -11,6 +15,14 @@ class ApiTest < ActionDispatch::IntegrationTest assert_response :unauthorized end + test "boards" do + get boards_path(format: :json), env: @davids_bearer_token + assert_equal users(:david).boards.count, @response.parsed_body.count + + get board_path(boards(:writebook), format: :json), env: @jasons_bearer_token + assert_equal boards(:writebook).name, @response.parsed_body["name"] + end + private def bearer_token_env(token) { "HTTP_AUTHORIZATION" => "Bearer #{token}" } diff --git a/test/fixtures/identity/access_tokens.yml b/test/fixtures/identity/access_tokens.yml index 17089b6c9..cfbbb6248 100644 --- a/test/fixtures/identity/access_tokens.yml +++ b/test/fixtures/identity/access_tokens.yml @@ -3,3 +3,9 @@ jasons_api_token: token: 018cf1425682700098f24f0799e3fe20 description: My Superscript permission: read + +davids_api_token: + identity: david + token: x18cf1425682700098f24f0799e3fe20 + description: My Superscript + permission: write From c65cb77ac4c52eb4165c9e27d189d81d462e7d2d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 1 Dec 2025 17:20:03 +0100 Subject: [PATCH 04/78] API index for cards --- app/views/cards/index.json.jbuilder | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/views/cards/index.json.jbuilder diff --git a/app/views/cards/index.json.jbuilder b/app/views/cards/index.json.jbuilder new file mode 100644 index 000000000..f3f204632 --- /dev/null +++ b/app/views/cards/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @page.records, partial: "cards/card", as: :card + +json.next_page_url cards_path(@board, page: @page.next_param) unless @page.last? From b53c0a5385441f516ef13b35068058387537d3a9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 1 Dec 2025 17:20:12 +0100 Subject: [PATCH 05/78] Correct --- test/controllers/api_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index 162f9d86a..7eff2d7bb 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -19,7 +19,7 @@ class ApiTest < ActionDispatch::IntegrationTest get boards_path(format: :json), env: @davids_bearer_token assert_equal users(:david).boards.count, @response.parsed_body.count - get board_path(boards(:writebook), format: :json), env: @jasons_bearer_token + get board_path(boards(:writebook), format: :json), env: @davids_bearer_token assert_equal boards(:writebook).name, @response.parsed_body["name"] end From db29562c4c5baa00989e611dadfbcf4bb18f43bd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 1 Dec 2025 18:07:17 +0100 Subject: [PATCH 06/78] Tie access token directly to session We need to present them differently in the session list and prevent them from being deleted --- app/controllers/concerns/authentication.rb | 4 ++-- app/models/identity/access_token.rb | 11 ++++------- .../20251201132341_create_identity_access_tokens.rb | 1 + db/schema.rb | 1 + db/schema_sqlite.rb | 1 + test/fixtures/identity/access_tokens.yml | 2 ++ test/fixtures/sessions.yml | 6 ++++++ test/models/identity/access_token_test.rb | 11 +++-------- 8 files changed, 20 insertions(+), 17 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index b01bc8c0d..58d101bc2 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -46,7 +46,7 @@ module Authentication end def resume_session - if session = find_session_by_cookie || find_or_start_session_by_bearer_token + if session = find_session_by_cookie || find_session_by_bearer_token set_current_session session end end @@ -55,7 +55,7 @@ module Authentication Session.find_signed(cookies.signed[:session_token]) end - def find_or_start_session_by_bearer_token + def find_session_by_bearer_token if request_authorized_by_bearer_token? Identity::AccessToken.find_by(token: authorization_bearer_token)&.session end diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index c5e3d6705..1702de78a 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -1,17 +1,14 @@ class Identity::AccessToken < ApplicationRecord belongs_to :identity + belongs_to :session has_secure_token enum :permission, %w[ read write ].index_by(&:itself), default: :read - def session - identity.sessions.find_or_create_by! user_agent: session_user_agent - end + before_validation :build_session, on: :create private - # Overload the user_agent identification for access token session reuse. - # This allows us to easily reuse a single session record per access token. - def session_user_agent - "access-token-#{id}" + def build_session + self.session = identity.sessions.build(user_agent: "Access Token") end end diff --git a/db/migrate/20251201132341_create_identity_access_tokens.rb b/db/migrate/20251201132341_create_identity_access_tokens.rb index 0e455104d..2b7648bf0 100644 --- a/db/migrate/20251201132341_create_identity_access_tokens.rb +++ b/db/migrate/20251201132341_create_identity_access_tokens.rb @@ -2,6 +2,7 @@ class CreateIdentityAccessTokens < ActiveRecord::Migration[8.2] def change create_table :identity_access_tokens, id: :uuid do |t| t.uuid :identity_id, null: false + t.uuid :session_id, null: false t.string :token t.string :permission t.text :description diff --git a/db/schema.rb b/db/schema.rb index 85a8f8064..9fe6d65a6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -326,6 +326,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.text "description" t.uuid "identity_id", null: false t.string "permission" + t.uuid "session_id", null: false t.string "token" t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index e2f65dccc..8121ea7ef 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -326,6 +326,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.text "description", limit: 65535 t.uuid "identity_id", null: false t.string "permission", limit: 255 + t.uuid "session_id", null: false t.string "token", limit: 255 t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" diff --git a/test/fixtures/identity/access_tokens.yml b/test/fixtures/identity/access_tokens.yml index cfbbb6248..83e3d5542 100644 --- a/test/fixtures/identity/access_tokens.yml +++ b/test/fixtures/identity/access_tokens.yml @@ -1,11 +1,13 @@ jasons_api_token: identity: jason + session: jason_api token: 018cf1425682700098f24f0799e3fe20 description: My Superscript permission: read davids_api_token: identity: david + session: david_api token: x18cf1425682700098f24f0799e3fe20 description: My Superscript permission: write diff --git a/test/fixtures/sessions.yml b/test/fixtures/sessions.yml index 56fc614a5..56b6fb7c7 100644 --- a/test/fixtures/sessions.yml +++ b/test/fixtures/sessions.yml @@ -1,6 +1,9 @@ david: identity: david +david_api: + identity: david + kevin: identity: kevin @@ -12,3 +15,6 @@ jason: mike: identity: mike + +jason_api: + identity: jason diff --git a/test/models/identity/access_token_test.rb b/test/models/identity/access_token_test.rb index 49f1c57ce..daa236b49 100644 --- a/test/models/identity/access_token_test.rb +++ b/test/models/identity/access_token_test.rb @@ -1,13 +1,8 @@ require "test_helper" class Identity::AccessTokenTest < ActiveSupport::TestCase - test "only one session at the time" do - assert_changes -> { Session.count }, +1 do - identity_access_tokens(:jasons_api_token).session - end - - assert_no_changes -> { Session.count } do - identity_access_tokens(:jasons_api_token).session - end + test "new access token comes with a session" do + access_token = identities(:david).access_tokens.create! + assert_equal "Access Token", identities(:david).sessions.last.user_agent end end From 48c83f34b0d00c20cd1a9ff7f5df0257d217bd72 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 1 Dec 2025 21:53:23 -0600 Subject: [PATCH 07/78] Add developer section to user profile --- app/views/users/_developer.html.erb | 9 +++++++++ app/views/users/_transfer.html.erb | 16 ++++++++-------- app/views/users/show.html.erb | 7 ++++--- 3 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 app/views/users/_developer.html.erb diff --git a/app/views/users/_developer.html.erb b/app/views/users/_developer.html.erb new file mode 100644 index 000000000..eb2ae4241 --- /dev/null +++ b/app/views/users/_developer.html.erb @@ -0,0 +1,9 @@ +
+
+

Developer

+
+ +
+ <%= link_to "Personal access tokens", user_access_tokens_path(user), class: "btn" %> +
+
diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb index 8befea9ce..12b5888d1 100644 --- a/app/views/users/_transfer.html.erb +++ b/app/views/users/_transfer.html.erb @@ -1,13 +1,13 @@ -
+
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %> +
+

Link a device

+

Use this link to sign-in on another device

+
+
@@ -34,4 +34,4 @@ Copy auto-login link <% end %>
-
+
\ No newline at end of file diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 17f418e84..d0a251f4f 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -32,9 +32,9 @@ <% if @user.verified? %>
<%= link_to "Which cards are assigned to #{me_or_you}?", - cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %> + cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %> <%= link_to "Which cards were added by #{me_or_you}?", - cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %> + cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %>
<% end %> @@ -43,10 +43,11 @@ <% if Current.user == @user %>
<%= render "users/transfer", user: @user %> + <%= render "users/developer", user: @user %>
<%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %> - Sign out + Sign out of Fizzy <% end %>
From 073d1af862749562a794f81a46ac783514ea3844 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 1 Dec 2025 21:54:06 -0600 Subject: [PATCH 08/78] List, create, and revoke access tokens --- app/assets/stylesheets/access-tokens.css | 19 ++++++++++ .../users/access_tokens_controller.rb | 35 ++++++++++++++++++ app/helpers/users_helper.rb | 4 ++ .../access_tokens/_access_token.html.erb | 13 +++++++ app/views/users/access_tokens/index.html.erb | 37 +++++++++++++++++++ app/views/users/access_tokens/new.html.erb | 29 +++++++++++++++ config/routes.rb | 1 + 7 files changed, 138 insertions(+) create mode 100644 app/assets/stylesheets/access-tokens.css create mode 100644 app/controllers/users/access_tokens_controller.rb create mode 100644 app/views/users/access_tokens/_access_token.html.erb create mode 100644 app/views/users/access_tokens/index.html.erb create mode 100644 app/views/users/access_tokens/new.html.erb diff --git a/app/assets/stylesheets/access-tokens.css b/app/assets/stylesheets/access-tokens.css new file mode 100644 index 000000000..a0f54f91c --- /dev/null +++ b/app/assets/stylesheets/access-tokens.css @@ -0,0 +1,19 @@ +.access_tokens_table { + border-collapse: collapse; + inline-size: 100%; + + td, th { + border-block-end: 1px solid var(--color-ink-light); + padding-inline: var(--inline-space); + text-align: start; + } + + th { + font-size: var(--text-x-small); + text-transform: uppercase; + } + + tr:nth-of-type(even) { + background-color: var(--color-ink-lightest); + } +} \ No newline at end of file diff --git a/app/controllers/users/access_tokens_controller.rb b/app/controllers/users/access_tokens_controller.rb new file mode 100644 index 000000000..5bc99e641 --- /dev/null +++ b/app/controllers/users/access_tokens_controller.rb @@ -0,0 +1,35 @@ +class Users::AccessTokensController < ApplicationController + before_action :set_user + before_action :set_access_token, except: %i[ index new create ] + + def index + set_page_and_extract_portion_from @user.identity.access_tokens.order(created_at: :desc) + end + + def new + @access_token = @user.identity.access_tokens.new + end + + def create + @access_token = @user.identity.access_tokens.create!(access_token_params) + redirect_to user_access_tokens_path(@user) + end + + def destroy + @access_token.destroy! + redirect_to user_access_tokens_path(@user) + end + + private + def set_user + @user = Current.account.users.active.find(params[:user_id]) + end + + def set_access_token + @access_token = @user.identity.access_tokens.find(params[:id]) + end + + def access_token_params + params.expect(access_token: [ :description, :permission ]) + end +end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 7cccfe4ec..bfef43f17 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -5,4 +5,8 @@ module UsersHelper else user.role.titleize end end + + def access_token_permission_options + Identity::AccessToken.permissions.keys.map { |it| [ it.humanize, it ] } + end end diff --git a/app/views/users/access_tokens/_access_token.html.erb b/app/views/users/access_tokens/_access_token.html.erb new file mode 100644 index 000000000..13025739b --- /dev/null +++ b/app/views/users/access_tokens/_access_token.html.erb @@ -0,0 +1,13 @@ + + <%= access_token.description %> + <%= access_token.permission.humanize %> + <%= local_datetime_tag access_token.created_at, style: :datetime %> + + <%= button_to user_access_token_path(@user, access_token), method: :delete, + class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent", + data: { turbo_confirm: "Are you sure you want to permanently revoke this access token?" } do %> + <%= icon_tag "trash" %> + Edit this token + <% end %> + + diff --git a/app/views/users/access_tokens/index.html.erb b/app/views/users/access_tokens/index.html.erb new file mode 100644 index 000000000..528696590 --- /dev/null +++ b/app/views/users/access_tokens/index.html.erb @@ -0,0 +1,37 @@ +<% @page_title = "Personal access tokens" %> + +<% content_for :header do %> +
+ <%= back_link_to "My profile", user_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <% if @page.used? %> +

Tokens you have generated that can be used to access the Fizzy API.

+ + + + + + + + + + + <%= with_automatic_pagination :access_tokens, @page do %> + <%= render partial: "users/access_tokens/access_token", collection: @page.records %> + <% end %> + +
DescriptionPermissionCreated
+ <% else %> +

Personal access tokens can be used like a password to access the Fizzy developer API. You can have as many tokens as you need and revoke access to each one at any time.

+ <% end %> + + <%= link_to new_user_access_token_path(@user), class: "btn btn--link" do %> + <%= icon_tag "add" %> + Generate a new access token + <% end %> +
diff --git a/app/views/users/access_tokens/new.html.erb b/app/views/users/access_tokens/new.html.erb new file mode 100644 index 000000000..78f36f20f --- /dev/null +++ b/app/views/users/access_tokens/new.html.erb @@ -0,0 +1,29 @@ +<% @page_title = "Generate a personal access token" %> + +<% content_for :header do %> +
+ <%= back_link_to "tokens", user_access_tokens_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <%= form_with model: @access_token, url: user_access_tokens_path(@user), scope: :access_token, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %> +
+ <%= form.label :description, "Access token description" %> + <%= form.text_field :description, required: true, autofocus: true, class: "input", placeholder: "e.g. Github", data: { action: "keydown.esc@document->form#cancel" } %> +
+ +
+ <%= form.label :permission %> + <%= form.select :permission, options_for_select(access_token_permission_options), {}, class: "input input--select" %> +
+ + <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> + Generate access token + <% end %> + + <%= link_to "Cancel and go back", user_access_tokens_path(@user), data: { form_target: "cancel" }, hidden: true %> + <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index e67e4ef7c..a66cda9c9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,6 +15,7 @@ Rails.application.routes.draw do resource :events resources :push_subscriptions + resources :access_tokens resources :email_addresses, param: :token do resource :confirmation, module: :email_addresses From 660fcff55850f98df349b3627a2eba44b7517d6c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 12:15:34 +0100 Subject: [PATCH 09/78] Authenticate api requests without needing a session --- app/controllers/concerns/authentication.rb | 12 +++++++----- app/models/current.rb | 10 +++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 58d101bc2..219ae1360 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -32,7 +32,7 @@ module Authentication private def authenticated? - Current.session.present? + Current.identity.present? end def require_account @@ -42,11 +42,11 @@ module Authentication end def require_authentication - resume_session || request_authentication + resume_session || authenticate_by_bearer_token || request_authentication end def resume_session - if session = find_session_by_cookie || find_session_by_bearer_token + if session = find_session_by_cookie set_current_session session end end @@ -55,9 +55,11 @@ module Authentication Session.find_signed(cookies.signed[:session_token]) end - def find_session_by_bearer_token + def authenticate_by_bearer_token if request_authorized_by_bearer_token? - Identity::AccessToken.find_by(token: authorization_bearer_token)&.session + if access_token = Identity::AccessToken.find_by(token: authorization_bearer_token) + Current.identity = access_token.identity + end end end diff --git a/app/models/current.rb b/app/models/current.rb index 47f2b6c21..34c79d7d9 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,5 +1,5 @@ class Current < ActiveSupport::CurrentAttributes - attribute :session, :user, :account + attribute :session, :user, :identity, :account attribute :http_method, :request_id, :user_agent, :ip_address, :referrer delegate :identity, to: :session, allow_nil: true @@ -8,6 +8,14 @@ class Current < ActiveSupport::CurrentAttributes super(value) if value.present? && account.present? + self.identity = session.identity + end + end + + def identity=(identity) + super(identity) + + if identity.present? self.user = identity.users.find_by(account: account) end end From 895b0e13b8a0da76f9bb9d5ebed23d7d2022e59d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 12:19:58 +0100 Subject: [PATCH 10/78] Drop the need for access tokens to have a session --- app/models/identity/access_token.rb | 8 -------- db/cable_schema.rb | 11 +---------- .../20251201132341_create_identity_access_tokens.rb | 1 - db/schema.rb | 1 - db/schema_sqlite.rb | 1 - test/fixtures/identity/access_tokens.yml | 2 -- test/fixtures/sessions.yml | 6 ------ test/models/identity/access_token_test.rb | 4 ---- 8 files changed, 1 insertion(+), 33 deletions(-) diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index 1702de78a..a5b719ab9 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -1,14 +1,6 @@ class Identity::AccessToken < ApplicationRecord belongs_to :identity - belongs_to :session has_secure_token enum :permission, %w[ read write ].index_by(&:itself), default: :read - - before_validation :build_session, on: :create - - private - def build_session - self.session = identity.sessions.build(user_agent: "Access Token") - end end diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 58afcedf5..ce4b03e6a 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -10,14 +10,5 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 1) do - create_table "solid_cable_messages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.binary "channel", limit: 1024, null: false - t.bigint "channel_hash", null: false - t.datetime "created_at", null: false - t.binary "payload", size: :long, null: false - t.index ["channel"], name: "index_solid_cable_messages_on_channel" - t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" - t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" - end +ActiveRecord::Schema[8.2].define(version: 0) do end diff --git a/db/migrate/20251201132341_create_identity_access_tokens.rb b/db/migrate/20251201132341_create_identity_access_tokens.rb index 2b7648bf0..0e455104d 100644 --- a/db/migrate/20251201132341_create_identity_access_tokens.rb +++ b/db/migrate/20251201132341_create_identity_access_tokens.rb @@ -2,7 +2,6 @@ class CreateIdentityAccessTokens < ActiveRecord::Migration[8.2] def change create_table :identity_access_tokens, id: :uuid do |t| t.uuid :identity_id, null: false - t.uuid :session_id, null: false t.string :token t.string :permission t.text :description diff --git a/db/schema.rb b/db/schema.rb index 9fe6d65a6..85a8f8064 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -326,7 +326,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.text "description" t.uuid "identity_id", null: false t.string "permission" - t.uuid "session_id", null: false t.string "token" t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 8121ea7ef..e2f65dccc 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -326,7 +326,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.text "description", limit: 65535 t.uuid "identity_id", null: false t.string "permission", limit: 255 - t.uuid "session_id", null: false t.string "token", limit: 255 t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" diff --git a/test/fixtures/identity/access_tokens.yml b/test/fixtures/identity/access_tokens.yml index 83e3d5542..cfbbb6248 100644 --- a/test/fixtures/identity/access_tokens.yml +++ b/test/fixtures/identity/access_tokens.yml @@ -1,13 +1,11 @@ jasons_api_token: identity: jason - session: jason_api token: 018cf1425682700098f24f0799e3fe20 description: My Superscript permission: read davids_api_token: identity: david - session: david_api token: x18cf1425682700098f24f0799e3fe20 description: My Superscript permission: write diff --git a/test/fixtures/sessions.yml b/test/fixtures/sessions.yml index 56b6fb7c7..56fc614a5 100644 --- a/test/fixtures/sessions.yml +++ b/test/fixtures/sessions.yml @@ -1,9 +1,6 @@ david: identity: david -david_api: - identity: david - kevin: identity: kevin @@ -15,6 +12,3 @@ jason: mike: identity: mike - -jason_api: - identity: jason diff --git a/test/models/identity/access_token_test.rb b/test/models/identity/access_token_test.rb index daa236b49..7324d97ab 100644 --- a/test/models/identity/access_token_test.rb +++ b/test/models/identity/access_token_test.rb @@ -1,8 +1,4 @@ require "test_helper" class Identity::AccessTokenTest < ActiveSupport::TestCase - test "new access token comes with a session" do - access_token = identities(:david).access_tokens.create! - assert_equal "Access Token", identities(:david).sessions.last.user_agent - end end From 3329008dd80dc2f6c9fd2d2379953b9dc026415a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 12:21:56 +0100 Subject: [PATCH 11/78] Handle everything in the same method --- app/controllers/concerns/authentication.rb | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 219ae1360..d52fb9469 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -59,6 +59,8 @@ module Authentication if request_authorized_by_bearer_token? if access_token = Identity::AccessToken.find_by(token: authorization_bearer_token) Current.identity = access_token.identity + else + head :unauthorized end end end @@ -72,15 +74,11 @@ module Authentication end def request_authentication - if request_authorized_by_bearer_token? - head :unauthorized - else - if Current.account.present? - session[:return_to_after_authenticating] = request.url - end - - redirect_to_login_url + if Current.account.present? + session[:return_to_after_authenticating] = request.url end + + redirect_to_login_url end def after_authentication_url From 467843fe22b7237288836064794c60b852afdf71 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 12:25:10 +0100 Subject: [PATCH 12/78] Inline now anemic helper methods --- app/controllers/concerns/authentication.rb | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index d52fb9469..0aa455ee2 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -56,8 +56,10 @@ module Authentication end def authenticate_by_bearer_token - if request_authorized_by_bearer_token? - if access_token = Identity::AccessToken.find_by(token: authorization_bearer_token) + scheme, bearer_token = request.authorization.to_s.split(" ", 2) + + if scheme == "Bearer" && bearer_token.present? + if access_token = Identity::AccessToken.find_by(token: bearer_token) Current.identity = access_token.identity else head :unauthorized @@ -65,14 +67,6 @@ module Authentication end end - def request_authorized_by_bearer_token? - request.authorization.to_s.starts_with? "Bearer" - end - - def authorization_bearer_token - request.authorization.to_s.split(" ", 2).second - end - def request_authentication if Current.account.present? session[:return_to_after_authenticating] = request.url From 7d2c284726c1f341490327f7bcc7dd0145a61701 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 12:26:22 +0100 Subject: [PATCH 13/78] Clarify --- app/controllers/concerns/authentication.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 0aa455ee2..b125ea550 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -56,9 +56,9 @@ module Authentication end def authenticate_by_bearer_token - scheme, bearer_token = request.authorization.to_s.split(" ", 2) + authorization_scheme, bearer_token = request.authorization.to_s.split(" ", 2) - if scheme == "Bearer" && bearer_token.present? + if authorization_scheme == "Bearer" && bearer_token.present? if access_token = Identity::AccessToken.find_by(token: bearer_token) Current.identity = access_token.identity else From 9254d17359db47603322c7876a85068ac04f869d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 12:26:55 +0100 Subject: [PATCH 14/78] The magic of it is not needing to manually yield it! --- app/helpers/users_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index bfef43f17..0ad5b70d2 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -7,6 +7,6 @@ module UsersHelper end def access_token_permission_options - Identity::AccessToken.permissions.keys.map { |it| [ it.humanize, it ] } + Identity::AccessToken.permissions.keys.map { [ it.humanize, it ] } end end From 0ba43de61a273b7c9572844722e857c00474e47e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 12:28:25 +0100 Subject: [PATCH 15/78] This had gotten stripped --- db/cable_schema.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/db/cable_schema.rb b/db/cable_schema.rb index ce4b03e6a..58afcedf5 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -10,5 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 0) do +ActiveRecord::Schema[8.2].define(version: 1) do + create_table "solid_cable_messages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.bigint "channel_hash", null: false + t.datetime "created_at", null: false + t.binary "payload", size: :long, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end end From caa4cd491ee7a396fcc4cd1dfb772c17afda37cf Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 12:47:00 +0100 Subject: [PATCH 16/78] Smooth out the finder API --- app/controllers/concerns/authentication.rb | 4 ++-- app/models/identity.rb | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index b125ea550..510cac074 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -59,8 +59,8 @@ module Authentication authorization_scheme, bearer_token = request.authorization.to_s.split(" ", 2) if authorization_scheme == "Bearer" && bearer_token.present? - if access_token = Identity::AccessToken.find_by(token: bearer_token) - Current.identity = access_token.identity + if identity = Identity.find_by_access_token(bearer_token) + Current.identity = identity else head :unauthorized end diff --git a/app/models/identity.rb b/app/models/identity.rb index f6dd7d433..43466bd30 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -14,6 +14,10 @@ class Identity < ApplicationRecord validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP } normalizes :email_address, with: ->(value) { value.strip.downcase.presence } + def self.find_by_access_token(token) + AccessToken.find_by(token: token)&.identity + end + def send_magic_link(**attributes) attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for) From 88902485e1dca4ddd0f115dcd8124b5c9bbc20b4 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 13:05:31 +0100 Subject: [PATCH 17/78] Access tokens are strictly personal --- .../my/access_tokens_controller.rb | 24 +++++++++++++ .../users/access_tokens_controller.rb | 35 ------------------- app/helpers/users_helper.rb | 4 --- .../access_tokens/_access_token.html.erb | 2 +- .../access_tokens/index.html.erb | 10 +++--- .../{users => my}/access_tokens/new.html.erb | 8 ++--- app/views/users/_developer.html.erb | 2 +- app/views/users/show.html.erb | 2 +- config/routes.rb | 2 +- 9 files changed, 36 insertions(+), 53 deletions(-) create mode 100644 app/controllers/my/access_tokens_controller.rb delete mode 100644 app/controllers/users/access_tokens_controller.rb rename app/views/{users => my}/access_tokens/_access_token.html.erb (86%) rename app/views/{users => my}/access_tokens/index.html.erb (70%) rename app/views/{users => my}/access_tokens/new.html.erb (61%) diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb new file mode 100644 index 000000000..1f6c9806c --- /dev/null +++ b/app/controllers/my/access_tokens_controller.rb @@ -0,0 +1,24 @@ +class My::AccessTokensController < ApplicationController + def index + @access_tokens = Current.identity.access_tokens.order(created_at: :desc) + end + + def new + @access_token = Current.identity.access_tokens.new + end + + def create + @access_token = Current.identity.access_tokens.create!(access_token_params) + redirect_to my_access_tokens_path + end + + def destroy + Current.identity.access_tokens.find(params[:id]).destroy! + redirect_to my_access_tokens_path + end + + private + def access_token_params + params.expect(access_token: [ :description, :permission ]) + end +end diff --git a/app/controllers/users/access_tokens_controller.rb b/app/controllers/users/access_tokens_controller.rb deleted file mode 100644 index 5bc99e641..000000000 --- a/app/controllers/users/access_tokens_controller.rb +++ /dev/null @@ -1,35 +0,0 @@ -class Users::AccessTokensController < ApplicationController - before_action :set_user - before_action :set_access_token, except: %i[ index new create ] - - def index - set_page_and_extract_portion_from @user.identity.access_tokens.order(created_at: :desc) - end - - def new - @access_token = @user.identity.access_tokens.new - end - - def create - @access_token = @user.identity.access_tokens.create!(access_token_params) - redirect_to user_access_tokens_path(@user) - end - - def destroy - @access_token.destroy! - redirect_to user_access_tokens_path(@user) - end - - private - def set_user - @user = Current.account.users.active.find(params[:user_id]) - end - - def set_access_token - @access_token = @user.identity.access_tokens.find(params[:id]) - end - - def access_token_params - params.expect(access_token: [ :description, :permission ]) - end -end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 0ad5b70d2..7cccfe4ec 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -5,8 +5,4 @@ module UsersHelper else user.role.titleize end end - - def access_token_permission_options - Identity::AccessToken.permissions.keys.map { [ it.humanize, it ] } - end end diff --git a/app/views/users/access_tokens/_access_token.html.erb b/app/views/my/access_tokens/_access_token.html.erb similarity index 86% rename from app/views/users/access_tokens/_access_token.html.erb rename to app/views/my/access_tokens/_access_token.html.erb index 13025739b..cdd5afb7e 100644 --- a/app/views/users/access_tokens/_access_token.html.erb +++ b/app/views/my/access_tokens/_access_token.html.erb @@ -3,7 +3,7 @@ <%= access_token.permission.humanize %> <%= local_datetime_tag access_token.created_at, style: :datetime %> - <%= button_to user_access_token_path(@user, access_token), method: :delete, + <%= button_to my_access_token_path(access_token), method: :delete, class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent", data: { turbo_confirm: "Are you sure you want to permanently revoke this access token?" } do %> <%= icon_tag "trash" %> diff --git a/app/views/users/access_tokens/index.html.erb b/app/views/my/access_tokens/index.html.erb similarity index 70% rename from app/views/users/access_tokens/index.html.erb rename to app/views/my/access_tokens/index.html.erb index 528696590..cc9b83ede 100644 --- a/app/views/users/access_tokens/index.html.erb +++ b/app/views/my/access_tokens/index.html.erb @@ -2,14 +2,14 @@ <% content_for :header do %>
- <%= back_link_to "My profile", user_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> + <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

<%= @page_title %>

<% end %>
- <% if @page.used? %> + <% if @access_tokens.any? %>

Tokens you have generated that can be used to access the Fizzy API.

@@ -21,16 +21,14 @@ - <%= with_automatic_pagination :access_tokens, @page do %> - <%= render partial: "users/access_tokens/access_token", collection: @page.records %> - <% end %> + <%= render partial: "my/access_tokens/access_token", collection: @access_tokens %>
<% else %>

Personal access tokens can be used like a password to access the Fizzy developer API. You can have as many tokens as you need and revoke access to each one at any time.

<% end %> - <%= link_to new_user_access_token_path(@user), class: "btn btn--link" do %> + <%= link_to new_my_access_token_path, class: "btn btn--link" do %> <%= icon_tag "add" %> Generate a new access token <% end %> diff --git a/app/views/users/access_tokens/new.html.erb b/app/views/my/access_tokens/new.html.erb similarity index 61% rename from app/views/users/access_tokens/new.html.erb rename to app/views/my/access_tokens/new.html.erb index 78f36f20f..be08e5031 100644 --- a/app/views/users/access_tokens/new.html.erb +++ b/app/views/my/access_tokens/new.html.erb @@ -2,14 +2,14 @@ <% content_for :header do %>
- <%= back_link_to "tokens", user_access_tokens_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> + <%= back_link_to "tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

<%= @page_title %>

<% end %>
- <%= form_with model: @access_token, url: user_access_tokens_path(@user), scope: :access_token, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %> + <%= form_with model: @access_token, url: my_access_tokens_path, scope: :access_token, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
<%= form.label :description, "Access token description" %> <%= form.text_field :description, required: true, autofocus: true, class: "input", placeholder: "e.g. Github", data: { action: "keydown.esc@document->form#cancel" } %> @@ -17,13 +17,13 @@
<%= form.label :permission %> - <%= form.select :permission, options_for_select(access_token_permission_options), {}, class: "input input--select" %> + <%= form.select :permission, options_for_select({ "Read" => "read", "Read + Write" => "write"}), {}, class: "input input--select" %>
<%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> Generate access token <% end %> - <%= link_to "Cancel and go back", user_access_tokens_path(@user), data: { form_target: "cancel" }, hidden: true %> + <%= link_to "Cancel and go back", my_access_tokens_path, data: { form_target: "cancel" }, hidden: true %> <% end %>
diff --git a/app/views/users/_developer.html.erb b/app/views/users/_developer.html.erb index eb2ae4241..318606b92 100644 --- a/app/views/users/_developer.html.erb +++ b/app/views/users/_developer.html.erb @@ -4,6 +4,6 @@
- <%= link_to "Personal access tokens", user_access_tokens_path(user), class: "btn" %> + <%= link_to "Personal access tokens", my_access_tokens_path, class: "btn" %>
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index d0a251f4f..eecea4506 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -43,7 +43,7 @@ <% if Current.user == @user %>
<%= render "users/transfer", user: @user %> - <%= render "users/developer", user: @user %> + <%= render "users/developer" %>
<%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %> diff --git a/config/routes.rb b/config/routes.rb index a66cda9c9..bf9c8c3b3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,7 +15,6 @@ Rails.application.routes.draw do resource :events resources :push_subscriptions - resources :access_tokens resources :email_addresses, param: :token do resource :confirmation, module: :email_addresses @@ -163,6 +162,7 @@ Rails.application.routes.draw do resource :landing namespace :my do + resources :access_tokens resources :pins resource :timezone resource :menu From d2bdd139090a6bba08689685dc9f20c446b86766 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 13:07:42 +0100 Subject: [PATCH 18/78] Inline anemic partial --- app/views/users/_developer.html.erb | 9 --------- app/views/users/show.html.erb | 11 ++++++++++- 2 files changed, 10 insertions(+), 10 deletions(-) delete mode 100644 app/views/users/_developer.html.erb diff --git a/app/views/users/_developer.html.erb b/app/views/users/_developer.html.erb deleted file mode 100644 index 318606b92..000000000 --- a/app/views/users/_developer.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -
-
-

Developer

-
- -
- <%= link_to "Personal access tokens", my_access_tokens_path, class: "btn" %> -
-
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index eecea4506..58436d505 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -43,7 +43,16 @@ <% if Current.user == @user %>
<%= render "users/transfer", user: @user %> - <%= render "users/developer" %> + +
+
+

API

+
+ +
+ <%= link_to "Personal access tokens", my_access_tokens_path, class: "btn" %> +
+
<%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %> From 96b62d6ec410168a13d7eae079fd8d13ca3f38fa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 15:03:17 +0100 Subject: [PATCH 19/78] Only allow new token to be viewed within 10 seconds --- .../my/access_tokens_controller.rb | 17 +++++++++-- app/views/my/access_tokens/show.html.erb | 1 + .../my/access_tokens_controller_test.rb | 30 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 app/views/my/access_tokens/show.html.erb create mode 100644 test/controllers/my/access_tokens_controller_test.rb diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb index 1f6c9806c..c39a6c973 100644 --- a/app/controllers/my/access_tokens_controller.rb +++ b/app/controllers/my/access_tokens_controller.rb @@ -3,13 +3,22 @@ class My::AccessTokensController < ApplicationController @access_tokens = Current.identity.access_tokens.order(created_at: :desc) end + def show + access_token_id = verifier.verify(params[:id]) + @access_token = Current.identity.access_tokens.find(access_token_id) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to my_access_tokens_path, alert: "Token is no longer visible" + end + def new @access_token = Current.identity.access_tokens.new end def create - @access_token = Current.identity.access_tokens.create!(access_token_params) - redirect_to my_access_tokens_path + access_token = Current.identity.access_tokens.create!(access_token_params) + expiring_id = verifier.generate access_token.id, expires_in: 10.seconds + + redirect_to my_access_token_path(expiring_id) end def destroy @@ -21,4 +30,8 @@ class My::AccessTokensController < ApplicationController def access_token_params params.expect(access_token: [ :description, :permission ]) end + + def verifier + Rails.application.message_verifier(:access_tokens) + end end diff --git a/app/views/my/access_tokens/show.html.erb b/app/views/my/access_tokens/show.html.erb new file mode 100644 index 000000000..f0551ffad --- /dev/null +++ b/app/views/my/access_tokens/show.html.erb @@ -0,0 +1 @@ +<%= @access_token.token %> diff --git a/test/controllers/my/access_tokens_controller_test.rb b/test/controllers/my/access_tokens_controller_test.rb new file mode 100644 index 000000000..43a90fac5 --- /dev/null +++ b/test/controllers/my/access_tokens_controller_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class My::AccessTokensControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "create new token" do + get my_access_tokens_path + assert_response :success + + get new_my_access_token_path + assert_response :success + + assert_changes -> { identities(:kevin).access_tokens.count }, +1 do + post my_access_tokens_path, params: { access_token: { description: "GitHub", permission: "read" } } + follow_redirect! + assert_in_body identities(:kevin).access_tokens.last.token + end + 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" } } + travel_to 15.seconds.from_now + follow_redirect! + assert_equal "Token is no longer visible", flash[:alert] + end + end +end From 7bc6ae4fac788acd5b808356c7cc7434d1bb95e2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 15:11:59 +0100 Subject: [PATCH 20/78] Polish --- app/controllers/my/access_tokens_controller.rb | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb index c39a6c973..99c87893f 100644 --- a/app/controllers/my/access_tokens_controller.rb +++ b/app/controllers/my/access_tokens_controller.rb @@ -1,34 +1,37 @@ class My::AccessTokensController < ApplicationController def index - @access_tokens = Current.identity.access_tokens.order(created_at: :desc) + @access_tokens = my_access_tokens.order(created_at: :desc) end def show - access_token_id = verifier.verify(params[:id]) - @access_token = Current.identity.access_tokens.find(access_token_id) + @access_token = my_access_tokens.find(verifier.verify(params[:id])) rescue ActiveSupport::MessageVerifier::InvalidSignature redirect_to my_access_tokens_path, alert: "Token is no longer visible" end def new - @access_token = Current.identity.access_tokens.new + @access_token = my_access_tokens.new end def create - access_token = Current.identity.access_tokens.create!(access_token_params) + access_token = my_access_tokens.create!(access_token_params) expiring_id = verifier.generate access_token.id, expires_in: 10.seconds redirect_to my_access_token_path(expiring_id) end def destroy - Current.identity.access_tokens.find(params[:id]).destroy! + my_access_tokens.find(params[:id]).destroy! redirect_to my_access_tokens_path end private + def my_access_tokens + Current.identity.access_tokens + end + def access_token_params - params.expect(access_token: [ :description, :permission ]) + params.expect(access_token: %i[ description permission ]) end def verifier From d36be764e2e9d4dcf9c48376cd4ba657f4230c91 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 15:12:23 +0100 Subject: [PATCH 21/78] Awaiting JZ's design --- app/views/my/access_tokens/show.html.erb | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/views/my/access_tokens/show.html.erb b/app/views/my/access_tokens/show.html.erb index f0551ffad..c81a4d171 100644 --- a/app/views/my/access_tokens/show.html.erb +++ b/app/views/my/access_tokens/show.html.erb @@ -1 +1,15 @@ -<%= @access_token.token %> +<% @page_title = "New personal access token" %> + +<% content_for :header do %> +
+ <%= back_link_to "tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+

<%= @access_token.token %>

+ + <%= link_to "Cancel and go back", my_access_tokens_path, data: { form_target: "cancel" }, hidden: true %> +
From 0ce3a85778ef11866bd3cca1bf7a8b6e78832c7e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 15:27:21 +0100 Subject: [PATCH 22/78] Only allow writing when the access token has permission --- app/controllers/concerns/authentication.rb | 6 ++++-- app/models/identity/access_token.rb | 4 ++++ test/controllers/api_test.rb | 9 +++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 510cac074..5dc3fb866 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -59,8 +59,10 @@ module Authentication authorization_scheme, bearer_token = request.authorization.to_s.split(" ", 2) if authorization_scheme == "Bearer" && bearer_token.present? - if identity = Identity.find_by_access_token(bearer_token) - Current.identity = identity + access_token = Identity::AccessToken.find_by(token: bearer_token) + + if access_token && access_token.allows?(request.method) + Current.identity = access_token.identity else head :unauthorized end diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index a5b719ab9..abdf37eba 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -3,4 +3,8 @@ class Identity::AccessToken < ApplicationRecord has_secure_token enum :permission, %w[ read write ].index_by(&:itself), default: :read + + def allows?(method) + method.in?(%w[ GET HEAD ]) || write? + end end diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index 7eff2d7bb..f33dfe997 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -3,6 +3,7 @@ require "test_helper" class ApiTest < ActionDispatch::IntegrationTest setup do @davids_bearer_token = bearer_token_env(identity_access_tokens(:davids_api_token).token) + @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token) end test "authenticate with valid access token" do @@ -15,6 +16,14 @@ class ApiTest < ActionDispatch::IntegrationTest assert_response :unauthorized end + test "changing data requires a write-endowed access token" do + post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @jasons_bearer_token + assert_response :unauthorized + + post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @davids_bearer_token + assert_response :redirect + end + test "boards" do get boards_path(format: :json), env: @davids_bearer_token assert_equal users(:david).boards.count, @response.parsed_body.count From efbd2cc3da19f62297fa1d26181277ed73d6b1b0 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 15:34:20 +0100 Subject: [PATCH 23/78] Allow API JSON requests to sidestep csrf protection --- app/controllers/concerns/request_forgery_protection.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/concerns/request_forgery_protection.rb b/app/controllers/concerns/request_forgery_protection.rb index 8ede51fc7..95fa12892 100644 --- a/app/controllers/concerns/request_forgery_protection.rb +++ b/app/controllers/concerns/request_forgery_protection.rb @@ -12,7 +12,7 @@ module RequestForgeryProtection end def verified_request? - super || safe_fetch_site? + super || safe_fetch_site? || request.format.json? end SAFE_FETCH_SITES = %w[ same-origin same-site ] From 129b81984f57d3081d68035aabc8c62e629cff52 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 15:42:46 +0100 Subject: [PATCH 24/78] Creating a new board will return the location header --- app/controllers/boards_controller.rb | 6 +++++- test/controllers/api_test.rb | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index d37a5358e..f6eafdc76 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -22,7 +22,11 @@ class BoardsController < ApplicationController def create @board = Board.create! board_params.with_defaults(all_access: true) - redirect_to board_path(@board) + + respond_to do |format| + format.html { redirect_to board_path(@board) } + format.json { head :created, location: board_path(@board) } + end end def edit diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index f33dfe997..977a75dc5 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -21,10 +21,10 @@ class ApiTest < ActionDispatch::IntegrationTest assert_response :unauthorized post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @davids_bearer_token - assert_response :redirect + assert_response :success end - test "boards" do + test "get boards" do get boards_path(format: :json), env: @davids_bearer_token assert_equal users(:david).boards.count, @response.parsed_body.count @@ -32,6 +32,14 @@ class ApiTest < ActionDispatch::IntegrationTest assert_equal boards(:writebook).name, @response.parsed_body["name"] end + test "create board" do + post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @davids_bearer_token + assert_equal board_path(Board.last), @response.headers["Location"] + + get board_path(Board.last, format: :json), env: @davids_bearer_token + assert_equal "My new board", @response.parsed_body["name"] + end + private def bearer_token_env(token) { "HTTP_AUTHORIZATION" => "Bearer #{token}" } From f0c0a87c74279a3021309f6a506a0207fcafa6b6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 15:54:21 +0100 Subject: [PATCH 25/78] Return json URLs for API actions --- app/controllers/boards_controller.rb | 2 +- test/controllers/api_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index f6eafdc76..ae2f59a88 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -25,7 +25,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 { head :created, location: board_path(@board, format: :json) } end end diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index 977a75dc5..7aadfdee0 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -34,7 +34,7 @@ class ApiTest < ActionDispatch::IntegrationTest test "create board" do post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @davids_bearer_token - assert_equal board_path(Board.last), @response.headers["Location"] + assert_equal board_path(Board.last, format: :json), @response.headers["Location"] get board_path(Board.last, format: :json), env: @davids_bearer_token assert_equal "My new board", @response.parsed_body["name"] From 983a19fd8a0e46a8d6c3a9915af79d9b3de3368c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Dec 2025 15:54:38 +0100 Subject: [PATCH 26/78] Create cards via API --- app/controllers/cards_controller.rb | 13 +++++++++++-- app/views/cards/show.json.jbuilder | 1 + test/controllers/api_test.rb | 8 ++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 app/views/cards/show.json.jbuilder diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index cb40079e8..0dc1148b1 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -10,8 +10,17 @@ class CardsController < ApplicationController end def create - card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted") - redirect_to card + respond_to do |format| + format.html do + card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted") + redirect_to card + end + + format.json do + card = @board.cards.create! card_params.merge(creator: Current.user) + head :created, location: card_path(card, format: :json) + end + end end def show diff --git a/app/views/cards/show.json.jbuilder b/app/views/cards/show.json.jbuilder new file mode 100644 index 000000000..8f44c1a24 --- /dev/null +++ b/app/views/cards/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "cards/card", card: @card diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index 7aadfdee0..62244855a 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -40,6 +40,14 @@ class ApiTest < ActionDispatch::IntegrationTest assert_equal "My new board", @response.parsed_body["name"] end + test "create card" do + post board_cards_path(boards(:writebook), format: :json), params: { card: { title: "My new card" } }, env: @davids_bearer_token + assert_equal card_path(Card.last, format: :json), @response.headers["Location"] + + get card_path(Card.last, format: :json), env: @davids_bearer_token + assert_equal "My new card", @response.parsed_body["title"] + end + private def bearer_token_env(token) { "HTTP_AUTHORIZATION" => "Bearer #{token}" } From fa2eb06992d09947d99553c59025319f86c75b74 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 2 Dec 2025 08:58:39 -0600 Subject: [PATCH 27/78] Design show view --- app/views/my/access_tokens/show.html.erb | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/views/my/access_tokens/show.html.erb b/app/views/my/access_tokens/show.html.erb index c81a4d171..16becf7c2 100644 --- a/app/views/my/access_tokens/show.html.erb +++ b/app/views/my/access_tokens/show.html.erb @@ -2,14 +2,25 @@ <% content_for :header do %>
- <%= back_link_to "tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> + <%= back_link_to "Tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

<%= @page_title %>

<% end %>
-

<%= @access_token.token %>

+
+ +

Be sure to save this access token now because you won’t be able to see it again.

- <%= link_to "Cancel and go back", my_access_tokens_path, data: { form_target: "cancel" }, hidden: true %> + <%= tag.button class: "btn btn--link center", data: { + controller: "copy-to-clipboard", action: "copy-to-clipboard#copy", + copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: @access_token.token } do %> + <%= icon_tag "copy-paste" %> + Copy access token + <% end %> +
From d82093c0cdfb03126c174d4c627186689ce1919f Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 2 Dec 2025 10:37:08 -0600 Subject: [PATCH 28/78] Complete the view transition loop --- app/views/my/access_tokens/_access_token.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/my/access_tokens/_access_token.html.erb b/app/views/my/access_tokens/_access_token.html.erb index cdd5afb7e..cd33831bb 100644 --- a/app/views/my/access_tokens/_access_token.html.erb +++ b/app/views/my/access_tokens/_access_token.html.erb @@ -1,4 +1,4 @@ - + <%= access_token.description %> <%= access_token.permission.humanize %> <%= local_datetime_tag access_token.created_at, style: :datetime %> From 79fc57a82a1988d992004679a9f729b8e6892a1c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 3 Dec 2025 10:39:47 +0100 Subject: [PATCH 29/78] Use built-in authenticate_or_request_with_http_token Hat tip to @adrienpoly --- app/controllers/concerns/authentication.rb | 12 +++--------- app/models/identity.rb | 6 ++++-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 5dc3fb866..398f71b03 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -56,15 +56,9 @@ module Authentication end def authenticate_by_bearer_token - authorization_scheme, bearer_token = request.authorization.to_s.split(" ", 2) - - if authorization_scheme == "Bearer" && bearer_token.present? - access_token = Identity::AccessToken.find_by(token: bearer_token) - - if access_token && access_token.allows?(request.method) - Current.identity = access_token.identity - else - head :unauthorized + authenticate_or_request_with_http_token do |token| + if identity = Identity.find_by_permissable_access_token(token, method: request.method) + Current.identity = identity end end end diff --git a/app/models/identity.rb b/app/models/identity.rb index 43466bd30..7495e37c3 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -14,8 +14,10 @@ class Identity < ApplicationRecord validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP } normalizes :email_address, with: ->(value) { value.strip.downcase.presence } - def self.find_by_access_token(token) - AccessToken.find_by(token: token)&.identity + def self.find_by_permissable_access_token(token, method:) + if (access_token = AccessToken.find_by(token: token)) && access_token.allows?(method) + access_token.identity + end end def send_magic_link(**attributes) From 5ad7b973cbea3b067fe5b4fec3b1c6ebc1f81653 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 3 Dec 2025 07:07:54 -0500 Subject: [PATCH 30/78] Add API support for users --- app/controllers/users_controller.rb | 6 +++++- app/views/users/index.json.jbuilder | 1 + app/views/users/show.json.jbuilder | 1 + test/controllers/api_test.rb | 8 ++++++++ 4 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 app/views/users/index.json.jbuilder create mode 100644 app/views/users/show.json.jbuilder diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 15f8ecd6f..df2f0e3c9 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,7 +1,11 @@ class UsersController < ApplicationController - before_action :set_user + before_action :set_user, except: %i[ index ] before_action :ensure_permission_to_change_user, only: %i[ update destroy ] + def index + @users = Current.account.users.active.alphabetically + end + def show end diff --git a/app/views/users/index.json.jbuilder b/app/views/users/index.json.jbuilder new file mode 100644 index 000000000..2faf5af04 --- /dev/null +++ b/app/views/users/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @users, partial: 'users/user', as: :user diff --git a/app/views/users/show.json.jbuilder b/app/views/users/show.json.jbuilder new file mode 100644 index 000000000..ff40bb960 --- /dev/null +++ b/app/views/users/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "users/user", user: @user diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index 62244855a..d5bb0514f 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -48,6 +48,14 @@ class ApiTest < ActionDispatch::IntegrationTest assert_equal "My new card", @response.parsed_body["title"] end + test "get users" do + get users_path(format: :json), env: @davids_bearer_token + assert_equal users(:david).account.users.active.count, @response.parsed_body.count + + get user_path(users(:david), format: :json), env: @davids_bearer_token + assert_equal users(:david).name, @response.parsed_body["name"] + end + private def bearer_token_env(token) { "HTTP_AUTHORIZATION" => "Bearer #{token}" } From 680f9c0c4dc70a05d67bd52b6555aa6748917148 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 3 Dec 2025 08:12:04 -0500 Subject: [PATCH 31/78] Add top-level API index support for tags --- app/controllers/tags_controller.rb | 5 +++++ app/views/tags/_tag.json.jbuilder | 5 +++++ app/views/tags/index.json.jbuilder | 1 + config/routes.rb | 2 ++ test/controllers/api_test.rb | 8 ++++++++ 5 files changed, 21 insertions(+) create mode 100644 app/controllers/tags_controller.rb create mode 100644 app/views/tags/_tag.json.jbuilder create mode 100644 app/views/tags/index.json.jbuilder diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb new file mode 100644 index 000000000..2dc468f0d --- /dev/null +++ b/app/controllers/tags_controller.rb @@ -0,0 +1,5 @@ +class TagsController < ApplicationController + def index + @tags = Current.account.tags.all.alphabetically + end +end diff --git a/app/views/tags/_tag.json.jbuilder b/app/views/tags/_tag.json.jbuilder new file mode 100644 index 000000000..c8a7ffe40 --- /dev/null +++ b/app/views/tags/_tag.json.jbuilder @@ -0,0 +1,5 @@ +json.cache! tag do + json.(tag, :id, :title) + json.created_at tag.created_at.utc + json.url cards_url(tag_ids: [ tag ]) +end diff --git a/app/views/tags/index.json.jbuilder b/app/views/tags/index.json.jbuilder new file mode 100644 index 000000000..9b85a42e4 --- /dev/null +++ b/app/views/tags/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @tags, partial: 'tags/tag', as: :tag diff --git a/config/routes.rb b/config/routes.rb index bf9c8c3b3..19358cb9d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -94,6 +94,8 @@ Rails.application.routes.draw do end end + resources :tags, only: :index + namespace :notifications do resource :settings resource :unsubscribe diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index d5bb0514f..994a7ebd6 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -48,6 +48,14 @@ class ApiTest < ActionDispatch::IntegrationTest assert_equal "My new card", @response.parsed_body["title"] end + test "get tags" do + tags = users(:david).account.tags.all.alphabetically + + get tags_path(format: :json), env: @davids_bearer_token + assert_equal tags.count, @response.parsed_body.count + assert_equal tags.pluck(:title), @response.parsed_body.pluck("title") + end + test "get users" do get users_path(format: :json), env: @davids_bearer_token assert_equal users(:david).account.users.active.count, @response.parsed_body.count From a8dc4fb31073e9422d3886d0b5d50120131ee3e7 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 3 Dec 2025 17:10:42 +0100 Subject: [PATCH 32/78] Excess whitespace --- test/controllers/api_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index 994a7ebd6..ad1fd8d1b 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -50,7 +50,7 @@ class ApiTest < ActionDispatch::IntegrationTest test "get tags" do tags = users(:david).account.tags.all.alphabetically - + get tags_path(format: :json), env: @davids_bearer_token assert_equal tags.count, @response.parsed_body.count assert_equal tags.pluck(:title), @response.parsed_body.pluck("title") From 0159f369f492e9f21f6970bce98507d6dfc89645 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 3 Dec 2025 17:18:08 +0100 Subject: [PATCH 33/78] Only authenticate with bearer token if the header is present --- app/controllers/concerns/authentication.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 398f71b03..9e8e5e756 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -56,9 +56,11 @@ module Authentication end def authenticate_by_bearer_token - authenticate_or_request_with_http_token do |token| - if identity = Identity.find_by_permissable_access_token(token, method: request.method) - Current.identity = identity + if request.authorization.to_s.include?("Bearer") + authenticate_or_request_with_http_token do |token| + if identity = Identity.find_by_permissable_access_token(token, method: request.method) + Current.identity = identity + end end end end From 3c3f0985002dd7df584231f4c80fc566e4759c53 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 3 Dec 2025 17:38:43 +0100 Subject: [PATCH 34/78] Compact --- app/views/cards/_card.json.jbuilder | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index 1fa339328..429a721c3 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -13,11 +13,7 @@ json.cache! [ card, card.column&.color ] do end json.column do - if card.column - json.partial! "columns/column", column: card.column - else - nil - end + json.partial! "columns/column", column: card.column if card.column end json.creator do From ae0eaadcdf79ecad9849e2ec89e624a55e67b421 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 3 Dec 2025 17:49:51 +0100 Subject: [PATCH 35/78] Publish any API card as soon as it is created --- app/controllers/cards_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 0dc1148b1..361415dec 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -18,6 +18,7 @@ class CardsController < ApplicationController format.json do card = @board.cards.create! card_params.merge(creator: Current.user) + card.publish head :created, location: card_path(card, format: :json) end end From 3f393c14238e83f17dea45bcf34009480655abb5 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 3 Dec 2025 18:03:10 +0100 Subject: [PATCH 36/78] Include card description and tags --- app/views/cards/_card.json.jbuilder | 4 ++++ test/controllers/api_test.rb | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index 429a721c3..976785b41 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -1,7 +1,11 @@ json.cache! [ card, card.column&.color ] do json.(card, :id, :title, :status) + json.description card.description.to_plain_text + json.description_html card.description.to_s json.image_url card.image.presence && url_for(card.image) + json.tags card.tags.pluck(:title).sort + json.golden card.golden? json.last_active_at card.last_active_at.utc json.created_at card.created_at.utc diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index ad1fd8d1b..9088497a8 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -41,11 +41,17 @@ class ApiTest < ActionDispatch::IntegrationTest end test "create card" do - post board_cards_path(boards(:writebook), format: :json), params: { card: { title: "My new card" } }, env: @davids_bearer_token + post board_cards_path(boards(:writebook), format: :json), + params: { card: { title: "My new card", description: "Big if true", tag_ids: [ tags(:web).id, tags(:mobile).id ] } }, + env: @davids_bearer_token + assert_equal card_path(Card.last, format: :json), @response.headers["Location"] - get card_path(Card.last, format: :json), env: @davids_bearer_token + new_card = Card.last + get card_path(new_card, format: :json), env: @davids_bearer_token assert_equal "My new card", @response.parsed_body["title"] + assert_equal "Big if true", @response.parsed_body["description"] + assert_equal [ tags(:web).title, tags(:mobile).title ].sort, @response.parsed_body["tags"] end test "get tags" do From 9fceef16e9ad8702bda29135fd3b9cbafe1c8a88 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 3 Dec 2025 18:10:52 +0100 Subject: [PATCH 37/78] Fix quoting --- app/views/tags/index.json.jbuilder | 2 +- app/views/users/index.json.jbuilder | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/tags/index.json.jbuilder b/app/views/tags/index.json.jbuilder index 9b85a42e4..03e8857d4 100644 --- a/app/views/tags/index.json.jbuilder +++ b/app/views/tags/index.json.jbuilder @@ -1 +1 @@ -json.array! @tags, partial: 'tags/tag', as: :tag +json.array! @tags, partial: "tags/tag", as: :tag diff --git a/app/views/users/index.json.jbuilder b/app/views/users/index.json.jbuilder index 2faf5af04..98788dadd 100644 --- a/app/views/users/index.json.jbuilder +++ b/app/views/users/index.json.jbuilder @@ -1 +1 @@ -json.array! @users, partial: 'users/user', as: :user +json.array! @users, partial: "users/user", as: :user From 26fc9ecad4d5fe7b2a0fb7c2012e7a3906ed1193 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Thu, 4 Dec 2025 10:52:21 -0500 Subject: [PATCH 38/78] Add an /identity.json endpoint to obtain the identity accounts and users --- app/controllers/identities_controller.rb | 7 +++++++ app/views/identities/_account.json.jbuilder | 4 ++++ app/views/identities/show.json.jbuilder | 6 ++++++ config/routes.rb | 2 ++ test/controllers/api_test.rb | 8 ++++++++ 5 files changed, 27 insertions(+) create mode 100644 app/controllers/identities_controller.rb create mode 100644 app/views/identities/_account.json.jbuilder create mode 100644 app/views/identities/show.json.jbuilder diff --git a/app/controllers/identities_controller.rb b/app/controllers/identities_controller.rb new file mode 100644 index 000000000..88d112dd4 --- /dev/null +++ b/app/controllers/identities_controller.rb @@ -0,0 +1,7 @@ +class IdentitiesController < ApplicationController + disallow_account_scope + + def show + @identity = Current.identity + end +end diff --git a/app/views/identities/_account.json.jbuilder b/app/views/identities/_account.json.jbuilder new file mode 100644 index 000000000..d3954ec2f --- /dev/null +++ b/app/views/identities/_account.json.jbuilder @@ -0,0 +1,4 @@ +json.cache! account do + json.(account, :id, :name, :external_account_id) + json.created_at account.created_at.utc +end diff --git a/app/views/identities/show.json.jbuilder b/app/views/identities/show.json.jbuilder new file mode 100644 index 000000000..ce74cb74d --- /dev/null +++ b/app/views/identities/show.json.jbuilder @@ -0,0 +1,6 @@ +json.accounts @identity.users do |user| + json.partial! "identities/account", account: user.account + json.user do + json.partial! "users/user", user: user + end +end diff --git a/config/routes.rb b/config/routes.rb index 19358cb9d..805b89f38 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -163,6 +163,8 @@ Rails.application.routes.draw do resource :landing + resource :identity, only: :show + namespace :my do resources :access_tokens resources :pins diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index 9088497a8..3a4c55aae 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -70,6 +70,14 @@ class ApiTest < ActionDispatch::IntegrationTest assert_equal users(:david).name, @response.parsed_body["name"] end + test "get identity" do + identity = identities(:david) + + get identity_path(format: :json), env: @davids_bearer_token + assert_response :success # Fix 302 redirect + assert_equal identity.accounts.count, @response.parsed_body["accounts"].count + end + private def bearer_token_env(token) { "HTTP_AUTHORIZATION" => "Bearer #{token}" } From 259707bf70318e4366ecf5a1417ff01af00738a2 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 4 Dec 2025 18:29:18 +0100 Subject: [PATCH 39/78] Fix identity tests --- app/controllers/concerns/authentication.rb | 2 +- app/controllers/identities_controller.rb | 2 +- app/models/current.rb | 2 -- app/views/identities/show.json.jbuilder | 2 +- test/controllers/api_test.rb | 8 +++++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 9e8e5e756..30f54820b 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -7,7 +7,7 @@ module Authentication after_action :ensure_development_magic_link_not_leaked helper_method :authenticated? - etag { Current.session.id if authenticated? } + etag { Current.identity.id if authenticated? } include LoginHelper end diff --git a/app/controllers/identities_controller.rb b/app/controllers/identities_controller.rb index 88d112dd4..f685cd348 100644 --- a/app/controllers/identities_controller.rb +++ b/app/controllers/identities_controller.rb @@ -1,6 +1,6 @@ class IdentitiesController < ApplicationController disallow_account_scope - + def show @identity = Current.identity end diff --git a/app/models/current.rb b/app/models/current.rb index 34c79d7d9..786e5e89b 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -2,8 +2,6 @@ class Current < ActiveSupport::CurrentAttributes attribute :session, :user, :identity, :account attribute :http_method, :request_id, :user_agent, :ip_address, :referrer - delegate :identity, to: :session, allow_nil: true - def session=(value) super(value) diff --git a/app/views/identities/show.json.jbuilder b/app/views/identities/show.json.jbuilder index ce74cb74d..5f8a1cefb 100644 --- a/app/views/identities/show.json.jbuilder +++ b/app/views/identities/show.json.jbuilder @@ -2,5 +2,5 @@ json.accounts @identity.users do |user| json.partial! "identities/account", account: user.account json.user do json.partial! "users/user", user: user - end + end end diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index 3a4c55aae..aa409de08 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -73,9 +73,11 @@ class ApiTest < ActionDispatch::IntegrationTest test "get identity" do identity = identities(:david) - get identity_path(format: :json), env: @davids_bearer_token - assert_response :success # Fix 302 redirect - assert_equal identity.accounts.count, @response.parsed_body["accounts"].count + untenanted do + get identity_path(format: :json), env: @davids_bearer_token + assert_response :success + assert_equal identity.accounts.count, @response.parsed_body["accounts"].count + end end private From 75f273c6fa99760ce6a5165bcbcd4bca0e5f96b6 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 4 Dec 2025 18:49:14 +0100 Subject: [PATCH 40/78] Fix Current not setting a session in some contexts --- app/models/current.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/current.rb b/app/models/current.rb index 786e5e89b..94ef9688c 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -5,7 +5,7 @@ class Current < ActiveSupport::CurrentAttributes def session=(value) super(value) - if value.present? && account.present? + if value.present? self.identity = session.identity end end From ae03f2b2831ed3b858c0c78aa1171623327b3839 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 13:28:58 +0100 Subject: [PATCH 41/78] Move tests into their controller tests --- test/controllers/api_test.rb | 56 ------------------- test/controllers/boards_controller_test.rb | 21 +++++++ test/controllers/cards_controller_test.rb | 22 ++++++++ .../controllers/identities_controller_test.rb | 17 ++++++ test/controllers/tags_controller_test.rb | 16 ++++++ test/controllers/users_controller_test.rb | 16 ++++++ 6 files changed, 92 insertions(+), 56 deletions(-) create mode 100644 test/controllers/identities_controller_test.rb create mode 100644 test/controllers/tags_controller_test.rb diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index aa409de08..a48511cf8 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -24,62 +24,6 @@ class ApiTest < ActionDispatch::IntegrationTest assert_response :success end - test "get boards" do - get boards_path(format: :json), env: @davids_bearer_token - assert_equal users(:david).boards.count, @response.parsed_body.count - - get board_path(boards(:writebook), format: :json), env: @davids_bearer_token - assert_equal boards(:writebook).name, @response.parsed_body["name"] - end - - test "create board" do - post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @davids_bearer_token - assert_equal board_path(Board.last, format: :json), @response.headers["Location"] - - get board_path(Board.last, format: :json), env: @davids_bearer_token - assert_equal "My new board", @response.parsed_body["name"] - end - - test "create card" do - post board_cards_path(boards(:writebook), format: :json), - params: { card: { title: "My new card", description: "Big if true", tag_ids: [ tags(:web).id, tags(:mobile).id ] } }, - env: @davids_bearer_token - - assert_equal card_path(Card.last, format: :json), @response.headers["Location"] - - new_card = Card.last - get card_path(new_card, format: :json), env: @davids_bearer_token - assert_equal "My new card", @response.parsed_body["title"] - assert_equal "Big if true", @response.parsed_body["description"] - assert_equal [ tags(:web).title, tags(:mobile).title ].sort, @response.parsed_body["tags"] - end - - test "get tags" do - tags = users(:david).account.tags.all.alphabetically - - get tags_path(format: :json), env: @davids_bearer_token - assert_equal tags.count, @response.parsed_body.count - assert_equal tags.pluck(:title), @response.parsed_body.pluck("title") - end - - test "get users" do - get users_path(format: :json), env: @davids_bearer_token - assert_equal users(:david).account.users.active.count, @response.parsed_body.count - - get user_path(users(:david), format: :json), env: @davids_bearer_token - assert_equal users(:david).name, @response.parsed_body["name"] - end - - test "get identity" do - identity = identities(:david) - - untenanted do - get identity_path(format: :json), env: @davids_bearer_token - assert_response :success - assert_equal identity.accounts.count, @response.parsed_body["accounts"].count - end - end - private def bearer_token_env(token) { "HTTP_AUTHORIZATION" => "Bearer #{token}" } diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index a7e3580de..6701b802d 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -190,4 +190,25 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}'][disabled]" end end + + test "index as JSON" do + get boards_path, as: :json + assert_response :success + assert_equal users(:kevin).boards.count, @response.parsed_body.count + end + + test "show as JSON" do + get board_path(boards(:writebook)), as: :json + assert_response :success + assert_equal boards(:writebook).name, @response.parsed_body["name"] + end + + test "create as JSON" do + assert_difference -> { Board.count }, +1 do + post boards_path, params: { board: { name: "My new board" } }, as: :json + end + + assert_response :created + assert_equal board_path(Board.last, format: :json), @response.headers["Location"] + end end diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index ea5d4e50e..ba38fb191 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -132,4 +132,26 @@ class CardsControllerTest < ActionDispatch::IntegrationTest get card_path(card) assert_response :success end + + test "show as JSON" do + get card_path(cards(:logo)), as: :json + assert_response :success + assert_equal cards(:logo).title, @response.parsed_body["title"] + end + + test "create as JSON" do + assert_difference -> { Card.count }, +1 do + post board_cards_path(boards(:writebook)), + params: { card: { title: "My new card", description: "Big if true", tag_ids: [ tags(:web).id, tags(:mobile).id ] } }, + as: :json + end + + assert_response :created + assert_equal card_path(Card.last, format: :json), @response.headers["Location"] + + card = Card.last + assert_equal "My new card", card.title + assert_equal "Big if true", card.description.to_plain_text + assert_equal [ tags(:mobile), tags(:web) ].sort, card.tags.sort + end end diff --git a/test/controllers/identities_controller_test.rb b/test/controllers/identities_controller_test.rb new file mode 100644 index 000000000..8d6e9a2fb --- /dev/null +++ b/test/controllers/identities_controller_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class IdentitiesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "show as JSON" do + identity = identities(:kevin) + + untenanted do + get identity_path, as: :json + assert_response :success + assert_equal identity.accounts.count, @response.parsed_body["accounts"].count + end + end +end diff --git a/test/controllers/tags_controller_test.rb b/test/controllers/tags_controller_test.rb new file mode 100644 index 000000000..e3685e60b --- /dev/null +++ b/test/controllers/tags_controller_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class TagsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "index as JSON" do + tags = users(:kevin).account.tags.all.alphabetically + + get tags_path, as: :json + assert_response :success + assert_equal tags.count, @response.parsed_body.count + assert_equal tags.pluck(:title), @response.parsed_body.pluck("title") + end +end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 385bacaf3..ae00982e7 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -86,4 +86,20 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert users(:kevin).reload.avatar.attached? assert_equal "image/png", users(:kevin).avatar.content_type end + + test "index as JSON" do + sign_in_as :kevin + + get users_path, as: :json + assert_response :success + assert_equal users(:kevin).account.users.active.count, @response.parsed_body.count + end + + test "show as JSON" do + sign_in_as :kevin + + get user_path(users(:david)), as: :json + assert_response :success + assert_equal users(:david).name, @response.parsed_body["name"] + end end From 235a94355e99a0219fc92ace8dbca660bdc66574 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 13:36:21 +0100 Subject: [PATCH 42/78] Add card update & delete actions --- app/controllers/cards_controller.rb | 11 ++++++++++- test/controllers/cards_controller_test.rb | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 361415dec..a3098b4b0 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -32,11 +32,20 @@ class CardsController < ApplicationController def update @card.update! card_params + + respond_to do |format| + format.turbo_stream + format.json { render :show } + end end def destroy @card.destroy! - redirect_to @card.board, notice: "Card deleted" + + respond_to do |format| + format.html { redirect_to @card.board, notice: "Card deleted" } + format.json { head :no_content } + end end private diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index ba38fb191..6ef509ac4 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -154,4 +154,20 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_equal "Big if true", card.description.to_plain_text assert_equal [ tags(:mobile), tags(:web) ].sort, card.tags.sort end + + test "update as JSON" do + card = cards(:logo) + put card_path(card, format: :json), params: { card: { title: "Update test" } } + + assert_response :success + assert_equal "Update test", card.reload.title + end + + test "delete as JSON" do + card = cards(:logo) + delete card_path(card, format: :json) + + assert_response :no_content + assert_not Card.exists?(card.id) + end end From 23e6f3b0957eba491006da68f86e4485b186441f Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 14:06:54 +0100 Subject: [PATCH 43/78] Add API for assigning cards --- app/controllers/cards/assignments_controller.rb | 5 +++++ .../cards/assignments_controller_test.rb | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/controllers/cards/assignments_controller.rb b/app/controllers/cards/assignments_controller.rb index 886537fa1..396fd3a5f 100644 --- a/app/controllers/cards/assignments_controller.rb +++ b/app/controllers/cards/assignments_controller.rb @@ -9,5 +9,10 @@ class Cards::AssignmentsController < ApplicationController def create @card.toggle_assignment @board.users.active.find(params[:assignee_id]) + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/test/controllers/cards/assignments_controller_test.rb b/test/controllers/cards/assignments_controller_test.rb index c823174a8..179f11c2d 100644 --- a/test/controllers/cards/assignments_controller_test.rb +++ b/test/controllers/cards/assignments_controller_test.rb @@ -22,6 +22,20 @@ class Cards::AssignmentsControllerTest < ActionDispatch::IntegrationTest end end + test "create as JSON" do + card = cards(:logo) + + assert_not card.assigned_to?(users(:david)) + + post card_assignments_path(card), params: { assignee_id: users(:david).id }, as: :json + assert_response :no_content + assert card.reload.assigned_to?(users(:david)) + + post card_assignments_path(card), params: { assignee_id: users(:david).id }, as: :json + assert_response :no_content + assert_not card.reload.assigned_to?(users(:david)) + end + private def assert_meta_replaced(card) assert_turbo_stream action: :replace, target: dom_id(card, :meta) From cf52982b8b4b87ee5193323c176c8adfe27cee4c Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 14:07:19 +0100 Subject: [PATCH 44/78] Add API for mobing cards between boards --- app/controllers/cards/boards_controller.rb | 6 +++++- test/controllers/cards/boards_controller_test.rb | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/controllers/cards/boards_controller.rb b/app/controllers/cards/boards_controller.rb index 4a8e503ab..3444cd345 100644 --- a/app/controllers/cards/boards_controller.rb +++ b/app/controllers/cards/boards_controller.rb @@ -11,7 +11,11 @@ class Cards::BoardsController < ApplicationController def update @card.move_to(@board) - redirect_to @card + + respond_to do |format| + format.html { redirect_to @card } + format.json { head :no_content } + end end private diff --git a/test/controllers/cards/boards_controller_test.rb b/test/controllers/cards/boards_controller_test.rb index 7f2b15c97..ebadff9b9 100644 --- a/test/controllers/cards/boards_controller_test.rb +++ b/test/controllers/cards/boards_controller_test.rb @@ -17,4 +17,16 @@ class Cards::BoardsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to card end + + test "update as JSON" do + card = cards(:logo) + new_board = boards(:private) + + assert_not_equal new_board, card.board + + put card_board_path(card), params: { board_id: new_board.id }, as: :json + + assert_response :no_content + assert_equal new_board, card.reload.board + end end From b4012dfb409ce3c6fde81242701a779dd8fea7a8 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 14:07:36 +0100 Subject: [PATCH 45/78] Add API for closing and opening cards --- app/controllers/cards/closures_controller.rb | 12 +++++++-- .../cards/closures_controller_test.rb | 26 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb index b23600d91..c4aac26d1 100644 --- a/app/controllers/cards/closures_controller.rb +++ b/app/controllers/cards/closures_controller.rb @@ -3,11 +3,19 @@ class Cards::ClosuresController < ApplicationController def create @card.close - render_card_replacement + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end end def destroy @card.reopen - render_card_replacement + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end end end diff --git a/test/controllers/cards/closures_controller_test.rb b/test/controllers/cards/closures_controller_test.rb index 73c1181e4..f95506f11 100644 --- a/test/controllers/cards/closures_controller_test.rb +++ b/test/controllers/cards/closures_controller_test.rb @@ -9,7 +9,7 @@ class Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest card = cards(:logo) assert_changes -> { card.reload.closed? }, from: false, to: true do - post card_closure_path(card) + post card_closure_path(card), as: :turbo_stream assert_card_container_rerendered(card) end end @@ -18,8 +18,30 @@ class Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest card = cards(:shipping) assert_changes -> { card.reload.closed? }, from: true, to: false do - delete card_closure_path(card) + delete card_closure_path(card), as: :turbo_stream assert_card_container_rerendered(card) end end + + test "create as JSON" do + card = cards(:logo) + + assert_not card.closed? + + post card_closure_path(card), as: :json + + assert_response :no_content + assert card.reload.closed? + end + + test "destroy as JSON" do + card = cards(:shipping) + + assert card.closed? + + delete card_closure_path(card), as: :json + + assert_response :no_content + assert_not card.reload.closed? + end end From 41115eaf87920f09cc592a3d53746772af644519 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 14:12:33 +0100 Subject: [PATCH 46/78] Add API for comments CRUD --- app/controllers/cards/comments_controller.rb | 15 ++++++++ app/views/cards/comments/show.json.jbuilder | 1 + .../cards/comments_controller_test.rb | 38 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 app/views/cards/comments/show.json.jbuilder diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb index b7c6c867a..c38181862 100644 --- a/app/controllers/cards/comments_controller.rb +++ b/app/controllers/cards/comments_controller.rb @@ -6,6 +6,11 @@ class Cards::CommentsController < ApplicationController def create @comment = @card.comments.create!(comment_params) + + respond_to do |format| + format.turbo_stream + format.json { head :created, location: card_comment_path(@card, @comment, format: :json) } + end end def show @@ -16,10 +21,20 @@ class Cards::CommentsController < ApplicationController def update @comment.update! comment_params + + respond_to do |format| + format.turbo_stream + format.json { render :show } + end end def destroy @comment.destroy + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/views/cards/comments/show.json.jbuilder b/app/views/cards/comments/show.json.jbuilder new file mode 100644 index 000000000..52ef29c2b --- /dev/null +++ b/app/views/cards/comments/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "cards/comments/comment", comment: @comment diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb index 856470302..8fc0289be 100644 --- a/test/controllers/cards/comments_controller_test.rb +++ b/test/controllers/cards/comments_controller_test.rb @@ -27,4 +27,42 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden end + + test "create as JSON" do + card = cards(:logo) + + assert_difference -> { card.comments.count }, +1 do + post card_comments_path(card), params: { comment: { body: "New comment" } }, as: :json + end + + assert_response :created + assert_equal card_comment_path(card, Comment.last, format: :json), @response.headers["Location"] + end + + test "show as JSON" do + comment = comments(:logo_agreement_kevin) + + get card_comment_path(cards(:logo), comment), as: :json + + assert_response :success + assert_equal comment.id, @response.parsed_body["id"] + end + + test "update as JSON" do + comment = comments(:logo_agreement_kevin) + + put card_comment_path(cards(:logo), comment), params: { comment: { body: "Updated comment" } }, as: :json + + assert_response :success + assert_equal "Updated comment", comment.reload.body.to_plain_text + end + + test "destroy as JSON" do + comment = comments(:logo_agreement_kevin) + + delete card_comment_path(cards(:logo), comment), as: :json + + assert_response :no_content + assert_not Comment.exists?(comment.id) + end end From 1caeea541d114fe21f64fa1a31dfa4a60e262676 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 14:37:04 +0100 Subject: [PATCH 47/78] Add API for gilding cards --- .../cards/goldnesses_controller.rb | 12 ++++++++-- .../cards/goldnesses_controller_test.rb | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/controllers/cards/goldnesses_controller.rb b/app/controllers/cards/goldnesses_controller.rb index 1a0912a24..b2b3e619d 100644 --- a/app/controllers/cards/goldnesses_controller.rb +++ b/app/controllers/cards/goldnesses_controller.rb @@ -3,11 +3,19 @@ class Cards::GoldnessesController < ApplicationController def create @card.gild - render_card_replacement + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end end def destroy @card.ungild - render_card_replacement + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end end end diff --git a/test/controllers/cards/goldnesses_controller_test.rb b/test/controllers/cards/goldnesses_controller_test.rb index 447aa2b71..3e4146443 100644 --- a/test/controllers/cards/goldnesses_controller_test.rb +++ b/test/controllers/cards/goldnesses_controller_test.rb @@ -18,4 +18,26 @@ class Cards::GoldnessesControllerTest < ActionDispatch::IntegrationTest assert_card_container_rerendered(cards(:logo)) end end + + test "create as JSON" do + card = cards(:text) + + assert_not card.golden? + + post card_goldness_path(card), as: :json + + assert_response :no_content + assert card.reload.golden? + end + + test "destroy as JSON" do + card = cards(:logo) + + assert card.golden? + + delete card_goldness_path(card), as: :json + + assert_response :no_content + assert_not card.reload.golden? + end end From 52e65b3c0827ea4a8775d3b628c4a0e05803a242 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 15:03:14 +0100 Subject: [PATCH 48/78] Add API for removing card images --- app/controllers/cards/images_controller.rb | 6 +++- .../cards/images_controller_test.rb | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 test/controllers/cards/images_controller_test.rb diff --git a/app/controllers/cards/images_controller.rb b/app/controllers/cards/images_controller.rb index fad419c52..f4fec16d5 100644 --- a/app/controllers/cards/images_controller.rb +++ b/app/controllers/cards/images_controller.rb @@ -3,6 +3,10 @@ class Cards::ImagesController < ApplicationController def destroy @card.image.purge_later - redirect_to @card + + respond_to do |format| + format.html { redirect_to @card } + format.json { head :no_content } + end end end diff --git a/test/controllers/cards/images_controller_test.rb b/test/controllers/cards/images_controller_test.rb new file mode 100644 index 000000000..5dbedcf22 --- /dev/null +++ b/test/controllers/cards/images_controller_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class Cards::ImagesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "destroy" do + card = cards(:logo) + card.image.attach(io: file_fixture("moon.jpg").open, filename: "moon.jpg") + + assert card.image.attached? + + delete card_image_path(card) + + assert_redirected_to card + assert_not card.reload.image.attached? + end + + test "destroy as JSON" do + card = cards(:logo) + card.image.attach(io: file_fixture("moon.jpg").open, filename: "moon.jpg") + + assert card.image.attached? + + delete card_image_path(card), as: :json + + assert_response :no_content + assert_not card.reload.image.attached? + end +end From 8a61ca2a79b3c4528f6d5845607c34410c8b92cf Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 15:03:32 +0100 Subject: [PATCH 49/78] Add API for postponing cards --- app/controllers/cards/not_nows_controller.rb | 6 +++++- test/controllers/cards/not_nows_controller_test.rb | 13 ++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/controllers/cards/not_nows_controller.rb b/app/controllers/cards/not_nows_controller.rb index 8f43db1be..8eeb0e663 100644 --- a/app/controllers/cards/not_nows_controller.rb +++ b/app/controllers/cards/not_nows_controller.rb @@ -3,6 +3,10 @@ class Cards::NotNowsController < ApplicationController def create @card.postpone - render_card_replacement + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end end end diff --git a/test/controllers/cards/not_nows_controller_test.rb b/test/controllers/cards/not_nows_controller_test.rb index 0e1313802..e53edabfb 100644 --- a/test/controllers/cards/not_nows_controller_test.rb +++ b/test/controllers/cards/not_nows_controller_test.rb @@ -9,8 +9,19 @@ class Cards::NotNowsControllerTest < ActionDispatch::IntegrationTest card = cards(:logo) assert_changes -> { card.reload.postponed? }, from: false, to: true do - post card_not_now_path(card) + post card_not_now_path(card), as: :turbo_stream assert_card_container_rerendered(card) end end + + test "create as JSON" do + card = cards(:logo) + + assert_not card.postponed? + + post card_not_now_path(card), as: :json + + assert_response :no_content + assert card.reload.postponed? + end end From c517ef606317bf3782513644b38968ad00610191 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 15:30:15 +0100 Subject: [PATCH 50/78] Add API for CRUD actions on steps --- app/controllers/cards/steps_controller.rb | 15 +++++++ app/views/cards/steps/_step.json.jbuilder | 1 + app/views/cards/steps/show.json.jbuilder | 1 + .../cards/steps_controller_test.rb | 43 +++++++++++++++++++ 4 files changed, 60 insertions(+) create mode 100644 app/views/cards/steps/_step.json.jbuilder create mode 100644 app/views/cards/steps/show.json.jbuilder diff --git a/app/controllers/cards/steps_controller.rb b/app/controllers/cards/steps_controller.rb index 305890508..0b788fe36 100644 --- a/app/controllers/cards/steps_controller.rb +++ b/app/controllers/cards/steps_controller.rb @@ -5,6 +5,11 @@ class Cards::StepsController < ApplicationController 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) } + end end def show @@ -15,10 +20,20 @@ class Cards::StepsController < ApplicationController def update @step.update!(step_params) + + respond_to do |format| + format.turbo_stream + format.json { render :show } + end end def destroy @step.destroy! + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/views/cards/steps/_step.json.jbuilder b/app/views/cards/steps/_step.json.jbuilder new file mode 100644 index 000000000..6cf500791 --- /dev/null +++ b/app/views/cards/steps/_step.json.jbuilder @@ -0,0 +1 @@ +json.(step, :id, :content, :completed) diff --git a/app/views/cards/steps/show.json.jbuilder b/app/views/cards/steps/show.json.jbuilder new file mode 100644 index 000000000..1190f84e1 --- /dev/null +++ b/app/views/cards/steps/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "cards/steps/step", step: @step diff --git a/test/controllers/cards/steps_controller_test.rb b/test/controllers/cards/steps_controller_test.rb index 72c24f071..039fbf4c4 100644 --- a/test/controllers/cards/steps_controller_test.rb +++ b/test/controllers/cards/steps_controller_test.rb @@ -52,4 +52,47 @@ class Cards::StepsControllerTest < ActionDispatch::IntegrationTest assert_turbo_stream action: :replace, target: dom_id(step) end end + + test "create as JSON" do + card = cards(:logo) + + assert_difference -> { card.steps.count }, +1 do + post card_steps_path(card), params: { step: { content: "New step" } }, as: :json + end + + assert_response :created + assert_equal card_step_path(card, Step.last, format: :json), @response.headers["Location"] + end + + test "show as JSON" do + card = cards(:logo) + step = card.steps.create!(content: "Test step") + + get card_step_path(card, step), as: :json + + assert_response :success + assert_equal step.id, @response.parsed_body["id"] + assert_equal "Test step", @response.parsed_body["content"] + end + + test "update as JSON" do + card = cards(:logo) + step = card.steps.create!(content: "Original") + + put card_step_path(card, step), params: { step: { content: "Updated" } }, as: :json + + assert_response :success + assert_equal "Updated", step.reload.content + assert_equal "Updated", @response.parsed_body["content"] + end + + test "destroy as JSON" do + card = cards(:logo) + step = card.steps.create!(content: "To delete") + + delete card_step_path(card, step), as: :json + + assert_response :no_content + assert_not Step.exists?(step.id) + end end From 8a8478505655e8c53dd47973aec0f98000a2c1db Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 15:36:09 +0100 Subject: [PATCH 51/78] Add API for tagging cards --- app/controllers/cards/taggings_controller.rb | 5 +++++ .../cards/taggings_controller_test.rb | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/app/controllers/cards/taggings_controller.rb b/app/controllers/cards/taggings_controller.rb index 9190b20f1..2385b4326 100644 --- a/app/controllers/cards/taggings_controller.rb +++ b/app/controllers/cards/taggings_controller.rb @@ -9,6 +9,11 @@ class Cards::TaggingsController < ApplicationController def create @card.toggle_tag_with sanitized_tag_title_param + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/test/controllers/cards/taggings_controller_test.rb b/test/controllers/cards/taggings_controller_test.rb index 5424dcc0d..0cd214509 100644 --- a/test/controllers/cards/taggings_controller_test.rb +++ b/test/controllers/cards/taggings_controller_test.rb @@ -23,4 +23,26 @@ class Cards::TaggingsControllerTest < ActionDispatch::IntegrationTest assert_turbo_stream action: :replace, target: dom_id(cards(:logo), :tags) end end + + test "toggle tag on as JSON" do + card = cards(:logo) + + assert_not card.tagged_with?(tags(:mobile)) + + post card_taggings_path(card), params: { tag_title: tags(:mobile).title }, as: :json + + assert_response :no_content + assert card.reload.tagged_with?(tags(:mobile)) + end + + test "toggle tag off as JSON" do + card = cards(:logo) + + assert card.tagged_with?(tags(:web)) + + post card_taggings_path(card), params: { tag_title: tags(:web).title }, as: :json + + assert_response :no_content + assert_not card.reload.tagged_with?(tags(:web)) + end end From ed1002f6c5e69e9ac06f7669db492c053a7cdcc3 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 15:36:32 +0100 Subject: [PATCH 52/78] Add API for card triage --- app/controllers/cards/triages_controller.rb | 11 ++++++++-- .../cards/triages_controller_test.rb | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/controllers/cards/triages_controller.rb b/app/controllers/cards/triages_controller.rb index 0c1a7bd1e..d8fc548f1 100644 --- a/app/controllers/cards/triages_controller.rb +++ b/app/controllers/cards/triages_controller.rb @@ -5,11 +5,18 @@ class Cards::TriagesController < ApplicationController column = @card.board.columns.find(params[:column_id]) @card.triage_into(column) - redirect_to @card + respond_to do |format| + format.html { redirect_to @card } + format.json { head :no_content } + end end def destroy @card.send_back_to_triage - redirect_to @card + + respond_to do |format| + format.html { redirect_to @card } + format.json { head :no_content } + end end end diff --git a/test/controllers/cards/triages_controller_test.rb b/test/controllers/cards/triages_controller_test.rb index 9076cdade..02b6d8178 100644 --- a/test/controllers/cards/triages_controller_test.rb +++ b/test/controllers/cards/triages_controller_test.rb @@ -24,4 +24,25 @@ class Cards::TriagesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to card end end + + test "create as JSON" do + card = cards(:logo) + column = columns(:writebook_in_progress) + + post card_triage_path(card, column_id: column.id), as: :json + + assert_response :no_content + assert_equal column, card.reload.column + end + + test "destroy as JSON" do + card = cards(:shipping) + + assert card.column.present? + + delete card_triage_path(card), as: :json + + assert_response :no_content + assert_nil card.reload.column + end end From eeed2a8416e3e4134873aefdad51e62341067627 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 15:37:54 +0100 Subject: [PATCH 53/78] Add API for watching cards --- app/controllers/cards/watches_controller.rb | 10 +++++++ .../cards/watches_controller_test.rb | 28 +++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/controllers/cards/watches_controller.rb b/app/controllers/cards/watches_controller.rb index 4d83567d0..9d314dedf 100644 --- a/app/controllers/cards/watches_controller.rb +++ b/app/controllers/cards/watches_controller.rb @@ -7,9 +7,19 @@ class Cards::WatchesController < ApplicationController def create @card.watch_by Current.user + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end def destroy @card.unwatch_by Current.user + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/test/controllers/cards/watches_controller_test.rb b/test/controllers/cards/watches_controller_test.rb index 5440a326b..7bcd82c3d 100644 --- a/test/controllers/cards/watches_controller_test.rb +++ b/test/controllers/cards/watches_controller_test.rb @@ -9,7 +9,7 @@ class Cards::WatchesControllerTest < ActionDispatch::IntegrationTest cards(:logo).unwatch_by users(:kevin) assert_changes -> { cards(:logo).watched_by?(users(:kevin)) }, from: false, to: true do - post card_watch_path(cards(:logo)) + post card_watch_path(cards(:logo)), as: :turbo_stream end end @@ -17,7 +17,31 @@ class Cards::WatchesControllerTest < ActionDispatch::IntegrationTest cards(:logo).watch_by users(:kevin) assert_changes -> { cards(:logo).watched_by?(users(:kevin)) }, from: true, to: false do - delete card_watch_path(cards(:logo)) + delete card_watch_path(cards(:logo)), as: :turbo_stream end end + + test "create as JSON" do + card = cards(:logo) + card.unwatch_by users(:kevin) + + assert_not card.watched_by?(users(:kevin)) + + post card_watch_path(card), as: :json + + assert_response :no_content + assert card.reload.watched_by?(users(:kevin)) + end + + test "destroy as JSON" do + card = cards(:logo) + card.watch_by users(:kevin) + + assert card.watched_by?(users(:kevin)) + + delete card_watch_path(card), as: :json + + assert_response :no_content + assert_not card.reload.watched_by?(users(:kevin)) + end end From d16b3756de4252446e0579b31ebea237a55cb470 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 5 Dec 2025 16:33:29 +0100 Subject: [PATCH 54/78] Add API for reactions --- .../cards/comments/reactions_controller.rb | 10 +++++++ app/views/cards/_card.json.jbuilder | 2 ++ .../reactions/_reaction.json.jbuilder | 5 ++++ .../comments/reactions/index.json.jbuilder | 1 + .../comments/reactions_controller_test.rb | 30 +++++++++++++++++++ 5 files changed, 48 insertions(+) create mode 100644 app/views/cards/comments/reactions/_reaction.json.jbuilder create mode 100644 app/views/cards/comments/reactions/index.json.jbuilder diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb index c65d34291..0f1277a3c 100644 --- a/app/controllers/cards/comments/reactions_controller.rb +++ b/app/controllers/cards/comments/reactions_controller.rb @@ -13,10 +13,20 @@ class Cards::Comments::ReactionsController < ApplicationController def create @reaction = @comment.reactions.create!(params.expect(reaction: :content)) + + respond_to do |format| + format.turbo_stream + format.json { head :created } + end end def destroy @reaction.destroy + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index 976785b41..a7116d7dd 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -23,4 +23,6 @@ json.cache! [ card, card.column&.color ] do json.creator do json.partial! "users/user", user: card.creator end + + json.comments_url card_comments_url(card) end diff --git a/app/views/cards/comments/reactions/_reaction.json.jbuilder b/app/views/cards/comments/reactions/_reaction.json.jbuilder new file mode 100644 index 000000000..2c743b7c9 --- /dev/null +++ b/app/views/cards/comments/reactions/_reaction.json.jbuilder @@ -0,0 +1,5 @@ +json.(reaction, :id, :content) +json.reacter do + json.partial! "users/user", user: reaction.reacter +end +json.url card_comment_reaction_url(reaction.comment.card, reaction.comment, reaction) diff --git a/app/views/cards/comments/reactions/index.json.jbuilder b/app/views/cards/comments/reactions/index.json.jbuilder new file mode 100644 index 000000000..5dd744f12 --- /dev/null +++ b/app/views/cards/comments/reactions/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @comment.reactions.ordered, partial: "cards/comments/reactions/reaction", as: :reaction diff --git a/test/controllers/cards/comments/reactions_controller_test.rb b/test/controllers/cards/comments/reactions_controller_test.rb index 57d635e96..21a94c5f3 100644 --- a/test/controllers/cards/comments/reactions_controller_test.rb +++ b/test/controllers/cards/comments/reactions_controller_test.rb @@ -7,6 +7,11 @@ class Cards::Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest @card = @comment.card end + test "index" do + get card_comment_reactions_path(@card, @comment) + assert_response :success + end + test "create" do assert_difference -> { @comment.reactions.count }, 1 do post card_comment_reactions_path(@comment.card, @comment, format: :turbo_stream), params: { reaction: { content: "Great work!" } } @@ -30,4 +35,29 @@ class Cards::Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden end end + + test "index as JSON" do + get card_comment_reactions_path(@card, @comment), as: :json + + assert_response :success + assert_equal @comment.reactions.count, @response.parsed_body.count + end + + test "create as JSON" do + assert_difference -> { @comment.reactions.count }, 1 do + post card_comment_reactions_path(@card, @comment), params: { reaction: { content: "👍" } }, as: :json + end + + assert_response :created + end + + test "destroy as JSON" do + reaction = reactions(:david) + + assert_difference -> { @comment.reactions.count }, -1 do + delete card_comment_reaction_path(@card, @comment, reaction), as: :json + end + + assert_response :no_content + end end From 7a0554774c7896dce3d46ba07286693634a63a9a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 11:30:37 +0100 Subject: [PATCH 55/78] Add API for columns --- app/controllers/boards/columns_controller.rb | 20 ++++++++++ app/views/boards/columns/show.json.jbuilder | 1 + .../boards/columns_controller_test.rb | 39 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 app/views/boards/columns/show.json.jbuilder diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb index fa4f3e072..1532d76dc 100644 --- a/app/controllers/boards/columns_controller.rb +++ b/app/controllers/boards/columns_controller.rb @@ -6,18 +6,38 @@ class Boards::ColumnsController < ApplicationController def show set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded fresh_when etag: @page.records + + respond_to do |format| + format.html + format.json + end end def create @column = @board.columns.create!(column_params) + + respond_to do |format| + format.turbo_stream + format.json { head :created, location: board_column_path(@board, @column, format: :json) } + end end def update @column.update!(column_params) + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end def destroy @column.destroy + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/views/boards/columns/show.json.jbuilder b/app/views/boards/columns/show.json.jbuilder new file mode 100644 index 000000000..f94e6584b --- /dev/null +++ b/app/views/boards/columns/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "columns/column", column: @column diff --git a/test/controllers/boards/columns_controller_test.rb b/test/controllers/boards/columns_controller_test.rb index 7c6e7f525..2bc96357c 100644 --- a/test/controllers/boards/columns_controller_test.rb +++ b/test/controllers/boards/columns_controller_test.rb @@ -36,4 +36,43 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest assert_response :success end end + + test "show as JSON" do + column = columns(:writebook_in_progress) + + get board_column_path(column.board, column), as: :json + + assert_response :success + assert_equal column.id, @response.parsed_body["id"] + end + + test "create as JSON" do + board = boards(:writebook) + + assert_difference -> { board.columns.count }, +1 do + post board_columns_path(board), params: { column: { name: "New Column" } }, as: :json + end + + assert_response :created + assert_equal board_column_path(board, Column.last, format: :json), @response.headers["Location"] + end + + test "update as JSON" do + column = columns(:writebook_in_progress) + + put board_column_path(column.board, column), params: { column: { name: "Updated Name" } }, as: :json + + assert_response :no_content + assert_equal "Updated Name", column.reload.name + end + + test "destroy as JSON" do + column = columns(:writebook_on_hold) + + assert_difference -> { column.board.columns.count }, -1 do + delete board_column_path(column.board, column), as: :json + end + + assert_response :no_content + end end From ba30a301dcae18ea0403db969b9bf9e37b597bfc Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 12:17:42 +0100 Subject: [PATCH 56/78] Add API for updating and deactivating users --- app/controllers/users_controller.rb | 11 +++++++++-- test/controllers/users_controller_test.rb | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index df2f0e3c9..53547f901 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -14,7 +14,10 @@ class UsersController < ApplicationController def update if @user.update(user_params) - redirect_to @user + respond_to do |format| + format.html { redirect_to @user } + format.json { head :no_content } + end else render :edit, status: :unprocessable_entity end @@ -22,7 +25,11 @@ class UsersController < ApplicationController def destroy @user.deactivate - redirect_to users_path + + respond_to do |format| + format.html { redirect_to users_path } + format.json { head :no_content } + end end private diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index ae00982e7..bf6854a0d 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -102,4 +102,23 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_equal users(:david).name, @response.parsed_body["name"] end + + test "update as JSON" do + sign_in_as :kevin + + put user_path(users(:david)), params: { user: { name: "New David" } }, as: :json + + assert_response :no_content + assert_equal "New David", users(:david).reload.name + end + + test "destroy as JSON" do + sign_in_as :kevin + + assert_difference -> { User.active.count }, -1 do + delete user_path(users(:david)), as: :json + end + + assert_response :no_content + end end From eb787fb59011b9d96b91c04db36158860cea3266 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 12:18:51 +0100 Subject: [PATCH 57/78] Replace external_account_id with slug The account ID isn't useful on its own since it has to be formatted to be used as part of a URL. Therfore let's return the slug instead. --- app/views/identities/_account.json.jbuilder | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/identities/_account.json.jbuilder b/app/views/identities/_account.json.jbuilder index d3954ec2f..4c2076f0c 100644 --- a/app/views/identities/_account.json.jbuilder +++ b/app/views/identities/_account.json.jbuilder @@ -1,4 +1,4 @@ json.cache! account do - json.(account, :id, :name, :external_account_id) + json.(account, :id, :name, :slug) json.created_at account.created_at.utc end From f3ff0c605e895729e2de4fdfbced6578a1302ac4 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 12:43:45 +0100 Subject: [PATCH 58/78] Add pagination to most places and fix cards pagination --- app/controllers/boards_controller.rb | 2 +- app/controllers/tags_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- app/views/boards/index.json.jbuilder | 2 +- app/views/cards/index.json.jbuilder | 2 -- app/views/tags/index.json.jbuilder | 2 +- app/views/users/index.json.jbuilder | 2 +- test/controllers/tags_controller_test.rb | 2 +- 8 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index ae2f59a88..0214f9ce6 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -5,7 +5,7 @@ class BoardsController < ApplicationController before_action :ensure_permission_to_admin_board, only: %i[ update destroy ] def index - @boards = Current.user.boards + set_page_and_extract_portion_from Current.user.boards end def show diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 2dc468f0d..6b72fdf50 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -1,5 +1,5 @@ class TagsController < ApplicationController def index - @tags = Current.account.tags.all.alphabetically + set_page_and_extract_portion_from Current.account.tags.alphabetically end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 53547f901..11bd28b7a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,7 +3,7 @@ class UsersController < ApplicationController before_action :ensure_permission_to_change_user, only: %i[ update destroy ] def index - @users = Current.account.users.active.alphabetically + set_page_and_extract_portion_from Current.account.users.active.alphabetically end def show diff --git a/app/views/boards/index.json.jbuilder b/app/views/boards/index.json.jbuilder index fe042c91c..047401cff 100644 --- a/app/views/boards/index.json.jbuilder +++ b/app/views/boards/index.json.jbuilder @@ -1 +1 @@ -json.array! @boards, partial: "boards/board", as: :board +json.array! @page.records, partial: "boards/board", as: :board diff --git a/app/views/cards/index.json.jbuilder b/app/views/cards/index.json.jbuilder index f3f204632..c1dc1dff1 100644 --- a/app/views/cards/index.json.jbuilder +++ b/app/views/cards/index.json.jbuilder @@ -1,3 +1 @@ json.array! @page.records, partial: "cards/card", as: :card - -json.next_page_url cards_path(@board, page: @page.next_param) unless @page.last? diff --git a/app/views/tags/index.json.jbuilder b/app/views/tags/index.json.jbuilder index 03e8857d4..58fa7f00f 100644 --- a/app/views/tags/index.json.jbuilder +++ b/app/views/tags/index.json.jbuilder @@ -1 +1 @@ -json.array! @tags, partial: "tags/tag", as: :tag +json.array! @page.records, partial: "tags/tag", as: :tag diff --git a/app/views/users/index.json.jbuilder b/app/views/users/index.json.jbuilder index 98788dadd..2472a7b47 100644 --- a/app/views/users/index.json.jbuilder +++ b/app/views/users/index.json.jbuilder @@ -1 +1 @@ -json.array! @users, partial: "users/user", as: :user +json.array! @page.records, partial: "users/user", as: :user diff --git a/test/controllers/tags_controller_test.rb b/test/controllers/tags_controller_test.rb index e3685e60b..d99939c60 100644 --- a/test/controllers/tags_controller_test.rb +++ b/test/controllers/tags_controller_test.rb @@ -6,7 +6,7 @@ class TagsControllerTest < ActionDispatch::IntegrationTest end test "index as JSON" do - tags = users(:kevin).account.tags.all.alphabetically + tags = users(:kevin).account.tags.alphabetically get tags_path, as: :json assert_response :success From 1a24c1373cdbe169f54d674dbdbb6a7349d77f0c Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 12:52:14 +0100 Subject: [PATCH 59/78] Add API for creating and updating boards --- app/controllers/boards_controller.rb | 19 ++++++++++++++----- test/controllers/boards_controller_test.rb | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 0214f9ce6..6c8a17575 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -39,16 +39,25 @@ class BoardsController < ApplicationController @board.update! board_params @board.accesses.revise granted: grantees, revoked: revokees if grantees_changed? - if @board.accessible_to?(Current.user) - redirect_to edit_board_path(@board), notice: "Saved" - else - redirect_to root_path, notice: "Saved (you were removed from the board)" + respond_to do |format| + format.html do + if @board.accessible_to?(Current.user) + redirect_to edit_board_path(@board), notice: "Saved" + else + redirect_to root_path, notice: "Saved (you were removed from the board)" + end + end + format.json { head :no_content } end end def destroy @board.destroy - redirect_to root_path + + respond_to do |format| + format.html { redirect_to root_path } + format.json { head :no_content } + end end private diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index 6701b802d..7669881cb 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -211,4 +211,23 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_response :created assert_equal board_path(Board.last, format: :json), @response.headers["Location"] end + + test "update as JSON" do + board = boards(:writebook) + + put board_path(board), params: { board: { name: "Updated Name" } }, as: :json + + assert_response :no_content + assert_equal "Updated Name", board.reload.name + end + + test "destroy as JSON" do + board = boards(:writebook) + + assert_difference -> { Board.count }, -1 do + delete board_path(board), as: :json + end + + assert_response :no_content + end end From bf519500072f279dedf646d2d62209623d14054b Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 15:11:12 +0100 Subject: [PATCH 60/78] Add API for reading notifications --- .../notifications/bulk_readings_controller.rb | 13 +- .../notifications/readings_controller.rb | 10 + app/controllers/notifications_controller.rb | 1 + .../notifications/_notification.json.jbuilder | 19 ++ app/views/notifications/index.json.jbuilder | 1 + .../notification/event/_body.json.jbuilder | 2 + .../notification/mention/_body.json.jbuilder | 4 + docs/API.md | 263 ++++++++++++++++++ .../bulk_readings_controller_test.rb | 10 + .../notifications/readings_controller_test.rb | 17 ++ .../notifications_controller_test.rb | 23 ++ 11 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 app/views/notifications/_notification.json.jbuilder create mode 100644 app/views/notifications/index.json.jbuilder create mode 100644 app/views/notifications/notification/event/_body.json.jbuilder create mode 100644 app/views/notifications/notification/mention/_body.json.jbuilder create mode 100644 docs/API.md diff --git a/app/controllers/notifications/bulk_readings_controller.rb b/app/controllers/notifications/bulk_readings_controller.rb index 5f80938fe..2c15a871b 100644 --- a/app/controllers/notifications/bulk_readings_controller.rb +++ b/app/controllers/notifications/bulk_readings_controller.rb @@ -2,10 +2,15 @@ class Notifications::BulkReadingsController < ApplicationController def create Current.user.notifications.unread.read_all - if from_tray? - head :ok - else - redirect_to notifications_path + respond_to do |format| + format.html do + if from_tray? + head :ok + else + redirect_to notifications_path + end + end + format.json { head :no_content } end end diff --git a/app/controllers/notifications/readings_controller.rb b/app/controllers/notifications/readings_controller.rb index 2accc3373..622668efc 100644 --- a/app/controllers/notifications/readings_controller.rb +++ b/app/controllers/notifications/readings_controller.rb @@ -2,10 +2,20 @@ class Notifications::ReadingsController < ApplicationController def create @notification = Current.user.notifications.find(params[:notification_id]) @notification.read + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end def destroy @notification = Current.user.notifications.find(params[:notification_id]) @notification.unread + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 76bd62974..1850aa202 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -8,6 +8,7 @@ class NotificationsController < ApplicationController respond_to do |format| format.turbo_stream if current_page_param # Allows read-all action to side step pagination format.html + format.json end end end diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder new file mode 100644 index 000000000..14b92cc42 --- /dev/null +++ b/app/views/notifications/_notification.json.jbuilder @@ -0,0 +1,19 @@ +json.cache! notification do + json.(notification, :id) + json.read notification.read? + json.read_at notification.read_at&.utc + json.created_at notification.created_at.utc + + json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification + + json.creator do + json.partial! "users/user", user: notification.creator + end + + json.card do + json.(notification.card, :id, :title, :status) + json.url card_url(notification.card) + end + + json.url notification_url(notification) +end diff --git a/app/views/notifications/index.json.jbuilder b/app/views/notifications/index.json.jbuilder new file mode 100644 index 000000000..660bbb673 --- /dev/null +++ b/app/views/notifications/index.json.jbuilder @@ -0,0 +1 @@ +json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification diff --git a/app/views/notifications/notification/event/_body.json.jbuilder b/app/views/notifications/notification/event/_body.json.jbuilder new file mode 100644 index 000000000..7ea7510e5 --- /dev/null +++ b/app/views/notifications/notification/event/_body.json.jbuilder @@ -0,0 +1,2 @@ +json.title event_notification_title(notification.source) +json.body event_notification_body(notification.source) diff --git a/app/views/notifications/notification/mention/_body.json.jbuilder b/app/views/notifications/notification/mention/_body.json.jbuilder new file mode 100644 index 000000000..970088149 --- /dev/null +++ b/app/views/notifications/notification/mention/_body.json.jbuilder @@ -0,0 +1,4 @@ +mention = notification.source + +json.title "#{mention.mentioner.first_name} @mentioned you" +json.body mention.source.mentionable_content.truncate(200) diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 000000000..dc1128d5f --- /dev/null +++ b/docs/API.md @@ -0,0 +1,263 @@ +# Fizzy API + +Fizzy has an API that allows you to integrate your application with it or to create +a bot to perform various actions for you. + +## Authentication + +To use the API you'll need an access token. To get one, go to your profile, then, +in the API section, click on "Personal access tokens" and then click on +"Generate new access token". + +Pick what kind of permission you want the access token to have: +- `Read`: allows reading data from your account +- `Read + Write`: allows reading and writing data to your account on your behalf + +> [!IMPORTANT] +> __An access token is like a password, keep it secret and do not share it with anyone.__ +> Any person or application that has your access token can perform actions on your behalf. + +To authenticate a request using your access token, include it in the `Authorization` header: + +```bash +curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" https://app.fizzy.do/identity +``` + +## Caching + +Most endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data. + +### Using ETags + +When you make a request, the response includes an `ETag` header: + +``` +HTTP/1.1 200 OK +ETag: "abc123" +Cache-Control: max-age=0, private, must-revalidate +``` + +On subsequent requests, include the ETag value in the `If-None-Match` header: + +``` +GET /1234567/cards/42.json +If-None-Match: "abc123" +``` + +If the resource hasn't changed, you'll receive a `304 Not Modified` response with no body, saving bandwidth and processing time: + +``` +HTTP/1.1 304 Not Modified +ETag: "abc123" +``` + +If the resource has changed, you'll receive the full response with a new ETag. + +__Example in Ruby:__ + +```ruby +# Store the ETag from the response +etag = response.headers["ETag"] + +# On next request, send it back +headers = { "If-None-Match" => etag } +response = client.get("/1234567/cards/42.json", headers: headers) + +if response.status == 304 + # Nothing to do, the card hasn't changed +else + # The card has changed, process the new data +end +``` + +## Pagination + +All endpoints that return a list of items are paginated. The page size can vary from endpoint to endpoint, +and we use a dynamic page size where initial pages return fewer results than later pages. + +If there are more results to fetch, the response will include a `Link` header with a `rel="next"` link to the next page of results: + +```bash +curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" -v http://fizzy.localhost:3006/686465299/cards +# ... +< link: ; rel="next" +# ... +``` + +## Endpoints + +### Identity + +An Identity represents a person using Fizzy, their email address and their users in different accounts. + +#### `GET /identity` + +Returns a list of accounts, including the User, the Identity has access to + +```json +{ + "accounts": [ + { + "id": "03f5v9zjskhcii2r45ih3u1rq", + "name": "37signals", + "slug": "/897362094", + "created_at": "2025-12-05T19:36:35.377Z", + "user": { + "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/users/03f5v9zjw7pz8717a4no1h8a7" + } + }, + { + "id": "03f5v9zpko7mmhjzwum3youpp", + "name": "Honcho", + "slug": "/686465299", + "created_at": "2025-12-05T19:36:36.746Z", + "user": { + "id": "03f5v9zppzlksuj4mxba2nbzn", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:36.783Z", + "url": "http://fizzy.localhost:3006/users/03f5v9zppzlksuj4mxba2nbzn" + } + } + ] +} +``` + +### Boards + +Boards are where you organize your work - they contain your cards. + +#### `GET /:account_slug/boards` + +Returns a list of Boards, that you can access, in the specified account. + +__Response:__ + +```json +[ + { + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Fizzy", + "all_access": true, + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", + "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` + +Returns the specific Board. + +__Response:__ + +```json +{ + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Fizzy", + "all_access": true, + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", + "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" + } +} +``` + +#### `POST /:account_slug/boards` + +Creates a new Board in the account. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | Yes | The name of the board | +| `all_access` | boolean | No | Whether any user in the account can access this board. Defaults to `true` | +| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed | +| `public_description` | string | No | Rich text description shown on the public board page | + +__Request:__ + +```json +{ + "board": { + "name": "My new board", + } +} +``` + +__Response:__ + +Returns `201 Created` with a `Location` header pointing to the new board: + +``` +HTTP/1.1 201 Created +Location: /897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm.json +``` + +#### `PUT /:account_slug/boards/:board_id` + +Updates a Board. Only board administrators can update a board. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | No | The name of the board | +| `all_access` | boolean | No | Whether any user in the account can access this board | +| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed | +| `public_description` | string | No | Rich text description shown on the public board page | +| `user_ids` | array | No | Array of *all* user IDs who should have access to this board (only applicable when `all_access` is `false`) | + +__Request:__ + +```json +{ + "board": { + "name": "Updated board name", + "auto_postpone_period": 14, + "public_description": "This is a **public** description of the board.", + all_access: false, + "user_ids": [ + "03f5v9zppzlksuj4mxba2nbzn", + "03f5v9zjw7pz8717a4no1h8a7" + ] + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + +#### `DELETE /:account_slug/boards/:board_id` + +Deletes a Board. Only board administrators can delete a board. + +__Response:__ + +Returns `204 No Content` on success. + +### Cards + +Cards represent tasks or items of work on a board diff --git a/test/controllers/notifications/bulk_readings_controller_test.rb b/test/controllers/notifications/bulk_readings_controller_test.rb index 124490f53..3512b7b43 100644 --- a/test/controllers/notifications/bulk_readings_controller_test.rb +++ b/test/controllers/notifications/bulk_readings_controller_test.rb @@ -22,4 +22,14 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes post bulk_reading_path, params: { from_tray: true } assert_response :ok end + + test "create as JSON" do + assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do + post bulk_reading_path, as: :json + end + end + + assert_response :no_content + end end diff --git a/test/controllers/notifications/readings_controller_test.rb b/test/controllers/notifications/readings_controller_test.rb index f25d77ccc..3f2a690a9 100644 --- a/test/controllers/notifications/readings_controller_test.rb +++ b/test/controllers/notifications/readings_controller_test.rb @@ -21,4 +21,21 @@ class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest assert_response :success end end + + test "create as JSON" do + assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + post notification_reading_path(notifications(:logo_published_kevin)), as: :json + assert_response :no_content + end + end + + test "destroy as JSON" do + notification = notifications(:logo_published_kevin) + notification.read + + assert_changes -> { notification.reload.read? }, from: true, to: false do + delete notification_reading_path(notification), as: :json + assert_response :no_content + end + end end diff --git a/test/controllers/notifications_controller_test.rb b/test/controllers/notifications_controller_test.rb index 13c211783..e4e7bec38 100644 --- a/test/controllers/notifications_controller_test.rb +++ b/test/controllers/notifications_controller_test.rb @@ -1,4 +1,27 @@ require "test_helper" class NotificationsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "index as JSON" do + get notifications_path, as: :json + + assert_response :success + assert_kind_of Array, @response.parsed_body + assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_published_kevin).id } + end + + test "index as JSON includes notification attributes" do + get notifications_path, as: :json + + notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_published_kevin).id } + + assert_not_nil notification["title"] + assert_not_nil notification["body"] + assert_not_nil notification["created_at"] + assert_not_nil notification["card"] + assert_not_nil notification["creator"] + end end From 6b229424a33bc969664debce8fd5033cfc52eebb Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 15:13:13 +0100 Subject: [PATCH 61/78] Lower the number of returned unread notifications After talking to the mobile team we cam to the conclusion that for the API and the mobile apps speed is of the utmost importance when working with notifications. So we agreed to lower the unread notifications limit to 100 like we have in Basecamp. This strikes a balance between speed and usability. --- app/controllers/notifications_controller.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 1850aa202..b116ed1a2 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -1,8 +1,9 @@ class NotificationsController < ApplicationController MAX_UNREAD_NOTIFICATIONS = 500 + MAX_UNREAD_NOTIFICATIONS_VIA_API = 100 def index - @unread = Current.user.notifications.unread.ordered.preloaded.limit(MAX_UNREAD_NOTIFICATIONS) unless current_page_param + @unread = Current.user.notifications.unread.ordered.preloaded.limit(max_unread_notifications) unless current_page_param set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded respond_to do |format| @@ -11,4 +12,9 @@ class NotificationsController < ApplicationController format.json end end + + private + def max_unread_notifications + request.format.json? ? MAX_UNREAD_NOTIFICATIONS_VIA_API : MAX_UNREAD_NOTIFICATIONS + end end From db22745d642bee0abd40e1ff81fceb7288b35655 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 15:22:34 +0100 Subject: [PATCH 62/78] Ignore documentation in Docker images --- .dockerignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.dockerignore b/.dockerignore index 96123753a..df5bbdacc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,14 @@ # Ignore bundler config. /.bundle +# Ignore documentation +/docs/ +/README.md +/CLAUDE.md +/AGENTS.md +/STYLE.md +/CONTRIBUTING.md + # Ignore all environment files (except templates). /.env* !/.env*.erb From 0c75712e83023425c3bfa5699796a252b7f6a1f9 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 15:31:54 +0100 Subject: [PATCH 63/78] Document notifications endpoints --- docs/API.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index dc1128d5f..9e79ed2de 100644 --- a/docs/API.md +++ b/docs/API.md @@ -260,4 +260,75 @@ Returns `204 No Content` on success. ### Cards -Cards represent tasks or items of work on a board +Cards are tasks or items of work on a board + +### Columns + +Columns represent stages in a workflow on a board. Cards move through columns as they progress. + +### Users + +Users represent people who have access to an account. + +### Notifications + +Notifications inform users about events that happened in the account, such as comments, assignments, and card updates. + +#### `GET /:account_slug/notifications` + +Returns a list of notifications for the current user. Unread notifications are returned first, followed by read notifications. + +__Response:__ + +```json +[ + { + "id": "03f5va03bpuvkcjemcxl73ho2", + "read": false, + "read_at": null, + "created_at": "2025-11-19T04:03:58.000Z", + "title": "Plain text mentions", + "body": "Assigned to self", + "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" + }, + "card": { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "title": "Plain text mentions", + "status": "published", + "url": "http://fizzy.localhost:3006/897362094/cards/3" + }, + "url": "http://fizzy.localhost:3006/897362094/notifications/03f5va03bpuvkcjemcxl73ho2" + } +] +``` + +#### `POST /:account_slug/notifications/:notification_id/reading` + +Marks a notification as read. + +__Response:__ + +Returns `204 No Content` on success. + +#### `DELETE /:account_slug/notifications/:notification_id/reading` + +Marks a notification as unread. + +__Response:__ + +Returns `204 No Content` on success. + +#### `POST /:account_slug/notifications/bulk_reading` + +Marks all unread notifications as read. + +__Response:__ + +Returns `204 No Content` on success. From d655074685b1ab7db49deb25999c01718b755346 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 15:43:28 +0100 Subject: [PATCH 64/78] Document users API endpoints --- docs/API.md | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/docs/API.md b/docs/API.md index 9e79ed2de..25b3f210a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -84,6 +84,21 @@ curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: applicati # ... ``` +## File Uploads + +Some endpoints accept file uploads. To upload a file, send a `multipart/form-data` request instead of JSON. +You can combine file uploads with other parameters in the same request. + +__Example using curl:__ + +```bash +curl -X PUT \ + -H "Authorization: Bearer put-your-access-token-here" \ + -F "user[name]=David H. Hansson" \ + -F "user[avatar]=@/path/to/avatar.jpg" \ + http://fizzy.localhost:3006/686465299/users/03f5v9zjw7pz8717a4no1h8a7 +``` + ## Endpoints ### Identity @@ -270,6 +285,102 @@ Columns represent stages in a workflow on a board. Cards move through columns as Users represent people who have access to an account. +#### `GET /:account_slug/users` + +Returns a list of active users in the account. + +__Response:__ + +```json +[ + { + "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" + }, + { + "id": "03f5v9zjysoy0fqs9yg0ei3hq", + "name": "Jason Fried", + "role": "member", + "active": true, + "email_address": "jason@example.com", + "created_at": "2025-12-05T19:36:35.419Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjysoy0fqs9yg0ei3hq" + }, + { + "id": "03f5v9zk1dtqduod5bkhv3k8m", + "name": "Jason Zimdars", + "role": "member", + "active": true, + "email_address": "jz@example.com", + "created_at": "2025-12-05T19:36:35.435Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zk1dtqduod5bkhv3k8m" + }, + { + "id": "03f5v9zk3nw9ja92e7s4h2wbe", + "name": "Kevin Mcconnell", + "role": "member", + "active": true, + "email_address": "kevin@example.com", + "created_at": "2025-12-05T19:36:35.451Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zk3nw9ja92e7s4h2wbe" + } +] +``` + +#### `GET /:account_slug/users/:user_id` + +Returns the specified user. + +__Response:__ + +```json +{ + "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" +} +``` + +#### `PUT /:account_slug/users/:user_id` + +Updates a user. You can only update users you have permission to change. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | No | The user's display name | +| `avatar` | file | No | The user's avatar image | + +__Request:__ + +```json +{ + "user": { + "name": "David H. Hansson" + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + +#### `DELETE /:account_slug/users/:user_id` + +Deactivates a user. You can only deactivate users you have permission to change. + +__Response:__ + +Returns `204 No Content` on success. + ### Notifications Notifications inform users about events that happened in the account, such as comments, assignments, and card updates. From 86aed7b7b7f3c05a283ceef59dedb6a0c8b9040b Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 15:59:33 +0100 Subject: [PATCH 65/78] Add detailed guide for creating access tokens --- docs/API.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 25b3f210a..2331a37e3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -9,10 +9,23 @@ To use the API you'll need an access token. To get one, go to your profile, then in the API section, click on "Personal access tokens" and then click on "Generate new access token". -Pick what kind of permission you want the access token to have: +Give it a description and pick what kind of permission you want the access token to have: - `Read`: allows reading data from your account - `Read + Write`: allows reading and writing data to your account on your behalf +Then click on "Generate access token". + +
+ Access token generation guide with screenshots + + | Step | Description | Screenshot | + |:----:|-------------|:----------:| + | 1 | Go to your profile | Profile page with API section | + | 2 | In the API section click on "Personal access token" | Personal access tokens page | + | 3 | Click on "Generate a new access token" | Generate new access token dialog | + | 4 | Give it a description and assign it a permission | Access token created | +
+ > [!IMPORTANT] > __An access token is like a password, keep it secret and do not share it with anyone.__ > Any person or application that has your access token can perform actions on your behalf. @@ -281,6 +294,76 @@ Cards are tasks or items of work on a board Columns represent stages in a workflow on a board. Cards move through columns as they progress. +#### `GET /:account_slug/boards/:board_id/columns/:column_id` + +Returns the specified column. + +__Response:__ + +```json +{ + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "In Progress", + "color": "var(--color-card-default)", + "created_at": "2025-12-05T19:36:35.534Z" +} +``` + +#### `POST /:account_slug/boards/:board_id/columns` + +Creates a new column on the board. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | Yes | The name of the column | +| `color` | string | No | The column color. One of: `var(--color-card-default)` (Blue), `var(--color-card-1)` (Gray), `var(--color-card-2)` (Tan), `var(--color-card-3)` (Yellow), `var(--color-card-4)` (Lime), `var(--color-card-5)` (Aqua), `var(--color-card-6)` (Violet), `var(--color-card-7)` (Purple), `var(--color-card-8)` (Pink) | + +__Request:__ + +```json +{ + "column": { + "name": "In Progress", + "color": "var(--color-card-4)" + } +} +``` + +__Response:__ + +Returns `201 Created` with a `Location` header pointing to the new column. + +#### `PUT /:account_slug/boards/:board_id/columns/:column_id` + +Updates a column. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | No | The name of the column | +| `color` | string | No | The column color | + +__Request:__ + +```json +{ + "column": { + "name": "Done" + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + +#### `DELETE /:account_slug/boards/:board_id/columns/:column_id` + +Deletes a column. + +__Response:__ + +Returns `204 No Content` on success. + ### Users Users represent people who have access to an account. From 707bf450637c8b8343c36c1628c119315619a63a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 16:45:12 +0100 Subject: [PATCH 66/78] Add steps to cards --- app/views/cards/_card.json.jbuilder | 2 +- app/views/cards/show.json.jbuilder | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index a7116d7dd..709b035f1 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -1,5 +1,5 @@ json.cache! [ card, card.column&.color ] do - json.(card, :id, :title, :status) + json.(card, :id, :number, :title, :status) json.description card.description.to_plain_text json.description_html card.description.to_s json.image_url card.image.presence && url_for(card.image) diff --git a/app/views/cards/show.json.jbuilder b/app/views/cards/show.json.jbuilder index 8f44c1a24..225bded6f 100644 --- a/app/views/cards/show.json.jbuilder +++ b/app/views/cards/show.json.jbuilder @@ -1 +1,7 @@ json.partial! "cards/card", card: @card + +json.steps do + json.array! @card.steps do |step| + json.partial! "steps/step", step: step + end +end From bea8e60fd064d01e801c2cc0fefc837dbc4a440b Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 16:45:31 +0100 Subject: [PATCH 67/78] Document Card-related APIs --- docs/API.md | 493 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 492 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 2331a37e3..da347025d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -112,6 +112,92 @@ curl -X PUT \ http://fizzy.localhost:3006/686465299/users/03f5v9zjw7pz8717a4no1h8a7 ``` +## Rich Text Fields + +Some fields accept rich text content. These fields accept HTML input, which will be sanitized to remove unsafe tags and attributes. + +```json +{ + "card": { + "title": "My card", + "description": "

This is bold and this is italic.

  • Item 1
  • Item 2
" + } +} +``` + +### Attaching files to rich text + +To attach files (images, documents) to rich text fields, use ActionText's direct upload flow: + +#### 1. Create a direct upload + +First, request a direct upload URL by sending file metadata: + +```bash +curl -X POST \ + -H "Authorization: Bearer your-token" \ + -H "Content-Type: application/json" \ + -d '{ + "blob": { + "filename": "screenshot.png", + "byte_size": 12345, + "checksum": "GQ5SqLsM7ylnji0Wgd9wNA==", + "content_type": "image/png" + } + }' \ + https://app.fizzy.do/rails/active_storage/direct_uploads +``` + +The `checksum` is a Base64-encoded MD5 hash of the file content. + +__Response:__ + +```json +{ + "id": "abc123", + "key": "abc123def456", + "filename": "screenshot.png", + "content_type": "image/png", + "byte_size": 12345, + "checksum": "GQ5SqLsM7ylnji0Wgd9wNA==", + "direct_upload": { + "url": "https://storage.example.com/...", + "headers": { + "Content-Type": "image/png", + "Content-MD5": "GQ5SqLsM7ylnji0Wgd9wNA==" + } + }, + "signed_id": "eyJfcmFpbHMi..." +} +``` + +#### 2. Upload the file + +Upload the file directly to the provided URL with the specified headers: + +```bash +curl -X PUT \ + -H "Content-Type: image/png" \ + -H "Content-MD5: GQ5SqLsM7ylnji0Wgd9wNA==" \ + --data-binary @screenshot.png \ + "https://storage.example.com/..." +``` + +#### 3. Reference the file in rich text + +Use the `signed_id` from step 1 to embed the file in your rich text using an `` tag: + +```json +{ + "card": { + "title": "Card with image", + "description": "

Here's a screenshot:

" + } +} +``` + +The `sgid` attribute should contain the `signed_id` returned from the direct upload response. + ## Endpoints ### Identity @@ -288,7 +374,412 @@ Returns `204 No Content` on success. ### Cards -Cards are tasks or items of work on a board +Cards are tasks or items of work on a board. They can be organized into columns, tagged, assigned to users, and have comments. + +#### `GET /:account_slug/cards` + +Returns a paginated list of cards you have access to. Results can be filtered using query parameters. + +__Query Parameters:__ + +| Parameter | Description | +|-----------|-------------| +| `board_id` | Filter by board ID | +| `column_id` | Filter by column ID | +| `tag_id` | Filter by tag ID | +| `assignee_id` | Filter by assignee user ID | +| `status` | Filter by status: `published`, `closed`, `not_now` | + +__Response:__ + +```json +[ + { + "id": "03f5vaeq985jlvwv3arl4srq2", + "number": 1, + "title": "First!", + "status": "published", + "description": "Hello, World!", + "description_html": "

Hello, World!

", + "image_url": null, + "tags": ["programming"], + "golden": false, + "last_active_at": "2025-12-05T19:38:48.553Z", + "created_at": "2025-12-05T19:38:48.540Z", + "url": "http://fizzy.localhost:3006/897362094/cards/4", + "board": { + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Fizzy", + "all_access": true, + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", + "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" + } + }, + "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" + }, + "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments" + }, +] +``` + +#### `GET /:account_slug/cards/:card_number` + +Returns a specific card by its number. + +__Response:__ + +Same as the card object in the list response. + +#### `POST /:account_slug/boards/:board_id/cards` + +Creates a new card in a board. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `title` | string | Yes | The title of the card | +| `description` | string | No | Rich text description of the card | +| `status` | string | No | Initial status: `published` (default), `closed`, `not_now` | +| `image` | file | No | Header image for the card | +| `tag_ids` | array | No | Array of tag IDs to apply to the card | + +__Request:__ + +```json +{ + "card": { + "title": "Add dark mode support", + "description": "We need to add dark mode to the app" + } +} +``` + +__Response:__ + +Returns `201 Created` with a `Location` header pointing to the new card. + +#### `PUT /:account_slug/cards/:card_number` + +Updates a card. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `title` | string | No | The title of the card | +| `description` | string | No | Rich text description of the card | +| `status` | string | No | Card status: `published`, `closed`, `not_now` | +| `image` | file | No | Header image for the card | +| `tag_ids` | array | No | Array of tag IDs to apply to the card | + +__Request:__ + +```json +{ + "card": { + "title": "Add dark mode support (Updated)" + } +} +``` + +__Response:__ + +Returns the updated card. + +#### `DELETE /:account_slug/cards/:card_number` + +Deletes a card. Only the card creator or board administrators can delete cards. + +__Response:__ + +Returns `204 No Content` on success. + +#### `POST /:account_slug/cards/:card_number/closure` + +Closes a card. + +__Response:__ + +Returns `204 No Content` on success. + +#### `DELETE /:account_slug/cards/:card_number/closure` + +Reopens a closed card. + +__Response:__ + +Returns `204 No Content` on success. + +#### `POST /:account_slug/cards/:card_number/not_now` + +Moves a card to "Not Now" status. + +__Response:__ + +Returns `204 No Content` on success. + +#### `POST /:account_slug/cards/:card_number/triage` + +Moves a card from triage into a column. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `column_id` | string | Yes | The ID of the column to move the card into | + +__Response:__ + +Returns `204 No Content` on success. + +#### `DELETE /:account_slug/cards/:card_number/triage` + +Sends a card back to triage. + +__Response:__ + +Returns `204 No Content` on success. + +#### `POST /:account_slug/cards/:card_number/taggings` + +Toggles a tag on or off for a card. If the tag doesn't exist, it will be created. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `tag_title` | string | Yes | The title of the tag (leading `#` is stripped) | + +__Response:__ + +Returns `204 No Content` on success. + +#### `POST /:account_slug/cards/:card_number/assignments` + +Toggles assignment of a user to/from a card. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `assignee_id` | string | Yes | The ID of the user to assign/unassign | + +__Response:__ + +Returns `204 No Content` on success. + +#### `POST /:account_slug/cards/:card_number/watch` + +Subscribes the current user to notifications for this card. + +__Response:__ + +Returns `204 No Content` on success. + +#### `DELETE /:account_slug/cards/:card_number/watch` + +Unsubscribes the current user from notifications for this card. + +__Response:__ + +Returns `204 No Content` on success. + +### Comments + +Comments are attached to cards and support rich text. + +#### `GET /:account_slug/cards/:card_number/comments/:comment_id` + +Returns a specific comment. + +__Response:__ + +```json +{ + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "created_at": "2025-12-05T19:36:35.534Z", + "updated_at": "2025-12-05T19:36:35.534Z", + "body": { + "plain_text": "This looks great!", + "html": "
This looks great!
" + }, + "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" + }, + "reactions_url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions", + "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz" +} +``` + +#### `POST /:account_slug/cards/:card_number/comments` + +Creates a new comment on a card. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `body` | string | Yes | The comment body (supports rich text) | + +__Request:__ + +```json +{ + "comment": { + "body": "This looks great!" + } +} +``` + +__Response:__ + +Returns `201 Created` with a `Location` header pointing to the new comment. + +#### `PUT /:account_slug/cards/:card_number/comments/:comment_id` + +Updates a comment. Only the comment creator can update their comments. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `body` | string | Yes | The updated comment body | + +__Request:__ + +```json +{ + "comment": { + "body": "This looks even better now!" + } +} +``` + +__Response:__ + +Returns the updated comment. + +#### `DELETE /:account_slug/cards/:card_number/comments/:comment_id` + +Deletes a comment. Only the comment creator can delete their comments. + +__Response:__ + +Returns `204 No Content` on success. + +### Reactions + +Reactions are short - 16 character long - responses to comments. + +#### `POST /:account_slug/cards/:card_number/comments/:comment_id/reactions` + +Adds a reaction to a comment. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `content` | string | Yes | The reaction emoji (e.g., "👍", "❤️", "🎉") | + +__Request:__ + +```json +{ + "reaction": { + "content": "👍" + } +} +``` + +__Response:__ + +Returns `201 Created` on success. + +#### `DELETE /:account_slug/cards/:card_number/comments/:comment_id/reactions/:reaction_id` + +Removes your reaction from a comment. + +__Response:__ + +Returns `204 No Content` on success. + +### Steps + +Steps are to-do items on a card. + +#### `GET /:account_slug/cards/:card_number/steps/:step_id` + +Returns a specific step. + +__Response:__ + +```json +{ + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "content": "Write tests", + "completed": false +} +``` + +#### `POST /:account_slug/cards/:card_number/steps` + +Creates a new step on a card. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `content` | string | Yes | The step text | +| `completed` | boolean | No | Whether the step is completed (default: `false`) | + +__Request:__ + +```json +{ + "step": { + "content": "Write tests" + } +} +``` + +__Response:__ + +Returns `201 Created` with a `Location` header pointing to the new step. + +#### `PUT /:account_slug/cards/:card_number/steps/:step_id` + +Updates a step. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `content` | string | No | The step text | +| `completed` | boolean | No | Whether the step is completed | + +__Request:__ + +```json +{ + "step": { + "completed": true + } +} +``` + +__Response:__ + +Returns the updated step. + +#### `DELETE /:account_slug/cards/:card_number/steps/:step_id` + +Deletes a step. + +__Response:__ + +Returns `204 No Content` on success. ### Columns From 7b30ca203e0892931c63f6323142041bb7dc0f73 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 17:03:27 +0100 Subject: [PATCH 68/78] Add index actions for Comments and Columns Also, fix crash when a deactivated user is serialized (they don't have an identity). --- app/controllers/boards/columns_controller.rb | 5 + app/controllers/cards/comments_controller.rb | 4 + app/views/boards/columns/index.json.jbuilder | 1 + app/views/cards/comments/index.json.jbuilder | 1 + app/views/users/_user.json.jbuilder | 2 +- docs/API.md | 108 +++++++++++++++++- .../boards/columns_controller_test.rb | 9 ++ .../cards/comments_controller_test.rb | 9 ++ 8 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 app/views/boards/columns/index.json.jbuilder create mode 100644 app/views/cards/comments/index.json.jbuilder diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb index 1532d76dc..4f6770e28 100644 --- a/app/controllers/boards/columns_controller.rb +++ b/app/controllers/boards/columns_controller.rb @@ -3,6 +3,11 @@ class Boards::ColumnsController < ApplicationController before_action :set_column, only: %i[ show update destroy ] + def index + @columns = @board.columns.sorted + fresh_when etag: @columns + end + def show set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded fresh_when etag: @page.records diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb index c38181862..d353930ee 100644 --- a/app/controllers/cards/comments_controller.rb +++ b/app/controllers/cards/comments_controller.rb @@ -4,6 +4,10 @@ class Cards::CommentsController < ApplicationController before_action :set_comment, only: %i[ show edit update destroy ] before_action :ensure_creatorship, only: %i[ edit update destroy ] + def index + set_page_and_extract_portion_from @card.comments.chronologically + end + def create @comment = @card.comments.create!(comment_params) diff --git a/app/views/boards/columns/index.json.jbuilder b/app/views/boards/columns/index.json.jbuilder new file mode 100644 index 000000000..4142996de --- /dev/null +++ b/app/views/boards/columns/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @columns, partial: "columns/column", as: :column diff --git a/app/views/cards/comments/index.json.jbuilder b/app/views/cards/comments/index.json.jbuilder new file mode 100644 index 000000000..7e261ccc7 --- /dev/null +++ b/app/views/cards/comments/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "cards/comments/comment", as: :comment diff --git a/app/views/users/_user.json.jbuilder b/app/views/users/_user.json.jbuilder index 6a49bd1de..0884f225a 100644 --- a/app/views/users/_user.json.jbuilder +++ b/app/views/users/_user.json.jbuilder @@ -1,7 +1,7 @@ json.cache! user do json.(user, :id, :name, :role, :active) - json.email_address user.identity.email_address + json.email_address user.identity&.email_address json.created_at user.created_at.utc json.url user_url(user) diff --git a/docs/API.md b/docs/API.md index da347025d..8673cf37b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -351,7 +351,7 @@ __Request:__ "name": "Updated board name", "auto_postpone_period": 14, "public_description": "This is a **public** description of the board.", - all_access: false, + "all_access": false, "user_ids": [ "03f5v9zppzlksuj4mxba2nbzn", "03f5v9zjw7pz8717a4no1h8a7" @@ -594,6 +594,37 @@ Returns `204 No Content` on success. Comments are attached to cards and support rich text. +#### `GET /:account_slug/cards/:card_number/comments` + +Returns a paginated list of comments on a card, sorted chronologically (oldest first). + +__Response:__ + +```json +[ + { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "created_at": "2025-12-05T19:36:35.534Z", + "updated_at": "2025-12-05T19:36:35.534Z", + "body": { + "plain_text": "This looks great!", + "html": "
This looks great!
" + }, + "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" + }, + "reactions_url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions", + "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz" + } +] +``` + #### `GET /:account_slug/cards/:card_number/comments/:comment_id` Returns a specific comment. @@ -679,6 +710,31 @@ Returns `204 No Content` on success. Reactions are short - 16 character long - responses to comments. +#### `GET /:account_slug/cards/:card_number/comments/:comment_id/reactions` + +Returns a list of reactions on a comment. + +__Response:__ + +```json +[ + { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "content": "👍", + "reacter": { + "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" + }, + "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions/03f5v9zo9qlcwwpyc0ascnikz" + } +] +``` + #### `POST /:account_slug/cards/:card_number/comments/:comment_id/reactions` Adds a reaction to a comment. @@ -785,6 +841,29 @@ Returns `204 No Content` on success. Columns represent stages in a workflow on a board. Cards move through columns as they progress. +#### `GET /:account_slug/boards/:board_id/columns` + +Returns a list of columns on a board, sorted by position. + +__Response:__ + +```json +[ + { + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Recording", + "color": "var(--color-card-default)", + "created_at": "2025-12-05T19:36:35.534Z" + }, + { + "id": "03f5v9zkft4hj9qq0lsn9ohcn", + "name": "Published", + "color": "var(--color-card-4)", + "created_at": "2025-12-05T19:36:35.534Z" + } +] +``` + #### `GET /:account_slug/boards/:board_id/columns/:column_id` Returns the specified column. @@ -1017,3 +1096,30 @@ Marks all unread notifications as read. __Response:__ Returns `204 No Content` on success. + +### Tags + +Tags are labels that can be applied to cards for organization and filtering. + +#### `GET /:account_slug/tags` + +Returns a list of all tags in the account, sorted alphabetically. + +__Response:__ + +```json +[ + { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "title": "bug", + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnikz" + }, + { + "id": "03f5v9zo9qlcwwpyc0ascnilz", + "title": "feature", + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnilz" + } +] +``` diff --git a/test/controllers/boards/columns_controller_test.rb b/test/controllers/boards/columns_controller_test.rb index 2bc96357c..d7542da27 100644 --- a/test/controllers/boards/columns_controller_test.rb +++ b/test/controllers/boards/columns_controller_test.rb @@ -37,6 +37,15 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest end end + test "index as JSON" do + board = boards(:writebook) + + get board_columns_path(board), as: :json + + assert_response :success + assert_equal board.columns.count, @response.parsed_body.count + end + test "show as JSON" do column = columns(:writebook_in_progress) diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb index 8fc0289be..138fada59 100644 --- a/test/controllers/cards/comments_controller_test.rb +++ b/test/controllers/cards/comments_controller_test.rb @@ -28,6 +28,15 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden end + test "index as JSON" do + card = cards(:logo) + + get card_comments_path(card), as: :json + + assert_response :success + assert_equal card.comments.count, @response.parsed_body.count + end + test "create as JSON" do card = cards(:logo) From a7ca41e548095bf0f10472b013d7b307f803a728 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 17:06:52 +0100 Subject: [PATCH 69/78] Move the tags section close to the cards --- docs/API.md | 60 ++++++++++++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/API.md b/docs/API.md index 8673cf37b..0ab722346 100644 --- a/docs/API.md +++ b/docs/API.md @@ -135,7 +135,7 @@ First, request a direct upload URL by sending file metadata: ```bash curl -X POST \ - -H "Authorization: Bearer your-token" \ + -H "Authorization: Bearer put-your-access-token-here" \ -H "Content-Type: application/json" \ -d '{ "blob": { @@ -741,14 +741,14 @@ Adds a reaction to a comment. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `content` | string | Yes | The reaction emoji (e.g., "👍", "❤️", "🎉") | +| `content` | string | Yes | The reaction text") | __Request:__ ```json { "reaction": { - "content": "👍" + "content": "Great 👍" } } ``` @@ -837,6 +837,33 @@ __Response:__ Returns `204 No Content` on success. +### Tags + +Tags are labels that can be applied to cards for organization and filtering. + +#### `GET /:account_slug/tags` + +Returns a list of all tags in the account, sorted alphabetically. + +__Response:__ + +```json +[ + { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "title": "bug", + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnikz" + }, + { + "id": "03f5v9zo9qlcwwpyc0ascnilz", + "title": "feature", + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnilz" + } +] +``` + ### Columns Columns represent stages in a workflow on a board. Cards move through columns as they progress. @@ -1096,30 +1123,3 @@ Marks all unread notifications as read. __Response:__ Returns `204 No Content` on success. - -### Tags - -Tags are labels that can be applied to cards for organization and filtering. - -#### `GET /:account_slug/tags` - -Returns a list of all tags in the account, sorted alphabetically. - -__Response:__ - -```json -[ - { - "id": "03f5v9zo9qlcwwpyc0ascnikz", - "title": "bug", - "created_at": "2025-12-05T19:36:35.534Z", - "url": "http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnikz" - }, - { - "id": "03f5v9zo9qlcwwpyc0ascnilz", - "title": "feature", - "created_at": "2025-12-05T19:36:35.534Z", - "url": "http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnilz" - } -] -``` From 462470ed1dd80e900e5ee68553b2729d3aad664f Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 17:13:20 +0100 Subject: [PATCH 70/78] Remove endpoints section It was too easy to get lost in the ### sections for each resource. With rescources being ## sections I hope it will be easier to find things. --- docs/API.md | 114 ++++++++++++++++++++++++++-------------------------- 1 file changed, 56 insertions(+), 58 deletions(-) diff --git a/docs/API.md b/docs/API.md index 0ab722346..47473a845 100644 --- a/docs/API.md +++ b/docs/API.md @@ -198,13 +198,11 @@ Use the `signed_id` from step 1 to embed the file in your rich text using an ` Date: Tue, 9 Dec 2025 17:26:22 +0100 Subject: [PATCH 71/78] Add cache directives --- .../cards/comments/reactions/_reaction.json.jbuilder | 10 ++++++---- app/views/cards/steps/_step.json.jbuilder | 4 +++- app/views/columns/_column.json.jbuilder | 6 ++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/views/cards/comments/reactions/_reaction.json.jbuilder b/app/views/cards/comments/reactions/_reaction.json.jbuilder index 2c743b7c9..e83dd3e4d 100644 --- a/app/views/cards/comments/reactions/_reaction.json.jbuilder +++ b/app/views/cards/comments/reactions/_reaction.json.jbuilder @@ -1,5 +1,7 @@ -json.(reaction, :id, :content) -json.reacter do - json.partial! "users/user", user: reaction.reacter +json.cache! reaction do + json.(reaction, :id, :content) + json.reacter do + json.partial! "users/user", user: reaction.reacter + end + json.url card_comment_reaction_url(reaction.comment.card, reaction.comment, reaction) end -json.url card_comment_reaction_url(reaction.comment.card, reaction.comment, reaction) diff --git a/app/views/cards/steps/_step.json.jbuilder b/app/views/cards/steps/_step.json.jbuilder index 6cf500791..ac0d5a1f8 100644 --- a/app/views/cards/steps/_step.json.jbuilder +++ b/app/views/cards/steps/_step.json.jbuilder @@ -1 +1,3 @@ -json.(step, :id, :content, :completed) +json.cache! step do + json.(step, :id, :content, :completed) +end diff --git a/app/views/columns/_column.json.jbuilder b/app/views/columns/_column.json.jbuilder index 355a62606..5bb56bdd6 100644 --- a/app/views/columns/_column.json.jbuilder +++ b/app/views/columns/_column.json.jbuilder @@ -1,2 +1,4 @@ -json.(column, :id, :name, :color) -json.created_at column.created_at.utc +json.cache! column do + json.(column, :id, :name, :color) + json.created_at column.created_at.utc +end From 8dc6c48db5262217c657e24019b860cd05bd206a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 9 Dec 2025 17:30:42 +0100 Subject: [PATCH 72/78] Remove composite cache key This prevents jbuilder from fetching multiple records at once, but it's no longer needed as the column and board touch all their associated cards on update so we can remove it --- app/views/cards/_card.json.jbuilder | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index 709b035f1..77422c05c 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -1,4 +1,4 @@ -json.cache! [ card, card.column&.color ] do +json.cache! card do json.(card, :id, :number, :title, :status) json.description card.description.to_plain_text json.description_html card.description.to_s From 79e77a37803fb57753a45b786000103c9a0fe307 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 10 Dec 2025 09:01:22 +0100 Subject: [PATCH 73/78] Handle user update failures --- app/controllers/users_controller.rb | 5 ++++- docs/API.md | 23 +++++++++++++++++++++++ test/controllers/users_controller_test.rb | 11 +++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 11bd28b7a..74deb686b 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -19,7 +19,10 @@ class UsersController < ApplicationController 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: @user.errors, status: :unprocessable_entity } + end end end diff --git a/docs/API.md b/docs/API.md index 47473a845..107521596 100644 --- a/docs/API.md +++ b/docs/API.md @@ -83,6 +83,29 @@ else end ``` +## Error Responses + +When a request fails, the API response will communicate the source of the problem through the HTTP status code. + +| Status Code | Description | +|-------------|-------------| +| `400 Bad Request` | The request was malformed or missing required parameters | +| `401 Unauthorized` | Authentication failed or access token is invalid | +| `403 Forbidden` | You don't have permission to perform this action | +| `404 Not Found` | The requested resource doesn't exist or you don't have access to it | +| `422 Unprocessable Entity` | Validation failed (see error response format above) | +| `500 Internal Server Error` | An unexpected error occurred on the server | + +If a request contains invalid data for fields, such as entering a string into a number field, in most cases the API will respond with a `500 Internal Server Error`. Clients are expected to perform some validation on their end before making a request. + +Validation error will produce a `422 Unprocessable Entity` which will sometimes be accompanied by details about the validation errors: + +```json +{ + "avatar": ["must be a JPEG, PNG, GIF, or WebP image"] +} +``` + ## Pagination All endpoints that return a list of items are paginated. The page size can vary from endpoint to endpoint, diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index bf6854a0d..20008dcee 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -112,6 +112,17 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal "New David", users(:david).reload.name end + test "update as JSON with invalid avatar returns errors" do + sign_in_as :kevin + + svg_file = fixture_file_upload("avatar.svg", "image/svg+xml") + + put user_path(users(:kevin), format: :json), params: { user: { avatar: svg_file } } + + assert_response :unprocessable_entity + assert @response.parsed_body["avatar"].present? + end + test "destroy as JSON" do sign_in_as :kevin From 566def581e5eb517dc09385e71b78679b9dac8d8 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 10 Dec 2025 15:05:13 +0100 Subject: [PATCH 74/78] Remove redundant respond_to --- app/controllers/boards/columns_controller.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb index 4f6770e28..9e14eb937 100644 --- a/app/controllers/boards/columns_controller.rb +++ b/app/controllers/boards/columns_controller.rb @@ -11,11 +11,6 @@ class Boards::ColumnsController < ApplicationController def show set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded fresh_when etag: @page.records - - respond_to do |format| - format.html - format.json - end end def create From 90e6322092e572906e9008d0fc49694c0155168f Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 10 Dec 2025 15:07:22 +0100 Subject: [PATCH 75/78] Return no content on update --- app/controllers/cards/comments_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb index d353930ee..f512b6235 100644 --- a/app/controllers/cards/comments_controller.rb +++ b/app/controllers/cards/comments_controller.rb @@ -28,7 +28,7 @@ class Cards::CommentsController < ApplicationController respond_to do |format| format.turbo_stream - format.json { render :show } + format.json { head :no_content } end end From 5ce71bf941bf7fda8d70843fb764d9caf49127d4 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 10 Dec 2025 15:13:35 +0100 Subject: [PATCH 76/78] Move Identities to My::Identities --- app/controllers/{ => my}/identities_controller.rb | 2 +- app/views/{ => my}/identities/_account.json.jbuilder | 0 app/views/{ => my}/identities/show.json.jbuilder | 2 +- config/routes.rb | 3 +-- test/controllers/{ => my}/identities_controller_test.rb | 4 ++-- 5 files changed, 5 insertions(+), 6 deletions(-) rename app/controllers/{ => my}/identities_controller.rb (59%) rename app/views/{ => my}/identities/_account.json.jbuilder (100%) rename app/views/{ => my}/identities/show.json.jbuilder (63%) rename test/controllers/{ => my}/identities_controller_test.rb (71%) diff --git a/app/controllers/identities_controller.rb b/app/controllers/my/identities_controller.rb similarity index 59% rename from app/controllers/identities_controller.rb rename to app/controllers/my/identities_controller.rb index f685cd348..7a388a3c3 100644 --- a/app/controllers/identities_controller.rb +++ b/app/controllers/my/identities_controller.rb @@ -1,4 +1,4 @@ -class IdentitiesController < ApplicationController +class My::IdentitiesController < ApplicationController disallow_account_scope def show diff --git a/app/views/identities/_account.json.jbuilder b/app/views/my/identities/_account.json.jbuilder similarity index 100% rename from app/views/identities/_account.json.jbuilder rename to app/views/my/identities/_account.json.jbuilder diff --git a/app/views/identities/show.json.jbuilder b/app/views/my/identities/show.json.jbuilder similarity index 63% rename from app/views/identities/show.json.jbuilder rename to app/views/my/identities/show.json.jbuilder index 5f8a1cefb..3d4ee1750 100644 --- a/app/views/identities/show.json.jbuilder +++ b/app/views/my/identities/show.json.jbuilder @@ -1,5 +1,5 @@ json.accounts @identity.users do |user| - json.partial! "identities/account", account: user.account + json.partial! "my/identities/account", account: user.account json.user do json.partial! "users/user", user: user end diff --git a/config/routes.rb b/config/routes.rb index 805b89f38..aa2815433 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -163,9 +163,8 @@ Rails.application.routes.draw do resource :landing - resource :identity, only: :show - namespace :my do + resource :identity, only: :show resources :access_tokens resources :pins resource :timezone diff --git a/test/controllers/identities_controller_test.rb b/test/controllers/my/identities_controller_test.rb similarity index 71% rename from test/controllers/identities_controller_test.rb rename to test/controllers/my/identities_controller_test.rb index 8d6e9a2fb..29e17cb85 100644 --- a/test/controllers/identities_controller_test.rb +++ b/test/controllers/my/identities_controller_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class IdentitiesControllerTest < ActionDispatch::IntegrationTest +class My::IdentitiesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end @@ -9,7 +9,7 @@ class IdentitiesControllerTest < ActionDispatch::IntegrationTest identity = identities(:kevin) untenanted do - get identity_path, as: :json + get my_identity_path, as: :json assert_response :success assert_equal identity.accounts.count, @response.parsed_body["accounts"].count end From 3257e4a6e92107f86710fe38e42eb1e3a2b218a4 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 10 Dec 2025 15:27:56 +0100 Subject: [PATCH 77/78] Inline partials --- app/views/boards/_board.json.jbuilder | 4 +--- app/views/cards/_card.json.jbuilder | 14 +++----------- app/views/cards/comments/_comment.json.jbuilder | 4 +--- .../comments/reactions/_reaction.json.jbuilder | 4 +--- app/views/cards/show.json.jbuilder | 7 +------ app/views/my/identities/show.json.jbuilder | 4 +--- .../notifications/_notification.json.jbuilder | 4 +--- app/views/webhooks/event.json.jbuilder | 9 ++------- 8 files changed, 11 insertions(+), 39 deletions(-) diff --git a/app/views/boards/_board.json.jbuilder b/app/views/boards/_board.json.jbuilder index 7723f6aae..fd4e891d7 100644 --- a/app/views/boards/_board.json.jbuilder +++ b/app/views/boards/_board.json.jbuilder @@ -3,7 +3,5 @@ json.cache! board do json.created_at board.created_at.utc json.url board_url(board) - json.creator do - json.partial! "users/user", user: board.creator - end + json.creator board.creator, partial: "users/user", as: :user end diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index 77422c05c..a1c509a66 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -12,17 +12,9 @@ json.cache! card do json.url card_url(card) - json.board do - json.partial! "boards/board", locals: { board: card.board } - end - - json.column do - json.partial! "columns/column", column: card.column if card.column - end - - json.creator do - json.partial! "users/user", user: card.creator - end + json.board card.board, partial: "boards/board", as: :board + json.column card.column, partial: "columns/column", as: :column if card.column + json.creator card.creator, partial: "users/user", as: :user json.comments_url card_comments_url(card) end diff --git a/app/views/cards/comments/_comment.json.jbuilder b/app/views/cards/comments/_comment.json.jbuilder index a66ae1ef9..3eb9855ed 100644 --- a/app/views/cards/comments/_comment.json.jbuilder +++ b/app/views/cards/comments/_comment.json.jbuilder @@ -9,9 +9,7 @@ json.cache! comment do json.html comment.body.to_s end - json.creator do - json.partial! "users/user", user: comment.creator - end + json.creator comment.creator, partial: "users/user", as: :user json.reactions_url card_comment_reactions_url(comment.card_id, comment.id) json.url card_comment_url(comment.card_id, comment.id) diff --git a/app/views/cards/comments/reactions/_reaction.json.jbuilder b/app/views/cards/comments/reactions/_reaction.json.jbuilder index e83dd3e4d..17ad70f70 100644 --- a/app/views/cards/comments/reactions/_reaction.json.jbuilder +++ b/app/views/cards/comments/reactions/_reaction.json.jbuilder @@ -1,7 +1,5 @@ json.cache! reaction do json.(reaction, :id, :content) - json.reacter do - json.partial! "users/user", user: reaction.reacter - end + json.reacter reaction.reacter, partial: "users/user", as: :user json.url card_comment_reaction_url(reaction.comment.card, reaction.comment, reaction) end diff --git a/app/views/cards/show.json.jbuilder b/app/views/cards/show.json.jbuilder index 225bded6f..0ed9bd1ad 100644 --- a/app/views/cards/show.json.jbuilder +++ b/app/views/cards/show.json.jbuilder @@ -1,7 +1,2 @@ json.partial! "cards/card", card: @card - -json.steps do - json.array! @card.steps do |step| - json.partial! "steps/step", step: step - end -end +json.steps @card.steps, partial: "steps/step", as: :step diff --git a/app/views/my/identities/show.json.jbuilder b/app/views/my/identities/show.json.jbuilder index 3d4ee1750..a36569e41 100644 --- a/app/views/my/identities/show.json.jbuilder +++ b/app/views/my/identities/show.json.jbuilder @@ -1,6 +1,4 @@ json.accounts @identity.users do |user| json.partial! "my/identities/account", account: user.account - json.user do - json.partial! "users/user", user: user - end + json.user user, partial: "users/user", as: :user end diff --git a/app/views/notifications/_notification.json.jbuilder b/app/views/notifications/_notification.json.jbuilder index 14b92cc42..ba27c5425 100644 --- a/app/views/notifications/_notification.json.jbuilder +++ b/app/views/notifications/_notification.json.jbuilder @@ -6,9 +6,7 @@ json.cache! notification do json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification - json.creator do - json.partial! "users/user", user: notification.creator - end + json.creator notification.creator, partial: "users/user", as: :user json.card do json.(notification.card, :id, :title, :status) diff --git a/app/views/webhooks/event.json.jbuilder b/app/views/webhooks/event.json.jbuilder index b7ce90dcd..2f4f817d8 100644 --- a/app/views/webhooks/event.json.jbuilder +++ b/app/views/webhooks/event.json.jbuilder @@ -9,11 +9,6 @@ json.cache! @event do end end - json.board do - json.partial! "boards/board", locals: { board: @event.board } - end - - json.creator do - json.partial! "users/user", user: @event.creator - end + json.board @event.board, partial: "boards/board", as: :board + json.creator @event.creator, partial: "users/user", as: :user end From 714f7642b2d8d1fee44da9d63b0bb9d551677607 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Wed, 10 Dec 2025 15:32:50 +0100 Subject: [PATCH 78/78] Rename /identity to /my/identity in docs --- docs/API.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API.md b/docs/API.md index 107521596..d1623d868 100644 --- a/docs/API.md +++ b/docs/API.md @@ -33,7 +33,7 @@ Then click on "Generate access token". To authenticate a request using your access token, include it in the `Authorization` header: ```bash -curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" https://app.fizzy.do/identity +curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" https://app.fizzy.do/my/identity ``` ## Caching @@ -225,7 +225,7 @@ The `sgid` attribute should contain the `signed_id` returned from the direct upl An Identity represents a person using Fizzy, their email address and their users in different accounts. -### `GET /identity` +### `GET /my/identity` Returns a list of accounts, including the User, the Identity has access to