Compare commits

...
Author SHA1 Message Date
Sojan e93af2681e Merge branch 'release/3.7.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-03-18 17:27:13 +05:30
Sojan e1e8857195 Bump version to 3.7.0 2024-03-18 17:26:09 +05:30
Muhsin KelothandGitHub b5c6d90abd chore: Fix CSATMetrics component snap shot spec (#9122)
chore: Fix snapshot specs
2024-03-18 17:02:23 +05:30
Sojan JoseandGitHub fd832d7593 chore: Add verbose errors for IMAP email channels (#9116) 2024-03-15 20:53:23 -07:00
Vishnu NarayananandGitHub 29e44ac6d0 feat: add option to delete and disable edits for SLA (#9108)
* feat: add delete button for SLA

* chore: remove edit SLA

* chore: remove update related texts from i18n
2024-03-15 12:21:32 +05:30
Sivin VargheseandGitHub 58ee2e125a fix: Update the error response during changing the password from profile settings (#9114) 2024-03-14 23:07:19 -07:00
89d0b2cb6e feat: Add the bot performance reports UI (#9036)
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-03-14 23:04:14 -07:00
476077ab84 fix: Update location component to avoid overflow, handle location title from Telegram payload (#9113)
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-03-14 22:53:43 -07:00
Muhsin KelothandGitHub 586552013e feat: Update the contact_type when creating or updating the contact (#9107)
* feat: Update location and country code when the contact create/update

* feat: Update the location and country_code when creating or updating the contact.

* chore: improve comments

* feat: Update the contact_type when the contact created/updated

* chore: add more specs

* chore: code cleanups

* chore: code cleanups

* Update contact_spec.rb

* Update inbox.rb

* Update sync_attributes_spec.rb

* chore: build fixes

* chore: check visitor type before update

* chore: review fixes
2024-03-15 10:55:40 +05:30
Sojan JoseandGitHub 3dae3ff3ad feat: Conversation update API for sla_policy_id (#8973)
- Add an endpoint for updating conversation attributes (priority / sla_policy_id )
- Swagger spec
- minor chores around the conversation API/spec

Fixes: https://linear.app/chatwoot/issue/CW-2100/feat-backend-api-to-update-the-sla-of-a-conversation
2024-03-14 17:22:32 +05:30
Sojan cb2b75ca17 Merge branch 'release/3.6.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-02-19 15:59:20 +05:30
Sojan 608592db0c Merge branch 'release/3.5.2'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-19 15:15:27 +04:00
Sojan ab1f803354 Merge branch 'release/3.5.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-17 13:48:22 +04:00
Sojan b81c722ac8 Merge branch 'release/3.5.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-01-16 11:15:11 +04:00
Sojan b40ad399f6 Merge branch 'release/3.4.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-12-18 21:22:55 -08:00
Sojan dcd18072b4 Merge branch 'release/3.3.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-18 10:24:25 -08:00
Sojan bda2d1d9a4 Merge branch 'release/3.3.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-11-17 20:37:25 -08:00
Sojan 83231b9678 Merge branch 'release/3.2.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-10-17 17:55:34 -07:00
52 changed files with 964 additions and 214 deletions
@@ -36,6 +36,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
end
def update
@conversation.update!(permitted_update_params)
end
def filter
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations]
@@ -110,6 +114,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
private
def permitted_update_params
# TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute
params.permit(:priority)
end
def update_last_seen_on_conversation(last_seen_at, update_assignee)
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@@ -176,3 +185,5 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.assignee_id? && Current.user == @conversation.assignee
end
end
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')
@@ -1,6 +1,6 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create],
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update],
'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create']
}.freeze
+18
View File
@@ -84,6 +84,24 @@ class ReportsAPI extends ApiClient {
params: { since, until, business_hours: businessHours },
});
}
getBotMetrics({ from, to } = {}) {
return axios.get(`${this.url}/bot_metrics`, {
params: { since: from, until: to },
});
}
getBotSummary({ from, to, groupBy, businessHours } = {}) {
return axios.get(`${this.url}/bot_summary`, {
params: {
since: from,
until: to,
type: 'account',
group_by: groupBy,
business_hours: businessHours,
},
});
}
}
export default new ReportsAPI();
@@ -111,6 +111,40 @@ describe('#Reports API', () => {
});
});
it('#getBotMetrics', () => {
reportsAPI.getBotMetrics({ from: 1621103400, to: 1621621800 });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/bot_metrics',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
it('#getBotSummary', () => {
reportsAPI.getBotSummary({
from: 1621103400,
to: 1621621800,
groupBy: 'date',
businessHours: true,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/bot_summary',
{
params: {
since: 1621103400,
until: 1621621800,
type: 'account',
group_by: 'date',
business_hours: true,
},
}
);
});
it('#getConversationMetric', () => {
reportsAPI.getConversationMetric('account');
expect(axiosMock.get).toHaveBeenCalledWith(
@@ -1,3 +1,4 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
const reports = accountId => ({
@@ -6,6 +7,7 @@ const reports = accountId => ({
'account_overview_reports',
'conversation_reports',
'csat_reports',
'bot_reports',
'agent_reports',
'label_reports',
'inbox_reports',
@@ -33,6 +35,14 @@ const reports = accountId => ({
toState: frontendURL(`accounts/${accountId}/reports/csat`),
toStateName: 'csat_reports',
},
{
icon: 'bot',
label: 'REPORTS_BOT',
hasSubMenu: false,
featureFlag: FEATURE_FLAGS.RESPONSE_BOT,
toState: frontendURL(`accounts/${accountId}/reports/bot`),
toStateName: 'bot_reports',
},
{
icon: 'people',
label: 'REPORTS_AGENT',
@@ -1,17 +1,45 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
latitude: {
type: Number,
default: undefined,
},
longitude: {
type: Number,
default: undefined,
},
name: {
type: String,
default: '',
},
});
const mapUrl = computed(
() => `https://maps.google.com/?q=${props.latitude},${props.longitude}`
);
</script>
<template>
<div class="location message-text__wrap">
<div class="icon-wrap">
<fluent-icon icon="location" class="file--icon" size="32" />
</div>
<div class="meta">
<div
class="flex flex-row items-center justify-start gap-1 w-full py-1 px-0 cursor-pointer overflow-hidden"
>
<fluent-icon
icon="location"
class="text-slate-600 dark:text-slate-200 leading-none my-0 flex items-center flex-shrink-0"
size="32"
/>
<div class="flex flex-col items-start flex-1 min-w-0">
<h5
class="text-sm text-slate-800 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis"
class="text-sm text-slate-800 dark:text-slate-100 truncate m-0 w-full"
:title="name"
>
{{ name }}
</h5>
<div class="link-wrap">
<div class="flex items-center">
<a
class="download clear link button small"
class="text-woot-600 dark:text-woot-600 text-xs underline"
rel="noreferrer noopener nofollow"
target="_blank"
:href="mapUrl"
@@ -22,49 +50,3 @@
</div>
</div>
</template>
<script>
export default {
props: {
latitude: {
type: Number,
default: undefined,
},
longitude: {
type: Number,
default: undefined,
},
name: {
type: String,
default: '',
},
},
computed: {
mapUrl() {
return `https://maps.google.com/?q=${this.latitude},${this.longitude}`;
},
},
};
</script>
<style lang="scss" scoped>
.location {
@apply flex flex-row py-1 px-0 cursor-pointer;
.icon-wrap {
@apply text-slate-600 dark:text-slate-200 leading-none my-0 mx-1;
}
.text-block-title {
@apply m-0 text-slate-800 dark:text-slate-100 break-words;
}
.meta {
@apply flex flex-col items-center pr-4;
}
.link-wrap {
@apply flex;
}
}
</style>
+1
View File
@@ -19,4 +19,5 @@ export const FEATURE_FLAGS = {
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
INBOX_VIEW: 'inbox_view',
SLA: 'sla',
RESPONSE_BOT: 'response_bot',
};
@@ -35,6 +35,14 @@
"NAME": "Resolution Count",
"DESC": "( Total )"
},
"BOT_RESOLUTION_COUNT": {
"NAME": "Resolution Count",
"DESC": "( Total )"
},
"BOT_HANDOFF_COUNT": {
"NAME": "Handoff Count",
"DESC": "( Total )"
},
"REPLY_TIME": {
"NAME": "Customer waiting time",
"TOOLTIP_TEXT": "Waiting time is %{metricValue} (based on %{conversationCount} replies)"
@@ -86,20 +94,49 @@
"MONTH": "Month",
"YEAR": "Year"
},
"GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }],
"GROUP_BY_DAY_OPTIONS": [
{
"id": 1,
"groupBy": "Day"
}
],
"GROUP_BY_WEEK_OPTIONS": [
{ "id": 1, "groupBy": "Day" },
{ "id": 2, "groupBy": "Week" }
{
"id": 1,
"groupBy": "Day"
},
{
"id": 2,
"groupBy": "Week"
}
],
"GROUP_BY_MONTH_OPTIONS": [
{ "id": 1, "groupBy": "Day" },
{ "id": 2, "groupBy": "Week" },
{ "id": 3, "groupBy": "Month" }
{
"id": 1,
"groupBy": "Day"
},
{
"id": 2,
"groupBy": "Week"
},
{
"id": 3,
"groupBy": "Month"
}
],
"GROUP_BY_YEAR_OPTIONS": [
{ "id": 2, "groupBy": "Week" },
{ "id": 3, "groupBy": "Month" },
{ "id": 4, "groupBy": "Year" }
{
"id": 2,
"groupBy": "Week"
},
{
"id": 3,
"groupBy": "Month"
},
{
"id": 4,
"groupBy": "Year"
}
],
"BUSINESS_HOURS": "Business Hours"
},
@@ -404,6 +441,27 @@
}
}
},
"BOT_REPORTS": {
"HEADER": "Bot Reports",
"METRIC": {
"TOTAL_CONVERSATIONS": {
"LABEL": "No. of Conversations",
"TOOLTIP": "Total number of conversations handled by the bot"
},
"TOTAL_RESPONSES": {
"LABEL": "Total Responses",
"TOOLTIP": "Total number of responses sent by the bot"
},
"RESOLUTION_RATE": {
"LABEL": "Resolution Rate",
"TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100"
},
"HANDOFF_RATE": {
"LABEL": "Handoff Rate",
"TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100"
}
}
},
"OVERVIEW_REPORTS": {
"HEADER": "Overview",
"LIVE": "Live",
@@ -234,6 +234,7 @@
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
"ONE_OFF": "One off",
"REPORTS_BOT": "Bot",
"REPORTS_AGENT": "Agents",
"REPORTS_LABEL": "Labels",
"REPORTS_INBOX": "Inbox",
@@ -55,11 +55,17 @@
"ERROR_MESSAGE": "There was an error, please try again"
}
},
"EDIT": {
"TITLE": "Edit SLA",
"DELETE": {
"TITLE": "Delete SLA",
"API": {
"SUCCESS_MESSAGE": "SLA updated successfully",
"SUCCESS_MESSAGE": "SLA deleted successfully",
"ERROR_MESSAGE": "There was an error, please try again"
},
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure you want to delete ",
"YES": "Yes, Delete ",
"NO": "No, Keep "
}
}
}
@@ -2,11 +2,19 @@ import { mapGetters } from 'vuex';
import { formatTime } from '@chatwoot/utils';
export default {
props: {
accountSummaryKey: {
type: String,
default: 'getAccountSummary',
},
},
computed: {
...mapGetters({
accountSummary: 'getAccountSummary',
accountReport: 'getAccountReports',
}),
accountSummary() {
return this.$store.getters[this.accountSummaryKey];
},
},
methods: {
calculateTrend(key) {
@@ -11,11 +11,42 @@ describe('reportMixin', () => {
beforeEach(() => {
getters = {
getAccountSummary: () => reportFixtures.summary,
getBotSummary: () => reportFixtures.botSummary,
getAccountReports: () => reportFixtures.report,
};
store = new Vuex.Store({ getters });
});
it('display the metric for account', async () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [reportMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
await wrapper.setProps({
accountSummaryKey: 'getAccountSummary',
});
expect(wrapper.vm.displayMetric('conversations_count')).toEqual('5,000');
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
'3 Min 18 Sec'
);
});
it('display the metric for bot', async () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [reportMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
await wrapper.setProps({
accountSummaryKey: 'getBotSummary',
});
expect(wrapper.vm.displayMetric('bot_resolutions_count')).toEqual('10');
expect(wrapper.vm.displayMetric('bot_handoffs_count')).toEqual('20');
});
it('display the metric', () => {
const Component = {
render() {},
@@ -15,6 +15,14 @@ export default {
},
resolutions_count: 3,
},
botSummary: {
bot_resolutions_count: 10,
bot_handoffs_count: 20,
previous: {
bot_resolutions_count: 8,
bot_handoffs_count: 5,
},
},
report: {
data: [
{ value: '0.00', timestamp: 1647541800, count: 0 },
@@ -74,6 +74,7 @@
import { required, minLength } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
export default {
mixins: [alertMixin],
@@ -125,10 +126,9 @@ export default {
});
this.errorMessage = this.$t('PROFILE_SETTINGS.PASSWORD_UPDATE_SUCCESS');
} catch (error) {
this.errorMessage = this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
if (error?.response?.data?.message) {
this.errorMessage = error.response.data.message;
}
this.errorMessage =
parseAPIErrorResponse(error) ||
this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
} finally {
this.isPasswordChanging = false;
this.showAlert(this.errorMessage);
@@ -0,0 +1,106 @@
<template>
<div class="flex-1 overflow-auto p-4">
<report-filter-selector
:show-agents-filter="false"
:show-group-by-filter="true"
:show-business-hours-switch="false"
@filter-change="onFilterChange"
/>
<bot-metrics :filters="requestPayload" />
<report-container
:group-by="groupBy"
:report-keys="reportKeys"
:account-summary-key="'getBotSummary'"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import BotMetrics from './components/BotMetrics.vue';
import ReportFilterSelector from './components/FilterSelector.vue';
import { GROUP_BY_FILTER } from './constants';
import reportMixin from 'dashboard/mixins/reportMixin';
import ReportContainer from './ReportContainer.vue';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
export default {
name: 'BotReports',
components: {
BotMetrics,
ReportFilterSelector,
ReportContainer,
},
mixins: [reportMixin],
data() {
return {
from: 0,
to: 0,
groupBy: GROUP_BY_FILTER[1],
reportKeys: {
BOT_RESOLUTION_COUNT: 'bot_resolutions_count',
BOT_HANDOFF_COUNT: 'bot_handoffs_count',
},
businessHours: false,
};
},
computed: {
...mapGetters({
accountReport: 'getAccountReports',
}),
requestPayload() {
return {
from: this.from,
to: this.to,
};
},
},
methods: {
fetchAllData() {
this.fetchBotSummary();
this.fetchChartData();
},
fetchBotSummary() {
try {
this.$store.dispatch('fetchBotSummary', this.getRequestPayload());
} catch {
this.showAlert(this.$t('REPORT.SUMMARY_FETCHING_FAILED'));
}
},
fetchChartData() {
Object.keys(this.reportKeys).forEach(async key => {
try {
await this.$store.dispatch('fetchAccountReport', {
metric: this.reportKeys[key],
...this.getRequestPayload(),
});
} catch {
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
}
});
},
getRequestPayload() {
const { from, to, groupBy, businessHours } = this;
return {
from,
to,
groupBy: groupBy?.period,
businessHours,
};
},
onFilterChange({ from, to, groupBy, businessHours }) {
this.from = from;
this.to = to;
this.groupBy = groupBy;
this.businessHours = businessHours;
this.fetchAllData();
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
filterValue: { from, to, groupBy, businessHours },
reportType: 'bots',
});
},
},
};
</script>
@@ -7,7 +7,7 @@
:key="metric.KEY"
class="p-4 rounded-md mb-3"
>
<chart-stats :metric="metric" />
<chart-stats :metric="metric" :account-summary-key="accountSummaryKey" />
<div class="mt-4 h-72">
<woot-loading-state
v-if="accountReport.isFetching[metric.KEY]"
@@ -0,0 +1,68 @@
<script setup>
import { ref, watch, onMounted } from 'vue';
import ReportMetricCard from './ReportMetricCard.vue';
import ReportsAPI from 'dashboard/api/reports';
const props = defineProps({
filters: {
type: Object,
required: true,
},
});
const conversationCount = ref('0');
const messageCount = ref('0');
const resolutionRate = ref('0');
const handoffRate = ref('0');
const formatToPercent = value => {
return value ? `${value}%` : '--';
};
const fetchMetrics = () => {
if (!props.filters.to || !props.filters.from) {
return;
}
ReportsAPI.getBotMetrics(props.filters).then(response => {
conversationCount.value = response.data.conversation_count.toLocaleString();
messageCount.value = response.data.message_count.toLocaleString();
resolutionRate.value = response.data.resolution_rate.toString();
handoffRate.value = response.data.handoff_rate.toString();
});
};
watch(() => props.filters, fetchMetrics, { deep: true });
onMounted(fetchMetrics);
</script>
<template>
<div
class="flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700"
>
<report-metric-card
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.TOOLTIP')"
:value="conversationCount"
class="flex-1"
/>
<report-metric-card
:label="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="messageCount"
class="flex-1"
/>
<report-metric-card
:label="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.TOOLTIP')"
:value="formatToPercent(resolutionRate)"
class="flex-1"
/>
<report-metric-card
:label="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.TOOLTIP')"
:value="formatToPercent(handoffRate)"
class="flex-1"
/>
</div>
</template>
@@ -1,6 +1,8 @@
<template>
<div>
<span class="text-sm">{{ metric.NAME }}</span>
<div class="text-slate-900 dark:text-slate-100">
<span class="text-sm">
{{ metric.NAME }}
</span>
<div class="flex items-end">
<div class="font-medium text-xl">
{{ displayMetric(metric.KEY) }}
@@ -6,17 +6,20 @@
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="responseCount"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<csat-metric-card
:disabled="ratingFilterEnabled"
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<csat-metric-card
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
:value="formatToPercent(responseRate)"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<div
@@ -21,7 +21,7 @@ defineProps({
<template>
<div
ref="reportMetricContainer"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%] m-0 p-4"
class="m-0 p-4"
:class="{
'grayscale pointer-events-none opacity-30': disabled,
}"
@@ -2,9 +2,9 @@
exports[`CsatMetrics.vue computes response count correctly 1`] = `
<div class="flex-col lg:flex-row flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700">
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" value="100" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" value="--" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" value="90%" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" value="100" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" value="--" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" value="90%" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"></csat-metric-card-stub>
<!---->
</div>
`;
@@ -194,6 +194,8 @@ export const METRIC_CHART = {
reply_time: TIME_CHART_CONFIG,
avg_resolution_time: TIME_CHART_CONFIG,
resolutions_count: DEFAULT_CHART,
bot_resolutions_count: DEFAULT_CHART,
bot_handoffs_count: DEFAULT_CHART,
};
export const OVERVIEW_METRICS = {
@@ -7,6 +7,7 @@ const LabelReports = () => import('./LabelReports.vue');
const InboxReports = () => import('./InboxReports.vue');
const TeamReports = () => import('./TeamReports.vue');
const CsatResponses = () => import('./CsatResponses.vue');
const BotReports = () => import('./BotReports.vue');
const LiveReports = () => import('./LiveReports.vue');
export default {
@@ -66,6 +67,23 @@ export default {
},
],
},
{
path: frontendURL('accounts/:accountId/reports'),
component: SettingsContent,
props: {
headerTitle: 'BOT_REPORTS.HEADER',
icon: 'bot',
keepAlive: false,
},
children: [
{
path: 'bot',
name: 'bot_reports',
roles: ['administrator'],
component: BotReports,
},
],
},
{
path: frontendURL('accounts/:accountId/reports'),
component: SettingsContent,
@@ -1,60 +0,0 @@
<template>
<div class="h-auto overflow-auto flex flex-col">
<woot-modal-header :header-title="pageTitle" />
<sla-form
:submit-label="$t('SLA.FORM.EDIT')"
:selected-response="selectedResponse"
@submit="editSLA"
@close="onClose"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import validationMixin from './validationMixin';
import validations from './validations';
import SlaForm from './SlaForm.vue';
export default {
components: {
SlaForm,
},
mixins: [alertMixin, validationMixin],
props: {
selectedResponse: {
type: Object,
default: () => {},
},
},
validations,
computed: {
...mapGetters({
uiFlags: 'sla/getUIFlags',
}),
pageTitle() {
return `${this.$t('SLA.EDIT.TITLE')} - ${this.selectedResponse.name}`;
},
},
methods: {
onClose() {
this.$emit('close');
},
async editSLA(payload) {
try {
await this.$store.dispatch('sla/update', {
id: this.selectedResponse.id,
...payload,
});
this.showAlert(this.$t('SLA.EDIT.API.SUCCESS_MESSAGE'));
this.onClose();
} catch (error) {
const errorMessage =
error.message || this.$t('SLA.EDIT.API.ERROR_MESSAGE');
this.showAlert(errorMessage);
}
},
},
};
</script>
@@ -58,14 +58,14 @@
</td>
<td class="button-wrapper">
<woot-button
v-tooltip.top="$t('SLA.FORM.EDIT')"
v-tooltip.top="$t('SLA.FORM.DELETE')"
variant="smooth"
color-scheme="alert"
size="tiny"
color-scheme="secondary"
icon="dismiss-circle"
class-names="grey-btn"
:is-loading="loading[sla.id]"
icon="edit"
@click="openEditPopup(sla)"
@click="openDeletePopup(sla)"
/>
</td>
</tr>
@@ -81,9 +81,16 @@
<add-SLA @close="hideAddPopup" />
</woot-modal>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<edit-SLA :selected-response="selectedResponse" @close="hideEditPopup" />
</woot-modal>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('SLA.DELETE.CONFIRM.TITLE')"
:message="$t('SLA.DELETE.CONFIRM.MESSAGE')"
:message-value="deleteMessage"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
/>
</div>
</template>
<script>
@@ -91,20 +98,18 @@ import { mapGetters } from 'vuex';
import { convertSecondsToTimeUnit } from '@chatwoot/utils';
import AddSLA from './AddSLA.vue';
import EditSLA from './EditSLA.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
AddSLA,
EditSLA,
},
mixins: [alertMixin],
data() {
return {
loading: {},
showAddPopup: false,
showEditPopup: false,
showDeleteConfirmationPopup: false,
selectedResponse: {},
};
},
@@ -113,6 +118,15 @@ export default {
records: 'sla/getSLA',
uiFlags: 'sla/getUIFlags',
}),
deleteConfirmText() {
return this.$t('SLA.DELETE.CONFIRM.YES');
},
deleteRejectText() {
return this.$t('SLA.DELETE.CONFIRM.NO');
},
deleteMessage() {
return ` ${this.selectedResponse.name}`;
},
},
mounted() {
this.$store.dispatch('sla/get');
@@ -124,12 +138,30 @@ export default {
hideAddPopup() {
this.showAddPopup = false;
},
openEditPopup(response) {
this.showEditPopup = true;
openDeletePopup(response) {
this.showDeleteConfirmationPopup = true;
this.selectedResponse = response;
},
hideEditPopup() {
this.showEditPopup = false;
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
confirmDeletion() {
this.loading[this.selectedResponse.id] = true;
this.closeDeletePopup();
this.deleteSla(this.selectedResponse.id);
},
deleteSla(id) {
this.$store
.dispatch('sla/delete', id)
.then(() => {
this.showAlert(this.$t('SLA.DELETE.API.SUCCESS_MESSAGE'));
})
.catch(() => {
this.showAlert(this.$t('SLA.DELETE.API.ERROR_MESSAGE'));
})
.finally(() => {
this.loading[this.selectedResponse.id] = false;
});
},
displayTime(threshold) {
const { time, unit } = convertSecondsToTimeUnit(threshold, {
@@ -19,6 +19,8 @@ const state = {
avg_first_response_time: false,
avg_resolution_time: false,
resolutions_count: false,
bot_resolutions_count: false,
bot_handoffs_count: false,
reply_time: false,
},
data: {
@@ -28,6 +30,8 @@ const state = {
avg_first_response_time: [],
avg_resolution_time: [],
resolutions_count: [],
bot_resolutions_count: [],
bot_handoffs_count: [],
reply_time: [],
},
},
@@ -39,6 +43,13 @@ const state = {
outgoing_messages_count: 0,
reply_time: 0,
resolutions_count: 0,
bot_resolutions_count: 0,
bot_handoffs_count: 0,
previous: {},
},
botSummary: {
bot_resolutions_count: 0,
bot_handoffs_count: 0,
previous: {},
},
overview: {
@@ -60,6 +71,9 @@ const getters = {
getAccountSummary(_state) {
return _state.accountSummary;
},
getBotSummary(_state) {
return _state.botSummary;
},
getAccountConversationMetric(_state) {
return _state.overview.accountConversationMetric;
},
@@ -125,6 +139,20 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
});
},
fetchBotSummary({ commit }, reportObj) {
Report.getBotSummary({
from: reportObj.from,
to: reportObj.to,
groupBy: reportObj.groupBy,
businessHours: reportObj.businessHours,
})
.then(botSummary => {
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
})
.catch(() => {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
});
},
fetchAccountConversationMetric({ commit }, reportObj) {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type)
@@ -243,6 +271,9 @@ const mutations = {
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
_state.accountSummary = summaryData;
},
[types.default.SET_BOT_SUMMARY](_state, summaryData) {
_state.botSummary = summaryData;
},
[types.default.SET_ACCOUNT_CONVERSATION_METRIC](_state, metricData) {
_state.overview.accountConversationMetric = metricData;
},
@@ -50,16 +50,16 @@ export const actions = {
}
},
update: async function update({ commit }, { id, ...updateObj }) {
commit(types.SET_SLA_UI_FLAG, { isUpdating: true });
delete: async function deleteSla({ commit }, id) {
commit(types.SET_SLA_UI_FLAG, { isDeleting: true });
try {
const response = await SlaAPI.update(id, updateObj);
AnalyticsHelper.track(SLA_EVENTS.UPDATE);
commit(types.EDIT_SLA, response.data.payload);
await SlaAPI.delete(id);
AnalyticsHelper.track(SLA_EVENTS.DELETED);
commit(types.DELETE_SLA, id);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_SLA_UI_FLAG, { isUpdating: false });
commit(types.SET_SLA_UI_FLAG, { isDeleting: false });
}
},
};
@@ -74,7 +74,7 @@ export const mutations = {
[types.SET_SLA]: MutationHelpers.set,
[types.ADD_SLA]: MutationHelpers.create,
[types.EDIT_SLA]: MutationHelpers.update,
[types.DELETE_SLA]: MutationHelpers.destroy,
};
export default {
@@ -166,6 +166,7 @@ export default {
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING',
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
+2 -2
View File
@@ -12,10 +12,10 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
process_email_for_channel(channel)
end
rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error e
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
channel.authorization_error!
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e
Rails.logger.error e
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}"
rescue StandardError => e
+3 -6
View File
@@ -61,7 +61,7 @@ class Contact < ApplicationRecord
after_create_commit :dispatch_create_event, :ip_lookup
after_update_commit :dispatch_update_event
after_destroy_commit :dispatch_destroy_event
before_save :update_contact_location_and_country_code
before_save :sync_contact_attributes
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
@@ -207,11 +207,8 @@ class Contact < ApplicationRecord
self.custom_attributes = {} if custom_attributes.blank?
end
def update_contact_location_and_country_code
# TODO: Ensure that location and country_code are updated from additional_attributes.
# We will remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app.
self.location = additional_attributes['city']
self.country_code = additional_attributes['country']
def sync_contact_attributes
::Contacts::SyncAttributes.new(self).perform
end
def dispatch_create_event
+37
View File
@@ -0,0 +1,37 @@
class Contacts::SyncAttributes
attr_reader :contact
def initialize(contact)
@contact = contact
end
def perform
update_contact_location_and_country_code
set_contact_type
end
private
def update_contact_location_and_country_code
# Ensure that location and country_code are updated from additional_attributes.
# TODO: Remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app.
@contact.location = @contact.additional_attributes['city']
@contact.country_code = @contact.additional_attributes['country']
end
def set_contact_type
# If the contact is already a lead or customer then do not change the contact type
return unless @contact.contact_type == 'visitor'
# If the contact has an email or phone number or social details( facebook_user_id, instagram_user_id, etc) then it is a lead
# If contact is from external channel like facebook, instagram, whatsapp, etc then it is a lead
return unless @contact.email.present? || @contact.phone_number.present? || social_details_present?
@contact.contact_type = 'lead'
end
def social_details_present?
@contact.additional_attributes.keys.any? do |key|
key.start_with?('social_') && @contact.additional_attributes[key].present?
end
end
end
@@ -130,6 +130,7 @@ class Telegram::IncomingMessageService
@message.attachments.new(
account_id: @message.account_id,
file_type: :location,
fallback_title: location_fallback_title,
coordinates_lat: location['latitude'],
coordinates_long: location['longitude']
)
@@ -139,6 +140,16 @@ class Telegram::IncomingMessageService
@file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence
end
def location_fallback_title
return '' if venue.blank?
venue[:title] || ''
end
def venue
@venue ||= params.dig(:message, :venue).presence
end
def location
@location ||= params.dig(:message, :location).presence
end
@@ -0,0 +1 @@
json.partial! 'api/v1/conversations/partials/conversation', formats: [:json], conversation: @conversation
@@ -47,3 +47,4 @@ json.last_non_activity_message conversation.messages.where(account_id: conversat
json.last_activity_at conversation.last_activity_at.to_i
json.priority conversation.priority
json.waiting_since conversation.waiting_since.to_i.to_i
json.sla_policy_id conversation.sla_policy_id
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.6.0'
version: '3.7.0'
development:
<<: *shared
+1 -1
View File
@@ -78,7 +78,7 @@ Rails.application.routes.draw do
namespace :channels do
resource :twilio_channel, only: [:create]
end
resources :conversations, only: [:index, :create, :show] do
resources :conversations, only: [:index, :create, :show, :update] do
collection do
get :meta
get :search
@@ -0,0 +1,5 @@
module Enterprise::Api::V1::Accounts::ConversationsController
def permitted_update_params
super.merge(params.permit(:sla_policy_id))
end
end
+7
View File
@@ -23,6 +23,13 @@ class AppliedSla < ApplicationRecord
belongs_to :conversation
validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] }
before_validation :ensure_account_id
enum sla_status: { active: 0, hit: 1, missed: 2 }
private
def ensure_account_id
self.account_id ||= sla_policy&.account_id
end
end
@@ -3,5 +3,35 @@ module Enterprise::EnterpriseConversationConcern
included do
belongs_to :sla_policy, optional: true
has_one :applied_sla, dependent: :destroy
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
end
private
def validate_sla_policy
# TODO: remove these validations once we figure out how to deal with these cases
if sla_policy_id.nil? && changes[:sla_policy_id].first.present?
errors.add(:sla_policy, 'cannot remove sla policy from conversation')
return
end
if changes[:sla_policy_id].first.present?
errors.add(:sla_policy, 'conversation already has a different sla')
return
end
errors.add(:sla_policy, 'sla policy account mismatch') if sla_policy&.account_id != account_id
end
# handling inside a transaction to ensure applied sla record is also created
def ensure_applied_sla_is_created
ActiveRecord::Base.transaction do
yield
create_applied_sla(sla_policy_id: sla_policy_id) if applied_sla.blank?
end
rescue ActiveRecord::RecordInvalid
raise ActiveRecord::Rollback
end
end
+1
View File
@@ -22,6 +22,7 @@ class SlaPolicy < ApplicationRecord
validates :name, presence: true
has_many :conversations, dependent: :nullify
has_many :applied_slas, dependent: :destroy
def push_event_data
{
@@ -8,16 +8,5 @@ module Enterprise::ActionService
Rails.logger.info "SLA:: Adding SLA #{sla_policy.id} to conversation: #{@conversation.id}"
@conversation.update!(sla_policy_id: sla_policy.id)
create_applied_sla(sla_policy)
end
def create_applied_sla(sla_policy)
Rails.logger.info "SLA:: Creating Applied SLA for conversation: #{@conversation.id}"
AppliedSla.create!(
account_id: @conversation.account_id,
sla_policy_id: sla_policy.id,
conversation_id: @conversation.id,
sla_status: 'active'
)
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.6.0",
"version": "3.7.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -210,6 +210,55 @@ RSpec.describe 'Conversations API', type: :request do
end
end
describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do
let(:conversation) { create(:conversation, account: account) }
let(:params) { { priority: 'high' } }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
params: params
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'does not update the conversation if you do not have access to it' do
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'updates the conversation if you are an administrator' do
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
params: params,
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
end
it 'updates the conversation if you are an agent with access to inbox' do
create(:inbox_member, user: agent, inbox: conversation.inbox)
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations' do
let(:contact) { create(:contact, account: account) }
let(:inbox) { create(:inbox, account: account) }
@@ -411,21 +460,6 @@ RSpec.describe 'Conversations API', type: :request do
expect(conversation.reload.status).to eq('snoozed')
expect(conversation.reload.snoozed_until.to_i).to eq(snoozed_until)
end
# TODO: remove this spec when we remove the condition check in controller
# Added for backwards compatibility for bot status
# remove in next release
# it 'toggles the conversation status to pending status when parameter bot is passed' do
# expect(conversation.status).to eq('open')
# post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
# headers: agent.create_new_auth_token,
# params: { status: 'bot' },
# as: :json
# expect(response).to have_http_status(:success)
# expect(conversation.reload.status).to eq('pending')
# end
end
context 'when it is an authenticated bot' do
@@ -0,0 +1,41 @@
require 'rails_helper'
RSpec.describe 'Enterprise Conversations API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do
let(:conversation) { create(:conversation, account: account) }
let(:sla_policy) { create(:sla_policy, account: account) }
let(:params) { { sla_policy_id: sla_policy.id } }
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'updates the conversation if you are an agent with access to inbox' do
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:sla_policy_id]).to eq(sla_policy.id)
end
it 'throws error if conversation already has a different sla' do
conversation.update(sla_policy: create(:sla_policy, account: account))
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body, symbolize_names: true)[:message]).to eq('Sla policy conversation already has a different sla')
end
end
end
end
+51 -11
View File
@@ -7,10 +7,10 @@ RSpec.describe Conversation, type: :model do
describe 'SLA policy updates' do
let!(:conversation) { create(:conversation) }
let!(:sla_policy) { create(:sla_policy) }
let!(:sla_policy) { create(:sla_policy, account: conversation.account) }
it 'generates an activity message when the SLA policy is updated' do
conversation.update(sla_policy_id: sla_policy.id)
conversation.update!(sla_policy_id: sla_policy.id)
perform_enqueued_jobs
@@ -21,18 +21,19 @@ RSpec.describe Conversation, type: :model do
expect(activity_message.content).to include('added SLA policy')
end
it 'generates an activity message when the SLA policy is removed' do
conversation.update(sla_policy_id: sla_policy.id)
conversation.update(sla_policy_id: nil)
# TODO: Reenable this when we let the SLA policy be removed from a conversation
# it 'generates an activity message when the SLA policy is removed' do
# conversation.update!(sla_policy_id: sla_policy.id)
# conversation.update!(sla_policy_id: nil)
perform_enqueued_jobs
# perform_enqueued_jobs
activity_message = conversation.messages.where(message_type: 'activity').last
# activity_message = conversation.messages.where(message_type: 'activity').last
expect(activity_message).not_to be_nil
expect(activity_message.message_type).to eq('activity')
expect(activity_message.content).to include('removed SLA policy')
end
# expect(activity_message).not_to be_nil
# expect(activity_message.message_type).to eq('activity')
# expect(activity_message.content).to include('removed SLA policy')
# end
end
describe 'conversation sentiments' do
@@ -64,4 +65,43 @@ RSpec.describe Conversation, type: :model do
expect(sentiments[:label]).to eq('positive')
end
end
describe 'sla_policy' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:sla_policy) { create(:sla_policy, account: account) }
let(:different_account_sla_policy) { create(:sla_policy) }
context 'when sla_policy is getting updated' do
it 'throws error if sla policy belongs to different account' do
conversation.sla_policy = different_account_sla_policy
expect(conversation.valid?).to be false
expect(conversation.errors[:sla_policy]).to include('sla policy account mismatch')
end
it 'creates applied sla record if sla policy is present' do
conversation.sla_policy = sla_policy
conversation.save!
expect(conversation.applied_sla.sla_policy_id).to eq(sla_policy.id)
end
end
context 'when conversation already has a different sla' do
before do
conversation.update(sla_policy: create(:sla_policy, account: account))
end
it 'throws error if trying to assing a different sla' do
conversation.sla_policy = sla_policy
expect(conversation.valid?).to be false
expect(conversation.errors[:sla_policy]).to eq(['conversation already has a different sla'])
end
it 'throws error if trying to set sla to nil' do
conversation.sla_policy = nil
expect(conversation.valid?).to be false
expect(conversation.errors[:sla_policy]).to eq(['cannot remove sla policy from conversation'])
end
end
end
end
@@ -5,14 +5,21 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
let!(:user_1) { create(:user, account: account) }
let!(:user_2) { create(:user, account: account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:conversation) { create(:conversation, created_at: 6.hours.ago, assignee: user_1, account: account) }
let!(:sla_policy) do
create(:sla_policy, account: conversation.account,
first_response_time_threshold: nil,
next_response_time_threshold: nil,
resolution_time_threshold: nil)
create(:sla_policy,
account: account,
first_response_time_threshold: nil,
next_response_time_threshold: nil,
resolution_time_threshold: nil)
end
let!(:applied_sla) { create(:applied_sla, conversation: conversation, sla_policy: sla_policy, sla_status: 'active') }
let!(:conversation) do
create(:conversation,
created_at: 6.hours.ago, assignee: user_1,
account: sla_policy.account,
sla_policy: sla_policy)
end
let!(:applied_sla) { conversation.applied_sla }
describe '#perform - SLA misses' do
context 'when first response SLA is missed' do
+19
View File
@@ -22,11 +22,13 @@ RSpec.describe Contact do
it 'sets email to lowercase' do
contact = create(:contact, email: 'Test@test.com')
expect(contact.email).to eq('test@test.com')
expect(contact.contact_type).to eq('lead')
end
it 'sets email to nil when empty string' do
contact = create(:contact, email: '')
expect(contact.email).to be_nil
expect(contact.contact_type).to eq('visitor')
end
it 'sets custom_attributes to {} when nil' do
@@ -83,4 +85,21 @@ RSpec.describe Contact do
expect(contact.country_code).to eq 'US'
end
end
context 'when a contact is created' do
it 'has contact type "visitor" by default' do
contact = create(:contact)
expect(contact.contact_type).to eq 'visitor'
end
it 'has contact type "lead" when email is present' do
contact = create(:contact, email: 'test@test.com')
expect(contact.contact_type).to eq 'lead'
end
it 'has contact type "lead" when contacted through a social channel' do
contact = create(:contact, additional_attributes: { social_facebook_user_id: '123' })
expect(contact.contact_type).to eq 'lead'
end
end
end
@@ -0,0 +1,46 @@
# spec/services/contacts/sync_attributes_spec.rb
require 'rails_helper'
RSpec.describe Contacts::SyncAttributes do
describe '#perform' do
let(:contact) { create(:contact, additional_attributes: { 'city' => 'New York', 'country' => 'US' }) }
context 'when contact has neither email/phone number nor social details' do
it 'does not change contact type' do
described_class.new(contact).perform
expect(contact.reload.contact_type).to eq('visitor')
end
end
context 'when contact has email or phone number' do
it 'sets contact type to lead' do
contact.email = 'test@test.com'
contact.save
described_class.new(contact).perform
expect(contact.reload.contact_type).to eq('lead')
end
end
context 'when contact has social details' do
it 'sets contact type to lead' do
contact.additional_attributes['social_facebook_user_id'] = '123456789'
contact.save
described_class.new(contact).perform
expect(contact.reload.contact_type).to eq('lead')
end
end
context 'when location and country code are updated from additional attributes' do
it 'updates location and country code' do
described_class.new(contact).perform
# Expect location and country code to be updated
expect(contact.reload.location).to eq('New York')
expect(contact.reload.country_code).to eq('US')
end
end
end
end
@@ -255,6 +255,30 @@ describe Telegram::IncomingMessageService do
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location')
end
it 'creates appropriate conversations, message and contacts if venue is present' do
params = {
'update_id' => 2_342_342_343_242,
'message' => {
'location': {
'latitude': 37.7893768,
'longitude': -122.3895553
},
venue: {
title: 'San Francisco'
}
}.merge(message_params)
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
attachment = telegram_channel.inbox.messages.first.attachments.first
expect(attachment.file_type).to eq('location')
expect(attachment.coordinates_lat).to eq(37.7893768)
expect(attachment.coordinates_long).to eq(-122.3895553)
expect(attachment.fallback_title).to eq('San Francisco')
end
end
context 'when valid callback_query params' do
@@ -0,0 +1,29 @@
tags:
- Conversations
operationId: update-conversation
summary: Update Conversation
description: Update Conversation Attributes
security:
- userApiKey: []
- agentBotApiKey: []
parameters:
- name: data
in: body
required: true
schema:
type: object
properties:
priority:
type: string
enum: ["urgent", "high", "medium", "low", "none"]
description: "The priority of the conversation"
sla_policy_id:
type: number
description: "The ID of the SLA policy (Available only in Enterprise edition)"
responses:
200:
description: Success
404:
description: Conversation not found
401:
description: Unauthorized
+2
View File
@@ -339,6 +339,8 @@
- $ref: '#/parameters/conversation_id'
get:
$ref: ./application/conversation/show.yml
patch:
$ref: ./application/conversation/update.yml
/api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status:
parameters:
- $ref: '#/parameters/account_id'
+58
View File
@@ -3319,6 +3319,64 @@
"description": "Access denied"
}
}
},
"patch": {
"tags": [
"Conversations"
],
"operationId": "update-conversation",
"summary": "Update Conversation",
"description": "Update Conversation Attributes",
"security": [
{
"userApiKey": [
]
},
{
"agentBotApiKey": [
]
}
],
"parameters": [
{
"name": "data",
"in": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"priority": {
"type": "string",
"enum": [
"urgent",
"high",
"medium",
"low",
"none"
],
"description": "The priority of the conversation"
},
"sla_policy_id": {
"type": "number",
"description": "The ID of the SLA policy (Available only in Enterprise edition)"
}
}
}
}
],
"responses": {
"200": {
"description": "Success"
},
"404": {
"description": "Conversation not found"
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status": {