+
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"