diff --git a/.env.example b/.env.example index 1b57141f0..befcde463 100644 --- a/.env.example +++ b/.env.example @@ -85,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 diff --git a/Gemfile b/Gemfile index 302094e8f..5e1e5917f 100644 --- a/Gemfile +++ b/Gemfile @@ -111,9 +111,9 @@ gem 'elastic-apm', require: false gem 'newrelic_rpm', require: false gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false gem 'scout_apm', require: false -gem 'sentry-rails', '>= 5.14.0', require: false +gem 'sentry-rails', '>= 5.18.0', require: false gem 'sentry-ruby', require: false -gem 'sentry-sidekiq', '>= 5.15.0', require: false +gem 'sentry-sidekiq', '>= 5.18.0', require: false ##-- background job processing --## gem 'sidekiq', '>= 7.2.4' @@ -228,7 +228,7 @@ group :development, :test do gem 'mock_redis' gem 'pry-rails' gem 'rspec_junit_formatter' - gem 'rspec-rails', '>= 6.0.3' + gem 'rspec-rails', '>= 6.1.3' gem 'rubocop', require: false gem 'rubocop-performance', require: false gem 'rubocop-rails', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 323847645..04a8fb534 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -150,7 +150,7 @@ GEM statsd-ruby (~> 1.1) base64 (0.2.0) bcrypt (3.1.20) - bigdecimal (3.1.7) + bigdecimal (3.1.8) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) @@ -223,7 +223,7 @@ GEM http (>= 3.0) ruby2_keywords email_reply_trimmer (0.1.13) - erubi (1.12.0) + erubi (1.13.0) et-orbi (1.2.7) tzinfo execjs (2.8.1) @@ -460,7 +460,7 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.7) - minitest (5.23.1) + minitest (5.24.0) mock_redis (0.36.0) ruby2_keywords msgpack (1.7.0) @@ -489,14 +489,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.16.5) + nokogiri (1.16.6) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.16.5-arm64-darwin) + nokogiri (1.16.6-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.5-x86_64-darwin) + nokogiri (1.16.6-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.5-x86_64-linux) + nokogiri (1.16.6-x86_64-linux) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -603,7 +603,7 @@ GEM ffi (~> 1.0) redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.22.1) + redis-client (0.22.2) connection_pool redis-namespace (1.10.0) redis (>= 4) @@ -631,13 +631,13 @@ GEM strscan (>= 3.0.9) rspec-core (3.13.0) rspec-support (~> 3.13.0) - rspec-expectations (3.13.0) + rspec-expectations (3.13.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-mocks (3.13.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.2) + rspec-rails (6.1.3) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) @@ -703,14 +703,14 @@ GEM activesupport (>= 4) selectize-rails (0.12.6) semantic_range (3.0.0) - sentry-rails (5.17.3) + sentry-rails (5.18.0) railties (>= 5.0) - sentry-ruby (~> 5.17.3) - sentry-ruby (5.17.3) + sentry-ruby (~> 5.18.0) + sentry-ruby (5.18.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.17.3) - sentry-ruby (~> 5.17.3) + sentry-sidekiq (5.18.0) + sentry-ruby (~> 5.18.0) sidekiq (>= 3.0) sexp_processor (4.17.0) shoulda-matchers (5.3.0) @@ -819,7 +819,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.6.15) + zeitwerk (2.6.16) PLATFORMS arm64-darwin-20 @@ -922,7 +922,7 @@ DEPENDENCIES responders (>= 3.1.1) rest-client reverse_markdown - rspec-rails (>= 6.0.3) + rspec-rails (>= 6.1.3) rspec_junit_formatter rubocop rubocop-performance @@ -931,9 +931,9 @@ DEPENDENCIES scout_apm scss_lint seed_dump - sentry-rails (>= 5.14.0) + sentry-rails (>= 5.18.0) sentry-ruby - sentry-sidekiq (>= 5.15.0) + sentry-sidekiq (>= 5.18.0) shoulda-matchers sidekiq (>= 7.2.4) sidekiq-cron (>= 1.12.0) diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index e656c2550..332f1528f 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -37,7 +37,7 @@ class DashboardController < ActionController::Base end def set_dashboard_scripts - @dashboard_scripts = GlobalConfig.get_value('DASHBOARD_SCRIPTS') + @dashboard_scripts = sensitive_path? ? nil : GlobalConfig.get_value('DASHBOARD_SCRIPTS') end def ensure_installation_onboarding @@ -75,4 +75,14 @@ class DashboardController < ActionController::Base 'application' end end + + def sensitive_path? + # dont load dashboard scripts on sensitive paths like password reset + sensitive_paths = [edit_user_password_path].freeze + + # remove app prefix + current_path = request.path.gsub(%r{^/app}, '') + + sensitive_paths.include?(current_path) + end end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 9d9494fc4..b8f3bd9a9 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -40,7 +40,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 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/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue index fc80b871c..63ab5643f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/Message.vue +++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue @@ -257,7 +257,16 @@ export default { html_content: { full: fullHTMLContent } = {}, text_content: { full: fullTextContent } = {}, } = this.contentAttributes.email || {}; - return fullHTMLContent || fullTextContent || ''; + + if (fullHTMLContent) { + return fullHTMLContent; + } + + if (fullTextContent) { + return fullTextContent.replace(/\n/g, '
'); + } + + return ''; }, displayQuotedButton() { if (this.emailMessageContent.includes(' { }; // Add the "None" option to the agent list -export const agentList = agents => [ +export const addNoneToList = agents => [ { id: 'nil', name: 'None', @@ -137,8 +137,8 @@ export const getActionOptions = ({ type, }) => { const actionsMap = { - assign_agent: agentList(agents), - assign_team: teams, + assign_agent: addNoneToList(agents), + assign_team: addNoneToList(teams), send_email_to_team: teams, add_label: generateConditionOptions(labels, 'title'), remove_label: generateConditionOptions(labels, 'title'), diff --git a/app/javascript/v3/views/index.js b/app/javascript/v3/views/index.js index 57da18116..dd30f0372 100644 --- a/app/javascript/v3/views/index.js +++ b/app/javascript/v3/views/index.js @@ -6,12 +6,16 @@ import { validateRouteAccess } from '../helpers/RouteHelper'; export const router = new VueRouter({ mode: 'history', routes }); +const sensitiveRouteNames = ['auth_password_edit']; + export const initalizeRouter = () => { router.beforeEach((to, _, next) => { - AnalyticsHelper.page(to.name || '', { - path: to.path, - name: to.name, - }); + if (!sensitiveRouteNames.includes(to.name)) { + AnalyticsHelper.page(to.name || '', { + path: to.path, + name: to.name, + }); + } return validateRouteAccess(to, next, window.chatwootConfig); }); diff --git a/app/javascript/widget/components/AgentMessage.vue b/app/javascript/widget/components/AgentMessage.vue index eb64e0c36..c38726031 100755 --- a/app/javascript/widget/components/AgentMessage.vue +++ b/app/javascript/widget/components/AgentMessage.vue @@ -30,7 +30,7 @@ />
+ + + @@ -84,6 +92,7 @@ import AgentMessageBubble from 'widget/components/AgentMessageBubble.vue'; import MessageReplyButton from 'widget/components/MessageReplyButton.vue'; import timeMixin from 'dashboard/mixins/time'; import ImageBubble from 'widget/components/ImageBubble.vue'; +import VideoBubble from 'widget/components/VideoBubble.vue'; import FileBubble from 'widget/components/FileBubble.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import { MESSAGE_TYPE } from 'widget/helpers/constants'; @@ -100,6 +109,7 @@ export default { components: { AgentMessageBubble, ImageBubble, + VideoBubble, Thumbnail, UserMessage, FileBubble, @@ -120,6 +130,7 @@ export default { data() { return { hasImageError: false, + hasVideoError: false, }; }, computed: { @@ -215,15 +226,20 @@ export default { watch: { message() { this.hasImageError = false; + this.hasVideoError = false; }, }, mounted() { this.hasImageError = false; + this.hasVideoError = false; }, methods: { onImageLoadError() { this.hasImageError = true; }, + onVideoLoadError() { + this.hasVideoError = true; + }, toggleReply() { emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message); }, diff --git a/app/javascript/widget/components/UserMessage.vue b/app/javascript/widget/components/UserMessage.vue index 0631b2f90..5a97422db 100755 --- a/app/javascript/widget/components/UserMessage.vue +++ b/app/javascript/widget/components/UserMessage.vue @@ -39,6 +39,14 @@ :readable-time="readableTime" @error="onImageLoadError" /> + + + +defineProps({ + url: { type: String, default: '' }, + readableTime: { type: String, default: '' }, +}); + +const emits = defineEmits(['error']); + +const onVideoError = () => { + emits('error'); +}; + + diff --git a/app/models/account_user.rb b/app/models/account_user.rb index 78b874334..176d52102 100644 --- a/app/models/account_user.rb +++ b/app/models/account_user.rb @@ -49,6 +49,10 @@ class AccountUser < ApplicationRecord ::Agents::DestroyJob.perform_later(account, user) end + def permissions + administrator? ? ['administrator'] : ['agent'] + end + def push_event_data { id: id, diff --git a/app/models/concerns/json_schema_validator.rb b/app/models/concerns/json_schema_validator.rb index 4ae94df12..808564be9 100644 --- a/app/models/concerns/json_schema_validator.rb +++ b/app/models/concerns/json_schema_validator.rb @@ -48,7 +48,6 @@ class JsonSchemaValidator < ActiveModel::Validator # Add validation errors to the record with a formatted statement validation_errors.each do |error| - # byebug format_and_append_error(error, record) end end diff --git a/app/services/action_service.rb b/app/services/action_service.rb index 79f1234a0..4f33a302b 100644 --- a/app/services/action_service.rb +++ b/app/services/action_service.rb @@ -50,7 +50,11 @@ class ActionService end def assign_team(team_ids = []) - return unassign_team if team_ids[0]&.zero? + # FIXME: The explicit checks for zero or nil (string) is bad. Move + # this to a separate unassign action. + should_unassign = team_ids.blank? || %w[nil 0].include?(team_ids[0].to_s) + return @conversation.update!(team_id: nil) if should_unassign + # check if team belongs to account only if team_id is present # if team_id is nil, then it means that the team is being unassigned return unless !team_ids[0].nil? && team_belongs_to_account?(team_ids) diff --git a/app/services/notification/fcm_service.rb b/app/services/notification/fcm_service.rb new file mode 100644 index 000000000..fc0ed6b05 --- /dev/null +++ b/app/services/notification/fcm_service.rb @@ -0,0 +1,40 @@ +class Notification::FcmService + SCOPES = ['https://www.googleapis.com/auth/firebase.messaging'].freeze + + def initialize(project_id, credentials) + @project_id = project_id + @credentials = credentials + @token_info = nil + end + + def fcm_client + FCM.new(current_token, credentials_path, @project_id) + end + + private + + def current_token + @token_info = generate_token if @token_info.nil? || token_expired? + @token_info[:token] + end + + def token_expired? + Time.zone.now >= @token_info[:expires_at] + end + + def generate_token + authorizer = Google::Auth::ServiceAccountCredentials.make_creds( + json_key_io: credentials_path, + scope: SCOPES + ) + token = authorizer.fetch_access_token! + { + token: token['access_token'], + expires_at: Time.zone.now + token['expires_in'].to_i + } + end + + def credentials_path + StringIO.new(@credentials) + end +end diff --git a/app/services/notification/push_notification_service.rb b/app/services/notification/push_notification_service.rb index b7fc231fa..9878107c1 100644 --- a/app/services/notification/push_notification_service.rb +++ b/app/services/notification/push_notification_service.rb @@ -42,14 +42,12 @@ class Notification::PushNotificationService app_account_conversation_url(account_id: conversation.account_id, id: conversation.display_id) end - def send_browser_push?(subscription) + def can_send_browser_push?(subscription) VapidService.public_key && subscription.browser_push? end - def send_browser_push(subscription) - return unless send_browser_push?(subscription) - - WebPush.payload_send( + def browser_push_payload(subscription) + { message: JSON.generate(push_message), endpoint: subscription.subscription_attributes['endpoint'], p256dh: subscription.subscription_attributes['p256dh'], @@ -62,44 +60,104 @@ class Notification::PushNotificationService ssl_timeout: 5, open_timeout: 5, read_timeout: 5 - ) - rescue WebPush::ExpiredSubscription + } + end + + def send_browser_push(subscription) + return unless can_send_browser_push?(subscription) + + WebPush.payload_send(**browser_push_payload(subscription)) + Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}") + rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e + Rails.logger.info "WebPush subscription expired: #{e.message}" subscription.destroy! rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e Rails.logger.error "WebPush operation error: #{e.message}" + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: notification.account).capture_exception + true end def send_fcm_push(subscription) - return unless ENV['FCM_SERVER_KEY'] + return unless firebase_credentials_present? return unless subscription.fcm? - fcm = FCM.new(ENV.fetch('FCM_SERVER_KEY', nil)) - response = fcm.send([subscription.subscription_attributes['push_token']], fcm_options) + fcm_service = Notification::FcmService.new( + GlobalConfigService.load('FIREBASE_PROJECT_ID', nil), GlobalConfigService.load('FIREBASE_CREDENTIALS', nil) + ) + fcm = fcm_service.fcm_client + response = fcm.send_v1(fcm_options(subscription)) remove_subscription_if_error(subscription, response) end def send_push_via_chatwoot_hub(subscription) - return if ENV['FCM_SERVER_KEY'] - return unless ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true)) + return if firebase_credentials_present? + return unless chatwoot_hub_enabled? return unless subscription.fcm? - ChatwootHub.send_browser_push([subscription.subscription_attributes['push_token']], fcm_options) + ChatwootHub.send_push(fcm_options(subscription)) + end + + def firebase_credentials_present? + GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil) + end + + def chatwoot_hub_enabled? + ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true)) end def remove_subscription_if_error(subscription, response) - subscription.destroy! if JSON.parse(response[:body])['results']&.first&.keys&.include?('error') + if JSON.parse(response[:body])['results']&.first&.keys&.include?('error') + subscription.destroy! + else + Rails.logger.info("FCM push sent to #{user.email} with title #{push_message[:title]}") + end end - def fcm_options + def fcm_options(subscription) { - notification: { - title: notification.push_message_title, - body: notification.push_message_body, - sound: 'default' - }, - android: { priority: 'high' }, - data: { notification: notification.fcm_push_data.to_json }, - collapse_key: "chatwoot_#{notification.primary_actor_type.downcase}_#{notification.primary_actor_id}" + 'token': subscription.subscription_attributes['push_token'], + 'data': fcm_data, + 'notification': fcm_notification, + 'android': fcm_android_options, + 'apns': fcm_apns_options, + 'fcm_options': { + analytics_label: 'Label' + } + } + end + + def fcm_data + { + payload: { + data: { + notification: notification.fcm_push_data + } + }.to_json + } + end + + def fcm_notification + { + title: notification.push_message_title, + body: notification.push_message_body + } + end + + def fcm_android_options + { + priority: 'high' + } + end + + def fcm_apns_options + { + payload: { + aps: { + sound: 'default', + category: Time.zone.now.to_i.to_s + } + } } end end diff --git a/app/views/api/v1/models/_user.json.jbuilder b/app/views/api/v1/models/_user.json.jbuilder index 74df836c0..0e8c95adb 100644 --- a/app/views/api/v1/models/_user.json.jbuilder +++ b/app/views/api/v1/models/_user.json.jbuilder @@ -14,6 +14,7 @@ json.provider resource.provider json.pubsub_token resource.pubsub_token json.custom_attributes resource.custom_attributes if resource.custom_attributes.present? json.role resource.active_account_user&.role +json.permissions resource.active_account_user&.permissions json.ui_settings resource.ui_settings json.uid resource.uid json.type resource.type @@ -24,6 +25,7 @@ json.accounts do json.status account_user.account.status json.active_at account_user.active_at json.role account_user.role + json.permissions account_user.permissions # the actual availability user has configured json.availability account_user.availability # availability derived from presence diff --git a/config/app.yml b/config/app.yml index 592467ee1..b8c238254 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '3.9.0' + version: '3.10.2' development: <<: *shared diff --git a/config/initializers/mailer.rb b/config/initializers/mailer.rb index 8e5053872..a0e5d7b73 100644 --- a/config/initializers/mailer.rb +++ b/config/initializers/mailer.rb @@ -24,6 +24,8 @@ Rails.application.configure do smtp_settings[:openssl_verify_mode] = ENV['SMTP_OPENSSL_VERIFY_MODE'] if ENV['SMTP_OPENSSL_VERIFY_MODE'].present? smtp_settings[:ssl] = ActiveModel::Type::Boolean.new.cast(ENV.fetch('SMTP_SSL', true)) if ENV['SMTP_SSL'] smtp_settings[:tls] = ActiveModel::Type::Boolean.new.cast(ENV.fetch('SMTP_TLS', true)) if ENV['SMTP_TLS'] + smtp_settings[:open_timeout] = ENV['SMTP_OPEN_TIMEOUT'].to_i if ENV['SMTP_OPEN_TIMEOUT'].present? + smtp_settings[:read_timeout] = ENV['SMTP_READ_TIMEOUT'].to_i if ENV['SMTP_READ_TIMEOUT'].present? config.action_mailer.delivery_method = :smtp unless Rails.env.test? config.action_mailer.smtp_settings = smtp_settings diff --git a/config/installation_config.yml b/config/installation_config.yml index 419a63352..5784c3b6e 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -204,3 +204,16 @@ locked: false description: 'Disable rendering profile update page for users' ## ------ End of Configs added for enterprise clients ------ ## + +## ------ Configs added for FCM v1 notifications ------ ## +- name: FIREBASE_PROJECT_ID + display_title: 'Firebase Project ID' + value: + locked: false + description: 'Firebase project ID' +- name: FIREBASE_CREDENTIALS + display_title: 'Firebase Credentials' + value: + locked: false + description: 'Contents on your firebase credentials json file' +## ------ End of Configs added for FCM v1 notifications ------ ## diff --git a/config/locales/vi.yml b/config/locales/vi.yml index 189184d4d..8fb3e4507 100644 --- a/config/locales/vi.yml +++ b/config/locales/vi.yml @@ -31,7 +31,7 @@ vi: disposable_email: Chúng tôi không cho phép các email dùng một lần invalid_email: Bạn đã nhập một email không hợp lệ email_already_exists: "Bạn đã đăng ký một tài khoản với %{email}" - invalid_params: 'Invalid, please check the signup paramters and try again' + invalid_params: 'Không hợp lệ, vui lòng kiểm tra thông số đăng ký và thử lại' failed: Đăng ký thât bại data_import: data_type: @@ -51,7 +51,7 @@ vi: dyte: invalid_message_type: "Loại tin nhắn không hợp lệ. Hành động không được phép" slack: - invalid_channel_id: "Invalid slack channel. Please try again" + invalid_channel_id: "Kênh chùng không hợp lệ. Vui lòng thử lại" inboxes: imap: socket_error: Vui lòng kiểm tra kết nối mạng, địa chỉ IMAP và thử lại. @@ -63,43 +63,43 @@ vi: name: không nên bắt đầu hoặc kết thúc bằng các ký hiệu và không nên có kí tự < > / \ @. custom_filters: number_of_records: Đã đạt giới hạn. Số lượng tuỳ chọn lọc tối đa cho mỗi mỗi người dùng mỗi tài khoản là 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 + invalid_attribute: Khóa thuộc tính không hợp lệ - [%{key}]. Chìa khóa phải là một trong [%{allowed_keys}] hoặc thuộc tính tùy chỉnh được xác định trong tài khoản. + invalid_operator: Toán tử không hợp lệ. Các toán tử được phép cho %{attribute_name} là [%{allowed_keys}]. + invalid_value: Giá trị không hợp lệ. Các giá trị được cung cấp cho %{attribute_name} không hợp lệ reports: period: Thời gian báo cáo từ %{since} đến %{until} utc_warning: Báo cáo đã được tạo với múi giờ UTC agent_csv: agent_name: Tên tổng đài viên - conversations_count: Assigned conversations - avg_first_response_time: Avg first response time + conversations_count: Cuộc trò chuyện được chỉ định + avg_first_response_time: Thời gian phản hồi đầu tiên trung bình avg_resolution_time: Avg resolution time resolution_count: Số lượng giải quyết - avg_customer_waiting_time: Avg customer waiting time + avg_customer_waiting_time: Thời gian chờ đợi trung bình của khách hàng inbox_csv: inbox_name: Tên kênh inbox_type: Kiểu kênh conversations_count: Số hội thoại - avg_first_response_time: Avg first response time - avg_resolution_time: Avg resolution time + avg_first_response_time: Thời gian phản hồi đầu tiên trung bình + avg_resolution_time: Thời gian giải quyết trung bình label_csv: label_title: Nhãn conversations_count: Số hội thoại - avg_first_response_time: Avg first response time - avg_resolution_time: Avg resolution time + avg_first_response_time: Thời gian phản hồi đầu tiên trung bình + avg_resolution_time: Thời gian giải quyết trung bình team_csv: team_name: Tên nhóm conversations_count: Số hội thoại - avg_first_response_time: Avg first response time - avg_resolution_time: Avg resolution time + avg_first_response_time: Thời gian phản hồi đầu tiên trung bình + avg_resolution_time: Thời gian giải quyết trung bình resolution_count: Số lượng giải quyết - avg_customer_waiting_time: Avg customer waiting time + avg_customer_waiting_time: Thời gian chờ đợi trung bình của khách hàng conversation_traffic_csv: timezone: Múi giờ sla_csv: - conversation_id: Conversation ID + conversation_id: ID hội thoại sla_policy_breached: SLA Policy - assignee: Assignee + assignee: Đại lý được chỉ định team: Nhóm inbox: Hộp thư đến labels: Nhãn @@ -118,22 +118,22 @@ vi: recorded_at: Ngày nghi notifications: notification_title: - conversation_creation: "A conversation (#%{display_id}) has been created in %{inbox_name}" - conversation_assignment: "A conversation (#%{display_id}) has been assigned to you" - assigned_conversation_new_message: "A new message is created in conversation (#%{display_id})" - conversation_mention: "You have been mentioned in conversation (#%{display_id})" - sla_missed_first_response: "SLA target first response missed for conversation (#%{display_id})" - sla_missed_next_response: "SLA target next response missed for conversation (#%{display_id})" - sla_missed_resolution: "SLA target resolution missed for conversation (#%{display_id})" - attachment: "Attachment" - no_content: "No content" + conversation_creation: "Một cuộc trò chuyện (#%{display_id}) đã được tạo trong %{inbox_name}" + conversation_assignment: "Một cuộc trò chuyện (#%{display_id}) đã được chỉ định cho bạn" + assigned_conversation_new_message: "Một tin nhắn mới được tạo trong cuộc trò chuyện (#%{display_id})" + conversation_mention: "Bạn đã được nhắc đến trong cuộc trò chuyện (#%{display_id})" + sla_missed_first_response: "Mục tiêu SLA phản hồi đầu tiên bị bỏ lỡ cho cuộc trò chuyện (#%{display_id})" + sla_missed_next_response: "Mục tiêu SLA phản hồi tiếp theo bị bỏ lỡ cho cuộc trò chuyện (#%{display_id})" + sla_missed_resolution: "Độ phân giải mục tiêu SLA bị bỏ lỡ cho cuộc trò chuyện (#%{display_id})" + attachment: "Tập tin đính kèm" + no_content: "Không có nội dung" conversations: messages: instagram_story_content: "%{story_sender} đã đề cập đến bạn trong hội thoại: " instagram_deleted_story_content: Hội thoại này không còn nữa. deleted: Tin nhắn đã bị xoá delivery_status: - error_code: "Error code: %{error_code}" + error_code: "Mã lỗi: %{error_code}" activity: status: resolved: "Cuộc trò chuyện được đánh dấu là đã giải quyết bởi %{user_name}" @@ -221,7 +221,7 @@ vi: common: home: Trang Chủ last_updated_on: 'Cập nhật lần cuối: %{last_updated_on}' - view_all_articles: View all + view_all_articles: Xem tất cả article: bài viết articles: bài viết author: tác giả @@ -233,17 +233,17 @@ vi: footer: made_with: Tạo bởi header: - go_to_homepage: Website + go_to_homepage: Trang web appearance: - system: System - light: Light - dark: Dark - featured_articles: Featured Articles + system: Hệ thống + light: Sáng + dark: Tối + featured_articles: Bài viết nổi bật uncategorized: Chưa được phân loại 404: - title: Page not found - description: We couldn't find the page you were looking for. - back_to_home: Go to home page + title: Không tìm thấy trang + description: Chúng tôi không thể tìm thấy trang bạn đang tìm kiếm. + back_to_home: Tới trang chủ slack_unfurl: fields: name: Tên @@ -255,10 +255,10 @@ vi: button: Mở cuộc trò chuyện time_units: days: - other: "%{count} days" + other: "%{count} ngày" hours: - other: "%{count} hours" + other: "%{count} giờ" minutes: - other: "%{count} minutes" + other: "%{count} phút" seconds: - other: "%{count} seconds" + other: "%{count} giây" diff --git a/lib/chatwoot_hub.rb b/lib/chatwoot_hub.rb index 6d28b10fa..22addcacb 100644 --- a/lib/chatwoot_hub.rb +++ b/lib/chatwoot_hub.rb @@ -77,8 +77,8 @@ class ChatwootHub ChatwootExceptionTracker.new(e).capture_exception end - def self.send_browser_push(fcm_token_list, fcm_options) - info = { fcm_token_list: fcm_token_list, fcm_options: fcm_options } + def self.send_push(fcm_options) + info = { fcm_options: fcm_options } RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e Rails.logger.error "Exception: #{e.message}" diff --git a/package.json b/package.json index 8f1140507..79ac41e02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "3.9.0", + "version": "3.10.2", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", diff --git a/spec/controllers/devise/session_controller_spec.rb b/spec/controllers/devise/session_controller_spec.rb index 42f8b8bd8..ad6d76bf9 100644 --- a/spec/controllers/devise/session_controller_spec.rb +++ b/spec/controllers/devise/session_controller_spec.rb @@ -41,6 +41,17 @@ RSpec.describe 'Session', type: :request do expect(response).to have_http_status(:success) expect(response.body).to include(user_with_new_pwd.email) end + + it 'returns the permission of the user' do + params = { email: user.email, password: 'Password1!' } + + post new_user_session_url, + params: params, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['data']['permissions']).to eq(['agent']) + end end context 'when it is invalid sso auth token' do diff --git a/spec/factories/conversations.rb b/spec/factories/conversations.rb index 6f2bc34ef..c552e6c30 100644 --- a/spec/factories/conversations.rb +++ b/spec/factories/conversations.rb @@ -16,5 +16,17 @@ FactoryBot.define do conversation.contact ||= create(:contact, :with_email, account: conversation.account) conversation.contact_inbox ||= create(:contact_inbox, contact: conversation.contact, inbox: conversation.inbox) end + + trait :with_team do + after(:build) do |conversation| + conversation.team ||= create(:team, account: conversation.account) + end + end + + trait :with_assignee do + after(:build) do |conversation| + conversation.assignee ||= create(:user, account: conversation.account, role: :agent) + end + end end end diff --git a/spec/factories/notification_subscriptions.rb b/spec/factories/notification_subscriptions.rb index 581c198a6..c886a12c0 100644 --- a/spec/factories/notification_subscriptions.rb +++ b/spec/factories/notification_subscriptions.rb @@ -6,5 +6,13 @@ FactoryBot.define do identifier { 'test' } subscription_type { 'browser_push' } subscription_attributes { { endpoint: 'test', auth: 'test' } } + + trait :browser_push do + subscription_type { 'browser_push' } + end + + trait :fcm do + subscription_type { 'fcm' } + end end end diff --git a/spec/models/account_user_spec.rb b/spec/models/account_user_spec.rb index 2b8ef790a..e5a560fe9 100644 --- a/spec/models/account_user_spec.rb +++ b/spec/models/account_user_spec.rb @@ -17,6 +17,17 @@ RSpec.describe AccountUser do end end + describe 'permissions' do + it 'returns the right permissions' do + expect(account_user.permissions).to eq(['agent']) + end + + it 'returns the right permissions for administrator' do + account_user.administrator! + expect(account_user.permissions).to eq(['administrator']) + end + end + describe 'destroy call agent::destroy service' do it 'gets created with the right default settings' do create(:conversation, account: account_user.account, assignee: account_user.user, inbox: inbox) diff --git a/spec/services/action_service_spec.rb b/spec/services/action_service_spec.rb index bdc9c88ff..c28761844 100644 --- a/spec/services/action_service_spec.rb +++ b/spec/services/action_service_spec.rb @@ -31,18 +31,54 @@ describe ActionService do describe '#assign_agent' do let(:agent) { create(:user, account: account, role: :agent) } - let(:conversation) { create(:conversation, account: account) } let(:inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: agent) } + let(:conversation) { create(:conversation, :with_assignee, account: account) } let(:action_service) { described_class.new(conversation) } it 'unassigns the conversation if agent id is nil' do action_service.assign_agent(['nil']) expect(conversation.reload.assignee).to be_nil end + end - it 'unassigns the team if team_id is nil' do - action_service.assign_team([nil]) - expect(conversation.reload.team).to be_nil + describe '#assign_team' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: agent) } + let(:team) { create(:team, name: 'ConversationTeam', account: account) } + let(:conversation) { create(:conversation, :with_team, account: account) } + let(:action_service) { described_class.new(conversation) } + + context 'when team_id is not present' do + it 'unassign the if team_id is "nil"' do + expect do + action_service.assign_team(['nil']) + end.not_to raise_error + expect(conversation.reload.team).to be_nil + end + + it 'unassign the if team_id is 0' do + expect do + action_service.assign_team([0]) + end.not_to raise_error + expect(conversation.reload.team).to be_nil + end + end + + context 'when team_id is present' do + it 'assign the team if the team is part of the account' do + original_team = conversation.team + expect do + action_service.assign_team([team.id]) + end.to change { conversation.reload.team }.from(original_team) + end + + it 'does not assign the team if the team is part of the account' do + original_team = conversation.team + invalid_team_id = 999_999_999 + expect do + action_service.assign_team([invalid_team_id]) + end.not_to change { conversation.reload.team }.from(original_team) + end end end end diff --git a/spec/services/notification/fcm_service_spec.rb b/spec/services/notification/fcm_service_spec.rb new file mode 100644 index 000000000..4da9c2bb3 --- /dev/null +++ b/spec/services/notification/fcm_service_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' + +describe Notification::FcmService do + let(:project_id) { 'test_project_id' } + let(:credentials) { '{ "type": "service_account", "project_id": "test_project_id" }' } + let(:fcm_service) { described_class.new(project_id, credentials) } + let(:fcm_double) { instance_double(FCM) } + let(:token_info) { { token: 'test_token', expires_at: 1.hour.from_now } } + let(:creds_double) do + instance_double(Google::Auth::ServiceAccountCredentials, fetch_access_token!: { 'access_token' => 'test_token', 'expires_in' => 3600 }) + end + + before do + allow(FCM).to receive(:new).and_return(fcm_double) + allow(fcm_service).to receive(:generate_token).and_return(token_info) + allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(creds_double) + end + + describe '#fcm_client' do + it 'returns an FCM client' do + expect(fcm_service.fcm_client).to eq(fcm_double) + expect(FCM).to have_received(:new).with('test_token', anything, project_id) + end + + it 'generates a new token if expired' do + allow(fcm_service).to receive(:generate_token).and_return(token_info) + allow(fcm_service).to receive(:token_expired?).and_return(true) + + expect(fcm_service.fcm_client).to eq(fcm_double) + expect(FCM).to have_received(:new).with('test_token', anything, project_id) + expect(fcm_service).to have_received(:generate_token) + end + end + + describe 'private methods' do + describe '#current_token' do + it 'returns the current token if not expired' do + fcm_service.instance_variable_set(:@token_info, token_info) + expect(fcm_service.send(:current_token)).to eq('test_token') + end + + it 'generates a new token if expired' do + expired_token_info = { token: 'expired_token', expires_at: 1.hour.ago } + fcm_service.instance_variable_set(:@token_info, expired_token_info) + allow(fcm_service).to receive(:generate_token).and_return(token_info) + + expect(fcm_service.send(:current_token)).to eq('test_token') + expect(fcm_service).to have_received(:generate_token) + end + end + + describe '#generate_token' do + it 'generates a new token' do + allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(creds_double) + + token = fcm_service.send(:generate_token) + expect(token[:token]).to eq('test_token') + expect(token[:expires_at]).to be_within(1.second).of(Time.zone.now + 3600) + end + end + + describe '#credentials_path' do + it 'creates a StringIO with credentials' do + string_io = fcm_service.send(:credentials_path) + expect(string_io).to be_a(StringIO) + expect(string_io.read).to eq(credentials) + end + end + end +end diff --git a/spec/services/notification/push_notification_service_spec.rb b/spec/services/notification/push_notification_service_spec.rb index 17fc5f6f3..bba912715 100644 --- a/spec/services/notification/push_notification_service_spec.rb +++ b/spec/services/notification/push_notification_service_spec.rb @@ -4,32 +4,61 @@ describe Notification::PushNotificationService do let!(:account) { create(:account) } let!(:user) { create(:user, account: account) } let!(:notification) { create(:notification, user: user, account: user.accounts.first) } - let(:fcm_double) { double } - - before do - allow(WebPush).to receive(:payload_send).and_return(true) - allow(FCM).to receive(:new).and_return(fcm_double) - allow(fcm_double).to receive(:send).and_return({ body: { 'results': [] }.to_json }) - end + let(:fcm_double) { instance_double(FCM) } + let(:fcm_service_double) { instance_double(Notification::FcmService, fcm_client: fcm_double) } describe '#perform' do - it 'sends webpush notifications for webpush subscription' do - with_modified_env VAPID_PUBLIC_KEY: 'test' do - create(:notification_subscription, user: notification.user) + context 'when the push server returns success' do + before do + allow(WebPush).to receive(:payload_send).and_return(true) + allow(Rails.logger).to receive(:info) + allow(Notification::FcmService).to receive(:new).and_return(fcm_service_double) + allow(fcm_double).to receive(:send_v1).and_return({ body: { 'results': [] }.to_json }) + allow(GlobalConfigService).to receive(:load).with('FIREBASE_PROJECT_ID', nil).and_return('test_project_id') + allow(GlobalConfigService).to receive(:load).with('FIREBASE_CREDENTIALS', nil).and_return('test_credentials') + end - described_class.new(notification: notification).perform - expect(WebPush).to have_received(:payload_send) - expect(FCM).not_to have_received(:new) + it 'sends webpush notifications for webpush subscription' do + with_modified_env VAPID_PUBLIC_KEY: 'test' do + create(:notification_subscription, user: notification.user) + + described_class.new(notification: notification).perform + expect(WebPush).to have_received(:payload_send) + expect(Notification::FcmService).not_to have_received(:new) + expect(Rails.logger).to have_received(:info).with("Browser push sent to #{user.email} with title #{notification.push_message_title}") + end + end + + it 'sends a fcm notification for firebase subscription' do + with_modified_env ENABLE_PUSH_RELAY_SERVER: 'false' do + create(:notification_subscription, user: notification.user, subscription_type: 'fcm') + + described_class.new(notification: notification).perform + expect(Notification::FcmService).to have_received(:new) + expect(fcm_double).to have_received(:send_v1) + expect(WebPush).not_to have_received(:payload_send) + expect(Rails.logger).to have_received(:info).with("FCM push sent to #{user.email} with title #{notification.push_message_title}") + end end end + end - it 'sends a fcm notification for firebase subscription' do - with_modified_env FCM_SERVER_KEY: 'test', ENABLE_PUSH_RELAY_SERVER: 'false' do - create(:notification_subscription, user: notification.user, subscription_type: 'fcm') + context 'when the push server returns error' do + it 'sends webpush notifications for webpush subscription' do + with_modified_env VAPID_PUBLIC_KEY: 'test' do + mock_response = instance_double(Net::HTTPResponse, body: 'Subscription is invalid') + mock_host = 'fcm.googleapis.com' + + allow(WebPush).to receive(:payload_send).and_raise(WebPush::InvalidSubscription.new(mock_response, mock_host)) + allow(Rails.logger).to receive(:info) + + create(:notification_subscription, :browser_push, user: notification.user) + + expect(Rails.logger).to receive(:info) do |message| + expect(message).to include('WebPush subscription expired:') + end described_class.new(notification: notification).perform - expect(FCM).to have_received(:new) - expect(WebPush).not_to have_received(:payload_send) end end end diff --git a/yarn.lock b/yarn.lock index 437e6c544..a03c27a0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21315,21 +21315,16 @@ write-file-atomic@^4.0.2: signal-exit "^3.0.7" ws@^6.2.1: - version "6.2.2" - resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.2.tgz#dd5cdbd57a9979916097652d78f1cc5faea0c32e" - integrity sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw== + version "6.2.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.3.tgz#ccc96e4add5fd6fedbc491903075c85c5a11d9ee" + integrity sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA== dependencies: async-limiter "~1.0.0" -ws@^8.11.0: - version "8.14.2" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" - integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== - -ws@^8.2.3: - version "8.13.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" - integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== +ws@^8.11.0, ws@^8.2.3: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== x-default-browser@^0.4.0: version "0.4.0"