diff --git a/.env.example b/.env.example index 26b1487ae..1b57141f0 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,9 @@ +# Learn about the various environment variables at +# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables + # Used to verify the integrity of signed cookies. so ensure a secure value is set +# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols. +# Use `rake secret` to generate this variable SECRET_KEY_BASE=replace_with_lengthy_secure_hex # Replace with the URL you are planning to use for your app @@ -184,12 +189,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: # SENTRY_DSN= -# MICROSOFT CLARITY -# MS_CLARITY_TOKEN=xxxxxxxxx - -# GOOGLE_TAG_MANAGER -# GOOGLE_TAG = GTM-XXXXXXX - ## Scout ## https://scoutapm.com/docs/ruby/configuration # SCOUT_KEY=YOURKEY diff --git a/Gemfile b/Gemfile index 38dee9bc4..1b2ed90b9 100644 --- a/Gemfile +++ b/Gemfile @@ -71,13 +71,13 @@ gem 'barnes' ##--- gems for authentication & authorization ---## gem 'devise', '>= 4.9.4' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' -gem 'devise_token_auth' +gem 'devise_token_auth', '>= 1.2.3' # authorization gem 'jwt' gem 'pundit' # super admin gem 'administrate', '>= 0.20.1' -gem 'administrate-field-active_storage', '>= 1.0.2' +gem 'administrate-field-active_storage', '>= 1.0.3' gem 'administrate-field-belongs_to_search', '>= 0.9.0' ##--- gems for pubsub service ---## @@ -166,7 +166,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1' # need for google auth gem 'omniauth', '>= 2.1.2' gem 'omniauth-google-oauth2', '>= 1.1.2' -gem 'omniauth-rails_csrf_protection', '~> 1.0' +gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' ## Gems for reponse bot # adds cosine similarity to postgres using vector extension diff --git a/Gemfile.lock b/Gemfile.lock index 5dc67bef1..374555cc3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,70 +33,70 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.0.8.1) - actionpack (= 7.0.8.1) - activesupport (= 7.0.8.1) + actioncable (7.0.8.3) + actionpack (= 7.0.8.3) + activesupport (= 7.0.8.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.1) - actionpack (= 7.0.8.1) - activejob (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionmailbox (7.0.8.3) + actionpack (= 7.0.8.3) + activejob (= 7.0.8.3) + activerecord (= 7.0.8.3) + activestorage (= 7.0.8.3) + activesupport (= 7.0.8.3) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.1) - actionpack (= 7.0.8.1) - actionview (= 7.0.8.1) - activejob (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionmailer (7.0.8.3) + actionpack (= 7.0.8.3) + actionview (= 7.0.8.3) + activejob (= 7.0.8.3) + activesupport (= 7.0.8.3) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.8.1) - actionview (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionpack (7.0.8.3) + actionview (= 7.0.8.3) + activesupport (= 7.0.8.3) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.1) - actionpack (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + actiontext (7.0.8.3) + actionpack (= 7.0.8.3) + activerecord (= 7.0.8.3) + activestorage (= 7.0.8.3) + activesupport (= 7.0.8.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.1) - activesupport (= 7.0.8.1) + actionview (7.0.8.3) + activesupport (= 7.0.8.3) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) active_record_query_trace (1.8) - activejob (7.0.8.1) - activesupport (= 7.0.8.1) + activejob (7.0.8.3) + activesupport (= 7.0.8.3) globalid (>= 0.3.6) - activemodel (7.0.8.1) - activesupport (= 7.0.8.1) - activerecord (7.0.8.1) - activemodel (= 7.0.8.1) - activesupport (= 7.0.8.1) + activemodel (7.0.8.3) + activesupport (= 7.0.8.3) + activerecord (7.0.8.3) + activemodel (= 7.0.8.3) + activesupport (= 7.0.8.3) activerecord-import (1.4.1) activerecord (>= 4.2) - activestorage (7.0.8.1) - actionpack (= 7.0.8.1) - activejob (= 7.0.8.1) - activerecord (= 7.0.8.1) - activesupport (= 7.0.8.1) + activestorage (7.0.8.3) + actionpack (= 7.0.8.3) + activejob (= 7.0.8.3) + activerecord (= 7.0.8.3) + activesupport (= 7.0.8.3) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.8.1) + activesupport (7.0.8.3) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -113,7 +113,7 @@ GEM kaminari (~> 1.2.2) sassc-rails (~> 2.1) selectize-rails (~> 0.6) - administrate-field-active_storage (1.0.2) + administrate-field-active_storage (1.0.3) administrate (>= 0.2.2) rails (>= 7.0) administrate-field-belongs_to_search (0.9.0) @@ -148,7 +148,7 @@ GEM barnes (0.0.9) multi_json (~> 1) statsd-ruby (~> 1.1) - base64 (0.1.1) + base64 (0.2.0) bcrypt (3.1.20) bigdecimal (3.1.7) bindex (0.8.1) @@ -200,10 +200,10 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise_token_auth (1.2.1) + devise_token_auth (1.2.3) bcrypt (~> 3.0) devise (> 3.5.2, < 5) - rails (>= 4.2.0, < 7.1) + rails (>= 4.2.0, < 7.2) diff-lcs (1.5.0) digest-crc (0.6.4) rake (>= 12.0.0, < 14.0.0) @@ -369,7 +369,7 @@ GEM mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.14.4) + i18n (1.14.5) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) @@ -461,7 +461,7 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.6) - minitest (5.22.3) + minitest (5.23.0) mock_redis (0.36.0) ruby2_keywords msgpack (1.7.0) @@ -474,7 +474,7 @@ GEM uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.10) + net-imap (0.4.11) date net-protocol net-pop (0.1.2) @@ -489,15 +489,15 @@ GEM sidekiq newrelic_rpm (9.6.0) base64 - nio4r (2.7.1) - nokogiri (1.16.4) + nio4r (2.7.3) + nokogiri (1.16.5) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.16.4-arm64-darwin) + nokogiri (1.16.5-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.4-x86_64-darwin) + nokogiri (1.16.5-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.4-x86_64-linux) + nokogiri (1.16.5-x86_64-linux) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -524,7 +524,7 @@ GEM omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) omniauth (~> 2.0) - omniauth-rails_csrf_protection (1.0.1) + omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) openssl (3.1.0) @@ -555,7 +555,7 @@ GEM rack (2.2.9) rack-attack (6.7.0) rack (>= 1.0, < 4) - rack-contrib (2.4.0) + rack-contrib (2.5.0) rack (< 4) rack-cors (2.0.0) rack (>= 2.0.0) @@ -569,20 +569,20 @@ GEM rack-test (2.1.0) rack (>= 1.3) rack-timeout (0.6.3) - rails (7.0.8.1) - actioncable (= 7.0.8.1) - actionmailbox (= 7.0.8.1) - actionmailer (= 7.0.8.1) - actionpack (= 7.0.8.1) - actiontext (= 7.0.8.1) - actionview (= 7.0.8.1) - activejob (= 7.0.8.1) - activemodel (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + rails (7.0.8.3) + actioncable (= 7.0.8.3) + actionmailbox (= 7.0.8.3) + actionmailer (= 7.0.8.3) + actionpack (= 7.0.8.3) + actiontext (= 7.0.8.3) + actionview (= 7.0.8.3) + activejob (= 7.0.8.3) + activemodel (= 7.0.8.3) + activerecord (= 7.0.8.3) + activestorage (= 7.0.8.3) + activesupport (= 7.0.8.3) bundler (>= 1.15.0) - railties (= 7.0.8.1) + railties (= 7.0.8.3) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -590,9 +590,9 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (7.0.8.1) - actionpack (= 7.0.8.1) - activesupport (= 7.0.8.1) + railties (7.0.8.3) + actionpack (= 7.0.8.3) + activesupport (= 7.0.8.3) method_source rake (>= 12.2) thor (~> 1.0) @@ -628,7 +628,8 @@ GEM retriable (3.1.2) reverse_markdown (2.1.1) nokogiri - rexml (3.2.5) + rexml (3.2.8) + strscan (>= 3.0.9) rspec-core (3.12.2) rspec-support (~> 3.12.0) rspec-expectations (3.12.3) @@ -758,6 +759,7 @@ GEM stackprof (0.2.25) statsd-ruby (1.5.0) stripe (8.5.0) + strscan (3.1.0) telephone_number (1.4.20) test-prof (1.2.1) thor (1.3.1) @@ -819,7 +821,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.6.13) + zeitwerk (2.6.14) PLATFORMS arm64-darwin-20 @@ -837,7 +839,7 @@ DEPENDENCIES activerecord-import acts-as-taggable-on administrate (>= 0.20.1) - administrate-field-active_storage (>= 1.0.2) + administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) annotate attr_extras @@ -860,7 +862,7 @@ DEPENDENCIES debug (~> 1.8) devise (>= 4.9.4) devise-secure_password! - devise_token_auth + devise_token_auth (>= 1.2.3) dotenv-rails down elastic-apm @@ -904,7 +906,7 @@ DEPENDENCIES omniauth (>= 2.1.2) omniauth-google-oauth2 (>= 1.1.2) omniauth-oauth2 - omniauth-rails_csrf_protection (~> 1.0) + omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) pg pg_search pgvector diff --git a/app.json b/app.json index 4ee025f06..d50040814 100644 --- a/app.json +++ b/app.json @@ -55,7 +55,7 @@ "plan": "heroku-redis:mini" }, { - "plan": "heroku-postgresql:mini" + "plan": "heroku-postgresql:essential-0" } ], "stack": "heroku-20", diff --git a/app/builders/v2/reports/conversations/base_report_builder.rb b/app/builders/v2/reports/conversations/base_report_builder.rb new file mode 100644 index 000000000..a7961b0d6 --- /dev/null +++ b/app/builders/v2/reports/conversations/base_report_builder.rb @@ -0,0 +1,30 @@ +class V2::Reports::Conversations::BaseReportBuilder + pattr_initialize :account, :params + + private + + AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze + COUNT_METRICS = %w[ + conversations_count + incoming_messages_count + outgoing_messages_count + resolutions_count + bot_resolutions_count + bot_handoffs_count + ].freeze + + def builder_class(metric) + case metric + when *AVG_METRICS + V2::Reports::Timeseries::AverageReportBuilder + when *COUNT_METRICS + V2::Reports::Timeseries::CountReportBuilder + end + end + + def log_invalid_metric + Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}" + + {} + end +end diff --git a/app/builders/v2/reports/conversations/metric_builder.rb b/app/builders/v2/reports/conversations/metric_builder.rb new file mode 100644 index 000000000..6635fb186 --- /dev/null +++ b/app/builders/v2/reports/conversations/metric_builder.rb @@ -0,0 +1,30 @@ +class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder + def summary + { + conversations_count: count('conversations_count'), + incoming_messages_count: count('incoming_messages_count'), + outgoing_messages_count: count('outgoing_messages_count'), + avg_first_response_time: count('avg_first_response_time'), + avg_resolution_time: count('avg_resolution_time'), + resolutions_count: count('resolutions_count'), + reply_time: count('reply_time') + } + end + + def bot_summary + { + bot_resolutions_count: count('bot_resolutions_count'), + bot_handoffs_count: count('bot_handoffs_count') + } + end + + private + + def count(metric) + builder_class(metric).new(account, builder_params(metric)).aggregate_value + end + + def builder_params(metric) + params.merge({ metric: metric }) + end +end diff --git a/app/builders/v2/reports/conversations/report_builder.rb b/app/builders/v2/reports/conversations/report_builder.rb new file mode 100644 index 000000000..8f992d4c6 --- /dev/null +++ b/app/builders/v2/reports/conversations/report_builder.rb @@ -0,0 +1,21 @@ +class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder + def timeseries + perform_action(:timeseries) + end + + def aggregate_value + perform_action(:aggregate_value) + end + + private + + def perform_action(method_name) + return builder.new(account, params).public_send(method_name) if builder.present? + + log_invalid_metric + end + + def builder + builder_class(params[:metric]) + end +end diff --git a/app/builders/v2/reports/timeseries/average_report_builder.rb b/app/builders/v2/reports/timeseries/average_report_builder.rb new file mode 100644 index 000000000..3e30557e1 --- /dev/null +++ b/app/builders/v2/reports/timeseries/average_report_builder.rb @@ -0,0 +1,48 @@ +class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder + def timeseries + grouped_average_time = reporting_events.average(average_value_key) + grouped_event_count = reporting_events.count + grouped_average_time.each_with_object([]) do |element, arr| + event_date, average_time = element + arr << { + value: average_time, + timestamp: event_date.in_time_zone(timezone).to_i, + count: grouped_event_count[event_date] + } + end + end + + def aggregate_value + object_scope.average(average_value_key) + end + + private + + def event_name + metric_to_event_name = { + avg_first_response_time: :first_response, + avg_resolution_time: :conversation_resolved, + reply_time: :reply_time + } + metric_to_event_name[params[:metric].to_sym] + end + + def object_scope + scope.reporting_events.where(name: event_name, created_at: range) + end + + def reporting_events + @grouped_values = object_scope.group_by_period( + group_by, + :created_at, + default_value: 0, + range: range, + permit: %w[day week month year hour], + time_zone: timezone + ) + end + + def average_value_key + @average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value + end +end diff --git a/app/builders/v2/reports/timeseries/base_timeseries_builder.rb b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb new file mode 100644 index 000000000..50699417d --- /dev/null +++ b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb @@ -0,0 +1,46 @@ +class V2::Reports::Timeseries::BaseTimeseriesBuilder + include TimezoneHelper + include DateRangeHelper + DEFAULT_GROUP_BY = 'day'.freeze + + pattr_initialize :account, :params + + def scope + case params[:type].to_sym + when :account + account + when :inbox + inbox + when :agent + user + when :label + label + when :team + team + end + end + + def inbox + @inbox ||= account.inboxes.find(params[:id]) + end + + def user + @user ||= account.users.find(params[:id]) + end + + def label + @label ||= account.labels.find(params[:id]) + end + + def team + @team ||= account.teams.find(params[:id]) + end + + def group_by + @group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY + end + + def timezone + @timezone ||= timezone_name_from_offset(params[:timezone_offset]) + end +end diff --git a/app/builders/v2/reports/timeseries/count_report_builder.rb b/app/builders/v2/reports/timeseries/count_report_builder.rb new file mode 100644 index 000000000..03a87a6fa --- /dev/null +++ b/app/builders/v2/reports/timeseries/count_report_builder.rb @@ -0,0 +1,71 @@ +class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder + def timeseries + grouped_count.each_with_object([]) do |element, arr| + event_date, event_count = element + + # The `event_date` is in Date format (without time), such as "Wed, 15 May 2024". + # We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i` + # because it converts the date to 12:00 AM server timezone. + # The desired output should be 12:00 AM in the specified timezone. + arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i } + end + end + + def aggregate_value + object_scope.count + end + + private + + def metric + @metric ||= params[:metric] + end + + def object_scope + send("scope_for_#{metric}") + end + + def scope_for_conversations_count + scope.conversations.where(account_id: account.id, created_at: range) + end + + def scope_for_incoming_messages_count + scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order) + end + + def scope_for_outgoing_messages_count + scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order) + end + + def scope_for_resolutions_count + scope.reporting_events.joins(:conversation).select(:conversation_id).where( + name: :conversation_resolved, + conversations: { status: :resolved }, created_at: range + ).distinct + end + + def scope_for_bot_resolutions_count + scope.reporting_events.joins(:conversation).select(:conversation_id).where( + name: :conversation_bot_resolved, + conversations: { status: :resolved }, created_at: range + ).distinct + end + + def scope_for_bot_handoffs_count + scope.reporting_events.joins(:conversation).select(:conversation_id).where( + name: :conversation_bot_handoff, + created_at: range + ).distinct + end + + def grouped_count + @grouped_values = object_scope.group_by_period( + group_by, + :created_at, + default_value: 0, + range: range, + permit: %w[day week month year hour], + time_zone: timezone + ).count + end +end diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb new file mode 100644 index 000000000..814373c7e --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -0,0 +1,93 @@ +class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController + before_action :fetch_conversation, only: [:link_issue, :linked_issues] + + def teams + teams = linear_processor_service.teams + if teams[:error] + render json: { error: teams[:error] }, status: :unprocessable_entity + else + render json: teams[:data], status: :ok + end + end + + def team_entities + team_id = permitted_params[:team_id] + team_entities = linear_processor_service.team_entities(team_id) + if team_entities[:error] + render json: { error: team_entities[:error] }, status: :unprocessable_entity + else + render json: team_entities[:data], status: :ok + end + end + + def create_issue + issue = linear_processor_service.create_issue(permitted_params) + if issue[:error] + render json: { error: issue[:error] }, status: :unprocessable_entity + else + render json: issue[:data], status: :ok + end + end + + def link_issue + issue_id = permitted_params[:issue_id] + title = permitted_params[:title] + issue = linear_processor_service.link_issue(conversation_link, issue_id, title) + if issue[:error] + render json: { error: issue[:error] }, status: :unprocessable_entity + else + render json: issue[:data], status: :ok + end + end + + def unlink_issue + link_id = permitted_params[:link_id] + issue = linear_processor_service.unlink_issue(link_id) + + if issue[:error] + render json: { error: issue[:error] }, status: :unprocessable_entity + else + render json: issue[:data], status: :ok + end + end + + def linked_issues + issues = linear_processor_service.linked_issues(conversation_link) + + if issues[:error] + render json: { error: issues[:error] }, status: :unprocessable_entity + else + render json: issues[:data], status: :ok + end + end + + def search_issue + render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return + + term = params[:q] + issues = linear_processor_service.search_issue(term) + if issues[:error] + render json: { error: issues[:error] }, status: :unprocessable_entity + else + render json: issues[:data], status: :ok + end + end + + private + + def conversation_link + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}" + end + + def fetch_conversation + @conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id]) + end + + def linear_processor_service + Integrations::Linear::ProcessorService.new(account: Current.account) + end + + def permitted_params + params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) + end +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index c67b74a43..ed5be5518 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -5,19 +5,17 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController before_action :check_authorization def index - builder = V2::ReportBuilder.new(Current.account, report_params) - data = builder.build + builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params) + data = builder.timeseries render json: data end def summary - render json: summary_metrics + render json: build_summary(:summary) end def bot_summary - summary = V2::ReportBuilder.new(Current.account, current_summary_params).bot_summary - summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).bot_summary - render json: summary + render json: build_summary(:bot_summary) end def agents @@ -126,10 +124,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController } end - def summary_metrics - summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary - summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary - summary + def build_summary(method) + builder = V2::Reports::Conversations::MetricBuilder + current_summary = builder.new(Current.account, current_summary_params).send(method) + previous_summary = builder.new(Current.account, previous_summary_params).send(method) + current_summary.merge(previous: previous_summary) end def conversation_metrics diff --git a/app/controllers/concerns/microsoft_concern.rb b/app/controllers/concerns/microsoft_concern.rb index 3aa3e4e81..0f37bd03f 100644 --- a/app/controllers/concerns/microsoft_concern.rb +++ b/app/controllers/concerns/microsoft_concern.rb @@ -2,7 +2,10 @@ module MicrosoftConcern extend ActiveSupport::Concern def microsoft_client - ::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil), + app_id = GlobalConfigService.load('AZURE_APP_ID', nil) + app_secret = GlobalConfigService.load('AZURE_APP_SECRET', nil) + + ::OAuth2::Client.new(app_id, app_secret, { site: 'https://login.microsoftonline.com', authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 047fd10c3..e656c2550 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -3,6 +3,7 @@ class DashboardController < ActionController::Base before_action :set_application_pack before_action :set_global_config + before_action :set_dashboard_scripts around_action :switch_locale before_action :ensure_installation_onboarding, only: [:index] before_action :render_hc_if_custom_domain, only: [:index] @@ -35,6 +36,10 @@ class DashboardController < ActionController::Base ).merge(app_config) end + def set_dashboard_scripts + @dashboard_scripts = GlobalConfig.get_value('DASHBOARD_SCRIPTS') + end + def ensure_installation_onboarding redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING) end @@ -58,7 +63,7 @@ class DashboardController < ActionController::Base FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), IS_ENTERPRISE: ChatwootApp.enterprise?, - AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''), + AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), GIT_SHA: GIT_HASH } end diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb index 215103bd4..aa1c22d91 100644 --- a/app/controllers/microsoft/callbacks_controller.rb +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -7,9 +7,14 @@ class Microsoft::CallbacksController < ApplicationController redirect_uri: "#{base_url}/microsoft/callback" ) - inbox = find_or_create_inbox + inbox, already_exists = find_or_create_inbox ::Redis::Alfred.delete(users_data['email'].downcase) - redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) + + if already_exists + redirect_to app_microsoft_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) + else + redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) + end rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception redirect_to '/' @@ -40,9 +45,17 @@ class Microsoft::CallbacksController < ApplicationController def find_or_create_inbox channel_email = Channel::Email.find_by(email: users_data['email'], account: account) + # we need this value to know where to redirect on sucessful processing of the callback + channel_exists = channel_email.present? + channel_email ||= create_microsoft_channel_with_inbox update_microsoft_channel(channel_email) - channel_email.inbox + + # reauthorize channel, this code path only triggers when microsoft auth is successful + # reauthorized will also update cache keys for the associated inbox + channel_email.reauthorized! + + [channel_email.inbox, channel_exists] end # Fallback name, for when name field is missing from users_data diff --git a/app/controllers/microsoft_controller.rb b/app/controllers/microsoft_controller.rb index 07e58d4db..e6a12dafa 100644 --- a/app/controllers/microsoft_controller.rb +++ b/app/controllers/microsoft_controller.rb @@ -12,6 +12,6 @@ class MicrosoftController < ApplicationController end def microsoft_indentity - @identity_json = ENV.fetch('AZURE_APP_ID', nil) + @identity_json = GlobalConfigService.load('AZURE_APP_ID', nil) end end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 6223f7174..9d9494fc4 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -35,6 +35,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController @allowed_configs = case @config when 'facebook' %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT] + when 'microsoft' + %w[AZURE_APP_ID AZURE_APP_SECRET] when 'email' ['MAILER_INBOUND_EMAIL_DOMAIN'] else diff --git a/app/helpers/timezone_helper.rb b/app/helpers/timezone_helper.rb new file mode 100644 index 000000000..b016cc9d9 --- /dev/null +++ b/app/helpers/timezone_helper.rb @@ -0,0 +1,19 @@ +module TimezoneHelper + # ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset] + # would return the timezone without considering day light savings. To get the correct timezone, + # this method uses zone.now.utc_offset for comparison as referenced in the issues below + # + # https://github.com/rails/rails/pull/22243 + # https://github.com/rails/rails/issues/21501 + # https://github.com/rails/rails/issues/7297 + def timezone_name_from_offset(offset) + return 'UTC' if offset.blank? + + offset_in_seconds = offset.to_f * 3600 + matching_zone = ActiveSupport::TimeZone.all.find do |zone| + zone.now.utc_offset == offset_in_seconds + end + + return matching_zone.name if matching_zone + end +end diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 94cc81354..ebc6176e7 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -15,6 +15,7 @@ class ConversationApi extends ApiClient { teamId, conversationType, sortBy, + updatedWithin, }) { return axios.get(this.url, { params: { @@ -26,6 +27,7 @@ class ConversationApi extends ApiClient { labels, conversation_type: conversationType, sort_by: sortBy, + updated_within: updatedWithin, }, }); } diff --git a/app/javascript/dashboard/api/integrations/linear.js b/app/javascript/dashboard/api/integrations/linear.js new file mode 100644 index 000000000..2ac0940aa --- /dev/null +++ b/app/javascript/dashboard/api/integrations/linear.js @@ -0,0 +1,47 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +class LinearAPI extends ApiClient { + constructor() { + super('integrations/linear', { accountScoped: true }); + } + + getTeams() { + return axios.get(`${this.url}/teams`); + } + + getTeamEntities(teamId) { + return axios.get(`${this.url}/team_entities?team_id=${teamId}`); + } + + createIssue(data) { + return axios.post(`${this.url}/create_issue`, data); + } + + link_issue(conversationId, issueId, title) { + return axios.post(`${this.url}/link_issue`, { + issue_id: issueId, + conversation_id: conversationId, + title: title, + }); + } + + getLinkedIssue(conversationId) { + return axios.get( + `${this.url}/linked_issues?conversation_id=${conversationId}` + ); + } + + unlinkIssue(linkId) { + return axios.post(`${this.url}/unlink_issue`, { + link_id: linkId, + }); + } + + searchIssues(query) { + return axios.get(`${this.url}/search_issue?q=${query}`); + } +} + +export default new LinearAPI(); diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index ecc833e16..3287d7477 100644 --- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -46,6 +46,7 @@ describe('#ConversationAPI', () => { page: 1, labels: [], teamId: 1, + updatedWithin: 20, }); expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', { params: { @@ -55,6 +56,7 @@ describe('#ConversationAPI', () => { assignee_type: 'me', page: 1, labels: [], + updated_within: 20, }, }); }); diff --git a/app/javascript/dashboard/api/specs/integrations/linear.spec.js b/app/javascript/dashboard/api/specs/integrations/linear.spec.js new file mode 100644 index 000000000..cc16feb16 --- /dev/null +++ b/app/javascript/dashboard/api/specs/integrations/linear.spec.js @@ -0,0 +1,202 @@ +import LinearAPIClient from '../../integrations/linear'; +import ApiClient from '../../ApiClient'; + +describe('#linearAPI', () => { + it('creates correct instance', () => { + expect(LinearAPIClient).toBeInstanceOf(ApiClient); + expect(LinearAPIClient).toHaveProperty('getTeams'); + expect(LinearAPIClient).toHaveProperty('getTeamEntities'); + expect(LinearAPIClient).toHaveProperty('createIssue'); + expect(LinearAPIClient).toHaveProperty('link_issue'); + expect(LinearAPIClient).toHaveProperty('getLinkedIssue'); + expect(LinearAPIClient).toHaveProperty('unlinkIssue'); + expect(LinearAPIClient).toHaveProperty('searchIssues'); + }); + + describe('getTeams', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getTeams(); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/teams' + ); + }); + }); + + describe('getTeamEntities', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getTeamEntities(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/team_entities?team_id=1' + ); + }); + }); + + describe('createIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + const issueData = { + title: 'New Issue', + description: 'Issue description', + }; + LinearAPIClient.createIssue(issueData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/create_issue', + issueData + ); + }); + }); + + describe('link_issue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.link_issue(1, 2); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/link_issue', + { + issue_id: 2, + conversation_id: 1, + } + ); + }); + }); + + describe('getLinkedIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getLinkedIssue(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/linked_issues?conversation_id=1' + ); + }); + }); + + describe('unlinkIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.unlinkIssue(1); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/unlink_issue', + { + link_id: 1, + } + ); + }); + }); + + describe('searchIssues', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.searchIssues('query'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/search_issue?q=query' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index e7fae6e01..3e251f42b 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -7,79 +7,17 @@ ]" > -
-
-

