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 b/Gemfile index 7f7a463bb..e8a5f8514 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,9 @@ source "https://rubygems.org" -gem "rails", github: "rails/rails", branch: "main" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } +gem "rails", github: "rails/rails", branch: "ast-immediate-variants-process-locally" + # Assets & front end gem "importmap-rails" gem "propshaft" @@ -27,7 +28,7 @@ gem "rqrcode" gem "redcarpet" gem "rouge" gem "jbuilder" -gem "lexxy" +gem "lexxy", bc: "lexxy" gem "image_processing", "~> 1.14" gem "platform_agent" gem "aws-sdk-s3", require: false diff --git a/Gemfile.lock b/Gemfile.lock index e4f364c1c..d99375463 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,10 @@ +GIT + remote: https://github.com/basecamp/lexxy + revision: d292280b6d2c5d8a924327adf8bb9f0b25953539 + specs: + lexxy (0.1.23.beta) + rails (>= 8.0.2) + GIT remote: https://github.com/basecamp/useragent revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e @@ -6,8 +13,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0 - branch: main + revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751 + branch: ast-immediate-variants-process-locally specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) @@ -237,8 +244,6 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - lexxy (0.1.23.beta) - rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) loofah (2.24.1) @@ -518,7 +523,7 @@ DEPENDENCIES jbuilder kamal letter_opener - lexxy + lexxy! mission_control-jobs mittens mocha diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index f093ac677..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 @@ -41,6 +41,13 @@ GIT yabeda-puma-plugin yabeda-rails (>= 0.10) +GIT + remote: https://github.com/basecamp/lexxy + revision: d292280b6d2c5d8a924327adf8bb9f0b25953539 + specs: + lexxy (0.1.23.beta) + rails (>= 8.0.2) + GIT remote: https://github.com/basecamp/queenbee-plugin revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2 @@ -75,8 +82,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0 - branch: main + revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751 + branch: ast-immediate-variants-process-locally specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) @@ -313,8 +320,6 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - lexxy (0.1.23.beta) - rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) loofah (2.24.1) @@ -653,7 +658,7 @@ DEPENDENCIES jbuilder kamal letter_opener - lexxy + lexxy! mission_control-jobs mittens mocha diff --git a/README.md b/README.md index 07f55614e..27ba149fc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This repo contains a starter deployment file that you can modify for your own sp The steps to configure your very own Fizzy are: 1. Fork the repo -2. Edit few things in config/deploy.yml, .kamal/secrets, and config/environments/production.rb +2. Edit few things in config/deploy.yml and .kamal/secrets 3. Run `kamal setup` to do your first deploy. We'll go through each of these in turn. @@ -37,6 +37,7 @@ We've added comments to that file to highlight what each setting needs to be, bu - `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here. - `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off. - `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy. +- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this. Fizzy also requires a few environment variables to be set up, some of which contain secrets. The simplest way to do this is to put them in a file called `.kamal/secrets`. @@ -58,6 +59,7 @@ SMTP_PASSWORD=email-provider-password The values you enter here will be specific to you, and you can get or create them as follows: - `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this. +- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use. - `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with: ```sh @@ -73,10 +75,6 @@ The values you enter here will be specific to you, and you can get or create the puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" ``` -- `SMTP_USERNAME`/`SMTP_PASSWORD` are credentials you should get from your email provider. - -Lastly, you'll need to set up the rest of your email configuration in `config/environments/production.rb`. There is an example configuration in comments at the top of that file. The actual settings you use here will depend on your email provider, but in most cases will look similar to that section, so you can uncomment it and edit to suit. Note that it will use the `SMTP_USERNAME` and `SMTP_PASSWORD` values you entered in your secrets. - Once you've made all those changes, commit them to your fork so they're saved. ### Deploy Fizzy! @@ -95,6 +93,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/push_subscriptions_controller.rb b/app/controllers/users/push_subscriptions_controller.rb index bf5e5f87e..b511a6b19 100644 --- a/app/controllers/users/push_subscriptions_controller.rb +++ b/app/controllers/users/push_subscriptions_controller.rb @@ -5,13 +5,7 @@ class Users::PushSubscriptionsController < ApplicationController end def create - if subscription = @push_subscriptions.find_by(push_subscription_params) - subscription.touch - else - @push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent) - end - - head :ok + @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params) end def destroy 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 61a636adb..34aaf64ae 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Entropic, Seedeable + include Account::Storage, Entropic, Seedeable has_one :join_code has_many :users, dependent: :destroy @@ -10,8 +10,6 @@ class Account < ApplicationRecord has_many :columns, dependent: :destroy has_many :exports, class_name: "Account::Export", dependent: :destroy - has_many_attached :uploads - before_create :assign_external_account_id after_create :create_join_code 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/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index c5dab1497..3ebe49b06 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -1,7 +1,10 @@ class Webhook::Delivery < ApplicationRecord + class ResponseTooLarge < StandardError; end + STALE_TRESHOLD = 7.days USER_AGENT = "fizzy/1.0.0 Webhook" ENDPOINT_TIMEOUT = 7.seconds + MAX_RESPONSE_SIZE = 100.kilobytes belongs_to :account, default: -> { webhook.account } belongs_to :webhook @@ -52,12 +55,16 @@ class Webhook::Delivery < ApplicationRecord if resolved_ip.nil? { error: :private_uri } else - response = http.request( - Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload } - ) + request = Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload } + + response = http.request(request) do |net_http_response| + stream_body_with_limit(net_http_response) + end { code: response.code.to_i } end + rescue ResponseTooLarge + { error: :response_too_large } rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError { error: :dns_lookup_failed } rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT @@ -68,6 +75,14 @@ class Webhook::Delivery < ApplicationRecord { error: :failed_tls } end + def stream_body_with_limit(response) + bytes_read = 0 + response.read_body do |chunk| + bytes_read += chunk.bytesize + raise ResponseTooLarge if bytes_read > MAX_RESPONSE_SIZE + end + end + def resolved_ip return @resolved_ip if defined?(@resolved_ip) @resolved_ip = SsrfProtection.resolve_public_ip(uri.host) 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/container/_gild.html.erb b/app/views/cards/container/_gild.html.erb index e3152eb9a..7647a3b4f 100644 --- a/app/views/cards/container/_gild.html.erb +++ b/app/views/cards/container/_gild.html.erb @@ -6,6 +6,6 @@ <% else %> <%= button_to card_goldness_path(card), class: "btn", data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click" } do %> <%= icon_tag "golden-ticket" %> - Promote to golden (shift+g) + Promote to Golden Ticket (shift+g) <% end %> <% end %> 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/uploads/create.json.jbuilder b/app/views/uploads/create.json.jbuilder deleted file mode 100644 index 984afba49..000000000 --- a/app/views/uploads/create.json.jbuilder +++ /dev/null @@ -1,4 +0,0 @@ -json.message "File uploaded successfully" -json.fileName @upload.filename.to_s -json.mimetype @upload.content_type -json.fileUrl @upload.slug_url 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/deploy.yml b/config/deploy.yml index 0e855727d..8245a7136 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -28,7 +28,8 @@ env: - SMTP_USERNAME - SMTP_PASSWORD clear: - MAILER_FROM_ADDRESS: support@example.com # The email address that Fizzy sends email from + MAILER_FROM_ADDRESS: support@example.com # The email "from" address that Fizzy sends email from + SMTP_ADDRESS: mail.example.com # The SMTP server you'll use to send email SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container diff --git a/config/environments/production.rb b/config/environments/production.rb index 7b29a00d1..6398d14d0 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -5,18 +5,20 @@ Rails.application.configure do # Email provider Settings # - # Configure these according to whichever email provider you use. An example setup - # using SMTP looks like the following: - # - # config.action_mailer.smtp_settings = { - # address: 'smtp.example.com', # The address of your email provider's SMTP server - # port: 2525, - # domain: 'example.com', # Your domain, which Fizzy will send email from - # user_name: ENV["SMTP_USERNAME"], - # password: ENV["SMTP_PASSWORD"], - # authentication: :plain, - # enable_starttls_auto: true - # } + # SMTP setting can be configured via environment variables. + # For other configuration options, consult the Action Mailer documentation. + if smtp_address = ENV["SMTP_ADDRESS"].presence + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: smtp_address, + port: ENV.fetch("SMTP_PORT", "587").to_i, + domain: ENV.fetch("SMTP_DOMAIN", nil), + user_name: ENV.fetch("SMTP_USERNAME", nil), + password: ENV.fetch("SMTP_PASSWORD", nil), + authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"), + enable_starttls_auto: ENV.fetch("SMTP_ENABLE_STARTTLS_AUTO", "true") == "true" + } + end # Code is not reloaded between requests. config.enable_reloading = false @@ -40,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" @@ -54,10 +62,10 @@ Rails.application.configure do # Assume all access to the app is happening through a SSL-terminating reverse proxy. # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - config.assume_ssl = true + config.assume_ssl = ENV.fetch("ASSUME_SSL", "true") == "true" # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true + config.force_ssl = ENV.fetch("FORCE_SSL", "true") == "true" # Log to STDOUT by default config.logger = ActiveSupport::Logger.new(STDOUT) 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 4a4a0343d..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 @@ -11,10 +15,9 @@ end # before the User model's upload callback, causing FileNotFoundError when # using `process: :immediately` for variants. # See: https://github.com/rails/rails/issues/53694 -# -# ActiveSupport.on_load(:active_storage_record) do -# configure_replica_connections -# end +ActiveSupport.on_load(:active_storage_record) do + configure_replica_connections +end module ActiveStorageControllerExtensions extend ActiveSupport::Concern 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/script/migrations/convert_markdowns_to_rich_text.rb b/script/migrations/convert_markdowns_to_rich_text.rb deleted file mode 100755 index e093730f6..000000000 --- a/script/migrations/convert_markdowns_to_rich_text.rb +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env ruby - -require_relative "../config/environment" -require "redcarpet" -require "nokogiri" - -class ActionText::Markdown < ApplicationRecord - belongs_to :record, polymorphic: true -end - -class MarkdownToActionTextConverter - ATTACHMENT_URL_REGEX = %r{/u/(?[^\/\s\)]+)} - - def initialize(html) - @doc = Nokogiri::HTML::DocumentFragment.parse(html) - @attachments = [] - end - - def convert - process_images - process_links - [ @doc.to_html, @attachments ] - end - - private - def process_images - @doc.css("img").each do |img| - src = img["src"].presence - if src && match = src.match(ATTACHMENT_URL_REGEX) - if (attachment = find_attachment(match[:slug])) - img.replace(build_attachment_node(attachment)) - @attachments << attachment - end - end - end - end - - def process_links - @doc.css("a").each do |link| - href = link["href"].presence - - if href && match = href.match(ATTACHMENT_URL_REGEX) - if (attachment = find_attachment(match[:slug])) - link.replace(build_attachment_node(attachment)) - @attachments << attachment - end - end - end - end - - def build_attachment_node(attachment) - html = ActionText::Attachment.from_attachable(attachment).to_html - fragment = Nokogiri::HTML::DocumentFragment.parse(html) - - node = fragment.at_css("action-text-attachment") - node["url"] = Rails.application.routes.url_helpers.rails_blob_path(attachment.blob, only_path: true) - - fragment - end - - def find_attachment(slug) - ActiveStorage::Attachment.find_by(slug: slug) - end -end - -class RedcarpetRenderer - def self.render(markdown) - renderer = Redcarpet::Render::HTML.new - markdowner = Redcarpet::Markdown.new(renderer, - autolink: true, - tables: true, - fenced_code_blocks: true, - strikethrough: true, - superscript: true, - ) - markdowner.render(markdown.to_s) - end -end - -def process_all(klass, field) - klass.find_each do |record| - markdown = ActionText::Markdown.find_by(record: record, name: field) - next unless markdown - - puts "markdown.id=#{markdown.id}" - next unless markdown.record - - html = RedcarpetRenderer.render(markdown.content.to_s) - converter = MarkdownToActionTextConverter.new(html) - rich_text_html, attachments = converter.convert - - rich_text = ActionText::RichText.create!( - name: markdown.name, - record: markdown.record, - body: rich_text_html - ) - - attachments.each do |attachment| - attachment.update!(record: rich_text) - end - - puts "✓ Created rich text for #{markdown.record_type}##{markdown.record_id} (#{markdown.name})" - rescue => e - warn "✗ Failed to process markdown ##{markdown.id}: #{e.class} - #{e.message}" - end -end - -ApplicationRecord.with_each_tenant do |tenant| - puts "Processing tenant: #{tenant}" - - ActionText::RichText.delete_all - - process_all(Card, :description) - process_all(Comment, :body) -end 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 dab8ae5bd..5fa4cebaf 100644 --- a/test/controllers/boards/columns_controller_test.rb +++ b/test/controllers/boards/columns_controller_test.rb @@ -48,6 +48,7 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest end end + test "destroy refreshes surrounding columns" do column = columns(:writebook_in_progress) surroundings = column.surroundings.to_a @@ -57,5 +58,52 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest surroundings.each do |surrounding| assert_turbo_stream action: :replace, target: dom_id(surrounding) 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/push_subscriptions_controller_test.rb b/test/controllers/users/push_subscriptions_controller_test.rb index c41d8313e..4b4dd0a09 100644 --- a/test/controllers/users/push_subscriptions_controller_test.rb +++ b/test/controllers/users/push_subscriptions_controller_test.rb @@ -14,30 +14,12 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest post user_push_subscriptions_path(users(:david)), params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" } - assert_response :ok + assert_response :no_content assert_equal subscription_params, users(:david).push_subscriptions.last.attributes.slice("endpoint", "p256dh_key", "auth_key") assert_equal "Mozilla/5.0", users(:david).push_subscriptions.last.user_agent end - test "touch existing subscription" do - existing_subscription = users(:david).push_subscriptions.create!( - endpoint: "https://fcm.googleapis.com/fcm/send/abc123", - p256dh_key: "123", - auth_key: "456" - ) - - assert_no_difference -> { users(:david).push_subscriptions.count } do - assert_changes -> { existing_subscription.reload.updated_at } do - post user_push_subscriptions_path(users(:david)), params: { - push_subscription: existing_subscription.attributes.slice("endpoint", "p256dh_key", "auth_key") - } - end - end - - assert_response :ok - end - test "destroy a push subscription" do subscription = users(:david).push_subscriptions.create!( endpoint: "https://fcm.googleapis.com/fcm/send/abc123", diff --git a/test/controllers/users_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/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index 30f91390e..d4d95528c 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -296,12 +296,15 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase stub_dns_resolution(PUBLIC_TEST_IP) # Verify Net::HTTP.new is called with the pinned IP + response_mock = stub(code: "200") + response_mock.stubs(:read_body) + http_mock = mock("http") http_mock.stubs(:use_ssl=) http_mock.stubs(:ipaddr=) http_mock.stubs(:open_timeout=) http_mock.stubs(:read_timeout=) - http_mock.stubs(:request).returns(stub(code: "200")) + http_mock.stubs(:request).yields(response_mock).returns(response_mock) Net::HTTP.expects(:new).with("example.com", 443).returns(http_mock) @@ -310,6 +313,32 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase assert delivery.succeeded? end + test "handles response too large error" do + delivery = webhook_deliveries(:pending) + + large_body = "x" * 200.kilobytes + stub_request(:post, delivery.webhook.url).to_return(status: 200, body: large_body) + + delivery.deliver + + assert_equal "completed", delivery.state + assert_equal "response_too_large", delivery.response[:error] + assert_not delivery.succeeded? + end + + test "allows responses within size limit" do + delivery = webhook_deliveries(:pending) + + small_body = "x" * 50.kilobytes + stub_request(:post, delivery.webhook.url).to_return(status: 200, body: small_body) + + delivery.deliver + + assert_equal "completed", delivery.state + assert_equal 200, delivery.response[:code] + assert delivery.succeeded? + end + private def stub_dns_resolution(*ips) dns_mock = mock("dns") 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