diff --git a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb index 6b1ee90e63c..d9f15613be7 100644 --- a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb @@ -1,6 +1,16 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController + before_action :ensure_unread_counts_enabled + def index counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform render json: { payload: counts } end + + private + + def ensure_unread_counts_enabled + return if Current.account.feature_enabled?('conversation_unread_counts') + + render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden + end end diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 0fccfbee59b..d9f523dfcac 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -61,9 +61,21 @@ const hasAdvancedAssignment = computed(() => { ); }); -const fetchConversationUnreadCounts = currentAccountId => { +const hasConversationUnreadCounts = computed(() => { + return isFeatureEnabledonAccount.value( + accountId.value, + FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS + ); +}); + +const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => { if (!currentAccountId) return; + if (!isEnabled) { + store.dispatch('conversationUnreadCounts/clear'); + return; + } + store.dispatch('conversationUnreadCounts/get'); }; @@ -188,7 +200,7 @@ onMounted(() => { store.dispatch('customViews/get', 'contact'); }); -watch(accountId, fetchConversationUnreadCounts, { +watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, { immediate: true, }); diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index b97e309840f..00a79763b87 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -46,6 +46,7 @@ export const FEATURE_FLAGS = { COMPANIES: 'companies', ADVANCED_SEARCH: 'advanced_search', CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes', + CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts', }; export const PREMIUM_FEATURES = [ diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index a2caab5f559..e6f2993e432 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -13,6 +13,7 @@ import { } from 'dashboard/composables/useWhatsappCallSession'; import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; const { isImpersonating } = useImpersonation(); const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000; @@ -171,10 +172,23 @@ class ActionCableConnector extends BaseActionCableConnector { }; fetchConversationUnreadCounts = () => { + if (!this.isConversationUnreadCountsEnabled()) return; + this.lastUnreadCountsFetchAt = Date.now(); this.app.$store.dispatch('conversationUnreadCounts/get'); }; + isConversationUnreadCountsEnabled = () => { + const accountId = this.app.$store.getters.getCurrentAccountId; + const isFeatureEnabled = + this.app.$store.getters['accounts/isFeatureEnabledonAccount']; + + return isFeatureEnabled?.( + accountId, + FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS + ); + }; + onTypingOn = ({ conversation, user }) => { const conversationId = conversation.id; diff --git a/app/javascript/dashboard/helper/specs/actionCable.spec.js b/app/javascript/dashboard/helper/specs/actionCable.spec.js index 2085b415aeb..8ba411a5f03 100644 --- a/app/javascript/dashboard/helper/specs/actionCable.spec.js +++ b/app/javascript/dashboard/helper/specs/actionCable.spec.js @@ -30,6 +30,7 @@ describe('ActionCableConnector - Copilot Tests', () => { dispatch: mockDispatch, getters: { getCurrentAccountId: 1, + 'accounts/isFeatureEnabledonAccount': vi.fn(() => true), }, }, }; @@ -88,6 +89,21 @@ describe('ActionCableConnector - Copilot Tests', () => { expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get'); }); + it('does not refetch unread counts when unread count feature is disabled', () => { + store.$store.getters[ + 'accounts/isFeatureEnabledonAccount' + ].mockReturnValue(false); + + actionCable.onReceived({ + event: 'conversation.unread_count_changed', + data: { account_id: 1 }, + }); + + expect(mockDispatch).not.toHaveBeenCalledWith( + 'conversationUnreadCounts/get' + ); + }); + it('should throttle unread count refetches for repeated events', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js index edffed4fd42..0503c080666 100644 --- a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js +++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js @@ -48,6 +48,9 @@ export const actions = { // Ignore errors so the sidebar can continue rendering without badges. } }, + clear({ commit }) { + commit(types.SET_CONVERSATION_UNREAD_COUNTS, {}); + }, }; export const mutations = { diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js index 0974689d50f..3100cdd10cd 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js @@ -39,4 +39,15 @@ describe('#actions', () => { expect(commit).not.toHaveBeenCalled(); }); }); + + describe('#clear', () => { + it('clears unread counts', () => { + actions.clear({ commit }); + + expect(commit).toHaveBeenCalledWith( + types.SET_CONVERSATION_UNREAD_COUNTS, + {} + ); + }); + }); }); diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb index 86fba29a056..3bc22150465 100644 --- a/app/listeners/action_cable_listener.rb +++ b/app/listeners/action_cable_listener.rb @@ -92,7 +92,7 @@ class ActionCableListener < BaseListener def conversation_unread_count_changed(event) account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform - return if account.blank? + return if account.blank? || !account.feature_enabled?('conversation_unread_counts') tokens = user_tokens(account, inbox_members) diff --git a/app/models/account.rb b/app/models/account.rb index 8d66acca8dc..667058a2f0c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -109,6 +109,7 @@ class Account < ApplicationRecord before_validation :validate_limit_keys after_create_commit :notify_creation + after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts? after_destroy :remove_account_sequences def agents diff --git a/app/services/conversations/unread_counts/listener.rb b/app/services/conversations/unread_counts/listener.rb index 72961363983..28792d884b1 100644 --- a/app/services/conversations/unread_counts/listener.rb +++ b/app/services/conversations/unread_counts/listener.rb @@ -4,6 +4,7 @@ class Conversations::UnreadCounts::Listener < BaseListener def message_created(event) message, = extract_message_and_account(event) return unless message.incoming? + return unless message.account.feature_enabled?('conversation_unread_counts') refresh(message.conversation) end @@ -35,7 +36,7 @@ class Conversations::UnreadCounts::Listener < BaseListener return if conversation_data.blank? account = Account.find_by(id: conversation_data[:account_id]) - return if account.blank? + return unless account&.feature_enabled?('conversation_unread_counts') return unless remove_deleted_conversation(account, conversation_data) Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h) diff --git a/app/services/conversations/unread_counts/notifier.rb b/app/services/conversations/unread_counts/notifier.rb index 461945f2cfd..652fbde3a56 100644 --- a/app/services/conversations/unread_counts/notifier.rb +++ b/app/services/conversations/unread_counts/notifier.rb @@ -9,6 +9,8 @@ class Conversations::UnreadCounts::Notifier end def perform + return false unless conversation.account.feature_enabled?('conversation_unread_counts') + return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation) diff --git a/config/features.yml b/config/features.yml index 8444b737975..03105588b6b 100644 --- a/config/features.yml +++ b/config/features.yml @@ -19,9 +19,8 @@ help_url: https://chwt.app/hc/fb - name: conversation_unread_counts display_name: Conversation Unread Counts - enabled: true + enabled: false chatwoot_internal: true - deprecated: true - name: ip_lookup display_name: IP Lookup enabled: false diff --git a/config/locales/en.yml b/config/locales/en.yml index b6b336f4560..8b8202f1c87 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -81,6 +81,9 @@ en: saml: feature_not_enabled: SAML feature not enabled for this account sso_not_enabled: SAML SSO is not enabled for this installation + conversations: + unread_counts: + feature_not_enabled: Conversation unread counts feature not enabled for this account data_import: data_type: invalid: Invalid data type diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 39bdb398c7c..f8fd446d2e4 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -126,32 +126,47 @@ RSpec.describe 'Conversations API', type: :request do Conversations::UnreadCounts::Store.clear_account!(account.id) end - it 'returns unread conversation counts scoped to the signed-in user' do - create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title]) - create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title]) + context 'when conversation unread counts feature is enabled' do + before do + account.enable_features!(:conversation_unread_counts) + end - get "/api/v1/accounts/#{account.id}/conversations/unread_counts", - headers: agent.create_new_auth_token, - as: :json + it 'returns unread conversation counts scoped to the signed-in user' do + create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title]) + create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title]) - expect(response).to have_http_status(:success) - expect(response.parsed_body['payload']).to eq( - 'inboxes' => { visible_inbox.id.to_s => 1 }, - 'labels' => { label.id.to_s => 1 }, - 'teams' => {} - ) + get "/api/v1/accounts/#{account.id}/conversations/unread_counts", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['payload']).to eq( + 'inboxes' => { visible_inbox.id.to_s => 1 }, + 'labels' => { label.id.to_s => 1 }, + 'teams' => {} + ) + end + + it 'returns unread team conversation counts scoped to the signed-in user' do + create_unread_conversation(account: account, inbox: visible_inbox, team: team) + create_unread_conversation(account: account, inbox: hidden_inbox, team: team) + + get "/api/v1/accounts/#{account.id}/conversations/unread_counts", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1) + end end - it 'returns unread team conversation counts scoped to the signed-in user' do - create_unread_conversation(account: account, inbox: visible_inbox, team: team) - create_unread_conversation(account: account, inbox: hidden_inbox, team: team) - + it 'returns forbidden when conversation unread counts feature is disabled' do get "/api/v1/accounts/#{account.id}/conversations/unread_counts", headers: agent.create_new_auth_token, as: :json - expect(response).to have_http_status(:success) - expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1) + expect(response).to have_http_status(:forbidden) + expect(response.parsed_body['error']).to eq('Conversation unread counts feature not enabled for this account') end end end @@ -833,6 +848,7 @@ RSpec.describe 'Conversations API', type: :request do end it 'refreshes unread count cache when conversation is marked read' do + account.enable_features!(:conversation_unread_counts) conversation.update!(agent_last_seen_at: 1.hour.ago) create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago) Conversations::UnreadCounts::Builder.new(account).build_base! @@ -920,6 +936,7 @@ RSpec.describe 'Conversations API', type: :request do end it 'refreshes unread count cache when conversation is marked unread' do + account.enable_features!(:conversation_unread_counts) conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now) Conversations::UnreadCounts::Builder.new(account).build_base! diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb index f8d50060fee..cdb9a93cbbd 100644 --- a/spec/listeners/action_cable_listener_spec.rb +++ b/spec/listeners/action_cable_listener_spec.rb @@ -237,6 +237,10 @@ describe ActionCableListener do let!(:agent_without_inbox_access) { create(:user, account: account, role: :agent) } let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) } + before do + account.enable_features!(:conversation_unread_counts) + end + it 'sends a lightweight refresh event to inbox agents and admins' do expect(conversation.inbox.reload.inbox_members.count).to eq(1) @@ -261,6 +265,14 @@ describe ActionCableListener do listener.conversation_unread_count_changed(event) end + it 'does not broadcast when conversation unread counts feature is disabled' do + account.disable_features!(:conversation_unread_counts) + + expect(ActionCableBroadcastJob).not_to receive(:perform_later) + + listener.conversation_unread_count_changed(event) + end + it 'supports deleted conversation data' do event = Events::Base.new( event_name, diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 76dbbcba23b..38ca9694aaf 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -50,6 +50,44 @@ RSpec.describe Account do end end + describe 'conversation unread counts feature flag' do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:store) { Conversations::UnreadCounts::Store } + let(:inbox_key) { store.inbox_key(account.id, inbox.id) } + + after do + store.clear_account!(account.id) + end + + it 'clears unread count cache when the feature is enabled' do + build_unread_count_cache + + account.enable_features!(:conversation_unread_counts) + + expect(store.base_ready?(account.id)).to be(false) + expect(store.assignment_ready?(account.id)).to be(false) + expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0) + end + + it 'clears unread count cache when the feature is disabled' do + account.enable_features!(:conversation_unread_counts) + build_unread_count_cache + + account.disable_features!(:conversation_unread_counts) + + expect(store.base_ready?(account.id)).to be(false) + expect(store.assignment_ready?(account.id)).to be(false) + expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0) + end + + def build_unread_count_cache + store.mark_base_ready!(account.id) + store.mark_assignment_ready!(account.id) + store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1) + end + end + describe 'inbound_email_domain' do let(:account) { create(:account) } diff --git a/spec/services/conversations/unread_counts/listener_spec.rb b/spec/services/conversations/unread_counts/listener_spec.rb index a8a4003655b..fbb0a083505 100644 --- a/spec/services/conversations/unread_counts/listener_spec.rb +++ b/spec/services/conversations/unread_counts/listener_spec.rb @@ -11,6 +11,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do end it 'refreshes unread counts when an incoming message is created' do + account.enable_features!(:conversation_unread_counts) message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming) event = Events::Base.new('message.created', Time.zone.now, message: message) @@ -29,6 +30,17 @@ RSpec.describe Conversations::UnreadCounts::Listener do expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new) end + it 'ignores incoming message creation when conversation unread counts are disabled' do + message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming) + event = Events::Base.new('message.created', Time.zone.now, message: message) + + expect(message).not_to receive(:conversation) + + listener.message_created(event) + + expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new) + end + it 'refreshes unread counts when conversation status changes' do changed_attributes = { 'status' => %w[open resolved] } event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) @@ -78,6 +90,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do end it 'removes unread count memberships when a conversation is deleted' do + account.enable_features!(:conversation_unread_counts) label = create(:label, account: account) team = create(:team, account: account) assignee = create(:user, account: account) diff --git a/spec/services/conversations/unread_counts/notifier_spec.rb b/spec/services/conversations/unread_counts/notifier_spec.rb index 6c6028548c4..1b35d37f6fb 100644 --- a/spec/services/conversations/unread_counts/notifier_spec.rb +++ b/spec/services/conversations/unread_counts/notifier_spec.rb @@ -6,6 +6,7 @@ RSpec.describe Conversations::UnreadCounts::Notifier do let(:refresh_result) { true } before do + conversation.account.enable_features!(:conversation_unread_counts) allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(refresher) allow(Rails.configuration.dispatcher).to receive(:dispatch) end @@ -29,4 +30,19 @@ RSpec.describe Conversations::UnreadCounts::Notifier do expect(Rails.configuration.dispatcher).not_to have_received(:dispatch) end end + + context 'when conversation unread counts feature is disabled' do + before do + conversation.account.disable_features!(:conversation_unread_counts) + allow(Conversations::UnreadCounts::Store).to receive(:clear_account!) + end + + it 'does not refresh, clear cache, or dispatch unread count changed event' do + described_class.new(conversation).perform + + expect(Conversations::UnreadCounts::Refresher).not_to have_received(:new) + expect(Conversations::UnreadCounts::Store).not_to have_received(:clear_account!) + expect(Rails.configuration.dispatcher).not_to have_received(:dispatch) + end + end end