Merge branch 'develop' into feat/new-auth-screens

This commit is contained in:
Sivin Varghese 2022-03-30 14:00:10 +05:30 committed by GitHub
commit 1107e2c4fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
96 changed files with 2038 additions and 341 deletions

View file

@ -12,8 +12,8 @@ defaults: &defaults
# Specify service dependencies here if necessary # Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images # CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/ # documented at https://circleci.com/docs/2.0/circleci-images/
- image: circleci/postgres:alpine - image: cimg/postgres:14.1
- image: circleci/redis:alpine - image: cimg/redis:6.2.6
environment: environment:
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
- RAILS_LOG_TO_STDOUT: false - RAILS_LOG_TO_STDOUT: false
@ -110,7 +110,7 @@ jobs:
- run: - run:
name: Run backend tests name: Run backend tests
command: | 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 ~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json
- persist_to_workspace: - persist_to_workspace:
root: ~/tmp root: ~/tmp

View file

@ -70,7 +70,7 @@ class ContactBuilder
update_contact_avatar(contact) update_contact_avatar(contact)
contact_inbox contact_inbox
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
raise e raise e
end end
end end

View file

@ -27,7 +27,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
ensure_contact_avatar ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError 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 rescue StandardError => e
Sentry.capture_exception(e) Sentry.capture_exception(e)
true true

View file

@ -1,5 +1,6 @@
class V2::ReportBuilder class V2::ReportBuilder
include DateRangeHelper include DateRangeHelper
include ReportHelper
attr_reader :account, :params attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze DEFAULT_GROUP_BY = 'day'.freeze
@ -18,8 +19,14 @@ class V2::ReportBuilder
# For backward compatible with old report # For backward compatible with old report
def build def build
timeseries.each_with_object([]) do |p, arr| if %w[avg_first_response_time avg_resolution_time].include?(params[:metric])
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i } 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
end end
@ -34,23 +41,16 @@ class V2::ReportBuilder
} }
end end
private def conversation_metrics
if params[:type].equal?(:account)
def scope conversations
case params[:type] else
when :account agent_metrics
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end end
end end
private
def inbox def inbox
@inbox ||= account.inboxes.find(params[:id]) @inbox ||= account.inboxes.find(params[:id])
end end
@ -68,7 +68,7 @@ class V2::ReportBuilder
end end
def get_grouped_values(object_scope) def get_grouped_values(object_scope)
object_scope.group_by_period( @grouped_values = object_scope.group_by_period(
params[:group_by] || DEFAULT_GROUP_BY, params[:group_by] || DEFAULT_GROUP_BY,
:created_at, :created_at,
default_value: 0, default_value: 0,
@ -78,47 +78,26 @@ class V2::ReportBuilder
) )
end end
def conversations_count def agent_metrics
(get_grouped_values scope.conversations).count 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 end
def incoming_messages_count def conversations
(get_grouped_values scope.messages.incoming.unscope(:order)).count @open_conversations = scope.conversations.open
end first_response_count = scope.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = {
def outgoing_messages_count open: @open_conversations.count,
(get_grouped_values scope.messages.outgoing.unscope(:order)).count unattended: @open_conversations.count - first_response_count
end }
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
def resolutions_count metric
(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
end end

View file

@ -7,13 +7,22 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
end end
def create 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 end
def show; end def show; end
def update def update
@automation_rule.update(automation_rules_permit) @automation_rule.update(automation_rules_permit)
process_attachments
@automation_rule
end end
def destroy def destroy
@ -30,11 +39,19 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
private 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 def automation_rules_permit
params.permit( params.permit(
:name, :description, :event_name, :account_id, :active, :name, :description, :event_name, :account_id, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }], conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
actions: [:action_name, { action_params: [{}] }] actions: [:action_name, { action_params: [] }]
) )
end end

View file

@ -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 = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', ''))
koala.exchange_access_token_info(omniauth_token)['access_token'] koala.exchange_access_token_info(omniauth_token)['access_token']
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
end end
def mark_already_existing_facebook_pages(data) def mark_already_existing_facebook_pages(data)

View file

@ -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' render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv'
end end
def conversations
return head :unprocessable_entity if params[:type].blank?
render json: conversation_metrics
end
private private
def check_authorization def check_authorization
@ -73,6 +79,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
} }
end end
def conversation_params
{
type: params[:type].to_sym,
user_id: params[:user_id]
}
end
def range def range
{ {
current: { 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[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
summary summary
end end
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end end

View file

@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end end
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
redirect_to twitter_app_redirect_url redirect_to twitter_app_redirect_url
end end

View file

@ -17,7 +17,7 @@ class Webhooks::InstagramController < ApplicationController
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry]) ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
render json: :ok render json: :ok
else 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 head :unprocessable_entity
end end
end end

View file

@ -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

View file

@ -18,6 +18,13 @@
font-size: $font-size-small; font-size: $font-size-small;
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
color: $color-heading; color: $color-heading;
display: flex;
align-items: center;
}
.info-icon {
color: var(--b-400);
margin-left: var(--space-micro);
} }
.metric-wrap { .metric-wrap {

View file

@ -7,7 +7,8 @@
<select <select
v-model="action_name" v-model="action_name"
class="action__question" class="action__question"
@change="resetFilter()" :class="{ 'full-width': !inputType }"
@change="resetAction()"
> >
<option <option
v-for="attribute in actionTypes" v-for="attribute in actionTypes"
@ -18,19 +19,38 @@
</option> </option>
</select> </select>
<div class="filter__answer--wrap"> <div class="filter__answer--wrap">
<div class="multiselect-wrap--small"> <div v-if="inputType">
<multiselect <div
v-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="'Select'"
:multiple="true"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
/>
</div>
<input
v-else-if="inputType === 'email'"
v-model="action_params" v-model="action_params"
track-by="id" type="email"
label="name" class="answer--text-input"
:placeholder="'Select'" placeholder="Enter email"
:multiple="true" />
selected-label <input
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" v-else-if="inputType === 'url'"
deselect-label="" v-model="action_params"
:max-height="160" type="url"
:options="dropdownValues" class="answer--text-input"
:allow-empty="false" placeholder="Enter url"
/> />
</div> </div>
</div> </div>
@ -91,13 +111,17 @@ export default {
this.$emit('input', { ...payload, action_params: value }); this.$emit('input', { ...payload, action_params: value });
}, },
}, },
inputType() {
return this.actionTypes.find(action => action.key === this.action_name)
.inputType;
},
}, },
methods: { methods: {
removeAction() { removeAction() {
this.$emit('removeAction'); this.$emit('removeAction');
}, },
resetFilter() { resetAction() {
this.$emit('resetFilter'); this.$emit('resetAction');
}, },
}, },
}; };
@ -136,6 +160,10 @@ export default {
max-width: 50%; max-width: 50%;
} }
.action__question.full-width {
max-width: 100%;
}
.filter__answer--wrap { .filter__answer--wrap {
margin-right: var(--space-smaller); margin-right: var(--space-smaller);
flex-grow: 1; flex-grow: 1;

View file

@ -5,7 +5,14 @@
@click="onClick(index)" @click="onClick(index)"
> >
<h3 class="heading"> <h3 class="heading">
{{ heading }} <span>{{ heading }}</span>
<fluent-icon
v-if="infoText"
v-tooltip="infoText"
size="14"
icon="info"
class="info-icon"
/>
</h3> </h3>
<div class="metric-wrap"> <div class="metric-wrap">
<h4 class="metric"> <h4 class="metric">
@ -22,6 +29,7 @@
export default { export default {
props: { props: {
heading: { type: String, default: '' }, heading: { type: String, default: '' },
infoText: { type: String, default: '' },
point: { type: [Number, String], default: '' }, point: { type: [Number, String], default: '' },
trend: { type: Number, default: null }, trend: { type: Number, default: null },
index: { type: Number, default: null }, index: { type: Number, default: null },

View file

@ -3,7 +3,7 @@ import { Bar } from 'vue-chartjs';
const fontFamily = const fontFamily =
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
const chartOptions = { const defaultChartOptions = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
legend: { legend: {
@ -11,10 +11,14 @@ const chartOptions = {
fontFamily, fontFamily,
}, },
}, },
datasets: {
bar: {
barPercentage: 1.0,
},
},
scales: { scales: {
xAxes: [ xAxes: [
{ {
barPercentage: 1.1,
ticks: { ticks: {
fontFamily, fontFamily,
}, },
@ -39,8 +43,20 @@ const chartOptions = {
export default { export default {
extends: Bar, extends: Bar,
props: ['collection'], props: {
collection: {
type: Object,
default: () => {},
},
chartOptions: {
type: Object,
default: () => {},
},
},
mounted() { mounted() {
this.renderChart(this.collection, chartOptions); this.renderChart(this.collection, {
...defaultChartOptions,
...this.chartOptions,
});
}, },
}; };

View file

@ -22,6 +22,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'contact.deleted': this.onContactDelete, 'contact.deleted': this.onContactDelete,
'contact.updated': this.onContactUpdate, 'contact.updated': this.onContactUpdate,
'conversation.mentioned': this.onConversationMentioned, 'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated,
}; };
} }
@ -134,6 +135,10 @@ class ActionCableConnector extends BaseActionCableConnector {
onContactUpdate = data => { onContactUpdate = data => {
this.app.$store.dispatch('contacts/updateContact', data); this.app.$store.dispatch('contacts/updateContact', data);
}; };
onNotificationCreated = data => {
this.app.$store.dispatch('notifications/addNotification', data);
};
} }
export default { export default {

View file

@ -70,6 +70,14 @@
"SUCCESS_MESSAGE": "Contacts saved successfully", "SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "There was an error, please try again" "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": { "DELETE_CONTACT": {
"BUTTON_LABEL": "Delete Contact", "BUTTON_LABEL": "Delete Contact",
"TITLE": "Delete contact", "TITLE": "Delete contact",

View file

@ -18,12 +18,14 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First response time", "NAME": "First Response Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "Resolution Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",
@ -98,12 +100,14 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First response time", "NAME": "First Response Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "Resolution Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",
@ -161,12 +165,14 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First response time", "NAME": "First Response Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "Resolution Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",
@ -224,12 +230,14 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First response time", "NAME": "First Response Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "Resolution Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",
@ -287,12 +295,14 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First response time", "NAME": "First Response Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "Resolution Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",

View file

@ -5,6 +5,7 @@ export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
accountSummary: 'getAccountSummary', accountSummary: 'getAccountSummary',
accountReport: 'getAccountReports',
}), }),
calculateTrend() { calculateTrend() {
return metric_key => { return metric_key => {
@ -19,15 +20,32 @@ export default {
}, },
displayMetric() { displayMetric() {
return metric_key => { return metric_key => {
if ( if (this.isAverageMetricType(metric_key)) {
['avg_first_response_time', 'avg_resolution_time'].includes(
metric_key
)
) {
return formatTime(this.accountSummary[metric_key]); return formatTime(this.accountSummary[metric_key]);
} }
return 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
);
};
},
}, },
}; };

View file

@ -11,6 +11,7 @@ describe('reportMixin', () => {
beforeEach(() => { beforeEach(() => {
getters = { getters = {
getAccountSummary: () => reportFixtures.summary, getAccountSummary: () => reportFixtures.summary,
getAccountReports: () => reportFixtures.report,
}; };
store = new Vuex.Store({ getters }); store = new Vuex.Store({ getters });
}); });
@ -38,4 +39,67 @@ describe('reportMixin', () => {
expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25); expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25);
expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0); 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('');
});
}); });

View file

@ -15,4 +15,15 @@ export default {
}, },
resolutions_count: 3, 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 },
],
},
}; };

