Merge branch 'release/3.10.2'

This commit is contained in:
Sojan
2024-06-26 17:08:38 -07:00
13 changed files with 157 additions and 63 deletions

View File

@@ -111,9 +111,9 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', 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-ruby', require: false
gem 'sentry-sidekiq', '>= 5.15.0', require: false gem 'sentry-sidekiq', '>= 5.18.0', require: false
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '>= 7.2.4' gem 'sidekiq', '>= 7.2.4'

View File

@@ -150,7 +150,7 @@ GEM
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
base64 (0.2.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
bigdecimal (3.1.7) bigdecimal (3.1.8)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.16.0)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -603,7 +603,7 @@ GEM
ffi (~> 1.0) ffi (~> 1.0)
redis (5.0.6) redis (5.0.6)
redis-client (>= 0.9.0) redis-client (>= 0.9.0)
redis-client (0.22.1) redis-client (0.22.2)
connection_pool connection_pool
redis-namespace (1.10.0) redis-namespace (1.10.0)
redis (>= 4) redis (>= 4)
@@ -703,14 +703,14 @@ GEM
activesupport (>= 4) activesupport (>= 4)
selectize-rails (0.12.6) selectize-rails (0.12.6)
semantic_range (3.0.0) semantic_range (3.0.0)
sentry-rails (5.17.3) sentry-rails (5.18.0)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.17.3) sentry-ruby (~> 5.18.0)
sentry-ruby (5.17.3) sentry-ruby (5.18.0)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.17.3) sentry-sidekiq (5.18.0)
sentry-ruby (~> 5.17.3) sentry-ruby (~> 5.18.0)
sidekiq (>= 3.0) sidekiq (>= 3.0)
sexp_processor (4.17.0) sexp_processor (4.17.0)
shoulda-matchers (5.3.0) shoulda-matchers (5.3.0)
@@ -931,9 +931,9 @@ DEPENDENCIES
scout_apm scout_apm
scss_lint scss_lint
seed_dump seed_dump
sentry-rails (>= 5.14.0) sentry-rails (>= 5.18.0)
sentry-ruby sentry-ruby
sentry-sidekiq (>= 5.15.0) sentry-sidekiq (>= 5.18.0)
shoulda-matchers shoulda-matchers
sidekiq (>= 7.2.4) sidekiq (>= 7.2.4)
sidekiq-cron (>= 1.12.0) sidekiq-cron (>= 1.12.0)

View File

