diff --git a/app/models/account.rb b/app/models/account.rb index 0fa3e6659..4295b162e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -7,6 +7,7 @@ # custom_attributes :jsonb # domain :string(100) # feature_flags :bigint default(0), not null +# feature_flags_ext_1 :bigint default(0), not null # internal_attributes :jsonb not null # limits :jsonb # locale :integer default("en") @@ -23,7 +24,7 @@ # class Account < ApplicationRecord - # used for single column multi flags + # used for multi-flag bitset columns include FlagShihTzu include Reportable include Featurable diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb index daa0b4bf6..914df8449 100644 --- a/app/models/concerns/featurable.rb +++ b/app/models/concerns/featurable.rb @@ -1,6 +1,10 @@ module Featurable extend ActiveSupport::Concern + DEFAULT_FEATURE_FLAG_COLUMN = 'feature_flags'.freeze + FEATURE_FLAG_COLUMNS = [DEFAULT_FEATURE_FLAG_COLUMN, 'feature_flags_ext_1'].freeze + MAX_FEATURES_PER_COLUMN = 63 + QUERY_MODE = { flag_query_mode: :bit_operator, check_for_column: false @@ -8,15 +12,62 @@ module Featurable FEATURE_LIST = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze - FEATURES = FEATURE_LIST.each_with_object({}) do |feature, result| - result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym + def self.feature_flag_mappings_for(feature_list) + features_by_column = feature_list.group_by { |feature| feature['column'].presence || DEFAULT_FEATURE_FLAG_COLUMN } + + mappings = FEATURE_FLAG_COLUMNS.index_with do |column| + features = features_by_column.delete(column) || [] + validate_feature_count!(column, features) + + features.each_with_index.to_h do |feature, index| + [index + 1, "feature_#{feature['name']}".to_sym] + end + end + + validate_feature_columns!(features_by_column) + mappings end + def self.validate_feature_count!(column, features) + return if features.size <= MAX_FEATURES_PER_COLUMN + + raise ArgumentError, "Account feature flag column #{column} supports up to #{MAX_FEATURES_PER_COLUMN} features" + end + + def self.validate_feature_columns!(features_by_column) + return if features_by_column.blank? + + invalid_columns = features_by_column.keys.join(', ') + raise ArgumentError, "Unknown account feature flag column: #{invalid_columns}" + end + + FEATURES_BY_COLUMN = feature_flag_mappings_for(FEATURE_LIST).freeze + included do include FlagShihTzu - has_flags FEATURES.merge(column: 'feature_flags').merge(QUERY_MODE) + + FEATURE_FLAG_COLUMNS.each do |column| + has_flags FEATURES_BY_COLUMN.fetch(column).merge(column: column).merge(QUERY_MODE) + end before_create :enable_default_features + + define_method :all_feature_flags do + FEATURE_FLAG_COLUMNS.flat_map { |column| all_flags(column) } + end + + define_method :selected_feature_flags do + FEATURE_FLAG_COLUMNS.flat_map { |column| selected_flags(column) } + end + + define_method :selected_feature_flags= do |chosen_flags| + FEATURE_FLAG_COLUMNS.each { |column| unselect_all_flags(column) } + return if chosen_flags.nil? + + chosen_flags.each do |selected_flag| + enable_flag(selected_flag.to_sym) if selected_flag.present? + end + end end def enable_features(*names) diff --git a/config/features.yml b/config/features.yml index c5bc8f608..55a1e815d 100644 --- a/config/features.yml +++ b/config/features.yml @@ -1,11 +1,19 @@ # DO NOT change the order of features EVER ############################################ -# name: the name to be used internally in the code -# display_name: the name to be used in the UI -# enabled: whether the feature is enabled by default -# help_url: the url to the help center article -# chatwoot_internal: whether the feature is internal to Chatwoot and should not be shown in the UI for other self hosted installations -# deprecated: purpose of feature flag is done, no need to show it in the UI anymore +# name: the name to be used internally in the code +# display_name: the name to be used in the UI +# enabled: whether the feature is enabled by default +# column: the account bitset column used to store the flag. Defaults to feature_flags. +# Use feature_flags_ext_1 for extension flags. Each bigint column supports 63 flags. +# help_url: the url to the help center article +# chatwoot_internal: whether the feature is internal to Chatwoot and should not be shown in the UI for other self hosted installations +# deprecated: purpose of feature flag is done, no need to show it in the UI anymore +# +# ADDING A NEW FEATURE FLAG: +# - The `feature_flags` column is FULL (63/63 slots used). Do NOT add to it. +# - New flags MUST set `column: feature_flags_ext_1` and be appended at the end. +# - Bit positions are persisted per column; never reorder or remove existing +# entries, and never change an existing feature's `column` after release. - name: inbound_emails display_name: Inbound Emails enabled: true diff --git a/db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb b/db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb new file mode 100644 index 000000000..ae7263aac --- /dev/null +++ b/db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb @@ -0,0 +1,5 @@ +class AddFeatureFlagsExt2ToAccounts < ActiveRecord::Migration[7.0] + def change + add_column :accounts, :feature_flags_ext_1, :bigint, default: 0, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 99d083d89..5e506a475 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[7.1].define(version: 2026_06_30_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_07_06_215758) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -73,6 +73,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_30_000000) do t.integer "status", default: 0 t.jsonb "internal_attributes", default: {}, null: false t.jsonb "settings", default: {} + t.bigint "feature_flags_ext_1", default: 0, null: false t.index ["status"], name: "index_accounts_on_status" end diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb index 03f3b8d37..7c57e217f 100644 --- a/spec/enterprise/models/account_spec.rb +++ b/spec/enterprise/models/account_spec.rb @@ -11,6 +11,27 @@ RSpec.describe Account, type: :model do it { is_expected.to have_many(:custom_roles).dependent(:destroy_async) } end + describe '#selected_feature_flags=' do + it 'keeps advanced assignment enabled when assignment v2 is selected for a business account' do + account = build(:account, custom_attributes: { 'plan_name' => 'Business' }) + + account.selected_feature_flags = [:feature_assignment_v2] + + expect(account).to be_feature_assignment_v2 + expect(account).to be_feature_advanced_assignment + end + + it 'disables advanced assignment when assignment v2 is not selected' do + account = build(:account, custom_attributes: { 'plan_name' => 'Business' }) + account.enable_features(:assignment_v2, :advanced_assignment) + + account.selected_feature_flags = [] + + expect(account).not_to be_feature_assignment_v2 + expect(account).not_to be_feature_advanced_assignment + end + end + describe 'sla_policies' do let!(:account) { create(:account) } let!(:sla_policy) { create(:sla_policy, account: account) } diff --git a/spec/lib/config_loader_spec.rb b/spec/lib/config_loader_spec.rb index 60b689872..4c5fd4fbd 100644 --- a/spec/lib/config_loader_spec.rb +++ b/spec/lib/config_loader_spec.rb @@ -42,5 +42,27 @@ describe ConfigLoader do expect(InstallationConfig.find_by(name: 'WHO').value).to eq('covid 19') end end + + it 'preserves feature flag column metadata in account level defaults' do + Dir.mktmpdir do |config_path| + File.write("#{config_path}/installation_config.yml", <<~YAML) + - name: TEST_CONFIG + value: test + locked: true + YAML + File.write("#{config_path}/features.yml", <<~YAML) + - name: extension_feature + display_name: Extension Feature + enabled: false + column: feature_flags_ext_1 + YAML + + described_class.new.process(config_path: config_path) + + expect(InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').value).to include( + a_hash_including('name' => 'extension_feature', 'column' => 'feature_flags_ext_1') + ) + end + end end end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index e3bc636d1..9adc98cc0 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -103,6 +103,33 @@ RSpec.describe Account do end end + describe 'feature flag columns' do + let(:account) { described_class.new(name: 'Test Account') } + + it 'configures the account feature flag extension column' do + expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1') + expect(described_class.flag_mapping['feature_flags_ext_1']).to eq({}) + end + + it 'keeps existing feature flags on the original column' do + expect(described_class.flag_mapping['feature_flags'][:feature_inbound_emails]).to eq(1) + expect(described_class.flag_mapping['feature_flags'][:feature_advanced_assignment]).to eq(1 << 62) + end + + it 'keeps bulk selected feature assignment compatible with existing feature names' do + account.selected_feature_flags = [:feature_ip_lookup, :feature_assignment_v2, :feature_advanced_assignment] + + expect(account).to be_feature_ip_lookup + expect(account).to be_feature_assignment_v2 + expect(account).to be_feature_advanced_assignment + expect(account.selected_feature_flags).to contain_exactly( + :feature_ip_lookup, + :feature_assignment_v2, + :feature_advanced_assignment + ) + end + end + describe 'inbound_email_domain' do let(:account) { create(:account) } diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb new file mode 100644 index 000000000..8f0b64b05 --- /dev/null +++ b/spec/models/concerns/featurable_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Featurable do + describe '.feature_flag_mappings_for' do + it 'maps features to the default feature_flags column when column is omitted' do + mappings = described_class.feature_flag_mappings_for([ + { 'name' => 'inbound_emails' }, + { 'name' => 'ip_lookup' } + ]) + + expect(mappings['feature_flags']).to eq( + 1 => :feature_inbound_emails, + 2 => :feature_ip_lookup + ) + expect(mappings['feature_flags_ext_1']).to eq({}) + end + + it 'maps extension flags to feature_flags_ext_1 with independent bit positions' do + mappings = described_class.feature_flag_mappings_for([ + { 'name' => 'inbound_emails' }, + { 'name' => 'ext_one', 'column' => 'feature_flags_ext_1' }, + { 'name' => 'ext_two', 'column' => 'feature_flags_ext_1' } + ]) + + expect(mappings['feature_flags']).to eq(1 => :feature_inbound_emails) + expect(mappings['feature_flags_ext_1']).to eq( + 1 => :feature_ext_one, + 2 => :feature_ext_two + ) + end + + it 'raises when a feature references an unknown flag column' do + expect do + described_class.feature_flag_mappings_for([ + { 'name' => 'unknown_column_feature', 'column' => 'feature_flags_3' } + ]) + end.to raise_error(ArgumentError, /Unknown account feature flag column: feature_flags_3/) + end + + it 'raises when a flag column has more than the supported number of features' do + features = Array.new(64) { |index| { 'name' => "feature_#{index}" } } + + expect do + described_class.feature_flag_mappings_for(features) + end.to raise_error(ArgumentError, /feature_flags supports up to 63 features/) + end + end +end