View file

@ -21,9 +21,19 @@
size="tiny" size="tiny"
icon="delete" icon="delete"
color-scheme="secondary" color-scheme="secondary"
@click="onDelete" @click="toggleDeleteModal"
/> />
</div> </div>
<woot-delete-modal
v-if="showDeleteModal"
:show.sync="showDeleteModal"
:on-close="closeDelete"
:on-confirm="confirmDeletion"
:title="$t('DELETE_NOTE.CONFIRM.TITLE')"
:message="$t('DELETE_NOTE.CONFIRM.MESSAGE')"
:confirm-text="$t('DELETE_NOTE.CONFIRM.YES')"
:reject-text="$t('DELETE_NOTE.CONFIRM.NO')"
/>
</div> </div>
<p class="note__content" v-html="formatMessage(note || '')" /> <p class="note__content" v-html="formatMessage(note || '')" />
</div> </div>
@ -59,7 +69,11 @@ export default {
default: 0, default: 0,
}, },
}, },
data() {
return {
showDeleteModal: false,
};
},
computed: { computed: {
readableTime() { readableTime() {
return this.dynamicTime(this.createdAt); return this.dynamicTime(this.createdAt);
@ -73,9 +87,19 @@ export default {
}, },
methods: { methods: {
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
},
onDelete() { onDelete() {
this.$emit('delete', this.id); this.$emit('delete', this.id);
}, },
confirmDeletion() {
this.onDelete();
this.closeDelete();
},
closeDelete() {
this.showDeleteModal = false;
},
}, },
}; };
</script> </script>

View file

@ -101,6 +101,7 @@
getActionDropdownValues(automation.actions[i].action_name) getActionDropdownValues(automation.actions[i].action_name)
" "
:v="$v.automation.actions.$each[i]" :v="$v.automation.actions.$each[i]"
@resetAction="resetAction(i)"
@removeAction="removeAction(i)" @removeAction="removeAction(i)"
/> />
<div class="filter-actions"> <div class="filter-actions">
@ -187,7 +188,13 @@ export default {
required, required,
$each: { $each: {
action_params: { 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) { getActionDropdownValues(type) {
switch (type) { switch (type) {
case 'assign_team': case 'assign_team':
case 'send_email_to_team':
return this.$store.getters['teams/getTeams']; return this.$store.getters['teams/getTeams'];
case 'add_label': case 'add_label':
return this.$store.getters['labels/getLabels'].map(i => { return this.$store.getters['labels/getLabels'].map(i => {
@ -424,6 +430,9 @@ export default {
).filterOperators[0].value; ).filterOperators[0].value;
this.automation.conditions[index].values = ''; this.automation.conditions[index].values = '';
}, },
resetAction(index) {
this.automation.actions[index].action_params = [];
},
showUserInput(operatorType) { showUserInput(operatorType) {
if (operatorType === 'is_present' || operatorType === 'is_not_present') if (operatorType === 'is_present' || operatorType === 'is_not_present')
return false; return false;

View file

@ -351,7 +351,6 @@ export default {
getActionDropdownValues(type) { getActionDropdownValues(type) {
switch (type) { switch (type) {
case 'assign_team': case 'assign_team':
case 'send_email_to_team':
return this.$store.getters['teams/getTeams']; return this.$store.getters['teams/getTeams'];
case 'add_label': case 'add_label':
return this.$store.getters['labels/getLabels'].map(i => { return this.$store.getters['labels/getLabels'].map(i => {

View file

@ -79,8 +79,33 @@ export const AUTOMATIONS = {
// { // {
// key: 'send_email_to_team', // key: 'send_email_to_team',
// name: 'Send an 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: { conversation_created: {
@ -120,15 +145,40 @@ export const AUTOMATIONS = {
name: 'Assign a team', name: 'Assign a team',
attributeI18nKey: 'ASSIGN_TEAM', attributeI18nKey: 'ASSIGN_TEAM',
}, },
{
key: 'assign_agent',
name: 'Assign an agent',
attributeI18nKey: 'ASSIGN_AGENT',
},
// { // {
// key: 'send_email_to_team', // key: 'send_email_to_team',
// name: 'Send an email to team', // name: 'Send an email to team',
// attributeI18nKey: 'SEND_MESSAGE', // attributeI18nKey: 'SEND_MESSAGE',
// }, // },
{ {
key: 'assign_agent', key: 'send_email_transcript',
name: 'Assign an agent', name: 'Send an email transcript',
attributeI18nKey: 'ASSIGN_AGENT', 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', name: 'Assign a team',
attributeI18nKey: 'ASSIGN_TEAM', attributeI18nKey: 'ASSIGN_TEAM',
}, },
// {
// key: 'send_email_to_team',
// name: 'Send an email to team',
// attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
// },
{ {
key: 'assign_agent', key: 'assign_agent',
name: 'Assign an agent', name: 'Assign an agent',
attributeI18nKey: 'ASSIGN_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', key: 'assign_team',
label: 'Assign a team', label: 'Assign a team',
inputType: 'multi_select',
}, },
{ {
key: 'add_label', key: 'add_label',
label: 'Add a label', label: 'Add a label',
inputType: 'multi_select',
}, },
// { // {
// key: 'send_email_to_team', // key: 'send_email_to_team',
// label: 'Send an 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',
},
]; ];

View file

@ -22,6 +22,7 @@
:key="metric.NAME" :key="metric.NAME"
:desc="metric.DESC" :desc="metric.DESC"
:heading="metric.NAME" :heading="metric.NAME"
:info-text="displayInfoText(metric.KEY)"
:index="index" :index="index"
:on-click="changeSelection" :on-click="changeSelection"
:point="displayMetric(metric.KEY)" :point="displayMetric(metric.KEY)"
@ -35,7 +36,11 @@
:message="$t('REPORT.LOADING_CHART')" :message="$t('REPORT.LOADING_CHART')"
/> />
<div v-else class="chart-container"> <div v-else class="chart-container">
<woot-bar v-if="accountReport.data.length" :collection="collection" /> <woot-bar
v-if="accountReport.data.length"
:collection="collection"
:chart-options="chartOptions"
/>
<span v-else class="empty-state"> <span v-else class="empty-state">
{{ $t('REPORT.NO_ENOUGH_DATA') }} {{ $t('REPORT.NO_ENOUGH_DATA') }}
</span> </span>
@ -49,7 +54,7 @@ import { mapGetters } from 'vuex';
import fromUnixTime from 'date-fns/fromUnixTime'; import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format'; import format from 'date-fns/format';
import ReportFilterSelector from './components/FilterSelector'; import ReportFilterSelector from './components/FilterSelector';
import { GROUP_BY_FILTER } from './constants'; import { GROUP_BY_FILTER, METRIC_CHART } from './constants';
import reportMixin from '../../../../mixins/reportMixin'; import reportMixin from '../../../../mixins/reportMixin';
const REPORTS_KEYS = { const REPORTS_KEYS = {
@ -108,16 +113,38 @@ export default {
} }
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy'); 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 { return {
labels, labels,
datasets: [ datasets,
{ };
label: this.metrics[this.currentSelection].NAME, },
backgroundColor: '#1f93ff', chartOptions() {
data, return {
}, scales: METRIC_CHART[this.metrics[this.currentSelection].KEY].scales,
],
}; };
}, },
metrics() { metrics() {
@ -133,6 +160,7 @@ export default {
NAME: this.$t(`REPORT.METRICS.${key}.NAME`), NAME: this.$t(`REPORT.METRICS.${key}.NAME`),
KEY: REPORTS_KEYS[key], KEY: REPORTS_KEYS[key],
DESC: this.$t(`REPORT.METRICS.${key}.DESC`), DESC: this.$t(`REPORT.METRICS.${key}.DESC`),
INFO_TEXT: this.$t(`REPORT.METRICS.${key}.INFO_TEXT`),
})); }));
}, },
}, },

View file

@ -25,6 +25,7 @@
:key="metric.NAME" :key="metric.NAME"
:desc="metric.DESC" :desc="metric.DESC"
:heading="metric.NAME" :heading="metric.NAME"
:info-text="displayInfoText(metric.KEY)"
:index="index" :index="index"
:on-click="changeSelection" :on-click="changeSelection"
:point="displayMetric(metric.KEY)" :point="displayMetric(metric.KEY)"
@ -41,6 +42,7 @@
<woot-bar <woot-bar
v-if="accountReport.data.length && filterItemsList.length" v-if="accountReport.data.length && filterItemsList.length"
:collection="collection" :collection="collection"
:chart-options="chartOptions"
/> />
<span v-else class="empty-state"> <span v-else class="empty-state">
{{ $t('REPORT.NO_ENOUGH_DATA') }} {{ $t('REPORT.NO_ENOUGH_DATA') }}
@ -55,7 +57,7 @@
import ReportFilters from './ReportFilters'; import ReportFilters from './ReportFilters';
import fromUnixTime from 'date-fns/fromUnixTime'; import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format'; 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'; import reportMixin from '../../../../../mixins/reportMixin';
const REPORTS_KEYS = { const REPORTS_KEYS = {
@ -137,16 +139,38 @@ export default {
} }
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy'); 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 { return {
labels, labels,
datasets: [ datasets,
{ };
label: this.metrics[this.currentSelection].NAME, },
backgroundColor: '#1f93ff', chartOptions() {
data, return {
}, scales: METRIC_CHART[this.metrics[this.currentSelection].KEY].scales,
],
}; };
}, },
metrics() { metrics() {
@ -168,6 +192,7 @@ export default {
NAME: this.$t(`REPORT.METRICS.${key}.NAME`), NAME: this.$t(`REPORT.METRICS.${key}.NAME`),
KEY: REPORTS_KEYS[key], KEY: REPORTS_KEYS[key],
DESC: this.$t(`REPORT.METRICS.${key}.DESC`), DESC: this.$t(`REPORT.METRICS.${key}.DESC`),
INFO_TEXT: this.$t(`REPORT.METRICS.${key}.INFO_TEXT`),
})); }));
}, },
}, },

