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 281ff95de..2aedf1928 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -36,10 +36,18 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end end + def update + @conversation.update!(permitted_update_params) + end + def filter result = ::Conversations::FilterService.new(params.permit!, current_user).perform @conversations = result[:conversations] @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 @@ -110,6 +118,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro private + def permitted_update_params + # TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute + params.permit(:priority) + end + def update_last_seen_on_conversation(last_seen_at, update_assignee) # rubocop:disable Rails/SkipsModelValidations @conversation.update_column(:agent_last_seen_at, last_seen_at) @@ -176,3 +189,5 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro @conversation.assignee_id? && Current.user == @conversation.assignee end end + +Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController') diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index cd760f7ce..2ee9f9854 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -1,6 +1,6 @@ module AccessTokenAuthHelper BOT_ACCESSIBLE_ENDPOINTS = { - 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create], + 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update], 'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/assignments' => ['create'] }.freeze 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/api/reports.js b/app/javascript/dashboard/api/reports.js index 987b69701..52fa7f444 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -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(); diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index 7822dad8f..05d4a152c 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -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( diff --git a/app/javascript/dashboard/assets/scss/_layout.scss b/app/javascript/dashboard/assets/scss/_layout.scss index ea40c1f3a..54a03c403 100644 --- a/app/javascript/dashboard/assets/scss/_layout.scss +++ b/app/javascript/dashboard/assets/scss/_layout.scss @@ -1,11 +1,10 @@ // scss-lint:disable SpaceAfterPropertyColon -// @import 'shared/assets/fonts/inter'; - +@import 'shared/assets/fonts/inter'; +// Inter, html, body { font-family: 'PlusJakarta', - Inter, -apple-system, system-ui, BlinkMacSystemFont, diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js b/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js index 967ee44ed..551256c74 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/reports.js @@ -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', diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue index 3b8fcbaa0..e72233b88 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue @@ -1,17 +1,45 @@ + + - - - - diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue index a9127ac16..bc24ed90f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue @@ -7,11 +7,12 @@ }" >
- +
+ +
+
@@ -82,6 +122,7 @@ export default { ol { padding-left: var(--space-two); } + table { margin: 0; border: 0; diff --git a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue index 2b324527e..b7b146040 100644 --- a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue @@ -8,45 +8,66 @@ >
-
-
+
+

{{ senderDetails.name }}

{{ readableTime }}
- {{ fileNameFromDataUrl }} - + v-dompurify-html="fileNameFromDataUrl" + class="overflow-hidden text-slate-700 dark:text-slate-100 whitespace-nowrap text-ellipsis" + />
+ +
-
+
-
+
-
+
@@ -174,6 +198,9 @@ const ALLOWED_FILE_TYPES = { AUDIO: 'audio', }; +const MAX_ZOOM_LEVEL = 2; +const MIN_ZOOM_LEVEL = 1; + export default { components: { Thumbnail, @@ -195,6 +222,7 @@ export default { }, data() { return { + zoomScale: 1, activeAttachment: {}, activeFileType: '', activeImageIndex: @@ -212,12 +240,9 @@ export default { return this.allAttachments.length > 1; }, readableTime() { - if (!this.activeAttachment.created_at) return ''; - const time = this.messageTimestamp( - this.activeAttachment.created_at, - 'LLL d yyyy, h:mm a' - ); - return time || ''; + const { created_at: createdAt } = this.activeAttachment; + if (!createdAt) return ''; + return this.messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || ''; }, isImage() { return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE; @@ -246,11 +271,12 @@ export default { const { data_url: dataUrl } = this.activeAttachment; if (!dataUrl) return ''; const fileName = dataUrl?.split('/').pop(); - return fileName || ''; + return decodeURIComponent(fileName || ''); }, imageRotationStyle() { return { - transform: `rotate(${this.activeImageRotation}deg)`, + transform: `rotate(${this.activeImageRotation}deg) scale(${this.zoomScale})`, + cursor: this.zoomScale < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out', }; }, }, @@ -268,6 +294,7 @@ export default { this.activeImageIndex = index; this.setImageAndVideoSrc(attachment); this.activeImageRotation = 0; + this.zoomScale = 1; }, setImageAndVideoSrc(attachment) { const { file_type: type } = attachment; @@ -316,6 +343,37 @@ export default { this.activeImageRotation += rotation; } }, + onClickZoomImage() { + this.onZoom(0.1); + }, + onZoom(scale) { + if (!this.isImage) { + return; + } + + const newZoomScale = this.zoomScale + scale; + // Check if the new zoom scale is within the allowed range + if (newZoomScale > MAX_ZOOM_LEVEL) { + // Set zoom to max but do not reset to default + this.zoomScale = MAX_ZOOM_LEVEL; + return; + } + if (newZoomScale < MIN_ZOOM_LEVEL) { + // Set zoom to min but do not reset to default + this.zoomScale = MIN_ZOOM_LEVEL; + return; + } + // If within bounds, update the zoom scale + this.zoomScale = newZoomScale; + }, + + onWheelImageZoom(e) { + if (!this.isImage) { + return; + } + const scale = e.deltaY > 0 ? -0.1 : 0.1; + this.onZoom(scale); + }, }, }; diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 2936d22ea..f2fd3e757 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -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', }; 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/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index a6d476bc9..56ca21773 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -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", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 63725f949..9a4bde2c8 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -234,6 +234,7 @@ "CAMPAIGNS": "Campaigns", "ONGOING": "Ongoing", "ONE_OFF": "One off", + "REPORTS_BOT": "Bot", "REPORTS_AGENT": "Agents", "REPORTS_LABEL": "Labels", "REPORTS_INBOX": "Inbox", diff --git a/app/javascript/dashboard/i18n/locale/en/sla.json b/app/javascript/dashboard/i18n/locale/en/sla.json index d87f057eb..dcf8d2dca 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,26 @@ "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" + ], + "BUSINESS_HOURS_ON": "Business hours on", + "BUSINESS_HOURS_OFF": "Business hours off", + "RESPONSE_TYPES": { + "FRT": "First response time threshold", + "NRT": "Next response time threshold", + "RT": "Resolution time threshold", + "SHORT_HAND": { + "FRT": "FRT", + "NRT": "NRT", + "RT": "RT" + } + } }, "FORM": { "NAME": { @@ -55,11 +77,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 " } } } diff --git a/app/javascript/dashboard/mixins/reportMixin.js b/app/javascript/dashboard/mixins/reportMixin.js index 2b8a5f87d..d57af1ad2 100644 --- a/app/javascript/dashboard/mixins/reportMixin.js +++ b/app/javascript/dashboard/mixins/reportMixin.js @@ -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) { diff --git a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js index d981de4e9..c0bc1e15f 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js +++ b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js @@ -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() {}, diff --git a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js index ab6b6fecf..591bf7c1f 100644 --- a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js +++ b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js @@ -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 }, 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/SettingsLayout.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue new file mode 100644 index 000000000..6dd3c306b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue @@ -0,0 +1,6 @@ + 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..3f5bf16f9 --- /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..e70358cc9 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue @@ -0,0 +1,102 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsListItem.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsListItem.vue new file mode 100644 index 000000000..005eece80 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsListItem.vue @@ -0,0 +1,53 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/ChangePassword.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/ChangePassword.vue index 8eb7201ae..99cee0325 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/ChangePassword.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/ChangePassword.vue @@ -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); diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue new file mode 100644 index 000000000..a75051aaf --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue @@ -0,0 +1,106 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue index c601b4277..273f814b7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue @@ -7,7 +7,7 @@ :key="metric.KEY" class="p-4 rounded-md mb-3" > - +
+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); + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue index 94fe1359d..4faf4de2b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue @@ -1,6 +1,8 @@ + 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 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue new file mode 100644 index 000000000..9a5fd0380 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue @@ -0,0 +1,64 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue new file mode 100644 index 000000000..a2caaa4e5 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue @@ -0,0 +1,36 @@ + + diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js index b529730fe..c4068b54f 100644 --- a/app/javascript/dashboard/store/modules/reports.js +++ b/app/javascript/dashboard/store/modules/reports.js @@ -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; }, diff --git a/app/javascript/dashboard/store/modules/sla.js b/app/javascript/dashboard/store/modules/sla.js index e1ded7f51..86d5db2ad 100644 --- a/app/javascript/dashboard/store/modules/sla.js +++ b/app/javascript/dashboard/store/modules/sla.js @@ -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 { diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index c9d2d1767..6f7d36cf7 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -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: 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 30e45d489..8c1ba6069 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -2,6 +2,8 @@ "add-circle-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM12 7a.75.75 0 0 1 .75.75v3.5h3.5a.75.75 0 0 1 0 1.5h-3.5v3.5a.75.75 0 0 1-1.5 0v-3.5h-3.5a.75.75 0 0 1 0-1.5h3.5v-3.5A.75.75 0 0 1 12 7Z", "add-outline": "M11.75 3a.75.75 0 0 1 .743.648l.007.102.001 7.25h7.253a.75.75 0 0 1 .102 1.493l-.102.007h-7.253l.002 7.25a.75.75 0 0 1-1.493.101l-.007-.102-.002-7.249H3.752a.75.75 0 0 1-.102-1.493L3.752 11h7.25L11 3.75a.75.75 0 0 1 .75-.75Z", "add-solid": "M11.883 3.007 12 3a1 1 0 0 1 .993.883L13 4v7h7a1 1 0 0 1 .993.883L21 12a1 1 0 0 1-.883.993L20 13h-7v7a1 1 0 0 1-.883.993L12 21a1 1 0 0 1-.993-.883L11 20v-7H4a1 1 0 0 1-.993-.883L3 12a1 1 0 0 1 .883-.993L4 11h7V4a1 1 0 0 1 .883-.993L12 3l-.117.007Z", + "alarm-on-outline": "m10.95 13.7l-1.425-1.425q-.3-.3-.7-.3t-.7.3q-.3.3-.3.713t.3.712l2.125 2.15q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.3-.712t-.3-.713q-.3-.3-.713-.3t-.712.3L10.95 13.7ZM12 22q-1.875 0-3.512-.713t-2.85-1.924q-1.213-1.213-1.925-2.85T3 13q0-1.875.713-3.513t1.924-2.85q1.213-1.212 2.85-1.924T12 4q1.875 0 3.513.713t2.85 1.925q1.212 1.212 1.925 2.85T21 13q0 1.875-.713 3.513t-1.924 2.85q-1.213 1.212-2.85 1.925T12 22Zm0-9ZM2.05 7.3q-.275-.275-.275-.7t.275-.7L4.9 3.05q.275-.275.7-.275t.7.275q.275.275.275.7t-.275.7L3.45 7.3q-.275.275-.7.275t-.7-.275Zm19.9 0q-.275.275-.7.275t-.7-.275L17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7ZM12 20q2.925 0 4.963-2.038T19 13q0-2.925-2.038-4.963T12 6Q9.075 6 7.037 8.038T5 13q0 2.925 2.038 4.963T12 20Z", + "alarm-off-outline": "m19.95 17.25l-1.5-1.5q.275-.675.413-1.313T19 13.1q0-2.9-2.05-5T12 6q-.7 0-1.35.113t-1.3.387L7.85 5q.95-.5 1.988-.75T12 4q1.85 0 3.488.7t2.862 1.938q1.225 1.237 1.938 2.887T21 13.1q0 1.125-.275 2.163t-.775 1.987ZM17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275L17.7 4.45ZM12 22q-1.85 0-3.488-.7T5.65 19.4q-1.225-1.2-1.938-2.825T3 13.1q0-1.55.463-2.912T4.8 7.7l-.85-.85l-.5.5q-.275.275-.7.275t-.7-.275q-.275-.275-.275-.7t.275-.7l.5-.5L1.4 4.3q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l18.4 18.4q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-2.45-2.45q-1.125.825-2.487 1.288T12 22Zm0-1.975q1.05 0 2.05-.325t1.85-.9L6.2 9.15q-.575.875-.887 1.888T5 13.1q0 2.9 2.05 4.913T12 20.025Zm-.95-6.05Zm2.85-2.85Z", "alert-outline": "M12 1.996a7.49 7.49 0 0 1 7.496 7.25l.004.25v4.097l1.38 3.156a1.25 1.25 0 0 1-1.145 1.75L15 18.502a3 3 0 0 1-5.995.177L9 18.499H4.275a1.251 1.251 0 0 1-1.147-1.747L4.5 13.594V9.496c0-4.155 3.352-7.5 7.5-7.5ZM13.5 18.5l-3 .002a1.5 1.5 0 0 0 2.993.145l.006-.147ZM12 3.496c-3.32 0-6 2.674-6 6v4.41L4.656 17h14.697L18 13.907V9.509l-.004-.225A5.988 5.988 0 0 0 12 3.496Z", "archive-outline": "M19.25 3c.966 0 1.75.784 1.75 1.75v2c0 .698-.408 1.3-1 1.581v9.919A3.75 3.75 0 0 1 16.25 22h-8.5A3.75 3.75 0 0 1 4 18.25V8.332A1.75 1.75 0 0 1 3 6.75v-2C3 3.784 3.784 3 4.75 3h14.5Zm-.75 5.5h-13v9.75a2.25 2.25 0 0 0 2.25 2.25h8.5a2.25 2.25 0 0 0 2.25-2.25V8.5Zm-8.5 3h4a.75.75 0 0 1 .102 1.493L14 13h-4a.75.75 0 0 1-.102-1.493L10 11.5h4-4Zm9.25-7H4.75a.25.25 0 0 0-.25.25v2c0 .138.112.25.25.25h14.5a.25.25 0 0 0 .25-.25v-2a.25.25 0 0 0-.25-.25Z", "arrow-chevron-left-outline": "M15 17.898c0 1.074-1.265 1.648-2.073.941l-6.31-5.522a1.75 1.75 0 0 1 0-2.634l6.31-5.522c.808-.707 2.073-.133 2.073.941v11.796Z", @@ -9,6 +11,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", @@ -118,6 +121,7 @@ "headphones-sound-wave-outline": "M3.5 12a8.5 8.5 0 0 1 17 0v2h-2.25a.75.75 0 0 0-.75.75v6.5c0 .414.336.75.75.75H19a3 3 0 0 0 3-3v-7c0-5.523-4.477-10-10-10S2 6.477 2 12v7a3 3 0 0 0 3 3h.75a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75H3.5v-2Zm17 3.5V19a1.5 1.5 0 0 1-1.5 1.5v-5h1.5ZM3.5 19v-3.5H5v5A1.5 1.5 0 0 1 3.5 19Zm9.25-7.25a.75.75 0 0 0-1.5 0v10.5a.75.75 0 0 0 1.5 0v-10.5Zm-4 2.25a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5a.75.75 0 0 1 .75-.75Zm7.25.75a.75.75 0 0 0-1.5 0v4.5a.75.75 0 0 0 1.5 0v-4.5Z", "image-outline": "M17.75 3A3.25 3.25 0 0 1 21 6.25v11.5A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h11.5Zm.58 16.401-5.805-5.686a.75.75 0 0 0-.966-.071l-.084.07-5.807 5.687c.182.064.378.099.582.099h11.5c.203 0 .399-.035.58-.099l-5.805-5.686L18.33 19.4ZM17.75 4.5H6.25A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .208.036.408.103.594l5.823-5.701a2.25 2.25 0 0 1 3.02-.116l.128.116 5.822 5.702c.067-.186.104-.386.104-.595V6.25a1.75 1.75 0 0 0-1.75-1.75Zm-2.498 2a2.252 2.252 0 1 1 0 4.504 2.252 2.252 0 0 1 0-4.504Zm0 1.5a.752.752 0 1 0 0 1.504.752.752 0 0 0 0-1.504Z", "info-outline": "M12 1.999c5.524 0 10.002 4.478 10.002 10.002 0 5.523-4.478 10.001-10.002 10.001-5.524 0-10.002-4.478-10.002-10.001C1.998 6.477 6.476 1.999 12 1.999Zm0 1.5a8.502 8.502 0 1 0 0 17.003A8.502 8.502 0 0 0 12 3.5Zm-.004 7a.75.75 0 0 1 .744.648l.007.102.003 5.502a.75.75 0 0 1-1.493.102l-.007-.101-.003-5.502a.75.75 0 0 1 .75-.75ZM12 7.003a.999.999 0 1 1 0 1.997.999.999 0 0 1 0-1.997Z", + "information-outline": "M12 17q.425 0 .713-.288T13 16v-4q0-.425-.288-.713T12 11q-.425 0-.713.288T11 12v4q0 .425.288.713T12 17Zm0-8q.425 0 .713-.288T13 8q0-.425-.288-.713T12 7q-.425 0-.713.288T11 8q0 .425.288.713T12 9Zm0 13q-2.075 0-3.9-.788t-3.175-2.137q-1.35-1.35-2.137-3.175T2 12q0-2.075.788-3.9t2.137-3.175q1.35-1.35 3.175-2.137T12 2q2.075 0 3.9.788t3.175 2.137q1.35 1.35 2.138 3.175T22 12q0 2.075-.788 3.9t-2.137 3.175q-1.35 1.35-3.175 2.138T12 22Zm0-2q3.35 0 5.675-2.325T20 12q0-3.35-2.325-5.675T12 4Q8.65 4 6.325 6.325T4 12q0 3.35 2.325 5.675T12 20Zm0-8Z", "key-outline": "M15 6a1 1 0 1 1-2 0a1 1 0 0 1 2 0Zm-2.5-4C9.424 2 7 4.424 7 7.5c0 .397.04.796.122 1.175c.058.27-.008.504-.142.638l-4.54 4.54A1.5 1.5 0 0 0 2 14.915V16.5A1.5 1.5 0 0 0 3.5 18h2A1.5 1.5 0 0 0 7 16.5V16h1a1 1 0 0 0 1-1v-1h1a1 1 0 0 0 1-1v-.18c.493.134 1.007.18 1.5.18c3.076 0 5.5-2.424 5.5-5.5S15.576 2 12.5 2ZM8 7.5C8 4.976 9.976 3 12.5 3S17 4.976 17 7.5S15.024 12 12.5 12c-.66 0-1.273-.095-1.776-.347A.5.5 0 0 0 10 12.1v.9H9a1 1 0 0 0-1 1v1H7a1 1 0 0 0-1 1v.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-1.586a.5.5 0 0 1 .146-.353l4.541-4.541c.432-.432.522-1.044.412-1.556A4.619 4.619 0 0 1 8 7.5Z", "keyboard-outline": "M19.745 5a2.25 2.25 0 0 1 2.25 2.25v9.505a2.25 2.25 0 0 1-2.25 2.25H4.25A2.25 2.25 0 0 1 2 16.755V7.25A2.25 2.25 0 0 1 4.25 5h15.495Zm0 1.5H4.25a.75.75 0 0 0-.75.75v9.505c0 .414.336.75.75.75h15.495a.75.75 0 0 0 .75-.75V7.25a.75.75 0 0 0-.75-.75Zm-12.995 8h10.5a.75.75 0 0 1 .102 1.493L17.25 16H6.75a.75.75 0 0 1-.102-1.493l.102-.007h10.5-10.5ZM16.5 11a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm-5.995 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm-3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm6 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM6 8a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm2.995 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z", "library-outline": "M4 3h1c1.054 0 1.918.816 1.995 1.85L7 5v14a2.001 2.001 0 0 1-1.85 1.994L5 21H4a2.001 2.001 0 0 1-1.995-1.85L2 19V5c0-1.054.816-1.918 1.85-1.995L4 3h1-1Zm6 0h1c1.054 0 1.918.816 1.995 1.85L13 5v14a2.001 2.001 0 0 1-1.85 1.994L11 21h-1a2.001 2.001 0 0 1-1.995-1.85L8 19V5c0-1.054.816-1.918 1.85-1.995L10 3h1-1Zm6.974 2c.84 0 1.608.531 1.89 1.346l.047.157 3.015 11.745a2 2 0 0 1-1.296 2.392l-.144.043-.969.248a2.002 2.002 0 0 1-2.387-1.284l-.047-.155-3.016-11.745a2 2 0 0 1 1.298-2.392l.143-.043.968-.248c.166-.043.334-.064.498-.064ZM5 4.5H4a.501.501 0 0 0-.492.41L3.5 5v14c0 .244.177.45.41.492L4 19.5h1c.245 0 .45-.178.492-.41L5.5 19V5a.501.501 0 0 0-.41-.492L5 4.5Zm6 0h-1a.501.501 0 0 0-.492.41L9.5 5v14c0 .244.177.45.41.492l.09.008h1c.245 0 .45-.178.492-.41L11.5 19V5a.501.501 0 0 0-.41-.492L11 4.5Zm5.975 2-.063.004-.063.013-.968.247a.498.498 0 0 0-.376.51l.015.1 3.016 11.745a.5.5 0 0 0 .483.375l.063-.003.062-.012.97-.25a.5.5 0 0 0 .374-.519l-.015-.088-3.015-11.747a.501.501 0 0 0-.483-.375Z", @@ -126,6 +130,14 @@ "location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z", "lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z", "lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z", + "zoom-in-outline": [ + "M13.5 10a.75.75 0 0 0-.75-.75h-2v-2a.75.75 0 0 0-1.5 0v2h-2a.75.75 0 1 0 0 1.5h2v2a.75.75 0 0 0 1.5 0v-2h2a.75.75 0 0 0 .75-.75Z", + "M10 2.75a7.25 7.25 0 0 1 5.63 11.819l4.9 4.9a.75.75 0 0 1-.976 1.134l-.084-.073-4.901-4.9A7.25 7.25 0 1 1 10 2.75Zm0 1.5a5.75 5.75 0 1 0 0 11.5 5.75 5.75 0 0 0 0-11.5Z" + ], + "zoom-out-outline": [ + "M12.75 9.25a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5h5.5Z", + "M17.25 10a7.25 7.25 0 1 0-2.681 5.63l4.9 4.9.085.073a.75.75 0 0 0 .976-1.133l-4.9-4.901A7.22 7.22 0 0 0 17.25 10Zm-13 0a5.75 5.75 0 1 1 11.5 0 5.75 5.75 0 0 1-11.5 0Z" + ], "mail-inbox-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z", "mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z", "mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z", @@ -158,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", @@ -233,7 +246,6 @@ "M14 0L13.9093 0.00622212C13.7497 0.0281315 13.6035 0.107093 13.4976 0.228504C13.3917 0.349915 13.3333 0.505562 13.3333 0.666661V1.33332H12.6667L12.576 1.33954C12.4164 1.36145 12.2701 1.44041 12.1642 1.56183C12.0584 1.68324 12 1.83888 12 1.99998L12.0062 2.09065C12.0281 2.25025 12.1071 2.39652 12.2285 2.50241C12.3499 2.60829 12.5056 2.66664 12.6667 2.66664H13.3333V3.3333L13.3396 3.42397C13.3615 3.58357 13.4404 3.72984 13.5618 3.83573C13.6833 3.94162 13.8389 3.99996 14 3.99996L14.0907 3.99374C14.416 3.9493 14.6667 3.67108 14.6667 3.3333V2.66664H15.3333L15.424 2.66042C15.7493 2.61598 16 2.33776 16 1.99998L15.9938 1.90932C15.9719 1.74971 15.8929 1.60345 15.7715 1.49756C15.6501 1.39167 15.4944 1.33333 15.3333 1.33332H14.6667V0.666661L14.6605 0.575995C14.6385 0.416393 14.5596 0.270123 14.4382 0.164236C14.3168 0.0583485 14.1611 6.79363e-06 14 0Z", "M16 12.0001L15.8187 12.0125C15.4995 12.0563 15.2069 12.2143 14.9951 12.4571C14.7834 12.6999 14.6667 13.0112 14.6667 13.3334V14.6667H13.3333L13.152 14.6792C12.8328 14.723 12.5403 14.8809 12.3285 15.1237C12.1167 15.3665 12 15.6778 12 16L12.0124 16.1814C12.0563 16.5006 12.2142 16.7931 12.457 17.0049C12.6998 17.2167 13.0111 17.3333 13.3333 17.3334H14.6667V18.6667L14.6791 18.848C14.7229 19.1672 14.8809 19.4598 15.1237 19.6715C15.3665 19.8833 15.6778 20 16 20L16.1813 19.9876C16.832 19.8987 17.3333 19.3422 17.3333 18.6667V17.3334H18.6667L18.848 17.3209C19.4987 17.232 20 16.6756 20 16L19.9876 15.8187C19.9437 15.4995 19.7858 15.207 19.543 14.9952C19.3002 14.7834 18.9889 14.6667 18.6667 14.6667H17.3333V13.3334L17.3209 13.1521C17.2771 12.8329 17.1191 12.5403 16.8763 12.3285C16.6335 12.1168 16.3222 12.0001 16 12.0001Z" ], - "book-copy-outline": [ "M3.66675 15.3334V5.33341C3.66675 4.89139 3.84234 4.46746 4.1549 4.1549C4.46746 3.84234 4.89139 3.66675 5.33341 3.66675H14.5001", "M6.16675 13.6667H5.33341C4.89139 13.6667 4.46746 13.8423 4.1549 14.1549C3.84234 14.4675 3.66675 14.8914 3.66675 15.3334C3.66675 15.7754 3.84234 16.1994 4.1549 16.5119C4.46746 16.8245 4.89139 17.0001 5.33341 17.0001H6.16675", diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index 5722e76a7..c528b9d6a 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -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 diff --git a/app/models/concerns/activity_message_handler.rb b/app/models/concerns/activity_message_handler.rb index 3c53b6798..d49442f10 100644 --- a/app/models/concerns/activity_message_handler.rb +++ b/app/models/concerns/activity_message_handler.rb @@ -2,14 +2,48 @@ module ActivityMessageHandler extend ActiveSupport::Concern include PriorityActivityMessageHandler + include LabelActivityMessageHandler + include SlaActivityMessageHandler + include TeamActivityMessageHandler private def create_activity - user_name = Current.user.name if Current.user.present? - status_change_activity(user_name) if saved_change_to_status? - priority_change_activity(user_name) if saved_change_to_priority? - create_label_change(activity_message_ownner(user_name)) if saved_change_to_label_list? + user_name = determine_user_name + + handle_status_change(user_name) + handle_priority_change(user_name) + handle_label_change(user_name) + handle_sla_policy_change(user_name) + end + + def determine_user_name + Current.user&.name + end + + def handle_status_change(user_name) + return unless saved_change_to_status? + + status_change_activity(user_name) + end + + def handle_priority_change(user_name) + return unless saved_change_to_priority? + + priority_change_activity(user_name) + end + + def handle_label_change(user_name) + return unless saved_change_to_label_list? + + create_label_change(activity_message_owner(user_name)) + end + + def handle_sla_policy_change(user_name) + return unless saved_change_to_sla_policy_id? + + sla_change_type = determine_sla_change_type + create_sla_change_activity(sla_change_type, activity_message_owner(user_name)) end def status_change_activity(user_name) @@ -45,21 +79,6 @@ module ActivityMessageHandler { account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content } end - def create_label_added(user_name, labels = []) - create_label_change_activity('added', user_name, labels) - end - - def create_label_removed(user_name, labels = []) - create_label_change_activity('removed', user_name, labels) - end - - def create_label_change_activity(change_type, user_name, labels = []) - return unless labels.size.positive? - - content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', ')) - ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content - end - def create_muted_message create_mute_change_activity('muted') end @@ -75,30 +94,6 @@ module ActivityMessageHandler ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content end - def generate_team_change_activity_key - team = Team.find_by(id: team_id) - key = team.present? ? 'assigned' : 'removed' - key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee - key - end - - def generate_team_name_for_activity - previous_team_id = previous_changes[:team_id][0] - Team.find_by(id: previous_team_id)&.name if previous_team_id.present? - end - - def create_team_change_activity(user_name) - user_name = activity_message_ownner(user_name) - return unless user_name - - key = generate_team_change_activity_key - params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name } - params[:team_name] = generate_team_name_for_activity if key == 'removed' - content = I18n.t("conversations.activity.team.#{key}", **params) - - ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content - end - def generate_assignee_change_activity_content(user_name) params = { assignee_name: assignee&.name, user_name: user_name }.compact key = assignee_id ? 'assigned' : 'removed' @@ -107,7 +102,7 @@ module ActivityMessageHandler end def create_assignee_change_activity(user_name) - user_name = activity_message_ownner(user_name) + user_name = activity_message_owner(user_name) return unless user_name @@ -115,7 +110,7 @@ module ActivityMessageHandler ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content end - def activity_message_ownner(user_name) + def activity_message_owner(user_name) user_name = 'Automation System' if !user_name && Current.executed_by.present? user_name end diff --git a/app/models/concerns/label_activity_message_handler.rb b/app/models/concerns/label_activity_message_handler.rb new file mode 100644 index 000000000..d5097e00f --- /dev/null +++ b/app/models/concerns/label_activity_message_handler.rb @@ -0,0 +1,20 @@ +module LabelActivityMessageHandler + extend ActiveSupport::Concern + + private + + def create_label_added(user_name, labels = []) + create_label_change_activity('added', user_name, labels) + end + + def create_label_removed(user_name, labels = []) + create_label_change_activity('removed', user_name, labels) + end + + def create_label_change_activity(change_type, user_name, labels = []) + return unless labels.size.positive? + + content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', ')) + ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content + end +end diff --git a/app/models/concerns/sla_activity_message_handler.rb b/app/models/concerns/sla_activity_message_handler.rb new file mode 100644 index 000000000..94603161e --- /dev/null +++ b/app/models/concerns/sla_activity_message_handler.rb @@ -0,0 +1,31 @@ +module SlaActivityMessageHandler + extend ActiveSupport::Concern + + private + + def create_sla_change_activity(change_type, user_name) + content = case change_type + when 'added' + I18n.t('conversations.activity.sla.added', user_name: user_name, sla_name: sla_policy_name) + when 'removed' + I18n.t('conversations.activity.sla.removed', user_name: user_name, sla_name: sla_policy_name) + when 'updated' + I18n.t('conversations.activity.sla.updated', user_name: user_name, sla_name: sla_policy_name) + end + ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content + end + + def sla_policy_name + SlaPolicy.find_by(id: sla_policy_id)&.name || '' + end + + def determine_sla_change_type + sla_policy_id_before, sla_policy_id_after = previous_changes[:sla_policy_id] + + if sla_policy_id_before.nil? && sla_policy_id_after.present? + 'added' + elsif sla_policy_id_before.present? && sla_policy_id_after.nil? + 'removed' + end + end +end diff --git a/app/models/concerns/team_activity_message_handler.rb b/app/models/concerns/team_activity_message_handler.rb new file mode 100644 index 000000000..aba5237c4 --- /dev/null +++ b/app/models/concerns/team_activity_message_handler.rb @@ -0,0 +1,29 @@ +module TeamActivityMessageHandler + extend ActiveSupport::Concern + + private + + def create_team_change_activity(user_name) + user_name = activity_message_owner(user_name) + return unless user_name + + key = generate_team_change_activity_key + params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name } + params[:team_name] = generate_team_name_for_activity if key == 'removed' + content = I18n.t("conversations.activity.team.#{key}", **params) + + ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content + end + + def generate_team_change_activity_key + team = Team.find_by(id: team_id) + key = team.present? ? 'assigned' : 'removed' + key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee + key + end + + def generate_team_name_for_activity + previous_team_id = previous_changes[:team_id][0] + Team.find_by(id: previous_team_id)&.name if previous_team_id.present? + end +end diff --git a/app/models/contact.rb b/app/models/contact.rb index 1e9ab8dc6..95ee69c75 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -61,6 +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 :sync_contact_attributes enum contact_type: { visitor: 0, lead: 1, customer: 2 } @@ -168,7 +169,7 @@ class Contact < ApplicationRecord end def self.from_email(email) - find_by(email: email.downcase) + find_by(email: email&.downcase) end private @@ -206,6 +207,10 @@ class Contact < ApplicationRecord self.custom_attributes = {} if custom_attributes.blank? end + def sync_contact_attributes + ::Contacts::SyncAttributes.new(self).perform + end + def dispatch_create_event Rails.configuration.dispatcher.dispatch(CONTACT_CREATED, Time.zone.now, contact: self) end diff --git a/app/models/user.rb b/app/models/user.rb index 3fdaf7f25..faadb3271 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -157,7 +157,7 @@ class User < ApplicationRecord end def self.from_email(email) - find_by(email: email.downcase) + find_by(email: email&.downcase) end private 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/contacts/sync_attributes.rb b/app/services/contacts/sync_attributes.rb new file mode 100644 index 000000000..bc10def66 --- /dev/null +++ b/app/services/contacts/sync_attributes.rb @@ -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 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/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb index d18994a00..a36231634 100644 --- a/app/services/telegram/incoming_message_service.rb +++ b/app/services/telegram/incoming_message_service.rb @@ -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 diff --git a/app/views/api/v1/accounts/conversations/update.json.jbuilder b/app/views/api/v1/accounts/conversations/update.json.jbuilder new file mode 100644 index 000000000..c273dd3c6 --- /dev/null +++ b/app/views/api/v1/accounts/conversations/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/conversations/partials/conversation', formats: [:json], conversation: @conversation diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index cef56fed4..2e90e073a 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -47,3 +47,4 @@ json.last_non_activity_message conversation.messages.where(account_id: conversat json.last_activity_at conversation.last_activity_at.to_i json.priority conversation.priority json.waiting_since conversation.waiting_since.to_i.to_i +json.sla_policy_id conversation.sla_policy_id 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/app.yml b/config/app.yml index 99f2706e9..220fabfe7 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '3.6.0' + version: '3.7.0' development: <<: *shared diff --git a/config/locales/en.yml b/config/locales/en.yml index 5a3816867..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 @@ -158,6 +160,9 @@ en: labels: added: "%{user_name} added %{labels}" removed: "%{user_name} removed %{labels}" + sla: + added: "%{user_name} added SLA policy %{sla_name}" + removed: "%{user_name} removed SLA policy %{sla_name}" muted: "%{user_name} has muted the conversation" unmuted: "%{user_name} has unmuted the conversation" templates: diff --git a/config/routes.rb b/config/routes.rb index 90317c7b2..cfa14c854 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,7 +78,7 @@ Rails.application.routes.draw do namespace :channels do resource :twilio_channel, only: [:create] end - resources :conversations, only: [:index, :create, :show] do + resources :conversations, only: [:index, :create, :show, :update] do collection do get :meta get :search 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/controllers/enterprise/api/v1/accounts/conversations_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb new file mode 100644 index 000000000..be8dfbddf --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/conversations_controller.rb @@ -0,0 +1,5 @@ +module Enterprise::Api::V1::Accounts::ConversationsController + def permitted_update_params + super.merge(params.permit(:sla_policy_id)) + end +end diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb index cb5ca508d..c2dde8377 100644 --- a/enterprise/app/models/applied_sla.rb +++ b/enterprise/app/models/applied_sla.rb @@ -22,7 +22,16 @@ 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 enum sla_status: { active: 0, hit: 1, missed: 2 } + + private + + def ensure_account_id + self.account_id ||= sla_policy&.account_id + end end diff --git a/enterprise/app/models/enterprise/enterprise_conversation_concern.rb b/enterprise/app/models/enterprise/enterprise_conversation_concern.rb index fc39a61a9..721f069dc 100644 --- a/enterprise/app/models/enterprise/enterprise_conversation_concern.rb +++ b/enterprise/app/models/enterprise/enterprise_conversation_concern.rb @@ -3,5 +3,35 @@ module Enterprise::EnterpriseConversationConcern included do belongs_to :sla_policy, optional: true + has_one :applied_sla, dependent: :destroy + before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? } + around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? } + end + + private + + def validate_sla_policy + # TODO: remove these validations once we figure out how to deal with these cases + if sla_policy_id.nil? && changes[:sla_policy_id].first.present? + errors.add(:sla_policy, 'cannot remove sla policy from conversation') + return + end + + if changes[:sla_policy_id].first.present? + errors.add(:sla_policy, 'conversation already has a different sla') + return + end + + errors.add(:sla_policy, 'sla policy account mismatch') if sla_policy&.account_id != account_id + end + + # handling inside a transaction to ensure applied sla record is also created + def ensure_applied_sla_is_created + ActiveRecord::Base.transaction do + yield + create_applied_sla(sla_policy_id: sla_policy_id) if applied_sla.blank? + end + rescue ActiveRecord::RecordInvalid + raise ActiveRecord::Rollback end end 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/enterprise/app/models/sla_policy.rb b/enterprise/app/models/sla_policy.rb index 647db2cfc..f53f00ed5 100644 --- a/enterprise/app/models/sla_policy.rb +++ b/enterprise/app/models/sla_policy.rb @@ -22,6 +22,7 @@ class SlaPolicy < ApplicationRecord validates :name, presence: true has_many :conversations, dependent: :nullify + has_many :applied_slas, dependent: :destroy def push_event_data { diff --git a/enterprise/app/services/enterprise/action_service.rb b/enterprise/app/services/enterprise/action_service.rb index 1e4165b09..f0c3bbf9f 100644 --- a/enterprise/app/services/enterprise/action_service.rb +++ b/enterprise/app/services/enterprise/action_service.rb @@ -8,16 +8,5 @@ module Enterprise::ActionService Rails.logger.info "SLA:: Adding SLA #{sla_policy.id} to conversation: #{@conversation.id}" @conversation.update!(sla_policy_id: sla_policy.id) - create_applied_sla(sla_policy) - end - - def create_applied_sla(sla_policy) - Rails.logger.info "SLA:: Creating Applied SLA for conversation: #{@conversation.id}" - AppliedSla.create!( - account_id: @conversation.account_id, - sla_policy_id: sla_policy.id, - conversation_id: @conversation.id, - sla_status: 'active' - ) end end 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/package.json b/package.json index ac353f186..fcf7e23fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "3.6.0", + "version": "3.7.0", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", 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 72e6603f7..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 @@ -210,6 +249,55 @@ RSpec.describe 'Conversations API', type: :request do end end + describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do + let(:conversation) { create(:conversation, account: account) } + let(:params) { { priority: 'high' } } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'does not update the conversation if you do not have access to it' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'updates the conversation if you are an administrator' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high') + end + + it 'updates the conversation if you are an agent with access to inbox' do + create(:inbox_member, user: agent, inbox: conversation.inbox) + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high') + end + end + end + describe 'POST /api/v1/accounts/{account.id}/conversations' do let(:contact) { create(:contact, account: account) } let(:inbox) { create(:inbox, account: account) } @@ -411,21 +499,6 @@ RSpec.describe 'Conversations API', type: :request do expect(conversation.reload.status).to eq('snoozed') expect(conversation.reload.snoozed_until.to_i).to eq(snoozed_until) end - - # TODO: remove this spec when we remove the condition check in controller - # Added for backwards compatibility for bot status - # remove in next release - # it 'toggles the conversation status to pending status when parameter bot is passed' do - # expect(conversation.status).to eq('open') - - # post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status", - # headers: agent.create_new_auth_token, - # params: { status: 'bot' }, - # as: :json - - # expect(response).to have_http_status(:success) - # expect(conversation.reload.status).to eq('pending') - # end end context 'when it is an authenticated bot' do diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb new file mode 100644 index 000000000..fb028b76f --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise Conversations API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + + describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do + let(:conversation) { create(:conversation, account: account) } + let(:sla_policy) { create(:sla_policy, account: account) } + let(:params) { { sla_policy_id: sla_policy.id } } + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + + before do + create(:inbox_member, user: agent, inbox: conversation.inbox) + end + + it 'updates the conversation if you are an agent with access to inbox' do + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body, symbolize_names: true)[:sla_policy_id]).to eq(sla_policy.id) + end + + it 'throws error if conversation already has a different sla' do + conversation.update(sla_policy: create(:sla_policy, account: account)) + patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + params: params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body, symbolize_names: true)[:message]).to eq('Sla policy conversation already has a different sla') + end + end + end +end diff --git a/spec/enterprise/models/conversation_spec.rb b/spec/enterprise/models/conversation_spec.rb index e91f3f885..e9fe811ca 100644 --- a/spec/enterprise/models/conversation_spec.rb +++ b/spec/enterprise/models/conversation_spec.rb @@ -5,6 +5,37 @@ RSpec.describe Conversation, type: :model do it { is_expected.to belong_to(:sla_policy).optional } end + describe 'SLA policy updates' do + let!(:conversation) { create(:conversation) } + let!(:sla_policy) { create(:sla_policy, account: conversation.account) } + + it 'generates an activity message when the SLA policy is updated' do + conversation.update!(sla_policy_id: sla_policy.id) + + perform_enqueued_jobs + + activity_message = conversation.messages.where(message_type: 'activity').last + + expect(activity_message).not_to be_nil + expect(activity_message.message_type).to eq('activity') + expect(activity_message.content).to include('added SLA policy') + end + + # TODO: Reenable this when we let the SLA policy be removed from a conversation + # it 'generates an activity message when the SLA policy is removed' do + # conversation.update!(sla_policy_id: sla_policy.id) + # conversation.update!(sla_policy_id: nil) + + # perform_enqueued_jobs + + # activity_message = conversation.messages.where(message_type: 'activity').last + + # expect(activity_message).not_to be_nil + # expect(activity_message.message_type).to eq('activity') + # expect(activity_message.content).to include('removed SLA policy') + # end + end + describe 'conversation sentiments' do include ActiveJob::TestHelper @@ -34,4 +65,43 @@ RSpec.describe Conversation, type: :model do expect(sentiments[:label]).to eq('positive') end end + + describe 'sla_policy' do + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + let(:sla_policy) { create(:sla_policy, account: account) } + let(:different_account_sla_policy) { create(:sla_policy) } + + context 'when sla_policy is getting updated' do + it 'throws error if sla policy belongs to different account' do + conversation.sla_policy = different_account_sla_policy + expect(conversation.valid?).to be false + expect(conversation.errors[:sla_policy]).to include('sla policy account mismatch') + end + + it 'creates applied sla record if sla policy is present' do + conversation.sla_policy = sla_policy + conversation.save! + expect(conversation.applied_sla.sla_policy_id).to eq(sla_policy.id) + end + end + + context 'when conversation already has a different sla' do + before do + conversation.update(sla_policy: create(:sla_policy, account: account)) + end + + it 'throws error if trying to assing a different sla' do + conversation.sla_policy = sla_policy + expect(conversation.valid?).to be false + expect(conversation.errors[:sla_policy]).to eq(['conversation already has a different sla']) + end + + it 'throws error if trying to set sla to nil' do + conversation.sla_policy = nil + expect(conversation.valid?).to be false + expect(conversation.errors[:sla_policy]).to eq(['cannot remove sla policy from conversation']) + end + end + end end 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/enterprise/services/sla/evaluate_applied_sla_service_spec.rb b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb index 17c09cb0e..12cb59d35 100644 --- a/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb +++ b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb @@ -5,14 +5,21 @@ RSpec.describe Sla::EvaluateAppliedSlaService do let!(:user_1) { create(:user, account: account) } let!(:user_2) { create(:user, account: account) } let!(:admin) { create(:user, account: account, role: :administrator) } - let!(:conversation) { create(:conversation, created_at: 6.hours.ago, assignee: user_1, account: account) } + let!(:sla_policy) do - create(:sla_policy, account: conversation.account, - first_response_time_threshold: nil, - next_response_time_threshold: nil, - resolution_time_threshold: nil) + create(:sla_policy, + account: account, + first_response_time_threshold: nil, + next_response_time_threshold: nil, + resolution_time_threshold: nil) end - let!(:applied_sla) { create(:applied_sla, conversation: conversation, sla_policy: sla_policy, sla_status: 'active') } + let!(:conversation) do + create(:conversation, + created_at: 6.hours.ago, assignee: user_1, + account: sla_policy.account, + sla_policy: sla_policy) + end + let!(:applied_sla) { conversation.applied_sla } describe '#perform - SLA misses' do context 'when first response SLA is missed' do 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/models/contact_spec.rb b/spec/models/contact_spec.rb index 7029c89fe..2ca65fa4e 100644 --- a/spec/models/contact_spec.rb +++ b/spec/models/contact_spec.rb @@ -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 @@ -75,4 +77,29 @@ RSpec.describe Contact do expect(contact.email).to eq 'test@test.com' end end + + context 'when city and country code passed in additional attributes' do + it 'updates location and country code' do + contact = create(:contact, additional_attributes: { city: 'New York', country: 'US' }) + expect(contact.location).to eq 'New York' + 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 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/contacts/sync_attributes_spec.rb b/spec/services/contacts/sync_attributes_spec.rb new file mode 100644 index 000000000..447bcd740 --- /dev/null +++ b/spec/services/contacts/sync_attributes_spec.rb @@ -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 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: '' diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 795202894..3cade6343 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -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 diff --git a/swagger/paths/application/conversation/update.yml b/swagger/paths/application/conversation/update.yml new file mode 100644 index 000000000..3add02635 --- /dev/null +++ b/swagger/paths/application/conversation/update.yml @@ -0,0 +1,29 @@ +tags: + - Conversations +operationId: update-conversation +summary: Update Conversation +description: Update Conversation Attributes +security: + - userApiKey: [] + - agentBotApiKey: [] +parameters: + - name: data + in: body + required: true + schema: + type: object + properties: + priority: + type: string + enum: ["urgent", "high", "medium", "low", "none"] + description: "The priority of the conversation" + sla_policy_id: + type: number + description: "The ID of the SLA policy (Available only in Enterprise edition)" +responses: + 200: + description: Success + 404: + description: Conversation not found + 401: + description: Unauthorized diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 2c6dd3f87..6df22d06e 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -339,6 +339,8 @@ - $ref: '#/parameters/conversation_id' get: $ref: ./application/conversation/show.yml + patch: + $ref: ./application/conversation/update.yml /api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status: parameters: - $ref: '#/parameters/account_id' diff --git a/swagger/swagger.json b/swagger/swagger.json index ec88a224b..d5f94f730 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -3319,6 +3319,64 @@ "description": "Access denied" } } + }, + "patch": { + "tags": [ + "Conversations" + ], + "operationId": "update-conversation", + "summary": "Update Conversation", + "description": "Update Conversation Attributes", + "security": [ + { + "userApiKey": [ + + ] + }, + { + "agentBotApiKey": [ + + ] + } + ], + "parameters": [ + { + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "priority": { + "type": "string", + "enum": [ + "urgent", + "high", + "medium", + "low", + "none" + ], + "description": "The priority of the conversation" + }, + "sla_policy_id": { + "type": "number", + "description": "The ID of the SLA policy (Available only in Enterprise edition)" + } + } + } + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Conversation not found" + }, + "401": { + "description": "Unauthorized" + } + } } }, "/api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status": { diff --git a/tailwind.config.js b/tailwind.config.js index aa7a2a14a..a34650fb5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -26,6 +26,11 @@ module.exports = { './app/views/**/*.html.erb', ], theme: { + extend: { + fontFamily: { + inter: ['Inter', ...defaultTheme.fontFamily.sans], + }, + }, fontSize: { ...defaultTheme.fontSize, xxs: '0.625rem',