diff --git a/app/models/collection.rb b/app/models/collection.rb index 39680721d20..c5082269e02 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -53,6 +53,7 @@ class Collection < ApplicationRecord validates :language, language: { if: :local?, allow_nil: true } validate :tag_is_usable validate :items_do_not_exceed_limit + validate :user_does_not_exceed_limit, on: :create scope :with_items, -> { includes(:collection_items).merge(CollectionItem.with_accounts) } scope :with_tag, -> { includes(:tag) } @@ -105,4 +106,11 @@ class Collection < ApplicationRecord def items_do_not_exceed_limit errors.add(:collection_items, :too_many, count: MAX_ITEMS) if pending_or_accepted_items.size > MAX_ITEMS end + + def user_does_not_exceed_limit + return unless local? + + limit = account.user.role.collection_limit + errors.add(:base, :too_many, count: limit) if account.collections.count >= limit + end end diff --git a/app/models/user_role.rb b/app/models/user_role.rb index e98d9bc479b..32e67a0dc2f 100644 --- a/app/models/user_role.rb +++ b/app/models/user_role.rb @@ -4,15 +4,16 @@ # # Table name: user_roles # -# id :bigint(8) not null, primary key -# color :string default(""), not null -# highlighted :boolean default(FALSE), not null -# name :string default(""), not null -# permissions :bigint(8) default(0), not null -# position :integer default(0), not null -# require_2fa :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint(8) not null, primary key +# collection_limit :integer default(10), not null +# color :string default(""), not null +# highlighted :boolean default(FALSE), not null +# name :string default(""), not null +# permissions :bigint(8) default(0), not null +# position :integer default(0), not null +# require_2fa :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null # class UserRole < ApplicationRecord @@ -104,6 +105,7 @@ class UserRole < ApplicationRecord validates :name, presence: true, unless: :everyone? validates :color, format: { with: CSS_COLORS }, if: :color? validates :position, numericality: { in: (-POSITION_LIMIT..POSITION_LIMIT) } + validates :collection_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validate :validate_permissions_elevation validate :validate_position_elevation diff --git a/app/serializers/rest/role_serializer.rb b/app/serializers/rest/role_serializer.rb index 5b81c6e0487..a3d8af64b21 100644 --- a/app/serializers/rest/role_serializer.rb +++ b/app/serializers/rest/role_serializer.rb @@ -3,6 +3,8 @@ class REST::RoleSerializer < ActiveModel::Serializer attributes :id, :name, :permissions, :color, :highlighted + attribute :collection_limit, if: -> { Mastodon::Feature.collections_enabled? } + def id object.id.to_s end diff --git a/app/views/admin/roles/_form.html.haml b/app/views/admin/roles/_form.html.haml index e4477392479..0d24e82d3ca 100644 --- a/app/views/admin/roles/_form.html.haml +++ b/app/views/admin/roles/_form.html.haml @@ -32,6 +32,14 @@ %hr.spacer/ +- if Mastodon::Feature.collections_enabled? + + .fields-group + = form.input :collection_limit, + wrapper: :with_label + + %hr.spacer/ + - unless current_user.role == form.object .field-group diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 72f6548532f..249c8202d13 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -160,6 +160,7 @@ en: other: We have to make sure you're at least %{count} to use %{domain}. We won't store this. role: The role controls which permissions the user has. user_role: + collection_limit: Limits the number of Collections that a single user with this role can create. Please note that when you decrease this number, users who are already at this limit will not lose any Collections. But they will not be able to create additional ones. color: Color to be used for the role throughout the UI, as RGB in hex format highlighted: This makes the role publicly visible name: Public name of the role, if role is set to be displayed as a badge @@ -386,6 +387,7 @@ en: role: Role time_zone: Time zone user_role: + collection_limit: Maximum number of Collections per user color: Badge color highlighted: Display role as badge on user profiles name: Name diff --git a/db/migrate/20260420124030_add_collection_limit_to_user_roles.rb b/db/migrate/20260420124030_add_collection_limit_to_user_roles.rb new file mode 100644 index 00000000000..659fd4b0f9e --- /dev/null +++ b/db/migrate/20260420124030_add_collection_limit_to_user_roles.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddCollectionLimitToUserRoles < ActiveRecord::Migration[8.1] + def change + add_column :user_roles, :collection_limit, :integer, null: false, default: 10 + end +end diff --git a/db/schema.rb b/db/schema.rb index 677ed7caa96..8523182b5e3 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.1].define(version: 2026_04_15_133505) do +ActiveRecord::Schema[8.1].define(version: 2026_04_20_124030) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -1337,6 +1337,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_15_133505) do end create_table "user_roles", force: :cascade do |t| + t.integer "collection_limit", default: 10, null: false t.string "color", default: "", null: false t.datetime "created_at", null: false t.boolean "highlighted", default: false, null: false diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb index df3a5eca410..b9a54536421 100644 --- a/spec/models/collection_spec.rb +++ b/spec/models/collection_spec.rb @@ -71,6 +71,20 @@ RSpec.describe Collection do it { is_expected.to be_valid } end end + + context 'when the user is already at the per-user limit of collections' do + subject { Fabricate.build(:collection, account:) } + + let(:role) { Fabricate(:user_role, collection_limit: 2) } + let(:user) { Fabricate(:user, role:) } + let(:account) { user.account } + + before do + Fabricate.times(2, :collection, account:) + end + + it { is_expected.to_not be_valid } + end end describe '#item_for' do diff --git a/spec/models/user_role_spec.rb b/spec/models/user_role_spec.rb index 547fc718cc3..ad2b0864c90 100644 --- a/spec/models/user_role_spec.rb +++ b/spec/models/user_role_spec.rb @@ -31,6 +31,12 @@ RSpec.describe UserRole do it { is_expected.to_not allow_values('x', '112233445566', '#xxyyzz').for(:color) } end + describe 'collection_limit' do + subject { Fabricate.build :user_role } + + it { is_expected.to validate_numericality_of(:collection_limit).only_integer.is_greater_than_or_equal_to(0) } + end + context 'when current_account is set' do subject { Fabricate :user_role } diff --git a/spec/serializers/rest/role_serializer_spec.rb b/spec/serializers/rest/role_serializer_spec.rb new file mode 100644 index 00000000000..5b380475872 --- /dev/null +++ b/spec/serializers/rest/role_serializer_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe REST::RoleSerializer do + subject { serialized_record_json(role, described_class) } + + let(:everyone) do + Fabricate.build(:user_role, permissions: 0) + end + let(:role) do + Fabricate.build(:user_role, id: 2342, name: 'test role', color: '#ABC', highlighted: true, permissions: 2300, collection_limit: 11) + end + + before do + allow(UserRole).to receive(:everyone).and_return(everyone) + end + + it 'includes the relevant attributes' do + expect(subject) + .to include({ + 'id' => '2342', + 'name' => 'test role', + 'color' => '#ABC', + 'highlighted' => true, + 'permissions' => '2300', + }) + end + + context 'when collections are enabled', feature: :collections do + it 'includes the relevant attributes' do + expect(subject) + .to include({ + 'id' => '2342', + 'name' => 'test role', + 'color' => '#ABC', + 'highlighted' => true, + 'permissions' => '2300', + 'collection_limit' => 11, + }) + end + end +end