diff --git a/.env.example b/.env.example index 26b1487ae..befcde463 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 @@ -80,6 +85,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer # Comment out the following environment variables if required by your SMTP server # SMTP_TLS= # SMTP_SSL= +# SMTP_OPEN_TIMEOUT +# SMTP_READ_TIMEOUT # Mail Incoming # This is the domain set for the reply emails when conversation continuity is enabled @@ -184,12 +191,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 bee9eddc1..302094e8f 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ ruby '3.2.2' ##-- base gems for rails --## gem 'rack-cors', '2.0.0', require: 'rack/cors' -gem 'rails', '~> 7.0.8.1' +gem 'rails', '~> 7.0.8.4' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false @@ -61,7 +61,7 @@ gem 'redis-namespace' gem 'activerecord-import' ##--- gems for server & infra configuration ---## -gem 'dotenv-rails' +gem 'dotenv-rails', '>= 3.0.0' gem 'foreman' gem 'puma' gem 'webpacker' @@ -77,7 +77,7 @@ 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 ---## @@ -122,7 +122,7 @@ gem 'sidekiq-cron', '>= 1.12.0' ##-- Push notification service --## gem 'fcm' -gem 'web-push' +gem 'web-push', '>= 3.0.1' ##-- geocoding / parse location from ip --## # http://www.rubygeocoder.com/ @@ -228,7 +228,7 @@ group :development, :test do gem 'mock_redis' gem 'pry-rails' gem 'rspec_junit_formatter' - gem 'rspec-rails' + gem 'rspec-rails', '>= 6.0.3' gem 'rubocop', require: false gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 2f890deaa..323847645 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.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) 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.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) 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.4) + actionpack (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activesupport (= 7.0.8.4) 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.4) + actionview (= 7.0.8.4) + activesupport (= 7.0.8.4) 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.4) + actionpack (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.1) - activesupport (= 7.0.8.1) + actionview (7.0.8.4) + activesupport (= 7.0.8.4) 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.4) + activesupport (= 7.0.8.4) 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.4) + activesupport (= 7.0.8.4) + activerecord (7.0.8.4) + activemodel (= 7.0.8.4) + activesupport (= 7.0.8.4) 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.4) + actionpack (= 7.0.8.4) + activejob (= 7.0.8.4) + activerecord (= 7.0.8.4) + activesupport (= 7.0.8.4) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.8.1) + activesupport (7.0.8.4) 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) @@ -156,7 +156,7 @@ GEM msgpack (~> 1.2) brakeman (5.4.1) browser (5.3.1) - builder (3.2.4) + builder (3.3.0) bullet (7.0.7) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) @@ -169,7 +169,7 @@ GEM climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.3) connection_pool (2.4.1) crack (0.4.5) rexml @@ -204,16 +204,16 @@ GEM bcrypt (~> 3.0) devise (> 3.5.2, < 5) rails (>= 4.2.0, < 7.2) - diff-lcs (1.5.0) + diff-lcs (1.5.1) digest-crc (0.6.4) rake (>= 12.0.0, < 14.0.0) docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - dotenv (2.8.1) - dotenv-rails (2.8.1) - dotenv (= 2.8.1) - railties (>= 3.2) + dotenv (3.1.2) + dotenv-rails (3.1.2) + dotenv (= 3.1.2) + railties (>= 6.1) down (5.4.0) addressable (~> 2.8) ecma-re-validator (0.4.0) @@ -355,7 +355,6 @@ GEM hana (1.3.7) hashdiff (1.0.1) hashie (5.0.0) - hkdf (1.0.0) http (5.1.1) addressable (~> 2.8) http-cookie (~> 1.0) @@ -460,8 +459,8 @@ GEM mime-types-data (3.2023.0218.1) mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.6) - minitest (5.22.3) + mini_portile2 (2.8.7) + minitest (5.23.1) mock_redis (0.36.0) ruby2_keywords msgpack (1.7.0) @@ -474,7 +473,7 @@ GEM uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.11) + net-imap (0.4.12) date net-protocol net-pop (0.1.2) @@ -527,7 +526,7 @@ GEM omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) - openssl (3.1.0) + openssl (3.2.0) orm_adapter (0.5.0) os (1.1.4) parallel (1.23.0) @@ -551,11 +550,11 @@ GEM pundit (2.3.0) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.7.3) + racc (1.8.0) 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 +568,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.4) + actioncable (= 7.0.8.4) + actionmailbox (= 7.0.8.4) + actionmailer (= 7.0.8.4) + actionpack (= 7.0.8.4) + actiontext (= 7.0.8.4) + actionview (= 7.0.8.4) + activejob (= 7.0.8.4) + activemodel (= 7.0.8.4) + activerecord (= 7.0.8.4) + activestorage (= 7.0.8.4) + activesupport (= 7.0.8.4) bundler (>= 1.15.0) - railties (= 7.0.8.1) + railties (= 7.0.8.4) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -590,9 +589,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.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) method_source rake (>= 12.2) thor (~> 1.0) @@ -628,24 +627,25 @@ GEM retriable (3.1.2) reverse_markdown (2.1.1) nokogiri - rexml (3.2.5) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + rexml (3.2.8) + strscan (>= 3.0.9) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.5) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-rails (6.0.2) + rspec-support (~> 3.13.0) + rspec-rails (6.1.2) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) - rspec-support (3.12.0) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.1) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) rubocop (1.50.2) @@ -758,6 +758,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) @@ -798,8 +799,7 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - web-push (3.0.0) - hkdf (~> 1.0) + web-push (3.0.1) jwt (~> 2.0) openssl (~> 3.0) webmock (3.18.1) @@ -819,7 +819,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.6.14) + zeitwerk (2.6.15) PLATFORMS arm64-darwin-20 @@ -837,7 +837,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 @@ -861,7 +861,7 @@ DEPENDENCIES devise (>= 4.9.4) devise-secure_password! devise_token_auth (>= 1.2.3) - dotenv-rails + dotenv-rails (>= 3.0.0) down elastic-apm email_reply_trimmer @@ -916,13 +916,13 @@ DEPENDENCIES rack-cors (= 2.0.0) rack-mini-profiler (>= 3.2.0) rack-timeout - rails (~> 7.0.8.1) + rails (~> 7.0.8.4) redis redis-namespace responders (>= 3.1.1) rest-client reverse_markdown - rspec-rails + rspec-rails (>= 6.0.3) rspec_junit_formatter rubocop rubocop-performance @@ -953,7 +953,7 @@ DEPENDENCIES uglifier valid_email2 web-console (>= 4.2.1) - web-push + web-push (>= 3.0.1) webmock webpacker wisper (= 2.0.0) diff --git a/VERSION_CW b/VERSION_CW index bea438e9a..a5c4c7633 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -3.3.1 +3.9.0 diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 24ba9a38d..834f26295 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -2.7.0 +2.8.0 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/google/authorizations_controller.rb b/app/controllers/api/v1/accounts/google/authorizations_controller.rb new file mode 100644 index 000000000..1140a214b --- /dev/null +++ b/app/controllers/api/v1/accounts/google/authorizations_controller.rb @@ -0,0 +1,32 @@ +class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController + include GoogleConcern + before_action :check_authorization + + def create + email = params[:authorization][:email] + redirect_url = google_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/google/callback", + scope: 'email profile https://mail.google.com/', + response_type: 'code', + prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it + access_type: 'offline', # the default is 'online' + client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) + } + ) + + if redirect_url + cache_key = "google::#{email.downcase}" + ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + 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/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index bee47b213..df563094a 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -12,8 +12,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts } ) if redirect_url - email = email.downcase - ::Redis::Alfred.setex(email, Current.account.id, 5.minutes) + cache_key = "microsoft::#{email.downcase}" + ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) render json: { success: true, url: redirect_url } else render json: { success: false }, status: :unprocessable_entity 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/google_concern.rb b/app/controllers/concerns/google_concern.rb new file mode 100644 index 000000000..474b14aec --- /dev/null +++ b/app/controllers/concerns/google_concern.rb @@ -0,0 +1,20 @@ +module GoogleConcern + extend ActiveSupport::Concern + + def google_client + app_id = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) + app_secret = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_SECRET', nil) + + ::OAuth2::Client.new(app_id, app_secret, { + site: 'https://oauth2.googleapis.com', + authorize_url: 'https://accounts.google.com/o/oauth2/auth', + token_url: 'https://accounts.google.com/o/oauth2/token' + }) + end + + private + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/concerns/microsoft_concern.rb b/app/controllers/concerns/microsoft_concern.rb index 3aa3e4e81..507b9f8a3 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', @@ -12,10 +15,6 @@ module MicrosoftConcern private - def parsed_body - @parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body) - end - def base_url ENV.fetch('FRONTEND_URL', 'http://localhost:3000') end 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/google/callbacks_controller.rb b/app/controllers/google/callbacks_controller.rb new file mode 100644 index 000000000..391e1de0f --- /dev/null +++ b/app/controllers/google/callbacks_controller.rb @@ -0,0 +1,18 @@ +class Google::CallbacksController < OauthCallbackController + include GoogleConcern + + private + + def provider_name + 'google' + end + + def imap_address + 'imap.gmail.com' + end + + def oauth_client + # from GoogleConcern + google_client + end +end diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb index 215103bd4..2f07505fc 100644 --- a/app/controllers/microsoft/callbacks_controller.rb +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -1,77 +1,17 @@ -class Microsoft::CallbacksController < ApplicationController +class Microsoft::CallbacksController < OauthCallbackController include MicrosoftConcern - def show - @response = microsoft_client.auth_code.get_token( - oauth_code, - redirect_uri: "#{base_url}/microsoft/callback" - ) - - inbox = 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) - rescue StandardError => e - ChatwootExceptionTracker.new(e).capture_exception - redirect_to '/' - end - private - def oauth_code - params[:code] + def oauth_client + microsoft_client end - def users_data - decoded_token = JWT.decode parsed_body[:id_token], nil, false - decoded_token[0] + def provider_name + 'microsoft' end - def parsed_body - @parsed_body ||= @response.response.parsed - end - - def account_id - ::Redis::Alfred.get(users_data['email'].downcase) - end - - def account - @account ||= Account.find(account_id) - end - - def find_or_create_inbox - channel_email = Channel::Email.find_by(email: users_data['email'], account: account) - channel_email ||= create_microsoft_channel_with_inbox - update_microsoft_channel(channel_email) - channel_email.inbox - end - - # Fallback name, for when name field is missing from users_data - def fallback_name - users_data['email'].split('@').first.parameterize.titleize - end - - def create_microsoft_channel_with_inbox - ActiveRecord::Base.transaction do - channel_email = Channel::Email.create!(email: users_data['email'], account: account) - account.inboxes.create!( - account: account, - channel: channel_email, - name: users_data['name'] || fallback_name - ) - channel_email - end - end - - def update_microsoft_channel(channel_email) - channel_email.update!({ - imap_login: users_data['email'], imap_address: 'outlook.office365.com', - imap_port: '993', imap_enabled: true, - provider: 'microsoft', - provider_config: { - access_token: parsed_body['access_token'], - refresh_token: parsed_body['refresh_token'], - expires_on: (Time.current.utc + 1.hour).to_s - } - }) + def imap_address + 'outlook.office365.com' end end 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/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb new file mode 100644 index 000000000..309160f1f --- /dev/null +++ b/app/controllers/oauth_callback_controller.rb @@ -0,0 +1,108 @@ +class OauthCallbackController < ApplicationController + def show + @response = oauth_client.auth_code.get_token( + oauth_code, + redirect_uri: "#{base_url}/#{provider_name}/callback" + ) + + handle_response + ::Redis::Alfred.delete(cache_key) + rescue StandardError => e + ChatwootExceptionTracker.new(e).capture_exception + redirect_to '/' + end + + private + + def handle_response + inbox, already_exists = find_or_create_inbox + + if already_exists + redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) + else + redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) + end + end + + 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_channel_with_inbox + update_channel(channel_email) + + # 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 + + def update_channel(channel_email) + channel_email.update!({ + imap_login: users_data['email'], imap_address: imap_address, + imap_port: '993', imap_enabled: true, + provider: provider_name, + provider_config: { + access_token: parsed_body['access_token'], + refresh_token: parsed_body['refresh_token'], + expires_on: (Time.current.utc + 1.hour).to_s + } + }) + end + + def provider_name + raise NotImplementedError + end + + def oauth_client + raise NotImplementedError + end + + def cache_key + "#{provider_name}::#{users_data['email'].downcase}" + end + + def create_channel_with_inbox + ActiveRecord::Base.transaction do + channel_email = Channel::Email.create!(email: users_data['email'], account: account) + account.inboxes.create!( + account: account, + channel: channel_email, + name: users_data['name'] || fallback_name + ) + channel_email + end + end + + def users_data + decoded_token = JWT.decode parsed_body[:id_token], nil, false + decoded_token[0] + end + + def account_id + ::Redis::Alfred.get(cache_key) + end + + def account + @account ||= Account.find(account_id) + end + + # Fallback name, for when name field is missing from users_data + def fallback_name + users_data['email'].split('@').first.parameterize.titleize + end + + def oauth_code + params[:code] + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end + + def parsed_body + @parsed_body ||= @response.response.parsed + end +end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 6223f7174..b8f3bd9a9 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -35,10 +35,12 @@ 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 - %w[ENABLE_ACCOUNT_SIGNUP] + %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS] end end end 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/App.vue b/app/javascript/dashboard/App.vue index 481c671ce..c343fbe4f 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -27,6 +27,7 @@ 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/Code.vue b/app/javascript/dashboard/components/Code.vue index 269311c2d..d8f609e29 100644 --- a/app/javascript/dashboard/components/Code.vue +++ b/app/javascript/dashboard/components/Code.vue @@ -25,8 +25,10 @@ + - - diff --git a/app/javascript/dashboard/components/SidemenuIcon.vue b/app/javascript/dashboard/components/SidemenuIcon.vue index e2811f65b..6106b9ad0 100644 --- a/app/javascript/dashboard/components/SidemenuIcon.vue +++ b/app/javascript/dashboard/components/SidemenuIcon.vue @@ -21,7 +21,7 @@ export default { }, methods: { onMenuItemClick() { - bus.$emit(BUS_EVENTS.TOGGLE_SIDEMENU); + this.$emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU); }, }, }; diff --git a/app/javascript/dashboard/components/SnackbarContainer.vue b/app/javascript/dashboard/components/SnackbarContainer.vue index 553ad2a2e..4785e4d4b 100644 --- a/app/javascript/dashboard/components/SnackbarContainer.vue +++ b/app/javascript/dashboard/components/SnackbarContainer.vue @@ -15,11 +15,13 @@