View file

@ -4,3 +4,142 @@ export const GROUP_BY_FILTER = {
3: { id: 3, period: 'month' }, 3: { id: 3, period: 'month' },
4: { id: 4, period: 'year' }, 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,
};

View file

@ -52,4 +52,8 @@ export const actions = {
throw new Error(error); throw new Error(error);
} }
}, },
addNotification({ commit }, data) {
commit(types.ADD_NOTIFICATION, data);
},
}; };

View file

@ -45,4 +45,14 @@ export const mutations = {
Vue.set($state.records[item.id], 'read_at', true); 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);
},
}; };

View file

@ -90,4 +90,12 @@ describe('#actions', () => {
await expect(actions.readAll({ commit })).rejects.toThrow(Error); 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 }],
]);
});
});
}); });

View file

@ -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);
});
});
}); });

View file

@ -116,6 +116,7 @@ export default {
SET_NOTIFICATIONS_UNREAD_COUNT: 'SET_NOTIFICATIONS_UNREAD_COUNT', SET_NOTIFICATIONS_UNREAD_COUNT: 'SET_NOTIFICATIONS_UNREAD_COUNT',
SET_NOTIFICATIONS_UI_FLAG: 'SET_NOTIFICATIONS_UI_FLAG', SET_NOTIFICATIONS_UI_FLAG: 'SET_NOTIFICATIONS_UI_FLAG',
UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION', UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION',
ADD_NOTIFICATION: 'ADD_NOTIFICATION',
UPDATE_ALL_NOTIFICATIONS: 'UPDATE_ALL_NOTIFICATIONS', UPDATE_ALL_NOTIFICATIONS: 'UPDATE_ALL_NOTIFICATIONS',
SET_NOTIFICATIONS_ITEM: 'SET_NOTIFICATIONS_ITEM', SET_NOTIFICATIONS_ITEM: 'SET_NOTIFICATIONS_ITEM',
SET_NOTIFICATIONS: 'SET_NOTIFICATIONS', SET_NOTIFICATIONS: 'SET_NOTIFICATIONS',

View file

@ -31,6 +31,14 @@ const runSDK = ({ baseUrl, websiteToken }) => {
IFrameHelper.events.toggleBubble(state); IFrameHelper.events.toggleBubble(state);
}, },
popoutChatWindow() {
IFrameHelper.events.popoutChatWindow({
baseUrl: window.$chatwoot.baseUrl,
websiteToken: window.$chatwoot.websiteToken,
locale: window.$chatwoot.locale,
});
},
setUser(identifier, user) { setUser(identifier, user) {
if (typeof identifier !== 'string' && typeof identifier !== 'number') { if (typeof identifier !== 'string' && typeof identifier !== 'number') {
throw new Error('Identifier should be a string or a number'); throw new Error('Identifier should be a string or a number');

View file

@ -31,6 +31,7 @@ import {
initOnEvents, initOnEvents,
} from 'shared/helpers/AudioNotificationHelper'; } from 'shared/helpers/AudioNotificationHelper';
import { isFlatWidgetStyle } from './settingsHelper'; import { isFlatWidgetStyle } from './settingsHelper';
import { popoutChatWindow } from '../widget/helpers/popoutHelper';
export const IFrameHelper = { export const IFrameHelper = {
getUrl({ baseUrl, websiteToken }) { getUrl({ baseUrl, websiteToken }) {
@ -190,6 +191,12 @@ export const IFrameHelper = {
onBubbleClick(bubbleState); onBubbleClick(bubbleState);
}, },
popoutChatWindow: ({ baseUrl, websiteToken, locale }) => {
const cwCookie = Cookies.get('cw_conversation');
window.$chatwoot.toggle('close');
popoutChatWindow(baseUrl, websiteToken, locale, cwCookie);
},
closeWindow: () => { closeWindow: () => {
onBubbleClick({ toggleValue: false }); onBubbleClick({ toggleValue: false });
removeUnreadClass(); removeUnreadClass();

View file

@ -29,7 +29,7 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { IFrameHelper, RNHelper } from 'widget/helpers/utils'; import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import { buildPopoutURL } from '../helpers/urlParamsHelper'; import { popoutChatWindow } from '../helpers/popoutHelper';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default { export default {
@ -69,19 +69,12 @@ export default {
chatwootWebChannel: { websiteToken }, chatwootWebChannel: { websiteToken },
authToken, authToken,
} = window; } = window;
popoutChatWindow(
const popoutWindowURL = buildPopoutURL({
origin, origin,
websiteToken, websiteToken,
locale: this.$root.$i18n.locale, this.$root.$i18n.locale,
conversationCookie: authToken, authToken
});
const popoutWindow = window.open(
popoutWindowURL,
`webwidget_session_${websiteToken}`,
'resizable=off,width=400,height=600'
); );
popoutWindow.focus();
}, },
closeWindow() { closeWindow() {
if (IFrameHelper.isIFrame()) { if (IFrameHelper.isIFrame()) {

View file

@ -0,0 +1,26 @@
import { buildPopoutURL } from './urlParamsHelper';
export const popoutChatWindow = (
origin,
websiteToken,
locale,
conversationCookie
) => {
try {
const windowUrl = buildPopoutURL({
origin,
websiteToken,
locale,
conversationCookie,
});
const popoutWindow = window.open(
windowUrl,
`webwidget_session_${websiteToken}`,
'resizable=off,width=400,height=600'
);
popoutWindow.focus();
} catch (err) {
// eslint-disable-next-line no-console
console.log(err);
}
};

View file

@ -58,6 +58,7 @@ export const actions = {
if (identifier_hash) { if (identifier_hash) {
dispatch('conversation/clearConversations', {}, { root: true }); dispatch('conversation/clearConversations', {}, { root: true });
dispatch('conversation/fetchOldConversations', {}, { root: true }); dispatch('conversation/fetchOldConversations', {}, { root: true });
dispatch('conversationAttributes/getAttributes', {}, { root: true });
} }
} catch (error) { } catch (error) {
const { const {

View file

@ -21,6 +21,7 @@ describe('#actions', () => {
['get'], ['get'],
['conversation/clearConversations', {}, { root: true }], ['conversation/clearConversations', {}, { root: true }],
['conversation/fetchOldConversations', {}, { root: true }], ['conversation/fetchOldConversations', {}, { root: true }],
['conversationAttributes/getAttributes', {}, { root: true }],
]); ]);
}); });
}); });

View file

@ -8,6 +8,6 @@ class ContactAvatarJob < ApplicationJob
) )
contact.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type) contact.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
rescue Down::Error => e rescue Down::Error => e
Rails.logger.info "Exception: invalid avatar url #{avatar_url} : #{e.message}" Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}"
end end
end end

