diff --git a/app/jobs/account/adjust_storage_job.rb b/app/jobs/account/adjust_storage_job.rb new file mode 100644 index 0000000000..8394c66f34 --- /dev/null +++ b/app/jobs/account/adjust_storage_job.rb @@ -0,0 +1,9 @@ +class Account::AdjustStorageJob < ApplicationJob + queue_as :backend + + limits_concurrency to: 1, key: ->(account, delta) { account } + + def perform(account, delta) + account.adjust_storage(delta) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 50f3bf3ee4..789c6c8bcb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Entropic, Seedeable + include Entropic, Seedeable, StorageTracking has_one :join_code has_many :users, dependent: :destroy diff --git a/app/models/account/storage_tracking.rb b/app/models/account/storage_tracking.rb new file mode 100644 index 0000000000..d0aceec2ca --- /dev/null +++ b/app/models/account/storage_tracking.rb @@ -0,0 +1,16 @@ +module Account::StorageTracking + extend ActiveSupport::Concern + + def adjust_storage(delta) + increment!(:bytes_used, delta) + end + + def adjust_storage_later(delta) + Account::AdjustStorageJob.perform_later(self, delta) unless delta.zero? + end + + def recalculate_bytes_used + boards.find_each(&:recalculate_bytes_used) + update_columns bytes_used: boards.sum(:bytes_used) + end +end diff --git a/app/models/board.rb b/app/models/board.rb index 9f1bcd5d71..949992dcd6 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, Broadcastable, Cards, Entropic, Filterable, Publishable, StorageTracking, Triageable belongs_to :creator, class_name: "User", default: -> { Current.user } belongs_to :account, default: -> { creator.account } diff --git a/app/models/board/storage_tracking.rb b/app/models/board/storage_tracking.rb new file mode 100644 index 0000000000..4828378e4a --- /dev/null +++ b/app/models/board/storage_tracking.rb @@ -0,0 +1,24 @@ +module Board::StorageTracking + extend ActiveSupport::Concern + + def bytes_used_changed(delta) + increment!(:bytes_used, delta) + account.adjust_storage_later(delta) + end + + def recalculate_bytes_used + update_columns bytes_used: count_bytes_used + end + + private + def count_bytes_used + total_bytes = 0 + + cards.with_rich_text_description_and_embeds.find_each do |card| + total_bytes += card.bytes_used + total_bytes += card.comments.with_rich_text_body_and_embeds.sum(&:bytes_used) + end + + total_bytes + end +end diff --git a/app/models/card.rb b/app/models/card.rb index dfad00cb89..00fb804f80 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, StorageTracking, Taggable, Triageable, Watchable belongs_to :account, default: -> { board.account } belongs_to :board diff --git a/app/models/card/storage_tracking.rb b/app/models/card/storage_tracking.rb new file mode 100644 index 0000000000..a89395f766 --- /dev/null +++ b/app/models/card/storage_tracking.rb @@ -0,0 +1,11 @@ +module Card::StorageTracking + extend ActiveSupport::Concern + + included do + include ::StorageTracking + end + + def bytes_used_changed(delta) + board.bytes_used_changed(delta) + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index f81c465adb..fcf5fd8d00 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, StorageTracking belongs_to :account, default: -> { card.account } belongs_to :card, touch: true diff --git a/app/models/comment/storage_tracking.rb b/app/models/comment/storage_tracking.rb new file mode 100644 index 0000000000..a133ee1ab5 --- /dev/null +++ b/app/models/comment/storage_tracking.rb @@ -0,0 +1,11 @@ +module Comment::StorageTracking + extend ActiveSupport::Concern + + included do + include ::StorageTracking + end + + def bytes_used_changed(delta) + card.bytes_used_changed(delta) + end +end diff --git a/app/models/concerns/storage_tracking.rb b/app/models/concerns/storage_tracking.rb new file mode 100644 index 0000000000..e33345864d --- /dev/null +++ b/app/models/concerns/storage_tracking.rb @@ -0,0 +1,34 @@ +module StorageTracking + extend ActiveSupport::Concern + + included do + after_save :track_storage_updated + after_destroy :track_storage_removed + end + + def bytes_used + rich_text_associations.sum { |association| send(association.name)&.bytes_used || 0 } + end + + private + def rich_text_associations + self.class.reflect_on_all_associations(:has_one).filter { |association| association.klass == ActionText::RichText } + end + + def track_storage_updated + bytes_used_changed(calculate_changed_storage_delta) + end + + def calculate_changed_storage_delta + rich_text_associations.sum do |association| + rich_text = send(association.name) + next 0 unless rich_text&.body_previously_changed? + + rich_text.bytes_used - rich_text.body_previously_was&.bytes_used.to_i + end + end + + def track_storage_removed + bytes_used_changed(-bytes_used) + end +end diff --git a/db/migrate/20251205112423_add_bytes_used_to_accounts.rb b/db/migrate/20251205112423_add_bytes_used_to_accounts.rb new file mode 100644 index 0000000000..c4980c0c14 --- /dev/null +++ b/db/migrate/20251205112423_add_bytes_used_to_accounts.rb @@ -0,0 +1,5 @@ +class AddBytesUsedToAccounts < ActiveRecord::Migration[8.2] + def change + add_column :accounts, :bytes_used, :bigint, default: 0 + end +end diff --git a/db/migrate/20251205150747_add_bytes_used_to_boards.rb b/db/migrate/20251205150747_add_bytes_used_to_boards.rb new file mode 100644 index 0000000000..a9efa82677 --- /dev/null +++ b/db/migrate/20251205150747_add_bytes_used_to_boards.rb @@ -0,0 +1,5 @@ +class AddBytesUsedToBoards < ActiveRecord::Migration[8.2] + def change + add_column :boards, :bytes_used, :bigint, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index b84d3c1603..4e36af98a6 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_01_100607) do +ActiveRecord::Schema[8.2].define(version: 2025_12_05_150747) 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 @@ -52,6 +52,7 @@ end create_table "accounts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "bytes_used", default: 0 t.bigint "cards_count", default: 0, null: false t.datetime "created_at", null: false t.bigint "external_account_id" @@ -145,6 +146,7 @@ create_table "boards", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.boolean "all_access", default: false, null: false + t.bigint "bytes_used", default: 0 t.datetime "created_at", null: false t.uuid "creator_id", null: false t.string "name", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 6f6d6cf532..c6895edb94 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_01_100607) do +ActiveRecord::Schema[8.2].define(version: 2025_12_05_150747) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -52,6 +52,7 @@ end create_table "accounts", id: :uuid, force: :cascade do |t| + t.bigint "bytes_used", default: 0 t.bigint "cards_count", default: 0, null: false t.datetime "created_at", null: false t.bigint "external_account_id" @@ -145,6 +146,7 @@ create_table "boards", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.boolean "all_access", default: false, null: false + t.bigint "bytes_used", default: 0 t.datetime "created_at", null: false t.uuid "creator_id", null: false t.string "name", limit: 255, null: false diff --git a/lib/rails_ext/action_text_storage_tracking.rb b/lib/rails_ext/action_text_storage_tracking.rb new file mode 100644 index 0000000000..1553d6a85d --- /dev/null +++ b/lib/rails_ext/action_text_storage_tracking.rb @@ -0,0 +1,19 @@ +module ActionTextContentStorageTracking + def bytes_used + attachables.sum { |attachable| attachable.try(:attachable_filesize) || 0 } + end +end + +module ActionTextRichTextStorageTracking + def bytes_used + body&.bytes_used || 0 + end +end + +ActiveSupport.on_load :action_text_content do + include ActionTextContentStorageTracking +end + +ActiveSupport.on_load :action_text_rich_text do + include ActionTextRichTextStorageTracking +end diff --git a/script/migrations/20251205-populate_bytes_used_on_accounts.rb b/script/migrations/20251205-populate_bytes_used_on_accounts.rb new file mode 100755 index 0000000000..2031152783 --- /dev/null +++ b/script/migrations/20251205-populate_bytes_used_on_accounts.rb @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby + +require_relative "../../config/environment" + +Account.find_each do |account| + account.recalculate_bytes_used + puts "#{account.id}: #{account.bytes_used} bytes" +end diff --git a/test/fixtures/active_storage/blobs.yml b/test/fixtures/active_storage/blobs.yml new file mode 100644 index 0000000000..2485700f3e --- /dev/null +++ b/test/fixtures/active_storage/blobs.yml @@ -0,0 +1,2 @@ +hello_txt: <%= ActiveStorage::FixtureSet.blob filename: "hello.txt", service_name: "local", account_id: ActiveRecord::FixtureSet.identify("37s", :uuid) %> +list_pdf: <%= ActiveStorage::FixtureSet.blob filename: "list.pdf", service_name: "local", account_id: ActiveRecord::FixtureSet.identify("37s", :uuid) %> diff --git a/test/fixtures/files/hello.txt b/test/fixtures/files/hello.txt new file mode 100644 index 0000000000..663adb0914 --- /dev/null +++ b/test/fixtures/files/hello.txt @@ -0,0 +1 @@ +Hi! diff --git a/test/fixtures/files/list.pdf b/test/fixtures/files/list.pdf new file mode 100644 index 0000000000..4425dc4cd8 Binary files /dev/null and b/test/fixtures/files/list.pdf differ diff --git a/test/models/account/storage_tracking_test.rb b/test/models/account/storage_tracking_test.rb new file mode 100644 index 0000000000..e860e3b29b --- /dev/null +++ b/test/models/account/storage_tracking_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class Account::StorageTrackingTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + @account = Current.account + end + + test "track storage deltas" do + @account.adjust_storage(1000) + assert_equal 1000, @account.reload.bytes_used + + @account.adjust_storage(-100) + assert_equal 900, @account.reload.bytes_used + end + + test "track storage deltas in jobs" do + assert_enqueued_with(job: Account::AdjustStorageJob, args: [ @account, 1000 ]) do + @account.adjust_storage_later(1000) + end + + assert_no_enqueued_jobs only: Account::AdjustStorageJob do + @account.adjust_storage_later(0) + end + end + + test "recalculate bytes used from cards and comments across boards" do + board1 = boards(:writebook) + board2 = boards(:private) + + card1 = board1.cards.create!(title: "Test 1", description: attachment_html(active_storage_blobs(:hello_txt)), status: :published) + card1.comments.create!(body: attachment_html(active_storage_blobs(:hello_txt))) + + card2 = board2.cards.create!(title: "Test 2", description: attachment_html(active_storage_blobs(:list_pdf)), status: :published) + + @account.recalculate_bytes_used + + board1_expected = active_storage_blobs(:hello_txt).byte_size * 2 + board2_expected = active_storage_blobs(:list_pdf).byte_size + + assert_equal board1_expected, board1.reload.bytes_used + assert_equal board2_expected, board2.reload.bytes_used + assert_equal board1_expected + board2_expected, @account.bytes_used + end +end diff --git a/test/models/board/storage_tracking_test.rb b/test/models/board/storage_tracking_test.rb new file mode 100644 index 0000000000..d51fb07e82 --- /dev/null +++ b/test/models/board/storage_tracking_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class Board::StorageTrackingTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + @board = boards(:writebook) + end + + test "recalculate bytes used from cards and comments" do + card = @board.cards.create!(title: "Test", description: attachment_html(active_storage_blobs(:hello_txt)), status: :published) + card.comments.create!(body: attachment_html(active_storage_blobs(:hello_txt))) + card.comments.create!(body: attachment_html(active_storage_blobs(:list_pdf))) + + @board.update_columns(bytes_used: 0) + @board.recalculate_bytes_used + + expected_bytes = active_storage_blobs(:hello_txt).byte_size * 2 + active_storage_blobs(:list_pdf).byte_size + assert_equal expected_bytes, @board.bytes_used + end +end diff --git a/test/models/card/storage_tracking_test.rb b/test/models/card/storage_tracking_test.rb new file mode 100644 index 0000000000..c71bef580c --- /dev/null +++ b/test/models/card/storage_tracking_test.rb @@ -0,0 +1,107 @@ +require "test_helper" + +class Card::StorageTrackingTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + @account = Current.account + @board = boards(:writebook) + end + + test "tracks storage when creating card with rich text attachment" do + expected_bytes = active_storage_blobs(:hello_txt).byte_size + + assert_difference -> { @board.reload.bytes_used }, expected_bytes do + assert_difference -> { @account.reload.bytes_used }, expected_bytes do + perform_enqueued_jobs only: Account::AdjustStorageJob do + @board.cards.create!(title: "Test", description: attachment_html(active_storage_blobs(:hello_txt)), status: :published) + end + end + end + end + + test "tracks storage delta when updating card with different attachment" do + perform_enqueued_jobs only: Account::AdjustStorageJob do + card = @board.cards.create!(title: "Test", description: attachment_html(active_storage_blobs(:hello_txt)), status: :published) + + expected_delta = active_storage_blobs(:list_pdf).byte_size - active_storage_blobs(:hello_txt).byte_size + assert_difference -> { @board.reload.bytes_used }, expected_delta do + assert_difference -> { @account.reload.bytes_used }, expected_delta do + card.update!(description: attachment_html(active_storage_blobs(:list_pdf))) + end + end + end + end + + test "tracks negative delta when removing attachment from card" do + perform_enqueued_jobs only: Account::AdjustStorageJob do + card = @board.cards.create!(title: "Test", description: attachment_html(active_storage_blobs(:hello_txt)), status: :published) + + expected_delta = -active_storage_blobs(:hello_txt).byte_size + assert_difference -> { @board.reload.bytes_used }, expected_delta do + assert_difference -> { @account.reload.bytes_used }, expected_delta do + card.update!(description: "No attachments") + end + end + end + end + + test "tracks negative storage when destroying card with attachment" do + perform_enqueued_jobs only: Account::AdjustStorageJob do + card = @board.cards.create!(title: "Test", description: attachment_html(active_storage_blobs(:hello_txt)), status: :published) + + expected_delta = -active_storage_blobs(:hello_txt).byte_size + assert_difference -> { @board.reload.bytes_used }, expected_delta do + assert_difference -> { @account.reload.bytes_used }, expected_delta do + card.destroy! + end + end + end + end + + test "does not change storage when no attachments change" do + assert_no_difference -> { @board.reload.bytes_used } do + assert_no_difference -> { @account.reload.bytes_used } do + perform_enqueued_jobs only: Account::AdjustStorageJob do + @board.cards.create!(title: "Test", description: "Plain text", status: :published) + end + end + end + end + + test "does not change storage when updating title on card with attachment" do + perform_enqueued_jobs only: Account::AdjustStorageJob do + card = @board.cards.create!(title: "Test", description: attachment_html(active_storage_blobs(:hello_txt)), status: :published) + + assert_no_difference -> { @board.reload.bytes_used } do + assert_no_difference -> { @account.reload.bytes_used } do + card.update!(title: "New title") + end + end + end + end + + test "does not change storage when updating description text but keeping same attachment" do + perform_enqueued_jobs only: Account::AdjustStorageJob do + card = @board.cards.create!(title: "Test", description: "Some text #{attachment_html(active_storage_blobs(:hello_txt))}", status: :published) + + assert_no_difference -> { @board.reload.bytes_used } do + assert_no_difference -> { @account.reload.bytes_used } do + card.update!(description: "Different text #{attachment_html(active_storage_blobs(:hello_txt))}") + end + end + end + end + + test "tracks storage separately for each board" do + other_board = boards(:private) + + perform_enqueued_jobs only: Account::AdjustStorageJob do + @board.cards.create!(title: "Card 1", description: attachment_html(active_storage_blobs(:hello_txt)), status: :published) + other_board.cards.create!(title: "Card 2", description: attachment_html(active_storage_blobs(:list_pdf)), status: :published) + + assert_equal active_storage_blobs(:hello_txt).byte_size, @board.reload.bytes_used + assert_equal active_storage_blobs(:list_pdf).byte_size, other_board.reload.bytes_used + assert_equal active_storage_blobs(:hello_txt).byte_size + active_storage_blobs(:list_pdf).byte_size, @account.reload.bytes_used + end + end +end diff --git a/test/models/comment/storage_tracking_test.rb b/test/models/comment/storage_tracking_test.rb new file mode 100644 index 0000000000..a1498d0f8b --- /dev/null +++ b/test/models/comment/storage_tracking_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +class Comment::StorageTrackingTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + @account = Current.account + @board = boards(:writebook) + @card = cards(:logo) + end + + test "tracks storage when creating comment with attachment" do + expected_bytes = active_storage_blobs(:hello_txt).byte_size + + assert_difference -> { @board.reload.bytes_used }, expected_bytes do + assert_difference -> { @account.reload.bytes_used }, expected_bytes do + perform_enqueued_jobs only: Account::AdjustStorageJob do + @card.comments.create!(body: attachment_html(active_storage_blobs(:hello_txt))) + end + end + end + end + + test "tracks storage delta when updating comment with different attachment" do + perform_enqueued_jobs only: Account::AdjustStorageJob do + comment = @card.comments.create!(body: attachment_html(active_storage_blobs(:hello_txt))) + + expected_delta = active_storage_blobs(:list_pdf).byte_size - active_storage_blobs(:hello_txt).byte_size + assert_difference -> { @board.reload.bytes_used }, expected_delta do + assert_difference -> { @account.reload.bytes_used }, expected_delta do + comment.reload.update!(body: attachment_html(active_storage_blobs(:list_pdf))) + end + end + end + end + + test "tracks negative storage when destroying comment with attachment" do + perform_enqueued_jobs only: Account::AdjustStorageJob do + comment = @card.comments.create!(body: attachment_html(active_storage_blobs(:hello_txt))) + + expected_delta = -active_storage_blobs(:hello_txt).byte_size + assert_difference -> { @board.reload.bytes_used }, expected_delta do + assert_difference -> { @account.reload.bytes_used }, expected_delta do + comment.destroy! + end + end + end + end + + test "tracks negative storage for card and comments when destroying card" do + perform_enqueued_jobs only: Account::AdjustStorageJob do + card = @board.cards.create!(title: "Test", description: attachment_html(active_storage_blobs(:hello_txt)), status: :published) + card.comments.create!(body: attachment_html(active_storage_blobs(:hello_txt))) + card.comments.create!(body: attachment_html(active_storage_blobs(:list_pdf))) + + total_bytes = active_storage_blobs(:hello_txt).byte_size * 2 + active_storage_blobs(:list_pdf).byte_size + assert_difference -> { @board.reload.bytes_used }, -total_bytes do + assert_difference -> { @account.reload.bytes_used }, -total_bytes do + card.destroy! + end + end + end + end +end diff --git a/test/models/concerns/storage_tracking_test.rb b/test/models/concerns/storage_tracking_test.rb new file mode 100644 index 0000000000..13e416f766 --- /dev/null +++ b/test/models/concerns/storage_tracking_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +# See: +Card::StorageTrackingTest+, +Comment::StorageTrackingTest+ +class StorageTrackingTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + @board = boards(:writebook) + end + + test "count the storage used by attachments" do + card = @board.cards.create!(title: "Test", description: attachment_html(active_storage_blobs(:hello_txt)), status: :published) + assert_equal active_storage_blobs(:hello_txt).byte_size, card.bytes_used + end +end diff --git a/test/test_helpers/action_text_test_helper.rb b/test/test_helpers/action_text_test_helper.rb index 09a26fceb6..8ab64bdff3 100644 --- a/test/test_helpers/action_text_test_helper.rb +++ b/test/test_helpers/action_text_test_helper.rb @@ -18,4 +18,8 @@ def normalize_html(html) end end.to_html.strip end + + def attachment_html(blob) + ActionText::Attachment.from_attachable(blob).to_html + end end