diff --git a/.circleci/config.yml b/.circleci/config.yml index 5c9b27f03..974b981ab 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,8 +12,8 @@ defaults: &defaults # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images # documented at https://circleci.com/docs/2.0/circleci-images/ - - image: circleci/postgres:alpine - - image: circleci/redis:alpine + - image: cimg/postgres:14.1 + - image: cimg/redis:6.2.6 environment: - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f - RAILS_LOG_TO_STDOUT: false @@ -110,7 +110,7 @@ jobs: - run: name: Run backend tests command: | - bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 + bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 --format documentation ~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json - persist_to_workspace: root: ~/tmp diff --git a/app/builders/contact_builder.rb b/app/builders/contact_builder.rb index 14f26aa82..b63d72627 100644 --- a/app/builders/contact_builder.rb +++ b/app/builders/contact_builder.rb @@ -70,7 +70,7 @@ class ContactBuilder update_contact_avatar(contact) contact_inbox rescue StandardError => e - Rails.logger.info e + Rails.logger.error e raise e end end diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 5410aa3c4..204b452d6 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -27,7 +27,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end ensure_contact_avatar rescue Koala::Facebook::AuthenticationError - Rails.logger.info "Facebook Authorization expired for Inbox #{@inbox.id}" + Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}" rescue StandardError => e Sentry.capture_exception(e) true diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index c45226397..67b80b7a5 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -1,5 +1,6 @@ class V2::ReportBuilder include DateRangeHelper + include ReportHelper attr_reader :account, :params DEFAULT_GROUP_BY = 'day'.freeze @@ -18,8 +19,14 @@ class V2::ReportBuilder # For backward compatible with old report def build - timeseries.each_with_object([]) do |p, arr| - arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i } + if %w[avg_first_response_time avg_resolution_time].include?(params[:metric]) + timeseries.each_with_object([]) do |p, arr| + arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] } + end + else + timeseries.each_with_object([]) do |p, arr| + arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i } + end end end @@ -34,23 +41,16 @@ class V2::ReportBuilder } end - private - - def scope - case params[:type] - when :account - account - when :inbox - inbox - when :agent - user - when :label - label - when :team - team + def conversation_metrics + if params[:type].equal?(:account) + conversations + else + agent_metrics end end + private + def inbox @inbox ||= account.inboxes.find(params[:id]) end @@ -68,7 +68,7 @@ class V2::ReportBuilder end def get_grouped_values(object_scope) - object_scope.group_by_period( + @grouped_values = object_scope.group_by_period( params[:group_by] || DEFAULT_GROUP_BY, :created_at, default_value: 0, @@ -78,47 +78,26 @@ class V2::ReportBuilder ) end - def conversations_count - (get_grouped_values scope.conversations).count + def agent_metrics + users = @account.users + users = users.where(id: params[:user_id]) if params[:user_id].present? + users.each_with_object([]) do |user, arr| + @user = user + arr << { + user: { id: user.id, name: user.name, thumbnail: user.avatar_url }, + metric: conversations + } + end end - def incoming_messages_count - (get_grouped_values scope.messages.incoming.unscope(:order)).count - end - - def outgoing_messages_count - (get_grouped_values scope.messages.outgoing.unscope(:order)).count - end - - def resolutions_count - (get_grouped_values scope.conversations.resolved).count - end - - def avg_first_response_time - (get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value) - end - - def avg_resolution_time - (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value) - end - - def avg_resolution_time_summary - avg_rt = scope.reporting_events - .where(name: 'conversation_resolved', created_at: range) - .average(:value) - - return 0 if avg_rt.blank? - - avg_rt - end - - def avg_first_response_time_summary - avg_frt = scope.reporting_events - .where(name: 'first_response', created_at: range) - .average(:value) - - return 0 if avg_frt.blank? - - avg_frt + def conversations + @open_conversations = scope.conversations.open + first_response_count = scope.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count + metric = { + open: @open_conversations.count, + unattended: @open_conversations.count - first_response_count + } + metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account) + metric end end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 3971dbcaf..dc4f7b6c8 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -7,13 +7,22 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont end def create - @automation_rule = Current.account.automation_rules.create(automation_rules_permit) + @automation_rule = Current.account.automation_rules.new(automation_rules_permit) + @automation_rule.actions = params[:actions] + + render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid? + + @automation_rule.save! + process_attachments + @automation_rule end def show; end def update @automation_rule.update(automation_rules_permit) + process_attachments + @automation_rule end def destroy @@ -30,11 +39,19 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont private + def process_attachments + return if params[:attachments].blank? + + params[:attachments].each do |uploaded_attachment| + @automation_rule.files.attach(uploaded_attachment) + end + end + def automation_rules_permit params.permit( :name, :description, :event_name, :account_id, :active, conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }], - actions: [:action_name, { action_params: [{}] }] + actions: [:action_name, { action_params: [] }] ) end diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 7c2469c05..842930874 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -77,7 +77,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', '')) koala.exchange_access_token_info(omniauth_token)['access_token'] rescue StandardError => e - Rails.logger.info e + Rails.logger.error e end def mark_already_existing_facebook_pages(data) diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index efa09a43c..b2932e34f 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -35,6 +35,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv' end + def conversations + return head :unprocessable_entity if params[:type].blank? + + render json: conversation_metrics + end + private def check_authorization @@ -73,6 +79,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController } end + def conversation_params + { + type: params[:type].to_sym, + user_id: params[:user_id] + } + end + def range { current: { @@ -91,4 +104,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary summary end + + def conversation_metrics + V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics + end end diff --git a/app/controllers/twitter/callbacks_controller.rb b/app/controllers/twitter/callbacks_controller.rb index d484b0871..32fffbceb 100644 --- a/app/controllers/twitter/callbacks_controller.rb +++ b/app/controllers/twitter/callbacks_controller.rb @@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) end rescue StandardError => e - Rails.logger.info e + Rails.logger.error e redirect_to twitter_app_redirect_url end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index 2338ca7d1..e6fe93566 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -17,7 +17,7 @@ class Webhooks::InstagramController < ApplicationController ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry]) render json: :ok else - Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}") + Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}") head :unprocessable_entity end end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb new file mode 100644 index 000000000..31e2c1ce8 --- /dev/null +++ b/app/helpers/report_helper.rb @@ -0,0 +1,62 @@ +module ReportHelper + private + + def scope + case params[:type] + when :account + account + when :inbox + inbox + when :agent + user + when :label + label + when :team + team + end + end + + def conversations_count + (get_grouped_values scope.conversations).count + end + + def incoming_messages_count + (get_grouped_values scope.messages.incoming.unscope(:order)).count + end + + def outgoing_messages_count + (get_grouped_values scope.messages.outgoing.unscope(:order)).count + end + + def resolutions_count + (get_grouped_values scope.conversations.resolved).count + end + + def avg_first_response_time + (get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value) + end + + def avg_resolution_time + (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value) + end + + def avg_resolution_time_summary + avg_rt = scope.reporting_events + .where(name: 'conversation_resolved', created_at: range) + .average(:value) + + return 0 if avg_rt.blank? + + avg_rt + end + + def avg_first_response_time_summary + avg_frt = scope.reporting_events + .where(name: 'first_response', created_at: range) + .average(:value) + + return 0 if avg_frt.blank? + + avg_frt + end +end diff --git a/app/javascript/dashboard/assets/scss/widgets/_report.scss b/app/javascript/dashboard/assets/scss/widgets/_report.scss index 6e8b2fe5c..444dccb7c 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_report.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_report.scss @@ -18,6 +18,13 @@ font-size: $font-size-small; font-weight: $font-weight-bold; color: $color-heading; + display: flex; + align-items: center; + } + + .info-icon { + color: var(--b-400); + margin-left: var(--space-micro); } .metric-wrap { diff --git a/app/javascript/dashboard/components/widgets/AutomationActionInput.vue b/app/javascript/dashboard/components/widgets/AutomationActionInput.vue index 22521af91..0c8178a9b 100644 --- a/app/javascript/dashboard/components/widgets/AutomationActionInput.vue +++ b/app/javascript/dashboard/components/widgets/AutomationActionInput.vue @@ -7,7 +7,8 @@
-
- +
+ +
+ +
@@ -91,13 +111,17 @@ export default { this.$emit('input', { ...payload, action_params: value }); }, }, + inputType() { + return this.actionTypes.find(action => action.key === this.action_name) + .inputType; + }, }, methods: { removeAction() { this.$emit('removeAction'); }, - resetFilter() { - this.$emit('resetFilter'); + resetAction() { + this.$emit('resetAction'); }, }, }; @@ -136,6 +160,10 @@ export default { max-width: 50%; } +.action__question.full-width { + max-width: 100%; +} + .filter__answer--wrap { margin-right: var(--space-smaller); flex-grow: 1; diff --git a/app/javascript/dashboard/components/widgets/ReportStatsCard.vue b/app/javascript/dashboard/components/widgets/ReportStatsCard.vue index 9bc46d021..a074ca857 100644 --- a/app/javascript/dashboard/components/widgets/ReportStatsCard.vue +++ b/app/javascript/dashboard/components/widgets/ReportStatsCard.vue @@ -5,7 +5,14 @@ @click="onClick(index)" >

- {{ heading }} + {{ heading }} +

@@ -22,6 +29,7 @@ export default { props: { heading: { type: String, default: '' }, + infoText: { type: String, default: '' }, point: { type: [Number, String], default: '' }, trend: { type: Number, default: null }, index: { type: Number, default: null }, diff --git a/app/javascript/dashboard/components/widgets/chart/BarChart.js b/app/javascript/dashboard/components/widgets/chart/BarChart.js index fbe42bc5c..a4dca263b 100644 --- a/app/javascript/dashboard/components/widgets/chart/BarChart.js +++ b/app/javascript/dashboard/components/widgets/chart/BarChart.js @@ -3,7 +3,7 @@ import { Bar } from 'vue-chartjs'; const fontFamily = '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; -const chartOptions = { +const defaultChartOptions = { responsive: true, maintainAspectRatio: false, legend: { @@ -11,10 +11,14 @@ const chartOptions = { fontFamily, }, }, + datasets: { + bar: { + barPercentage: 1.0, + }, + }, scales: { xAxes: [ { - barPercentage: 1.1, ticks: { fontFamily, }, @@ -39,8 +43,20 @@ const chartOptions = { export default { extends: Bar, - props: ['collection'], + props: { + collection: { + type: Object, + default: () => {}, + }, + chartOptions: { + type: Object, + default: () => {}, + }, + }, mounted() { - this.renderChart(this.collection, chartOptions); + this.renderChart(this.collection, { + ...defaultChartOptions, + ...this.chartOptions, + }); }, }; diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 1dd656659..bbbbe8a8a 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -22,6 +22,7 @@ class ActionCableConnector extends BaseActionCableConnector { 'contact.deleted': this.onContactDelete, 'contact.updated': this.onContactUpdate, 'conversation.mentioned': this.onConversationMentioned, + 'notification.created': this.onNotificationCreated, }; } @@ -134,6 +135,10 @@ class ActionCableConnector extends BaseActionCableConnector { onContactUpdate = data => { this.app.$store.dispatch('contacts/updateContact', data); }; + + onNotificationCreated = data => { + this.app.$store.dispatch('notifications/addNotification', data); + }; } export default { diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 037f6f769..002e834cb 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -70,6 +70,14 @@ "SUCCESS_MESSAGE": "Contacts saved successfully", "ERROR_MESSAGE": "There was an error, please try again" }, + "DELETE_NOTE": { + "CONFIRM":{ + "TITLE": "Confirm Deletion", + "MESSAGE": "Are you want sure to delete this note?", + "YES": "Yes, Delete it", + "NO": "No, Keep it" + } + }, "DELETE_CONTACT": { "BUTTON_LABEL": "Delete Contact", "TITLE": "Delete contact", diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 053c730e1..ed730d798 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -18,12 +18,14 @@ "DESC": "( Total )" }, "FIRST_RESPONSE_TIME": { - "NAME": "First response time", - "DESC": "( Avg )" + "NAME": "First Response Time", + "DESC": "( Avg )", + "INFO_TEXT": "Total number of conversations used for computation:" }, "RESOLUTION_TIME": { "NAME": "Resolution Time", - "DESC": "( Avg )" + "DESC": "( Avg )", + "INFO_TEXT": "Total number of conversations used for computation:" }, "RESOLUTION_COUNT": { "NAME": "Resolution Count", @@ -98,12 +100,14 @@ "DESC": "( Total )" }, "FIRST_RESPONSE_TIME": { - "NAME": "First response time", - "DESC": "( Avg )" + "NAME": "First Response Time", + "DESC": "( Avg )", + "INFO_TEXT": "Total number of conversations used for computation:" }, "RESOLUTION_TIME": { "NAME": "Resolution Time", - "DESC": "( Avg )" + "DESC": "( Avg )", + "INFO_TEXT": "Total number of conversations used for computation:" }, "RESOLUTION_COUNT": { "NAME": "Resolution Count", @@ -161,12 +165,14 @@ "DESC": "( Total )" }, "FIRST_RESPONSE_TIME": { - "NAME": "First response time", - "DESC": "( Avg )" + "NAME": "First Response Time", + "DESC": "( Avg )", + "INFO_TEXT": "Total number of conversations used for computation:" }, "RESOLUTION_TIME": { "NAME": "Resolution Time", - "DESC": "( Avg )" + "DESC": "( Avg )", + "INFO_TEXT": "Total number of conversations used for computation:" }, "RESOLUTION_COUNT": { "NAME": "Resolution Count", @@ -224,12 +230,14 @@ "DESC": "( Total )" }, "FIRST_RESPONSE_TIME": { - "NAME": "First response time", - "DESC": "( Avg )" + "NAME": "First Response Time", + "DESC": "( Avg )", + "INFO_TEXT": "Total number of conversations used for computation:" }, "RESOLUTION_TIME": { "NAME": "Resolution Time", - "DESC": "( Avg )" + "DESC": "( Avg )", + "INFO_TEXT": "Total number of conversations used for computation:" }, "RESOLUTION_COUNT": { "NAME": "Resolution Count", @@ -287,12 +295,14 @@ "DESC": "( Total )" }, "FIRST_RESPONSE_TIME": { - "NAME": "First response time", - "DESC": "( Avg )" + "NAME": "First Response Time", + "DESC": "( Avg )", + "INFO_TEXT": "Total number of conversations used for computation:" }, "RESOLUTION_TIME": { "NAME": "Resolution Time", - "DESC": "( Avg )" + "DESC": "( Avg )", + "INFO_TEXT": "Total number of conversations used for computation:" }, "RESOLUTION_COUNT": { "NAME": "Resolution Count", diff --git a/app/javascript/dashboard/mixins/reportMixin.js b/app/javascript/dashboard/mixins/reportMixin.js index 8ef7fdfe5..d5bb8c9cc 100644 --- a/app/javascript/dashboard/mixins/reportMixin.js +++ b/app/javascript/dashboard/mixins/reportMixin.js @@ -5,6 +5,7 @@ export default { computed: { ...mapGetters({ accountSummary: 'getAccountSummary', + accountReport: 'getAccountReports', }), calculateTrend() { return metric_key => { @@ -19,15 +20,32 @@ export default { }, displayMetric() { return metric_key => { - if ( - ['avg_first_response_time', 'avg_resolution_time'].includes( - metric_key - ) - ) { + if (this.isAverageMetricType(metric_key)) { return formatTime(this.accountSummary[metric_key]); } return this.accountSummary[metric_key]; }; }, + displayInfoText() { + return metric_key => { + if (this.metrics[this.currentSelection].KEY !== metric_key) { + return ''; + } + if (this.isAverageMetricType(metric_key)) { + const total = this.accountReport.data + .map(item => item.count) + .reduce((prev, curr) => prev + curr, 0); + return `${this.metrics[this.currentSelection].INFO_TEXT} ${total}`; + } + return ''; + }; + }, + isAverageMetricType() { + return metric_key => { + return ['avg_first_response_time', 'avg_resolution_time'].includes( + metric_key + ); + }; + }, }, }; diff --git a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js index 003295f95..ca1c45751 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js +++ b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js @@ -11,6 +11,7 @@ describe('reportMixin', () => { beforeEach(() => { getters = { getAccountSummary: () => reportFixtures.summary, + getAccountReports: () => reportFixtures.report, }; store = new Vuex.Store({ getters }); }); @@ -38,4 +39,67 @@ describe('reportMixin', () => { expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25); expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0); }); + + it('display info text', () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [reportMixin], + data() { + return { + currentSelection: 0, + }; + }, + computed: { + metrics() { + return [ + { + DESC: '( Avg )', + INFO_TEXT: 'Total number of conversations used for computation:', + KEY: 'avg_first_response_time', + NAME: 'First Response Time', + }, + ]; + }, + }, + }; + const wrapper = shallowMount(Component, { store, localVue }); + expect(wrapper.vm.displayInfoText('avg_first_response_time')).toEqual( + 'Total number of conversations used for computation: 4' + ); + }); + + it('do not display info text', () => { + const Component = { + render() {}, + title: 'TestComponent', + mixins: [reportMixin], + data() { + return { + currentSelection: 0, + }; + }, + computed: { + metrics() { + return [ + { + DESC: '( Total )', + INFO_TEXT: '', + KEY: 'conversation_count', + NAME: 'Conversations', + }, + { + DESC: '( Avg )', + INFO_TEXT: 'Total number of conversations used for computation:', + KEY: 'avg_first_response_time', + NAME: 'First Response Time', + }, + ]; + }, + }, + }; + const wrapper = shallowMount(Component, { store, localVue }); + expect(wrapper.vm.displayInfoText('conversation_count')).toEqual(''); + expect(wrapper.vm.displayInfoText('incoming_messages_count')).toEqual(''); + }); }); diff --git a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js index 5c8315ab1..8402c3940 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js +++ b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js @@ -15,4 +15,15 @@ export default { }, resolutions_count: 3, }, + report: { + data: [ + { value: '0.00', timestamp: 1647541800, count: 0 }, + { value: '0.00', timestamp: 1647628200, count: 0 }, + { value: '0.00', timestamp: 1647714600, count: 0 }, + { value: '0.00', timestamp: 1647801000, count: 0 }, + { value: '0.01', timestamp: 1647887400, count: 4 }, + { value: '0.00', timestamp: 1647973800, count: 0 }, + { value: '0.00', timestamp: 1648060200, count: 0 }, + ], + }, }; diff --git a/app/javascript/dashboard/modules/notes/components/ContactNote.vue b/app/javascript/dashboard/modules/notes/components/ContactNote.vue index d29c5e7b0..b781d9639 100644 --- a/app/javascript/dashboard/modules/notes/components/ContactNote.vue +++ b/app/javascript/dashboard/modules/notes/components/ContactNote.vue @@ -21,9 +21,19 @@ size="tiny" icon="delete" color-scheme="secondary" - @click="onDelete" + @click="toggleDeleteModal" />

+

@@ -59,7 +69,11 @@ export default { default: 0, }, }, - + data() { + return { + showDeleteModal: false, + }; + }, computed: { readableTime() { return this.dynamicTime(this.createdAt); @@ -73,9 +87,19 @@ export default { }, methods: { + toggleDeleteModal() { + this.showDeleteModal = !this.showDeleteModal; + }, onDelete() { this.$emit('delete', this.id); }, + confirmDeletion() { + this.onDelete(); + this.closeDelete(); + }, + closeDelete() { + this.showDeleteModal = false; + }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue index 814e90c25..2e10280e5 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue @@ -101,6 +101,7 @@ getActionDropdownValues(automation.actions[i].action_name) " :v="$v.automation.actions.$each[i]" + @resetAction="resetAction(i)" @removeAction="removeAction(i)" />

@@ -187,7 +188,13 @@ export default { required, $each: { action_params: { - required, + required: requiredIf(prop => { + return !( + prop.action_name === 'mute_conversation' || + prop.action_name === 'snooze_convresation' || + prop.action_name === 'resolve_convresation' + ); + }), }, }, }, @@ -351,7 +358,6 @@ export default { getActionDropdownValues(type) { switch (type) { case 'assign_team': - case 'send_email_to_team': return this.$store.getters['teams/getTeams']; case 'add_label': return this.$store.getters['labels/getLabels'].map(i => { @@ -424,6 +430,9 @@ export default { ).filterOperators[0].value; this.automation.conditions[index].values = ''; }, + resetAction(index) { + this.automation.actions[index].action_params = []; + }, showUserInput(operatorType) { if (operatorType === 'is_present' || operatorType === 'is_not_present') return false; diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue index d55ce419b..6add27ba5 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue @@ -351,7 +351,6 @@ export default { getActionDropdownValues(type) { switch (type) { case 'assign_team': - case 'send_email_to_team': return this.$store.getters['teams/getTeams']; case 'add_label': return this.$store.getters['labels/getLabels'].map(i => { diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js index 90e3a7395..dd11700bd 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js @@ -79,8 +79,33 @@ export const AUTOMATIONS = { // { // key: 'send_email_to_team', // name: 'Send an email to team', - // attributeI18nKey: 'SEND_EMAIL_TO_TEAM', + // attributeI18nKey: 'SEND_MESSAGE', // }, + { + key: 'send_email_transcript', + name: 'Send an email transcript', + attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT', + }, + { + key: 'mute_conversation', + name: 'Mute conversation', + attributeI18nKey: 'MUTE_CONVERSATION', + }, + { + key: 'snooze_convresation', + name: 'Snooze conversation', + attributeI18nKey: 'MUTE_CONVERSATION', + }, + { + key: 'resolve_convresation', + name: 'Resolve conversation', + attributeI18nKey: 'RESOLVE_CONVERSATION', + }, + { + key: 'send_webhook_event', + name: 'Send Webhook Event', + attributeI18nKey: 'SEND_WEBHOOK_EVENT', + }, ], }, conversation_created: { @@ -120,15 +145,40 @@ export const AUTOMATIONS = { name: 'Assign a team', attributeI18nKey: 'ASSIGN_TEAM', }, + { + key: 'assign_agent', + name: 'Assign an agent', + attributeI18nKey: 'ASSIGN_AGENT', + }, // { // key: 'send_email_to_team', // name: 'Send an email to team', // attributeI18nKey: 'SEND_MESSAGE', // }, { - key: 'assign_agent', - name: 'Assign an agent', - attributeI18nKey: 'ASSIGN_AGENT', + key: 'send_email_transcript', + name: 'Send an email transcript', + attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT', + }, + { + key: 'mute_conversation', + name: 'Mute conversation', + attributeI18nKey: 'MUTE_CONVERSATION', + }, + { + key: 'snooze_convresation', + name: 'Snooze conversation', + attributeI18nKey: 'MUTE_CONVERSATION', + }, + { + key: 'resolve_convresation', + name: 'Resolve conversation', + attributeI18nKey: 'RESOLVE_CONVERSATION', + }, + { + key: 'send_webhook_event', + name: 'Send Webhook Event', + attributeI18nKey: 'SEND_WEBHOOK_EVENT', }, ], }, @@ -183,16 +233,40 @@ export const AUTOMATIONS = { name: 'Assign a team', attributeI18nKey: 'ASSIGN_TEAM', }, - // { - // key: 'send_email_to_team', - // name: 'Send an email to team', - // attributeI18nKey: 'SEND_EMAIL_TO_TEAM', - // }, { key: 'assign_agent', name: 'Assign an agent', attributeI18nKey: 'ASSIGN_AGENT', - attributeKey: 'assignee_id', + }, + // { + // key: 'send_email_to_team', + // name: 'Send an email to team', + // attributeI18nKey: 'SEND_MESSAGE', + // }, + { + key: 'send_email_transcript', + name: 'Send an email transcript', + attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT', + }, + { + key: 'mute_conversation', + name: 'Mute conversation', + attributeI18nKey: 'MUTE_CONVERSATION', + }, + { + key: 'snooze_convresation', + name: 'Snooze conversation', + attributeI18nKey: 'MUTE_CONVERSATION', + }, + { + key: 'resolve_convresation', + name: 'Resolve conversation', + attributeI18nKey: 'RESOLVE_CONVERSATION', + }, + { + key: 'send_webhook_event', + name: 'Send Webhook Event', + attributeI18nKey: 'SEND_WEBHOOK_EVENT', }, ], }, @@ -217,13 +291,41 @@ export const AUTOMATION_ACTION_TYPES = [ { key: 'assign_team', label: 'Assign a team', + inputType: 'multi_select', }, { key: 'add_label', label: 'Add a label', + inputType: 'multi_select', }, // { // key: 'send_email_to_team', // label: 'Send an email to team', + // inputType: 'multi_select', // }, + { + key: 'send_email_transcript', + label: 'Send an email transcript', + inputType: 'email', + }, + { + key: 'mute_conversation', + label: 'Mute conversation', + inputType: null, + }, + { + key: 'snooze_convresation', + label: 'Snooze conversation', + inputType: null, + }, + { + key: 'resolve_convresation', + label: 'Resolve conversation', + inputType: null, + }, + { + key: 'send_webhook_event', + label: 'Send Webhook Event', + inputType: 'url', + }, ]; diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue index fe0240d74..1b46c474a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue @@ -22,6 +22,7 @@ :key="metric.NAME" :desc="metric.DESC" :heading="metric.NAME" + :info-text="displayInfoText(metric.KEY)" :index="index" :on-click="changeSelection" :point="displayMetric(metric.KEY)" @@ -35,7 +36,11 @@ :message="$t('REPORT.LOADING_CHART')" />
- + {{ $t('REPORT.NO_ENOUGH_DATA') }} @@ -49,7 +54,7 @@ import { mapGetters } from 'vuex'; import fromUnixTime from 'date-fns/fromUnixTime'; import format from 'date-fns/format'; import ReportFilterSelector from './components/FilterSelector'; -import { GROUP_BY_FILTER } from './constants'; +import { GROUP_BY_FILTER, METRIC_CHART } from './constants'; import reportMixin from '../../../../mixins/reportMixin'; const REPORTS_KEYS = { @@ -108,16 +113,38 @@ export default { } return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy'); }); - const data = this.accountReport.data.map(element => element.value); + + const datasets = METRIC_CHART[ + this.metrics[this.currentSelection].KEY + ].datasets.map(dataset => { + switch (dataset.type) { + case 'bar': + return { + ...dataset, + yAxisID: 'y-left', + label: this.metrics[this.currentSelection].NAME, + data: this.accountReport.data.map(element => element.value), + }; + case 'line': + return { + ...dataset, + yAxisID: 'y-right', + label: this.metrics[0].NAME, + data: this.accountReport.data.map(element => element.count), + }; + default: + return dataset; + } + }); + return { labels, - datasets: [ - { - label: this.metrics[this.currentSelection].NAME, - backgroundColor: '#1f93ff', - data, - }, - ], + datasets, + }; + }, + chartOptions() { + return { + scales: METRIC_CHART[this.metrics[this.currentSelection].KEY].scales, }; }, metrics() { @@ -133,6 +160,7 @@ export default { NAME: this.$t(`REPORT.METRICS.${key}.NAME`), KEY: REPORTS_KEYS[key], DESC: this.$t(`REPORT.METRICS.${key}.DESC`), + INFO_TEXT: this.$t(`REPORT.METRICS.${key}.INFO_TEXT`), })); }, }, 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 8ce264080..c5cf3993b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue @@ -25,6 +25,7 @@ :key="metric.NAME" :desc="metric.DESC" :heading="metric.NAME" + :info-text="displayInfoText(metric.KEY)" :index="index" :on-click="changeSelection" :point="displayMetric(metric.KEY)" @@ -41,6 +42,7 @@ {{ $t('REPORT.NO_ENOUGH_DATA') }} @@ -55,7 +57,7 @@ import ReportFilters from './ReportFilters'; import fromUnixTime from 'date-fns/fromUnixTime'; import format from 'date-fns/format'; -import { GROUP_BY_FILTER } from '../constants'; +import { GROUP_BY_FILTER, METRIC_CHART } from '../constants'; import reportMixin from '../../../../../mixins/reportMixin'; const REPORTS_KEYS = { @@ -137,16 +139,38 @@ export default { } return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy'); }); - const data = this.accountReport.data.map(element => element.value); + + const datasets = METRIC_CHART[ + this.metrics[this.currentSelection].KEY + ].datasets.map(dataset => { + switch (dataset.type) { + case 'bar': + return { + ...dataset, + yAxisID: 'y-left', + label: this.metrics[this.currentSelection].NAME, + data: this.accountReport.data.map(element => element.value), + }; + case 'line': + return { + ...dataset, + yAxisID: 'y-right', + label: this.metrics[0].NAME, + data: this.accountReport.data.map(element => element.count), + }; + default: + return dataset; + } + }); + return { labels, - datasets: [ - { - label: this.metrics[this.currentSelection].NAME, - backgroundColor: '#1f93ff', - data, - }, - ], + datasets, + }; + }, + chartOptions() { + return { + scales: METRIC_CHART[this.metrics[this.currentSelection].KEY].scales, }; }, metrics() { @@ -168,6 +192,7 @@ export default { NAME: this.$t(`REPORT.METRICS.${key}.NAME`), KEY: REPORTS_KEYS[key], DESC: this.$t(`REPORT.METRICS.${key}.DESC`), + INFO_TEXT: this.$t(`REPORT.METRICS.${key}.INFO_TEXT`), })); }, }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js b/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js index f285061cb..91348543e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js @@ -4,3 +4,142 @@ export const GROUP_BY_FILTER = { 3: { id: 3, period: 'month' }, 4: { id: 4, period: 'year' }, }; + +export const CHART_FONT_FAMILY = + '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; + +export const DEFAULT_LINE_CHART = { + type: 'line', + fill: false, + borderColor: '#779BBB', + pointBackgroundColor: '#779BBB', +}; + +export const DEFAULT_BAR_CHART = { + type: 'bar', + backgroundColor: 'rgb(31, 147, 255, 0.5)', +}; + +export const DEFAULT_CHART = { + datasets: [DEFAULT_BAR_CHART], + scales: { + xAxes: [ + { + ticks: { + fontFamily: CHART_FONT_FAMILY, + }, + gridLines: { + drawOnChartArea: false, + }, + }, + ], + yAxes: [ + { + id: 'y-left', + type: 'linear', + position: 'left', + ticks: { + fontFamily: CHART_FONT_FAMILY, + beginAtZero: true, + stepSize: 1, + }, + gridLines: { + drawOnChartArea: false, + }, + }, + ], + }, +}; + +export const METRIC_CHART = { + conversations_count: DEFAULT_CHART, + incoming_messages_count: DEFAULT_CHART, + outgoing_messages_count: DEFAULT_CHART, + avg_first_response_time: { + datasets: [DEFAULT_BAR_CHART, DEFAULT_LINE_CHART], + scales: { + xAxes: [ + { + ticks: { + fontFamily: CHART_FONT_FAMILY, + }, + gridLines: { + drawOnChartArea: false, + }, + }, + ], + yAxes: [ + { + id: 'y-left', + type: 'linear', + position: 'left', + ticks: { + fontFamily: CHART_FONT_FAMILY, + beginAtZero: true, + precision: 2, + }, + gridLines: { + drawOnChartArea: false, + }, + }, + { + id: 'y-right', + type: 'linear', + position: 'right', + ticks: { + fontFamily: CHART_FONT_FAMILY, + beginAtZero: true, + stepSize: 1, + }, + gridLines: { + drawOnChartArea: false, + }, + }, + ], + }, + }, + avg_resolution_time: { + datasets: [DEFAULT_BAR_CHART, DEFAULT_LINE_CHART], + scales: { + xAxes: [ + { + ticks: { + fontFamily: CHART_FONT_FAMILY, + }, + gridLines: { + drawOnChartArea: false, + }, + }, + ], + yAxes: [ + { + id: 'y-left', + type: 'linear', + position: 'left', + ticks: { + fontFamily: CHART_FONT_FAMILY, + beginAtZero: true, + precision: 2, + }, + gridLines: { + drawOnChartArea: false, + }, + }, + { + id: 'y-right', + type: 'linear', + position: 'right', + ticks: { + fontFamily: CHART_FONT_FAMILY, + beginAtZero: true, + stepSize: 1, + }, + gridLines: { + drawOnChartArea: false, + }, + }, + ], + }, + }, + resolutions_count: DEFAULT_CHART, +}; diff --git a/app/javascript/dashboard/store/modules/notifications/actions.js b/app/javascript/dashboard/store/modules/notifications/actions.js index 8bc4fdfcd..d28020b37 100644 --- a/app/javascript/dashboard/store/modules/notifications/actions.js +++ b/app/javascript/dashboard/store/modules/notifications/actions.js @@ -52,4 +52,8 @@ export const actions = { throw new Error(error); } }, + + addNotification({ commit }, data) { + commit(types.ADD_NOTIFICATION, data); + }, }; diff --git a/app/javascript/dashboard/store/modules/notifications/mutations.js b/app/javascript/dashboard/store/modules/notifications/mutations.js index ca896835f..96e6816a6 100644 --- a/app/javascript/dashboard/store/modules/notifications/mutations.js +++ b/app/javascript/dashboard/store/modules/notifications/mutations.js @@ -45,4 +45,14 @@ export const mutations = { Vue.set($state.records[item.id], 'read_at', true); }); }, + + [types.ADD_NOTIFICATION]($state, data) { + const { notification, unread_count: unreadCount, count } = data; + Vue.set($state.records, notification.id, { + ...($state.records[notification.id] || {}), + ...notification, + }); + Vue.set($state.meta, 'unreadCount', unreadCount); + Vue.set($state.meta, 'count', count); + }, }; diff --git a/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js b/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js index fd94d9a67..7351905a0 100644 --- a/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js @@ -90,4 +90,12 @@ describe('#actions', () => { await expect(actions.readAll({ commit })).rejects.toThrow(Error); }); }); + describe('#addNotification', () => { + it('sends correct actions if API is success', async () => { + await actions.addNotification({ commit }, { data: 1 }); + expect(commit.mock.calls).toEqual([ + [types.ADD_NOTIFICATION, { data: 1 }], + ]); + }); + }); }); diff --git a/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js index 15a097df7..ab8027c6e 100644 --- a/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js @@ -93,4 +93,29 @@ describe('#mutations', () => { }); }); }); + + describe('#ADD_NOTIFICATION', () => { + it('add notification', () => { + const state = { + meta: { unreadCount: 4, count: 231 }, + records: { + 1: { id: 1, primary_actor_id: 1 }, + 2: { id: 2, primary_actor_id: 2 }, + }, + }; + const data = { + notification: { id: 3, primary_actor_id: 3 }, + unread_count: 5, + count: 232, + }; + mutations[types.ADD_NOTIFICATION](state, data); + expect(state.records).toEqual({ + 1: { id: 1, primary_actor_id: 1 }, + 2: { id: 2, primary_actor_id: 2 }, + 3: { id: 3, primary_actor_id: 3 }, + }); + expect(state.meta.unreadCount).toEqual(5); + expect(state.meta.count).toEqual(232); + }); + }); }); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 734e0b5e5..9a24fa3b4 100755 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -116,6 +116,7 @@ export default { SET_NOTIFICATIONS_UNREAD_COUNT: 'SET_NOTIFICATIONS_UNREAD_COUNT', SET_NOTIFICATIONS_UI_FLAG: 'SET_NOTIFICATIONS_UI_FLAG', UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION', + ADD_NOTIFICATION: 'ADD_NOTIFICATION', UPDATE_ALL_NOTIFICATIONS: 'UPDATE_ALL_NOTIFICATIONS', SET_NOTIFICATIONS_ITEM: 'SET_NOTIFICATIONS_ITEM', SET_NOTIFICATIONS: 'SET_NOTIFICATIONS', diff --git a/app/javascript/packs/sdk.js b/app/javascript/packs/sdk.js index 66f6ba790..76e1a2cd8 100755 --- a/app/javascript/packs/sdk.js +++ b/app/javascript/packs/sdk.js @@ -31,6 +31,14 @@ const runSDK = ({ baseUrl, websiteToken }) => { IFrameHelper.events.toggleBubble(state); }, + popoutChatWindow() { + IFrameHelper.events.popoutChatWindow({ + baseUrl: window.$chatwoot.baseUrl, + websiteToken: window.$chatwoot.websiteToken, + locale: window.$chatwoot.locale, + }); + }, + setUser(identifier, user) { if (typeof identifier !== 'string' && typeof identifier !== 'number') { throw new Error('Identifier should be a string or a number'); diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js index ba495bbc8..df36d8e0d 100644 --- a/app/javascript/sdk/IFrameHelper.js +++ b/app/javascript/sdk/IFrameHelper.js @@ -31,6 +31,7 @@ import { initOnEvents, } from 'shared/helpers/AudioNotificationHelper'; import { isFlatWidgetStyle } from './settingsHelper'; +import { popoutChatWindow } from '../widget/helpers/popoutHelper'; export const IFrameHelper = { getUrl({ baseUrl, websiteToken }) { @@ -190,6 +191,12 @@ export const IFrameHelper = { onBubbleClick(bubbleState); }, + popoutChatWindow: ({ baseUrl, websiteToken, locale }) => { + const cwCookie = Cookies.get('cw_conversation'); + window.$chatwoot.toggle('close'); + popoutChatWindow(baseUrl, websiteToken, locale, cwCookie); + }, + closeWindow: () => { onBubbleClick({ toggleValue: false }); removeUnreadClass(); diff --git a/app/javascript/widget/components/HeaderActions.vue b/app/javascript/widget/components/HeaderActions.vue index 4d6e5faa9..1090d54b0 100644 --- a/app/javascript/widget/components/HeaderActions.vue +++ b/app/javascript/widget/components/HeaderActions.vue @@ -29,7 +29,7 @@ diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 87deded55..adefee312 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -91,7 +91,7 @@ end # Log blocked events ActiveSupport::Notifications.subscribe('throttle.rack_attack') do |_name, _start, _finish, _request_id, payload| - Rails.logger.info "[Rack::Attack][Blocked] remote_ip: \"#{payload[:request].remote_ip}\", path: \"#{payload[:request].path}\"" + Rails.logger.warn "[Rack::Attack][Blocked] remote_ip: \"#{payload[:request].remote_ip}\", path: \"#{payload[:request].path}\"" end Rack::Attack.enabled = Rails.env.production? ? ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) : false diff --git a/config/routes.rb b/config/routes.rb index 6e4296e62..a2afa4f09 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -52,8 +52,8 @@ Rails.application.routes.draw do post :reauthorize_page end end - resources :canned_responses, except: [:show, :edit, :new] - resources :automation_rules, except: [:edit] do + resources :canned_responses, only: [:index, :create, :update, :destroy] + resources :automation_rules, only: [:index, :create, :show, :update, :destroy] do post :clone end resources :campaigns, only: [:index, :create, :show, :update, :destroy] @@ -145,7 +145,7 @@ Rails.application.routes.draw do resource :authorization, only: [:create] end - resources :webhooks, except: [:show] + resources :webhooks, only: [:index, :create, :update, :destroy] namespace :integrations do resources :apps, only: [:index, :show] resources :hooks, only: [:create, :update, :destroy] @@ -212,6 +212,7 @@ Rails.application.routes.draw do get :inboxes get :labels get :teams + get :conversations end end end diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 2c6d89933..e83ddf8f0 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -18,6 +18,7 @@ - [bots, 1] - [active_storage_analysis, 1] - [action_mailbox_incineration, 1] + - [active_storage_purge, 1] - [integrations, 2] - [default, 2] - [mailers, 2] diff --git a/lib/chatwoot_hub.rb b/lib/chatwoot_hub.rb index c8ef38de0..655060c7e 100644 --- a/lib/chatwoot_hub.rb +++ b/lib/chatwoot_hub.rb @@ -39,7 +39,7 @@ class ChatwootHub response = RestClient.post(PING_URL, info.to_json, { content_type: :json, accept: :json }) version = JSON.parse(response)['version'] rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e - Rails.logger.info "Exception: #{e.message}" + Rails.logger.error "Exception: #{e.message}" rescue StandardError => e Sentry.capture_exception(e) end @@ -50,7 +50,7 @@ class ChatwootHub info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true } RestClient.post(REGISTRATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e - Rails.logger.info "Exception: #{e.message}" + Rails.logger.error "Exception: #{e.message}" rescue StandardError => e Sentry.capture_exception(e) end @@ -59,7 +59,7 @@ class ChatwootHub info = { fcm_token_list: fcm_token_list, fcm_options: fcm_options } RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e - Rails.logger.info "Exception: #{e.message}" + Rails.logger.error "Exception: #{e.message}" rescue StandardError => e Sentry.capture_exception(e) end @@ -70,7 +70,7 @@ class ChatwootHub info = { event_name: event_name, event_data: event_data } RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e - Rails.logger.info "Exception: #{e.message}" + Rails.logger.error "Exception: #{e.message}" rescue StandardError => e Sentry.capture_exception(e) end diff --git a/lib/filters/filter_keys.json b/lib/filters/filter_keys.json index 2736473b7..3d662d19b 100644 --- a/lib/filters/filter_keys.json +++ b/lib/filters/filter_keys.json @@ -142,6 +142,13 @@ "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], "attribute_type": "additional_attributes" }, + "company": { + "attribute_name": "Company", + "input_type": "textbox", + "data_type": "text", + "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], + "attribute_type": "additional_attributes" + }, "labels": { "attribute_name": "Labels", "input_type": "tags", diff --git a/lib/integrations/slack/send_on_slack_service.rb b/lib/integrations/slack/send_on_slack_service.rb index 6f780ae38..1eb91b3ea 100644 --- a/lib/integrations/slack/send_on_slack_service.rb +++ b/lib/integrations/slack/send_on_slack_service.rb @@ -48,7 +48,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService post_message if message_content.present? upload_file if message.attachments.any? rescue Slack::Web::Api::Errors::AccountInactive => e - Rails.logger.info e + Rails.logger.error e hook.authorization_error! hook.disable if hook.enabled? end diff --git a/spec/controllers/api/v1/accounts/automation_rules_controller_spec.rb b/spec/controllers/api/v1/accounts/automation_rules_controller_spec.rb index 5755bf95a..0d3b3e784 100644 --- a/spec/controllers/api/v1/accounts/automation_rules_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/automation_rules_controller_spec.rb @@ -84,6 +84,23 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do }.with_indifferent_access end + it 'throws an error for unknown attributes in condtions' do + expect(account.automation_rules.count).to eq(0) + params[:conditions] << { + attribute_key: 'unknown_attribute', + filter_operator: 'equal_to', + values: ['en'], + query_operator: 'AND' + } + + post "/api/v1/accounts/#{account.id}/automation_rules", + headers: administrator.create_new_auth_token, + params: params + + expect(response).to have_http_status(:unprocessable_entity) + expect(account.automation_rules.count).to eq(0) + end + it 'Saves for automation_rules for account with country_code and browser_language conditions' do expect(account.automation_rules.count).to eq(0) @@ -113,6 +130,67 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do expect(response).to have_http_status(:success) expect(account.automation_rules.count).to eq(1) end + + it 'Saves file in the automation actions to send an attachments' do + file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') + params[:attachments] = [file] + params[:actions] = [ + { + action_name: :send_message, + action_params: ['Welcome to the chatwoot platform.'] + }, + { + action_name: :update_additional_attributes, + action_params: [{ intiated_at: '2021-12-03 17:25:26.844536 +0530' }] + }, + { + action_name: :send_attachments + } + ] + + expect(account.automation_rules.count).to eq(0) + + post "/api/v1/accounts/#{account.id}/automation_rules", + headers: administrator.create_new_auth_token, + params: params + + expect(response).to have_http_status(:success) + expect(account.automation_rules.count).to eq(1) + automation_rule = account.automation_rules.first + expect(automation_rule.files.presence).to be_truthy + expect(automation_rule.files.count).to eq(1) + end + + it 'Saves files in the automation actions to send multiple attachments' do + file_1 = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') + file_2 = fixture_file_upload(Rails.root.join('spec/assets/sample.pdf'), 'image/png') + params[:attachments] = [file_1, file_2] + params[:actions] = [ + { + action_name: :send_message, + action_params: ['Welcome to the chatwoot platform.'] + }, + { + action_name: :update_additional_attributes, + action_params: [{ intiated_at: '2021-12-03 17:25:26.844536 +0530' }] + }, + { + action_name: :send_attachments + } + ] + + expect(account.automation_rules.count).to eq(0) + + post "/api/v1/accounts/#{account.id}/automation_rules", + headers: administrator.create_new_auth_token, + params: params + + expect(response).to have_http_status(:success) + expect(account.automation_rules.count).to eq(1) + automation_rule = account.automation_rules.first + expect(automation_rule.files.presence).to be_truthy + expect(automation_rule.files.count).to eq(2) + end end end diff --git a/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb index 5d65c70ca..6583eaf0e 100644 --- a/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb @@ -35,6 +35,9 @@ RSpec.describe 'Conversation Assignment API', type: :request do end it 'assigns a team to the conversation' do + team_member = create(:user, account: account, role: :agent) + create(:inbox_member, inbox: conversation.inbox, user: team_member) + create(:team_member, team: team, user: team_member) params = { team_id: team.id } post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id), @@ -44,6 +47,8 @@ RSpec.describe 'Conversation Assignment API', type: :request do expect(response).to have_http_status(:success) expect(conversation.reload.team).to eq(team) + # assignee will be from team + expect(conversation.reload.assignee).to eq(team_member) end end diff --git a/spec/controllers/api/v2/accounts/report_controller_spec.rb b/spec/controllers/api/v2/accounts/report_controller_spec.rb index 7fbc3ed18..cb2b9b72d 100644 --- a/spec/controllers/api/v2/accounts/report_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/report_controller_spec.rb @@ -7,7 +7,8 @@ RSpec.describe 'Reports API', type: :request do let!(:user) { create(:user, account: account) } let!(:inbox) { create(:inbox, account: account) } let(:inbox_member) { create(:inbox_member, user: user, inbox: inbox) } - let(:date_timestamp) { Time.current.beginning_of_day.to_i } + let(:default_timezone) { ActiveSupport::TimeZone[0]&.name } + let(:date_timestamp) { Time.current.in_time_zone(default_timezone).beginning_of_day.to_i } let(:params) { { timezone_offset: Time.zone.utc_offset } } before do @@ -56,6 +57,68 @@ RSpec.describe 'Reports API', type: :request do expect(current_day_metric.length).to eq(1) expect(current_day_metric[0]['value']).to eq(10) end + + it 'return conversation metrics in account level' do + unassigned_conversation = create(:conversation, account: account, inbox: inbox, + assignee: nil, created_at: Time.zone.today) + unassigned_conversation.save! + + get "/api/v2/accounts/#{account.id}/reports/conversations", + params: { + type: :account + }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + + expect(json_response['open']).to eq(11) + expect(json_response['unattended']).to eq(11) + expect(json_response['unassigned']).to eq(1) + end + + it 'return conversation metrics for user in account level' do + create_list(:conversation, 2, account: account, inbox: inbox, + assignee: admin, created_at: Time.zone.today) + + get "/api/v2/accounts/#{account.id}/reports/conversations", + params: { + type: :agent + }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response.blank?).to be false + user_metrics = json_response.find { |item| item['user']['name'] == admin[:name] } + expect(user_metrics.present?).to be true + + expect(user_metrics['metric']['open']).to eq(2) + expect(user_metrics['metric']['unattended']).to eq(2) + end + + it 'return conversation metrics for specific user in account level' do + create_list(:conversation, 2, account: account, inbox: inbox, + assignee: admin, created_at: Time.zone.today) + + get "/api/v2/accounts/#{account.id}/reports/conversations", + params: { + type: :agent, + user_id: user.id + }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response.blank?).to be false + expect(json_response[0]['metric']['open']).to eq(10) + expect(json_response[0]['metric']['unattended']).to eq(10) + end end end diff --git a/spec/cypress/app_commands/log_fail.rb b/spec/cypress/app_commands/log_fail.rb index 8c64be394..264d9ea66 100644 --- a/spec/cypress/app_commands/log_fail.rb +++ b/spec/cypress/app_commands/log_fail.rb @@ -13,7 +13,7 @@ if defined?(ActiveRecord::Base) ActiveRecord::Base.descendants.each_with_object({}) do |record_class, records| records[record_class.to_s] = record_class.limit(100).map(&:attributes) rescue StandardError => e - Rails.logger.info e.message + Rails.logger.error e.message end end diff --git a/spec/listeners/automation_rule_listener_spec.rb b/spec/listeners/automation_rule_listener_spec.rb index ff289a6dd..6c4fc0c2a 100644 --- a/spec/listeners/automation_rule_listener_spec.rb +++ b/spec/listeners/automation_rule_listener_spec.rb @@ -15,6 +15,12 @@ describe AutomationRuleListener do end before do + create(:custom_attribute_definition, + attribute_key: 'customer_type', + account: account, + attribute_model: 'contact_attribute', + attribute_display_type: 'list', + attribute_values: %w[regular platinum gold]) create(:team_member, user: user_1, team: team) create(:team_member, user: user_2, team: team) create(:account_user, user: user_2, account: account) @@ -31,13 +37,17 @@ describe AutomationRuleListener do }, { 'action_name' => 'assign_team', 'action_params' => [team.id] }, { 'action_name' => 'add_label', 'action_params' => %w[support priority_customer] }, - { 'action_name' => 'send_webhook_events', 'action_params' => 'https://www.example.com' }, + { 'action_name' => 'send_webhook_event', 'action_params' => ['https://www.example.com'] }, { 'action_name' => 'assign_best_agent', 'action_params' => [user_1.id] }, { 'action_name' => 'send_email_transcript', 'action_params' => 'new_agent@example.com' }, { 'action_name' => 'mute_conversation', 'action_params' => nil }, { 'action_name' => 'change_status', 'action_params' => ['snoozed'] }, - { 'action_name' => 'send_message', 'action_params' => ['Send this message.'] } + { 'action_name' => 'send_message', 'action_params' => ['Send this message.'] }, + { 'action_name' => 'send_attachments' } ]) + file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') + automation_rule.files.attach(file) + automation_rule.save end describe '#conversation_status_changed' do @@ -77,6 +87,7 @@ describe AutomationRuleListener do listener.conversation_status_changed(event) conversation.reload + expect(conversation.labels.pluck(:name)).to contain_exactly('support', 'priority_customer') end @@ -105,7 +116,7 @@ describe AutomationRuleListener do conversation.reload - expect(conversation.messages.last.content).to eq('Send this message.') + expect(conversation.messages.first.content).to eq('Send this message.') end it 'triggers automation rule changes status to snoozed' do @@ -143,6 +154,120 @@ describe AutomationRuleListener do listener.conversation_status_changed(event) end + + it 'triggers automation rule send attachments in messages' do + automation_rule + + expect(TeamNotifications::AutomationNotificationMailer).to receive(:conversation_creation) + + listener.conversation_status_changed(event) + + conversation.reload + + expect(conversation.messages.last.attachments.count).to eq(1) + end + end + end + + describe '#conversation_updated with contacts attributes' do + before do + conversation.contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' }, + additional_attributes: { 'company': 'Marvel' }) + + automation_rule.update!( + event_name: 'conversation_updated', + name: 'Call actions conversation updated', + description: 'Add labels, assign team after conversation updated', + conditions: [ + { + attribute_key: 'company', + filter_operator: 'equal_to', + values: ['Marvel'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'customer_type', + filter_operator: 'equal_to', + values: ['platinum'], + query_operator: nil + }.with_indifferent_access + ] + ) + end + + let!(:event) do + Events::Base.new('conversation_updated', Time.zone.now, { conversation: conversation }) + end + + context 'when rule matches with additional_attributes and custom_attributes' do + it 'triggers automation rule to assign team' do + expect(conversation.team_id).not_to eq(team.id) + + listener.conversation_updated(event) + + conversation.reload + expect(conversation.team_id).to eq(team.id) + end + + it 'triggers automation rule to add label and assign best agents' do + expect(conversation.labels).to eq([]) + expect(conversation.assignee).to be_nil + + listener.conversation_updated(event) + + conversation.reload + expect(conversation.labels.pluck(:name)).to contain_exactly('support', 'priority_customer') + expect(conversation.assignee).to eq(user_1) + end + + it 'triggers automation rule send email transcript to the mentioned email' do + mailer = double + + expect(TeamNotifications::AutomationNotificationMailer).to receive(:conversation_updated) + + listener.conversation_updated(event) + + conversation.reload + + allow(mailer).to receive(:conversation_transcript) + end + + it 'triggers automation rule send message to the contacts' do + expect(conversation.messages).to be_empty + + expect(TeamNotifications::AutomationNotificationMailer).to receive(:conversation_updated) + + listener.conversation_updated(event) + + conversation.reload + + expect(conversation.messages.first.content).to eq('Send this message.') + end + + it 'triggers automation_rule with contact standard attributes' do + automation_rule.update!( + conditions: [ + { + attribute_key: 'email', + filter_operator: 'contains', + values: ['example.com'], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'customer_type', + filter_operator: 'equal_to', + values: ['platinum'], + query_operator: nil + }.with_indifferent_access + ] + ) + expect(conversation.team_id).not_to eq(team.id) + + listener.conversation_updated(event) + + conversation.reload + expect(conversation.team_id).to eq(team.id) + end end end @@ -224,7 +349,7 @@ describe AutomationRuleListener do conversation.reload - expect(conversation.messages.last.content).to eq('Send this message.') + expect(conversation.messages.first.content).to eq('Send this message.') end end end diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb index 0bebc8d15..bcc990c5b 100644 --- a/spec/listeners/webhook_listener_spec.rb +++ b/spec/listeners/webhook_listener_spec.rb @@ -105,4 +105,80 @@ describe WebhookListener do end end end + + describe '#conversation_resolved' do + let!(:conversation_resolved_event) do + Events::Base.new(event_name, Time.zone.now, conversation: conversation.reload, changed_attributes: { status: [:open, :resolved] }) + end + let(:event_name) { :'conversation.resolved' } + + context 'when webhook is not configured' do + it 'does not trigger webhook' do + expect(WebhookJob).to receive(:perform_later).exactly(0).times + listener.conversation_resolved(conversation_resolved_event) + end + end + + context 'when webhook is configured' do + it 'triggers webhook' do + webhook = create(:webhook, inbox: inbox, account: account) + + conversation.update(status: :resolved) + + expect(WebhookJob).to receive(:perform_later).with(webhook.url, + conversation.webhook_data.merge(event: 'conversation_resolved', + changed_attributes: [{ status: { + current_value: :resolved, previous_value: :open + } }])).once + + listener.conversation_resolved(conversation_resolved_event) + end + end + end + + describe '#conversation_updated' do + let(:custom_attributes) { { test: nil } } + let!(:conversation_updated_event) do + Events::Base.new( + event_name, Time.zone.now, + conversation: conversation.reload, + changed_attributes: { + custom_attributes: [{ test: nil }, { test: 'testing custom attri webhook' }] + } + ) + end + let(:event_name) { :'conversation.updated' } + + context 'when webhook is not configured' do + it 'does not trigger webhook' do + expect(WebhookJob).to receive(:perform_later).exactly(0).times + listener.conversation_updated(conversation_updated_event) + end + end + + context 'when webhook is configured' do + it 'triggers webhook' do + webhook = create(:webhook, inbox: inbox, account: account) + + conversation.update(custom_attributes: { test: 'testing custom attri webhook' }) + + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, + conversation.webhook_data.merge( + event: 'conversation_updated', + changed_attributes: [ + { + custom_attributes: { + previous_value: { test: nil }, + current_value: { test: 'testing custom attri webhook' } + } + } + ] + ) + ).once + + listener.conversation_updated(conversation_updated_event) + end + end + end end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index a1d67f7d0..182073fbc 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -54,7 +54,8 @@ RSpec.describe Conversation, type: :model do it 'runs after_create callbacks' do # send_events expect(Rails.configuration.dispatcher).to have_received(:dispatch) - .with(described_class::CONVERSATION_CREATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: false) + .with(described_class::CONVERSATION_CREATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: false, + changed_attributes: nil) end end @@ -115,14 +116,21 @@ RSpec.describe Conversation, type: :model do assignee: new_assignee, label_list: [label.title] ) + status_change = conversation.status_change + changed_attributes = conversation.previous_changes + expect(Rails.configuration.dispatcher).to have_received(:dispatch) - .with(described_class::CONVERSATION_RESOLVED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true) + .with(described_class::CONVERSATION_RESOLVED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true, + changed_attributes: status_change) expect(Rails.configuration.dispatcher).to have_received(:dispatch) - .with(described_class::CONVERSATION_READ, kind_of(Time), conversation: conversation, notifiable_assignee_change: true) + .with(described_class::CONVERSATION_READ, kind_of(Time), conversation: conversation, notifiable_assignee_change: true, + changed_attributes: nil) expect(Rails.configuration.dispatcher).to have_received(:dispatch) - .with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true) + .with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true, + changed_attributes: nil) expect(Rails.configuration.dispatcher).to have_received(:dispatch) - .with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true) + .with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true, + changed_attributes: changed_attributes) end it 'will not run conversation_updated event for empty updates' do diff --git a/spec/services/round_robin/manage_service_spec.rb b/spec/services/round_robin/manage_service_spec.rb index 0deef3109..75110d325 100644 --- a/spec/services/round_robin/manage_service_spec.rb +++ b/spec/services/round_robin/manage_service_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe RoundRobin::ManageService do - subject(:round_robin_manage_service) { ::RoundRobin::ManageService.new(inbox: inbox) } + subject(:round_robin_manage_service) { described_class.new(inbox: inbox) } let!(:account) { create(:account) } let!(:inbox) { create(:inbox, account: account) } @@ -18,8 +18,9 @@ describe RoundRobin::ManageService do it 'gets intersection of priority list and agent queue. get and move agent to the end of the list' do expected_queue = [inbox_members[2].user_id, inbox_members[4].user_id, inbox_members[3].user_id, inbox_members[1].user_id, inbox_members[0].user_id].map(&:to_s) - expect(round_robin_manage_service.available_agent(priority_list: [inbox_members[3].user_id, - inbox_members[2].user_id])).to eq inbox_members[2].user + # prority list will be ids in string, since thats what redis supplies to us + expect(round_robin_manage_service.available_agent(priority_list: [inbox_members[3].user_id.to_s, + inbox_members[2].user_id.to_s])).to eq inbox_members[2].user expect(round_robin_manage_service.send(:queue)).to eq(expected_queue) end @@ -39,5 +40,32 @@ describe RoundRobin::ManageService do # the service have refreshed the redis queue before performing expect(round_robin_manage_service.send(:queue).sort.map(&:to_i)).to eq(inbox_members.map(&:user_id).sort) end + + context 'when allowed_member_ids is passed' do + it 'will get the first allowed member and move it to the end of the queue' do + expected_queue = [inbox_members[3].user_id, inbox_members[2].user_id, inbox_members[4].user_id, inbox_members[1].user_id, + inbox_members[0].user_id].map(&:to_s) + expect(described_class.new(inbox: inbox, + allowed_member_ids: [inbox_members[3].user_id, + inbox_members[2].user_id]).available_agent).to eq inbox_members[2].user + expect(described_class.new(inbox: inbox, + allowed_member_ids: [inbox_members[3].user_id, + inbox_members[2].user_id]).available_agent).to eq inbox_members[3].user + expect(round_robin_manage_service.send(:queue)).to eq(expected_queue) + end + + it 'will get union of priority list and allowed_member_ids and move it to the end of the queue' do + expected_queue = [inbox_members[3].user_id, inbox_members[4].user_id, inbox_members[2].user_id, inbox_members[1].user_id, + inbox_members[0].user_id].map(&:to_s) + # prority list will be ids in string, since thats what redis supplies to us + expect(described_class.new(inbox: inbox, + allowed_member_ids: [inbox_members[3].user_id, + inbox_members[2].user_id]) + .available_agent( + priority_list: [inbox_members[3].user_id.to_s] + )).to eq inbox_members[3].user + expect(round_robin_manage_service.send(:queue)).to eq(expected_queue) + end + end end end diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml index fb24800de..c120320c8 100644 --- a/swagger/definitions/index.yml +++ b/swagger/definitions/index.yml @@ -12,6 +12,8 @@ request_error: generic_id: $ref: ./resource/extension/generic.yml +canned_response: + $ref: ./resource/canned_response.yml contact: $ref: ./resource/contact.yml conversation: @@ -64,6 +66,9 @@ agent_bot_create_update_payload: user_create_update_payload: $ref: ./request/user/create_update_payload.yml +canned_response_create_update_payload: + $ref: ./request/canned_response/create_update_payload.yml + ## contact contact_create: $ref: ./request/contact/create.yml @@ -144,10 +149,11 @@ extended_message: - $ref: ./resource/extension/message/with_source_sender.yml -## report list -report: - type: array - description: 'array of conversation count based on date' - items: - allOf: - - $ref: './resource/report.yml' +## report +account_summary: + $ref: './resource/reports/summary.yml' +agent_conversation_metrics: + $ref: './resource/reports/conversation/agent.yml' + + + diff --git a/swagger/definitions/request/canned_response/create_update_payload.yml b/swagger/definitions/request/canned_response/create_update_payload.yml new file mode 100644 index 000000000..ef6f7f74a --- /dev/null +++ b/swagger/definitions/request/canned_response/create_update_payload.yml @@ -0,0 +1,8 @@ +type: object +properties: + content: + type: string + description: Message content for canned response + short_code: + type: string + description: Short Code for quick access of the canned response diff --git a/swagger/definitions/resource/canned_response.yml b/swagger/definitions/resource/canned_response.yml new file mode 100644 index 000000000..b044b703a --- /dev/null +++ b/swagger/definitions/resource/canned_response.yml @@ -0,0 +1,14 @@ +type: object +properties: + id: + type: integer + description: ID of the canned response + content: + type: string + description: Message content for canned response + short_code: + type: string + description: Short Code for quick access of the canned response + account_id: + type: integer + description: Account Id diff --git a/swagger/definitions/resource/reports/conversation/agent.yml b/swagger/definitions/resource/reports/conversation/agent.yml new file mode 100644 index 000000000..7077b767b --- /dev/null +++ b/swagger/definitions/resource/reports/conversation/agent.yml @@ -0,0 +1,18 @@ +type: object +properties: + user: + type: object + properties: + id: + type: number + name: + type: string + thumbnail: + type: string + metric: + type: object + properties: + open: + type: number + unattended: + type: number \ No newline at end of file diff --git a/swagger/definitions/resource/report.yml b/swagger/definitions/resource/reports/summary.yml similarity index 100% rename from swagger/definitions/resource/report.yml rename to swagger/definitions/resource/reports/summary.yml diff --git a/swagger/index.yml b/swagger/index.yml index 2420658e3..28f20ce5a 100644 --- a/swagger/index.yml +++ b/swagger/index.yml @@ -54,6 +54,7 @@ x-tagGroups: tags: - Account AgentBots - Agent + - Canned Response - Contact - Conversation - Conversation Assignment diff --git a/swagger/paths/application/canned_responses/create.yml b/swagger/paths/application/canned_responses/create.yml new file mode 100644 index 000000000..69b3673bc --- /dev/null +++ b/swagger/paths/application/canned_responses/create.yml @@ -0,0 +1,21 @@ +tags: + - Canned Response +operationId: add-new-canned-response-to-account +summary: Add a New Canned Response +description: Add a new Canned Response to Account +security: + - userApiKey: [] +parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/canned_response_create_update_payload' +responses: + 200: + description: Success + schema: + description: 'Newly Created Canned Response' + $ref: '#/definitions/canned_response' + 403: + description: Access denied diff --git a/swagger/paths/application/canned_responses/delete.yml b/swagger/paths/application/canned_responses/delete.yml new file mode 100644 index 000000000..4b82e5068 --- /dev/null +++ b/swagger/paths/application/canned_responses/delete.yml @@ -0,0 +1,20 @@ +tags: + - Canned Response +operationId: delete-canned-response-from-account +summary: Remove a Canned Response from Account +description: Remove a Canned Response from Account +security: + - userApiKey: [] +parameters: + - in: path + name: id + type: integer + required: true + description: The ID of the canned response to be deleted +responses: + 200: + description: Success + 404: + description: Canned Response not found + 403: + description: Access denied diff --git a/swagger/paths/application/canned_responses/index.yml b/swagger/paths/application/canned_responses/index.yml new file mode 100644 index 000000000..4e4a24c5d --- /dev/null +++ b/swagger/paths/application/canned_responses/index.yml @@ -0,0 +1,17 @@ +tags: + - Canned Response +operationId: get-account-canned-response +summary: List all Canned Responses in an Account +description: Get Details of Canned Responses in an Account +security: + - userApiKey: [] +responses: + 200: + description: Success + schema: + type: array + description: 'Array of all canned responses' + items: + $ref: '#/definitions/canned_response' + 403: + description: Access denied diff --git a/swagger/paths/application/canned_responses/update.yml b/swagger/paths/application/canned_responses/update.yml new file mode 100644 index 000000000..7ea801f2f --- /dev/null +++ b/swagger/paths/application/canned_responses/update.yml @@ -0,0 +1,28 @@ +tags: + - Canned Response +operationId: update-canned-response-in-account +summary: Update Canned Response in Account +description: Update a Canned Response in Account +security: + - userApiKey: [] +parameters: + - in: path + name: id + type: integer + required: true + description: The ID of the canned response to be updated. + - name: data + in: body + required: true + schema: + $ref: '#/definitions/canned_response_create_update_payload' +responses: + 200: + description: Success + schema: + description: 'The updated canned response' + $ref: '#/definitions/canned_response' + 404: + description: Agent not found + 403: + description: Access denied diff --git a/swagger/paths/application/reports/conversation/account.yml b/swagger/paths/application/reports/conversation/account.yml new file mode 100644 index 000000000..28aac72e7 --- /dev/null +++ b/swagger/paths/application/reports/conversation/account.yml @@ -0,0 +1,23 @@ +tags: + - Reports +operationId: get-account-conversation-metrics +summary: Account Conversation Metrics +description: Get conversation metrics for Account +responses: + 200: + description: Success + schema: + type: object + description: 'Object of account conversation metrics' + properties: + open: + type: number + unattended: + type: number + unassigned: + type: number + + 404: + description: reports not found + 403: + description: Access denied diff --git a/swagger/paths/application/reports/conversation/agent.yml b/swagger/paths/application/reports/conversation/agent.yml new file mode 100644 index 000000000..90f433e4a --- /dev/null +++ b/swagger/paths/application/reports/conversation/agent.yml @@ -0,0 +1,18 @@ +tags: + - Reports +operationId: get-agent-conversation-metrics +summary: Agent Conversation Metrics +description: Get conversation metrics for Agent +responses: + 200: + description: Success + schema: + type: array + description: 'Array of agent based conversation metrics' + items: + $ref: '#/definitions/agent_conversation_metrics' + + 404: + description: reports not found + 403: + description: Access denied diff --git a/swagger/paths/application/reports/index.yml b/swagger/paths/application/reports/index.yml index dacf77a13..3d86df38d 100644 --- a/swagger/paths/application/reports/index.yml +++ b/swagger/paths/application/reports/index.yml @@ -10,7 +10,12 @@ responses: type: array description: 'Array of date based conversation statistics' items: - $ref: '#/definitions/report' + type: object + properties: + value: + type: string + timestamp: + type: number 404: description: reports not found 403: diff --git a/swagger/paths/application/reports/summary.yml b/swagger/paths/application/reports/summary.yml index ec659e8e8..f9538a9f8 100644 --- a/swagger/paths/application/reports/summary.yml +++ b/swagger/paths/application/reports/summary.yml @@ -7,10 +7,8 @@ responses: 200: description: Success schema: - type: array - description: 'Array of date based conversation statistics' - items: - $ref: '#/definitions/report' + description: 'Object of summary metrics' + $ref: '#/definitions/account_summary' 404: description: reports not found 403: diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 8a4154bb8..eeb2539f1 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -154,6 +154,21 @@ delete: $ref: ./application/agents/delete.yml +# Agents +/api/v1/accounts/{account_id}/canned_responses: + parameters: + - $ref: '#/parameters/account_id' + get: + $ref: ./application/canned_responses/index.yml + post: + $ref: ./application/canned_responses/create.yml +/api/v1/accounts/{account_id}/canned_responses/{id}: + parameters: + - $ref: '#/parameters/account_id' + patch: + $ref: ./application/canned_responses/update.yml + delete: + $ref: ./application/canned_responses/delete.yml # Contacts /api/v1/accounts/{account_id}/contacts: @@ -376,3 +391,35 @@ description: The timestamp from where report should stop. get: $ref: './application/reports/summary.yml' + +# Conversation metrics for account +/api/v2/accounts/{account_id}/reports/conversations: + parameters: + - $ref: '#/parameters/account_id' + - in: query + name: type + type: string + enum: + - account + required: true + description: Type of report + get: + $ref: './application/reports/conversation/account.yml' + +# Conversation metrics for agent +/api/v2/accounts/{account_id}/reports/conversations/: + parameters: + - $ref: '#/parameters/account_id' + - in: query + name: type + type: string + enum: + - agent + required: true + description: Type of report + - in: query + name: user_id + type: string + description: The numeric ID of the user + get: + $ref: './application/reports/conversation/agent.yml' \ No newline at end of file diff --git a/swagger/swagger.json b/swagger/swagger.json index 8e574fedc..b17f9c586 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1333,6 +1333,167 @@ } } }, + "/api/v1/accounts/{account_id}/canned_responses": { + "parameters": [ + { + "$ref": "#/parameters/account_id" + } + ], + "get": { + "tags": [ + "Canned Response" + ], + "operationId": "get-account-canned-response", + "summary": "List all Canned Responses in an Account", + "description": "Get Details of Canned Responses in an Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "array", + "description": "Array of all canned responses", + "items": { + "$ref": "#/definitions/canned_response" + } + } + }, + "403": { + "description": "Access denied" + } + } + }, + "post": { + "tags": [ + "Canned Response" + ], + "operationId": "add-new-canned-response-to-account", + "summary": "Add a New Canned Response", + "description": "Add a new Canned Response to Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "parameters": [ + { + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/canned_response_create_update_payload" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/canned_response" + } + }, + "403": { + "description": "Access denied" + } + } + } + }, + "/api/v1/accounts/{account_id}/canned_responses/{id}": { + "parameters": [ + { + "$ref": "#/parameters/account_id" + } + ], + "patch": { + "tags": [ + "Canned Response" + ], + "operationId": "update-canned-response-in-account", + "summary": "Update Canned Response in Account", + "description": "Update a Canned Response in Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "type": "integer", + "required": true, + "description": "The ID of the canned response to be updated." + }, + { + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/canned_response_create_update_payload" + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/canned_response" + } + }, + "404": { + "description": "Agent not found" + }, + "403": { + "description": "Access denied" + } + } + }, + "delete": { + "tags": [ + "Canned Response" + ], + "operationId": "delete-canned-response-from-account", + "summary": "Remove a Canned Response from Account", + "description": "Remove a Canned Response from Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "type": "integer", + "required": true, + "description": "The ID of the canned response to be deleted" + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Canned Response not found" + }, + "403": { + "description": "Access denied" + } + } + } + }, "/api/v1/accounts/{account_id}/contacts": { "get": { "tags": [ @@ -3443,7 +3604,15 @@ "type": "array", "description": "Array of date based conversation statistics", "items": { - "$ref": "#/definitions/report" + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "timestamp": { + "type": "number" + } + } } } }, @@ -3490,14 +3659,110 @@ "operationId": "list-all-conversation-statistics-summary", "summary": "Get Account reports summary", "description": "Get Account reports summary for a specific type and date range", + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/account_summary" + } + }, + "404": { + "description": "reports not found" + }, + "403": { + "description": "Access denied" + } + } + } + }, + "/api/v2/accounts/{account_id}/reports/conversations": { + "parameters": [ + { + "$ref": "#/parameters/account_id" + }, + { + "in": "query", + "name": "type", + "type": "string", + "enum": [ + "account" + ], + "required": true, + "description": "Type of report" + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-account-conversation-metrics", + "summary": "Account Conversation Metrics", + "description": "Get conversation metrics for Account", + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "object", + "description": "Object of account conversation metrics", + "properties": { + "open": { + "type": "number" + }, + "unattended": { + "type": "number" + }, + "unassigned": { + "type": "number" + } + } + } + }, + "404": { + "description": "reports not found" + }, + "403": { + "description": "Access denied" + } + } + } + }, + "/api/v2/accounts/{account_id}/reports/conversations/": { + "parameters": [ + { + "$ref": "#/parameters/account_id" + }, + { + "in": "query", + "name": "type", + "type": "string", + "enum": [ + "agent" + ], + "required": true, + "description": "Type of report" + }, + { + "in": "query", + "name": "user_id", + "type": "string", + "description": "The numeric ID of the user" + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-agent-conversation-metrics", + "summary": "Agent Conversation Metrics", + "description": "Get conversation metrics for Agent", "responses": { "200": { "description": "Success", "schema": { "type": "array", - "description": "Array of date based conversation statistics", + "description": "Array of agent based conversation metrics", "items": { - "$ref": "#/definitions/report" + "$ref": "#/definitions/agent_conversation_metrics" } } }, @@ -3549,6 +3814,27 @@ } } }, + "canned_response": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "ID of the canned response" + }, + "content": { + "type": "string", + "description": "Message content for canned response" + }, + "short_code": { + "type": "string", + "description": "Short Code for quick access of the canned response" + }, + "account_id": { + "type": "integer", + "description": "Account Id" + } + } + }, "contact": { "type": "object", "properties": { @@ -4196,6 +4482,19 @@ } } }, + "canned_response_create_update_payload": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Message content for canned response" + }, + "short_code": { + "type": "string", + "description": "Short Code for quick access of the canned response" + } + } + }, "contact_create": { "type": "object", "required": [ @@ -4684,58 +4983,80 @@ } ] }, - "report": { - "type": "array", - "description": "array of conversation count based on date", - "items": { - "allOf": [ - { - "type": "object", - "properties": { - "avg_first_response_time": { - "type": "string" - }, - "avg_resolution_time": { - "type": "string" - }, - "conversations_count": { - "type": "number" - }, - "incoming_messages_count": { - "type": "number" - }, - "outgoing_messages_count": { - "type": "number" - }, - "resolutions_count": { - "type": "number" - }, - "previous": { - "type": "object", - "properties": { - "avg_first_response_time": { - "type": "string" - }, - "avg_resolution_time": { - "type": "string" - }, - "conversations_count": { - "type": "number" - }, - "incoming_messages_count": { - "type": "number" - }, - "outgoing_messages_count": { - "type": "number" - }, - "resolutions_count": { - "type": "number" - } - } - } + "account_summary": { + "type": "object", + "properties": { + "avg_first_response_time": { + "type": "string" + }, + "avg_resolution_time": { + "type": "string" + }, + "conversations_count": { + "type": "number" + }, + "incoming_messages_count": { + "type": "number" + }, + "outgoing_messages_count": { + "type": "number" + }, + "resolutions_count": { + "type": "number" + }, + "previous": { + "type": "object", + "properties": { + "avg_first_response_time": { + "type": "string" + }, + "avg_resolution_time": { + "type": "string" + }, + "conversations_count": { + "type": "number" + }, + "incoming_messages_count": { + "type": "number" + }, + "outgoing_messages_count": { + "type": "number" + }, + "resolutions_count": { + "type": "number" } } - ] + } + } + }, + "agent_conversation_metrics": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "thumbnail": { + "type": "string" + } + } + }, + "metric": { + "type": "object", + "properties": { + "open": { + "type": "number" + }, + "unattended": { + "type": "number" + } + } + } } } }, @@ -4894,6 +5215,7 @@ "tags": [ "Account AgentBots", "Agent", + "Canned Response", "Contact", "Conversation", "Conversation Assignment",