View file

@ -8,7 +8,7 @@ class ContactIpLookupJob < ApplicationJob
update_contact_location_from_ip(contact) update_contact_location_from_ip(contact)
rescue Errno::ETIMEDOUT => e rescue Errno::ETIMEDOUT => e
Rails.logger.info "Exception: ip resolution failed : #{e.message}" Rails.logger.warn "Exception: ip resolution failed : #{e.message}"
end end
private private

View file

@ -2,9 +2,9 @@ class ActionCableListener < BaseListener
include Events::Types include Events::Types
def notification_created(event) def notification_created(event)
notification, account = extract_notification_and_account(event) notification, account, unread_count, count = extract_notification_and_account(event)
tokens = [event.data[:notification].user.pubsub_token] tokens = [event.data[:notification].user.pubsub_token]
broadcast(account, tokens, NOTIFICATION_CREATED, notification.push_event_data) broadcast(account, tokens, NOTIFICATION_CREATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
end end
def message_created(event) def message_created(event)

View file

@ -8,7 +8,9 @@ class BaseListener
def extract_notification_and_account(event) def extract_notification_and_account(event)
notification = event.data[:notification] notification = event.data[:notification]
[notification, notification.account] unread_count = notification.user.notifications_meta[:unread_count]
count = notification.user.notifications_meta[:count]
[notification, notification.account, unread_count, count]
end end
def extract_message_and_account(event) def extract_message_and_account(event)
@ -20,4 +22,12 @@ class BaseListener
contact = event.data[:contact] contact = event.data[:contact]
[contact, contact.account] [contact, contact.account]
end end
def extract_changed_attributes(event)
changed_attributes = event.data[:changed_attributes]
return if changed_attributes.blank?
changed_attributes.map { |k, v| { k => { previous_value: v[0], current_value: v[1] } } }
end
end end

View file

@ -2,23 +2,34 @@ class WebhookListener < BaseListener
# FIXME: deprecate the opened and resolved events in future in favor of status changed event. # FIXME: deprecate the opened and resolved events in future in favor of status changed event.
def conversation_resolved(event) def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0] conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
inbox = conversation.inbox inbox = conversation.inbox
payload = conversation.webhook_data.merge(event: __method__.to_s) payload = conversation.webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
deliver_webhook_payloads(payload, inbox) deliver_webhook_payloads(payload, inbox)
end end
# FIXME: deprecate the opened and resolved events in future in favor of status changed event. # FIXME: deprecate the opened and resolved events in future in favor of status changed event.
def conversation_opened(event) def conversation_opened(event)
conversation = extract_conversation_and_account(event)[0] conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
inbox = conversation.inbox inbox = conversation.inbox
payload = conversation.webhook_data.merge(event: __method__.to_s) payload = conversation.webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
deliver_webhook_payloads(payload, inbox) deliver_webhook_payloads(payload, inbox)
end end
def conversation_status_changed(event) def conversation_status_changed(event)
conversation = extract_conversation_and_account(event)[0] conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
inbox = conversation.inbox inbox = conversation.inbox
payload = conversation.webhook_data.merge(event: __method__.to_s) payload = conversation.webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
deliver_webhook_payloads(payload, inbox)
end
def conversation_updated(event)
conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
inbox = conversation.inbox
payload = conversation.webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
deliver_webhook_payloads(payload, inbox) deliver_webhook_payloads(payload, inbox)
end end

View file

@ -25,8 +25,8 @@ class ApplicationMailer < ActionMailer::Base
private private
def handle_smtp_exceptions(message) def handle_smtp_exceptions(message)
Rails.logger.info 'Failed to send Email' Rails.logger.warn 'Failed to send Email'
Rails.logger.info "Exception: #{message}" Rails.logger.error "Exception: #{message}"
end end
def send_mail_with_liquid(*args) def send_mail_with_liquid(*args)

View file

@ -72,6 +72,7 @@ class Account < ApplicationRecord
has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms' has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms'
has_many :working_hours, dependent: :destroy_async has_many :working_hours, dependent: :destroy_async
has_many :automation_rules, dependent: :destroy has_many :automation_rules, dependent: :destroy
has_many :notifications, dependent: :destroy
has_flags ACCOUNT_SETTINGS_FLAGS.merge(column: 'settings_flags').merge(DEFAULT_QUERY_SETTING) has_flags ACCOUNT_SETTINGS_FLAGS.merge(column: 'settings_flags').merge(DEFAULT_QUERY_SETTING)

View file

@ -19,6 +19,7 @@
# #
class AutomationRule < ApplicationRecord class AutomationRule < ApplicationRecord
belongs_to :account belongs_to :account
has_many_attached :files
validate :json_conditions_format validate :json_conditions_format
validate :json_actions_format validate :json_actions_format
@ -26,8 +27,8 @@ class AutomationRule < ApplicationRecord
scope :active, -> { where(active: true) } scope :active, -> { where(active: true) }
CONDITIONS_ATTRS = %w[country_code status browser_language assignee_id team_id referer].freeze CONDITIONS_ATTRS = %w[email country_code status message_type browser_language assignee_id team_id referer city company].freeze
ACTIONS_ATTRS = %w[send_message add_label send_email_to_team assign_team assign_best_agents].freeze ACTIONS_ATTRS = %w[send_message add_label send_email_to_team assign_team assign_best_agents send_attachments].freeze
private private
@ -35,13 +36,17 @@ class AutomationRule < ApplicationRecord
return if conditions.nil? return if conditions.nil?
attributes = conditions.map { |obj, _| obj['attribute_key'] } attributes = conditions.map { |obj, _| obj['attribute_key'] }
(attributes - CONDITIONS_ATTRS).blank? conditions = attributes - CONDITIONS_ATTRS
conditions -= account.custom_attribute_definitions.pluck(:attribute_key)
errors.add(:conditions, "Automation conditions #{conditions.join(',')} not supported.") if conditions.any?
end end
def json_actions_format def json_actions_format
return if actions.nil? return if actions.nil?
attributes = actions.map { |obj, _| obj['attribute_key'] } attributes = actions.map { |obj, _| obj['attribute_key'] }
(attributes - ACTIONS_ATTRS).blank? actions = attributes - ACTIONS_ATTRS
errors.add(:actions, "Automation actions #{actions.join(',')} not supported.") if actions.any?
end end
end end

View file

@ -45,7 +45,7 @@ class Channel::FacebookPage < ApplicationRecord
source_id: instagram_id source_id: instagram_id
) )
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
end end
end end

View file

@ -40,7 +40,7 @@ class Channel::TwitterProfile < ApplicationRecord
source_id: profile_id source_id: profile_id
) )
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
end end
end end
@ -62,6 +62,6 @@ class Channel::TwitterProfile < ApplicationRecord
unsubscribe_response = twitter_client.remove_subscription(user_id: profile_id) unsubscribe_response = twitter_client.remove_subscription(user_id: profile_id)
Rails.logger.info "TWITTER_UNSUBSCRIBE: #{unsubscribe_response.body}" Rails.logger.info "TWITTER_UNSUBSCRIBE: #{unsubscribe_response.body}"
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
end end
end end

