diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb
index 71e9100e7..729db34b5 100644
--- a/app/controllers/api/v1/accounts/contacts_controller.rb
+++ b/app/controllers/api/v1/accounts/contacts_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb
index d0d8f6d5b..2aedf1928 100644
--- a/app/controllers/api/v1/accounts/conversations_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations_controller.rb
@@ -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
diff --git a/app/controllers/concerns/domain_helper.rb b/app/controllers/concerns/domain_helper.rb
new file mode 100644
index 000000000..1b7d8f187
--- /dev/null
+++ b/app/controllers/concerns/domain_helper.rb
@@ -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
diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb
index 744a70da9..3013ff3cc 100644
--- a/app/controllers/concerns/switch_locale.rb
+++ b/app/controllers/concerns/switch_locale.rb
@@ -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
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 0aea9df83..047fd10c3 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -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',
diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb
index 4d3cc56b8..f6c10f7c4 100644
--- a/app/controllers/public/api/v1/portals/base_controller.rb
+++ b/app/controllers/public/api/v1/portals/base_controller.rb
@@ -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, &)
diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb
index 0c3f52ff6..3b83a2210 100644
--- a/app/controllers/public_controller.rb
+++ b/app/controllers/public_controller.rb
@@ -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?
diff --git a/app/helpers/filter_helper.rb b/app/helpers/filter_helper.rb
new file mode 100644
index 000000000..bce2de5ea
--- /dev/null
+++ b/app/helpers/filter_helper.rb
@@ -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
diff --git a/app/javascript/dashboard/i18n/locale/en/contactFilters.json b/app/javascript/dashboard/i18n/locale/en/contactFilters.json
index 09a543984..02d5dcf89 100644
--- a/app/javascript/dashboard/i18n/locale/en/contactFilters.json
+++ b/app/javascript/dashboard/i18n/locale/en/contactFilters.json
@@ -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",
diff --git a/app/javascript/dashboard/i18n/locale/en/sla.json b/app/javascript/dashboard/i18n/locale/en/sla.json
index 228f87066..d2a4f1d2a 100644
--- a/app/javascript/dashboard/i18n/locale/en/sla.json
+++ b/app/javascript/dashboard/i18n/locale/en/sla.json
@@ -1,6 +1,9 @@
{
"SLA": {
"HEADER": "SLA",
+ "ADD_ACTION": "Add SLA",
+ "DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
+ "LEARN_MORE": "Learn more about SLA",
"HEADER_BTN_TXT": "Add SLA",
"LOADING": "Fetching SLAs",
"SEARCH_404": "There are no items matching this query",
@@ -9,7 +12,14 @@
"404": "There are no SLAs available in this account.",
"TITLE": "Manage SLA",
"DESC": "SLAs: Friendly promises for great service!",
- "TABLE_HEADER": ["Name", "Description", "FRT", "NRT", "RT", "Business Hours"]
+ "TABLE_HEADER": [
+ "Name",
+ "Description",
+ "FRT",
+ "NRT",
+ "RT",
+ "Business Hours"
+ ]
},
"FORM": {
"NAME": {
diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue
index 41ceb25e5..6f5e92017 100644
--- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue
+++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue
@@ -243,7 +243,7 @@ export default {
attr.attribute_display_type === 'checkbox'
);
});
- if (isCustomAttributeCheckbox) {
+ if (isCustomAttributeCheckbox || type === 'blocked') {
return [
{
id: true,
diff --git a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js
index 2d54c37bc..59376f8ef 100644
--- a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js
+++ b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js
@@ -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',
+ },
],
},
];
diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue
new file mode 100644
index 000000000..293a093af
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue
new file mode 100644
index 000000000..a888b5093
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue
new file mode 100644
index 000000000..8b3236836
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+ {{ $t('SLA.ADD_ACTION') }}
+
+
+
+
diff --git a/app/javascript/shared/components/EmojiOrIcon.vue b/app/javascript/shared/components/EmojiOrIcon.vue
index 5cde463fe..2a93dc06f 100644
--- a/app/javascript/shared/components/EmojiOrIcon.vue
+++ b/app/javascript/shared/components/EmojiOrIcon.vue
@@ -4,6 +4,7 @@
v-else-if="showIcon"
:size="iconSize"
:icon="icon"
+ class="flex-shrink-0"
:class="className"
/>
diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
index cc00e629d..7bac92334 100644
--- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json
+++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
@@ -10,6 +10,7 @@
"arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z",
"arrow-download-outline": "M18.25 20.5a.75.75 0 1 1 0 1.5l-13 .004a.75.75 0 1 1 0-1.5l13-.004ZM11.648 2.012l.102-.007a.75.75 0 0 1 .743.648l.007.102-.001 13.685 3.722-3.72a.75.75 0 0 1 .976-.073l.085.073a.75.75 0 0 1 .072.976l-.073.084-4.997 4.997a.75.75 0 0 1-.976.073l-.085-.073-5.003-4.996a.75.75 0 0 1 .976-1.134l.084.072 3.719 3.714L11 2.755a.75.75 0 0 1 .648-.743l.102-.007-.102.007Z",
"arrow-expand-outline": "M7.669 14.923a1 1 0 0 1 1.414 1.414l-2.668 2.667H8a1 1 0 0 1 .993.884l.007.116a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 1 1 2 0v1.587l2.669-2.668Zm8.336 6.081a1 1 0 1 1 0-2h1.583l-2.665-2.667a1 1 0 0 1-.083-1.32l.083-.094a1 1 0 0 1 1.414 0l2.668 2.67v-1.589a1 1 0 0 1 .883-.993l.117-.007a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4ZM8 3a1 1 0 0 1 0 2H6.417l2.665 2.668a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.414 0L5 6.412V8a1 1 0 0 1-.883.993L4 9a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4Zm12.005 0a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V6.412l-2.668 2.67a1 1 0 0 1-1.32.083l-.094-.083a1 1 0 0 1 0-1.414L17.589 5h-1.584a1 1 0 0 1-.993-.883L15.005 4a1 1 0 0 1 1-1h4Z",
+ "arrow-outwards-outline": "m16 8.4l-8.875 8.9q-.3.3-.713.3t-.712-.3q-.3-.3-.3-.713t.3-.712L14.6 7H7q-.425 0-.713-.288T6 6q0-.425.288-.713T7 5h10q.425 0 .713.288T18 6v10q0 .425-.288.713T17 17q-.425 0-.713-.288T16 16V8.4Z",
"arrow-redo-outline": "M19.25 2a.75.75 0 0 0-.743.648l-.007.102v5.69l-4.574-4.56a6.41 6.41 0 0 0-8.878-.179l-.186.18a6.41 6.41 0 0 0 0 9.063l8.845 8.84a.75.75 0 0 0 1.06-1.062l-8.845-8.838a4.91 4.91 0 0 1 6.766-7.112l.178.17L17.438 9.5H11.75a.75.75 0 0 0-.743.648L11 10.25c0 .38.282.694.648.743l.102.007h7.5a.75.75 0 0 0 .743-.648L20 10.25v-7.5a.75.75 0 0 0-.75-.75Z",
"arrow-right-import-outline": "M21.25 4.5a.75.75 0 0 1 .743.648L22 5.25v13.004a.75.75 0 0 1-1.493.102l-.007-.102V5.25a.75.75 0 0 1 .75-.75Zm-8.603 1.804l.072-.084a.75.75 0 0 1 .977-.073l.084.073l4.997 4.997a.75.75 0 0 1 .073.976l-.073.085l-4.997 5.003a.75.75 0 0 1-1.133-.976l.072-.084l3.711-3.717H2.75a.75.75 0 0 1-.743-.647L2 11.755a.75.75 0 0 1 .648-.743l.102-.007l13.693-.001l-3.724-3.724a.75.75 0 0 1-.072-.976l.072-.084l-.072.084Z",
"arrow-reply-outline": "M9.277 16.221a.75.75 0 0 1-1.061 1.06l-4.997-5.003a.75.75 0 0 1 0-1.06L8.217 6.22a.75.75 0 0 1 1.061 1.06L5.557 11h7.842c1.595 0 2.81.242 3.889.764l.246.126a6.203 6.203 0 0 1 2.576 2.576c.61 1.14.89 2.418.89 4.135a.75.75 0 0 1-1.5 0c0-1.484-.228-2.52-.713-3.428a4.702 4.702 0 0 0-1.96-1.96c-.838-.448-1.786-.676-3.094-.709L13.4 12.5H5.562l3.715 3.721Z",
@@ -169,6 +170,7 @@
"person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z",
"person-filled": "M17.754 14a2.249 2.249 0 0 1 2.249 2.25v.918a2.75 2.75 0 0 1-.513 1.598c-1.545 2.164-4.07 3.235-7.49 3.235c-3.421 0-5.944-1.072-7.486-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.251 14h11.502ZM12 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Z",
"play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z",
+ "plus-sign-outline": "M12 19q-.425 0-.713-.288T11 18v-5H6q-.425 0-.713-.288T5 12q0-.425.288-.713T6 11h5V6q0-.425.288-.713T12 5q.425 0 .713.288T13 6v5h5q.425 0 .713.288T19 12q0 .425-.288.713T18 13h-5v5q0 .425-.288.713T12 19Z",
"power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z",
"quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z",
"repeat-outline": "m14.712 2.289l-.087-.078a1 1 0 0 0-1.327.078l-.078.087a.999.999 0 0 0 .078 1.326l1.299 1.297H8.999l-.24.004A6.997 6.997 0 0 0 2 11.993a6.94 6.94 0 0 0 1.189 3.899a.999.999 0 0 0 1.626-1.163l-.135-.218A4.997 4.997 0 0 1 9 6.998h5.595l-1.297 1.297l-.078.087a.999.999 0 0 0 1.492 1.326l3.006-3.003l.077-.087a.999.999 0 0 0-.078-1.326l-3.005-3.003Zm6.075 5.771A.999.999 0 0 0 19 8.677c0 .209.064.402.172.561a4.997 4.997 0 0 1-4.17 7.75H9.414l1.294-1.29l.083-.096a1 1 0 0 0-.006-1.23l-.077-.088l-.095-.084a1.001 1.001 0 0 0-1.232.006l-.088.078l-3.005 3.003l-.083.095a1 1 0 0 0 .006 1.231l.077.087l3.005 3.003l.095.084a1 1 0 0 0 1.397-1.41l-.077-.087l-1.304-1.303H15l.24-.003a6.997 6.997 0 0 0 5.546-10.927v.003Z",
diff --git a/app/services/automation_rules/condition_validation_service.rb b/app/services/automation_rules/condition_validation_service.rb
index 6f9f9a7c3..63e58b5bc 100644
--- a/app/services/automation_rules/condition_validation_service.rb
+++ b/app/services/automation_rules/condition_validation_service.rb
@@ -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']
diff --git a/app/services/automation_rules/conditions_filter_service.rb b/app/services/automation_rules/conditions_filter_service.rb
index 44b72db9e..daf9cb7e2 100644
--- a/app/services/automation_rules/conditions_filter_service.rb
+++ b/app/services/automation_rules/conditions_filter_service.rb
@@ -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']
diff --git a/app/services/contacts/filter_service.rb b/app/services/contacts/filter_service.rb
index 70062cad0..d3b24e4e2 100644
--- a/app/services/contacts/filter_service.rb
+++ b/app/services/contacts/filter_service.rb
@@ -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)
diff --git a/app/services/conversations/filter_service.rb b/app/services/conversations/filter_service.rb
index 87384017c..3a06959b8 100644
--- a/app/services/conversations/filter_service.rb
+++ b/app/services/conversations/filter_service.rb
@@ -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
diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb
index f1a699811..c545ac7d8 100644
--- a/app/services/filter_service.rb
+++ b/app/services/filter_service.rb
@@ -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
diff --git a/app/views/public/api/v1/portals/_header.html.erb b/app/views/public/api/v1/portals/_header.html.erb
index 9b875fcee..544fa1ba8 100644
--- a/app/views/public/api/v1/portals/_header.html.erb
+++ b/app/views/public/api/v1/portals/_header.html.erb
@@ -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| %>
-
+
<% end %>
<%= render partial: 'icons/chevron-down' %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c19499fa5..4c4f0483c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -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
diff --git a/db/migrate/20240319062553_create_sla_events.rb b/db/migrate/20240319062553_create_sla_events.rb
new file mode 100644
index 000000000..a6f1a5de2
--- /dev/null
+++ b/db/migrate/20240319062553_create_sla_events.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index d0499cb7b..498ad13d3 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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"
diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb
index 48fd852e4..c2dde8377 100644
--- a/enterprise/app/models/applied_sla.rb
+++ b/enterprise/app/models/applied_sla.rb
@@ -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
diff --git a/enterprise/app/models/sla_event.rb b/enterprise/app/models/sla_event.rb
new file mode 100644
index 000000000..28f709346
--- /dev/null
+++ b/enterprise/app/models/sla_event.rb
@@ -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
diff --git a/lib/automation_rules/conditions.json b/lib/automation_rules/conditions.json
deleted file mode 100644
index ba4bd4b5b..000000000
--- a/lib/automation_rules/conditions.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/lib/custom_exceptions/custom_filter.rb b/lib/custom_exceptions/custom_filter.rb
new file mode 100644
index 000000000..03ff9ec7a
--- /dev/null
+++ b/lib/custom_exceptions/custom_filter.rb
@@ -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
diff --git a/lib/filters/conversation_filters.json b/lib/filters/conversation_filters.json
deleted file mode 100644
index 39f58f5c6..000000000
--- a/lib/filters/conversation_filters.json
+++ /dev/null
@@ -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"
- }
- ]
-}
diff --git a/lib/filters/filter_keys.json b/lib/filters/filter_keys.json
deleted file mode 100644
index 9266d9bea..000000000
--- a/lib/filters/filter_keys.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml
new file mode 100644
index 000000000..598ab84d6
--- /dev/null
+++ b/lib/filters/filter_keys.yml
@@ -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 ----- ###
diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb
index d2527e6a9..37e64357f 100644
--- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb
@@ -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
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index 70f5b82ee..d93886fc3 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -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
diff --git a/spec/enterprise/models/sla_event_spec.rb b/spec/enterprise/models/sla_event_spec.rb
new file mode 100644
index 000000000..862b22e6c
--- /dev/null
+++ b/spec/enterprise/models/sla_event_spec.rb
@@ -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
diff --git a/spec/factories/sla_events.rb b/spec/factories/sla_events.rb
new file mode 100644
index 000000000..12be18ede
--- /dev/null
+++ b/spec/factories/sla_events.rb
@@ -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
diff --git a/spec/services/contacts/filter_service_spec.rb b/spec/services/contacts/filter_service_spec.rb
index 572d2b205..a373cf81e 100644
--- a/spec/services/contacts/filter_service_spec.rb
+++ b/spec/services/contacts/filter_service_spec.rb
@@ -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
diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb
index cb2279acf..2fbaf5f61 100644
--- a/spec/services/conversations/filter_service_spec.rb
+++ b/spec/services/conversations/filter_service_spec.rb
@@ -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: ''