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:
Sony Mathew
2026-07-08 17:38:26 +05:30
committed by GitHub
parent d3c588ff10
commit 873d16f54c
9 changed files with 197 additions and 11 deletions
+2 -1
View File
@@ -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
+54 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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) }
+22
View File
@@ -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
+27
View File
@@ -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) }
+50
View File
@@ -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