View file

@ -86,7 +86,7 @@ class Channel::WebWidget < ApplicationRecord
) )
contact_inbox contact_inbox
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
end end
end end
end end

View file

@ -10,6 +10,8 @@ module ActivityMessageHandler
end end
def status_change_activity(user_name) def status_change_activity(user_name)
return send_automation_activity if Current.executed_by.present?
create_status_change_message(user_name) create_status_change_message(user_name)
end end
@ -29,6 +31,11 @@ module ActivityMessageHandler
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end end
def send_automation_activity
content = I18n.t("conversations.activity.status.#{status}", user_name: 'Automation System')
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def create_label_added(user_name, labels = []) def create_label_added(user_name, labels = [])
return unless labels.size.positive? return unless labels.size.positive?

View file

@ -12,18 +12,19 @@ module AssignmentHandler
def ensure_assignee_is_from_team def ensure_assignee_is_from_team
return unless team_id_changed? return unless team_id_changed?
ensure_current_assignee_team validate_current_assignee_team
self.assignee_id ||= find_team_assignee_id_for_inbox if team&.allow_auto_assign.present? self.assignee ||= find_assignee_from_team
end end
def ensure_current_assignee_team def validate_current_assignee_team
self.assignee_id = nil if team&.members&.exclude?(assignee) self.assignee_id = nil if team&.members&.exclude?(assignee)
end end
def find_team_assignee_id_for_inbox def find_assignee_from_team
members = inbox.members.ids & team.members.ids return if team&.allow_auto_assign.blank?
# TODO: User round robin to determine the next agent instead of using sample
members.sample team_members = inbox.members.ids & team.members.ids
::RoundRobin::AssignmentService.new(conversation: self, allowed_member_ids: team_members).find_assignee
end end
def notify_assignment_change def notify_assignment_change

View file

@ -188,7 +188,7 @@ class Conversation < ApplicationRecord
return unless previous_changes.keys.present? && (previous_changes.keys & %w[team_id assignee_id status snoozed_until return unless previous_changes.keys.present? && (previous_changes.keys & %w[team_id assignee_id status snoozed_until
custom_attributes]).present? custom_attributes]).present?
dispatcher_dispatch(CONVERSATION_UPDATED) dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
end end
def self_assign?(assignee_id) def self_assign?(assignee_id)
@ -207,14 +207,15 @@ class Conversation < ApplicationRecord
CONVERSATION_READ => -> { saved_change_to_contact_last_seen_at? }, CONVERSATION_READ => -> { saved_change_to_contact_last_seen_at? },
CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? } CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? }
}.each do |event, condition| }.each do |event, condition|
condition.call && dispatcher_dispatch(event) condition.call && dispatcher_dispatch(event, status_change)
end end
end end
def dispatcher_dispatch(event_name) def dispatcher_dispatch(event_name, changed_attributes = nil)
return if Current.executed_by.present? && Current.executed_by.instance_of?(AutomationRule) return if Current.executed_by.present? && Current.executed_by.instance_of?(AutomationRule)
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, conversation: self, notifiable_assignee_change: notifiable_assignee_change?) Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, conversation: self, notifiable_assignee_change: notifiable_assignee_change?,
changed_attributes: changed_attributes)
end end
def conversation_status_changed_to_open? def conversation_status_changed_to_open?

View file

@ -52,15 +52,24 @@ class Notification < ApplicationRecord
notification_type: notification_type, notification_type: notification_type,
primary_actor_type: primary_actor_type, primary_actor_type: primary_actor_type,
primary_actor_id: primary_actor_id, primary_actor_id: primary_actor_id,
primary_actor: primary_actor.push_event_data, primary_actor: primary_actor_data,
read_at: read_at, read_at: read_at,
secondary_actor: secondary_actor&.push_event_data, secondary_actor: secondary_actor&.push_event_data,
user: user&.push_event_data, user: user&.push_event_data,
created_at: created_at, created_at: created_at.to_i,
account_id: account_id account_id: account_id,
push_message_title: push_message_title
} }
end end
def primary_actor_data
if %w[assigned_conversation_new_message conversation_mention].include? notification_type
primary_actor.conversation.push_event_data
else
primary_actor.push_event_data
end
end
def fcm_push_data def fcm_push_data
{ {
id: id, id: id,

View file

@ -187,4 +187,11 @@ class User < ApplicationRecord
def will_save_change_to_email? def will_save_change_to_email?
mutations_from_database.changed?('email') mutations_from_database.changed?('email')
end end
def notifications_meta
{
unread_count: notifications.where(read_at: nil).count,
count: notifications.count
}
end
end end

View file

@ -21,28 +21,43 @@ class AutomationRules::ActionService
private private
def send_email_transcript(email) def send_attachments(_file_params)
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(@conversation, email)&.deliver_later return if @rule.event_name == 'message_created'
blobs = @rule.files.map { |file, _| file.blob }
params = { content: nil, private: false, attachments: blobs }
mb = Messages::MessageBuilder.new(nil, @conversation, params)
mb.perform
end
def send_email_transcript(emails)
emails.each do |email|
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, email)&.deliver_later
end
end end
def mute_conversation(_params) def mute_conversation(_params)
@conversation.mute! @conversation.mute!
end end
def snooze_conversation(_params)
@conversation.ensure_snooze_until_reset
end
def change_status(status) def change_status(status)
@conversation.update!(status: status[0]) @conversation.update!(status: status[0])
end end
def send_webhook_events(webhook_url) def send_webhook_event(webhook_url)
payload = @conversation.webhook_data.merge(event: "automation_event: #{@rule.event_name}") payload = @conversation.webhook_data.merge(event: "automation_event: #{@rule.event_name}")
WebhookJob.perform_later(webhook_url, payload) WebhookJob.perform_later(webhook_url[0], payload)
end end
def send_message(message) def send_message(message)
return if @rule.event_name == 'message_created' return if @rule.event_name == 'message_created'
params = { content: message[0], private: false } params = { content: message[0], private: false }
mb = Messages::MessageBuilder.new(@administrator, @conversation, params) mb = Messages::MessageBuilder.new(nil, @conversation, params)
mb.perform mb.perform
end end
@ -79,10 +94,6 @@ class AutomationRules::ActionService
end end
end end
def administrator
@administrator ||= @account.administrators.first
end
def agent_belongs_to_account?(agent_ids) def agent_belongs_to_account?(agent_ids)
@account.agents.pluck(:id).include?(agent_ids[0]) @account.agents.pluck(:id).include?(agent_ids[0])
end end

View file

@ -1,20 +1,33 @@
require 'json' require 'json'
class AutomationRules::ConditionsFilterService < FilterService class AutomationRules::ConditionsFilterService < FilterService
ATTRIBUTE_MODEL = 'contact_attribute'.freeze
def initialize(rule, conversation = nil) def initialize(rule, conversation = nil)
super([], nil) super([], nil)
@rule = rule @rule = rule
@conversation = conversation @conversation = conversation
@account = conversation.account
file = File.read('./lib/filters/filter_keys.json') file = File.read('./lib/filters/filter_keys.json')
@filters = JSON.parse(file) @filters = JSON.parse(file)
end end
def perform def perform
conversation_filters = @filters['conversations'] conversation_filters = @filters['conversations']
contact_filters = @filters['contacts']
@rule.conditions.each_with_index do |query_hash, current_index| @rule.conditions.each_with_index do |query_hash, current_index|
current_filter = conversation_filters[query_hash['attribute_key']] conversation_filter = conversation_filters[query_hash['attribute_key']]
@query_string += conversation_query_string(current_filter, query_hash.with_indifferent_access, current_index) contact_filter = contact_filters[query_hash['attribute_key']]
if conversation_filter
@query_string += conversation_query_string('conversations', conversation_filter, query_hash.with_indifferent_access, current_index)
elsif contact_filter
@query_string += conversation_query_string('contacts', contact_filter, query_hash.with_indifferent_access, current_index)
elsif custom_attribute(query_hash['attribute_key'], @account)
# send table name according to attribute key right now we are supporting contact based custom attribute filter
@query_string += custom_attribute_query(query_hash.with_indifferent_access, 'contacts', current_index)
end
end end
records = base_relation.where(@query_string, @filter_values.with_indifferent_access) records = base_relation.where(@query_string, @filter_values.with_indifferent_access)
@ -57,25 +70,26 @@ class AutomationRules::ConditionsFilterService < FilterService
records.any? records.any?
end end
def conversation_query_string(current_filter, query_hash, current_index) def conversation_query_string(table_name, current_filter, query_hash, current_index)
attribute_key = query_hash['attribute_key'] attribute_key = query_hash['attribute_key']
query_operator = query_hash['query_operator'] query_operator = query_hash['query_operator']
filter_operator_value = filter_operation(query_hash, current_index) filter_operator_value = filter_operation(query_hash, current_index)
case current_filter['attribute_type'] case current_filter['attribute_type']
when 'additional_attributes' when 'additional_attributes'
" conversations.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} " " #{table_name}.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
when 'standard' when 'standard'
if attribute_key == 'labels' if attribute_key == 'labels'
" tags.id #{filter_operator_value} #{query_operator} " " tags.id #{filter_operator_value} #{query_operator} "
else else
" conversations.#{attribute_key} #{filter_operator_value} #{query_operator} " " #{table_name}.#{attribute_key} #{filter_operator_value} #{query_operator} "
end end
end end
end end
private
def base_relation def base_relation
Conversation.where(id: @conversation.id) Conversation.where(id: @conversation.id).joins('LEFT OUTER JOIN contacts on conversations.contact_id = contacts.id')
end end
end end

View file

@ -26,7 +26,7 @@ class Contacts::FilterService < FilterService
query_operator = query_hash[:query_operator] query_operator = query_hash[:query_operator]
filter_operator_value = filter_operation(query_hash, current_index) filter_operator_value = filter_operation(query_hash, current_index)
return custom_attribute_query(query_hash, current_index) if current_filter.nil? return custom_attribute_query(query_hash, 'contacts', current_index) if current_filter.nil?
case current_filter['attribute_type'] case current_filter['attribute_type']
when 'additional_attributes' when 'additional_attributes'
@ -64,18 +64,4 @@ class Contacts::FilterService < FilterService
"!= :value_#{current_index}" "!= :value_#{current_index}"
end end
def custom_attribute_query(query_hash, current_index)
attribute_key = query_hash[:attribute_key]
query_operator = query_hash[:query_operator]
attribute_type = custom_attribute(attribute_key).try(:attribute_display_type)
filter_operator_value = filter_operation(query_hash, current_index)
attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
if custom_attribute(attribute_key)
" LOWER(contacts.custom_attributes ->> '#{attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
else
' '
end
end
end end

View file

@ -32,7 +32,7 @@ class Conversations::FilterService < FilterService
query_operator = query_hash[:query_operator] query_operator = query_hash[:query_operator]
filter_operator_value = filter_operation(query_hash, current_index) filter_operator_value = filter_operation(query_hash, current_index)
return custom_attribute_query(query_hash, current_index) if current_filter.nil? return custom_attribute_query(query_hash, 'conversations', current_index) if current_filter.nil?
case current_filter['attribute_type'] case current_filter['attribute_type']
when 'additional_attributes' when 'additional_attributes'
@ -62,20 +62,4 @@ class Conversations::FilterService < FilterService
) )
@conversations.latest.page(current_page) @conversations.latest.page(current_page)
end end
private
def custom_attribute_query(query_hash, current_index)
attribute_key = query_hash[:attribute_key]
query_operator = query_hash[:query_operator]
attribute_type = custom_attribute(attribute_key).try(:attribute_display_type)
filter_operator_value = filter_operation(query_hash, current_index)
attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
if custom_attribute(attribute_key)
" LOWER(conversations.custom_attributes ->> '#{attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
else
' '
end
end
end end