- {{ pageTitle }} -

- - {{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }} - -
-
-
- - -
-
- - -
- - -
-
+ + + + @@ -180,8 +127,8 @@ import { mapGetters } from 'vuex'; import VirtualList from 'vue-virtual-scroll-list'; +import ChatListHeader from './ChatListHeader.vue'; import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue'; -import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue'; import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ConversationItem from './ConversationItem.vue'; import timeMixin from '../mixins/time'; @@ -205,10 +152,15 @@ import { isOnUnattendedView, } from '../store/modules/conversations/helpers/actionHelpers'; import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events'; +import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents'; +import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers'; +import { getUnixTime } from 'date-fns'; +import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue'; import IntersectionObserver from './IntersectionObserver.vue'; export default { components: { + ChatListHeader, AddCustomViews, ChatTypeTabs, // eslint-disable-next-line vue/no-unused-components @@ -216,9 +168,9 @@ export default { ConversationAdvancedFilter, DeleteCustomViews, ConversationBulkActions, - ConversationBasicFilter, IntersectionObserver, VirtualList, + CustomSnoozeModal, }, mixins: [ timeMixin, @@ -288,7 +240,6 @@ export default { foldersQuery: {}, showAddFoldersModal: false, showDeleteFoldersModal: false, - selectedConversations: [], selectedInboxes: [], isContextMenuOpen: false, appliedFilter: [], @@ -296,6 +247,7 @@ export default { root: this.$refs.conversationList, rootMargin: '100px 0px 100px 0px', }, + showCustomSnoozeModal: false, itemComponent: ConversationItem, // virtualListExtraProps is to pass the props to the conversationItem component. @@ -316,6 +268,7 @@ export default { chatLists: 'getAllConversations', mineChatsList: 'getMineChats', allChatList: 'getAllStatusChats', + chatListFilters: 'getChatListFilters', unAssignedChatsList: 'getUnAssignedChats', chatListLoading: 'getChatListLoadingStatus', currentUserID: 'getCurrentUserID', @@ -329,23 +282,18 @@ export default { inboxesList: 'inboxes/getInboxes', campaigns: 'campaigns/getAllCampaigns', labels: 'labels/getLabels', + selectedConversations: 'bulkActions/getSelectedConversationIds', + contextMenuChatId: 'getContextMenuChatId', }), hasAppliedFilters() { return this.appliedFilters.length !== 0; }, hasActiveFolders() { - return this.activeFolder && this.foldersId !== 0; + return Boolean(this.activeFolder && this.foldersId !== 0); }, hasAppliedFiltersOrActiveFolders() { return this.hasAppliedFilters || this.hasActiveFolders; }, - savedFoldersValue() { - if (this.hasActiveFolders) { - const payload = this.activeFolder.query; - this.fetchSavedFilteredConversations(payload); - } - return {}; - }, showEndOfListMessage() { return ( this.conversationList.length && @@ -421,7 +369,6 @@ export default { labels: this.label ? [this.label] : undefined, teamId: this.teamId || undefined, conversationType: this.conversationType || undefined, - folders: this.hasActiveFolders ? this.savedFoldersValue : undefined, }; }, conversationListPagination() { @@ -534,7 +481,13 @@ export default { this.resetAndFetchData(); this.updateVirtualListProps('conversationType', this.conversationType); }, - activeFolder() { + activeFolder(newVal, oldVal) { + if (newVal !== oldVal) { + this.$store.dispatch( + 'customViews/setActiveConversationFolder', + newVal || null + ); + } this.resetAndFetchData(); this.updateVirtualListProps('foldersId', this.foldersId); }, @@ -544,8 +497,14 @@ export default { showAssigneeInConversationCard(newVal) { this.updateVirtualListProps('showAssignee', newVal); }, + conversationFilters(newVal, oldVal) { + if (newVal !== oldVal) { + this.$store.dispatch('updateChatListFilters', newVal); + } + }, }, mounted() { + this.$store.dispatch('setChatListFilters', this.conversationFilters); this.setFiltersFromUISettings(); this.$store.dispatch('setChatStatusFilter', this.activeStatus); this.$store.dispatch('setChatSortFilter', this.activeSortBy); @@ -558,6 +517,11 @@ export default { bus.$on('fetch_conversation_stats', () => { this.$store.dispatch('conversationStats/get', this.conversationFilters); }); + + bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation); + }, + beforeDestroy() { + bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation); }, methods: { updateVirtualListProps(key, value) { @@ -736,8 +700,9 @@ export default { this.fetchConversations(); }, fetchConversations() { + this.$store.dispatch('updateChatListFilters', this.conversationFilters); this.$store - .dispatch('fetchAllConversations', this.conversationFilters) + .dispatch('fetchAllConversations') .then(this.emitConversationLoaded); }, loadMoreConversations() { @@ -799,7 +764,7 @@ export default { }); }, resetBulkActions() { - this.selectedConversations = []; + this.$store.dispatch('bulkActions/clearSelectedConversationIds'); this.selectedInboxes = []; }, onBasicFilterChange(value, type) { @@ -830,12 +795,16 @@ export default { return this.selectedConversations.includes(id); }, selectConversation(conversationId, inboxId) { - this.selectedConversations.push(conversationId); + this.$store.dispatch( + 'bulkActions/setSelectedConversationIds', + conversationId + ); this.selectedInboxes.push(inboxId); }, deSelectConversation(conversationId, inboxId) { - this.selectedConversations = this.selectedConversations.filter( - item => item !== conversationId + this.$store.dispatch( + 'bulkActions/removeSelectedConversationIds', + conversationId ); this.selectedInboxes = this.selectedInboxes.filter( item => item !== inboxId @@ -843,7 +812,10 @@ export default { }, selectAllConversations(check) { if (check) { - this.selectedConversations = this.conversationList.map(item => item.id); + this.$store.dispatch( + 'bulkActions/setSelectedConversationIds', + this.conversationList.map(item => item.id) + ); this.selectedInboxes = this.conversationList.map(item => item.inbox_id); } else { this.resetBulkActions(); @@ -859,7 +831,7 @@ export default { assignee_id: agent.id, }, }); - this.selectedConversations = []; + this.$store.dispatch('bulkActions/clearSelectedConversationIds'); if (conversationId) { this.showAlert( this.$t( @@ -957,7 +929,7 @@ export default { add: labels, }, }); - this.selectedConversations = []; + this.$store.dispatch('bulkActions/clearSelectedConversationIds'); if (conversationId) { this.showAlert( this.$t( @@ -984,13 +956,13 @@ export default { team_id: team.id, }, }); - this.selectedConversations = []; + this.$store.dispatch('bulkActions/clearSelectedConversationIds'); this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL')); } catch (err) { this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED')); } }, - async onUpdateConversations(status) { + async onUpdateConversations(status, snoozedUntil) { try { await this.$store.dispatch('bulkActions/process', { type: 'Conversation', @@ -998,8 +970,9 @@ export default { fields: { status, }, + snoozed_until: snoozedUntil, }); - this.selectedConversations = []; + this.$store.dispatch('bulkActions/clearSelectedConversationIds'); this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL')); } catch (err) { this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED')); @@ -1020,12 +993,49 @@ export default { allSelectedConversationsStatus(status) { if (!this.selectedConversations.length) return false; return this.selectedConversations.every(item => { - return this.$store.getters.getConversationById(item).status === status; + return this.$store.getters.getConversationById(item)?.status === status; }); }, onContextMenuToggle(state) { this.isContextMenuOpen = state; }, + onCmdSnoozeConversation(snoozeType) { + if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) { + this.showCustomSnoozeModal = true; + } else { + this.toggleStatus( + wootConstants.STATUS_TYPE.SNOOZED, + findSnoozeTime(snoozeType) || null + ); + } + }, + chooseSnoozeTime(customSnoozeTime) { + this.showCustomSnoozeModal = false; + if (customSnoozeTime) { + this.toggleStatus( + wootConstants.STATUS_TYPE.SNOOZED, + getUnixTime(customSnoozeTime) + ); + } + }, + toggleStatus(status, snoozedUntil) { + this.$store + .dispatch('toggleStatus', { + conversationId: this.currentChat?.id || this.contextMenuChatId, + status, + snoozedUntil, + }) + .then(() => { + this.$store.dispatch('setContextMenuChatId', null); + this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS')); + }); + }, + hideCustomSnoozeModal() { + // if we select custom snooze and then the custom snooze modal is open + // Then if the custom snooze modal is closed and set the context menu chat id to null + this.$store.dispatch('setContextMenuChatId', null); + this.showCustomSnoozeModal = false; + }, }, }; diff --git a/app/javascript/dashboard/components/ChatListHeader.vue b/app/javascript/dashboard/components/ChatListHeader.vue new file mode 100644 index 000000000..d2df2bea3 --- /dev/null +++ b/app/javascript/dashboard/components/ChatListHeader.vue @@ -0,0 +1,115 @@ + + + diff --git a/app/javascript/dashboard/components/CustomAttribute.vue b/app/javascript/dashboard/components/CustomAttribute.vue index 2c0fb48f7..c9e8e6766 100644 --- a/app/javascript/dashboard/components/CustomAttribute.vue +++ b/app/javascript/dashboard/components/CustomAttribute.vue @@ -27,7 +27,7 @@ />
-
+
- - -
+