diff --git a/AGENTS.md b/AGENTS.md index 97a940410..3f481d5cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,3 +115,18 @@ Practical checklist for any change impacting core logic or public APIs ## Branding / White-labeling note - For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy. + +## Account-level toggles: do NOT extend `config/features.yml` + +- `Account#feature_flags` is a `bigint` driven by FlagShihTzu, with each YAML entry mapped to bit position `index` (0-based). Signed bigint can only hold bits 0..63. Adding a 65th entry produces values >= 2^64 that overflow the column on write and silently break high-bit features. +- `chatwoot-pro-main` already inserts `kanban` and `internal_chat_pro` mid-list, pushing upstream features to bits 60+. After merging into Pro, any new flag added on `main` lands at an even higher bit, accelerating the overflow. The `Featurable.feature_flag_value` helper applies a two's-complement workaround that only fixes manual SQL queries (`feature_flags & ? != 0`); it does NOT fix the FlagShihTzu write path used by the superadmin form. +- Local DB pitfall: bit positions differ between `main` and `chatwoot-pro-main` because of the kanban/internal_chat_pro insertion. The same bit set on one branch maps to a different feature on the other. Use separate dev DBs per branch or reset `feature_flags` when switching. + +For NEW account-level toggles, prefer the `settings` jsonb column instead of `feature_flags`: + +1. Declare a `store_accessor :settings, :your_toggle` in `app/models/account.rb` and override the writer to cast (`super(ActiveModel::Type::Boolean.new.cast(value))` for booleans) so JSON schema validation accepts the value. +2. Add the key to `SETTINGS_PARAMS_SCHEMA` in `app/models/concerns/account_settings_schema.rb`. +3. Register it as a `Field::Boolean` (or appropriate field) in `app/dashboards/account_dashboard.rb` (`ATTRIBUTE_TYPES`, `FORM_ATTRIBUTES`, `SHOW_PAGE_ATTRIBUTES`). +4. The frontend reads it from `account.settings.your_toggle` (already serialized via `app/views/api/v1/models/_account.json.jbuilder` as `json.settings resource.settings`). + +This keeps toggles keyed by name (immune to bit-position drift between branches) and unbounded by the bigint width. diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 9be674f11..a184a1df8 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -34,7 +34,9 @@ class AccountDashboard < Administrate::BaseDashboard locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }), status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]), account_users: Field::HasMany, - custom_attributes: Field::String + custom_attributes: Field::String, + hide_agent_unassigned_tab: Field::Boolean, + hide_agent_all_tab: HideAgentAllTabField }.merge(enterprise_attribute_types).freeze # COLLECTION_ATTRIBUTES @@ -70,6 +72,8 @@ class AccountDashboard < Administrate::BaseDashboard status conversations account_users + hide_agent_unassigned_tab + hide_agent_all_tab ] + enterprise_show_page_attributes).freeze # FORM_ATTRIBUTES @@ -87,6 +91,8 @@ class AccountDashboard < Administrate::BaseDashboard name locale status + hide_agent_unassigned_tab + hide_agent_all_tab ] + enterprise_form_attributes).freeze # COLLECTION_FILTERS diff --git a/app/fields/hide_agent_all_tab_field.rb b/app/fields/hide_agent_all_tab_field.rb new file mode 100644 index 000000000..2ac6e186a --- /dev/null +++ b/app/fields/hide_agent_all_tab_field.rb @@ -0,0 +1,4 @@ +require 'administrate/field/base' + +class HideAgentAllTabField < Administrate::Field::Boolean +end diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 779eff58a..11bc063ec 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -63,6 +63,7 @@ import { } from '../store/modules/conversations/helpers/actionHelpers'; import { getUserPermissions, + getUserRole, filterItemsByPermission, } from 'dashboard/helper/permissionsHelper.js'; import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers'; @@ -129,6 +130,7 @@ const inboxesList = useMapGetter('inboxes/getInboxes'); const campaigns = useMapGetter('campaigns/getAllCampaigns'); const labels = useMapGetter('labels/getLabels'); const currentAccountId = useMapGetter('getCurrentAccountId'); +const getAccount = useMapGetter('accounts/getAccount'); // We can't useFunctionGetter here since it needs to be called on setup? const getTeamFn = useMapGetter('teams/getTeam'); const getConversationById = useMapGetter('getConversationById'); @@ -197,9 +199,29 @@ const userPermissions = computed(() => { return getUserPermissions(currentUser.value, currentAccountId.value); }); +const assigneeTabPermissions = computed(() => { + if (getUserRole(currentUser.value, currentAccountId.value) !== 'agent') { + return ASSIGNEE_TYPE_TAB_PERMISSIONS; + } + + const accountSettings = + getAccount.value(currentAccountId.value)?.settings || {}; + const hideUnassigned = Boolean(accountSettings.hide_agent_unassigned_tab); + const hideAll = hideUnassigned || Boolean(accountSettings.hide_agent_all_tab); + + if (!hideUnassigned && !hideAll) return ASSIGNEE_TYPE_TAB_PERMISSIONS; + + const { unassigned, all, ...rest } = ASSIGNEE_TYPE_TAB_PERMISSIONS; + return { + ...rest, + ...(hideUnassigned ? {} : { unassigned }), + ...(hideAll ? {} : { all }), + }; +}); + const assigneeTabItems = computed(() => { return filterItemsByPermission( - ASSIGNEE_TYPE_TAB_PERMISSIONS, + assigneeTabPermissions.value, userPermissions.value, item => item.permissions ).map(({ key, count: countKey }) => ({ @@ -242,10 +264,10 @@ const conversationCustomAttributes = useFunctionGetter( ); const activeAssigneeTabCount = computed(() => { - const count = assigneeTabItems.value.find( - item => item.key === activeAssigneeTab.value - ).count; - return count; + return ( + assigneeTabItems.value.find(item => item.key === activeAssigneeTab.value) + ?.count ?? 0 + ); }); const conversationListPagination = computed(() => { @@ -856,6 +878,12 @@ onMounted(() => { watch(conversationList, subscribePresenceForTopChats); +watch(assigneeTabItems, items => { + if (!items.some(item => item.key === activeAssigneeTab.value)) { + updateAssigneeTab(wootConstants.ASSIGNEE_TYPE.ME); + } +}); + onBeforeUnmount(() => { if (presenceInterval) clearInterval(presenceInterval); }); diff --git a/app/models/account.rb b/app/models/account.rb index f228846b0..2eaffd8bd 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -52,6 +52,17 @@ class Account < ApplicationRecord store_accessor :settings, :reporting_timezone store_accessor :settings, :keep_pending_on_bot_failure store_accessor :settings, :captain_auto_resolve_mode + store_accessor :settings, :hide_agent_unassigned_tab, :hide_agent_all_tab + before_validation :enforce_agent_assignee_tabs_constraint + + def hide_agent_unassigned_tab=(value) + super(ActiveModel::Type::Boolean.new.cast(value)) + end + + def hide_agent_all_tab=(value) + super(ActiveModel::Type::Boolean.new.cast(value)) + end + include AccountCaptainAutoResolve has_many :account_users, dependent: :destroy_async @@ -190,6 +201,10 @@ class Account < ApplicationRecord errors.add(:reporting_timezone, I18n.t('errors.account.reporting_timezone.invalid')) end + def enforce_agent_assignee_tabs_constraint + self.hide_agent_all_tab = true if hide_agent_unassigned_tab + end + def validate_support_email_format value = attributes['support_email'] return if value.blank? diff --git a/app/models/concerns/account_settings_schema.rb b/app/models/concerns/account_settings_schema.rb index 52e1c2811..8de371e52 100644 --- a/app/models/concerns/account_settings_schema.rb +++ b/app/models/concerns/account_settings_schema.rb @@ -11,6 +11,8 @@ module AccountSettingsSchema 'audio_transcriptions': { 'type': %w[boolean null] }, 'auto_resolve_label': { 'type': %w[string null] }, 'keep_pending_on_bot_failure': { 'type': %w[boolean null] }, + 'hide_agent_unassigned_tab': { 'type': %w[boolean null] }, + 'hide_agent_all_tab': { 'type': %w[boolean null] }, 'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] }, 'conversation_required_attributes': { 'type': %w[array null], diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb index 919dddd41..1cc1b2a0c 100644 --- a/app/models/concerns/featurable.rb +++ b/app/models/concerns/featurable.rb @@ -12,6 +12,16 @@ module Featurable result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym end + def self.feature_flag_value(feature_name) + feature_index = FEATURE_LIST.index { |f| f['name'] == feature_name } + return 0 if feature_index.nil? + + value = 2**feature_index + # Convert to signed 64-bit representation for PostgreSQL bigint compatibility. + # Values >= 2^63 overflow signed bigint; two's complement conversion fixes this. + value >= (1 << 63) ? value - (1 << 64) : value + end + included do include FlagShihTzu has_flags FEATURES.merge(column: 'feature_flags').merge(QUERY_MODE) diff --git a/app/views/fields/hide_agent_all_tab_field/_form.html.erb b/app/views/fields/hide_agent_all_tab_field/_form.html.erb new file mode 100644 index 000000000..ef040ab02 --- /dev/null +++ b/app/views/fields/hide_agent_all_tab_field/_form.html.erb @@ -0,0 +1,25 @@ +