View file

@ -28,7 +28,7 @@ class FilterService
@filter_values["value_#{current_index}"] = filter_values(query_hash) @filter_values["value_#{current_index}"] = filter_values(query_hash)
equals_to_filter_string(query_hash[:filter_operator], current_index) equals_to_filter_string(query_hash[:filter_operator], current_index)
when 'contains', 'does_not_contain' when 'contains', 'does_not_contain'
@filter_values["value_#{current_index}"] = "%#{filter_values(query_hash)}%" @filter_values["value_#{current_index}"] = "%#{string_filter_values(query_hash)}%"
like_filter_string(query_hash[:filter_operator], current_index) like_filter_string(query_hash[:filter_operator], current_index)
when 'is_present' when 'is_present'
@filter_values["value_#{current_index}"] = 'IS NOT NULL' @filter_values["value_#{current_index}"] = 'IS NOT NULL'
@ -57,6 +57,12 @@ class FilterService
end end
end end
def string_filter_values(query_hash)
return query_hash['values'][0] if query_hash['values'].is_a?(Array)
query_hash['values']
end
def lt_gt_filter_values(query_hash) def lt_gt_filter_values(query_hash)
attribute_key = query_hash[:attribute_key] attribute_key = query_hash[:attribute_key]
attribute_type = custom_attribute(attribute_key).try(:attribute_display_type) attribute_type = custom_attribute(attribute_key).try(:attribute_display_type)
@ -81,10 +87,26 @@ class FilterService
] ]
end end
def custom_attribute_query(query_hash, table_name, current_index)
attribute_key = query_hash[:attribute_key]
query_operator = query_hash[:query_operator]
attribute_type = custom_attribute(attribute_key, @account).try(:attribute_display_type)
filter_operator_value = filter_operation(query_hash, current_index)
attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
if custom_attribute(attribute_key, @account)
" LOWER(#{table_name}.custom_attributes ->> '#{attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
else
' '
end
end
private private
def custom_attribute(attribute_key) def custom_attribute(attribute_key, account = nil)
@custom_attribute = Current.account.custom_attribute_definitions.where( current_account = account || Current.account
@custom_attribute = current_account.custom_attribute_definitions.where(
attribute_model: self.class::ATTRIBUTE_MODEL attribute_model: self.class::ATTRIBUTE_MODEL
).find_by(attribute_key: attribute_key) ).find_by(attribute_key: attribute_key)
end end

View file

@ -62,7 +62,7 @@ class Instagram::SendOnInstagramService < Base::SendOnChannelService
query: query query: query
) )
Rails.logger.info("Instagram response: #{response['error']} : #{message_content}") if response['error'] Rails.logger.error("Instagram response: #{response['error']} : #{message_content}") if response['error']
message.update!(source_id: response['message_id']) if response['message_id'].present? message.update!(source_id: response['message_id']) if response['message_id'].present?
response response

View file

@ -66,7 +66,7 @@ class Notification::PushNotificationService
rescue Webpush::ExpiredSubscription rescue Webpush::ExpiredSubscription
subscription.destroy! subscription.destroy!
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.info "Webpush operation error: #{e.message}" Rails.logger.error "Webpush operation error: #{e.message}"
end end
def send_fcm_push(subscription) def send_fcm_push(subscription)

View file

@ -1,9 +1,13 @@
class RoundRobin::AssignmentService class RoundRobin::AssignmentService
pattr_initialize [:conversation] pattr_initialize [:conversation, { allowed_member_ids: [] }]
def find_assignee
round_robin_manage_service.available_agent(priority_list: online_agents)
end
def perform def perform
# online agents will get priority # online agents will get priority
new_assignee = round_robin_manage_service.available_agent(priority_list: online_agents) new_assignee = find_assignee
conversation.update(assignee: new_assignee) if new_assignee conversation.update(assignee: new_assignee) if new_assignee
end end
@ -15,7 +19,7 @@ class RoundRobin::AssignmentService
end end
def round_robin_manage_service def round_robin_manage_service
@round_robin_manage_service ||= RoundRobin::ManageService.new(inbox: conversation.inbox) @round_robin_manage_service ||= RoundRobin::ManageService.new(inbox: conversation.inbox, allowed_member_ids: allowed_member_ids)
end end
def round_robin_key def round_robin_key

View file

@ -1,5 +1,7 @@
# If allowed_member_ids are supplied round robin service will only fetch a member from member id
# This is used in case of team assignment
class RoundRobin::ManageService class RoundRobin::ManageService
pattr_initialize [:inbox!] pattr_initialize [:inbox!, { allowed_member_ids: [] }]
# called on inbox delete # called on inbox delete
def clear_queue def clear_queue
@ -18,9 +20,9 @@ class RoundRobin::ManageService
def available_agent(priority_list: []) def available_agent(priority_list: [])
reset_queue unless validate_queue? reset_queue unless validate_queue?
user_id = get_agent_via_priority_list(priority_list) user_id = get_member_via_priority_list(priority_list)
# incase priority list was empty or inbox members weren't present # incase priority list was empty or inbox members weren't present
user_id ||= ::Redis::Alfred.rpoplpush(round_robin_key, round_robin_key) user_id ||= fetch_user_id
inbox.inbox_members.find_by(user_id: user_id)&.user if user_id.present? inbox.inbox_members.find_by(user_id: user_id)&.user if user_id.present?
end end
@ -31,17 +33,36 @@ class RoundRobin::ManageService
private private
def get_agent_via_priority_list(priority_list) def fetch_user_id
if allowed_member_ids_in_str.present?
user_id = queue.intersection(allowed_member_ids_in_str).pop
pop_push_to_queue(user_id)
user_id
else
::Redis::Alfred.rpoplpush(round_robin_key, round_robin_key)
end
end
# priority list is usually the members who are online passed from assignmebt service
def get_member_via_priority_list(priority_list)
return if priority_list.blank?
# when allowed member ids is passed we will be looking to get members from that list alone
priority_list = priority_list.intersection(allowed_member_ids_in_str) if allowed_member_ids_in_str.present?
return if priority_list.blank? return if priority_list.blank?
user_id = queue.intersection(priority_list.map(&:to_s)).pop user_id = queue.intersection(priority_list.map(&:to_s)).pop
if user_id.present? pop_push_to_queue(user_id)
remove_agent_from_queue(user_id)
add_agent_to_queue(user_id)
end
user_id user_id
end end
def pop_push_to_queue(user_id)
return if user_id.blank?
remove_agent_from_queue(user_id)
add_agent_to_queue(user_id)
end
def validate_queue? def validate_queue?
return true if inbox.inbox_members.map(&:user_id).sort == queue.map(&:to_i).sort return true if inbox.inbox_members.map(&:user_id).sort == queue.map(&:to_i).sort
end end
@ -53,4 +74,9 @@ class RoundRobin::ManageService
def round_robin_key def round_robin_key
format(::Redis::Alfred::ROUND_ROBIN_AGENTS, inbox_id: inbox.id) format(::Redis::Alfred::ROUND_ROBIN_AGENTS, inbox_id: inbox.id)
end end
def allowed_member_ids_in_str
# NOTE: the values which are returned from redis for priority list are string
@allowed_member_ids_in_str ||= allowed_member_ids.map(&:to_s)
end
end end

