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
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/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb
index fa4f3e072..9e14eb937 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
@@ -10,14 +15,29 @@ class Boards::ColumnsController < ApplicationController
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/controllers/boards_controller.rb b/app/controllers/boards_controller.rb
index ff7c35ec0..6c8a17575 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
+ set_page_and_extract_portion_from Current.user.boards
+ end
+
def show
if @filter.used?(ignore_boards: true)
show_filtered_cards
@@ -18,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, format: :json) }
+ end
end
def edit
@@ -31,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/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/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/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/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/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb
index b7c6c867a..f512b6235 100644
--- a/app/controllers/cards/comments_controller.rb
+++ b/app/controllers/cards/comments_controller.rb
@@ -4,8 +4,17 @@ 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)
+
+ 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 +25,20 @@ class Cards::CommentsController < ApplicationController
def update
@comment.update! comment_params
+
+ respond_to do |format|
+ format.turbo_stream
+ format.json { head :no_content }
+ 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/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/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/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/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/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/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/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/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb
index cb40079e8..a3098b4b0 100644
--- a/app/controllers/cards_controller.rb
+++ b/app/controllers/cards_controller.rb
@@ -10,8 +10,18 @@ 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)
+ card.publish
+ head :created, location: card_path(card, format: :json)
+ end
+ end
end
def show
@@ -22,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/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index 4cdbbdb69..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
@@ -32,7 +32,7 @@ module Authentication
private
def authenticated?
- Current.session.present?
+ Current.identity.present?
end
def require_account
@@ -42,7 +42,7 @@ module Authentication
end
def require_authentication
- resume_session || request_authentication
+ resume_session || authenticate_by_bearer_token || request_authentication
end
def resume_session
@@ -55,6 +55,16 @@ module Authentication
Session.find_signed(cookies.signed[:session_token])
end
+ def authenticate_by_bearer_token
+ 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
+
def request_authentication
if Current.account.present?
session[:return_to_after_authenticating] = request.url
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 ]
diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb
new file mode 100644
index 000000000..99c87893f
--- /dev/null
+++ b/app/controllers/my/access_tokens_controller.rb
@@ -0,0 +1,40 @@
+class My::AccessTokensController < ApplicationController
+ def index
+ @access_tokens = my_access_tokens.order(created_at: :desc)
+ end
+
+ def show
+ @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 = my_access_tokens.new
+ end
+
+ def create
+ 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
+ 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: %i[ description permission ])
+ end
+
+ def verifier
+ Rails.application.message_verifier(:access_tokens)
+ end
+end
diff --git a/app/controllers/my/identities_controller.rb b/app/controllers/my/identities_controller.rb
new file mode 100644
index 000000000..7a388a3c3
--- /dev/null
+++ b/app/controllers/my/identities_controller.rb
@@ -0,0 +1,7 @@
+class My::IdentitiesController < ApplicationController
+ disallow_account_scope
+
+ def show
+ @identity = Current.identity
+ end
+end
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..b116ed1a2 100644
--- a/app/controllers/notifications_controller.rb
+++ b/app/controllers/notifications_controller.rb
@@ -1,13 +1,20 @@
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|
format.turbo_stream if current_page_param # Allows read-all action to side step pagination
format.html
+ format.json
end
end
+
+ private
+ def max_unread_notifications
+ request.format.json? ? MAX_UNREAD_NOTIFICATIONS_VIA_API : MAX_UNREAD_NOTIFICATIONS
+ end
end
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
new file mode 100644
index 000000000..6b72fdf50
--- /dev/null
+++ b/app/controllers/tags_controller.rb
@@ -0,0 +1,5 @@
+class TagsController < ApplicationController
+ def index
+ 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 15f8ecd6f..74deb686b 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
+ set_page_and_extract_portion_from Current.account.users.active.alphabetically
+ end
+
def show
end
@@ -10,15 +14,25 @@ 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
+ respond_to do |format|
+ format.html { render :edit, status: :unprocessable_entity }
+ format.json { render json: @user.errors, status: :unprocessable_entity }
+ end
end
end
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/app/models/current.rb b/app/models/current.rb
index 47f2b6c21..94ef9688c 100644
--- a/app/models/current.rb
+++ b/app/models/current.rb
@@ -1,13 +1,19 @@
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
-
def session=(value)
super(value)
- if value.present? && account.present?
+ if value.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
diff --git a/app/models/identity.rb b/app/models/identity.rb
index bb69734b4..7495e37c3 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
@@ -13,6 +14,12 @@ 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_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)
attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for)
diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb
new file mode 100644
index 000000000..abdf37eba
--- /dev/null
+++ b/app/models/identity/access_token.rb
@@ -0,0 +1,10 @@
+class Identity::AccessToken < ApplicationRecord
+ belongs_to :identity
+
+ 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/app/views/boards/_board.json.jbuilder b/app/views/boards/_board.json.jbuilder
index ce2ef0451..fd4e891d7 100644
--- a/app/views/boards/_board.json.jbuilder
+++ b/app/views/boards/_board.json.jbuilder
@@ -1,8 +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
- end
+ json.creator board.creator, partial: "users/user", as: :user
end
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/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/app/views/boards/index.json.jbuilder b/app/views/boards/index.json.jbuilder
new file mode 100644
index 000000000..047401cff
--- /dev/null
+++ b/app/views/boards/index.json.jbuilder
@@ -0,0 +1 @@
+json.array! @page.records, 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
diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder
index 1fa339328..a1c509a66 100644
--- a/app/views/cards/_card.json.jbuilder
+++ b/app/views/cards/_card.json.jbuilder
@@ -1,26 +1,20 @@
-json.cache! [ card, card.column&.color ] do
- json.(card, :id, :title, :status)
+json.cache! card do
+ 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)
+ 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
json.url card_url(card)
- json.board do
- json.partial! "boards/board", locals: { board: card.board }
- 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.column do
- if card.column
- json.partial! "columns/column", column: card.column
- else
- nil
- end
- end
-
- 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/_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/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/cards/comments/reactions/_reaction.json.jbuilder b/app/views/cards/comments/reactions/_reaction.json.jbuilder
new file mode 100644
index 000000000..17ad70f70
--- /dev/null
+++ b/app/views/cards/comments/reactions/_reaction.json.jbuilder
@@ -0,0 +1,5 @@
+json.cache! reaction do
+ json.(reaction, :id, :content)
+ 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/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/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/app/views/cards/index.json.jbuilder b/app/views/cards/index.json.jbuilder
new file mode 100644
index 000000000..c1dc1dff1
--- /dev/null
+++ b/app/views/cards/index.json.jbuilder
@@ -0,0 +1 @@
+json.array! @page.records, partial: "cards/card", as: :card
diff --git a/app/views/cards/show.json.jbuilder b/app/views/cards/show.json.jbuilder
new file mode 100644
index 000000000..0ed9bd1ad
--- /dev/null
+++ b/app/views/cards/show.json.jbuilder
@@ -0,0 +1,2 @@
+json.partial! "cards/card", card: @card
+json.steps @card.steps, partial: "steps/step", as: :step
diff --git a/app/views/cards/steps/_step.json.jbuilder b/app/views/cards/steps/_step.json.jbuilder
new file mode 100644
index 000000000..ac0d5a1f8
--- /dev/null
+++ b/app/views/cards/steps/_step.json.jbuilder
@@ -0,0 +1,3 @@
+json.cache! step do
+ json.(step, :id, :content, :completed)
+end
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/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
diff --git a/app/views/my/access_tokens/_access_token.html.erb b/app/views/my/access_tokens/_access_token.html.erb
new file mode 100644
index 000000000..cd33831bb
--- /dev/null
+++ b/app/views/my/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 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" %>
+ Edit this token
+ <% end %>
+
+
diff --git a/app/views/my/access_tokens/index.html.erb b/app/views/my/access_tokens/index.html.erb
new file mode 100644
index 000000000..cc9b83ede
--- /dev/null
+++ b/app/views/my/access_tokens/index.html.erb
@@ -0,0 +1,35 @@
+<% @page_title = "Personal access tokens" %>
+
+<% content_for :header do %>
+
+
+
+<% end %>
+
+
+ <% if @access_tokens.any? %>
+ Tokens you have generated that can be used to access the Fizzy API.
+
+
+
+ Description
+ Permission
+ Created
+
+
+
+
+ <%= 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_my_access_token_path, class: "btn btn--link" do %>
+ <%= icon_tag "add" %>
+ Generate a new access token
+ <% end %>
+
diff --git a/app/views/my/access_tokens/new.html.erb b/app/views/my/access_tokens/new.html.erb
new file mode 100644
index 000000000..be08e5031
--- /dev/null
+++ b/app/views/my/access_tokens/new.html.erb
@@ -0,0 +1,29 @@
+<% @page_title = "Generate a personal access token" %>
+
+<% content_for :header do %>
+
+
+
+<% end %>
+
+
+ <%= 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" } %>
+
+
+
+ <%= form.label :permission %>
+ <%= 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", my_access_tokens_path, data: { form_target: "cancel" }, hidden: true %>
+ <% 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..16becf7c2
--- /dev/null
+++ b/app/views/my/access_tokens/show.html.erb
@@ -0,0 +1,26 @@
+<% @page_title = "New personal access token" %>
+
+<% content_for :header do %>
+
+
+
+<% end %>
+
+
+
+
+ <%= @access_token.description %> (<%= @access_token.permission == "write" ? "Read + Write" : "Read" %>)
+
+
+
Be sure to save this access token now because you wonβt be able to see it again.
+
+ <%= 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 %>
+
+
diff --git a/app/views/my/identities/_account.json.jbuilder b/app/views/my/identities/_account.json.jbuilder
new file mode 100644
index 000000000..4c2076f0c
--- /dev/null
+++ b/app/views/my/identities/_account.json.jbuilder
@@ -0,0 +1,4 @@
+json.cache! account do
+ json.(account, :id, :name, :slug)
+ json.created_at account.created_at.utc
+end
diff --git a/app/views/my/identities/show.json.jbuilder b/app/views/my/identities/show.json.jbuilder
new file mode 100644
index 000000000..a36569e41
--- /dev/null
+++ b/app/views/my/identities/show.json.jbuilder
@@ -0,0 +1,4 @@
+json.accounts @identity.users do |user|
+ json.partial! "my/identities/account", account: user.account
+ 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
new file mode 100644
index 000000000..ba27c5425
--- /dev/null
+++ b/app/views/notifications/_notification.json.jbuilder
@@ -0,0 +1,17 @@
+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 notification.creator, partial: "users/user", as: :user
+
+ 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/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..58fa7f00f
--- /dev/null
+++ b/app/views/tags/index.json.jbuilder
@@ -0,0 +1 @@
+json.array! @page.records, partial: "tags/tag", as: :tag
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 @@
-
\ No newline at end of file
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/app/views/users/index.json.jbuilder b/app/views/users/index.json.jbuilder
new file mode 100644
index 000000000..2472a7b47
--- /dev/null
+++ b/app/views/users/index.json.jbuilder
@@ -0,0 +1 @@
+json.array! @page.records, partial: "users/user", as: :user
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index 17f418e84..58436d505 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 %>
@@ -44,9 +44,19 @@
<%= render "users/transfer", user: @user %>
+
+
+
+
+ <%= 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 %>
- Sign out
+ Sign out of Fizzy
<% end %>
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/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
diff --git a/config/routes.rb b/config/routes.rb
index e67e4ef7c..aa2815433 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
@@ -162,6 +164,8 @@ Rails.application.routes.draw do
resource :landing
namespace :my do
+ resource :identity, only: :show
+ resources :access_tokens
resources :pins
resource :timezone
resource :menu
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/docs/API.md b/docs/API.md
new file mode 100644
index 000000000..d1623d868
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,1146 @@
+# 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".
+
+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 | |
+ | 2 | In the API section click on "Personal access token" | |
+ | 3 | Click on "Generate a new access token" | |
+ | 4 | Give it a description and assign it a permission | |
+
+
+> [!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/my/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
+```
+
+## 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,
+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"
+# ...
+```
+
+## 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
+```
+
+## 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 .
"
+ }
+}
+```
+
+### 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 put-your-access-token-here" \
+ -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.
+
+## Identity
+
+An Identity represents a person using Fizzy, their email address and their users in different accounts.
+
+### `GET /my/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 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": "",
+ "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`
+
+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.
+
+__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.
+
+### `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.
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `content` | string | Yes | The reaction text") |
+
+__Request:__
+
+```json
+{
+ "reaction": {
+ "content": "Great π"
+ }
+}
+```
+
+__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.
+
+## 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.
+
+### `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.
+
+__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.
+
+### `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.
+
+### `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.
diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb
new file mode 100644
index 000000000..a48511cf8
--- /dev/null
+++ b/test/controllers/api_test.rb
@@ -0,0 +1,31 @@
+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
+ get boards_path(format: :json), env: @davids_bearer_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
+
+ 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 :success
+ end
+
+ private
+ def bearer_token_env(token)
+ { "HTTP_AUTHORIZATION" => "Bearer #{token}" }
+ end
+end
diff --git a/test/controllers/boards/columns_controller_test.rb b/test/controllers/boards/columns_controller_test.rb
index 7c6e7f525..d7542da27 100644
--- a/test/controllers/boards/columns_controller_test.rb
+++ b/test/controllers/boards/columns_controller_test.rb
@@ -36,4 +36,52 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
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)
+
+ 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
diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb
index a7e3580de..7669881cb 100644
--- a/test/controllers/boards_controller_test.rb
+++ b/test/controllers/boards_controller_test.rb
@@ -190,4 +190,44 @@ 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
+
+ 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
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)
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
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
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
diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb
index 856470302..138fada59 100644
--- a/test/controllers/cards/comments_controller_test.rb
+++ b/test/controllers/cards/comments_controller_test.rb
@@ -27,4 +27,51 @@ 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)
+
+ 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
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
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
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
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
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
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
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
diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb
index ea5d4e50e..6ef509ac4 100644
--- a/test/controllers/cards_controller_test.rb
+++ b/test/controllers/cards_controller_test.rb
@@ -132,4 +132,42 @@ 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
+
+ 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
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
diff --git a/test/controllers/my/identities_controller_test.rb b/test/controllers/my/identities_controller_test.rb
new file mode 100644
index 000000000..29e17cb85
--- /dev/null
+++ b/test/controllers/my/identities_controller_test.rb
@@ -0,0 +1,17 @@
+require "test_helper"
+
+class My::IdentitiesControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in_as :kevin
+ end
+
+ test "show as JSON" do
+ identity = identities(:kevin)
+
+ untenanted do
+ get my_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/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
diff --git a/test/controllers/tags_controller_test.rb b/test/controllers/tags_controller_test.rb
new file mode 100644
index 000000000..d99939c60
--- /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.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..20008dcee 100644
--- a/test/controllers/users_controller_test.rb
+++ b/test/controllers/users_controller_test.rb
@@ -86,4 +86,50 @@ 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
+
+ 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 "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
+
+ assert_difference -> { User.active.count }, -1 do
+ delete user_path(users(:david)), as: :json
+ end
+
+ assert_response :no_content
+ end
end
diff --git a/test/fixtures/identity/access_tokens.yml b/test/fixtures/identity/access_tokens.yml
new file mode 100644
index 000000000..cfbbb6248
--- /dev/null
+++ b/test/fixtures/identity/access_tokens.yml
@@ -0,0 +1,11 @@
+jasons_api_token:
+ identity: jason
+ token: 018cf1425682700098f24f0799e3fe20
+ description: My Superscript
+ permission: read
+
+davids_api_token:
+ identity: david
+ token: x18cf1425682700098f24f0799e3fe20
+ description: My Superscript
+ permission: write
diff --git a/test/models/identity/access_token_test.rb b/test/models/identity/access_token_test.rb
new file mode 100644
index 000000000..7324d97ab
--- /dev/null
+++ b/test/models/identity/access_token_test.rb
@@ -0,0 +1,4 @@
+require "test_helper"
+
+class Identity::AccessTokenTest < ActiveSupport::TestCase
+end