From 49b0ab0e1f7d96e58ffe474c32bba60e16f19201 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 2 Jul 2026 01:03:22 -0700 Subject: [PATCH 01/88] fix: Consider business hours when computing SLA breaches (#13392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixes SLA breach computation to respect the "Only during business hours" setting - Backend now pre-computes SLA deadlines, simplifying frontend logic ## How it works Before: SLA deadlines were calculated using wall-clock time, ignoring business hours. After: When an SLA policy has "Only during business hours" enabled and the inbox has working hours configured, the deadline is calculated by adding threshold time only during business hours. **How you check if a conversation has a SLA hit or miss?** Screenshot 2026-01-28 at 7 06 53 PM **Example:** - Conversation created: Friday 4:30 PM - FRT threshold: 1 hour - Business hours: Mon-Fri 9 AM - 5 PM | | Breach time | |--|--| | Before | Friday 5:30 PM | | After | Monday 9:30 AM | ## Test plan - [x] Create an SLA policy with "Only during business hours" enabled - [x] Configure inbox with business hours (e.g., Mon-Fri 9-5) - [x] Conversation created during business hours - Create a conversation on Wednesday 10:00 AM UTC - Expected: FRT deadline shows Wednesday 12:00 PM UTC (2 business hours later) - [x] Conversation created before business hours - Create a conversation on Wednesday 7:00 AM UTC - Expected: FRT deadline shows Wednesday 11:00 AM UTC (counting starts at 9 AM) - [x] Conversation created after business hours - Create a conversation on Wednesday 6:00 PM UTC - Expected: FRT deadline shows Thursday 11:00 AM UTC (counting starts next day 9 AM) - [x] Conversation created on weekend - Create a conversation on Saturday 10:00 AM UTC - Expected: FRT deadline shows Monday 11:00 AM UTC (skips weekend) - [x] Threshold spans weekend - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT - Expected: FRT deadline shows Monday 10:00 AM UTC (1h Friday + 1h Monday) - [x] SLA without business hours - Create an SLA policy with only_during_business_hours: false - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT - Expected: FRT deadline shows Friday 6:00 PM UTC (wall-clock time) - [x] All Day marked as closed_all_day - Create a conversation on Tuesday 4:00 PM UTC with 2-hour FRT - Expected: FRT deadline shows Thursday 10:00 AM UTC - [x] All Day marked as open_all_day - Create a conversation on Saturday 10:00 AM UTC with 2-hour FRT - Expected: FRT deadline shows Saturday 12:00 PM UTC - [x] UI displays correct countdown - Verify conversation card shows correct SLA timer - Verify timer shows flame icon when breached - Verify timer shows alarm icon when within threshold - Time updates automatically when time passes - [x] Verify the breach with a different timezone than your local timezone --------- Co-authored-by: Muhsin Keloth Co-authored-by: Sojan Jose Co-authored-by: Sony Mathew Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> --- .../ConversationCard/SLACardLabel.vue | 15 +- .../Conversation/Sla/SLACardLabel.vue | 4 +- .../conversation/components/SLACardLabel.vue | 3 +- app/javascript/dashboard/helper/slaHelper.js | 150 ++++++ .../dashboard/helper/specs/slaHelper.spec.js | 450 ++++++++++++++++++ .../finders/enterprise/conversation_finder.rb | 4 +- enterprise/app/models/applied_sla.rb | 59 ++- .../services/sla/business_hours_service.rb | 108 +++++ .../sla/evaluate_applied_sla_service.rb | 155 +++--- .../api/v1/models/_applied_sla.json.jbuilder | 4 + spec/enterprise/models/applied_sla_spec.rb | 119 ++++- .../sla/business_hours_service_spec.rb | 184 +++++++ .../sla/evaluate_applied_sla_service_spec.rb | 73 ++- 13 files changed, 1228 insertions(+), 100 deletions(-) create mode 100644 app/javascript/dashboard/helper/slaHelper.js create mode 100644 app/javascript/dashboard/helper/specs/slaHelper.spec.js create mode 100644 enterprise/app/services/sla/business_hours_service.rb create mode 100644 spec/enterprise/services/sla/business_hours_service_spec.rb diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue index ff57d6c93..608bd84bd 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue @@ -1,6 +1,6 @@ + + diff --git a/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js b/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js new file mode 100644 index 000000000..a6800beb5 --- /dev/null +++ b/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js @@ -0,0 +1,222 @@ +import { mount } from '@vue/test-utils'; +import { h, nextTick } from 'vue'; +import DraggableReorderList from '../DraggableReorderList.vue'; + +// The component is pointer-driven, so we drive it through real pointer events on +// window while mocking the layout APIs jsdom does not implement: elementFromPoint +// (which card is under the cursor) and getBoundingClientRect (its geometry). +const elementAtPoint = { current: null }; + +const move = (clientX, clientY) => + window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY })); +const release = () => window.dispatchEvent(new MouseEvent('pointerup')); + +// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list. +const stubGeometry = wrapper => { + wrapper.element.getBoundingClientRect = () => ({ + left: 0, + right: 500, + top: 0, + bottom: 600, + }); + wrapper.findAll('[data-drag-id]').forEach((li, index) => { + const top = index * 50; + li.element.getBoundingClientRect = () => ({ + top, + height: 40, + bottom: top + 40, + }); + }); +}; + +const mountList = (props = {}) => + mount(DraggableReorderList, { + props: { items: [], ...props }, + slots: { + item: scope => h('div', { class: 'card' }, scope.item.title), + ghost: scope => h('div', { class: 'ghost' }, scope.item.title), + }, + global: { stubs: { Icon: true, teleport: true } }, + }); + +describe('DraggableReorderList', () => { + let wrapper; + + beforeEach(() => { + elementAtPoint.current = null; + document.elementFromPoint = vi.fn(() => elementAtPoint.current); + }); + + afterEach(() => { + wrapper?.unmount(); + vi.useRealTimers(); + }); + + const startDragging = async id => { + stubGeometry(wrapper); + wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent( + new MouseEvent('pointerdown', { + button: 0, + clientX: 250, + clientY: 20, + bubbles: true, + }) + ); + await nextTick(); + }; + + it('renders each item through the item slot', () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + }); + + const cards = wrapper.findAll('.card'); + expect(cards).toHaveLength(2); + expect(cards[0].text()).toBe('Alpha'); + expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true); + expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true); + }); + + it('shows a grab affordance only when enabled', () => { + wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] }); + expect(wrapper.find('[data-drag-id="1"]').classes()).toContain( + 'cursor-grab' + ); + + wrapper.unmount(); + wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true }); + expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain( + 'cursor-grab' + ); + }); + + it('does not start a drag when disabled', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + disabled: true, + }); + await startDragging(1); + move(250, 200); + await nextTick(); + + expect(wrapper.emitted('dragging')).toBeUndefined(); + }); + + it('emits dragging true then false across a drag', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + }); + await startDragging(1); + elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element; + move(250, 60); + await nextTick(); + + expect(wrapper.emitted('dragging')[0]).toEqual([true]); + + release(); + await nextTick(); + expect(wrapper.emitted('dragging')[1]).toEqual([false]); + }); + + it('emits the midpoint position when dropped between two rows', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + { id: 3, title: 'Gamma', position: 30 }, + ], + }); + await startDragging(1); + + // Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap + // sits before Gamma; dropping there lands halfway between Beta and Gamma. + elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element; + move(250, 85); + await nextTick(); + release(); + await nextTick(); + + expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 }); + }); + + it('does not reorder when the only row on a page is dropped in place', async () => { + // P1: dragging the lone article on a later page and releasing without + // crossing to another page must be a no-op, not move it to the top. + wrapper = mountList({ + items: [{ id: 5, title: 'Solo', position: 260 }], + currentPage: 2, + totalPages: 2, + }); + await startDragging(5); + move(250, 300); + await nextTick(); + release(); + await nextTick(); + + expect(wrapper.emitted('dragging')).toEqual([[true], [false]]); + expect(wrapper.emitted('reorder')).toBeUndefined(); + }); + + it('turns the page after dwelling on a pageable edge', async () => { + vi.useFakeTimers(); + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + ], + currentPage: 1, + totalPages: 2, + }); + await startDragging(1); + + // Drag to the right edge over blank space (no card) and hold. + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + + expect(wrapper.emitted('navigatePage')[0]).toEqual([2]); + }); + + it('can still turn pages after releasing during a pending flip', async () => { + // Releasing while a flip fetch is in flight must clear paging state, or every + // later drag would be stuck unable to navigate. + vi.useFakeTimers(); + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + ], + currentPage: 1, + totalPages: 2, + }); + + // First drag: park at the edge to start a flip, then release before the new + // page arrives (items never change here). + await startDragging(1); + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + release(); + await nextTick(); + + // Second drag must be able to flip again. + await startDragging(1); + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + + expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]); + }); +}); diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue index 75aeb86d1..9e886d1d3 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue @@ -1,6 +1,5 @@ { ); }); + it('adopts the backend re-spaced positions when the response returns them', async () => { + const serverPositions = { 1: 10, 2: 30, 3: 20 }; + axios.post.mockResolvedValue({ data: { positions: serverPositions } }); + + await actions.reorder( + { commit, state }, + { + portalSlug: 'test-portal', + categorySlug: 'test-category', + reorderedGroup: { 3: 25 }, + } + ); + + expect(commit).toHaveBeenCalledWith( + types.default.SET_ARTICLE_POSITIONS, + serverPositions + ); + }); + it('rolls back positions and throws when API call fails', async () => { axios.post.mockRejectedValue({ message: 'Network error' }); const reorderedGroup = { 1: 1, 2: 2 }; diff --git a/app/models/article.rb b/app/models/article.rb index a04ca05fe..9d1247e8b 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -137,15 +137,41 @@ class Article < ApplicationRecord end def self.update_positions(portal:, positions_hash:) - return if positions_hash.blank? + return {} if positions_hash.blank? + + moved_ids = positions_hash.keys.map(&:to_i) transaction do positions_hash.each do |article_id, new_position| portal.articles.find(article_id).update!(position: new_position) end + # Re-space touched categories to clean gaps and return the final positions + rebalance_positions(portal, moved_ids) end end + def self.rebalance_positions(portal, moved_ids) + category_ids = portal.articles.where(id: moved_ids).distinct.pluck(:category_id).compact + category_ids.each_with_object({}) do |category_id, positions| + resequence_category(portal, category_id, moved_ids, positions) + end + end + + def self.resequence_category(portal, category_id, moved_ids, positions) + ordered = portal.articles.where(category_id: category_id) + .sort_by { |article| [article.position || 0, moved_ids.include?(article.id) ? 1 : 0, article.id] } + return if ordered.length < 2 # a lone article can't collide, leave it as-is + + ordered.each_with_index do |article, index| + new_position = (index + 1) * 10 + positions[article.id] = new_position + next if article.position == new_position + + article.update_column(:position, new_position) # rubocop:disable Rails/SkipsModelValidations + end + end + private_class_method :rebalance_positions, :resequence_category + private def category_id_changed_action diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb index 04466ccd1..cdad2d9f4 100644 --- a/spec/models/article_spec.rb +++ b/spec/models/article_spec.rb @@ -207,4 +207,29 @@ RSpec.describe Article do expect(article.to_llm_text).to eq(expected_output) end end + + describe '.update_positions' do + let!(:article_a) { create(:article, portal: portal_1, category: category_1, author: user, position: 10) } + let!(:article_b) { create(:article, portal: portal_1, category: category_1, author: user, position: 11) } + let!(:article_c) { create(:article, portal: portal_1, category: category_1, author: user, position: 30) } + + it 're-spaces the category to clean gaps and places a collided move after its tie' do + # Dropping C into the tight 10/11 gap gives a floored midpoint of 10, colliding with A + positions = described_class.update_positions(portal: portal_1, positions_hash: { article_c.id => 10 }) + + expect(article_a.reload.position).to eq(10) + expect(article_c.reload.position).to eq(20) + expect(article_b.reload.position).to eq(30) + expect(positions).to eq(article_a.id => 10, article_c.id => 20, article_b.id => 30) + end + + it 'leaves a lone article untouched and returns nothing to sync' do + lone = create(:article, portal: portal_1, category: create(:category, portal_id: portal_1.id), author: user, position: 20) + + positions = described_class.update_positions(portal: portal_1, positions_hash: { lone.id => 20 }) + + expect(lone.reload.position).to eq(20) + expect(positions).to be_empty + end + end end From 6c9efc4e9272e6d6691b35de54e8d00df1f73dbf Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 2 Jul 2026 16:06:11 +0530 Subject: [PATCH 05/88] fix: assign outbound voice call conversation to the calling agent (#14906) Outbound voice calls were being auto-assigned to the wrong agent. When an agent placed an outbound call, the conversation was created without an assignee, so inboxes with auto-assignment enabled would round-robin it to a different agent instead of keeping it with the person who actually made the call. This made it hard to tell which agent was on an active call. ## What changed - Set the calling agent as the conversation's assignee when creating an outbound voice call conversation. - This prevents the generic auto-assignment handler from treating the conversation as unassigned and reassigning it. - Added specs covering the assignment, including a regression case with inbox auto-assignment enabled. **Note:** this applies to newly placed calls; it does not retroactively fix conversations that were already mis-assigned. --------- Co-authored-by: Tanmay Deep Sharma Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> --- .../v1/accounts/whatsapp_calls_controller.rb | 5 ++ .../services/voice/outbound_call_builder.rb | 10 ++++ .../whatsapp_calls_controller_spec.rb | 25 ++++++++++ .../voice/outbound_call_builder_spec.rb | 48 +++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb index 28dd378da..a0627ce49 100644 --- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -106,9 +106,14 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro def create_outbound_call contact_phone = @conversation.contact.phone_number.delete('+') + # Claim for the caller only if unassigned at trigger time (before the round-trip); wins over auto-assignment. + claim_for_caller = @conversation.assignee_id.nil? + result = provider_service.initiate_call(contact_phone, params[:sdp_offer]) provider_call_id = result.dig('calls', 0, 'id') || result['call_id'] + @conversation.with_lock { @conversation.update!(assignee: Current.user) } if claim_for_caller + Current.account.calls.create!( provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact, provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing', diff --git a/enterprise/app/services/voice/outbound_call_builder.rb b/enterprise/app/services/voice/outbound_call_builder.rb index 30a74099e..c58407a3f 100644 --- a/enterprise/app/services/voice/outbound_call_builder.rb +++ b/enterprise/app/services/voice/outbound_call_builder.rb @@ -17,10 +17,19 @@ class Voice::OutboundCallBuilder raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank? raise ArgumentError, 'Agent required' if user.blank? + # Claim for the caller if a reused conversation is unassigned at trigger time; wins over auto-assignment. + # New conversations set the assignee at creation instead (see create_conversation!). + claim_for_caller = @existing_conversation && @existing_conversation.assignee_id.nil? + ActiveRecord::Base.transaction do contact_inbox = ensure_contact_inbox! conversation = @existing_conversation || create_conversation!(contact_inbox) + # Dial before locking so the Twilio round-trip doesn't hold the conversation row lock. call_sid = initiate_call! + if claim_for_caller + @existing_conversation.lock! + @existing_conversation.update!(assignee: user) + end call = create_call!(conversation, call_sid) message = Voice::CallMessageBuilder.new(call).perform! call.update!(message_id: message.id) @@ -44,6 +53,7 @@ class Voice::OutboundCallBuilder contact_inbox_id: contact_inbox.id, inbox_id: inbox.id, contact_id: contact.id, + assignee_id: user.id, status: :open ) end diff --git a/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb index e0027e273..a66249d5a 100644 --- a/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb @@ -113,6 +113,31 @@ RSpec.describe 'WhatsApp Calls API', type: :request do expect(Call.find_by(provider_call_id: 'wacid_outbound')).to have_attributes(direction: 'outgoing', status: 'ringing') end + it 'assigns the conversation to the agent placing the call when it is unassigned' do + allow(provider_service).to receive(:initiate_call).and_return({ 'calls' => [{ 'id' => 'wacid_outbound' }] }) + + post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate", + params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' }, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:ok) + expect(initiate_conversation.reload.assignee_id).to eq(agent.id) + end + + it 'keeps the existing assignee when the conversation is already assigned' do + other_agent = create(:user, account: account, role: :agent) + create(:inbox_member, user: other_agent, inbox: inbox) + initiate_conversation.update!(assignee: other_agent) + allow(provider_service).to receive(:initiate_call).and_return({ 'calls' => [{ 'id' => 'wacid_outbound' }] }) + + post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate", + params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' }, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:ok) + expect(initiate_conversation.reload.assignee_id).to eq(other_agent.id) + end + it 'sends a permission request and records the wamid when Meta returns NoCallPermission' do allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission) allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid.req_xyz' }] }) diff --git a/spec/enterprise/services/voice/outbound_call_builder_spec.rb b/spec/enterprise/services/voice/outbound_call_builder_spec.rb index 796afe715..0dc565eaf 100644 --- a/spec/enterprise/services/voice/outbound_call_builder_spec.rb +++ b/spec/enterprise/services/voice/outbound_call_builder_spec.rb @@ -44,6 +44,54 @@ RSpec.describe Voice::OutboundCallBuilder do end end + it 'assigns the conversation to the agent placing the call' do + call = described_class.perform!( + account: account, + inbox: inbox, + user: user, + contact: contact + ) + + expect(call.conversation.assignee_id).to eq(user.id) + end + + it 'keeps the calling agent assigned even when auto-assignment would pick an online agent' do + other_agent = create(:user, account: account) + create(:inbox_member, inbox: inbox, user: other_agent) + create(:inbox_member, inbox: inbox, user: user) + inbox.update!(enable_auto_assignment: true) + # Only other_agent is online, so round-robin would claim the conversation unless the caller wins at creation. + OnlineStatusTracker.update_presence(account.id, 'User', other_agent.id) + OnlineStatusTracker.set_status(account.id, other_agent.id, 'online') + + call = described_class.perform!( + account: account, + inbox: inbox, + user: user, + contact: contact + ) + + expect(call.conversation.assignee_id).to eq(user.id) + end + + it 'claims a reused conversation for the caller when it is unassigned' do + # Reload so the builder gets a DB-fresh record, mirroring the controller's find_by load. + conversation = create(:conversation, account: account, inbox: inbox, contact: contact).reload + + described_class.perform!(account: account, inbox: inbox, user: user, contact: contact, conversation: conversation) + + expect(conversation.reload.assignee_id).to eq(user.id) + end + + it 'keeps the existing assignee when a reused conversation is already assigned' do + other_agent = create(:user, account: account) + conversation = create(:conversation, account: account, inbox: inbox, contact: contact, assignee: other_agent).reload + + described_class.perform!(account: account, inbox: inbox, user: user, contact: contact, conversation: conversation) + + expect(conversation.reload.assignee_id).to eq(other_agent.id) + end + it 'does not set conversation.identifier or write call state to additional_attributes' do call = described_class.perform!( account: account, From 6a7ca9dd3bdb26d24368d6c2feb77d1a3c9bdcf7 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Thu, 2 Jul 2026 16:07:26 +0530 Subject: [PATCH 06/88] feat: Add report bar drilldown drawer (#14626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds drilldown support for report bar charts powered by `ReportContainer`. Clicking a non-zero report bar now opens a right-side drawer with the conversations or messages that contributed to that bucket, with each row linking to the underlying conversation and message rows linking with `messageId`. This includes a new `GET /api/v2/accounts/:account_id/reports/drilldown` endpoint, backend drilldown builders/serializers, generic chart click emission, local drawer state via `useReportDrilldown`, compact drilldown cards, pagination, stale-response protection, and validation for unsupported drilldown dimensions. Fixes # CW-4497 https://linear.app/chatwoot/issue/CW-4497/drill-down-on-agent-conversations-report ## Type of change - [x] 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? Ran the focused backend and frontend checks for the drilldown endpoint, builder, chart click handling, drawer/card UI, API helper, and stale-response handling. Here are the screenshots on how it looks like: Screenshot 2026-06-02 at 11 32
11 PM Screenshot 2026-06-02 at 11 32
34 PM Screenshot 2026-06-02 at 11 32
46 PM ## 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 - [ ] 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 - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Vishnu Narayanan Co-authored-by: Shivam Mishra --- app/builders/v2/reports/drilldown_builder.rb | 213 ++++++++++++ .../v2/reports/drilldown_record_serializer.rb | 199 +++++++++++ .../api/v2/accounts/reports_controller.rb | 23 ++ app/javascript/dashboard/api/reports.js | 36 ++ .../dashboard/api/specs/reports.spec.js | 67 +++- .../dashboard/i18n/locale/en/report.json | 20 ++ .../dashboard/settings/reports/BotReports.vue | 3 + .../dashboard/settings/reports/Index.vue | 7 +- .../settings/reports/ReportContainer.vue | 138 +++++++- .../components/ReportDrilldownCard.vue | 279 +++++++++++++++ .../components/ReportDrilldownDrawer.vue | 312 +++++++++++++++++ .../reports/components/WootReports.vue | 8 + .../specs/ReportDrilldownCard.spec.js | 195 +++++++++++ .../specs/ReportDrilldownDrawer.spec.js | 329 ++++++++++++++++++ .../specs/useReportDrilldown.spec.js | 124 +++++++ .../reports/composables/useReportDrilldown.js | 138 ++++++++ .../reports/specs/ReportContainer.spec.js | 179 ++++++++++ .../shared/components/charts/BarChart.vue | 39 ++- .../shared/components/specs/BarChart.spec.js | 52 +++ .../reports/drilldown_timestamp_validator.rb | 53 +++ config/initializers/rack_attack.rb | 24 +- config/routes.rb | 1 + .../v2/reports/drilldown_builder_spec.rb | 230 ++++++++++++ .../api/v2/accounts/report_controller_spec.rb | 101 ++++++ .../v2/accounts/reports_controller_spec.rb | 24 ++ 25 files changed, 2788 insertions(+), 6 deletions(-) create mode 100644 app/builders/v2/reports/drilldown_builder.rb create mode 100644 app/builders/v2/reports/drilldown_record_serializer.rb create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownDrawer.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownCard.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/composables/specs/useReportDrilldown.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/specs/ReportContainer.spec.js create mode 100644 app/javascript/shared/components/specs/BarChart.spec.js create mode 100644 app/services/reports/drilldown_timestamp_validator.rb create mode 100644 spec/builders/v2/reports/drilldown_builder_spec.rb diff --git a/app/builders/v2/reports/drilldown_builder.rb b/app/builders/v2/reports/drilldown_builder.rb new file mode 100644 index 000000000..a3d2c073d --- /dev/null +++ b/app/builders/v2/reports/drilldown_builder.rb @@ -0,0 +1,213 @@ +class V2::Reports::DrilldownBuilder + include DateRangeHelper + include TimezoneHelper + + DEFAULT_GROUP_BY = 'day'.freeze + DEFAULT_PAGE = 1 + DEFAULT_PER_PAGE = 25 + MAX_PER_PAGE = 100 + SUPPORTED_GROUP_BY = %w[hour day week month year].freeze + SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze + MESSAGE_METRICS = { + 'incoming_messages_count' => :incoming, + 'outgoing_messages_count' => :outgoing + }.freeze + MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze + + pattr_initialize :account, :params + + def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s) + + def build + records = paginated_records.to_a + { meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } } + end + + private + + def meta + { + metric: metric, + record_type: record_type, + bucket: { + since: bucket_range.begin.to_i, + until: bucket_range.end.to_i + }, + current_page: current_page, + per_page: per_page, + total_count: paginated_records.total_count, + conversation_count: conversation_count + } + end + + def conversation_count + return paginated_records.total_count if conversation_metric? + + drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id) + end + + def paginated_records + @paginated_records ||= drilldown_scope.page(current_page).per(per_page) + end + + def drilldown_scope + if message_metric? + message_scope + elsif conversation_metric? + conversation_scope + else + reporting_event_scope + end + end + + def message_scope + scope.messages + .where(account_id: account.id, created_at: bucket_range) + .public_send(MESSAGE_METRICS.fetch(metric)) + .includes(:sender, conversation: [:assignee, :contact, :inbox]) + .reorder(created_at: :desc) + end + + def conversation_scope + scope.conversations + .where(account_id: account.id, created_at: bucket_range) + .includes(:assignee, :contact, :inbox) + .order(created_at: :desc) + end + + def reporting_event_scope + events = scope.reporting_events + .where(account_id: account.id, name: raw_event_name, created_at: bucket_range) + .includes(:user, :inbox, conversation: [:assignee, :contact, :inbox]) + .order(created_at: :desc) + + if raw_count_strategy == :exclude_bot_handoffs + events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery) + elsif raw_count_strategy == :distinct_conversation + events = events.where(id: distinct_conversation_event_ids(events)) + end + + events + end + + def bot_handoff_conversation_ids_subquery + scope.reporting_events + .where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) + .where.not(conversation_id: nil) + .select(:conversation_id) + end + + def distinct_conversation_event_ids(events) + events.reorder(nil) + .where.not(conversation_id: nil) + .select('MAX(reporting_events.id)') + .group(:conversation_id) + end + + def record_serializer(records) + @record_serializer ||= V2::Reports::DrilldownRecordSerializer.new( + account, + metric, + use_business_hours?, + records + ) + end + + def bucket_range + @bucket_range ||= begin + bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone) + bucket_end = bucket_end_for(bucket_start) + requested_start = Time.zone.at(params[:since].to_i) + requested_end = Time.zone.at(params[:until].to_i) + + [bucket_start, requested_start].max...[bucket_end, requested_end].min + end + end + + def bucket_end_for(bucket_start) + { + 'hour' => bucket_start + 1.hour, + 'day' => bucket_start + 1.day, + 'week' => bucket_start + 1.week, + 'month' => bucket_start + 1.month, + 'year' => bucket_start + 1.year + }.fetch(group_by) + end + + def scope + case dimension_type + when 'account' then account + when 'inbox' then inbox + when 'agent' then user + when 'label' then label + when 'team' then team + else + raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}" + end + end + + def inbox = @inbox ||= account.inboxes.find(params[:id]) + + def user = @user ||= account.users.find(params[:id]) + + def label = @label ||= account.labels.find(params[:id]) + + def team = @team ||= account.teams.find(params[:id]) + + def metric + params[:metric].to_s + end + + def report_metric + @report_metric ||= Reports::ReportMetricRegistry.fetch(metric) + end + + def raw_event_name + report_metric&.raw_event_name + end + + def raw_count_strategy + report_metric&.raw_count_strategy + end + + def record_type + return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric) + + 'conversation' + end + + def message_metric? + MESSAGE_METRICS.key?(metric) + end + + def conversation_metric? + metric == 'conversations_count' + end + + def dimension_type + (params[:type].presence || 'account').to_s + end + + def group_by + @group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY + end + + def timezone + @timezone ||= timezone_name_from_offset(params[:timezone_offset]) + end + + def current_page + [params[:page].to_i, DEFAULT_PAGE].max + end + + def per_page + requested_per_page = params[:per_page].to_i + requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0 + + [requested_per_page, MAX_PER_PAGE].min + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end +end diff --git a/app/builders/v2/reports/drilldown_record_serializer.rb b/app/builders/v2/reports/drilldown_record_serializer.rb new file mode 100644 index 000000000..04edf65b4 --- /dev/null +++ b/app/builders/v2/reports/drilldown_record_serializer.rb @@ -0,0 +1,199 @@ +class V2::Reports::DrilldownRecordSerializer + MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze + + attr_reader :account, :metric, :use_business_hours, :records + + def initialize(account, metric, use_business_hours, records = []) + @account = account + @metric = metric + @use_business_hours = use_business_hours + @records = records + end + + def serialize(record) + return serialize_message(record) if record.is_a?(Message) + return serialize_conversation_event(record) if record.is_a?(ReportingEvent) + + serialize_conversation(record) + end + + private + + def serialize_message(message, metric_value: nil, occurred_at: nil) + { + record_type: 'message', + conversation: conversation_attributes(message.conversation), + message: message_attributes(message), + metric_value: metric_value, + occurred_at: (occurred_at || message.created_at).to_i + } + end + + def serialize_conversation_event(event) + inferred_message = inferred_message_for(event) + if inferred_message.present? + return serialize_message( + inferred_message, + metric_value: event_metric_value(event), + occurred_at: event_timestamp(event) + ) + end + + serialize_conversation( + event.conversation, + metric_value: event_metric_value(event), + occurred_at: event_timestamp(event), + event_name: event.name + ) + end + + def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil) + serialized_record = { + record_type: 'conversation', + conversation: conversation_attributes(conversation), + message: nil, + metric_value: metric_value, + occurred_at: (occurred_at || conversation&.created_at)&.to_i + } + serialized_record[:event_name] = event_name if event_name.present? + serialized_record + end + + def conversation_attributes(conversation) + return {} if conversation.blank? + + { + id: conversation.id, + display_id: conversation.display_id, + contact_id: conversation.contact_id, + contact_name: conversation.contact&.name, + inbox_id: conversation.inbox_id, + inbox_name: conversation.inbox&.name, + assignee_id: conversation.assignee_id, + assignee_name: conversation.assignee&.name, + status: conversation.status, + created_at: conversation.created_at.to_i, + last_activity_at: conversation.last_activity_at.to_i, + last_message: last_message_attributes(conversation) + } + end + + def message_attributes(message) + { + id: message.id, + content: message.content, + message_type: message.message_type, + sender_name: message.sender&.try(:name), + created_at: message.created_at.to_i + } + end + + def last_message_attributes(conversation) + message = latest_messages_by_conversation_id[conversation.id] + return if message.blank? + + message_attributes(message) + end + + def inferred_message_for(event) + return unless MESSAGE_EVENT_METRICS.include?(metric) + return if event.conversation.blank? || event.event_end_time.blank? + + inferred_messages_by_event_id[event.id] + end + + def first_response_event_with_user?(event) + metric == 'avg_first_response_time' && event.user_id.present? + end + + def message_inference_range(event) + (event.event_end_time - 1.second)..(event.event_end_time + 1.second) + end + + def event_metric_value(event) + use_business_hours ? event.value_in_business_hours : event.value + end + + def event_timestamp(event) + event.event_end_time || event.created_at + end + + def latest_messages_by_conversation_id + @latest_messages_by_conversation_id ||= if conversation_ids.blank? + {} + else + latest_messages.index_by(&:conversation_id) + end + end + + def latest_messages + Message + .where(account_id: account.id, conversation_id: conversation_ids) + .where.not(message_type: :activity) + .select('DISTINCT ON (messages.conversation_id) messages.*') + .reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC')) + .includes(:sender) + end + + def inferred_messages_by_event_id + @inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id| + messages_by_event_id[event.id] = inferred_message_candidates.find do |message| + message_matches_event?(message, event) + end + end + end + + def inferred_message_candidates + @inferred_message_candidates ||= if inference_events.blank? + [] + else + inferred_messages.to_a + end + end + + def inferred_messages + Message + .where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq) + .where(created_at: inference_time_range) + .where(message_type: %i[outgoing template]) + .includes(:sender) + .reorder(created_at: :desc, id: :desc) + end + + def message_matches_event?(message, event) + message.conversation_id == event.conversation_id && + message.created_at.between?( + message_inference_range(event).begin, + message_inference_range(event).end + ) && + message_sender_matches_event?(message, event) + end + + def message_sender_matches_event?(message, event) + return true unless first_response_event_with_user?(event) + + message.sender_id == event.user_id && message.sender_type == 'User' + end + + def inference_time_range + event_end_times = inference_events.map(&:event_end_time) + + (event_end_times.min - 1.second)..(event_end_times.max + 1.second) + end + + def inference_events + @inference_events ||= records.select do |record| + record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present? + end + end + + def conversation_ids + @conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq + end + + def conversation_id_for(record) + return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent) + + record.id + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 192b3619c..93be19eb9 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -51,6 +51,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic') end + def drilldown + return head :unauthorized unless Current.account_user.administrator? + return head :unprocessable_entity unless valid_drilldown_params? + + render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build + end + def conversations return head :unprocessable_entity if params[:type].blank? @@ -133,6 +140,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController }) end + def drilldown_params + permitted_params = params.permit( + :metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page + ).to_h.symbolize_keys + permitted_params.merge( + type: (params[:type].presence || 'account').to_sym, + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + ) + end + + def valid_drilldown_params? + %i[metric bucket_timestamp since until].all? { |param| params[param].present? } && + Reports::ReportMetricRegistry.supported?(params[:metric]) && + V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params) + end + def conversation_params { type: params[:type].to_sym, diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 00f040f8e..daa0cb11d 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -31,6 +31,42 @@ class ReportsAPI extends ApiClient { }); } + getDrilldown({ + metric, + bucketTimestamp, + from, + to, + type = 'account', + id, + groupBy, + businessHours, + page, + perPage, + signal, + }) { + const requestConfig = { + params: { + metric, + bucket_timestamp: bucketTimestamp, + since: from, + until: to, + type, + id, + group_by: groupBy, + business_hours: businessHours, + timezone_offset: getTimeOffset(), + page, + per_page: perPage, + }, + }; + + if (signal) { + requestConfig.signal = signal; + } + + return axios.get(`${this.url}/drilldown`, requestConfig); + } + // eslint-disable-next-line default-param-last getSummary(since, until, type = 'account', id, groupBy, businessHours) { return axios.get(`${this.url}/summary`, { diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index e458633d0..178c98a70 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -1,6 +1,8 @@ import reportsAPI from '../reports'; import ApiClient from '../ApiClient'; +const timezoneOffset = () => -new Date().getTimezoneOffset() / 60; + describe('#Reports API', () => { it('creates correct instance', () => { expect(reportsAPI).toBeInstanceOf(ApiClient); @@ -11,6 +13,7 @@ describe('#Reports API', () => { expect(reportsAPI).toHaveProperty('update'); expect(reportsAPI).toHaveProperty('delete'); expect(reportsAPI).toHaveProperty('getReports'); + expect(reportsAPI).toHaveProperty('getDrilldown'); expect(reportsAPI).toHaveProperty('getSummary'); expect(reportsAPI).toHaveProperty('getAgentReports'); expect(reportsAPI).toHaveProperty('getLabelReports'); @@ -42,11 +45,14 @@ describe('#Reports API', () => { }); expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', { params: { + business_hours: undefined, + group_by: undefined, + id: undefined, metric: 'conversations_count', since: 1621103400, until: 1621621800, type: 'account', - timezone_offset: -0, + timezone_offset: timezoneOffset(), }, }); }); @@ -59,13 +65,70 @@ describe('#Reports API', () => { group_by: undefined, id: undefined, since: 1621103400, - timezone_offset: -0, + timezone_offset: timezoneOffset(), type: 'account', until: 1621621800, }, }); }); + it('#getDrilldown', () => { + reportsAPI.getDrilldown({ + metric: 'incoming_messages_count', + bucketTimestamp: 1621103400, + from: 1621103400, + to: 1621621800, + type: 'inbox', + id: 1, + groupBy: 'day', + businessHours: false, + page: 2, + perPage: 25, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', { + params: { + metric: 'incoming_messages_count', + bucket_timestamp: 1621103400, + since: 1621103400, + until: 1621621800, + type: 'inbox', + id: 1, + group_by: 'day', + business_hours: false, + timezone_offset: timezoneOffset(), + page: 2, + per_page: 25, + }, + }); + }); + + it('#getDrilldown with abort signal', () => { + const controller = new AbortController(); + + reportsAPI.getDrilldown({ + metric: 'incoming_messages_count', + bucketTimestamp: 1621103400, + signal: controller.signal, + }); + + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', { + params: { + metric: 'incoming_messages_count', + bucket_timestamp: 1621103400, + since: undefined, + until: undefined, + type: 'account', + id: undefined, + group_by: undefined, + business_hours: undefined, + timezone_offset: timezoneOffset(), + page: undefined, + per_page: undefined, + }, + signal: controller.signal, + }); + }); + it('#getAgentReports', () => { reportsAPI.getAgentReports({ from: 1621103400, diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 2ffa0ef11..0171b9620 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -121,6 +121,26 @@ "CLEAR_FILTER": "Clear filter", "EMPTY_LIST": "No results found" }, + "DRILLDOWN": { + "TITLE": "{metric} details", + "RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations", + "RESULT_COUNT_MESSAGE": "{count} message | {count} messages", + "EMPTY": "No records found for this bar.", + "ERROR": "Could not load records. Please try again.", + "ADMIN_ONLY": "Only administrators can drill down into report records.", + "LOAD_MORE": "Load more", + "CLOSE": "Close details", + "PREVIOUS_BUCKET": "Previous bar", + "NEXT_BUCKET": "Next bar", + "UNKNOWN_CONTACT": "Unknown contact", + "UNKNOWN_INBOX": "Unknown inbox", + "UNASSIGNED_AGENT": "Unassigned", + "NO_MESSAGE_CONTENT": "No message content", + "MESSAGE_CREATED_AT": "Message created at {time}", + "EVENT_OCCURRED_AT": "Event occurred at {time}", + "INCOMING_MESSAGE": "Incoming message", + "OUTGOING_MESSAGE": "Outgoing message" + }, "PAGINATION": { "RESULTS": "Showing {start} to {end} of {total} results", "PER_PAGE_TEMPLATE": "{size} / page" diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue index 03b7290d6..2a1cd18c8 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue @@ -101,6 +101,9 @@ export default { summary-fetching-key="getBotSummaryFetchingStatus" :group-by="groupBy" :report-keys="reportKeys" + :from="from" + :to="to" + :business-hours="businessHours" /> diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue index 9794d97e4..ea1e80e70 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue @@ -121,6 +121,11 @@ export default { show-group-by @filter-change="onFilterChange" /> - + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue index c44ab58e5..ccd71b3a4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue @@ -5,16 +5,38 @@ import { GROUP_BY_FILTER, METRIC_CHART } from './constants'; import fromUnixTime from 'date-fns/fromUnixTime'; import format from 'date-fns/format'; import { formatTime } from '@chatwoot/utils'; +import { useAlert } from 'dashboard/composables'; import ChartStats from './components/ChartElements/ChartStats.vue'; import BarChart from 'shared/components/charts/BarChart.vue'; +import ReportDrilldownDrawer from './components/ReportDrilldownDrawer.vue'; export default { - components: { ChartStats, BarChart }, + components: { ChartStats, BarChart, ReportDrilldownDrawer }, props: { groupBy: { type: Object, default: () => ({}), }, + from: { + type: Number, + default: 0, + }, + to: { + type: Number, + default: 0, + }, + reportType: { + type: String, + default: 'account', + }, + selectedItemId: { + type: [String, Number], + default: null, + }, + businessHours: { + type: Boolean, + default: false, + }, accountSummaryKey: { type: String, default: 'getAccountSummary', @@ -42,10 +64,27 @@ export default { ); return { calculateTrend, isAverageMetricType }; }, + data() { + return { + drilldownRequest: null, + drilldownMetric: null, + drilldownIndex: null, + }; + }, computed: { ...mapGetters({ accountReport: 'getAccountReports', + currentRole: 'getCurrentRole', }), + isAdmin() { + return this.currentRole === 'administrator'; + }, + canDrilldownPrev() { + return this.findDrillableIndex(this.drilldownIndex - 1, -1) !== null; + }, + canDrilldownNext() { + return this.findDrillableIndex(this.drilldownIndex + 1, 1) !== null; + }, metrics() { const reportKeys = Object.keys(this.reportKeys); const infoText = { @@ -139,6 +178,82 @@ export default { return options; }, + isDrilldownEnabled() { + return !!(this.from && this.to); + }, + onChartElementClick(metric, event) { + if (!this.isDrilldownEnabled()) return; + + const dataPoint = this.accountReport.data[metric.KEY]?.[event.dataIndex]; + if (!this.canOpenDrilldown(metric, dataPoint)) return; + if (!this.isAdmin) { + useAlert(this.$t('REPORT.DRILLDOWN.ADMIN_ONLY')); + return; + } + + this.openDrilldownAt(metric, event.dataIndex); + }, + openDrilldownAt(metric, dataIndex) { + const dataPoint = this.accountReport.data[metric.KEY]?.[dataIndex]; + if (!this.canOpenDrilldown(metric, dataPoint)) return; + + const labels = this.getCollection(metric).labels || []; + + this.drilldownMetric = metric; + this.drilldownIndex = dataIndex; + this.drilldownRequest = { + metric: metric.KEY, + metricName: metric.NAME, + bucketLabel: labels[dataIndex], + bucketTimestamp: dataPoint.timestamp, + bucketValue: dataPoint.value, + isAverageMetric: this.isAverageMetricType(metric.KEY), + from: this.from, + to: this.to, + type: this.reportType, + id: this.selectedItemId, + groupBy: this.groupBy?.period, + businessHours: this.businessHours, + }; + }, + navigateDrilldown(direction) { + const nextIndex = this.findDrillableIndex( + this.drilldownIndex + direction, + direction + ); + if (nextIndex === null) return; + + this.openDrilldownAt(this.drilldownMetric, nextIndex); + }, + findDrillableIndex(startIndex, step) { + if (!this.drilldownMetric) return null; + + const data = this.accountReport.data[this.drilldownMetric.KEY] || []; + for ( + let index = startIndex; + index >= 0 && index < data.length; + index += step + ) { + if (this.canOpenDrilldown(this.drilldownMetric, data[index])) + return index; + } + + return null; + }, + canOpenDrilldown(metric, dataPoint) { + if (!dataPoint) return false; + + if (this.isAverageMetricType(metric.KEY)) { + return dataPoint.count > 0; + } + + return dataPoint.value > 0; + }, + closeDrilldown() { + this.drilldownRequest = null; + this.drilldownMetric = null; + this.drilldownIndex = null; + }, }, }; @@ -168,6 +283,8 @@ export default { v-if="accountReport.data[metric.KEY].length" :collection="getCollection(metric)" :chart-options="getChartOptions(metric)" + :clickable="isDrilldownEnabled()" + @element-click="onChartElementClick(metric, $event)" /> {{ $t('REPORT.NO_ENOUGH_DATA') }} @@ -176,4 +293,23 @@ export default { + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue new file mode 100644 index 000000000..327db6291 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue @@ -0,0 +1,279 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownDrawer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownDrawer.vue new file mode 100644 index 000000000..0b245a35a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportDrilldownDrawer.vue @@ -0,0 +1,312 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue index e54c9f53e..7b30ee128 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue @@ -69,6 +69,9 @@ export default { isAgentType() { return this.type === 'agent'; }, + selectedFilterId() { + return this.selectedFilter?.id || null; + }, reportKeys() { return { CONVERSATIONS: 'conversations_count', @@ -181,5 +184,10 @@ export default { v-if="filterItemsList.length" :group-by="groupBy" :report-keys="reportKeys" + :from="from" + :to="to" + :report-type="type" + :selected-item-id="selectedFilterId" + :business-hours="businessHours" /> diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownCard.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownCard.spec.js new file mode 100644 index 000000000..7fda49a36 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownCard.spec.js @@ -0,0 +1,195 @@ +import { mount } from '@vue/test-utils'; +import ReportDrilldownCard from '../ReportDrilldownCard.vue'; + +vi.mock('vue-router', () => ({ + useRoute: () => ({ + params: { + accountId: 1, + }, + }), +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, params = {}) => { + if (key === 'REPORT.DRILLDOWN.MESSAGE_CREATED_AT') { + return `Message created at ${params.time}`; + } + if (key === 'REPORT.DRILLDOWN.EVENT_OCCURRED_AT') { + return `Event occurred at ${params.time}`; + } + if (key === 'REPORT.DRILLDOWN.INCOMING_MESSAGE') { + return 'Incoming message'; + } + if (key === 'REPORT.DRILLDOWN.OUTGOING_MESSAGE') { + return 'Outgoing message'; + } + return key; + }, + }), +})); + +vi.mock('shared/helpers/timeHelper', () => ({ + dynamicTime: timestamp => { + const timestamps = { + 1621103500: '2 minutes ago', + 1621103400: '4 days ago', + 1621103700: '4 days ago', + }; + return timestamps[timestamp] || 'less than a minute ago'; + }, + shortTimestamp: time => { + const timestamps = { + '2 minutes ago': '2m', + '4 days ago': '4d', + }; + return timestamps[time] || 'now'; + }, + dateFormat: timestamp => `date-${timestamp}`, +})); + +describe('ReportDrilldownCard.vue', () => { + const record = { + record_type: 'message', + conversation: { + id: 10, + display_id: 42, + contact_id: 11, + contact_name: 'Jane', + inbox_id: 12, + inbox_name: 'Website', + assignee_id: 13, + assignee_name: 'Alex', + status: 'open', + created_at: 1621103400, + last_activity_at: 1621103700, + last_message: { + id: 100, + content: 'Latest reply', + message_type: 'outgoing', + created_at: 1621103600, + }, + }, + message: { + id: 99, + content: 'Need help', + message_type: 'incoming', + created_at: 1621103500, + }, + metric_value: null, + occurred_at: 1621103500, + }; + + const mountCard = (props = {}) => + mount(ReportDrilldownCard, { + props: { + record, + ...props, + }, + global: { + mocks: { + $t: key => key, + }, + }, + }); + + beforeEach(() => { + vi.spyOn(window, 'open').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('opens the card conversation link in a new tab', async () => { + const wrapper = mountCard(); + + expect(wrapper.text()).toContain('#42'); + expect(wrapper.text()).toContain('Need help'); + expect(wrapper.find('.i-lucide-arrow-down-left').exists()).toBe(true); + expect(wrapper.find('[aria-label="Incoming message"]').exists()).toBe(true); + + await wrapper.find('[role="link"]').trigger('click'); + + expect(window.open).toHaveBeenCalledWith( + '/app/accounts/1/conversations/42?messageId=99', + '_blank', + 'noopener,noreferrer' + ); + }); + + it('renders only message created timestamp for message rows', () => { + const wrapper = mountCard(); + const messageCreatedLabel = wrapper + .findAll('[aria-label]') + .map(timestamp => timestamp.attributes('aria-label')) + .find(label => label.includes('Message created at')); + + expect(wrapper.text()).toContain('2m'); + expect(wrapper.text()).not.toContain('4d • 4d'); + expect(messageCreatedLabel).toContain('Message created at'); + }); + + it('renders separate contact, inbox, and agent links', async () => { + const wrapper = mountCard(); + const links = wrapper.findAll('a'); + + expect(links.map(link => link.attributes('href'))).toEqual([ + '/app/accounts/1/contacts/11', + '/app/accounts/1/inbox/12', + '/app/accounts/1/reports/agents/13', + ]); + expect(links.every(link => link.attributes('target') === '_blank')).toBe( + true + ); + expect( + links.every(link => link.classes().includes('text-n-slate-10')) + ).toBe(true); + expect( + links.every(link => !link.classes().includes('text-n-blue-11')) + ).toBe(true); + expect(wrapper.find('.i-lucide-contact').exists()).toBe(true); + expect(wrapper.find('.i-lucide-inbox').exists()).toBe(true); + expect(wrapper.find('.i-lucide-user-round').exists()).toBe(true); + + await links[0].trigger('click'); + + expect(window.open).not.toHaveBeenCalled(); + }); + + it('renders the last message for conversation rows', () => { + const wrapper = mountCard({ + record: { + ...record, + record_type: 'conversation', + message: null, + occurred_at: 1621103500, + }, + }); + + expect(wrapper.text()).toContain('Latest reply'); + expect(wrapper.text()).toContain('4d • 4d'); + }); + + it('renders event time alongside TimeAgo for event-backed conversation rows', () => { + const wrapper = mountCard({ + record: { + ...record, + record_type: 'conversation', + message: null, + event_name: 'conversation_bot_handoff', + occurred_at: 1621103500, + }, + }); + const eventOccurredLabel = wrapper + .findAll('[aria-label]') + .map(timestamp => timestamp.attributes('aria-label')) + .find(label => label.includes('Event occurred at')); + + expect(wrapper.text()).toContain('Latest reply'); + expect(wrapper.text()).toContain('4d • 4d'); + expect(wrapper.text()).toContain('2m'); + expect(eventOccurredLabel).toContain('Event occurred at'); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js new file mode 100644 index 000000000..d6cec362f --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js @@ -0,0 +1,329 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { formatTime } from '@chatwoot/utils'; +import ReportsAPI from 'dashboard/api/reports'; +import ReportDrilldownDrawer from '../ReportDrilldownDrawer.vue'; + +vi.mock('dashboard/api/reports', () => ({ + default: { + getDrilldown: vi.fn(), + }, +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key, params = {}) => { + if (key === 'REPORT.DRILLDOWN.TITLE') { + return `${params.metric} details`; + } + if (key === 'REPORT.DRILLDOWN.RESULT_COUNT_CONVERSATION') { + return `${params.count} conversations`; + } + if (key === 'REPORT.DRILLDOWN.RESULT_COUNT_MESSAGE') { + return `${params.count} messages`; + } + return key; + }, + }), +})); + +describe('ReportDrilldownDrawer.vue', () => { + const request = { + metric: 'incoming_messages_count', + metricName: 'Messages received', + bucketLabel: '20-May', + bucketTimestamp: 1621103400, + from: 1621103400, + to: 1621621800, + type: 'account', + groupBy: 'day', + businessHours: false, + }; + + const payload = [ + { + record_type: 'message', + conversation: { + id: 10, + display_id: 42, + contact_id: 11, + contact_name: 'Jane', + inbox_id: 12, + inbox_name: 'Website', + assignee_id: 13, + assignee_name: 'Alex', + status: 'open', + created_at: 1621103400, + last_activity_at: 1621103700, + last_message: { + id: 100, + content: 'Latest reply', + message_type: 'outgoing', + created_at: 1621103600, + }, + }, + message: { + id: 99, + content: 'Need help', + message_type: 'incoming', + created_at: 1621103500, + }, + metric_value: null, + occurred_at: 1621103500, + }, + ]; + + const mountDrawer = options => + mount(ReportDrilldownDrawer, { + props: { open: true, ...request, ...options?.props }, + attachTo: options?.attachTo, + global: { + stubs: { + Teleport: true, + Transition: false, + Spinner: true, + Button: { + props: ['label'], + emits: ['click'], + template: + '', + }, + ReportDrilldownCard: { + props: ['record'], + template: + '
#{{ record.conversation.display_id }}
', + }, + }, + mocks: { + $t: key => key, + }, + }, + }); + + beforeEach(() => { + ReportsAPI.getDrilldown.mockResolvedValue({ + data: { + meta: { + total_count: 1, + current_page: 1, + record_type: 'message', + conversation_count: 1, + }, + payload, + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('loads and renders drilldown cards for the request', async () => { + const wrapper = mountDrawer(); + await flushPromises(); + + expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith( + expect.objectContaining({ + metric: 'incoming_messages_count', + bucketTimestamp: 1621103400, + page: 1, + }) + ); + expect(wrapper.text()).toContain('Messages received'); + expect(wrapper.text()).toContain('1 conversations'); + expect(wrapper.find('[data-testid="drilldown-card"]').text()).toBe('#42'); + }); + + it('shows the bucket aggregate value for average metrics', async () => { + const wrapper = mountDrawer({ + props: { + metric: 'avg_first_response_time', + metricName: 'First response time', + isAverageMetric: true, + bucketValue: 2580, + }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain(formatTime(2580)); + }); + + it('shows both conversation and message counts when they differ (reply time)', async () => { + ReportsAPI.getDrilldown.mockResolvedValue({ + data: { + meta: { + total_count: 8, + current_page: 1, + record_type: 'message', + conversation_count: 5, + }, + payload, + }, + }); + const wrapper = mountDrawer({ + props: { + metric: 'reply_time', + isAverageMetric: true, + bucketValue: 2580, + }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain('5 conversations'); + expect(wrapper.text()).toContain('8 messages'); + }); + + it('hides the message count when it matches the conversation count (first response time)', async () => { + ReportsAPI.getDrilldown.mockResolvedValue({ + data: { + meta: { + total_count: 5, + current_page: 1, + record_type: 'message', + conversation_count: 5, + }, + payload, + }, + }); + const wrapper = mountDrawer({ + props: { + metric: 'avg_first_response_time', + isAverageMetric: true, + bucketValue: 2580, + }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain('5 conversations'); + expect(wrapper.text()).not.toContain('messages'); + }); + + it('shows the plain count as the bucket value for count metrics', async () => { + const wrapper = mountDrawer({ props: { bucketValue: 128 } }); + await flushPromises(); + + expect(wrapper.text()).toContain('128'); + expect(wrapper.text()).not.toContain(formatTime(128)); + }); + + it('hides the redundant subtitle count for conversation-count metrics', async () => { + ReportsAPI.getDrilldown.mockResolvedValue({ + data: { + meta: { + total_count: 5, + current_page: 1, + record_type: 'conversation', + conversation_count: 5, + }, + payload, + }, + }); + const wrapper = mountDrawer({ + props: { metric: 'conversations_count', bucketValue: 5 }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain('5'); + expect(wrapper.text()).not.toContain('conversations'); + }); + + it('keeps the subtitle count when it differs from the stat value', async () => { + ReportsAPI.getDrilldown.mockResolvedValue({ + data: { + meta: { + total_count: 8, + current_page: 1, + record_type: 'conversation', + conversation_count: 5, + }, + payload, + }, + }); + const wrapper = mountDrawer({ + props: { metric: 'resolutions_count', bucketValue: 8 }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain('5 conversations'); + }); + + it('emits close when the drawer close button is clicked', async () => { + const wrapper = mountDrawer(); + await flushPromises(); + + await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click'); + + expect(wrapper.emitted('close')).toBeTruthy(); + }); + + it('emits navigate when the next button is clicked', async () => { + const wrapper = mountDrawer({ props: { canNext: true } }); + await flushPromises(); + + await wrapper + .get('[aria-label="REPORT.DRILLDOWN.NEXT_BUCKET"]') + .trigger('click'); + + expect(wrapper.emitted('navigate')).toStrictEqual([[1]]); + }); + + it('does not emit navigate past the available range', async () => { + const wrapper = mountDrawer({ props: { canPrev: false } }); + await flushPromises(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + + expect(wrapper.emitted('navigate')).toBeUndefined(); + }); + + it('moves focus into the drawer when opened', async () => { + const target = document.createElement('div'); + document.body.appendChild(target); + const wrapper = mountDrawer({ attachTo: target }); + await flushPromises(); + await nextTick(); + + expect(document.activeElement).toBe( + wrapper.find('[role="dialog"]').element + ); + + wrapper.unmount(); + target.remove(); + }); + + it('closes on Escape even when focus is outside the drawer', async () => { + const target = document.createElement('div'); + document.body.appendChild(target); + const wrapper = mountDrawer({ attachTo: target }); + await flushPromises(); + + document.body.focus(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(wrapper.emitted('close')).toBeTruthy(); + + wrapper.unmount(); + target.remove(); + }); + + it('restores focus to the previously focused element when closed', async () => { + const opener = document.createElement('button'); + const target = document.createElement('div'); + document.body.appendChild(opener); + document.body.appendChild(target); + opener.focus(); + + const wrapper = mountDrawer({ attachTo: target }); + await flushPromises(); + await nextTick(); + + await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click'); + + expect(document.activeElement).toBe(opener); + + wrapper.unmount(); + target.remove(); + opener.remove(); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/composables/specs/useReportDrilldown.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/specs/useReportDrilldown.spec.js new file mode 100644 index 000000000..b83742b9b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/specs/useReportDrilldown.spec.js @@ -0,0 +1,124 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import ReportsAPI from 'dashboard/api/reports'; +import { useReportDrilldown } from '../useReportDrilldown'; + +vi.mock('dashboard/api/reports', () => ({ + default: { + getDrilldown: vi.fn(), + }, +})); + +const deferredPromise = () => { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +}; + +const drilldownRequest = overrides => ({ + metric: 'conversations_count', + bucketTimestamp: 1, + from: 1621103400, + to: 1621621800, + type: 'account', + groupBy: 'day', + businessHours: false, + ...overrides, +}); + +describe('useReportDrilldown', () => { + const mountComposable = () => + mount({ + setup() { + return useReportDrilldown(); + }, + template: '
', + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('does not request drilldown again for an identical active request', async () => { + const request = deferredPromise(); + ReportsAPI.getDrilldown.mockReturnValue(request.promise); + + const wrapper = mountComposable(); + wrapper.vm.open(drilldownRequest()); + wrapper.vm.open(drilldownRequest()); + + expect(ReportsAPI.getDrilldown).toHaveBeenCalledTimes(1); + }); + + it('aborts an in-flight request when a newer request is opened', async () => { + const firstRequest = deferredPromise(); + const secondRequest = deferredPromise(); + let firstSignal; + + ReportsAPI.getDrilldown + .mockImplementationOnce(({ signal }) => { + firstSignal = signal; + return firstRequest.promise; + }) + .mockReturnValueOnce(secondRequest.promise); + + const wrapper = mountComposable(); + wrapper.vm.open(drilldownRequest({ bucketTimestamp: 1 })); + wrapper.vm.open(drilldownRequest({ bucketTimestamp: 2 })); + + expect(firstSignal.aborted).toBe(true); + }); + + it('passes an abort signal to drilldown requests', async () => { + const request = deferredPromise(); + ReportsAPI.getDrilldown.mockReturnValue(request.promise); + + const wrapper = mountComposable(); + wrapper.vm.open(drilldownRequest()); + + expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + signal: expect.any(AbortSignal), + }) + ); + }); + + it('ignores stale responses when a newer request is opened first', async () => { + const firstRequest = deferredPromise(); + const secondRequest = deferredPromise(); + ReportsAPI.getDrilldown + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(secondRequest.promise); + + const wrapper = mountComposable(); + wrapper.vm.open(drilldownRequest({ bucketTimestamp: 1 })); + wrapper.vm.open(drilldownRequest({ bucketTimestamp: 2 })); + + secondRequest.resolve({ + data: { + meta: { current_page: 1, total_count: 1 }, + payload: [{ id: 'second' }], + }, + }); + await flushPromises(); + + expect(wrapper.vm.records).toEqual([{ id: 'second' }]); + expect(wrapper.vm.meta).toEqual({ current_page: 1, total_count: 1 }); + + firstRequest.resolve({ + data: { + meta: { current_page: 1, total_count: 1 }, + payload: [{ id: 'first' }], + }, + }); + await flushPromises(); + + expect(wrapper.vm.records).toEqual([{ id: 'second' }]); + expect(wrapper.vm.meta).toEqual({ current_page: 1, total_count: 1 }); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js new file mode 100644 index 000000000..7c37cd9cc --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js @@ -0,0 +1,138 @@ +import { computed, ref } from 'vue'; +import ReportsAPI from 'dashboard/api/reports'; + +export function useReportDrilldown() { + const activeRequest = ref(null); + const records = ref([]); + const meta = ref({}); + const isFetching = ref(false); + const isFetchingMore = ref(false); + const hasError = ref(false); + let requestToken = 0; + let activeRequestController = null; + let activeRequestFingerprint = null; + + const hasRecords = computed(() => records.value.length > 0); + const hasMore = computed(() => { + return records.value.length < (meta.value.total_count || 0); + }); + + const isCurrentRequest = token => + token === requestToken && !!activeRequest.value; + + const requestFingerprint = request => + JSON.stringify({ + metric: request.metric, + bucketTimestamp: request.bucketTimestamp, + from: request.from, + to: request.to, + type: request.type, + id: request.id, + groupBy: request.groupBy, + businessHours: request.businessHours, + }); + + const abortActiveRequest = () => { + if (!activeRequestController) return; + + activeRequestController.abort(); + activeRequestController = null; + }; + + const isAbortError = error => + error?.name === 'AbortError' || + error?.name === 'CanceledError' || + error?.code === 'ERR_CANCELED'; + + const fetchPage = async (page, token = requestToken) => { + if (!activeRequest.value) return; + + const request = activeRequest.value; + const controller = new AbortController(); + const loadingState = page === 1 ? isFetching : isFetchingMore; + activeRequestController = controller; + loadingState.value = true; + hasError.value = false; + + try { + const response = await ReportsAPI.getDrilldown({ + ...request, + page, + signal: controller.signal, + }); + if (!isCurrentRequest(token)) return; + + meta.value = response.data.meta || {}; + records.value = + page === 1 + ? response.data.payload || [] + : [...records.value, ...(response.data.payload || [])]; + } catch (error) { + if (!isCurrentRequest(token) || isAbortError(error)) return; + + hasError.value = true; + } finally { + if (activeRequestController === controller) { + activeRequestController = null; + } + + if (isCurrentRequest(token)) { + loadingState.value = false; + } + } + }; + + const open = async request => { + const fingerprint = requestFingerprint(request); + if (activeRequestFingerprint === fingerprint) return; + + abortActiveRequest(); + requestToken += 1; + activeRequestFingerprint = fingerprint; + activeRequest.value = request; + records.value = []; + meta.value = {}; + hasError.value = false; + isFetchingMore.value = false; + await fetchPage(1, requestToken); + }; + + const close = () => { + abortActiveRequest(); + requestToken += 1; + activeRequestFingerprint = null; + activeRequest.value = null; + records.value = []; + meta.value = {}; + hasError.value = false; + isFetching.value = false; + isFetchingMore.value = false; + }; + + const loadMore = () => { + if ( + !activeRequest.value || + !hasMore.value || + isFetching.value || + isFetchingMore.value + ) { + return; + } + + fetchPage((meta.value.current_page || 1) + 1, requestToken); + }; + + return { + activeRequest, + records, + meta, + isFetching, + isFetchingMore, + hasError, + hasRecords, + hasMore, + open, + close, + loadMore, + }; +} diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/specs/ReportContainer.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/specs/ReportContainer.spec.js new file mode 100644 index 000000000..b45102611 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/specs/ReportContainer.spec.js @@ -0,0 +1,179 @@ +import { shallowMount } from '@vue/test-utils'; +import { useAlert } from 'dashboard/composables'; +import ReportContainer from '../ReportContainer.vue'; + +vi.mock('dashboard/composables', () => ({ + useAlert: vi.fn(), +})); + +vi.mock('dashboard/composables/useReportMetrics', () => ({ + useReportMetrics: () => ({ + calculateTrend: () => 0, + isAverageMetricType: key => + ['avg_first_response_time', 'avg_resolution_time', 'reply_time'].includes( + key + ), + }), +})); + +describe('ReportContainer.vue', () => { + const mountComponent = ({ + dataPoint = { value: 2, timestamp: 1621103400 }, + data, + reportKey = 'conversations_count', + role = 'administrator', + } = {}) => + shallowMount(ReportContainer, { + props: { + from: 1621103400, + to: 1621621800, + groupBy: { period: 'day' }, + reportType: 'inbox', + selectedItemId: 1, + businessHours: true, + reportKeys: { + CONVERSATIONS: reportKey, + }, + }, + global: { + mocks: { + $t: key => key, + $store: { + getters: { + getAccountReports: { + isFetching: { + [reportKey]: false, + }, + data: { + [reportKey]: data || [dataPoint], + }, + }, + getCurrentRole: role, + }, + }, + }, + stubs: { + ChartStats: true, + ReportDrilldownDrawer: { + name: 'ReportDrilldownDrawer', + props: [ + 'open', + 'metric', + 'metricName', + 'bucketLabel', + 'bucketTimestamp', + 'bucketValue', + 'isAverageMetric', + 'from', + 'to', + 'type', + 'id', + 'groupBy', + 'businessHours', + 'canPrev', + 'canNext', + ], + emits: ['navigate', 'close'], + template: '
', + }, + BarChart: { + name: 'BarChart', + props: ['collection', 'chartOptions', 'clickable'], + emits: ['elementClick'], + template: + '
{ diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js index d6cec362f..10bc38bee 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/ReportDrilldownDrawer.spec.js @@ -79,7 +79,9 @@ describe('ReportDrilldownDrawer.vue', () => { attachTo: options?.attachTo, global: { stubs: { - Teleport: true, + TeleportWithDirection: { + template: '
', + }, Transition: false, Spinner: true, Button: { @@ -248,6 +250,27 @@ describe('ReportDrilldownDrawer.vue', () => { expect(wrapper.text()).toContain('5 conversations'); }); + it('anchors the drawer to the inline-end edge so it flips in RTL', async () => { + const wrapper = mountDrawer(); + await flushPromises(); + + const drawer = wrapper.get('[role="dialog"]'); + expect(drawer.classes()).toContain('end-0'); + expect(drawer.classes()).not.toContain('right-0'); + }); + + it('flips the navigation caret icons in RTL', async () => { + const wrapper = mountDrawer({ props: { canPrev: true, canNext: true } }); + await flushPromises(); + + expect( + wrapper.get('[aria-label="REPORT.DRILLDOWN.PREVIOUS_BUCKET"]').classes() + ).toContain('rtl:rotate-180'); + expect( + wrapper.get('[aria-label="REPORT.DRILLDOWN.NEXT_BUCKET"]').classes() + ).toContain('rtl:rotate-180'); + }); + it('emits close when the drawer close button is clicked', async () => { const wrapper = mountDrawer(); await flushPromises(); From 8818d276b954ac4f84cffd8915c99f40e43804ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ask=20Bj=C3=B8rn=20Hansen?= Date: Thu, 2 Jul 2026 06:59:50 -0700 Subject: [PATCH 10/88] fix(captain): read OpenAI key from InstallationConfig in article search terms (#14915) generate_article_search_terms still pulled ENV['OPENAI_API_KEY'], left over from before the Jan 2025 Captain migration moved the key into InstallationConfig as CAPTAIN_OPEN_AI_API_KEY. Every other Captain LLM call site got updated then; this one (used by Portal::ArticleIndexingJob for help center article embedding search terms) didn't, so it sent a blank bearer token unless you also happened to have the old env var set. Also drops the stale OPENAI_API_KEY line from .env.example and points to where the key actually lives now (Super Admin > App Configs > Captain). --------- Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Co-authored-by: Sony Mathew --- .env.example | 6 +++--- enterprise/app/models/enterprise/concerns/article.rb | 8 ++++++-- .../api/v1/accounts/applied_slas_controller_spec.rb | 3 ++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 69b1b9cde..c9f3c855c 100644 --- a/.env.example +++ b/.env.example @@ -272,9 +272,9 @@ AZURE_APP_SECRET= # ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false -# AI powered features -## OpenAI key -# OPENAI_API_KEY= +# AI powered features (Captain) +# The OpenAI API key and endpoint for Captain are not configured via .env. +# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT). # Housekeeping/Performance related configurations # Set to true if you want to remove stale contact inboxes diff --git a/enterprise/app/models/enterprise/concerns/article.rb b/enterprise/app/models/enterprise/concerns/article.rb index 9482313fd..6be262fef 100644 --- a/enterprise/app/models/enterprise/concerns/article.rb +++ b/enterprise/app/models/enterprise/concerns/article.rb @@ -67,7 +67,7 @@ module Enterprise::Concerns::Article { role: 'system', content: article_to_search_terms_prompt }, { role: 'user', content: "title: #{title} \n description: #{description} \n content: #{content}" } ] - headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', nil)}" } + headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{openai_api_key}" } body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json Rails.logger.info "Requesting Chat GPT with body: #{body}" response = HTTParty.post(openai_api_url, headers: headers, body: body) @@ -77,8 +77,12 @@ module Enterprise::Concerns::Article private + def openai_api_key + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value.presence || raise(I18n.t('captain.api_key_missing')) + end + def openai_api_url - endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/' + endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' endpoint = endpoint.chomp('/') "#{endpoint}/v1/chat/completions" end diff --git a/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb index 4187adfea..e4b2bfe70 100644 --- a/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb @@ -144,7 +144,8 @@ RSpec.describe 'Applied SLAs API', type: :request do csv_data = CSV.parse(response.body) csv_data.reject! { |row| row.all?(&:nil?) } expect(csv_data.size).to eq(3) - expect(csv_data[1][0].to_i).to eq(conversation1.display_id) + conversation_ids = csv_data.drop(1).map { |row| row[0].to_i } + expect(conversation_ids).to contain_exactly(conversation1.display_id, conversation2.display_id) end it 'excludes conversations with blocked contacts from the CSV file' do From 11deffdd5de353c0112cceddede42f6c2ea849d7 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:35:38 +0530 Subject: [PATCH 11/88] feat: billing brl pix new users (#14617) ## Linear ticket - https://linear.app/chatwoot/issue/CW-7253/billing-brl-pix-new-users ## Description New accounts that sign up in Brazilian Portuguese are now billed in BRL instead of USD. Their Stripe customer is created with a Brazil address and Portuguese locale (so the Stripe portal offers Real prices and PIX), and the AI credit top-up flow shows packages priced in the account's billing currency. Currency support is config-driven, so adding another currency later is a configuration change rather than a code change. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - https://www.loom.com/share/c8d3d08c1b844ed6b820438d4209491a ## Screenshot image ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- app/helpers/billing_helper.rb | 12 ++ .../dashboard/api/enterprise/account.js | 9 ++ app/javascript/dashboard/constants/billing.js | 35 +++++ .../dashboard/i18n/locale/en/settings.json | 14 +- .../dashboard/settings/billing/Index.vue | 62 +++++++- .../billing/components/CreditPackageCard.vue | 8 +- .../components/PurchaseCreditsModal.vue | 134 ++++++++++++------ .../dashboard/store/modules/accounts.js | 15 +- app/policies/account_policy.rb | 8 ++ .../api/v1/models/_account.json.jbuilder | 1 + config/installation_config.yml | 11 ++ config/locales/en.yml | 3 + config/routes.rb | 2 + .../enterprise/api/v1/accounts_controller.rb | 47 +++++- enterprise/app/models/enterprise/account.rb | 24 ++++ .../billing/create_stripe_customer_service.rb | 39 +++-- .../services/enterprise/billing/currencies.rb | 55 +++++++ .../billing/handle_stripe_event_service.rb | 20 ++- .../enterprise/billing/plan_configuration.rb | 46 ++++++ .../billing/topup_checkout_service.rb | 28 +++- .../api/v1/accounts_controller_spec.rb | 8 ++ .../create_stripe_customer_service_spec.rb | 26 +++- .../enterprise/billing/currencies_spec.rb | 36 +++++ .../billing/topup_checkout_service_spec.rb | 9 ++ 24 files changed, 562 insertions(+), 90 deletions(-) create mode 100644 app/javascript/dashboard/constants/billing.js create mode 100644 enterprise/app/services/enterprise/billing/currencies.rb create mode 100644 enterprise/app/services/enterprise/billing/plan_configuration.rb create mode 100644 spec/enterprise/services/enterprise/billing/currencies_spec.rb diff --git a/app/helpers/billing_helper.rb b/app/helpers/billing_helper.rb index e2ada7e86..7545b6d8f 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -22,4 +22,16 @@ module BillingHelper def agents(account) account.users.count end + + # current_period_end moved to the subscription item in newer Stripe API versions; read both. + def subscription_period_end(subscription) + subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end') + end + + def subscription_ends_on(subscription) + period_end = subscription_period_end(subscription) + return if period_end.blank? + + Time.zone.at(period_end) + end end diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 9e6d40a62..03456a288 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -14,6 +14,10 @@ class EnterpriseAccountAPI extends ApiClient { return axios.post(`${this.url}subscription`); } + selectBillingCurrency(currency) { + return axios.post(`${this.url}select_billing_currency`, { currency }); + } + getLimits() { return axios.get(`${this.url}limits`); } @@ -27,6 +31,11 @@ class EnterpriseAccountAPI extends ApiClient { createTopupCheckout(credits) { return axios.post(`${this.url}topup_checkout`, { credits }); } + + // Topup packages for the account's billing currency. + getTopupOptions() { + return axios.get(`${this.url}topup_options`); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/constants/billing.js b/app/javascript/dashboard/constants/billing.js new file mode 100644 index 000000000..d372330f8 --- /dev/null +++ b/app/javascript/dashboard/constants/billing.js @@ -0,0 +1,35 @@ +// Single source of truth for billing currencies on the frontend. +// Adding a currency = one entry in BILLING_CURRENCY_CONFIG, add the code to +// SUPPORTED_BILLING_CURRENCIES, and add its label key under +// BILLING_SETTINGS.CURRENCY.OPTIONS in the locale files. + +export const DEFAULT_BILLING_CURRENCY = 'usd'; + +// Order here drives the order of the currency toggle in the UI. +export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl']; + +export const BILLING_CURRENCY_CONFIG = { + usd: { + code: 'usd', + intlLocale: 'en-US', + i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD', + }, + brl: { + code: 'brl', + intlLocale: 'pt-BR', + i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL', + }, +}; + +export const getCurrencyConfig = code => + BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] || + BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY]; + +export const formatCurrencyAmount = (amount, code, options = {}) => { + const { intlLocale, code: currencyCode } = getCurrencyConfig(code); + return new Intl.NumberFormat(intlLocale, { + style: 'currency', + currency: currencyCode.toUpperCase(), + ...options, + }).format(amount); +}; diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 640b9c506..4caca05fb 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -467,7 +467,18 @@ "TITLE": "Current Plan", "PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses", "SEAT_COUNT": "Number of seats", - "RENEWS_ON": "Renews on" + "RENEWS_ON": "Renews on", + "CURRENCY": "Currency" + }, + "CURRENCY": { + "SELECT": { + "TITLE": "Choose your billing currency", + "DESCRIPTION": "Select the currency you'd like to be billed in. This can't be changed once your subscription is created." + }, + "OPTIONS": { + "USD": "US Dollar (USD)", + "BRL": "Brazilian Real (BRL)" + } }, "VIEW_PRICING": "View Pricing", "MANAGE_SUBSCRIPTION": { @@ -503,6 +514,7 @@ "PURCHASE": "Purchase Credits", "LOADING": "Loading options...", "FETCH_ERROR": "Failed to load credit options. Please try again.", + "RETRY": "Retry", "PURCHASE_ERROR": "Failed to process purchase. Please try again.", "PURCHASE_SUCCESS": "Successfully added {credits} credits to your account", "CONFIRM": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue index bcfa46193..521fa654b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -15,6 +15,8 @@ import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import SettingsLayout from '../SettingsLayout.vue'; import ButtonV4 from 'next/button/Button.vue'; +import { getCurrencyConfig } from 'dashboard/constants/billing'; +import { useI18n } from 'vue-i18n'; const router = useRouter(); const { currentAccount, isOnChatwootCloud } = useAccount(); @@ -29,6 +31,7 @@ const { const uiFlags = useMapGetter('accounts/getUIFlags'); const store = useStore(); +const { t } = useI18n(); const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted'; @@ -36,6 +39,10 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted'; const isWaitingForBilling = ref(false); const purchaseCreditsModalRef = ref(null); +// Currency selection shown to new accounts whose locale supports a non-USD currency. +const currencySelectionRequired = ref(false); +const currencyOptions = ref([]); + const customAttributes = computed(() => { return currentAccount.value.custom_attributes || {}; }); @@ -61,6 +68,13 @@ const subscribedQuantity = computed(() => { return customAttributes.value.subscribed_quantity; }); +const billingCurrency = computed(() => { + if (!customAttributes.value.billing_currency) return ''; + return t( + getCurrencyConfig(customAttributes.value.billing_currency).i18nLabelKey + ); +}); + const subscriptionRenewsOn = computed(() => { if (!customAttributes.value.subscription_ends_on) return ''; const endDate = new Date(customAttributes.value.subscription_ends_on); @@ -78,7 +92,9 @@ const hasABillingPlan = computed(() => { const fetchAccountDetails = async () => { if (!hasABillingPlan.value) { - await store.dispatch('accounts/subscription'); + const data = await store.dispatch('accounts/subscription'); + currencySelectionRequired.value = !!data?.currency_selection_required; + currencyOptions.value = data?.currency_options || []; } // Always fetch limits for billing page to show credit usage fetchLimits(); @@ -97,6 +113,9 @@ const handleBillingPageLogic = async () => { // If cloud user, fetch account details first await fetchAccountDetails(); + // Waiting on the user to pick a billing currency — don't auto-refresh. + if (currencySelectionRequired.value) return; + // If still no billing plan after fetch if (!hasABillingPlan.value) { // If we haven't attempted refresh yet, do it once @@ -118,6 +137,13 @@ const handleBillingPageLogic = async () => { } }; +const onSelectCurrency = async code => { + await store.dispatch('accounts/selectBillingCurrency', code); + currencySelectionRequired.value = false; + // Currency stored and customer creation kicked off — resume the standard wait flow. + await handleBillingPageLogic(); +}; + const onClickBillingPortal = () => { store.dispatch('accounts/checkout'); }; @@ -148,7 +174,9 @@ onMounted(handleBillingPageLogic); ? $t('BILLING_SETTINGS.NO_BILLING_USER') : $t('ATTRIBUTES_MGMT.LOADING') " - :no-records-found="!hasABillingPlan && !isWaitingForBilling" + :no-records-found=" + !hasABillingPlan && !isWaitingForBilling && !currencySelectionRequired + " :no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')" >