View file

@ -5,14 +5,14 @@ class Twilio::WebhookSetupService
def perform def perform
if phone_numbers.empty? if phone_numbers.empty?
Rails.logger.info "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}" Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
else else
twilio_client twilio_client
.incoming_phone_numbers(phonenumber_sid) .incoming_phone_numbers(phonenumber_sid)
.update(sms_method: 'POST', sms_url: twilio_callback_index_url) .update(sms_method: 'POST', sms_url: twilio_callback_index_url)
end end
rescue Twilio::REST::TwilioError => e rescue Twilio::REST::TwilioError => e
Rails.logger.info "TWILIO_FAILURE: #{e.message}" Rails.logger.error "TWILIO_FAILURE: #{e.message}"
end end
private private

View file

@ -58,7 +58,7 @@ class Twitter::SendOnTwitterService < Base::SendOnChannelService
tweet_data = response.body tweet_data = response.body
message.update!(source_id: tweet_data['id_str']) message.update!(source_id: tweet_data['id_str'])
else else
Rails.logger.info "TWITTER_TWEET_REPLY_ERROR #{response.body}" Rails.logger.error "TWITTER_TWEET_REPLY_ERROR #{response.body}"
end end
end end
end end

View file

@ -40,7 +40,7 @@ window.addEventListener('chatwoot:ready', function() {
console.log('chatwoot:ready', window.$chatwoot); console.log('chatwoot:ready', window.$chatwoot);
if (window.location.search.includes('setUser')) { if (window.location.search.includes('setUser')) {
window.$chatwoot.setUser('<%= user_id %>', { window.$chatwoot.setUser('<%= user_id %>', {
identifier_hash: 'a<%= user_hash %>', identifier_hash: '<%= user_hash %>',
email: 'jane@example.com', email: 'jane@example.com',
name: 'Jane Doe', name: 'Jane Doe',
phone_number: '' phone_number: ''
@ -49,6 +49,6 @@ window.addEventListener('chatwoot:ready', function() {
}) })
window.addEventListener('chatwoot:error', function(e) { window.addEventListener('chatwoot:error', function(e) {
console.log('chatwoot:error', e.details) console.log('chatwoot:error', e.detail)
}) })
</script> </script>

View file

@ -91,7 +91,7 @@ end
# Log blocked events # Log blocked events
ActiveSupport::Notifications.subscribe('throttle.rack_attack') do |_name, _start, _finish, _request_id, payload| 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 end
Rack::Attack.enabled = Rails.env.production? ? ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) : false Rack::Attack.enabled = Rails.env.production? ? ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) : false

View file

@ -52,8 +52,8 @@ Rails.application.routes.draw do
post :reauthorize_page post :reauthorize_page
end end
end end
resources :canned_responses, except: [:show, :edit, :new] resources :canned_responses, only: [:index, :create, :update, :destroy]
resources :automation_rules, except: [:edit] do resources :automation_rules, only: [:index, :create, :show, :update, :destroy] do
post :clone post :clone
end end
resources :campaigns, only: [:index, :create, :show, :update, :destroy] resources :campaigns, only: [:index, :create, :show, :update, :destroy]
@ -145,7 +145,7 @@ Rails.application.routes.draw do
resource :authorization, only: [:create] resource :authorization, only: [:create]
end end
resources :webhooks, except: [:show] resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do namespace :integrations do
resources :apps, only: [:index, :show] resources :apps, only: [:index, :show]
resources :hooks, only: [:create, :update, :destroy] resources :hooks, only: [:create, :update, :destroy]
@ -212,6 +212,7 @@ Rails.application.routes.draw do
get :inboxes get :inboxes
get :labels get :labels
get :teams get :teams
get :conversations
end end
end end
end end

View file

@ -18,6 +18,7 @@
- [bots, 1] - [bots, 1]
- [active_storage_analysis, 1] - [active_storage_analysis, 1]
- [action_mailbox_incineration, 1] - [action_mailbox_incineration, 1]
- [active_storage_purge, 1]
- [integrations, 2] - [integrations, 2]
- [default, 2] - [default, 2]
- [mailers, 2] - [mailers, 2]

View file

@ -39,7 +39,7 @@ class ChatwootHub
response = RestClient.post(PING_URL, info.to_json, { content_type: :json, accept: :json }) response = RestClient.post(PING_URL, info.to_json, { content_type: :json, accept: :json })
version = JSON.parse(response)['version'] version = JSON.parse(response)['version']
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.info "Exception: #{e.message}" Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) Sentry.capture_exception(e)
end end
@ -50,7 +50,7 @@ class ChatwootHub
info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true } 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 }) RestClient.post(REGISTRATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.info "Exception: #{e.message}" Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) Sentry.capture_exception(e)
end end
@ -59,7 +59,7 @@ class ChatwootHub
info = { fcm_token_list: fcm_token_list, fcm_options: fcm_options } 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 }) RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.info "Exception: #{e.message}" Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) Sentry.capture_exception(e)
end end
@ -70,7 +70,7 @@ class ChatwootHub
info = { event_name: event_name, event_data: event_data } info = { event_name: event_name, event_data: event_data }
RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.info "Exception: #{e.message}" Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) Sentry.capture_exception(e)
end end

View file

@ -142,6 +142,13 @@
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes" "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": { "labels": {
"attribute_name": "Labels", "attribute_name": "Labels",
"input_type": "tags", "input_type": "tags",

View file

@ -48,7 +48,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
post_message if message_content.present? post_message if message_content.present?
upload_file if message.attachments.any? upload_file if message.attachments.any?
rescue Slack::Web::Api::Errors::AccountInactive => e rescue Slack::Web::Api::Errors::AccountInactive => e
Rails.logger.info e Rails.logger.error e
hook.authorization_error! hook.authorization_error!
hook.disable if hook.enabled? hook.disable if hook.enabled?
end end

View file

@ -84,6 +84,23 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
}.with_indifferent_access }.with_indifferent_access
end 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 it 'Saves for automation_rules for account with country_code and browser_language conditions' do
expect(account.automation_rules.count).to eq(0) 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(response).to have_http_status(:success)
expect(account.automation_rules.count).to eq(1) expect(account.automation_rules.count).to eq(1)
end 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
end end

View file

@ -35,6 +35,9 @@ RSpec.describe 'Conversation Assignment API', type: :request do
end end
it 'assigns a team to the conversation' do 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 } params = { team_id: team.id }
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_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(response).to have_http_status(:success)
expect(conversation.reload.team).to eq(team) expect(conversation.reload.team).to eq(team)
# assignee will be from team
expect(conversation.reload.assignee).to eq(team_member)
end end
end end

View file

@ -7,7 +7,8 @@ RSpec.describe 'Reports API', type: :request do
let!(:user) { create(:user, account: account) } let!(:user) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account) } let!(:inbox) { create(:inbox, account: account) }
let(:inbox_member) { create(:inbox_member, user: user, inbox: inbox) } 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 } } let(:params) { { timezone_offset: Time.zone.utc_offset } }
before do 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.length).to eq(1)
expect(current_day_metric[0]['value']).to eq(10) expect(current_day_metric[0]['value']).to eq(10)
end 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
end end

View file

@ -13,7 +13,7 @@ if defined?(ActiveRecord::Base)
ActiveRecord::Base.descendants.each_with_object({}) do |record_class, records| ActiveRecord::Base.descendants.each_with_object({}) do |record_class, records|
records[record_class.to_s] = record_class.limit(100).map(&:attributes) records[record_class.to_s] = record_class.limit(100).map(&:attributes)
rescue StandardError => e rescue StandardError => e
Rails.logger.info e.message Rails.logger.error e.message
end end
end end

