feat: Extend account feature flag storage (#14947)
# Pull Request Template ## Description Extends account-level feature flags by adding a second bigint bitset column, `feature_flags_ext_2`, while preserving the existing `flag_shih_tzu` feature check and enable/disable APIs. Existing flags continue to live on `feature_flags`; future flags can opt into the extension column through `config/features.yml` metadata. Fixes [CW-7238](https://linear.app/chatwoot/issue/CW-7238/feature-flag-extension) ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? - `eval "$(rbenv init -)" && RAILS_ENV=test POSTGRES_DATABASE=chatwoot_test_31a6 bundle exec rspec spec/models/concerns/featurable_spec.rb spec/models/account_spec.rb spec/lib/config_loader_spec.rb spec/controllers/platform/api/v1/accounts_controller_spec.rb spec/controllers/super_admin/accounts_controller_spec.rb spec/enterprise/models/account_spec.rb` - 144 examples, 0 failures - `eval "$(rbenv init -)" && bundle exec rubocop app/models/concerns/featurable.rb app/models/account.rb db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb spec/models/concerns/featurable_spec.rb spec/models/account_spec.rb spec/lib/config_loader_spec.rb spec/enterprise/models/account_spec.rb` - 7 files inspected, no offenses detected - `ruby -ryaml -e "features = YAML.safe_load(File.read('config/features.yml')); abort unless features.size == 63; puts features.group_by { |f| f['column'] || 'feature_flags' }.transform_values(&:size).inspect"` - `{"feature_flags" => 63}` - `git diff --check` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+14
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user