Merge branch 'chatwoot:develop' into feature/#7907-script-to-change-local-storage-to-aws

This commit is contained in:
Francisco Brito
2024-03-20 12:32:03 -03:00
committed by GitHub
68 changed files with 1464 additions and 902 deletions
@@ -65,6 +65,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = result[:contacts]
@contacts_count = result[:count]
@contacts = fetch_contacts(contacts)
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
def contactable_inboxes
@@ -44,6 +44,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations]
@conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
def mute
@@ -0,0 +1,5 @@
module DomainHelper
def self.chatwoot_domain?(domain = request.host)
[URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
end
end
+15
View File
@@ -6,6 +6,7 @@ module SwitchLocale
def switch_locale(&)
# priority is for locale set in query string (mostly for widget/from js sdk)
locale ||= locale_from_params
locale ||= locale_from_custom_domain
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
locale ||= locale_from_env_variable
set_locale(locale, &)
@@ -16,6 +17,20 @@ module SwitchLocale
set_locale(locale, &)
end
# If the request is coming from a custom domain, it should be for a helpcenter portal
# We will use the portal locale in such cases
def locale_from_custom_domain(&)
return if params[:locale]
domain = request.host
return if DomainHelper.chatwoot_domain?(domain)
@portal = Portal.find_by(custom_domain: domain)
return unless @portal
@portal.default_locale
end
def set_locale(locale, &)
# if locale is empty, use default_locale
locale ||= I18n.default_locale
+1
View File
@@ -18,6 +18,7 @@ class DashboardController < ActionController::Base
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
'INSTALLATION_NAME',
'WIDGET_BRAND_URL', 'TERMS_URL',
'BRAND_URL', 'BRAND_NAME',
'PRIVACY_URL',
'DISPLAY_MANIFEST',
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
@@ -47,7 +47,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
@locale = if article.category.present?
article.category.locale
else
'en'
article.portal.default_locale
end
I18n.with_locale(@locale, &)
+1 -2
View File
@@ -8,8 +8,7 @@ class PublicController < ActionController::Base
def ensure_custom_domain_request
domain = request.host
return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
return if DomainHelper.chatwoot_domain?(domain)
@portal = ::Portal.find_by(custom_domain: domain)
return if @portal.present?
+84
View File
@@ -0,0 +1,84 @@
module FilterHelper
def build_condition_query(model_filters, query_hash, current_index)
current_filter = model_filters[query_hash['attribute_key']]
# Throw InvalidOperator Error if the attribute is a standard attribute
# and the operator is not allowed in the config
if current_filter.present? && current_filter['filter_operators'].exclude?(query_hash[:filter_operator])
raise CustomExceptions::CustomFilter::InvalidOperator.new(
attribute_name: query_hash['attribute_key'],
allowed_keys: current_filter['filter_operators']
)
end
# Every other filter expects a value to be present
if %w[is_present is_not_present].exclude?(query_hash[:filter_operator]) && query_hash['values'].blank?
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: query_hash['attribute_key'])
end
condition_query = build_condition_query_string(current_filter, query_hash, current_index)
# The query becomes empty only when it doesn't match to any supported
# standard attribute or custom attribute defined in the account.
if condition_query.empty?
raise CustomExceptions::CustomFilter::InvalidAttribute.new(key: query_hash['attribute_key'],
allowed_keys: model_filters.keys)
end
condition_query
end
def build_condition_query_string(current_filter, query_hash, current_index)
filter_operator_value = filter_operation(query_hash, current_index)
return handle_nil_filter(query_hash, current_index) if current_filter.nil?
case current_filter['attribute_type']
when 'additional_attributes'
handle_additional_attributes(query_hash, filter_operator_value, current_filter['data_type'])
else
handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
end
end
def handle_nil_filter(query_hash, current_index)
attribute_type = "#{filter_config[:entity].downcase}_attribute"
custom_attribute_query(query_hash, attribute_type, current_index)
end
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive'
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
else
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
"#{filter_operator_value} #{query_hash[:query_operator]} "
end
end
def handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
case current_filter['data_type']
when 'date'
date_filter(current_filter, query_hash, filter_operator_value)
when 'labels'
tag_filter_query(query_hash, current_index)
when 'text_case_insensitive'
text_case_insensitive_filter(query_hash, filter_operator_value)
else
default_filter(query_hash, filter_operator_value)
end
end
def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
end
def text_case_insensitive_filter(query_hash, filter_operator_value)
"LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
end
+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',
};
@@ -44,7 +44,8 @@
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Last Activity",
"REFERER_LINK": "Referrer link"
"REFERER_LINK": "Referrer link",
"BLOCKED": "Blocked"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
@@ -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 },
@@ -243,7 +243,7 @@ export default {
attr.attribute_display_type === 'checkbox'
);
});
if (isCustomAttributeCheckbox) {
if (isCustomAttributeCheckbox || type === 'blocked') {
return [
{
id: true,
@@ -76,6 +76,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'blocked',
attributeI18nKey: 'BLOCKED',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
];
export const filterAttributeGroups = [
@@ -115,6 +123,10 @@ export const filterAttributeGroups = [
key: 'last_activity_at',
i18nKey: 'LAST_ACTIVITY',
},
{
key: 'blocked',
i18nKey: 'BLOCKED',
},
],
},
];
@@ -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
@@ -5,8 +5,8 @@ class AutomationRules::ConditionValidationService
@rule = rule
@account = rule.account
file = File.read('./lib/filters/filter_keys.json')
@filters = JSON.parse(file)
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts']
@@ -11,8 +11,9 @@ class AutomationRules::ConditionsFilterService < FilterService
@account = conversation.account
# setup filters from json file
file = File.read('./lib/filters/filter_keys.json')
@filters = JSON.parse(file)
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts']
@message_filters = @filters['messages']
+8 -33
View File
@@ -2,7 +2,7 @@ class Contacts::FilterService < FilterService
ATTRIBUTE_MODEL = 'contact_attribute'.freeze
def perform
@contacts = contact_query_builder
@contacts = query_builder(@filters['contacts'])
{
contacts: @contacts,
@@ -10,38 +10,6 @@ class Contacts::FilterService < FilterService
}
end
def contact_query_builder
contact_filters = @filters['contacts']
@params[:payload].each_with_index do |query_hash, current_index|
current_filter = contact_filters[query_hash['attribute_key']]
@query_string += contact_query_string(current_filter, query_hash, current_index)
end
base_relation.where(@query_string, @filter_values.with_indifferent_access)
end
def contact_query_string(current_filter, query_hash, current_index)
attribute_key = query_hash[:attribute_key]
query_operator = query_hash[:query_operator]
filter_operator_value = filter_operation(query_hash, current_index)
return custom_attribute_query(query_hash, 'contact_attribute', current_index) if current_filter.nil?
case current_filter['attribute_type']
when 'additional_attributes'
" LOWER(contacts.additional_attributes ->> '#{attribute_key}') #{filter_operator_value} #{query_operator} "
when 'date_attributes'
" (contacts.#{attribute_key})::#{current_filter['data_type']} #{filter_operator_value}#{current_filter['data_type']} #{query_operator} "
when 'standard'
if attribute_key == 'labels'
" #{tag_filter_query('Contact', 'contacts', query_hash, current_index)} "
else
" LOWER(contacts.#{attribute_key}) #{filter_operator_value} #{query_operator} "
end
end
end
def filter_values(query_hash)
current_val = query_hash['values'][0]
if query_hash['attribute_key'] == 'phone_number'
@@ -57,6 +25,13 @@ class Contacts::FilterService < FilterService
Current.account.contacts
end
def filter_config
{
entity: 'Contact',
table_name: 'contacts'
}
end
private
def equals_to_filter_string(filter_operator, current_index)
+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
+8 -32
View File
@@ -7,7 +7,7 @@ class Conversations::FilterService < FilterService
end
def perform
@conversations = conversation_query_builder
@conversations = query_builder(@filters['conversations'])
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
@@ -22,37 +22,6 @@ class Conversations::FilterService < FilterService
}
end
def conversation_query_builder
conversation_filters = @filters['conversations']
@params[:payload].each_with_index do |query_hash, current_index|
current_filter = conversation_filters[query_hash['attribute_key']]
@query_string += conversation_query_string(current_filter, query_hash, current_index)
end
base_relation.where(@query_string, @filter_values.with_indifferent_access)
end
def conversation_query_string(current_filter, query_hash, current_index)
attribute_key = query_hash[:attribute_key]
query_operator = query_hash[:query_operator]
filter_operator_value = filter_operation(query_hash, current_index)
return custom_attribute_query(query_hash, 'conversation_attribute', current_index) if current_filter.nil?
case current_filter['attribute_type']
when 'additional_attributes'
" conversations.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
when 'date_attributes'
" (conversations.#{attribute_key})::#{current_filter['data_type']} #{filter_operator_value}#{current_filter['data_type']} #{query_operator} "
when 'standard'
if attribute_key == 'labels'
" #{tag_filter_query('Conversation', 'conversations', query_hash, current_index)} "
else
" conversations.#{attribute_key} #{filter_operator_value} #{query_operator} "
end
end
end
def base_relation
@account.conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
@@ -63,6 +32,13 @@ class Conversations::FilterService < FilterService
@params[:page] || 1
end
def filter_config
{
entity: 'Conversation',
table_name: 'conversations'
}
end
def conversations
@conversations.sort_on_last_activity_at.page(current_page)
end
+18 -8
View File
@@ -1,6 +1,9 @@
require 'json'
class FilterService
include FilterHelper
include CustomExceptions::CustomFilter
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
ATTRIBUTE_TYPES = {
date: 'date', text: 'text', number: 'numeric', link: 'text', list: 'text', checkbox: 'boolean'
@@ -9,8 +12,8 @@ class FilterService
def initialize(params, user)
@params = params
@user = user
file = File.read('./lib/filters/filter_keys.json')
@filters = JSON.parse(file)
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@query_string = ''
@filter_values = {}
end
@@ -106,7 +109,9 @@ class FilterService
]
end
def tag_filter_query(model_name, table_name, query_hash, current_index)
def tag_filter_query(query_hash, current_index)
model_name = filter_config[:entity]
table_name = filter_config[:table_name]
query_operator = query_hash[:query_operator]
@filter_values["value_#{current_index}"] = filter_values(query_hash)
@@ -130,10 +135,8 @@ class FilterService
def custom_attribute_query(query_hash, custom_attribute_type, current_index)
@attribute_key = query_hash[:attribute_key]
@custom_attribute_type = custom_attribute_type
attribute_data_type
return ' ' if @custom_attribute.blank?
return '' if @custom_attribute.blank?
build_custom_attr_query(query_hash, current_index)
end
@@ -155,9 +158,9 @@ class FilterService
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
query = if attribute_data_type == 'text'
" LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
"LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
else
" (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
"(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
end
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
@@ -194,4 +197,11 @@ class FilterService
"NOT LIKE :value_#{current_index}"
end
def query_builder(model_filters)
@params[:payload].each_with_index do |query_hash, current_index|
@query_string += " #{build_condition_query(model_filters, query_hash, current_index).strip}"
end
base_relation.where(@query_string, @filter_values.with_indifferent_access)
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
@@ -83,7 +83,7 @@
class="w-24 overflow-hidden text-sm font-medium leading-tight bg-white appearance-none cursor-pointer dark:bg-slate-900 text-ellipsis whitespace-nowrap focus:outline-none focus:shadow-outline locale-switcher"
>
<% @portal.config["allowed_locales"].each do |locale| %>
<option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
<option <%= locale == @locale ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
<% end %>
</select>
<%= render partial: 'icons/chevron-down' %>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.6.0'
version: '3.7.0'
development:
<<: *shared
+3 -1
View File
@@ -77,7 +77,9 @@ en:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
reports:
period: Reporting period %{since} to %{until}
utc_warning: The report generated is in UTC timezone
@@ -0,0 +1,16 @@
class CreateSlaEvents < ActiveRecord::Migration[7.0]
def change
create_table :sla_events do |t|
t.references :applied_sla, null: false
t.references :conversation, null: false
t.references :account, null: false
t.references :sla_policy, null: false
t.references :inbox, null: false
t.integer :event_type
t.jsonb :meta, default: {}
t.timestamps
end
end
end
+18 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_03_06_201954) do
ActiveRecord::Schema[7.0].define(version: 2024_03_19_062553) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -842,6 +842,23 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_06_201954) do
t.index ["user_id"], name: "index_reporting_events_on_user_id"
end
create_table "sla_events", force: :cascade do |t|
t.bigint "applied_sla_id", null: false
t.bigint "conversation_id", null: false
t.bigint "account_id", null: false
t.bigint "sla_policy_id", null: false
t.bigint "inbox_id", null: false
t.integer "event_type"
t.jsonb "meta", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_sla_events_on_account_id"
t.index ["applied_sla_id"], name: "index_sla_events_on_applied_sla_id"
t.index ["conversation_id"], name: "index_sla_events_on_conversation_id"
t.index ["inbox_id"], name: "index_sla_events_on_inbox_id"
t.index ["sla_policy_id"], name: "index_sla_events_on_sla_policy_id"
end
create_table "sla_policies", force: :cascade do |t|
t.string "name", null: false
t.float "first_response_time_threshold"
+2
View File
@@ -22,6 +22,8 @@ class AppliedSla < ApplicationRecord
belongs_to :sla_policy
belongs_to :conversation
has_many :sla_events, dependent: :destroy
validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] }
before_validation :ensure_account_id
+52
View File
@@ -0,0 +1,52 @@
# == Schema Information
#
# Table name: sla_events
#
# id :bigint not null, primary key
# event_type :integer
# meta :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# applied_sla_id :bigint not null
# conversation_id :bigint not null
# inbox_id :bigint not null
# sla_policy_id :bigint not null
#
# Indexes
#
# index_sla_events_on_account_id (account_id)
# index_sla_events_on_applied_sla_id (applied_sla_id)
# index_sla_events_on_conversation_id (conversation_id)
# index_sla_events_on_inbox_id (inbox_id)
# index_sla_events_on_sla_policy_id (sla_policy_id)
#
class SlaEvent < ApplicationRecord
belongs_to :account
belongs_to :inbox
belongs_to :conversation
belongs_to :sla_policy
belongs_to :applied_sla
enum event_type: { frt: 0, nrt: 1, rt: 2 }
before_validation :ensure_applied_sla_id, :ensure_account_id, :ensure_inbox_id, :ensure_sla_policy_id
private
def ensure_applied_sla_id
self.applied_sla_id ||= AppliedSla.find_by(conversation_id: conversation_id)&.last&.id
end
def ensure_account_id
self.account_id ||= conversation&.account_id
end
def ensure_inbox_id
self.inbox_id ||= conversation&.inbox_id
end
def ensure_sla_policy_id
self.sla_policy_id ||= applied_sla&.sla_policy_id
end
end
-195
View File
@@ -1,195 +0,0 @@
{
"conversations": {
"status": {
"attribute_name": "Status",
"input_type": "multi_select",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "standard"
},
"assignee_id": {
"attribute_name": "Assignee Name",
"input_type": "search_box with name tags/plain text",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"contact_id": {
"attribute_name": "Contact Name",
"input_type": "plain_text",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"inbox_id": {
"attribute_name": "Inbox Name",
"input_type": "search_box",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"team_id": {
"attribute_name": "Team Name",
"input_type": "search_box",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"id": {
"attribute_name": "Conversation Identifier",
"input_type": "textbox",
"table_name": "conversations",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"campaign_id": {
"attribute_name": "Campaign Name",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"labels": {
"attribute_name": "Labels",
"input_type": "tags",
"data_type": "text",
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"browser_language": {
"attribute_name": "Browser Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"conversation_language": {
"attribute_name": "Conversation Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "additional_attributes"
},
"mail_subject": {
"attribute_name": "Email Subject",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"country_code": {
"attribute_name": "Country Name",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
"referer": {
"attribute_name": "Referer link",
"input_type": "textbox",
"data_type": "link",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
"plan": {
"attribute_name": "Plan",
"input_type": "multi_select",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
}
},
"contacts": {
"assignee_id": {
"attribute_name": "Assignee Name",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"phone_number": {
"attribute_name": "Phone Number",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with" ],
"attribute_type": "standard"
},
"contact_id": {
"attribute_name": "Contact Name",
"input_type": "plain_text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"inbox_id": {
"attribute_name": "Inbox Name",
"input_type": "search_box",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"team_id": {
"attribute_name": "Team Name",
"input_type": "search_box",
"data_type": "number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"id": {
"attribute_name": "Conversation Identifier",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"campaign_id": {
"attribute_name": "Campaign Name",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"labels": {
"attribute_name": "Labels",
"input_type": "tags",
"data_type": "text",
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"browser_language": {
"attribute_name": "Browser Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"mail_subject": {
"attribute_name": "Email Subject",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"email": {
"attribute_name": "Email",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"country_code": {
"attribute_name": "Country Name",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
"referer": {
"attribute_name": "Referer link",
"input_type": "textbox",
"data_type": "link",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
module CustomExceptions::CustomFilter
class InvalidAttribute < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_attribute', key: @data[:key], allowed_keys: @data[:allowed_keys].join(','))
end
end
class InvalidOperator < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_operator', attribute_name: @data[:attribute_name], allowed_keys: @data[:allowed_keys].join(','))
end
end
class InvalidValue < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name])
end
end
end
-92
View File
@@ -1,92 +0,0 @@
{
"conversations": [
{
"attribute_key": "status",
"attribute_name": "Status",
"input_type": "multi_select",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "standard"
},
{
"attribute_key": "assigne",
"attribute_name": "Assignee Name",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "contact",
"attribute_name": "Contact Name",
"input_type": "plain_text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "inbox",
"attribute_name": "Inbox Name",
"input_type": "search_box",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "team_id",
"attribute_name": "Team Name",
"input_type": "search_box",
"data_type": "number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "id",
"attribute_name": "Conversation Identifier",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "campaign_id",
"attribute_name": "Campaign Name",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
{
"attribute_key": "labels",
"attribute_name": "Labels",
"input_type": "tags",
"data_type": "text",
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
{
"attribute_key": "browser_language",
"attribute_name": "Browser Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
{
"attribute_key": "country_code",
"attribute_name": "Country Name",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
{
"attribute_key": "referer",
"attribute_name": "Referer link",
"input_type": "textbox",
"data_type": "link",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
}
]
}
-204
View File
@@ -1,204 +0,0 @@
{
"conversations": {
"status": {
"attribute_name": "Status",
"input_type": "multi_select",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "standard"
},
"assignee_id": {
"attribute_name": "Assignee Name",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"contact_id": {
"attribute_name": "Contact Name",
"input_type": "plain_text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"inbox_id": {
"attribute_name": "Inbox Name",
"input_type": "search_box",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"team_id": {
"attribute_name": "Team Name",
"input_type": "search_box",
"data_type": "number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"display_id": {
"attribute_name": "Conversation Identifier",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"campaign_id": {
"attribute_name": "Campaign Name",
"input_type": "textbox",
"data_type": "Number",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
"attribute_type": "standard"
},
"labels": {
"attribute_name": "Labels",
"input_type": "tags",
"data_type": "text",
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"browser_language": {
"attribute_name": "Browser Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"conversation_language": {
"attribute_name": "Conversation Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "additional_attributes"
},
"country_code": {
"attribute_name": "Country Name",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
"referer": {
"attribute_name": "Referer link",
"input_type": "textbox",
"data_type": "link",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
"attribute_type": "additional_attributes"
},
"created_at": {
"attribute_name": "Created At",
"input_type": "date",
"data_type": "date",
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
"attribute_type": "date_attributes"
},
"last_activity_at": {
"attribute_name": "Created At",
"input_type": "date",
"data_type": "date",
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
"attribute_type": "date_attributes"
},
"mail_subject": {
"attribute_name": "Email Subject",
"input_type": "text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain"],
"attribute_type": "additional_attributes"
}
},
"contacts": {
"name": {
"attribute_name": "Name",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"phone_number": {
"attribute_name": "Phone Number",
"input_type": "text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with"],
"attribute_type": "standard"
},
"email": {
"attribute_name": "Email",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"identifier": {
"attribute_name": "Contact Identifier",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "standard"
},
"country_code": {
"attribute_name": "Country",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "additional_attributes"
},
"city": {
"attribute_name": "City",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"browser_language": {
"attribute_name": "Browser Language",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"company": {
"attribute_name": "Company",
"input_type": "textbox",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "additional_attributes"
},
"labels": {
"attribute_name": "Labels",
"input_type": "tags",
"data_type": "text",
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
},
"created_at": {
"attribute_name": "Created At",
"input_type": "date",
"data_type": "date",
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
"attribute_type": "date_attributes"
},
"last_activity_at": {
"attribute_name": "Created At",
"input_type": "date",
"data_type": "date",
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
"attribute_type": "date_attributes"
}
},
"messages": {
"message_type": {
"attribute_name": "Message Type",
"input_type": "search_box with name tags/plain text",
"data_type": "numeric",
"filter_operators": [ "equal_to", "not_equal_to" ],
"attribute_type": "standard"
},
"content": {
"attribute_name": "Message Content",
"input_type": "search_box with name tags/plain text",
"data_type": "text",
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
"attribute_type": "standard"
}
}
}
+226
View File
@@ -0,0 +1,226 @@
## This file contains the filter configurations which we use for the following
# 1. Conversation Filters (app/services/filter_service.rb)
# 2. Contact Filters (app/services/filter_service.rb)
# 3. Automation Filters (app/services/automation_rules/conditions_filter_service.rb), (app/services/automation_rules/condition_validation_service.rb)
# Format
# - Parent Key (conversation, contact, messages)
# - Key (attribute_name)
# - attribute_type: "standard" : supported ["standard", "additional_attributes (only for conversations and messages)"]
# - data_type: "text" : supported ["text", "text_case_insensitive", "number", "boolean", "labels", "date", "link"]
# - filter_operators: ["equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present", "is_greater_than", "is_less_than", "days_before", "starts_with"]
### ----- Conversation Filters ----- ###
conversations:
status:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
assignee_id:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
inbox_id:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
team_id:
attribute_type: "standard"
data_type: "number"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
display_id:
attribute_type: "standard"
data_type: "Number"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
campaign_id:
attribute_type: "standard"
data_type: "Number"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
labels:
attribute_type: "standard"
data_type: "labels"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
browser_language:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
conversation_language:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
country_code:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
referer:
attribute_type: "additional_attributes"
data_type: "link"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
created_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
last_activity_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
mail_subject:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
### ----- End of Conversation Filters ----- ###
### ----- Contact Filters ----- ###
contacts:
name:
attribute_type: "standard"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
phone_number:
attribute_type: "standard"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
- "starts_with"
email:
attribute_type: "standard"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
identifier:
attribute_type: "standard"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
country_code:
attribute_type: "additional_attributes"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
city:
attribute_type: "additional_attributes"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
company:
attribute_type: "additional_attributes"
data_type: "text_case_insensitive"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
labels:
attribute_type: "standard"
data_type: "labels"
filter_operators:
- "equal_to"
- "not_equal_to"
- "is_present"
- "is_not_present"
created_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
last_activity_at:
attribute_type: "standard"
data_type: "date"
filter_operators:
- "is_greater_than"
- "is_less_than"
- "days_before"
blocked:
attribute_type: "standard"
data_type: "boolean"
filter_operators:
- "equal_to"
- "not_equal_to"
### ----- End of Contact Filters ----- ###
### ----- Message Filters ----- ###
messages:
message_type:
attribute_type: "standard"
data_type: "numeric"
filter_operators:
- "equal_to"
- "not_equal_to"
content:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
- "contains"
- "does_not_contain"
### ----- End of Message Filters ----- ###
+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}",
@@ -338,14 +338,18 @@ RSpec.describe 'Contacts API', type: :request do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:contact1) { create(:contact, :with_email, account: account) }
let!(:contact2) { create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com') }
let!(:contact1) { create(:contact, :with_email, account: account, additional_attributes: { country_code: 'US' }) }
let!(:contact2) do
create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com', additional_attributes: { country_code: 'US' })
end
it 'returns all contacts when query is empty' do
post "/api/v1/accounts/#{account.id}/contacts/filter",
params: {
payload: []
},
params: { payload: [
attribute_key: 'country_code',
filter_operator: 'equal_to',
values: ['US']
] },
headers: admin.create_new_auth_token,
as: :json
@@ -353,6 +357,34 @@ RSpec.describe 'Contacts API', type: :request do
expect(response.body).to include(contact2.email)
expect(response.body).to include(contact1.email)
end
it 'returns error the query operator is invalid' do
post "/api/v1/accounts/#{account.id}/contacts/filter",
params: { payload: [
attribute_key: 'country_code',
filter_operator: 'eq',
values: ['US']
] },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('Invalid operator. The allowed operators for country_code are [equal_to,not_equal_to]')
end
it 'returns error the query value is invalid' do
post "/api/v1/accounts/#{account.id}/contacts/filter",
params: { payload: [
attribute_key: 'country_code',
filter_operator: 'equal_to',
values: []
] },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('Invalid value. The values provided for country_code are invalid"')
end
end
end
@@ -152,17 +152,56 @@ RSpec.describe 'Conversations API', type: :request do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'returns all conversations with empty query' do
it 'returns all conversations matching the query' do
post "/api/v1/accounts/#{account.id}/conversations/filter",
headers: agent.create_new_auth_token,
params: { payload: [] },
params: {
payload: [{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open']
}]
},
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data.count).to eq(2)
end
it 'returns error if the filters contain invalid attributes' do
post "/api/v1/accounts/#{account.id}/conversations/filter",
headers: agent.create_new_auth_token,
params: {
payload: [{
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: ['open']
}]
},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:error]).to include('Invalid attribute key - [phone_number]')
end
it 'returns error if the filters contain invalid operator' do
post "/api/v1/accounts/#{account.id}/conversations/filter",
headers: agent.create_new_auth_token,
params: {
payload: [{
attribute_key: 'status',
filter_operator: 'eq',
values: ['open']
}]
},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:error]).to eq('Invalid operator. The allowed operators for status are [equal_to,not_equal_to].')
end
end
end
+28
View File
@@ -0,0 +1,28 @@
require 'rails_helper'
RSpec.describe SlaEvent, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:applied_sla) }
it { is_expected.to belong_to(:conversation) }
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:sla_policy) }
it { is_expected.to belong_to(:inbox) }
end
describe 'validates_factory' do
it 'creates valid sla event object' do
sla_event = create(:sla_event)
expect(sla_event.event_type).to eq 'frt'
end
end
describe 'backfilling ids' do
it 'automatically backfills account_id, inbox_id, and sla_id upon creation' do
sla_event = create(:sla_event)
expect(sla_event.account_id).to eq sla_event.conversation.account_id
expect(sla_event.inbox_id).to eq sla_event.conversation.inbox_id
expect(sla_event.sla_policy_id).to eq sla_event.applied_sla.sla_policy_id
end
end
end
+10
View File
@@ -0,0 +1,10 @@
FactoryBot.define do
factory :sla_event do
applied_sla
conversation
event_type { 'frt' }
account { conversation.account }
inbox { conversation.inbox }
sla_policy { applied_sla.sla_policy }
end
end
+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
+169 -145
View File
@@ -7,9 +7,9 @@ describe Contacts::FilterService do
let!(:first_user) { create(:user, account: account) }
let!(:second_user) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
let(:en_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'en' }) }
let(:el_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'el' }) }
let(:cs_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'cs' }) }
let!(:en_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'uk' }) }
let!(:el_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'gr' }) }
let!(:cs_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'cz' }) }
before do
create(:inbox_member, user: first_user, inbox: inbox)
@@ -37,6 +37,8 @@ describe Contacts::FilterService do
end
describe '#perform' do
let!(:params) { { payload: [], page: 1 } }
before do
en_contact.update_labels(%w[random_label support])
cs_contact.update_labels('support')
@@ -46,90 +48,7 @@ describe Contacts::FilterService do
cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' })
end
context 'with query present' do
let!(:params) { { payload: [], page: 1 } }
let(:payload) do
[
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: ['en'],
query_operator: nil
}.with_indifferent_access
]
end
context 'with label filter' do
it 'returns equal_to filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: ['support'],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be 2
expect(result[:contacts].first.label_list).to include('support')
expect(result[:contacts].last.label_list).to include('support')
end
it 'returns not_equal_to filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'not_equal_to',
values: ['support'],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.id).to eq el_contact.id
end
it 'returns is_present filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'is_present',
values: [],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be 2
expect(result[:contacts].first.label_list).to include('support')
expect(result[:contacts].last.label_list).to include('support')
end
it 'returns is_not_present filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'is_not_present',
values: [],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.id).to eq el_contact.id
end
end
it 'filter contacts by additional_attributes' do
params[:payload] = payload
result = filter_service.new(params, first_user).perform
expect(result[:count]).to be 1
expect(result[:contacts].first.id).to eq(en_contact.id)
end
context 'with standard attributes - name' do
it 'filter contacts by name' do
params[:payload] = [
{
@@ -145,7 +64,168 @@ describe Contacts::FilterService do
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.name).to eq(en_contact.name)
end
end
context 'with standard attributes - blocked' do
it 'filter contacts by blocked' do
blocked_contact = create(:contact, account: account, blocked: true)
params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: ['true'],
query_operator: nil }.with_indifferent_access] }
result = filter_service.new(params, first_user).perform
expect(result[:count]).to be 1
expect(result[:contacts].first.id).to eq(blocked_contact.id)
end
it 'filter contacts by not_blocked' do
params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: [false],
query_operator: nil }.with_indifferent_access] }
result = filter_service.new(params, first_user).perform
# existing contacts are not blocked
expect(result[:count]).to be 3
end
end
context 'with standard attributes - label' do
it 'returns equal_to filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: ['support'],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be 2
expect(result[:contacts].first.label_list).to include('support')
expect(result[:contacts].last.label_list).to include('support')
end
it 'returns not_equal_to filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'not_equal_to',
values: ['support'],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.id).to eq el_contact.id
end
it 'returns is_present filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'is_present',
values: [],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be 2
expect(result[:contacts].first.label_list).to include('support')
expect(result[:contacts].last.label_list).to include('support')
end
it 'returns is_not_present filter results properly' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'is_not_present',
values: [],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.id).to eq el_contact.id
end
end
context 'with standard attributes - last_activity_at' do
before do
Time.zone = 'UTC'
el_contact.update(last_activity_at: (Time.zone.today - 4.days))
cs_contact.update(last_activity_at: (Time.zone.today - 5.days))
en_contact.update(last_activity_at: (Time.zone.today - 2.days))
end
it 'filter by last_activity_at 3_days_before and custom_attributes' do
params[:payload] = [
{
attribute_key: 'last_activity_at',
filter_operator: 'days_before',
values: [3],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'contact_additional_information',
filter_operator: 'equal_to',
values: ['test custom data'],
query_operator: nil
}.with_indifferent_access
]
expected_count = Contact.where(
"last_activity_at < ? AND
custom_attributes->>'contact_additional_information' = ?",
(Time.zone.today - 3.days),
'test custom data'
).count
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be expected_count
expect(result[:contacts].first.id).to eq(el_contact.id)
end
it 'filter by last_activity_at 2_days_before and custom_attributes' do
params[:payload] = [
{
attribute_key: 'last_activity_at',
filter_operator: 'days_before',
values: [2],
query_operator: nil
}.with_indifferent_access
]
expected_count = Contact.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be expected_count
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
expect(result[:contacts].pluck(:id)).to include(cs_contact.id)
expect(result[:contacts].pluck(:id)).not_to include(en_contact.id)
end
end
context 'with additional attributes' do
let(:payload) do
[
{
attribute_key: 'country_code',
filter_operator: 'equal_to',
values: ['uk'],
query_operator: nil
}.with_indifferent_access
]
end
it 'filter contacts by additional_attributes' do
params[:payload] = payload
result = filter_service.new(params, first_user).perform
expect(result[:count]).to be 1
expect(result[:contacts].first.id).to eq(en_contact.id)
end
end
context 'with custom attributes' do
it 'filter by custom_attributes and labels' do
params[:payload] = [
{
@@ -181,9 +261,9 @@ describe Contacts::FilterService do
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'browser_language',
attribute_key: 'country_code',
filter_operator: 'equal_to',
values: ['el'],
values: ['GR'],
query_operator: 'AND'
}.with_indifferent_access,
{
@@ -220,62 +300,6 @@ describe Contacts::FilterService do
expect(result[:contacts].length).to be expected_count
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
end
context 'with x_days_before filter' do
before do
Time.zone = 'UTC'
el_contact.update(last_activity_at: (Time.zone.today - 4.days))
cs_contact.update(last_activity_at: (Time.zone.today - 5.days))
en_contact.update(last_activity_at: (Time.zone.today - 2.days))
end
it 'filter by last_activity_at 3_days_before and custom_attributes' do
params[:payload] = [
{
attribute_key: 'last_activity_at',
filter_operator: 'days_before',
values: [3],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'contact_additional_information',
filter_operator: 'equal_to',
values: ['test custom data'],
query_operator: nil
}.with_indifferent_access
]
expected_count = Contact.where(
"last_activity_at < ? AND
custom_attributes->>'contact_additional_information' = ?",
(Time.zone.today - 3.days),
'test custom data'
).count
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be expected_count
expect(result[:contacts].first.id).to eq(el_contact.id)
end
it 'filter by last_activity_at 2_days_before and custom_attributes' do
params[:payload] = [
{
attribute_key: 'last_activity_at',
filter_operator: 'days_before',
values: [2],
query_operator: nil
}.with_indifferent_access
]
expected_count = Contact.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
result = filter_service.new(params, first_user).perform
expect(result[:contacts].length).to be expected_count
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
expect(result[:contacts].pluck(:id)).to include(cs_contact.id)
expect(result[:contacts].pluck(:id)).not_to include(en_contact.id)
end
end
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
@@ -55,7 +55,7 @@ describe Conversations::FilterService do
[
{
attribute_key: 'browser_language',
filter_operator: 'contains',
filter_operator: 'equal_to',
values: 'en',
query_operator: 'AND',
custom_attribute_type: ''
@@ -88,7 +88,7 @@ describe Conversations::FilterService do
it 'filters items with contains filter_operator with values being an array' do
params[:payload] = [{
attribute_key: 'browser_language',
filter_operator: 'contains',
filter_operator: 'equal_to',
values: %w[tr fr],
query_operator: '',
custom_attribute_type: ''
@@ -106,7 +106,7 @@ describe Conversations::FilterService do
it 'filters items with does not contain filter operator with values being an array' do
params[:payload] = [{
attribute_key: 'browser_language',
filter_operator: 'does_not_contain',
filter_operator: 'not_equal_to',
values: %w[tr en],
query_operator: '',
custom_attribute_type: ''
@@ -291,6 +291,11 @@ describe Conversations::FilterService do
end
it 'filter by custom_attributes and additional_attributes' do
conversations = user_1.conversations
conversations[0].update!(additional_attributes: { 'browser_language': 'en' }, custom_attributes: { conversation_type: 'silver' })
conversations[1].update!(additional_attributes: { 'browser_language': 'en' }, custom_attributes: { conversation_type: 'platinum' })
conversations[2].update!(additional_attributes: { 'browser_language': 'tr' }, custom_attributes: { conversation_type: 'platinum' })
params[:payload] = [
{
attribute_key: 'conversation_type',
@@ -301,7 +306,7 @@ describe Conversations::FilterService do
}.with_indifferent_access,
{
attribute_key: 'browser_language',
filter_operator: 'is_equal_to',
filter_operator: 'not_equal_to',
values: 'en',
query_operator: nil,
custom_attribute_type: ''
@@ -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