From 586552013ef5556816abf141744c1de49146309f Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Fri, 15 Mar 2024 10:55:40 +0530 Subject: [PATCH 1/4] feat: Update the `contact_type` when creating or updating the contact (#9107) * feat: Update location and country code when the contact create/update * feat: Update the location and country_code when creating or updating the contact. * chore: improve comments * feat: Update the contact_type when the contact created/updated * chore: add more specs * chore: code cleanups * chore: code cleanups * Update contact_spec.rb * Update inbox.rb * Update sync_attributes_spec.rb * chore: build fixes * chore: check visitor type before update * chore: review fixes --- app/models/contact.rb | 9 ++-- app/services/contacts/sync_attributes.rb | 37 +++++++++++++++ spec/models/contact_spec.rb | 19 ++++++++ .../services/contacts/sync_attributes_spec.rb | 46 +++++++++++++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 app/services/contacts/sync_attributes.rb create mode 100644 spec/services/contacts/sync_attributes_spec.rb diff --git a/app/models/contact.rb b/app/models/contact.rb index 1f3d2fb90..a60e9f4d2 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -61,7 +61,7 @@ class Contact < ApplicationRecord after_create_commit :dispatch_create_event, :ip_lookup after_update_commit :dispatch_update_event after_destroy_commit :dispatch_destroy_event - before_save :update_contact_location_and_country_code + before_save :sync_contact_attributes enum contact_type: { visitor: 0, lead: 1, customer: 2 } @@ -207,11 +207,8 @@ class Contact < ApplicationRecord self.custom_attributes = {} if custom_attributes.blank? end - def update_contact_location_and_country_code - # TODO: Ensure that location and country_code are updated from additional_attributes. - # We will remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app. - self.location = additional_attributes['city'] - self.country_code = additional_attributes['country'] + def sync_contact_attributes + ::Contacts::SyncAttributes.new(self).perform end def dispatch_create_event diff --git a/app/services/contacts/sync_attributes.rb b/app/services/contacts/sync_attributes.rb new file mode 100644 index 000000000..bc10def66 --- /dev/null +++ b/app/services/contacts/sync_attributes.rb @@ -0,0 +1,37 @@ +class Contacts::SyncAttributes + attr_reader :contact + + def initialize(contact) + @contact = contact + end + + def perform + update_contact_location_and_country_code + set_contact_type + end + + private + + def update_contact_location_and_country_code + # Ensure that location and country_code are updated from additional_attributes. + # TODO: Remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app. + @contact.location = @contact.additional_attributes['city'] + @contact.country_code = @contact.additional_attributes['country'] + end + + def set_contact_type + # If the contact is already a lead or customer then do not change the contact type + return unless @contact.contact_type == 'visitor' + # If the contact has an email or phone number or social details( facebook_user_id, instagram_user_id, etc) then it is a lead + # If contact is from external channel like facebook, instagram, whatsapp, etc then it is a lead + return unless @contact.email.present? || @contact.phone_number.present? || social_details_present? + + @contact.contact_type = 'lead' + end + + def social_details_present? + @contact.additional_attributes.keys.any? do |key| + key.start_with?('social_') && @contact.additional_attributes[key].present? + end + end +end diff --git a/spec/models/contact_spec.rb b/spec/models/contact_spec.rb index ff186466a..2ca65fa4e 100644 --- a/spec/models/contact_spec.rb +++ b/spec/models/contact_spec.rb @@ -22,11 +22,13 @@ RSpec.describe Contact do it 'sets email to lowercase' do contact = create(:contact, email: 'Test@test.com') expect(contact.email).to eq('test@test.com') + expect(contact.contact_type).to eq('lead') end it 'sets email to nil when empty string' do contact = create(:contact, email: '') expect(contact.email).to be_nil + expect(contact.contact_type).to eq('visitor') end it 'sets custom_attributes to {} when nil' do @@ -83,4 +85,21 @@ RSpec.describe Contact do expect(contact.country_code).to eq 'US' end end + + context 'when a contact is created' do + it 'has contact type "visitor" by default' do + contact = create(:contact) + expect(contact.contact_type).to eq 'visitor' + end + + it 'has contact type "lead" when email is present' do + contact = create(:contact, email: 'test@test.com') + expect(contact.contact_type).to eq 'lead' + end + + it 'has contact type "lead" when contacted through a social channel' do + contact = create(:contact, additional_attributes: { social_facebook_user_id: '123' }) + expect(contact.contact_type).to eq 'lead' + end + end end diff --git a/spec/services/contacts/sync_attributes_spec.rb b/spec/services/contacts/sync_attributes_spec.rb new file mode 100644 index 000000000..447bcd740 --- /dev/null +++ b/spec/services/contacts/sync_attributes_spec.rb @@ -0,0 +1,46 @@ +# spec/services/contacts/sync_attributes_spec.rb + +require 'rails_helper' + +RSpec.describe Contacts::SyncAttributes do + describe '#perform' do + let(:contact) { create(:contact, additional_attributes: { 'city' => 'New York', 'country' => 'US' }) } + + context 'when contact has neither email/phone number nor social details' do + it 'does not change contact type' do + described_class.new(contact).perform + expect(contact.reload.contact_type).to eq('visitor') + end + end + + context 'when contact has email or phone number' do + it 'sets contact type to lead' do + contact.email = 'test@test.com' + contact.save + described_class.new(contact).perform + + expect(contact.reload.contact_type).to eq('lead') + end + end + + context 'when contact has social details' do + it 'sets contact type to lead' do + contact.additional_attributes['social_facebook_user_id'] = '123456789' + contact.save + described_class.new(contact).perform + + expect(contact.reload.contact_type).to eq('lead') + end + end + + context 'when location and country code are updated from additional attributes' do + it 'updates location and country code' do + described_class.new(contact).perform + + # Expect location and country code to be updated + expect(contact.reload.location).to eq('New York') + expect(contact.reload.country_code).to eq('US') + end + end + end +end From 476077ab846e046b48fc183625137af2e48ba158 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 15 Mar 2024 11:23:43 +0530 Subject: [PATCH 2/4] fix: Update location component to avoid overflow, handle location title from Telegram payload (#9113) Co-authored-by: Pranav --- .../widgets/conversation/bubble/Location.vue | 90 ++++++++----------- .../telegram/incoming_message_service.rb | 11 +++ .../telegram/incoming_message_service_spec.rb | 24 +++++ 3 files changed, 71 insertions(+), 54 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue index 3b8fcbaa0..e72233b88 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue @@ -1,17 +1,45 @@ + + - - - - diff --git a/app/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb index d18994a00..a36231634 100644 --- a/app/services/telegram/incoming_message_service.rb +++ b/app/services/telegram/incoming_message_service.rb @@ -130,6 +130,7 @@ class Telegram::IncomingMessageService @message.attachments.new( account_id: @message.account_id, file_type: :location, + fallback_title: location_fallback_title, coordinates_lat: location['latitude'], coordinates_long: location['longitude'] ) @@ -139,6 +140,16 @@ class Telegram::IncomingMessageService @file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence end + def location_fallback_title + return '' if venue.blank? + + venue[:title] || '' + end + + def venue + @venue ||= params.dig(:message, :venue).presence + end + def location @location ||= params.dig(:message, :location).presence end diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 795202894..3cade6343 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -255,6 +255,30 @@ describe Telegram::IncomingMessageService do expect(Contact.all.first.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location') end + + it 'creates appropriate conversations, message and contacts if venue is present' do + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { + 'location': { + 'latitude': 37.7893768, + 'longitude': -122.3895553 + }, + venue: { + title: 'San Francisco' + } + }.merge(message_params) + }.with_indifferent_access + described_class.new(inbox: telegram_channel.inbox, params: params).perform + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + + attachment = telegram_channel.inbox.messages.first.attachments.first + expect(attachment.file_type).to eq('location') + expect(attachment.coordinates_lat).to eq(37.7893768) + expect(attachment.coordinates_long).to eq(-122.3895553) + expect(attachment.fallback_title).to eq('San Francisco') + end end context 'when valid callback_query params' do From 89d0b2cb6eb4f65410b2e6ef225ac74685864e7f Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 15 Mar 2024 11:34:14 +0530 Subject: [PATCH 3/4] feat: Add the bot performance reports UI (#9036) Co-authored-by: Pranav Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- app/javascript/dashboard/api/reports.js | 18 +++ .../dashboard/api/specs/reports.spec.js | 34 ++++++ .../layout/config/sidebarItems/reports.js | 10 ++ app/javascript/dashboard/featureFlags.js | 1 + .../dashboard/i18n/locale/en/report.json | 76 +++++++++++-- .../dashboard/i18n/locale/en/settings.json | 1 + .../dashboard/mixins/reportMixin.js | 10 +- .../mixins/specs/reportMixin.spec.js | 31 +++++ .../mixins/specs/reportMixinFixtures.js | 8 ++ .../dashboard/settings/reports/BotReports.vue | 106 ++++++++++++++++++ .../settings/reports/ReportContainer.vue | 2 +- .../reports/components/BotMetrics.vue | 68 +++++++++++ .../components/ChartElements/ChartStats.vue | 6 +- .../reports/components/CsatMetrics.vue | 3 + .../reports/components/ReportMetricCard.vue | 2 +- .../dashboard/settings/reports/constants.js | 2 + .../settings/reports/reports.routes.js | 18 +++ .../dashboard/store/modules/reports.js | 31 +++++ .../dashboard/store/mutation-types.js | 1 + 19 files changed, 414 insertions(+), 14 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/BotMetrics.vue diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 987b69701..52fa7f444 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -84,6 +84,24 @@ class ReportsAPI extends ApiClient { params: { since, until, business_hours: businessHours }, }); } + + getBotMetrics({ from, to } = {}) { + return axios.get(`${this.url}/bot_metrics`, { + params: { since: from, until: to }, + }); + } + + getBotSummary({ from, to, groupBy, businessHours } = {}) { + return axios.get(`${this.url}/bot_summary`, { + params: { + since: from, + until: to, + type: 'account', + group_by: groupBy, + business_hours: businessHours, + }, + }); + } } export default new ReportsAPI(); diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index 7822dad8f..05d4a152c 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -111,6 +111,40 @@ describe('#Reports API', () => { }); }); + it('#getBotMetrics', () => { + reportsAPI.getBotMetrics({ from: 1621103400, to: 1621621800 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v2/reports/bot_metrics', + { + params: { + since: 1621103400, + until: 1621621800, + }, + } + ); + }); + + it('#getBotSummary', () => { + reportsAPI.getBotSummary({ + from: 1621103400, + to: 1621621800, + groupBy: 'date', + businessHours: true, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v2/reports/bot_summary', + { + params: { + since: 1621103400, + until: 1621621800, + type: 'account', + group_by: 'date', + business_hours: true, + }, + } + ); + }); + it('#getConversationMetric', () => { reportsAPI.getConversationMetric('account'); expect(axiosMock.get).toHaveBeenCalledWith( diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js b/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js index 967ee44ed..551256c74 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; const reports = accountId => ({ @@ -6,6 +7,7 @@ const reports = accountId => ({ 'account_overview_reports', 'conversation_reports', 'csat_reports', + 'bot_reports', 'agent_reports', 'label_reports', 'inbox_reports', @@ -33,6 +35,14 @@ const reports = accountId => ({ toState: frontendURL(`accounts/${accountId}/reports/csat`), toStateName: 'csat_reports', }, + { + icon: 'bot', + label: 'REPORTS_BOT', + hasSubMenu: false, + featureFlag: FEATURE_FLAGS.RESPONSE_BOT, + toState: frontendURL(`accounts/${accountId}/reports/bot`), + toStateName: 'bot_reports', + }, { icon: 'people', label: 'REPORTS_AGENT', diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 2936d22ea..f2fd3e757 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -19,4 +19,5 @@ export const FEATURE_FLAGS = { INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply', INBOX_VIEW: 'inbox_view', SLA: 'sla', + RESPONSE_BOT: 'response_bot', }; diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index a6d476bc9..56ca21773 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -35,6 +35,14 @@ "NAME": "Resolution Count", "DESC": "( Total )" }, + "BOT_RESOLUTION_COUNT": { + "NAME": "Resolution Count", + "DESC": "( Total )" + }, + "BOT_HANDOFF_COUNT": { + "NAME": "Handoff Count", + "DESC": "( Total )" + }, "REPLY_TIME": { "NAME": "Customer waiting time", "TOOLTIP_TEXT": "Waiting time is %{metricValue} (based on %{conversationCount} replies)" @@ -86,20 +94,49 @@ "MONTH": "Month", "YEAR": "Year" }, - "GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }], + "GROUP_BY_DAY_OPTIONS": [ + { + "id": 1, + "groupBy": "Day" + } + ], "GROUP_BY_WEEK_OPTIONS": [ - { "id": 1, "groupBy": "Day" }, - { "id": 2, "groupBy": "Week" } + { + "id": 1, + "groupBy": "Day" + }, + { + "id": 2, + "groupBy": "Week" + } ], "GROUP_BY_MONTH_OPTIONS": [ - { "id": 1, "groupBy": "Day" }, - { "id": 2, "groupBy": "Week" }, - { "id": 3, "groupBy": "Month" } + { + "id": 1, + "groupBy": "Day" + }, + { + "id": 2, + "groupBy": "Week" + }, + { + "id": 3, + "groupBy": "Month" + } ], "GROUP_BY_YEAR_OPTIONS": [ - { "id": 2, "groupBy": "Week" }, - { "id": 3, "groupBy": "Month" }, - { "id": 4, "groupBy": "Year" } + { + "id": 2, + "groupBy": "Week" + }, + { + "id": 3, + "groupBy": "Month" + }, + { + "id": 4, + "groupBy": "Year" + } ], "BUSINESS_HOURS": "Business Hours" }, @@ -404,6 +441,27 @@ } } }, + "BOT_REPORTS": { + "HEADER": "Bot Reports", + "METRIC": { + "TOTAL_CONVERSATIONS": { + "LABEL": "No. of Conversations", + "TOOLTIP": "Total number of conversations handled by the bot" + }, + "TOTAL_RESPONSES": { + "LABEL": "Total Responses", + "TOOLTIP": "Total number of responses sent by the bot" + }, + "RESOLUTION_RATE": { + "LABEL": "Resolution Rate", + "TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100" + }, + "HANDOFF_RATE": { + "LABEL": "Handoff Rate", + "TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100" + } + } + }, "OVERVIEW_REPORTS": { "HEADER": "Overview", "LIVE": "Live", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 63725f949..9a4bde2c8 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -234,6 +234,7 @@ "CAMPAIGNS": "Campaigns", "ONGOING": "Ongoing", "ONE_OFF": "One off", + "REPORTS_BOT": "Bot", "REPORTS_AGENT": "Agents", "REPORTS_LABEL": "Labels", "REPORTS_INBOX": "Inbox", diff --git a/app/javascript/dashboard/mixins/reportMixin.js b/app/javascript/dashboard/mixins/reportMixin.js index 2b8a5f87d..d57af1ad2 100644 --- a/app/javascript/dashboard/mixins/reportMixin.js +++ b/app/javascript/dashboard/mixins/reportMixin.js @@ -2,11 +2,19 @@ import { mapGetters } from 'vuex'; import { formatTime } from '@chatwoot/utils'; export default { + props: { + accountSummaryKey: { + type: String, + default: 'getAccountSummary', + }, + }, computed: { ...mapGetters({ - accountSummary: 'getAccountSummary', accountReport: 'getAccountReports', }), + accountSummary() { + return this.$store.getters[this.accountSummaryKey]; + }, }, methods: { calculateTrend(key) { diff --git a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js index d981de4e9..c0bc1e15f 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js +++ b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js @@ -11,11 +11,42 @@ describe('reportMixin', () => { beforeEach(() => { getters = { getAccountSummary: () => reportFixtures.summary, + getBotSummary: () => reportFixtures.botSummary, getAccountReports: () => reportFixtures.report, }; store = new Vuex.Store({ getters }); }); + it('display the metric for account', async () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [reportMixin], + }; + const wrapper = shallowMount(Component, { store, localVue }); + await wrapper.setProps({ + accountSummaryKey: 'getAccountSummary', + }); + expect(wrapper.vm.displayMetric('conversations_count')).toEqual('5,000'); + expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual( + '3 Min 18 Sec' + ); + }); + + it('display the metric for bot', async () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [reportMixin], + }; + const wrapper = shallowMount(Component, { store, localVue }); + await wrapper.setProps({ + accountSummaryKey: 'getBotSummary', + }); + expect(wrapper.vm.displayMetric('bot_resolutions_count')).toEqual('10'); + expect(wrapper.vm.displayMetric('bot_handoffs_count')).toEqual('20'); + }); + it('display the metric', () => { const Component = { render() {}, diff --git a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js index ab6b6fecf..591bf7c1f 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js +++ b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js @@ -15,6 +15,14 @@ export default { }, resolutions_count: 3, }, + botSummary: { + bot_resolutions_count: 10, + bot_handoffs_count: 20, + previous: { + bot_resolutions_count: 8, + bot_handoffs_count: 5, + }, + }, report: { data: [ { value: '0.00', timestamp: 1647541800, count: 0 }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue new file mode 100644 index 000000000..a75051aaf --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue @@ -0,0 +1,106 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue index c601b4277..273f814b7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue @@ -7,7 +7,7 @@ :key="metric.KEY" class="p-4 rounded-md mb-3" > - +
+import { ref, watch, onMounted } from 'vue'; +import ReportMetricCard from './ReportMetricCard.vue'; +import ReportsAPI from 'dashboard/api/reports'; + +const props = defineProps({ + filters: { + type: Object, + required: true, + }, +}); + +const conversationCount = ref('0'); +const messageCount = ref('0'); +const resolutionRate = ref('0'); +const handoffRate = ref('0'); + +const formatToPercent = value => { + return value ? `${value}%` : '--'; +}; + +const fetchMetrics = () => { + if (!props.filters.to || !props.filters.from) { + return; + } + ReportsAPI.getBotMetrics(props.filters).then(response => { + conversationCount.value = response.data.conversation_count.toLocaleString(); + messageCount.value = response.data.message_count.toLocaleString(); + resolutionRate.value = response.data.resolution_rate.toString(); + handoffRate.value = response.data.handoff_rate.toString(); + }); +}; + +watch(() => props.filters, fetchMetrics, { deep: true }); + +onMounted(fetchMetrics); + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue index 94fe1359d..4faf4de2b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue @@ -1,6 +1,8 @@