View file

@ -15,6 +15,12 @@ describe AutomationRuleListener do
end end
before do 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_1, team: team)
create(:team_member, user: user_2, team: team) create(:team_member, user: user_2, team: team)
create(:account_user, user: user_2, account: account) 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' => 'assign_team', 'action_params' => [team.id] },
{ 'action_name' => 'add_label', 'action_params' => %w[support priority_customer] }, { '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' => 'assign_best_agent', 'action_params' => [user_1.id] },
{ 'action_name' => 'send_email_transcript', 'action_params' => 'new_agent@example.com' }, { 'action_name' => 'send_email_transcript', 'action_params' => 'new_agent@example.com' },
{ 'action_name' => 'mute_conversation', 'action_params' => nil }, { 'action_name' => 'mute_conversation', 'action_params' => nil },
{ 'action_name' => 'change_status', 'action_params' => ['snoozed'] }, { '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 end
describe '#conversation_status_changed' do describe '#conversation_status_changed' do
@ -77,6 +87,7 @@ describe AutomationRuleListener do
listener.conversation_status_changed(event) listener.conversation_status_changed(event)
conversation.reload conversation.reload
expect(conversation.labels.pluck(:name)).to contain_exactly('support', 'priority_customer') expect(conversation.labels.pluck(:name)).to contain_exactly('support', 'priority_customer')
end end
@ -105,7 +116,7 @@ describe AutomationRuleListener do
conversation.reload conversation.reload
expect(conversation.messages.last.content).to eq('Send this message.') expect(conversation.messages.first.content).to eq('Send this message.')
end end
it 'triggers automation rule changes status to snoozed' do it 'triggers automation rule changes status to snoozed' do
@ -143,6 +154,120 @@ describe AutomationRuleListener do
listener.conversation_status_changed(event) listener.conversation_status_changed(event)
end 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
end end
@ -224,7 +349,7 @@ describe AutomationRuleListener do
conversation.reload 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 end
end end

View file

@ -105,4 +105,80 @@ describe WebhookListener do
end end
end 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 end

View file

@ -54,7 +54,8 @@ RSpec.describe Conversation, type: :model do
it 'runs after_create callbacks' do it 'runs after_create callbacks' do
# send_events # send_events
expect(Rails.configuration.dispatcher).to have_received(:dispatch) 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
end end
@ -115,14 +116,21 @@ RSpec.describe Conversation, type: :model do
assignee: new_assignee, assignee: new_assignee,
label_list: [label.title] label_list: [label.title]
) )
status_change = conversation.status_change
changed_attributes = conversation.previous_changes
expect(Rails.configuration.dispatcher).to have_received(:dispatch) 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) 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) 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) 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 end
it 'will not run conversation_updated event for empty updates' do it 'will not run conversation_updated event for empty updates' do

View file

@ -1,7 +1,7 @@
require 'rails_helper' require 'rails_helper'
describe RoundRobin::ManageService do 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!(:account) { create(:account) }
let!(:inbox) { create(:inbox, account: 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 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, 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) inbox_members[0].user_id].map(&:to_s)
expect(round_robin_manage_service.available_agent(priority_list: [inbox_members[3].user_id, # prority list will be ids in string, since thats what redis supplies to us
inbox_members[2].user_id])).to eq inbox_members[2].user 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) expect(round_robin_manage_service.send(:queue)).to eq(expected_queue)
end end
@ -39,5 +40,32 @@ describe RoundRobin::ManageService do
# the service have refreshed the redis queue before performing # 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) expect(round_robin_manage_service.send(:queue).sort.map(&:to_i)).to eq(inbox_members.map(&:user_id).sort)
end 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
end end

View file

@ -12,6 +12,8 @@ request_error:
generic_id: generic_id:
$ref: ./resource/extension/generic.yml $ref: ./resource/extension/generic.yml
canned_response:
$ref: ./resource/canned_response.yml
contact: contact:
$ref: ./resource/contact.yml $ref: ./resource/contact.yml
conversation: conversation:
@ -64,6 +66,9 @@ agent_bot_create_update_payload:
user_create_update_payload: user_create_update_payload:
$ref: ./request/user/create_update_payload.yml $ref: ./request/user/create_update_payload.yml
canned_response_create_update_payload:
$ref: ./request/canned_response/create_update_payload.yml
## contact ## contact
contact_create: contact_create:
$ref: ./request/contact/create.yml $ref: ./request/contact/create.yml
@ -144,10 +149,11 @@ extended_message:
- $ref: ./resource/extension/message/with_source_sender.yml - $ref: ./resource/extension/message/with_source_sender.yml
## report list ## report
report: account_summary:
type: array $ref: './resource/reports/summary.yml'
description: 'array of conversation count based on date' agent_conversation_metrics:
items: $ref: './resource/reports/conversation/agent.yml'
allOf:
- $ref: './resource/report.yml'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -54,6 +54,7 @@ x-tagGroups:
tags: tags:
- Account AgentBots - Account AgentBots
- Agent - Agent
- Canned Response
- Contact - Contact
- Conversation - Conversation
- Conversation Assignment - Conversation Assignment

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -10,7 +10,12 @@ responses:
type: array type: array
description: 'Array of date based conversation statistics' description: 'Array of date based conversation statistics'
items: items:
$ref: '#/definitions/report' type: object
properties:
value:
type: string
timestamp:
type: number
404: 404:
description: reports not found description: reports not found
403: 403:

View file

@ -7,10 +7,8 @@ responses:
200: 200:
description: Success description: Success
schema: schema:
type: array description: 'Object of summary metrics'
description: 'Array of date based conversation statistics' $ref: '#/definitions/account_summary'
items:
$ref: '#/definitions/report'
404: 404:
description: reports not found description: reports not found
403: 403:

View file

@ -154,6 +154,21 @@
delete: delete:
$ref: ./application/agents/delete.yml $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 # Contacts
/api/v1/accounts/{account_id}/contacts: /api/v1/accounts/{account_id}/contacts:
@ -376,3 +391,35 @@
description: The timestamp from where report should stop. description: The timestamp from where report should stop.
get: get:
$ref: './application/reports/summary.yml' $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'

View file

@ -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": { "/api/v1/accounts/{account_id}/contacts": {
"get": { "get": {
"tags": [ "tags": [
@ -3443,7 +3604,15 @@
"type": "array", "type": "array",
"description": "Array of date based conversation statistics", "description": "Array of date based conversation statistics",
"items": { "items": {
"$ref": "#/definitions/report" "type": "object",
"properties": {
"value": {
"type": "string"
},
"timestamp": {
"type": "number"
}
}
} }
} }
}, },
@ -3490,14 +3659,110 @@
"operationId": "list-all-conversation-statistics-summary", "operationId": "list-all-conversation-statistics-summary",
"summary": "Get Account reports summary", "summary": "Get Account reports summary",
"description": "Get Account reports summary for a specific type and date range", "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": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
"schema": { "schema": {
"type": "array", "type": "array",
"description": "Array of date based conversation statistics", "description": "Array of agent based conversation metrics",
"items": { "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": { "contact": {
"type": "object", "type": "object",
"properties": { "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": { "contact_create": {
"type": "object", "type": "object",
"required": [ "required": [
@ -4684,58 +4983,80 @@
} }
] ]
}, },
"report": { "account_summary": {
"type": "array", "type": "object",
"description": "array of conversation count based on date", "properties": {
"items": { "avg_first_response_time": {
"allOf": [ "type": "string"
{ },
"type": "object", "avg_resolution_time": {
"properties": { "type": "string"
"avg_first_response_time": { },
"type": "string" "conversations_count": {
}, "type": "number"
"avg_resolution_time": { },
"type": "string" "incoming_messages_count": {
}, "type": "number"
"conversations_count": { },
"type": "number" "outgoing_messages_count": {
}, "type": "number"
"incoming_messages_count": { },
"type": "number" "resolutions_count": {
}, "type": "number"
"outgoing_messages_count": { },
"type": "number" "previous": {
}, "type": "object",
"resolutions_count": { "properties": {
"type": "number" "avg_first_response_time": {
}, "type": "string"
"previous": { },
"type": "object", "avg_resolution_time": {
"properties": { "type": "string"
"avg_first_response_time": { },
"type": "string" "conversations_count": {
}, "type": "number"
"avg_resolution_time": { },
"type": "string" "incoming_messages_count": {
}, "type": "number"
"conversations_count": { },
"type": "number" "outgoing_messages_count": {
}, "type": "number"
"incoming_messages_count": { },
"type": "number" "resolutions_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": [ "tags": [
"Account AgentBots", "Account AgentBots",
"Agent", "Agent",
"Canned Response",
"Contact", "Contact",
"Conversation", "Conversation",
"Conversation Assignment", "Conversation Assignment",