@@ -121,7 +121,7 @@ export const generateConditionOptions = (options, key = 'id') => {
}; };
// Add the "None" option to the agent list // Add the "None" option to the agent list
export const agentList = agents => [ export const addNoneToList = agents => [
{ {
id: 'nil', id: 'nil',
name: 'None', name: 'None',
@@ -137,8 +137,8 @@ export const getActionOptions = ({
type, type,
}) => { }) => {
const actionsMap = { const actionsMap = {
assign_agent: agentList(agents), assign_agent: addNoneToList(agents),
assign_team: teams, assign_team: addNoneToList(teams),
send_email_to_team: teams, send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'), add_label: generateConditionOptions(labels, 'title'),
remove_label: generateConditionOptions(labels, 'title'), remove_label: generateConditionOptions(labels, 'title'),

View File

@@ -39,6 +39,14 @@
:readable-time="readableTime" :readable-time="readableTime"
@error="onImageLoadError" @error="onImageLoadError"
/> />
<video-bubble
v-if="attachment.file_type === 'video' && !hasVideoError"
:url="attachment.data_url"
:readable-time="readableTime"
@error="onVideoLoadError"
/>
<file-bubble <file-bubble
v-else v-else
:url="attachment.data_url" :url="attachment.data_url"
@@ -72,6 +80,7 @@
import UserMessageBubble from 'widget/components/UserMessageBubble.vue'; import UserMessageBubble from 'widget/components/UserMessageBubble.vue';
import MessageReplyButton from 'widget/components/MessageReplyButton.vue'; import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
import ImageBubble from 'widget/components/ImageBubble.vue'; import ImageBubble from 'widget/components/ImageBubble.vue';
import VideoBubble from 'widget/components/VideoBubble.vue';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import FileBubble from 'widget/components/FileBubble.vue'; import FileBubble from 'widget/components/FileBubble.vue';
import timeMixin from 'dashboard/mixins/time'; import timeMixin from 'dashboard/mixins/time';
@@ -88,6 +97,7 @@ export default {
UserMessageBubble, UserMessageBubble,
MessageReplyButton, MessageReplyButton,
ImageBubble, ImageBubble,
VideoBubble,
FileBubble, FileBubble,
FluentIcon, FluentIcon,
ReplyToChip, ReplyToChip,
@@ -107,6 +117,7 @@ export default {
data() { data() {
return { return {
hasImageError: false, hasImageError: false,
hasVideoError: false,
}; };
}, },
computed: { computed: {
@@ -143,10 +154,12 @@ export default {
watch: { watch: {
message() { message() {
this.hasImageError = false; this.hasImageError = false;
this.hasVideoError = false;
}, },
}, },
mounted() { mounted() {
this.hasImageError = false; this.hasImageError = false;
this.hasVideoError = false;
}, },
methods: { methods: {
async retrySendMessage() { async retrySendMessage() {
@@ -158,6 +171,9 @@ export default {
onImageLoadError() { onImageLoadError() {
this.hasImageError = true; this.hasImageError = true;
}, },
onVideoLoadError() {
this.hasVideoError = true;
},
toggleReply() { toggleReply() {
this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message); this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
}, },

View File

@@ -0,0 +1,27 @@
<script setup>
defineProps({
url: { type: String, default: '' },
readableTime: { type: String, default: '' },
});
const emits = defineEmits(['error']);
const onVideoError = () => {
emits('error');
};
</script>
<template>
<div class="block relative max-w-full">
<video
class="w-full max-w-[250px] h-auto"
:src="url"
controls
@error="onVideoError"
/>
<span
class="text-xs absolute text-white dark:text-white right-3 bottom-1 whitespace-nowrap"
>
{{ readableTime }}
</span>
</div>
</template>

View File

@@ -48,7 +48,6 @@ class JsonSchemaValidator < ActiveModel::Validator
# Add validation errors to the record with a formatted statement # Add validation errors to the record with a formatted statement
validation_errors.each do |error| validation_errors.each do |error|
# byebug
format_and_append_error(error, record) format_and_append_error(error, record)
end end
end end

View File

@@ -50,7 +50,11 @@ class ActionService
end end
def assign_team(team_ids = []) 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 # 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 # 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) return unless !team_ids[0].nil? && team_belongs_to_account?(team_ids)

View File

@@ -66,7 +66,7 @@ class Notification::PushNotificationService
def send_browser_push(subscription) def send_browser_push(subscription)
return unless can_send_browser_push?(subscription) return unless can_send_browser_push?(subscription)
WebPush.payload_send(browser_push_payload(subscription)) WebPush.payload_send(**browser_push_payload(subscription))
Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}") Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}")
rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e
Rails.logger.info "WebPush subscription expired: #{e.message}" Rails.logger.info "WebPush subscription expired: #{e.message}"

View File

@@ -1,5 +1,5 @@
shared: &shared shared: &shared
version: '3.10.1' version: '3.10.2'
development: development:
<<: *shared <<: *shared

View File

@@ -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 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ệ 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}" 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 failed: Đăng ký thât bại
data_import: data_import:
data_type: data_type:
@@ -51,7 +51,7 @@ vi:
dyte: dyte:
invalid_message_type: "Loại tin nhắn không hợp lệ. Hành động không được phép" invalid_message_type: "Loại tin nhắn không hợp lệ. Hành động không được phép"
slack: 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: inboxes:
imap: imap:
socket_error: Vui lòng kiểm tra kết nối mạng, địa chỉ IMAP và thử lại. 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ự < > / \ @. 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: 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. 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_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: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}]. invalid_operator: Toán tử không hợp lệ. Các toán tử được phép cho %{attribute_name} [%{allowed_keys}].
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid 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: reports:
period: Thời gian báo cáo từ %{since} đến %{until} 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 utc_warning: Báo cáo đã được tạo với múi giờ UTC
agent_csv: agent_csv:
agent_name: Tên tổng đài viên agent_name: Tên tổng đài viên
conversations_count: Assigned conversations conversations_count: Cuộc trò chuyện được chỉ định
avg_first_response_time: Avg first response time avg_first_response_time: Thời gian phản hồi đầu tiên trung bình
avg_resolution_time: Avg resolution time avg_resolution_time: Avg resolution time
resolution_count: Số lượng giải quyết 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_csv:
inbox_name: Tên kênh inbox_name: Tên kênh
inbox_type: Kiểu kênh inbox_type: Kiểu kênh
conversations_count: Số hội thoại conversations_count: Số hội thoại
avg_first_response_time: Avg first response time avg_first_response_time: Thời gian phản hồi đầu tiên trung bình
avg_resolution_time: Avg resolution time avg_resolution_time: Thời gian giải quyết trung bình
label_csv: label_csv:
label_title: Nhãn label_title: Nhãn
conversations_count: Số hội thoại conversations_count: Số hội thoại
avg_first_response_time: Avg first response time avg_first_response_time: Thời gian phản hồi đầu tiên trung bình
avg_resolution_time: Avg resolution time avg_resolution_time: Thời gian giải quyết trung bình
team_csv: team_csv:
team_name: Tên nhóm team_name: Tên nhóm
conversations_count: Số hội thoại conversations_count: Số hội thoại
avg_first_response_time: Avg first response time avg_first_response_time: Thời gian phản hồi đầu tiên trung bình
avg_resolution_time: Avg resolution time avg_resolution_time: Thời gian giải quyết trung bình
resolution_count: Số lượng giải quyết 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: conversation_traffic_csv:
timezone: Múi giờ timezone: Múi giờ
sla_csv: sla_csv:
conversation_id: Conversation ID conversation_id: ID hội thoại
sla_policy_breached: SLA Policy sla_policy_breached: SLA Policy
assignee: Assignee assignee: Đại lý được chỉ định
team: Nhóm team: Nhóm
inbox: Hộp thư đến inbox: Hộp thư đến
labels: Nhãn labels: Nhãn
@@ -118,22 +118,22 @@ vi:
recorded_at: Ngày nghi recorded_at: Ngày nghi
notifications: notifications:
notification_title: notification_title:
conversation_creation: "A conversation (#%{display_id}) has been created in %{inbox_name}" conversation_creation: "Một cuộc trò chuyện (#%{display_id}) đã được tạo trong %{inbox_name}"
conversation_assignment: "A conversation (#%{display_id}) has been assigned to you" conversation_assignment: "Một cuộc trò chuyện (#%{display_id}) đã được chỉ định cho bạn"
assigned_conversation_new_message: "A new message is created in conversation (#%{display_id})" assigned_conversation_new_message: "Một tin nhắn mới được tạo trong cuộc trò chuyện (#%{display_id})"
conversation_mention: "You have been mentioned in conversation (#%{display_id})" conversation_mention: "Bạn đã được nhắc đến trong cuộc trò chuyện (#%{display_id})"
sla_missed_first_response: "SLA target first response missed for conversation (#%{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: "SLA target next response missed for conversation (#%{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: "SLA target resolution missed for conversation (#%{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: "Attachment" attachment: "Tập tin đính kèm"
no_content: "No content" no_content: "Không có nội dung"
conversations: conversations:
messages: messages:
instagram_story_content: "%{story_sender} đã đề cập đến bạn trong hội thoại: " 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. instagram_deleted_story_content: Hội thoại này không còn nữa.
deleted: Tin nhắn đã bị xoá deleted: Tin nhắn đã bị xoá
delivery_status: delivery_status:
error_code: "Error code: %{error_code}" error_code: "Mã lỗi: %{error_code}"
activity: activity:
status: status:
resolved: "Cuộc trò chuyện được đánh dấu là đã giải quyết bởi %{user_name}" 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: common:
home: Trang Chủ home: Trang Chủ
last_updated_on: 'Cập nhật lần cuối: %{last_updated_on}' 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 article: bài viết
articles: bài viết articles: bài viết
author: tác giả author: tác giả
@@ -233,17 +233,17 @@ vi:
footer: footer:
made_with: Tạo bởi made_with: Tạo bởi
header: header:
go_to_homepage: Website go_to_homepage: Trang web
appearance: appearance:
system: System system: Hệ thống
light: Light light: Sáng
dark: Dark dark: Tối
featured_articles: Featured Articles featured_articles: Bài viết nổi bật
uncategorized: Chưa được phân loại uncategorized: Chưa được phân loại
404: 404:
title: Page not found title: Không tìm thấy trang
description: We couldn't find the page you were looking for. description: Chúng tôi không thể tìm thấy trang bạn đang tìm kiếm.
back_to_home: Go to home page back_to_home: Tới trang chủ
slack_unfurl: slack_unfurl:
fields: fields:
name: Tên name: Tên
@@ -255,10 +255,10 @@ vi:
button: Mở cuộc trò chuyện button: Mở cuộc trò chuyện
time_units: time_units:
days: days:
other: "%{count} days" other: "%{count} ngày"
hours: hours:
other: "%{count} hours" other: "%{count} giờ"
minutes: minutes:
other: "%{count} minutes" other: "%{count} phút"
seconds: seconds:
other: "%{count} seconds" other: "%{count} giây"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@chatwoot/chatwoot", "name": "@chatwoot/chatwoot",
"version": "3.10.1", "version": "3.10.2",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"eslint": "eslint app/**/*.{js,vue}", "eslint": "eslint app/**/*.{js,vue}",

View File

@@ -16,5 +16,17 @@ FactoryBot.define do
conversation.contact ||= create(:contact, :with_email, account: conversation.account) conversation.contact ||= create(:contact, :with_email, account: conversation.account)
conversation.contact_inbox ||= create(:contact_inbox, contact: conversation.contact, inbox: conversation.inbox) conversation.contact_inbox ||= create(:contact_inbox, contact: conversation.contact, inbox: conversation.inbox)
end 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
end end

View File

@@ -31,18 +31,54 @@ describe ActionService do
describe '#assign_agent' do describe '#assign_agent' do
let(:agent) { create(:user, account: account, role: :agent) } 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(: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) } let(:action_service) { described_class.new(conversation) }
it 'unassigns the conversation if agent id is nil' do it 'unassigns the conversation if agent id is nil' do
action_service.assign_agent(['nil']) action_service.assign_agent(['nil'])
expect(conversation.reload.assignee).to be_nil expect(conversation.reload.assignee).to be_nil
end end
end
it 'unassigns the team if team_id is nil' do describe '#assign_team' do
action_service.assign_team([nil]) let(:agent) { create(:user, account: account, role: :agent) }
expect(conversation.reload.team).to be_nil 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 end
end end