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/.github/dependabot.yml b/.github/dependabot.yml index f4f755614..dbb544dc1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,4 +30,3 @@ updates: open-pull-requests-limit: 10 cooldown: default-days: 7 - semver-major-days: 14 diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index f3b992387..d097b9590 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: .ruby-version @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: .ruby-version diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 4970ef182..4286f90ae 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -39,13 +39,13 @@ jobs: IMAGE_NAME: ${{ github.repository }} steps: - name: Checkout - uses: actions/checkout@v5.0.0 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.11.1 - name: Log in to GHCR - uses: docker/login-action@v3.5.0 + uses: docker/login-action@v3.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -62,7 +62,7 @@ jobs: - name: Extract Docker metadata (tags, labels) with arch suffix id: meta - uses: docker/metadata-action@v5.8.0 + uses: docker/metadata-action@v5.10.0 with: images: ${{ steps.vars.outputs.canonical }} tags: | @@ -118,7 +118,7 @@ jobs: uses: docker/setup-buildx-action@v3.11.1 - name: Log in to GHCR - uses: docker/login-action@v3.5.0 + uses: docker/login-action@v3.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -135,7 +135,7 @@ jobs: - name: Compute base tags (no suffix) id: meta - uses: docker/metadata-action@v5.8.0 + uses: docker/metadata-action@v5.10.0 with: images: ${{ steps.vars.outputs.canonical }} tags: | @@ -179,7 +179,7 @@ jobs: done <<< "$tags" - name: Install Cosign - uses: sigstore/cosign-installer@v3.9.2 + uses: sigstore/cosign-installer@v4.0.0 - name: Cosign sign all tags (keyless OIDC) shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e62118357..beee16f15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,7 +56,7 @@ jobs: - name: Install system packages run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: diff --git a/.gitleaks.toml b/.gitleaks.toml index 6c6bb399a..4a1991496 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -6,4 +6,6 @@ paths = [ '''log''', '''tmp''', '''.*\.yml\.enc''', + '''docs/''', + '''test/''', ] diff --git a/.gitleaksignore b/.gitleaksignore index 0d7b9b49f..7a3cd9392 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,9 +1,3 @@ d8463077:gems/fizzy-saas/bin/setup:generic-api-key:54 c4073c1c:app/models/integration/basecamp.rb:generic-api-key:3 c4073c1c:app/models/integration/basecamp.rb:generic-api-key:4 -02a42167:test/models/webhook_test.rb:slack-webhook-url:57 -2fc9215b:test/models/webhook/delivery_test.rb:slack-webhook-url:156 -2fc9215b:test/models/webhook_test.rb:slack-webhook-url:57 -a515ea3b:test/fixtures/webhooks.yml:generic-api-key:5 -a515ea3b:test/fixtures/webhooks.yml:generic-api-key:11 -1f21c12c:test/vcr_cassettes/command/ai/translator_test-test_combine_commands_and_filters.yml:github-oauth:73012 diff --git a/Gemfile.lock b/Gemfile.lock index ea4794b5a..d99375463 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: bf81d40a91880c059ce9d0447219385a2c8e4a15 + revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751 branch: ast-immediate-variants-process-locally specs: actioncable (8.2.0.alpha) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index aabe04258..91c11943f 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: 43dbc896ce7b6a08194a92ddd1695d3f1ebf554b + revision: a14df11b57818697df4b2cc7b6a43e762ebaa196 specs: fizzy-saas (0.1.0) audits1984 @@ -82,7 +82,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: bf81d40a91880c059ce9d0447219385a2c8e4a15 + revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751 branch: ast-immediate-variants-process-locally specs: actioncable (8.2.0.alpha) diff --git a/README.md b/README.md index b1739b5e8..5ea839008 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,25 @@ After the first deploy is done, any subsequent steps won't need to do that initi bin/kamal deploy ``` +## File storage (Active Storage) + +Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`. + +To use the included `s3` service, set: + +- `ACTIVE_STORAGE_SERVICE=s3` +- `S3_ACCESS_KEY_ID` +- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`) +- `S3_REGION` (defaults to `us-east-1`) +- `S3_SECRET_ACCESS_KEY` + +Optional for S3-compatible endpoints: + +- `S3_ENDPOINT` +- `S3_FORCE_PATH_STYLE=true` +- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`) +- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`) + ## Development ### Setting up 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/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index cc79ca76a..f89028f02 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -295,6 +295,10 @@ max-height: 200px; overflow: scroll; + + &:is(.lexxy-prompt-menu--visible) { + padding: var(--lexxy-prompt-padding); + } } .lexxy-prompt-menu--visible { diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index 8d9b2e5c1..b50189366 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -219,6 +219,15 @@ } } + .nav__blank-slate { + font-size: var(--text-small); + margin: 1rem auto; + + .nav:has(.popup__item:not([hidden])) & { + display: none; + } + } + .nav__footer { background-color: var(--color-canvas); border-block-start: 1px solid var(--color-ink-lighter); diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 5b73e1266..0f487e1f9 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -116,6 +116,8 @@ pointer-events: none; } + .cursor-pointer { cursor: pointer; } + /* Padding */ .pad { padding: var(--block-space) var(--inline-space); } .pad-double { padding: var(--block-space-double) var(--inline-space-double); } diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb index fba2ccfe9..217ee603c 100644 --- a/app/controllers/account/join_codes_controller.rb +++ b/app/controllers/account/join_codes_controller.rb @@ -9,8 +9,11 @@ class Account::JoinCodesController < ApplicationController end def update - @join_code.update!(join_code_params) - redirect_to account_join_code_path + if @join_code.update(join_code_params) + redirect_to account_join_code_path + else + render :edit, status: :unprocessable_entity + end end def destroy 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..27c507abe 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 @@ -32,6 +51,6 @@ class Cards::CommentsController < ApplicationController end def comment_params - params.expect(comment: :body) + params.expect(comment: [ :body, :created_at ]) end end 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..8223d3801 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -10,8 +10,17 @@ class CardsController < ApplicationController end def create - card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted") - redirect_to card + respond_to do |format| + format.html do + card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted") + redirect_to card + end + + format.json do + card = @board.cards.create! card_params.merge(creator: Current.user, status: "published") + head :created, location: card_path(card, format: :json) + end + end end def show @@ -22,11 +31,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 @@ -43,6 +61,6 @@ class CardsController < ApplicationController end def card_params - params.expect(card: [ :status, :title, :description, :image, tag_ids: [] ]) + params.expect(card: [ :status, :title, :description, :image, :created_at, tag_ids: [] ]) end end 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/helpers/html_helper.rb b/app/helpers/html_helper.rb index 8998ffcb9..218d58e78 100644 --- a/app/helpers/html_helper.rb +++ b/app/helpers/html_helper.rb @@ -1,6 +1,11 @@ module HtmlHelper + include ERB::Util + + EXCLUDE_PUNCTUATION = %(.?,:!;"'<>) + EXCLUDE_PUNCTUATION_REGEX = /[#{Regexp.escape(EXCLUDE_PUNCTUATION)}]+\z/ + def format_html(html) - fragment = Nokogiri::HTML.fragment(html) + fragment = Nokogiri::HTML5.fragment(html) auto_link(fragment) @@ -16,14 +21,16 @@ module HtmlHelper fragment.traverse do |node| next unless auto_linkable_node?(node) - content = node.text + # Take care to escape the html text node, so that the subsequent Nokogiri re-parse doesn't + # create tags where there aren't any. + content = h(node.text) linked_content = content.dup auto_link_urls(linked_content) auto_link_emails(linked_content) if linked_content != content - node.replace(Nokogiri::HTML.fragment(linked_content)) + node.replace(Nokogiri::HTML5.fragment(linked_content)) end end end @@ -40,8 +47,10 @@ module HtmlHelper end def extract_url_and_punctuation(url_match) - if url_match.end_with?(".", "?", ",", ":") - [ url_match[..-2], url_match[-1] ] + url_match = CGI.unescapeHTML(url_match) + if match = url_match.match(EXCLUDE_PUNCTUATION_REGEX) + len = match[0].length + [ url_match[..-(len+1)], url_match[-len..] ] else [ url_match, "" ] end diff --git a/app/jobs/storage/materialize_job.rb b/app/jobs/storage/materialize_job.rb new file mode 100644 index 000000000..78ecd3746 --- /dev/null +++ b/app/jobs/storage/materialize_job.rb @@ -0,0 +1,10 @@ +class Storage::MaterializeJob < ApplicationJob + queue_as :backend + limits_concurrency to: 1, key: ->(owner) { owner } + + discard_on ActiveJob::DeserializationError + + def perform(owner) + owner.materialize_storage + end +end diff --git a/app/jobs/storage/reconcile_job.rb b/app/jobs/storage/reconcile_job.rb new file mode 100644 index 000000000..be0c0748c --- /dev/null +++ b/app/jobs/storage/reconcile_job.rb @@ -0,0 +1,9 @@ +class Storage::ReconcileJob < ApplicationJob + queue_as :backend + + discard_on ActiveJob::DeserializationError + + def perform(owner) + owner.reconcile_storage + end +end diff --git a/app/models/account.rb b/app/models/account.rb index bedfdc630..761efd2fa 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Entropic, Seedeable, MultiTenant + include Account::Storage, Entropic, MultiTenant, Seedeable has_one :join_code has_many :users, dependent: :destroy diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb index 82283e4f6..79160cbaa 100644 --- a/app/models/account/join_code.rb +++ b/app/models/account/join_code.rb @@ -1,8 +1,11 @@ class Account::JoinCode < ApplicationRecord CODE_LENGTH = 12 + USAGE_LIMIT_MAX = 10_000_000_000 belongs_to :account + validates :usage_limit, numericality: { less_than_or_equal_to: USAGE_LIMIT_MAX, message: "cannot be larger than the population of the planet" } + scope :active, -> { where("usage_count < usage_limit") } before_create :generate_code, if: -> { code.blank? } diff --git a/app/models/account/storage.rb b/app/models/account/storage.rb new file mode 100644 index 000000000..8b432e8ce --- /dev/null +++ b/app/models/account/storage.rb @@ -0,0 +1,9 @@ +module Account::Storage + extend ActiveSupport::Concern + include Storage::Totaled + + private + def calculate_real_storage_bytes + boards.sum { |board| board.send(:calculate_real_storage_bytes) } + end +end diff --git a/app/models/board.rb b/app/models/board.rb index 9f1bcd5d7..ada49f1f2 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -1,5 +1,5 @@ class Board < ApplicationRecord - include Accessible, AutoPostponing, Broadcastable, Cards, Entropic, Filterable, Publishable, Triageable + include Accessible, AutoPostponing, Board::Storage, Broadcastable, Cards, Entropic, Filterable, Publishable, ::Storage::Tracked, Triageable belongs_to :creator, class_name: "User", default: -> { Current.user } belongs_to :account, default: -> { creator.account } diff --git a/app/models/board/storage.rb b/app/models/board/storage.rb new file mode 100644 index 000000000..64dde9985 --- /dev/null +++ b/app/models/board/storage.rb @@ -0,0 +1,56 @@ +module Board::Storage + extend ActiveSupport::Concern + include Storage::Totaled + + # Board's own embeds (public_description) count toward itself + def board_for_storage_tracking + self + end + + private + BATCH_SIZE = 1000 + + # Calculate actual storage by summing blob sizes. + # + # Uses batched pluck queries to avoid loading huge ID arrays, and avoids + # ActiveRecord model queries on ActiveStorage tables to sidestep cross-pool + # issues when ActiveStorage uses separate connection pools (e.g., with replicas). + def calculate_real_storage_bytes + card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes + end + + def card_image_bytes + sum_blob_bytes_in_batches \ + ActiveStorage::Attachment.where(record_type: "Card", name: "image"), + cards.pluck(:id) + end + + def card_embed_bytes + sum_embed_bytes_for "Card", cards.pluck(:id) + end + + def comment_embed_bytes + sum_embed_bytes_for "Comment", Comment.where(card_id: cards.pluck(:id)).pluck(:id) + end + + def board_embed_bytes + sum_embed_bytes_for "Board", [ id ] + end + + def sum_embed_bytes_for(record_type, record_ids) + rich_text_ids = ActionText::RichText \ + .where(record_type: record_type, record_id: record_ids) + .pluck(:id) + + sum_blob_bytes_in_batches \ + ActiveStorage::Attachment.where(record_type: "ActionText::RichText", name: "embeds"), + rich_text_ids + end + + def sum_blob_bytes_in_batches(base_scope, record_ids) + record_ids.each_slice(BATCH_SIZE).sum do |batch_ids| + blob_ids = base_scope.where(record_id: batch_ids).pluck(:blob_id) + ActiveStorage::Blob.where(id: blob_ids).sum(:byte_size) + end + end +end diff --git a/app/models/card.rb b/app/models/card.rb index dfad00cb8..33c96e502 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,7 +1,7 @@ class Card < ApplicationRecord include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, - Readable, Searchable, Stallable, Statuses, Taggable, Triageable, Watchable + Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable belongs_to :account, default: -> { board.account } belongs_to :board diff --git a/app/models/card/eventable/system_commenter.rb b/app/models/card/eventable/system_commenter.rb index 0e76700b3..5c7defc29 100644 --- a/app/models/card/eventable/system_commenter.rb +++ b/app/models/card/eventable/system_commenter.rb @@ -1,4 +1,6 @@ class Card::Eventable::SystemCommenter + include ERB::Util + attr_reader :card, :event def initialize(card, event) @@ -15,25 +17,53 @@ class Card::Eventable::SystemCommenter def comment_body case event.action when "card_assigned" - "#{event.creator.name} assigned this to #{event.assignees.pluck(:name).to_sentence}." + "#{creator_name} assigned this to #{assignee_names}." when "card_unassigned" - "#{event.creator.name} unassigned from #{event.assignees.pluck(:name).to_sentence}." + "#{creator_name} unassigned from #{assignee_names}." when "card_closed" - "Moved to “Done” by #{event.creator.name}" + "Moved to “Done” by #{creator_name}" when "card_reopened" - "Reopened by #{event.creator.name}" + "Reopened by #{creator_name}" when "card_postponed" - "#{event.creator.name} moved this to “Not Now”" + "#{creator_name} moved this to “Not Now”" when "card_auto_postponed" "Moved to “Not Now” due to inactivity" when "card_title_changed" - "#{event.creator.name} changed the title from “#{event.particulars.dig('particulars', 'old_title')}” to “#{event.particulars.dig('particulars', 'new_title')}”." + "#{creator_name} changed the title from “#{old_title}” to “#{new_title}”." when "card_board_changed" - "#{event.creator.name} moved this from “#{event.particulars.dig('particulars', 'old_board')}” to “#{event.particulars.dig('particulars', 'new_board')}”." + "#{creator_name} moved this from “#{old_board}” to “#{new_board}”." when "card_triaged" - "#{event.creator.name} moved this to “#{event.particulars.dig('particulars', 'column')}”" + "#{creator_name} moved this to “#{column}”" when "card_sent_back_to_triage" - "#{event.creator.name} moved this back to “Maybe?”" + "#{creator_name} moved this back to “Maybe?”" end end + + def creator_name + h event.creator.name + end + + def assignee_names + h event.assignees.pluck(:name).to_sentence + end + + def old_title + h event.particulars.dig("particulars", "old_title") + end + + def new_title + h event.particulars.dig("particulars", "new_title") + end + + def old_board + h event.particulars.dig("particulars", "old_board") + end + + def new_board + h event.particulars.dig("particulars", "new_board") + end + + def column + h event.particulars.dig("particulars", "column") + end end diff --git a/app/models/card/statuses.rb b/app/models/card/statuses.rb index 53b243e4e..5311c211e 100644 --- a/app/models/card/statuses.rb +++ b/app/models/card/statuses.rb @@ -4,37 +4,25 @@ module Card::Statuses included do enum :status, %w[ drafted published ].index_by(&:itself) - attr_reader :initial_status - - before_save :update_created_at_on_publication - before_save :remember_initial_status + before_save :mark_if_just_published after_create -> { track_event :published }, if: :published? scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) } end + attr_accessor :was_just_published + alias_method :was_just_published?, :was_just_published + def publish transaction do + self.created_at = Time.current published! track_event :published end end - def was_just_published? - initial_status&.drafted? && status_in_database.inquiry.published? - end - private - def update_created_at_on_publication - if will_save_change_to_status? && status_in_database.inquiry.drafted? - self.created_at = Time.current - end - end - - # So that we can check it in callbacks when other operations in the transaction clean the changes. - def remember_initial_status - if will_save_change_to_status? - @initial_status ||= status_in_database.to_s.inquiry - end + def mark_if_just_published + self.was_just_published = true if published? && status_changed? end end diff --git a/app/models/comment.rb b/app/models/comment.rb index f81c465ad..a396a904f 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,5 +1,5 @@ class Comment < ApplicationRecord - include Attachments, Eventable, Mentions, Promptable, Searchable + include Attachments, Eventable, Mentions, Promptable, Searchable, Storage::Tracked belongs_to :account, default: -> { card.account } belongs_to :card, touch: true diff --git a/app/models/concerns/storage/totaled.rb b/app/models/concerns/storage/totaled.rb new file mode 100644 index 000000000..3f220f55f --- /dev/null +++ b/app/models/concerns/storage/totaled.rb @@ -0,0 +1,70 @@ +module Storage::Totaled + extend ActiveSupport::Concern + + included do + has_one :storage_total, as: :owner, class_name: "Storage::Total", dependent: :destroy + has_many :storage_entries, class_name: "Storage::Entry", foreign_key: foreign_key_for_storage + end + + class_methods do + def foreign_key_for_storage + "#{model_name.singular}_id" + end + end + + # Fast: materialized snapshot (may be slightly stale) + def bytes_used + storage_total&.bytes_stored || 0 + end + + # Exact: snapshot + pending entries + def bytes_used_exact + (storage_total || create_storage_total!).current_usage + end + + def materialize_storage_later + Storage::MaterializeJob.perform_later(self) + end + + # Materialize all pending entries into snapshot + def materialize_storage + total = storage_total || create_storage_total! + + total.with_lock do + latest_entry_id = storage_entries.maximum(:id) + + if latest_entry_id && total.last_entry_id != latest_entry_id + scope = storage_entries.where(id: ..latest_entry_id) + scope = scope.where.not(id: ..total.last_entry_id) if total.last_entry_id + delta_sum = scope.sum(:delta) + + total.update! bytes_stored: total.bytes_stored + delta_sum, last_entry_id: latest_entry_id + end + end + end + + # Reconcile ledger against actual attachment storage. + # Uses cursor to ensure consistency: captures max entry ID first, then calculates + # real bytes, then sums only entries up to that cursor. Concurrent uploads during + # calculation will have entries with IDs beyond the cursor, avoiding double-count. + def reconcile_storage + max_entry_id = storage_entries.maximum(:id) + real_bytes = calculate_real_storage_bytes + ledger_bytes = max_entry_id ? storage_entries.where(id: ..max_entry_id).sum(:delta) : 0 + diff = real_bytes - ledger_bytes + + if diff.nonzero? + Storage::Entry.record \ + account: is_a?(Account) ? self : account, + board: is_a?(Board) ? self : nil, + recordable: nil, + delta: diff, + operation: "reconcile" + end + end + + private + def calculate_real_storage_bytes + raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes" + end +end diff --git a/app/models/concerns/storage/tracked.rb b/app/models/concerns/storage/tracked.rb new file mode 100644 index 000000000..7b96e95c0 --- /dev/null +++ b/app/models/concerns/storage/tracked.rb @@ -0,0 +1,57 @@ +module Storage::Tracked + extend ActiveSupport::Concern + + included do + before_update :track_board_transfer, if: :board_transfer? + end + + # Return self as the trackable record for storage entries + def storage_tracked_record + self + end + + # Override in models where board is determined differently (e.g., Board itself) + def board_for_storage_tracking + board + end + + # Total bytes for all attachments on this record + def storage_bytes + attachments_for_storage.sum { |a| a.blob.byte_size } + end + + private + def board_transfer? + respond_to?(:board_id_changed?) && board_id_changed? + end + + def track_board_transfer + old_board_id = board_id_was + current_bytes = storage_bytes + + if current_bytes.positive? + # Debit old board + if old_board_id + Storage::Entry.record \ + account: account, + board_id: old_board_id, + recordable: self, + delta: -current_bytes, + operation: "transfer_out" + end + + # Credit new board + Storage::Entry.record \ + account: account, + board: board, + recordable: self, + delta: current_bytes, + operation: "transfer_in" + end + end + + # Override if needed. Default = all direct attachments + def attachments_for_storage + ActiveStorage::Attachment.where(record: self) + end +end 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/models/push/subscription.rb b/app/models/push/subscription.rb index afd417367..1248e9091 100644 --- a/app/models/push/subscription.rb +++ b/app/models/push/subscription.rb @@ -1,5 +1,6 @@ class Push::Subscription < ApplicationRecord PERMITTED_ENDPOINT_HOSTS = %w[ + jmt17.google.com fcm.googleapis.com updates.push.services.mozilla.com web.push.apple.com diff --git a/app/models/storage.rb b/app/models/storage.rb new file mode 100644 index 000000000..9c17b7cd9 --- /dev/null +++ b/app/models/storage.rb @@ -0,0 +1,5 @@ +module Storage + def self.table_name_prefix + "storage_" + end +end diff --git a/app/models/storage/attachment_tracking.rb b/app/models/storage/attachment_tracking.rb new file mode 100644 index 000000000..d68f5845d --- /dev/null +++ b/app/models/storage/attachment_tracking.rb @@ -0,0 +1,53 @@ +module Storage::AttachmentTracking + extend ActiveSupport::Concern + + included do + # Snapshot IDs in before_destroy since parent record may be deleted + # by the time after_destroy_commit runs + before_destroy :snapshot_storage_context + after_create_commit :record_storage_attach + after_destroy_commit :record_storage_detach + end + + private + def record_storage_attach + return unless storage_tracked_record + + Storage::Entry.record \ + account: storage_tracked_record.account, + board: storage_tracked_record.board_for_storage_tracking, + recordable: storage_tracked_record, + blob: blob, + delta: blob.byte_size, + operation: "attach" + end + + def record_storage_detach + return unless @storage_snapshot + + Storage::Entry.record \ + account_id: @storage_snapshot[:account_id], + board_id: @storage_snapshot[:board_id], + recordable_type: @storage_snapshot[:recordable_type], + recordable_id: @storage_snapshot[:recordable_id], + blob_id: @storage_snapshot[:blob_id], + delta: -blob.byte_size, + operation: "detach" + end + + def snapshot_storage_context + return unless storage_tracked_record + + @storage_snapshot = { + account_id: storage_tracked_record.account.id, + board_id: storage_tracked_record.board_for_storage_tracking&.id, + recordable_type: storage_tracked_record.class.name, + recordable_id: storage_tracked_record.id, + blob_id: blob.id + } + end + + def storage_tracked_record + record.try(:storage_tracked_record) + end +end diff --git a/app/models/storage/entry.rb b/app/models/storage/entry.rb new file mode 100644 index 000000000..a2f6e726b --- /dev/null +++ b/app/models/storage/entry.rb @@ -0,0 +1,35 @@ +class Storage::Entry < ApplicationRecord + belongs_to :account + belongs_to :board, optional: true + belongs_to :recordable, polymorphic: true, optional: true + + scope :pending, ->(last_entry_id) { where.not(id: ..last_entry_id) if last_entry_id } + + # Accepts either objects or _id params (for after_destroy_commit snapshots) + def self.record(delta:, operation:, account: nil, account_id: nil, board: nil, board_id: nil, + recordable: nil, recordable_type: nil, recordable_id: nil, blob: nil, blob_id: nil) + return if delta.zero? + + account_id ||= account&.id + board_id ||= board&.id + blob_id ||= blob&.id + + entry = create! \ + account_id: account_id, + board_id: board_id, + recordable_type: recordable_type || recordable&.class&.name, + recordable_id: recordable_id || recordable&.id, + blob_id: blob_id, + delta: delta, + operation: operation, + user_id: Current.user&.id, + request_id: Current.request_id + + # Enqueue materialization - use find_by to handle cascading deletes + # (Account/Board may be destroyed while attachments are still being cleaned up) + Account.find_by(id: account_id)&.materialize_storage_later + Board.find_by(id: board_id)&.materialize_storage_later if board_id + + entry + end +end diff --git a/app/models/storage/total.rb b/app/models/storage/total.rb new file mode 100644 index 000000000..7e1651d79 --- /dev/null +++ b/app/models/storage/total.rb @@ -0,0 +1,12 @@ +class Storage::Total < ApplicationRecord + belongs_to :owner, polymorphic: true + + def pending_entries + owner.storage_entries.pending(last_entry_id) + end + + # Exact current usage (snapshot + pending) + def current_usage + bytes_stored + pending_entries.sum(:delta) + end +end diff --git a/app/views/account/join_codes/edit.html.erb b/app/views/account/join_codes/edit.html.erb index bca92e58f..8a65adca7 100644 --- a/app/views/account/join_codes/edit.html.erb +++ b/app/views/account/join_codes/edit.html.erb @@ -14,12 +14,21 @@ <%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %> <%= form.number_field :usage_limit, - required: true, autofocus: true, min: @join_code.usage_count, + required: true, autofocus: true, + in: 0..Account::JoinCode::USAGE_LIMIT_MAX, class: "input center txt-large fit-content font-weight-black txt-align-center", style: "max-inline-size: 8ch", data: { action: "keydown.esc@document->form#cancel focus->form#select" } %> + <% if @join_code.errors.any? %> +
+ <% @join_code.errors.full_messages.each do |message| %> +

<%= message %>

+ <% end %> +
+ <% end %> +

- This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times. + This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit_in_database %> times.

<%= form.button type: :submit, class: "btn btn--link center txt-medium", data: { form_target: "submit" } do %> 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/edit/_publication.html.erb b/app/views/boards/edit/_publication.html.erb index 7cfc10ca6..c0f60cd77 100644 --- a/app/views/boards/edit/_publication.html.erb +++ b/app/views/boards/edit/_publication.html.erb @@ -6,17 +6,17 @@ <% if board.published? %>
-
- <%= icon_tag "lock" %> -
+ + <% end %>
<%= text_field_tag :publication_url, published_board_url(board), readonly: true, class: "full-width input fill-white" %> @@ -40,17 +40,17 @@
<% else %>
-
- <%= icon_tag "lock" %> -
+ <% end %>
<% end %> <% end %> 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..39feca775 --- /dev/null +++ b/app/views/cards/show.json.jbuilder @@ -0,0 +1,2 @@ +json.partial! "cards/card", card: @card +json.steps @card.steps, partial: "cards/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 %> +
+ <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <% if @access_tokens.any? %> +

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

+ + + + + + + + + + + <%= render partial: "my/access_tokens/access_token", collection: @access_tokens %> + +
DescriptionPermissionCreated
+ <% else %> +

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

+ <% end %> + + <%= link_to new_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 %> +
+ <%= back_link_to "tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <%= form_with model: @access_token, url: 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 %> +
+ <%= back_link_to "Tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+
+ +

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/my/menus/_jump.html.erb b/app/views/my/menus/_jump.html.erb index e35d03105..1f1ea70c9 100644 --- a/app/views/my/menus/_jump.html.erb +++ b/app/views/my/menus/_jump.html.erb @@ -25,4 +25,8 @@
<%= yield %> + + 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 @@ -
+
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %> +
+

Link a device

+

Use this link to sign-in on another device

+
+
@@ -34,4 +34,4 @@ Copy auto-login link <% end %>
-
+
\ No newline at end of file diff --git a/app/views/users/_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 %> +
+
+

API

+
+ +
+ <%= link_to "Personal access tokens", my_access_tokens_path, class: "btn" %> +
+
+
<%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %> - 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/edit.html.erb b/app/views/webhooks/edit.html.erb index 9075683a4..badb16bc3 100644 --- a/app/views/webhooks/edit.html.erb +++ b/app/views/webhooks/edit.html.erb @@ -15,7 +15,7 @@
<%= form.label :actions do %> Events
-

Trigger a call to the Payload URL when these things occur:

+

Trigger a call to the Payload URL when:

<% end %> <%= render "webhooks/form/actions", form: form %>
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/app/views/webhooks/form/_actions.html.erb b/app/views/webhooks/form/_actions.html.erb index a887fa644..95a287abe 100644 --- a/app/views/webhooks/form/_actions.html.erb +++ b/app/views/webhooks/form/_actions.html.erb @@ -4,13 +4,14 @@ webhook_action_options, :first, :last do |item| %> -
  • -
  • + - <%= item.text %>
  • <% end %> diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb index 02522aedb..e1a6af513 100644 --- a/app/views/webhooks/new.html.erb +++ b/app/views/webhooks/new.html.erb @@ -31,7 +31,7 @@
    <%= form.label :actions do %> Events
    -

    Trigger a call to the Payload URL when these things occur:

    +

    Trigger a call to the Payload URL when:

    <% end %> <%= render "webhooks/form/actions", form: form %>
    diff --git a/bin/dev b/bin/dev index 51d232d91..d01747dd3 100755 --- a/bin/dev +++ b/bin/dev @@ -4,8 +4,8 @@ bin/rails runner - < password: <%= ENV["MYSQL_PASSWORD"] %> pool: 50 + ssl_mode: <%= ENV["MYSQL_SSL_MODE"] %> timeout: 5000 development: @@ -40,4 +41,4 @@ production: cache: <<: *default database: fizzy_production_cache - migrations_paths: db/cache_migrate \ No newline at end of file + migrations_paths: db/cache_migrate diff --git a/config/environments/production.rb b/config/environments/production.rb index 3c676693f..6398d14d0 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -42,6 +42,12 @@ Rails.application.configure do "Cache-Control" => "public, max-age=#{1.year.to_i}" } + # Select Active Storage service via env var; default to local disk. + # Don't overwrite if it's already been set (e.g. by fizzy-saas) + if config.active_storage.service.blank? + config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym + end + # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" diff --git a/config/initializers/action_text.rb b/config/initializers/action_text.rb index eee87de0d..0f804e57b 100644 --- a/config/initializers/action_text.rb +++ b/config/initializers/action_text.rb @@ -11,6 +11,11 @@ module ActionText end end end + + # Delegate storage tracking to the parent record (Card, Comment, Board, etc.) + def storage_tracked_record + record.try(:storage_tracked_record) + end end end end diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index 74461c795..e647a72cf 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -1,3 +1,7 @@ +ActiveSupport.on_load(:active_storage_attachment) do + include Storage::AttachmentTracking +end + ActiveSupport.on_load(:active_storage_blob) do ActiveStorage::DiskController.after_action only: :show do expires_in 5.minutes, public: true diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index fe37a5cde..eb84315e8 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -2,9 +2,40 @@ # Define an application-wide Content Security Policy. # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy +# +# Directives are configurable via environment variables with fallback to config.x +# settings. This allows fizzy-saas (or other deployments) to extend the base policy +# without duplicating it. +# +# ENV vars (space-separated sources): +# CSP_DEFAULT_SRC, CSP_SCRIPT_SRC, CSP_STYLE_SRC, CSP_CONNECT_SRC, CSP_FRAME_SRC, +# CSP_IMG_SRC, CSP_FONT_SRC, CSP_MEDIA_SRC, CSP_WORKER_SRC, CSP_FRAME_ANCESTORS, +# CSP_FORM_ACTION, CSP_REPORT_URI, CSP_REPORT_ONLY, DISABLE_CSP +# +# config.x.content_security_policy.* (string, space-separated string, or array): +# script_src, style_src, connect_src, frame_src, img_src, font_src, media_src, +# worker_src, frame_ancestors, form_action, report_uri, report_only Rails.application.configure do - # Configure with environment variables with fallback to config.x values (via fizzy-sass) + # Helper to get additional CSP sources from ENV or config.x. + # Supports: nil, string, space-separated string, or array. + sources = ->(directive) do + env_key = "CSP_#{directive.to_s.upcase}" + value = if ENV.key?(env_key) + ENV[env_key] + else + config.x.content_security_policy.send(directive) + end + + case value + when nil then [] + when Array then value + when String then value.split + else [] + end + end + + # Report URI and report-only mode report_uri = ENV.fetch("CSP_REPORT_URI") { config.x.content_security_policy.report_uri } report_only = if ENV.key?("CSP_REPORT_ONLY") @@ -18,23 +49,25 @@ Rails.application.configure do config.content_security_policy_nonce_directives = %w[ script-src ] config.content_security_policy do |policy| - policy.default_src :self - policy.script_src :self, "https://challenges.cloudflare.com" - policy.connect_src :self, "https://storage.basecamp.com" - policy.frame_src :self, "https://challenges.cloudflare.com" + policy.default_src :self, *sources.(:default_src) + policy.script_src :self, *sources.(:script_src) + policy.connect_src :self, *sources.(:connect_src) + policy.frame_src :self, *sources.(:frame_src) # Don't fight user tools: permit inline styles, data:/https: sources, and # blob: workers for accessibility extensions, privacy tools, and custom fonts. - policy.style_src :self, :unsafe_inline - policy.img_src :self, "blob:", "data:", "https:" - policy.font_src :self, "data:", "https:" - policy.media_src :self, "blob:", "data:", "https:" - policy.worker_src :self, "blob:" + policy.style_src :self, :unsafe_inline, *sources.(:style_src) + policy.img_src :self, "blob:", "data:", "https:", *sources.(:img_src) + policy.font_src :self, "data:", "https:", *sources.(:font_src) + policy.media_src :self, "blob:", "data:", "https:", *sources.(:media_src) + policy.worker_src :self, "blob:", *sources.(:worker_src) + # Security-critical defaults (not configurable) policy.object_src :none policy.base_uri :none - policy.form_action :self - policy.frame_ancestors :self + + policy.form_action :self, *sources.(:form_action) + policy.frame_ancestors :self, *sources.(:frame_ancestors) # Specify URI for violation reports (e.g., Sentry CSP endpoint) policy.report_uri report_uri if report_uri diff --git a/config/puma.rb b/config/puma.rb index 489ae0779..70ca6ebb4 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -7,8 +7,11 @@ pidfile ENV.fetch("PIDFILE", "tmp/pids/server.pid") # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart -# Run Solid Queue with Puma -plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] +# Run Solid Queue with Puma by default. +# Disabled when running fizzy-saas or via SOLID_QUEUE_IN_PUMA=false. +unless Fizzy.saas? || ENV["SOLID_QUEUE_IN_PUMA"] == "false" + plugin :solid_queue +end # Expose Prometheus metrics at http://0.0.0.0:9394/metrics (SaaS only). # In dev, overridden to http://127.0.0.1:9306/metrics in .mise.toml. 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/config/storage.oss.yml b/config/storage.oss.yml index e50debd34..37052e6c5 100644 --- a/config/storage.oss.yml +++ b/config/storage.oss.yml @@ -16,3 +16,14 @@ devminio: region: us-east-1 # default region required for signer access_key_id: minioadmin secret_access_key: minioadmin + +s3: + service: S3 + access_key_id: <%= ENV["S3_ACCESS_KEY_ID"] %> + bucket: <%= ENV["S3_BUCKET"] || "fizzy-#{Rails.env}-activestorage" %> + endpoint: <%= ENV["S3_ENDPOINT"] %> + force_path_style: <%= ENV["S3_FORCE_PATH_STYLE"] == "true" %> + region: <%= ENV.fetch("S3_REGION", "us-east-1") %> + request_checksum_calculation: <%= ENV.fetch("S3_REQUEST_CHECKSUM_CALCULATION", "when_supported") %> + response_checksum_validation: <%= ENV.fetch("S3_RESPONSE_CHECKSUM_VALIDATION", "when_supported") %> + secret_access_key: <%= ENV["S3_SECRET_ACCESS_KEY"] %> 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/migrate/20251205205826_create_storage_tables.rb b/db/migrate/20251205205826_create_storage_tables.rb new file mode 100644 index 000000000..878b6dd72 --- /dev/null +++ b/db/migrate/20251205205826_create_storage_tables.rb @@ -0,0 +1,27 @@ +class CreateStorageTables < ActiveRecord::Migration[8.0] + def change + # Storage ledger: debit/credit event stream + create_table :storage_entries, id: :uuid do |t| + t.references :account, type: :uuid, null: false + t.references :board, type: :uuid, null: true + + t.references :recordable, type: :uuid, polymorphic: true, null: true + + t.bigint :delta, null: false + t.string :operation, null: false + + t.datetime :created_at, null: false + end + + # Storage totals: cached snapshots + create_table :storage_totals, id: :uuid do |t| + t.references :owner, type: :uuid, polymorphic: true, null: false, index: false + + t.bigint :bytes_stored, null: false, default: 0 + t.uuid :last_entry_id # Cursor: includes all entries <= this ID + + t.timestamps + t.index %i[ owner_type owner_id ], unique: true + end + end +end diff --git a/db/migrate/20251210054934_add_blob_id_and_audit_context_to_storage_entries.rb b/db/migrate/20251210054934_add_blob_id_and_audit_context_to_storage_entries.rb new file mode 100644 index 000000000..42fa66a77 --- /dev/null +++ b/db/migrate/20251210054934_add_blob_id_and_audit_context_to_storage_entries.rb @@ -0,0 +1,9 @@ +class AddBlobIdAndAuditContextToStorageEntries < ActiveRecord::Migration[8.2] + def change + change_table :storage_entries do |t| + t.references :blob, type: :uuid, foreign_key: false, index: true + t.references :user, type: :uuid, foreign_key: false, index: true + t.string :request_id, index: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 42458db17..645cb7a96 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do +ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -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 @@ -687,6 +697,35 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.index ["card_id"], name: "index_steps_on_card_id" end + create_table "storage_entries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "blob_id" + t.uuid "board_id" + t.datetime "created_at", null: false + t.bigint "delta", null: false + t.string "operation", null: false + t.uuid "recordable_id" + t.string "recordable_type" + t.string "request_id" + t.uuid "user_id" + t.index ["account_id"], name: "index_storage_entries_on_account_id" + t.index ["blob_id"], name: "index_storage_entries_on_blob_id" + t.index ["board_id"], name: "index_storage_entries_on_board_id" + t.index ["recordable_type", "recordable_id"], name: "index_storage_entries_on_recordable" + t.index ["request_id"], name: "index_storage_entries_on_request_id" + t.index ["user_id"], name: "index_storage_entries_on_user_id" + end + + create_table "storage_totals", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "bytes_stored", default: 0, null: false + t.datetime "created_at", null: false + t.uuid "last_entry_id" + t.uuid "owner_id", null: false + t.string "owner_type", null: false + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id"], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true + end + create_table "taggings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 1dd2b3e00..84fd827bb 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do +ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -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 @@ -460,6 +470,35 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do t.index ["card_id"], name: "index_steps_on_card_id" end + create_table "storage_entries", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "blob_id" + t.uuid "board_id" + t.datetime "created_at", null: false + t.bigint "delta", null: false + t.string "operation", limit: 255, null: false + t.uuid "recordable_id" + t.string "recordable_type", limit: 255 + t.string "request_id" + t.uuid "user_id" + t.index ["account_id"], name: "index_storage_entries_on_account_id" + t.index ["blob_id"], name: "index_storage_entries_on_blob_id" + t.index ["board_id"], name: "index_storage_entries_on_board_id" + t.index ["recordable_type", "recordable_id"], name: "index_storage_entries_on_recordable" + t.index ["request_id"], name: "index_storage_entries_on_request_id" + t.index ["user_id"], name: "index_storage_entries_on_user_id" + end + + create_table "storage_totals", id: :uuid, force: :cascade do |t| + t.bigint "bytes_stored", default: 0, null: false + t.datetime "created_at", null: false + t.uuid "last_entry_id" + t.uuid "owner_id", null: false + t.string "owner_type", limit: 255, null: false + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id"], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true + end + create_table "taggings", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 000000000..3710e582f --- /dev/null +++ b/docs/API.md @@ -0,0 +1,1165 @@ +# 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 | Profile page with API section | + | 2 | In the API section click on "Personal access token" | Personal access tokens page | + | 3 | Click on "Generate a new access token" | Generate new access token dialog | + | 4 | Give it a description and assign it a permission | Access token created | +
    + +> [!IMPORTANT] +> __An access token is like a password, keep it secret and do not share it with anyone.__ +> Any person or application that has your access token can perform actions on your behalf. + +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. + +A validation error will produce a `422 Unprocessable Entity` response, which will sometimes be accompanied by details about the error: + +```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" +# ... +``` + +## List parameters + +When an endpoint accepts a list of values as a parameter, you can provide multiple values by repeating the parameter name: + +``` +?tag_ids[]=tag1&tag_ids[]=tag2&tag_ids[]=tag3 +``` + +List parameters always end with `[]`. + +## 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. + +### `GET /my/identity` + +Returns a list of accounts the identity has access to, including the user for each account. + +```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 specified 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_ids[]` | Filter by board ID(s) | +| `tag_ids[]` | Filter by tag ID(s) | +| `assignee_ids[]` | Filter by assignee user ID(s) | +| `creator_ids[]` | Filter by card creator ID(s) | +| `closer_ids[]` | Filter by user ID(s) who closed the cards | +| `card_ids[]` | Filter to specific card ID(s) | +| `indexed_by` | Filter by: `all` (default), `closed`, `not_now`, `stalled`, `postponing_soon`, `golden` | +| `sorted_by` | Sort order: `latest` (default), `newest`, `oldest` | +| `assignment_status` | Filter by assignment status: `unassigned` | +| `creation` | Filter by creation date: `today`, `yesterday`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`, `thisyear`, `lastyear` | +| `closure` | Filter by closure date: `today`, `yesterday`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`, `thisyear`, `lastyear` | +| `terms[]` | Search terms to filter cards | + +__Response:__ + +```json +[ + { + "id": "03f5vaeq985jlvwv3arl4srq2", + "number": 1, + "title": "First!", + "status": "published", + "description": "Hello, World!", + "description_html": "

    Hello, World!

    ", + "image_url": null, + "tags": ["programming"], + "golden": false, + "last_active_at": "2025-12-05T19:38:48.553Z", + "created_at": "2025-12-05T19:38:48.540Z", + "url": "http://fizzy.localhost:3006/897362094/cards/4", + "board": { + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Fizzy", + "all_access": true, + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + } + }, + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + }, + "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments" + }, +] +``` + +### `GET /:account_slug/cards/:card_number` + +Returns a specific card by its number. + +__Response:__ + +Same as the card object in the list response. + +### `POST /:account_slug/boards/:board_id/cards` + +Creates a new card in a board. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `title` | string | Yes | The title of the card | +| `description` | string | No | Rich text description of the card | +| `status` | string | No | Initial status: `published` (default), `drafted` | +| `image` | file | No | Header image for the card | +| `tag_ids` | array | No | Array of tag IDs to apply to the card | +| `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) | + +__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: `drafted`, `published` | +| `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) | +| `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) | + +__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 max) 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/lib/rails_ext/active_record_replica_support.rb b/lib/rails_ext/active_record_replica_support.rb index 2625a1ac9..8b9e63a98 100644 --- a/lib/rails_ext/active_record_replica_support.rb +++ b/lib/rails_ext/active_record_replica_support.rb @@ -34,6 +34,18 @@ module ActiveRecordReplicaSupport def replica_configured? configurations.find_db_config("replica").present? end + + # Execute block using read replica if available, otherwise use primary. + # + # Example: + # ApplicationRecord.with_reading_role { User.count } + def with_reading_role(&block) + if replica_configured? + connected_to(role: :reading, &block) + else + yield + end + end end end diff --git a/script/migrations/backfill-storage-ledger.rb b/script/migrations/backfill-storage-ledger.rb new file mode 100644 index 000000000..9ad0b20b4 --- /dev/null +++ b/script/migrations/backfill-storage-ledger.rb @@ -0,0 +1,73 @@ +#!/usr/bin/env ruby + +# Backfill storage ledger with attach entries for all existing attachments. +# +# Run locally: +# bin/rails runner script/migrations/backfill-storage-ledger.rb +# +# Run via Kamal: +# kamal app exec -d -p --reuse "bin/rails runner script/migrations/backfill-storage-ledger.rb" +# +# Safe to re-run: skips attachments that already have entries (by blob_id). +class BackfillStorageLedger + def run + puts "Backfilling storage entries…" + backfill_entries + + puts "\nMaterializing totals…" + materialize_totals + end + + private + def backfill_entries + created = 0 + skipped = 0 + + ActiveStorage::Attachment.includes(:blob).find_each do |attachment| + record = attachment.record.try(:storage_tracked_record) + + if record.nil? || Storage::Entry.exists?(blob_id: attachment.blob_id) + skipped += 1 + next + end + + Storage::Entry.create! \ + account_id: record.account.id, + board_id: record.board_for_storage_tracking&.id, + recordable_type: record.class.name, + recordable_id: record.id, + blob_id: attachment.blob_id, + delta: attachment.blob.byte_size, + operation: "attach" + created += 1 + + print "." if created % 100 == 0 + end + + puts "\n\nBackfill complete!" + puts " Entries created: #{created}" + puts " Attachments skipped: #{skipped}" + end + + def materialize_totals + boards_materialized = 0 + accounts_materialized = 0 + + Board.find_each do |board| + board.materialize_storage + boards_materialized += 1 + print "." if boards_materialized % 100 == 0 + end + + Account.find_each do |account| + account.materialize_storage + accounts_materialized += 1 + end + + puts "\n\nMaterialization complete!" + puts " Boards: #{boards_materialized}" + puts " Accounts: #{accounts_materialized}" + end +end + +BackfillStorageLedger.new.run diff --git a/test/controllers/accounts/join_codes_controller_test.rb b/test/controllers/accounts/join_codes_controller_test.rb index 33c118386..15d41b5ec 100644 --- a/test/controllers/accounts/join_codes_controller_test.rb +++ b/test/controllers/accounts/join_codes_controller_test.rb @@ -37,4 +37,14 @@ class Account::JoinCodesControllerTest < ActionDispatch::IntegrationTest delete account_join_code_path assert_response :forbidden end + + test "update with extremely large usage_limit" do + # A number larger than bigint max (2^63 - 1 = 9223372036854775807) + huge_number = "99999999999999999999999999999999999" + + put account_join_code_path, params: { account_join_code: { usage_limit: huge_number } } + + assert_response :unprocessable_entity + assert_select ".txt-negative", text: /cannot be larger than the population of the planet/ + end end 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..b34b64193 100644 --- a/test/controllers/cards/comments_controller_test.rb +++ b/test/controllers/cards/comments_controller_test.rb @@ -27,4 +27,63 @@ 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 "create as JSON with custom created_at" do + card = cards(:logo) + custom_time = Time.utc(2024, 1, 15, 10, 30, 0) + + assert_difference -> { card.comments.count }, +1 do + post card_comments_path(card), params: { comment: { body: "Backdated comment", created_at: custom_time } }, as: :json + end + + assert_response :created + assert_equal custom_time, Comment.last.created_at + 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..a22018a46 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -132,4 +132,60 @@ class CardsControllerTest < ActionDispatch::IntegrationTest get card_path(card) assert_response :success end + + test "show as JSON" do + card = cards(:logo) + card.steps.create!(content: "First step") + card.steps.create!(content: "Second step", completed: true) + + get card_path(card), as: :json + assert_response :success + assert_equal card.title, @response.parsed_body["title"] + assert_equal 2, @response.parsed_body["steps"].size + 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 "create as JSON with custom created_at" do + custom_time = Time.utc(2024, 1, 15, 10, 30, 0) + + assert_difference -> { Card.count }, +1 do + post board_cards_path(boards(:writebook)), + params: { card: { title: "Backdated card", created_at: custom_time } }, + as: :json + end + + assert_response :created + assert_equal custom_time, Card.last.created_at + 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/helpers/html_helper_test.rb b/test/helpers/html_helper_test.rb index e6389a5fa..4011cbf70 100644 --- a/test/helpers/html_helper_test.rb +++ b/test/helpers/html_helper_test.rb @@ -10,25 +10,65 @@ class HtmlHelperTest < ActionView::TestCase format_html("

    Check this: https://example.com/

    ") end - test "don't' include punctuation in URL autolinking" do + test "don't include punctuation in URL autolinking" do assert_equal_html \ - %(

    Check this: https://example.com!

    ), - format_html("

    Check this: https://example.com!

    ") + %(

    Check this: https://example.com/!

    ), + format_html("

    Check this: https://example.com/!

    ") assert_equal_html \ - %(

    Check this: https://example.com.

    ), - format_html("

    Check this: https://example.com.

    ") + %(

    Check this: https://example.com/.

    ), + format_html("

    Check this: https://example.com/.

    ") assert_equal_html \ - %(

    Check this: https://example.com?

    ), - format_html("

    Check this: https://example.com?

    ") + %(

    Check this: https://example.com/?

    ), + format_html("

    Check this: https://example.com/?

    ") assert_equal_html \ - %(

    Check this: https://example.com,

    ), - format_html("

    Check this: https://example.com,

    ") + %(

    Check this: https://example.com/,

    ), + format_html("

    Check this: https://example.com/,

    ") assert_equal_html \ - %(

    Check this: https://example.com:

    ), - format_html("

    Check this: https://example.com:

    ") + %(

    Check this: https://example.com/:

    ), + format_html("

    Check this: https://example.com/:

    ") assert_equal_html \ - %(

    Check this: https://example.com;

    ), - format_html("

    Check this: https://example.com;

    ") + %(

    Check this: https://example.com/;

    ), + format_html("

    Check this: https://example.com/;

    ") + assert_equal_html \ + %(

    Check this: https://example.com/"

    ), + format_html("

    Check this: https://example.com/\"

    ") + assert_equal_html \ + %(

    Check this: https://example.com/'

    ), + format_html("

    Check this: https://example.com/'

    ") + + # trailing entities that decode to punctuation + # use assert_equal and not assert_equal_html to make sure we're getting entities correct + assert_equal \ + %(

    Check this: https://example.com/<

    ), + format_html("

    Check this: https://example.com/<

    ") + assert_equal \ + %(

    Check this: https://example.com/>

    ), + format_html("

    Check this: https://example.com/>

    ") + assert_equal \ + %(

    Check this: https://example.com/"

    ), + format_html("

    Check this: https://example.com/"

    ") + + # multiple punctuation characters including entities + assert_equal_html \ + %(

    Check this: https://example.com/!?;

    ), + format_html("

    Check this: https://example.com/!?;

    ") + assert_equal_html \ + %(<img src="https://example.com/">), + format_html(%(<img src="https://example.com/">)) + assert_equal_html \ + %(<img src="https://example.com/"!>), + format_html(%(<img src="https://example.com/"!>)) + end + + test "handle URLs with query parameters" do + # use assert_equal and not assert_equal_html to make sure we're getting entities correct + assert_equal \ + %(

    Check this: https://example.com/a?b=c&d=e

    ), + format_html("

    Check this: https://example.com/a?b=c&d=e

    ") + + assert_equal \ + %(

    Check this: https://example.com/a?b=c&d=e

    ), + format_html("

    Check this: https://example.com/a?b=c&d=e

    ") end test "respect existing links" do @@ -56,4 +96,12 @@ class HtmlHelperTest < ActionView::TestCase format_html("<#{element}>Check this: https://example.com") end end + + test "preserve escaped HTML containing URLs" do + input = 'before text <img src="https://example.com/image.png"> after text' + output = format_html(input) + + assert_no_match(/Injected" + Current.session = sessions(:david) + + assert_difference -> { @card.comments.count }, 1 do + @card.toggle_assignment users(:kevin) + end + + comment = @card.comments.last + html = comment.body.body.to_html + assert_includes html, "<em>Injected</em> assigned this to Kevin." + refute_includes html, "Injected" + end + test "don't notify on system comments" do @card.watch_by(users(:david)) 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 diff --git a/test/models/push/subscription_test.rb b/test/models/push/subscription_test.rb index 9f4650614..89887599f 100644 --- a/test/models/push/subscription_test.rb +++ b/test/models/push/subscription_test.rb @@ -98,6 +98,7 @@ class Push::SubscriptionTest < ActiveSupport::TestCase test "accepts all permitted push service domains" do permitted_endpoints = [ "https://fcm.googleapis.com/fcm/send/token123", + "https://jmt17.google.com/fcm/send/token123", "https://updates.push.services.mozilla.com/wpush/v2/token123", "https://web.push.apple.com/QaBC123", "https://wns2-db5p.notify.windows.com/w/?token=abc123" diff --git a/test/models/storage/attachment_tracking_test.rb b/test/models/storage/attachment_tracking_test.rb new file mode 100644 index 000000000..55407630e --- /dev/null +++ b/test/models/storage/attachment_tracking_test.rb @@ -0,0 +1,263 @@ +require "test_helper" + +class Storage::AttachmentTrackingTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + Current.request_id = "test-request-123" + @account = accounts("37s") + @board = boards(:writebook) + @card = cards(:logo) + end + + + # Attachment Creation + + test "attaching file creates storage entry with positive delta" do + assert_difference "Storage::Entry.count", +1 do + @card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png" + end + + entry = Storage::Entry.last + assert_equal 2048, entry.delta + assert_equal "attach", entry.operation + assert_equal @account.id, entry.account_id + assert_equal @board.id, entry.board_id + assert_equal @card.class.name, entry.recordable_type + assert_equal @card.id, entry.recordable_id + assert_equal @card.image.blob.id, entry.blob_id + assert_equal Current.user.id, entry.user_id + assert_equal Current.request_id, entry.request_id + end + + test "attaching file enqueues MaterializeJob for account" do + assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do + @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" + end + end + + test "attaching file enqueues MaterializeJob for board" do + assert_enqueued_with job: Storage::MaterializeJob, args: [ @board ] do + @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" + end + end + + + # Attachment Deletion + + test "destroying attachment creates storage entry with negative delta" do + @card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png" + attachment = @card.image.attachment + blob_id = attachment.blob_id + + # Destroy the attachment directly to trigger callbacks + attachment.destroy! + + entry = Storage::Entry.find_by(operation: "detach", recordable: @card) + assert_not_nil entry, "Expected detach entry to be created" + assert_equal -2048, entry.delta + assert_equal "detach", entry.operation + assert_equal blob_id, entry.blob_id + end + + test "destroying attachment uses snapshotted IDs from before_destroy" do + @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" + + # Capture expected values before destroy + expected_account_id = @account.id + expected_board_id = @board.id + expected_recordable_type = @card.class.name + expected_recordable_id = @card.id + + attachment = @card.image.attachment + attachment.destroy! + + entry = Storage::Entry.find_by(operation: "detach", recordable_id: expected_recordable_id) + assert_not_nil entry, "Expected detach entry to be created" + assert_equal expected_account_id, entry.account_id + assert_equal expected_board_id, entry.board_id + assert_equal expected_recordable_type, entry.recordable_type + assert_equal expected_recordable_id, entry.recordable_id + end + + + # Non-Trackable Records + + test "does not track attachments on records without account method" do + # Account uploads are not trackable (Account.account returns self, but + # uploads on Account are not board-scoped in the same way) + # This test verifies the guard clause works + + # Create a model that doesn't respond to :board + identity = identities(:david) + + # Identity doesn't have :account or :board, so attachments shouldn't be tracked + # (Though in practice, Identity may not have attachments in this codebase) + # We test the guard by checking that the tracking module handles non-trackable records + assert_respond_to @card, :account + assert_respond_to @card, :board + end + + + # Edge Cases + + test "attachment tracking handles nil board gracefully" do + # Create a card with nil board association won't happen in practice + # but test that entry creation handles nil board_id + @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" + entry = Storage::Entry.last + assert_not_nil entry.account_id + # board_id should be present for cards + assert_not_nil entry.board_id + end + + test "replacing attachment creates detach and attach entries" do + # First attachment + @card.image.attach io: StringIO.new("x" * 1024), filename: "first.png", content_type: "image/png" + initial_count = Storage::Entry.count + + # Replace with new attachment + @card.image.attach io: StringIO.new("x" * 2048), filename: "second.png", content_type: "image/png" + + # Should have detach (-1024) and attach (+2048) entries + # Note: depending on purge_later vs purge, the detach might be async + entries = Storage::Entry.where(recordable: @card).order(:id).last(2) + + # At minimum, we should have the new attach entry + attach_entry = entries.find { |e| e.operation == "attach" && e.delta == 2048 } + assert_not_nil attach_entry + end + + + # Rich Text Embeds + # + # ActionText embeds are automatically extracted from body content that contains + # tags referencing ActiveStorage::Blob objects. + # The embeds association is populated during before_validation callback. + + test "card description embed creates storage entry" do + blob = ActiveStorage::Blob.create_and_upload! \ + io: file_fixture("moon.jpg").open, + filename: "card_embed.jpg", + content_type: "image/jpeg" + + # Create rich text content with embedded blob attachment + attachment_html = ActionText::Attachment.from_attachable(blob).to_html + + assert_difference "Storage::Entry.count", +1 do + @card.update!(description: "

    Description with image: #{attachment_html}

    ") + end + + entry = Storage::Entry.last + assert_equal blob.byte_size, entry.delta + assert_equal "attach", entry.operation + assert_equal "Card", entry.recordable_type + assert_equal @card.id, entry.recordable_id + end + + test "comment embed creates storage entry via rich text body" do + blob = ActiveStorage::Blob.create_and_upload! \ + io: file_fixture("moon.jpg").open, + filename: "comment_image.jpg", + content_type: "image/jpeg" + + attachment_html = ActionText::Attachment.from_attachable(blob).to_html + + assert_difference "Storage::Entry.count", +1 do + @card.comments.create!(body: "

    Comment with image: #{attachment_html}

    ") + end + + entry = Storage::Entry.last + assert_equal blob.byte_size, entry.delta + assert_equal "attach", entry.operation + assert_equal @account.id, entry.account_id + assert_equal @board.id, entry.board_id + assert_equal "Comment", entry.recordable_type + end + + test "comment embed uses card's board for tracking" do + blob = ActiveStorage::Blob.create_and_upload! \ + io: file_fixture("moon.jpg").open, + filename: "test.jpg", + content_type: "image/jpeg" + + attachment_html = ActionText::Attachment.from_attachable(blob).to_html + comment = @card.comments.create!(body: "

    Comment: #{attachment_html}

    ") + + entry = Storage::Entry.last + assert_equal @card.board_id, entry.board_id + assert_equal comment.id, entry.recordable_id + end + + test "board public_description embed creates storage entry" do + blob = ActiveStorage::Blob.create_and_upload! \ + io: file_fixture("moon.jpg").open, + filename: "board_image.jpg", + content_type: "image/jpeg" + + attachment_html = ActionText::Attachment.from_attachable(blob).to_html + + assert_difference "Storage::Entry.count", +1 do + @board.update!(public_description: "

    Board description: #{attachment_html}

    ") + end + + entry = Storage::Entry.last + assert_equal blob.byte_size, entry.delta + assert_equal "attach", entry.operation + assert_equal @account.id, entry.account_id + assert_equal @board.id, entry.board_id + assert_equal "Board", entry.recordable_type + assert_equal @board.id, entry.recordable_id + end + + + # Reconciliation includes all attachment types + + test "board calculate_real_storage_bytes includes comment embeds" do + blob = ActiveStorage::Blob.create_and_upload! \ + io: file_fixture("moon.jpg").open, + filename: "comment_embed.jpg", + content_type: "image/jpeg" + + attachment_html = ActionText::Attachment.from_attachable(blob).to_html + @card.comments.create!(body: "

    Comment: #{attachment_html}

    ") + + board_bytes = @board.send(:calculate_real_storage_bytes) + + assert board_bytes >= blob.byte_size, "board bytes should include comment embed bytes" + end + + test "account calculate_real_storage_bytes includes comment embeds via boards" do + blob = ActiveStorage::Blob.create_and_upload! \ + io: file_fixture("moon.jpg").open, + filename: "comment_embed.jpg", + content_type: "image/jpeg" + + attachment_html = ActionText::Attachment.from_attachable(blob).to_html + @card.comments.create!(body: "

    Comment: #{attachment_html}

    ") + + account_bytes = @account.send(:calculate_real_storage_bytes) + + assert account_bytes >= blob.byte_size, "account bytes should include comment embed bytes" + end + + + # Cascading Deletes + + test "attachment tracking handles card deletion gracefully" do + @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" + card_id = @card.id + + # Delete the card - this should trigger attachment purge + # The before_destroy snapshot should capture IDs before card is gone + perform_enqueued_jobs do + assert_nothing_raised do + @card.destroy! + end + end + + # Should have detach entry with snapshotted IDs + detach_entry = Storage::Entry.find_by(recordable_id: card_id, operation: "detach") + assert_not_nil detach_entry, "Expected detach entry for destroyed card" + assert_equal -1024, detach_entry.delta + end +end diff --git a/test/models/storage/entry_test.rb b/test/models/storage/entry_test.rb new file mode 100644 index 000000000..cf5befd52 --- /dev/null +++ b/test/models/storage/entry_test.rb @@ -0,0 +1,168 @@ +require "test_helper" + +class Storage::EntryTest < ActiveSupport::TestCase + setup do + @account = accounts("37s") + @board = boards(:writebook) + @card = cards(:logo) + end + + test "record! creates entry with positive delta" do + assert_difference "Storage::Entry.count", +1 do + entry = Storage::Entry.record \ + account: @account, + board: @board, + recordable: @card, + delta: 1024, + operation: "attach" + + assert_equal @account.id, entry.account_id + assert_equal @board.id, entry.board_id + assert_equal @card.class.name, entry.recordable_type + assert_equal @card.id, entry.recordable_id + assert_equal 1024, entry.delta + assert_equal "attach", entry.operation + end + end + + test "record! creates entry with negative delta" do + entry = Storage::Entry.record \ + account: @account, + board: @board, + recordable: @card, + delta: -512, + operation: "detach" + + assert_equal -512, entry.delta + assert_equal "detach", entry.operation + end + + test "record! returns nil and creates no entry when delta is zero" do + assert_no_difference "Storage::Entry.count" do + result = Storage::Entry.record \ + account: @account, + board: @board, + recordable: @card, + delta: 0, + operation: "attach" + + assert_nil result + end + end + + test "record! accepts _id params for after_destroy_commit snapshots" do + entry = Storage::Entry.record \ + account_id: @account.id, + board_id: @board.id, + recordable_type: "Card", + recordable_id: @card.id, + delta: 2048, + operation: "detach" + + assert_equal @account.id, entry.account_id + assert_equal @board.id, entry.board_id + assert_equal "Card", entry.recordable_type + assert_equal @card.id, entry.recordable_id + end + + test "record! creates entry without board" do + entry = Storage::Entry.record \ + account: @account, + board: nil, + recordable: @card, + delta: 1024, + operation: "attach" + + assert_nil entry.board_id + end + + test "record! creates entry without recordable" do + entry = Storage::Entry.record \ + account: @account, + board: @board, + recordable: nil, + delta: 1024, + operation: "reconcile" + + assert_nil entry.recordable_type + assert_nil entry.recordable_id + end + + test "record! enqueues MaterializeJob for account" do + assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do + Storage::Entry.record \ + account: @account, + board: nil, + recordable: nil, + delta: 1024, + operation: "attach" + end + end + + test "record! enqueues MaterializeJob for board when board_id present" do + assert_enqueued_with job: Storage::MaterializeJob, args: [ @board ] do + Storage::Entry.record \ + account: @account, + board: @board, + recordable: nil, + delta: 1024, + operation: "attach" + end + end + + test "record! does not enqueue job when account is deleted" do + # The graceful handling is that find_by returns nil, so no job is enqueued + # for a non-existent account. We can't test with a fake ID due to FK constraints, + # but we can verify the find_by behavior by stubbing. + Account.stubs(:find_by).returns(nil) + + assert_no_enqueued_jobs only: Storage::MaterializeJob do + Storage::Entry.record \ + account: @account, + delta: 1024, + operation: "attach" + end + end + + test "record! does not enqueue board job when board is deleted" do + Board.stubs(:find_by).returns(nil) + + # Account job still enqueued, but board job is not + entry = Storage::Entry.record \ + account: @account, + board: @board, + delta: 1024, + operation: "attach" + + assert_not_nil entry + end + + test "entries belong to account" do + entry = Storage::Entry.record \ + account: @account, + delta: 1024, + operation: "attach" + + assert_equal @account, entry.account + end + + test "entries belong to board (optional)" do + entry = Storage::Entry.record \ + account: @account, + board: @board, + delta: 1024, + operation: "attach" + + assert_equal @board, entry.board + end + + test "entries belong to recordable (polymorphic, optional)" do + entry = Storage::Entry.record \ + account: @account, + recordable: @card, + delta: 1024, + operation: "attach" + + assert_equal @card, entry.recordable + end +end diff --git a/test/models/storage/total_test.rb b/test/models/storage/total_test.rb new file mode 100644 index 000000000..c57ee6e4f --- /dev/null +++ b/test/models/storage/total_test.rb @@ -0,0 +1,83 @@ +require "test_helper" + +class Storage::TotalTest < ActiveSupport::TestCase + setup do + @account = accounts("37s") + @board = boards(:writebook) + end + + test "pending_entries returns all entries when no cursor" do + # Create some entries + 3.times do |i| + Storage::Entry.record \ + account: @account, + delta: 1024 * (i + 1), + operation: "attach" + end + + total = @account.create_storage_total! + assert_nil total.last_entry_id + + assert_equal 3, total.pending_entries.count + end + + test "pending_entries returns only entries after cursor" do + # Create first entry and set cursor + entry1 = Storage::Entry.record(account: @account, delta: 1024, operation: "attach") + total = @account.create_storage_total!(last_entry_id: entry1.id, bytes_stored: 1024) + + # Advance time to ensure UUIDv7 timestamps sort correctly + travel 1.second + + # Create more entries AFTER cursor is set + entry2 = Storage::Entry.record(account: @account, delta: 2048, operation: "attach") + travel 1.second + entry3 = Storage::Entry.record(account: @account, delta: 512, operation: "attach") + + pending = total.pending_entries + assert_equal 2, pending.count + assert_includes pending, entry2 + assert_includes pending, entry3 + assert_not_includes pending, entry1 + end + + test "current_usage returns snapshot value when no pending entries" do + total = @account.create_storage_total!(bytes_stored: 5000) + + # No entries exist, so nothing pending + assert_equal 5000, total.current_usage + end + + test "current_usage sums snapshot and pending entries" do + # Create first entry and set cursor + entry1 = Storage::Entry.record(account: @account, delta: 1024, operation: "attach") + total = @account.create_storage_total!(last_entry_id: entry1.id, bytes_stored: 1024) + + # Small delay to ensure UUIDv7 timestamp component advances + travel 1.second + + # Create more entries AFTER cursor is set + Storage::Entry.record(account: @account, delta: 2048, operation: "attach") + travel 1.second + Storage::Entry.record(account: @account, delta: -512, operation: "detach") + + # 1024 (snapshot) + 2048 - 512 (pending) = 2560 + assert_equal 2560, total.current_usage + end + + test "belongs to owner polymorphically" do + account_total = Storage::Total.create!(owner: @account) + assert_equal @account, account_total.owner + + board_total = Storage::Total.create!(owner: @board) + assert_equal @board, board_total.owner + end + + test "unique constraint on owner" do + Storage::Total.create!(owner: @account) + + assert_raises ActiveRecord::RecordNotUnique do + Storage::Total.create!(owner: @account) + end + end +end diff --git a/test/models/storage/totaled_test.rb b/test/models/storage/totaled_test.rb new file mode 100644 index 000000000..f53ce1a89 --- /dev/null +++ b/test/models/storage/totaled_test.rb @@ -0,0 +1,276 @@ +require "test_helper" + +class Storage::TotaledTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + @account = accounts("37s") + @board = boards(:writebook) + end + + + # bytes_used (fast snapshot) + + test "bytes_used returns 0 when no storage_total exists" do + assert_nil @account.storage_total + assert_equal 0, @account.bytes_used + end + + test "bytes_used returns snapshot value" do + @account.create_storage_total!(bytes_stored: 10_000) + assert_equal 10_000, @account.bytes_used + end + + test "bytes_used does not include pending entries (fast path)" do + @account.create_storage_total!(bytes_stored: 1000) + + # Create pending entry (not materialized) + Storage::Entry.record(account: @account, delta: 500, operation: "attach") + + # bytes_used is fast path - only reads snapshot + assert_equal 1000, @account.bytes_used + end + + + # bytes_used_exact (snapshot + pending) + + test "bytes_used_exact creates storage_total if missing" do + assert_nil @account.storage_total + + @account.bytes_used_exact + + assert_not_nil @account.reload.storage_total + end + + test "bytes_used_exact includes pending entries" do + # Create first entry and set cursor at that entry + entry = Storage::Entry.record(account: @account, delta: 500, operation: "attach") + @account.create_storage_total!(bytes_stored: 500, last_entry_id: entry.id) + + # Small delay to ensure UUIDv7 timestamp advances + travel 1.second + + # Create pending entry AFTER cursor + Storage::Entry.record(account: @account, delta: 256, operation: "attach") + + # 500 (snapshot) + 256 (pending) = 756 + assert_equal 756, @account.bytes_used_exact + end + + test "bytes_used_exact returns 0 when no entries and no snapshot" do + assert_equal 0, @account.bytes_used_exact + end + + + # materialize_storage + + test "materialize_storage creates storage_total if missing" do + assert_nil @account.storage_total + + Storage::Entry.record(account: @account, delta: 1024, operation: "attach") + @account.materialize_storage + + total = @account.reload.storage_total + assert_not_nil total + assert_equal 1024, total.bytes_stored + end + + test "materialize_storage processes all pending entries" do + Storage::Entry.record(account: @account, delta: 1000, operation: "attach") + Storage::Entry.record(account: @account, delta: 2000, operation: "attach") + Storage::Entry.record(account: @account, delta: -500, operation: "detach") + + @account.materialize_storage + + assert_equal 2500, @account.storage_total.bytes_stored + assert_equal 0, @account.storage_total.pending_entries.count + end + + test "materialize_storage updates cursor to latest entry" do + entry1 = Storage::Entry.record(account: @account, delta: 1000, operation: "attach") + entry2 = Storage::Entry.record(account: @account, delta: 500, operation: "attach") + + @account.materialize_storage + + assert_equal entry2.id, @account.storage_total.last_entry_id + end + + test "materialize_storage is idempotent when no new entries" do + Storage::Entry.record(account: @account, delta: 1000, operation: "attach") + @account.materialize_storage + + initial_bytes = @account.storage_total.bytes_stored + initial_cursor = @account.storage_total.last_entry_id + + @account.materialize_storage + + assert_equal initial_bytes, @account.storage_total.bytes_stored + assert_equal initial_cursor, @account.storage_total.last_entry_id + end + + test "materialize_storage processes only entries since cursor" do + entry1 = Storage::Entry.record(account: @account, delta: 1000, operation: "attach") + @account.materialize_storage + + assert_equal 1000, @account.storage_total.bytes_stored + + # Small delay to ensure UUIDv7 timestamp advances + travel 1.second + + # Add more entries + Storage::Entry.record(account: @account, delta: 500, operation: "attach") + @account.materialize_storage + + assert_equal 1500, @account.storage_total.bytes_stored + end + + test "materialize_storage does nothing when no entries" do + @account.materialize_storage + + total = @account.reload.storage_total + assert_not_nil total + assert_equal 0, total.bytes_stored + assert_nil total.last_entry_id + end + + test "materialize_storage handles concurrent calls safely" do + # Pre-create storage_total to avoid unique constraint race + @account.create_storage_total! + + Storage::Entry.record(account: @account, delta: 1000, operation: "attach") + + # Simulate concurrent materialization + threads = 3.times.map do + Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + @account.materialize_storage + end + end + end + threads.each(&:join) + + # Should still have correct total + assert_equal 1000, @account.reload.storage_total.bytes_stored + end + + + # storage_entries association + + test "account has storage_entries association" do + entry = Storage::Entry.record(account: @account, delta: 1024, operation: "attach") + + assert_includes @account.storage_entries, entry + end + + test "board has storage_entries association" do + entry = Storage::Entry.record(account: @account, board: @board, delta: 1024, operation: "attach") + + assert_includes @board.storage_entries, entry + end + + + # storage_total association + + test "storage_total is destroyed when owner is destroyed" do + @account.create_storage_total!(bytes_stored: 1000) + total_id = @account.storage_total.id + + # Create a new account to destroy (don't destroy fixtures) + new_account = Account.create!(name: "Temp Account") + new_account.create_storage_total!(bytes_stored: 500) + storage_total_id = new_account.storage_total.id + + new_account.destroy! + + assert_not Storage::Total.exists?(storage_total_id) + end + + + # Board-specific tests + + test "board bytes_used works independently of account" do + # Create entries for both account and board + Storage::Entry.record(account: @account, board: nil, delta: 1000, operation: "attach") + Storage::Entry.record(account: @account, board: @board, delta: 500, operation: "attach") + + @account.materialize_storage + @board.materialize_storage + + # Account sees all its entries (1000 + 500 = 1500) + assert_equal 1500, @account.bytes_used + + # Board only sees entries with its board_id (500) + assert_equal 500, @board.bytes_used + end + + test "board and account have independent cursors" do + entry1 = Storage::Entry.record(account: @account, board: @board, delta: 1000, operation: "attach") + + @account.materialize_storage + # Board not yet materialized + + entry2 = Storage::Entry.record(account: @account, board: @board, delta: 500, operation: "attach") + + # Account cursor at entry1, board has no cursor yet + assert_equal entry1.id, @account.storage_total.last_entry_id + + @board.materialize_storage + + # Board cursor now at entry2 + assert_equal entry2.id, @board.storage_total.last_entry_id + assert_equal 1500, @board.bytes_used + end + + + # reconcile_storage + + test "reconcile_storage creates entry for drift" do + board = @account.boards.create!(name: "Test Board", creator: users(:david)) + card = board.cards.create!(title: "Test Card", creator: users(:david)) + card.image.attach io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png" + + # Delete entry to simulate drift + Storage::Entry.where(board: board).delete_all + + assert_difference "Storage::Entry.count", +1 do + board.reconcile_storage + end + + entry = Storage::Entry.find_by(board: board, operation: "reconcile") + assert_equal 1000, entry.delta + end + + test "reconcile_storage no-op when ledger matches reality" do + board = @account.boards.create!(name: "Test Board", creator: users(:david)) + card = board.cards.create!(title: "Test Card", creator: users(:david)) + card.image.attach io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png" + + assert_no_difference "Storage::Entry.where(operation: 'reconcile').count" do + board.reconcile_storage + end + end + + test "reconcile_storage handles empty board" do + board = @account.boards.create!(name: "Empty Board", creator: users(:david)) + + assert_no_difference "Storage::Entry.count" do + board.reconcile_storage + end + end + + test "reconcile_storage handles negative drift" do + board = @account.boards.create!(name: "Test Board", creator: users(:david)) + + # Create fake ledger entry with no real attachment + Storage::Entry.create! \ + account_id: @account.id, + board_id: board.id, + delta: 5000, + operation: "attach" + + board.reconcile_storage + + entry = Storage::Entry.find_by(board: board, operation: "reconcile") + assert_not_nil entry + assert_equal(-5000, entry.delta) + end +end diff --git a/test/models/storage/tracked_test.rb b/test/models/storage/tracked_test.rb new file mode 100644 index 000000000..029329792 --- /dev/null +++ b/test/models/storage/tracked_test.rb @@ -0,0 +1,119 @@ +require "test_helper" + +class Storage::TrackedTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + @account = accounts("37s") + @board1 = boards(:writebook) + @board2 = boards(:private) + @card = cards(:logo) + end + + test "storage_bytes returns 0 when no attachments" do + assert_equal 0, @card.storage_bytes + end + + test "storage_bytes sums all attachment blob sizes" do + @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" + assert_equal 1024, @card.storage_bytes + end + + test "board transfer creates transfer_out entry for old board" do + @card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png" + old_board_id = @card.board_id + + assert_difference "Storage::Entry.count", +2 do + @card.update!(board: @board2) + end + + transfer_out = Storage::Entry.find_by(board_id: old_board_id, operation: "transfer_out") + + assert_not_nil transfer_out + assert_equal -2048, transfer_out.delta + assert_equal @account.id, transfer_out.account_id + assert_equal @card.class.name, transfer_out.recordable_type + assert_equal @card.id, transfer_out.recordable_id + end + + test "board transfer creates transfer_in entry for new board" do + @card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png" + @card.update!(board: @board2) + + transfer_in = Storage::Entry.find_by(board_id: @board2.id, operation: "transfer_in") + + assert_not_nil transfer_in + assert_equal 2048, transfer_in.delta + assert_equal @account.id, transfer_in.account_id + end + + test "board transfer does not create entries when no attachments" do + # Ensure card has no attachments + @card.image.purge if @card.image.attached? + + # Count only transfer entries + initial_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count + + @card.update!(board: @board2) + + final_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count + assert_equal initial_count, final_count + end + + test "board transfer net effect on account is zero" do + @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" + + # Materialize account storage before transfer + @account.materialize_storage + initial_account_bytes = @account.bytes_used + + @card.update!(board: @board2) + + # Materialize again + @account.materialize_storage + + # Account total should be unchanged (transfer_out + transfer_in = 0 for account) + assert_equal initial_account_bytes, @account.bytes_used + end + + test "board transfer correctly moves storage between boards" do + @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" + + # Materialize both boards + @board1.materialize_storage + @board2.materialize_storage + + board1_initial = @board1.bytes_used + board2_initial = @board2.bytes_used + + # Small delay to ensure UUIDv7 timestamp advances for transfer entries + travel 1.second + + @card.update!(board: @board2) + + # Materialize again + @board1.materialize_storage + @board2.materialize_storage + + # Board1 loses 1024, Board2 gains 1024 + assert_equal board1_initial - 1024, @board1.bytes_used + assert_equal board2_initial + 1024, @board2.bytes_used + end + + test "non-board updates do not trigger transfer tracking" do + @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" + initial_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count + + @card.update!(title: "New Title") + + final_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count + assert_equal initial_count, final_count + end + + test "attachments_for_storage returns all direct attachments" do + @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" + attachments = @card.send(:attachments_for_storage) + + assert_equal 1, attachments.count + assert_equal @card.image.blob.byte_size, attachments.first.blob.byte_size + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index d18f4c9c4..12ab491ed 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -38,7 +38,7 @@ end module ActiveSupport class TestCase - parallelize(workers: :number_of_processors) + parallelize workers: :number_of_processors, work_stealing: ENV["WORK_STEALING"] != "false" # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all