Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70fd1e27dc | ||
|
|
a2f9cd6e38 | ||
|
|
758c4bd361 | ||
|
|
d53097f77d | ||
|
|
9911c5dc12 | ||
|
|
19aef3e94b | ||
|
|
23230e0143 | ||
|
|
f92cea144c | ||
|
|
4a48569ec4 | ||
|
|
20c609a615 | ||
|
|
e6cf8c39b7 | ||
|
|
c5c0845151 | ||
|
|
5d9fb55370 | ||
|
|
721a2f5052 | ||
|
|
7320957405 | ||
|
|
010998d541 | ||
|
|
4b88ae906d | ||
|
|
e4d072c79c | ||
|
|
71ee10c889 | ||
|
|
978a8a4cb2 | ||
|
|
c1ce9fe215 | ||
|
|
94b86a1244 | ||
|
|
ac947eedf5 | ||
|
|
6fb3e96bc9 | ||
|
|
cd06b2b337 | ||
|
|
6eb06377cc | ||
|
|
94892e7168 | ||
|
|
72f64836a5 | ||
|
|
33d93310d2 | ||
|
|
fe744abe26 | ||
|
|
9ec4ec94ed | ||
|
|
a902b49bc5 | ||
|
|
9c07b6dd46 | ||
|
|
3b7b06dbec | ||
|
|
04e829e639 | ||
|
|
69182f748b | ||
|
|
597a4164e8 | ||
|
|
b6244c870c | ||
|
|
9d063b730b | ||
|
|
678c00f254 | ||
|
|
4146da5a6d | ||
|
|
b53730bb73 | ||
|
|
7298002da7 | ||
|
|
75b57d6a0a | ||
|
|
e3cd3283a0 | ||
|
|
554395b6c9 | ||
|
|
8b2f656372 | ||
|
|
b4686ec07a | ||
|
|
96b781b7fd | ||
|
|
4d995b16f0 | ||
|
|
8de4911fb3 | ||
|
|
1ce5cbe275 | ||
|
|
a870d1c8a2 | ||
|
|
ab0ab9b032 | ||
|
|
ac4ce529bf | ||
|
|
f19474c681 | ||
|
|
7359f10be2 | ||
|
|
778dbaf812 | ||
|
|
faa7a5f585 | ||
|
|
c607f09be0 | ||
|
|
eaddb253a5 | ||
|
|
8d200b8a99 | ||
|
|
33a6ad9f7e | ||
|
|
e662336eaf | ||
|
|
34aef51064 | ||
|
|
0158a55845 | ||
|
|
657843960c | ||
|
|
139b0d8a4c | ||
|
|
440e357c84 | ||
|
|
fc6a22b072 | ||
|
|
58959d42f5 | ||
|
|
227d99934e | ||
|
|
5036b28e45 | ||
|
|
01264d45c2 | ||
|
|
8548f6e00b | ||
|
|
0763cc477f | ||
|
|
8b5735c915 | ||
|
|
2fc5c6f236 | ||
|
|
eb379e1849 | ||
|
|
5935a4ff6c | ||
|
|
43de19f685 | ||
|
|
6a630bc489 | ||
|
|
0d1b474e60 | ||
|
|
84f6cadae7 | ||
|
|
372e740c38 |
@@ -254,7 +254,6 @@ AZURE_APP_SECRET=
|
||||
# Sentiment analysis model file path
|
||||
SENTIMENT_FILE_PATH=
|
||||
|
||||
|
||||
# Housekeeping/Performance related configurations
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
# contact_inboxes with no conversation older than 90 days will be removed
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
## All javascript files should be reviewed by pranav before merging
|
||||
*.js @pranavrajs
|
||||
*.vue @pranavrajs
|
||||
|
||||
|
||||
## All enterprise related files should be reviewed by sojan before merging
|
||||
/enterprise/* @sojan-official
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for log lines and calculate percentage
|
||||
run: |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# # This action will try to install and setup
|
||||
# # chatwoot on an Ubuntu 20.04 machine using
|
||||
# # the linux installer script.
|
||||
# #
|
||||
# #
|
||||
# # This is set to run daily at midnight.
|
||||
# #
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
run: |
|
||||
sudo ./install.sh --install < input
|
||||
|
||||
# disabling http verify for now as http
|
||||
# disabling http verify for now as http
|
||||
# access to port 3000 fails in gh action env
|
||||
# - name: Verify
|
||||
# if: always()
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# curl http://localhost:3000/api
|
||||
|
||||
- name: Upload chatwoot setup log file as an artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: chatwoot-setup-log-file
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: yarn
|
||||
@@ -76,11 +76,11 @@ jobs:
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
bundle exec rspec --profile=10 --format documentation
|
||||
env:
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload rails log folder
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: rails-log-folder
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: yarn
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
--format documentation
|
||||
|
||||
- name: Upload rails log folder
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: rails-log-folder
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'yarn'
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
|
||||
- name: Run asset compile
|
||||
run: bundle exec rake assets:precompile
|
||||
env:
|
||||
|
||||
+9
-9
@@ -316,16 +316,16 @@ GEM
|
||||
google-cloud-translate-v3 (0.6.0)
|
||||
gapic-common (>= 0.17.1, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-protobuf (3.22.3)
|
||||
google-protobuf (3.22.3-arm64-darwin)
|
||||
google-protobuf (3.22.3-x86_64-darwin)
|
||||
google-protobuf (3.22.3-x86_64-linux)
|
||||
google-protobuf (3.25.2)
|
||||
google-protobuf (3.25.2-arm64-darwin)
|
||||
google-protobuf (3.25.2-x86_64-darwin)
|
||||
google-protobuf (3.25.2-x86_64-linux)
|
||||
googleapis-common-protos (1.4.0)
|
||||
google-protobuf (~> 3.14)
|
||||
googleapis-common-protos-types (~> 1.2)
|
||||
grpc (~> 1.27)
|
||||
googleapis-common-protos-types (1.6.0)
|
||||
google-protobuf (~> 3.14)
|
||||
googleapis-common-protos-types (1.11.0)
|
||||
google-protobuf (~> 3.18)
|
||||
googleauth (1.5.2)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
@@ -335,13 +335,13 @@ GEM
|
||||
signet (>= 0.16, < 2.a)
|
||||
groupdate (6.2.1)
|
||||
activesupport (>= 5.2)
|
||||
grpc (1.54.0)
|
||||
grpc (1.54.3)
|
||||
google-protobuf (~> 3.21)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.54.0-x86_64-darwin)
|
||||
grpc (1.54.3-x86_64-darwin)
|
||||
google-protobuf (~> 3.21)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.54.0-x86_64-linux)
|
||||
grpc (1.54.3-x86_64-linux)
|
||||
google-protobuf (~> 3.21)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
haikunator (1.1.1)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
set_grouped_conversations_count
|
||||
set_grouped_avg_reply_time
|
||||
set_grouped_avg_first_response_time
|
||||
set_grouped_avg_resolution_time
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_grouped_conversations_count
|
||||
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
|
||||
end
|
||||
|
||||
def set_grouped_avg_resolution_time
|
||||
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_first_response_time
|
||||
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_reply_time
|
||||
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
:user_id
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.each_with_object([]) do |account_user, arr|
|
||||
arr << {
|
||||
id: account_user.user_id,
|
||||
conversations_count: @grouped_conversations_count[account_user.user_id],
|
||||
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
|
||||
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
|
||||
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class V2::Reports::BaseSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
private
|
||||
|
||||
def group_by_key
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def get_grouped_average(events)
|
||||
events.group(group_by_key).average(average_value_key)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
set_grouped_conversations_count
|
||||
set_grouped_avg_reply_time
|
||||
set_grouped_avg_first_response_time
|
||||
set_grouped_avg_resolution_time
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_grouped_conversations_count
|
||||
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
|
||||
end
|
||||
|
||||
def set_grouped_avg_resolution_time
|
||||
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_first_response_time
|
||||
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_reply_time
|
||||
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
'conversations.team_id'
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.each_with_object([]) do |team, arr|
|
||||
arr << {
|
||||
id: team.id,
|
||||
conversations_count: @grouped_conversations_count[team.id],
|
||||
avg_resolution_time: @grouped_avg_resolution_time[team.id],
|
||||
avg_first_response_time: @grouped_avg_first_response_time[team.id],
|
||||
avg_reply_time: @grouped_avg_reply_time[team.id]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -43,8 +43,17 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
builder.perform
|
||||
begin
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
end
|
||||
|
||||
# This endpoint is used to bulk create agents during onboarding
|
||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def export
|
||||
column_names = params['column_names']
|
||||
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
|
||||
Account::ContactsExportJob.perform_later(Current.account.id, column_names, Current.user.email)
|
||||
head :ok, message: I18n.t('errors.contacts.export.success')
|
||||
end
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@notifications = notification_finder.notifications
|
||||
@unread_count = notification_finder.unread_count
|
||||
@notifications = notification_finder.perform
|
||||
@count = notification_finder.count
|
||||
end
|
||||
|
||||
@@ -54,7 +54,8 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def snooze
|
||||
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s)) if params[:snoozed_until]
|
||||
updated_meta = (@notification.meta || {}).merge('last_snoozed_at' => nil)
|
||||
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s), meta: updated_meta) if params[:snoozed_until]
|
||||
render json: @notification
|
||||
end
|
||||
|
||||
|
||||
@@ -44,7 +44,10 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||
@account.save!
|
||||
end
|
||||
|
||||
def update_active_at
|
||||
@@ -83,6 +86,10 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone)
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||
end
|
||||
|
||||
@@ -10,7 +10,9 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
@user.update!(password_params.except(:current_password))
|
||||
end
|
||||
|
||||
@user.update!(profile_params)
|
||||
@user.assign_attributes(profile_params)
|
||||
@user.custom_attributes.merge!(custom_attributes_params)
|
||||
@user.save!
|
||||
end
|
||||
|
||||
def avatar
|
||||
@@ -62,6 +64,10 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
)
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.require(:profile).permit(:phone_number)
|
||||
end
|
||||
|
||||
def password_params
|
||||
params.require(:profile).permit(
|
||||
:current_password,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :prepare_builder_params, only: [:agent, :team]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
end
|
||||
|
||||
def team
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
authorize :report, :view?
|
||||
end
|
||||
|
||||
def prepare_builder_params
|
||||
@builder_params = {
|
||||
since: permitted_params[:since],
|
||||
until: permitted_params[:until],
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
|
||||
}
|
||||
end
|
||||
|
||||
def render_report_with(builder_class)
|
||||
builder = builder_class.new(account: Current.account, params: @builder_params)
|
||||
data = builder.build
|
||||
render json: data
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:since, :until, :business_hours)
|
||||
end
|
||||
end
|
||||
@@ -17,8 +17,13 @@ class Api::V2::AccountsController < Api::BaseController
|
||||
@user, @account = AccountBuilder.new(
|
||||
email: account_params[:email],
|
||||
user_password: account_params[:password],
|
||||
locale: account_params[:locale],
|
||||
user: current_user
|
||||
).perform
|
||||
|
||||
fetch_account_and_user_info
|
||||
update_account_info if @account.present?
|
||||
|
||||
if @user
|
||||
send_auth_headers(@user)
|
||||
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
|
||||
@@ -29,6 +34,20 @@ class Api::V2::AccountsController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def account_attributes
|
||||
{
|
||||
custom_attributes: @account.custom_attributes.merge({ 'onboarding_step' => 'profile_update' })
|
||||
}
|
||||
end
|
||||
|
||||
def update_account_info
|
||||
@account.update!(
|
||||
account_attributes
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_account_and_user_info; end
|
||||
|
||||
def fetch_account
|
||||
@account = current_user.accounts.find(params[:id])
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
@@ -46,3 +65,5 @@ class Api::V2::AccountsController < Api::BaseController
|
||||
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
||||
end
|
||||
end
|
||||
|
||||
Api::V2::AccountsController.prepend_mod_with('Api::V2::AccountsController')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status create],
|
||||
'api/v1/accounts/conversations/messages' => ['create']
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
}.freeze
|
||||
|
||||
def ensure_access_token
|
||||
|
||||
@@ -25,6 +25,6 @@ module EnsureCurrentAccountHelper
|
||||
end
|
||||
|
||||
def account_accessible_for_bot?(account)
|
||||
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -55,7 +55,7 @@ class DashboardController < ActionController::Base
|
||||
VAPID_PUBLIC_KEY: VapidService.public_key,
|
||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: 'v14.0',
|
||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH
|
||||
|
||||
@@ -34,7 +34,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
def allowed_configs
|
||||
@allowed_configs = case @config
|
||||
when 'facebook'
|
||||
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
|
||||
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
|
||||
when 'email'
|
||||
['MAILER_INBOUND_EMAIL_DOMAIN']
|
||||
else
|
||||
|
||||
@@ -28,8 +28,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
||||
end
|
||||
|
||||
def sha
|
||||
sha = `git rev-parse HEAD`
|
||||
@metrics['Git SHA'] = sha.presence || 'n/a'
|
||||
@metrics['Git SHA'] = GIT_HASH
|
||||
end
|
||||
|
||||
def postgres_status
|
||||
|
||||
@@ -169,6 +169,12 @@ class ConversationFinder
|
||||
)
|
||||
|
||||
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
|
||||
@conversations.send(sort_by, sort_order).page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
|
||||
@conversations = @conversations.send(sort_by, sort_order)
|
||||
|
||||
if params[:updated_within].present?
|
||||
@conversations.where('conversations.updated_at > ?', Time.zone.now - params[:updated_within].to_i.seconds)
|
||||
else
|
||||
@conversations.page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,8 +10,8 @@ class NotificationFinder
|
||||
set_up
|
||||
end
|
||||
|
||||
def perform
|
||||
notifications
|
||||
def notifications
|
||||
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: sort_order)
|
||||
end
|
||||
|
||||
def unread_count
|
||||
@@ -26,27 +26,31 @@ class NotificationFinder
|
||||
|
||||
def set_up
|
||||
find_all_notifications
|
||||
filter_by_read_status
|
||||
filter_by_status
|
||||
filter_snoozed_notifications
|
||||
fitler_read_notifications
|
||||
end
|
||||
|
||||
def find_all_notifications
|
||||
@notifications = current_user.notifications.where(account_id: @current_account.id)
|
||||
end
|
||||
|
||||
def filter_by_status
|
||||
@notifications = @notifications.where('snoozed_until > ?', DateTime.now.utc) if params[:status] == 'snoozed'
|
||||
def filter_snoozed_notifications
|
||||
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
|
||||
end
|
||||
|
||||
def filter_by_read_status
|
||||
@notifications = @notifications.where.not(read_at: nil) if params[:type] == 'read'
|
||||
def fitler_read_notifications
|
||||
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
|
||||
end
|
||||
|
||||
def type_included?(type)
|
||||
(params[:includes] || []).include?(type)
|
||||
end
|
||||
|
||||
def current_page
|
||||
params[:page] || 1
|
||||
end
|
||||
|
||||
def notifications
|
||||
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: params[:sort_order] || :desc)
|
||||
def sort_order
|
||||
params[:sort_order] || :desc
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,8 +15,8 @@ module Api::V2::Accounts::HeatmapHelper
|
||||
dates = data.pluck(:date).uniq.sort
|
||||
|
||||
# add the dates as the first row, leave an empty cell for the hour column
|
||||
# e.g. [nil, '2023-01-01', '2023-1-02', '2023-01-03']
|
||||
result_arr << ([nil] + dates)
|
||||
# e.g. ['Start of the hour', '2023-01-01', '2023-1-02', '2023-01-03']
|
||||
result_arr << (['Start of the hour'] + dates)
|
||||
|
||||
# group the data by hour, we do not need to sort it, because the data is already sorted
|
||||
# given it starts from the beginning of the day
|
||||
@@ -25,7 +25,7 @@ module Api::V2::Accounts::HeatmapHelper
|
||||
# value = [{date: 2023-01-01, value: 1}, {date: 2023-01-02, value: 1}, {date: 2023-01-03, value: 1}, ...]
|
||||
data.group_by { |d| d[:hour] }.each do |hour, items|
|
||||
# create a new row for each hour
|
||||
row = [hour]
|
||||
row = [format('%02d:00', hour)]
|
||||
|
||||
# group the items by date, so we can easily access the value for each date
|
||||
# grouped values will be a hasg with the date as the key, and the value as the value
|
||||
@@ -37,7 +37,7 @@ module Api::V2::Accounts::HeatmapHelper
|
||||
row << (grouped_values[date][0][:value] if grouped_values[date].is_a?(Array))
|
||||
end
|
||||
|
||||
# row will look like [22, 0, 0, 1, 4, 6, 7, 4]
|
||||
# row will look like ['22:00', 0, 0, 1, 4, 6, 7, 4]
|
||||
# add the row to the result array
|
||||
|
||||
result_arr << row
|
||||
@@ -46,12 +46,12 @@ module Api::V2::Accounts::HeatmapHelper
|
||||
# return the resultant array
|
||||
# the result looks like this
|
||||
# [
|
||||
# [nil, '2023-01-01', '2023-1-02', '2023-01-03'],
|
||||
# [0, 0, 0, 0],
|
||||
# [1, 0, 0, 0],
|
||||
# [2, 0, 0, 0],
|
||||
# [3, 0, 0, 0],
|
||||
# [4, 0, 0, 0],
|
||||
# ['Start of the hour', '2023-01-01', '2023-1-02', '2023-01-03'],
|
||||
# ['00:00', 0, 0, 0],
|
||||
# ['01:00', 0, 0, 0],
|
||||
# ['02:00', 0, 0, 0],
|
||||
# ['03:00', 0, 0, 0],
|
||||
# ['04:00', 0, 0, 0],
|
||||
# ]
|
||||
result_arr
|
||||
end
|
||||
|
||||
@@ -45,12 +45,8 @@ module Api::V2::Accounts::ReportsHelper
|
||||
def generate_readable_report_metrics(report_metric)
|
||||
[
|
||||
report_metric[:conversations_count],
|
||||
time_to_minutes(report_metric[:avg_first_response_time]),
|
||||
time_to_minutes(report_metric[:avg_resolution_time])
|
||||
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format,
|
||||
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format
|
||||
]
|
||||
end
|
||||
|
||||
def time_to_minutes(time_in_seconds)
|
||||
(time_in_seconds / 60).to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
>
|
||||
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<template v-if="currentAccountId">
|
||||
<pending-email-verification-banner />
|
||||
<payment-pending-banner />
|
||||
<pending-email-verification-banner v-if="hideOnOnboardingView" />
|
||||
<payment-pending-banner v-if="hideOnOnboardingView" />
|
||||
<upgrade-banner />
|
||||
</template>
|
||||
<transition name="fade" mode="out-in">
|
||||
@@ -38,6 +38,7 @@ import vueActionCable from './helper/actionCable';
|
||||
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
@@ -79,6 +80,9 @@ export default {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
return accounts.length > 0;
|
||||
},
|
||||
hideOnOnboardingView() {
|
||||
return !isOnOnboardingView(this.$route);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class Agents extends ApiClient {
|
||||
constructor() {
|
||||
super('agents', { accountScoped: true });
|
||||
}
|
||||
|
||||
bulkInvite({ emails }) {
|
||||
return axios.post(`${this.url}/bulk_create`, {
|
||||
emails,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Agents();
|
||||
|
||||
@@ -7,12 +7,13 @@ class NotificationsAPI extends ApiClient {
|
||||
}
|
||||
|
||||
get({ page, status, type, sortOrder }) {
|
||||
const includesFilter = [status, type].filter(value => !!value);
|
||||
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
status,
|
||||
type,
|
||||
sort_order: sortOrder,
|
||||
includes: includesFilter,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,4 +10,29 @@ describe('#AgentAPI', () => {
|
||||
expect(agents).toHaveProperty('update');
|
||||
expect(agents).toHaveProperty('delete');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#bulkInvite', () => {
|
||||
agents.bulkInvite({ emails: ['hello@hi.com'] });
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/agents/bulk_create',
|
||||
{
|
||||
emails: ['hello@hi.com'],
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,20 +27,36 @@ describe('#NotificationAPI', () => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('#get', () => {
|
||||
notificationsAPI.get({
|
||||
page: 1,
|
||||
status: 'read',
|
||||
type: 'Conversation',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
|
||||
params: {
|
||||
describe('#get', () => {
|
||||
it('generates the API call if both params are available', () => {
|
||||
notificationsAPI.get({
|
||||
page: 1,
|
||||
status: 'read',
|
||||
type: 'Conversation',
|
||||
sort_order: 'desc',
|
||||
},
|
||||
status: 'snoozed',
|
||||
type: 'read',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
|
||||
params: {
|
||||
page: 1,
|
||||
sort_order: 'desc',
|
||||
includes: ['snoozed', 'read'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('generates the API call if one of the params are available', () => {
|
||||
notificationsAPI.get({
|
||||
page: 1,
|
||||
type: 'read',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', {
|
||||
params: {
|
||||
page: 1,
|
||||
sort_order: 'desc',
|
||||
includes: ['read'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -753,7 +753,7 @@ export default {
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
@apply z-50 bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800;
|
||||
@apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
|
||||
|
||||
h5 {
|
||||
@apply dark:text-slate-25 text-slate-800;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:chat="currentChat"
|
||||
:is-inbox-view="isInboxView"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:show-back-button="isOnExpandedLayout"
|
||||
:show-back-button="isOnExpandedLayout && !isInboxView"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<woot-tabs
|
||||
@@ -35,7 +35,10 @@
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<empty-state v-else :is-on-expanded-layout="isOnExpandedLayout" />
|
||||
<empty-state
|
||||
v-if="!currentChat.id && !isInboxView"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
/>
|
||||
<div
|
||||
v-show="showContactPanel"
|
||||
class="conversation-sidebar-wrap basis-full sm:basis-[17.5rem] md:basis-[18.75rem] lg:basis-[19.375rem] xl:basis-[20.625rem] 2xl:basis-[25rem] rtl:border-r border-slate-50 dark:border-slate-700 h-auto overflow-auto z-10 flex-shrink-0 flex-grow-0"
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row"
|
||||
>
|
||||
<div
|
||||
class="flex-1 w-full min-w-0 flex flex-col md:flex-row items-center justify-center"
|
||||
class="flex-1 w-full min-w-0 flex flex-col items-center justify-center gap-4"
|
||||
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
|
||||
>
|
||||
<div
|
||||
class="flex justify-start items-center mr-4 rtl:mr-0 rtl:ml-4 min-w-0 w-[inherit]"
|
||||
>
|
||||
<div class="flex justify-start items-center min-w-0 w-fit max-w-full">
|
||||
<back-button
|
||||
v-if="showBackButton"
|
||||
:back-url="backButtonUrl"
|
||||
@@ -20,9 +19,11 @@
|
||||
:status="currentContact.availability_status"
|
||||
/>
|
||||
<div
|
||||
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-[inherit] overflow-hidden"
|
||||
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-fit overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center flex-row gap-1 m-0 p-0 w-[inherit]">
|
||||
<div
|
||||
class="flex items-center flex-row gap-1 m-0 p-0 w-fit max-w-full"
|
||||
>
|
||||
<woot-button
|
||||
variant="link"
|
||||
color-scheme="secondary"
|
||||
@@ -66,7 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0 rtl:relative rtl:left-6"
|
||||
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0"
|
||||
:class="{ 'justify-end': isContactPanelOpen }"
|
||||
>
|
||||
<more-actions :conversation-id="currentChat.id" />
|
||||
@@ -87,7 +88,6 @@ import Thumbnail from '../Thumbnail.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -128,9 +128,6 @@ export default {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||||
name,
|
||||
} = this.$route;
|
||||
if (this.isInboxView) {
|
||||
return frontendURL(`accounts/${accountId}/inbox`);
|
||||
}
|
||||
return conversationListPageURL({
|
||||
accountId,
|
||||
inboxId,
|
||||
|
||||
@@ -79,7 +79,13 @@
|
||||
"TITLE": "Export Contacts",
|
||||
"DESC": "Export contacts to a CSV file.",
|
||||
"SUCCESS_MESSAGE": "Export is in progress. You will be notified on email when the export file is ready to download.",
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
"ERROR_MESSAGE": "There was an error, please try again",
|
||||
"CONFIRM": {
|
||||
"TITLE": "Export Contacts",
|
||||
"MESSAGE": "Are you sure you want to export all contacts?",
|
||||
"YES": "Yes, Export",
|
||||
"NO": "No, Cancel"
|
||||
}
|
||||
},
|
||||
"DELETE_NOTE": {
|
||||
"CONFIRM": {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"TITLE": "Inbox",
|
||||
"DISPLAY_DROPDOWN": "Display",
|
||||
"LOADING": "Fetching notifications",
|
||||
"EOF": "All notifications loaded 🎉",
|
||||
"404": "There are no active notifications in this group.",
|
||||
"NO_NOTIFICATIONS": "No notifications",
|
||||
"NOTE": "Notifications from all subscribed inboxes",
|
||||
@@ -14,7 +13,8 @@
|
||||
},
|
||||
"ACTION_HEADER": {
|
||||
"SNOOZE": "Snooze notification",
|
||||
"DELETE": "Delete notification"
|
||||
"DELETE": "Delete notification",
|
||||
"BACK": "Back"
|
||||
},
|
||||
"TYPES": {
|
||||
"CONVERSATION_MENTION": "You have been mentioned in a conversation",
|
||||
|
||||
@@ -18,3 +18,4 @@ export const CMD_AI_ASSIST = 'CMD_AI_ASSIST';
|
||||
|
||||
// Inbox Commands (Notifications)
|
||||
export const CMD_SNOOZE_NOTIFICATION = 'CMD_SNOOZE_NOTIFICATION';
|
||||
export const CMD_TOGGLE_SNOOZE_NOTIFICATION = 'CMD_TOGGLE_SNOOZE_NOTIFICATION';
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
hideBreadcrumbs
|
||||
:placeholder="placeholder"
|
||||
@selected="onSelected"
|
||||
@closed="onClosed"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import 'ninja-keys';
|
||||
import '@chatwoot/ninja-keys';
|
||||
import conversationHotKeysMixin from './conversationHotKeys';
|
||||
import inboxHotKeysMixin from './inboxHotKeys';
|
||||
import goToCommandHotKeys from './goToCommandHotKeys';
|
||||
@@ -32,6 +33,11 @@ export default {
|
||||
appearanceHotKeys,
|
||||
goToCommandHotKeys,
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
modalObserver: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
placeholder() {
|
||||
return this.$t('COMMAND_BAR.SEARCH_PLACEHOLDER');
|
||||
@@ -55,6 +61,10 @@ export default {
|
||||
routeName() {
|
||||
this.setCommandbarData();
|
||||
},
|
||||
hotKeys() {
|
||||
// This is to update the command bar data when the hot keys are updated dynamically
|
||||
this.setCommandbarData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setCommandbarData();
|
||||
@@ -72,6 +82,9 @@ export default {
|
||||
});
|
||||
this.setCommandbarData();
|
||||
},
|
||||
onClosed() {
|
||||
this.showSnoozeNotificationItems = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
|
||||
import {
|
||||
CMD_SNOOZE_NOTIFICATION,
|
||||
CMD_TOGGLE_SNOOZE_NOTIFICATION,
|
||||
} from './commandBarBusEvents';
|
||||
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
|
||||
|
||||
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
|
||||
@@ -61,14 +64,28 @@ const INBOX_SNOOZE_EVENTS = [
|
||||
},
|
||||
];
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showSnoozeNotificationItems: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
inboxHotKeys() {
|
||||
if (isAInboxViewRoute(this.$route.name)) {
|
||||
if (
|
||||
isAInboxViewRoute(this.$route.name) ||
|
||||
this.showSnoozeNotificationItems
|
||||
) {
|
||||
return this.prepareActions(INBOX_SNOOZE_EVENTS);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(CMD_TOGGLE_SNOOZE_NOTIFICATION, this.toggleSnoozeOptions);
|
||||
},
|
||||
destroyed() {
|
||||
bus.$off(CMD_TOGGLE_SNOOZE_NOTIFICATION, this.toggleSnoozeOptions);
|
||||
},
|
||||
methods: {
|
||||
prepareActions(actions) {
|
||||
return actions.map(action => ({
|
||||
@@ -77,5 +94,9 @@ export default {
|
||||
section: this.$t(action.section),
|
||||
}));
|
||||
},
|
||||
toggleSnoozeOptions() {
|
||||
// Used to show/hide snooze notification items in cmd bar dynamically
|
||||
this.showSnoozeNotificationItems = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -113,6 +113,13 @@
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
<woot-confirm-modal
|
||||
ref="confirmExportContactsDialog"
|
||||
:title="$t('EXPORT_CONTACTS.CONFIRM.TITLE')"
|
||||
:description="$t('EXPORT_CONTACTS.CONFIRM.MESSAGE')"
|
||||
:confirm-label="$t('EXPORT_CONTACTS.CONFIRM.YES')"
|
||||
:cancel-label="$t('EXPORT_CONTACTS.CONFIRM.NO')"
|
||||
/>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -175,8 +182,13 @@ export default {
|
||||
toggleImport() {
|
||||
this.$emit('on-toggle-import');
|
||||
},
|
||||
submitExport() {
|
||||
this.$emit('on-export-submit');
|
||||
async submitExport() {
|
||||
const ok =
|
||||
await this.$refs.confirmExportContactsDialog.showConfirmation();
|
||||
|
||||
if (ok) {
|
||||
this.$emit('on-export-submit');
|
||||
}
|
||||
},
|
||||
submitSearch() {
|
||||
this.$emit('on-search-submit');
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
const ConversationView = () => import('./ConversationView');
|
||||
const InboxView = () => import('../inbox/InboxView.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/inbox-view'),
|
||||
name: 'inbox_view',
|
||||
roles: ['administrator', 'agent'],
|
||||
component: InboxView,
|
||||
props: () => {
|
||||
return { inboxId: 0 };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/inbox-view/:conversation_id'),
|
||||
name: 'inbox_view_conversation',
|
||||
roles: ['administrator', 'agent'],
|
||||
component: InboxView,
|
||||
props: route => {
|
||||
return { inboxId: 0, conversationId: route.params.conversation_id };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/dashboard'),
|
||||
name: 'home',
|
||||
|
||||
@@ -3,6 +3,7 @@ import conversation from './conversation/conversation.routes';
|
||||
import { routes as searchRoutes } from '../../modules/search/search.routes';
|
||||
import { routes as contactRoutes } from './contacts/routes';
|
||||
import { routes as notificationRoutes } from './notifications/routes';
|
||||
import { routes as inboxRoutes } from './inbox/routes';
|
||||
import { frontendURL } from '../../helper/URLHelper';
|
||||
import helpcenterRoutes from './helpcenter/helpcenter.routes';
|
||||
|
||||
@@ -16,6 +17,7 @@ export default {
|
||||
path: frontendURL('accounts/:account_id'),
|
||||
component: AppContainer,
|
||||
children: [
|
||||
...inboxRoutes,
|
||||
...conversation.routes,
|
||||
...settings.routes,
|
||||
...contactRoutes,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full w-full md:w-[calc(100%-360px)]">
|
||||
<inbox-item-header
|
||||
:total-length="totalNotifications"
|
||||
:current-index="activeNotificationPosition"
|
||||
:active-notification="activeNotification"
|
||||
@next="onClickNext"
|
||||
@prev="onClickPrev"
|
||||
/>
|
||||
<conversation-box
|
||||
class="h-[calc(100%-56px)]"
|
||||
is-inbox-view
|
||||
:inbox-id="inboxId"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:is-on-expanded-layout="false"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import InboxItemHeader from './components/InboxItemHeader.vue';
|
||||
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
InboxItemHeader,
|
||||
ConversationBox,
|
||||
},
|
||||
mixins: [uiSettingsMixin],
|
||||
computed: {
|
||||
...mapGetters({
|
||||
notifications: 'notifications/getNotifications',
|
||||
activeNotificationById: 'notifications/getActiveNotificationById',
|
||||
currentChat: 'getSelectedChat',
|
||||
conversationById: 'getConversationById',
|
||||
uiFlags: 'notifications/getUIFlags',
|
||||
}),
|
||||
inboxId() {
|
||||
return Number(this.$route.params.inboxId);
|
||||
},
|
||||
notificationId() {
|
||||
return Number(this.$route.params.notification_id);
|
||||
},
|
||||
activeNotification() {
|
||||
return this.activeNotificationById(this.notificationId);
|
||||
},
|
||||
conversationId() {
|
||||
return this.activeNotification?.primary_actor?.id;
|
||||
},
|
||||
totalNotifications() {
|
||||
return this.notifications?.length ?? 0;
|
||||
},
|
||||
activeNotificationPosition() {
|
||||
const notificationIndex = this.notifications.findIndex(
|
||||
n => n.id === this.notificationId
|
||||
);
|
||||
return notificationIndex >= 0 ? notificationIndex + 1 : 0;
|
||||
},
|
||||
isContactPanelOpen() {
|
||||
if (this.currentChat.id) {
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } =
|
||||
this.uiSettings;
|
||||
return isContactSidebarOpen;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
conversationId: {
|
||||
immediate: true,
|
||||
handler(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.fetchConversationById();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
async fetchConversationById() {
|
||||
if (!this.notificationId || !this.conversationId) return;
|
||||
this.$store.dispatch('clearSelectedState');
|
||||
const existingChat = this.findConversation();
|
||||
if (existingChat) {
|
||||
this.setActiveChat(existingChat);
|
||||
return;
|
||||
}
|
||||
await this.$store.dispatch('getConversation', this.conversationId);
|
||||
this.setActiveChat();
|
||||
},
|
||||
setActiveChat(conversation = null) {
|
||||
const selectedConversation = conversation || this.findConversation();
|
||||
if (!selectedConversation) return;
|
||||
|
||||
this.$store
|
||||
.dispatch('setActiveChat', { data: selectedConversation })
|
||||
.then(() => {
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
});
|
||||
},
|
||||
findConversation() {
|
||||
return this.conversationById(this.conversationId);
|
||||
},
|
||||
navigateToConversation(activeIndex, direction) {
|
||||
const indexOffset = direction === 'next' ? 0 : -2;
|
||||
const targetNotification = this.notifications[activeIndex + indexOffset];
|
||||
if (targetNotification) {
|
||||
const {
|
||||
id,
|
||||
primary_actor_id: primaryActorId,
|
||||
primary_actor_type: primaryActorType,
|
||||
primary_actor: { inbox_id: inboxId, meta: { unreadCount } = {} },
|
||||
notification_type: notificationType,
|
||||
} = targetNotification;
|
||||
|
||||
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
|
||||
notificationType,
|
||||
});
|
||||
|
||||
this.$store.dispatch('notifications/read', {
|
||||
id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
unreadCount,
|
||||
});
|
||||
|
||||
this.$router.push({
|
||||
name: 'inbox_view_conversation',
|
||||
params: { inboxId, notification_id: id },
|
||||
});
|
||||
}
|
||||
},
|
||||
onClickNext() {
|
||||
this.navigateToConversation(this.activeNotificationPosition, 'next');
|
||||
},
|
||||
onClickPrev() {
|
||||
this.navigateToConversation(this.activeNotificationPosition, 'prev');
|
||||
},
|
||||
onToggleContactPanel() {
|
||||
this.updateUISettings({
|
||||
is_contact_sidebar_open: !this.isContactPanelOpen,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full hidden md:flex items-center"
|
||||
>
|
||||
<span v-if="uiFlags.isFetching" class="spinner mt-4 mb-4" />
|
||||
<div v-else class="flex flex-col items-center gap-2">
|
||||
<fluent-icon
|
||||
icon="mail-inbox"
|
||||
size="40"
|
||||
class="text-slate-600 dark:text-slate-400"
|
||||
/>
|
||||
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
|
||||
{{ $t('INBOX.LIST.NOTE') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'notifications/getUIFlags',
|
||||
}),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-full w-full ltr:border-r border-slate-50 dark:border-slate-800/50"
|
||||
:class="isOnExpandedLayout ? '' : 'min-w-[360px] max-w-[360px]'"
|
||||
class="flex-col h-full w-full md:min-w-[360px] md:max-w-[360px] ltr:border-r border-slate-50 dark:border-slate-800/50"
|
||||
:class="!notificationId ? 'flex' : 'hidden md:flex'"
|
||||
>
|
||||
<inbox-list-header @filter="onFilterChange" />
|
||||
<inbox-list-header
|
||||
:is-context-menu-open="isInboxContextMenuOpen"
|
||||
@filter="onFilterChange"
|
||||
@redirect="redirectToInbox"
|
||||
/>
|
||||
<div
|
||||
ref="notificationList"
|
||||
class="flex flex-col w-full h-[calc(100%-56px)] overflow-x-hidden overflow-y-auto"
|
||||
@@ -15,6 +19,9 @@
|
||||
@mark-notification-as-read="markNotificationAsRead"
|
||||
@mark-notification-as-unread="markNotificationAsUnRead"
|
||||
@delete-notification="deleteNotification"
|
||||
@snooze-notification="openSnoozeNotificationModal"
|
||||
@context-menu-open="isInboxContextMenuOpen = true"
|
||||
@context-menu-close="isInboxContextMenuOpen = false"
|
||||
/>
|
||||
<div v-if="uiFlags.isFetching" class="text-center">
|
||||
<span class="spinner mt-4 mb-4" />
|
||||
@@ -25,18 +32,21 @@
|
||||
>
|
||||
{{ $t('INBOX.LIST.NO_NOTIFICATIONS') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="showEndOfListMessage"
|
||||
class="text-center text-slate-400 dark:text-slate-400 p-4"
|
||||
>
|
||||
{{ $t('INBOX.LIST.EOF') }}
|
||||
</p>
|
||||
<intersection-observer
|
||||
v-if="!showEndOfList && !uiFlags.isFetching"
|
||||
:options="infiniteLoaderOptions"
|
||||
@observed="loadMoreNotifications"
|
||||
/>
|
||||
</div>
|
||||
<woot-modal
|
||||
:show.sync="showCustomSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<custom-snooze-modal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="scheduleCustomSnooze"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -46,26 +56,25 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import InboxCard from './components/InboxCard.vue';
|
||||
import InboxListHeader from './components/InboxListHeader.vue';
|
||||
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
CMD_TOGGLE_SNOOZE_NOTIFICATION,
|
||||
CMD_SNOOZE_NOTIFICATION,
|
||||
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
InboxCard,
|
||||
InboxListHeader,
|
||||
IntersectionObserver,
|
||||
CustomSnoozeModal,
|
||||
},
|
||||
mixins: [alertMixin, uiSettingsMixin],
|
||||
props: {
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
infiniteLoaderOptions: {
|
||||
@@ -76,6 +85,9 @@ export default {
|
||||
status: '',
|
||||
type: '',
|
||||
sortOrder: wootConstants.INBOX_SORT_BY.NEWEST,
|
||||
isInboxContextMenuOpen: false,
|
||||
showCustomSnoozeModal: false,
|
||||
notificationIdToSnooze: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -85,6 +97,9 @@ export default {
|
||||
uiFlags: 'notifications/getUIFlags',
|
||||
notification: 'notifications/getFilteredNotifications',
|
||||
}),
|
||||
notificationId() {
|
||||
return Number(this.$route.params.notification_id);
|
||||
},
|
||||
inboxFilters() {
|
||||
return {
|
||||
page: this.page,
|
||||
@@ -102,13 +117,14 @@ export default {
|
||||
showEmptyState() {
|
||||
return !this.uiFlags.isFetching && !this.notifications.length;
|
||||
},
|
||||
showEndOfListMessage() {
|
||||
return this.showEndOfList && this.notifications.length;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setSavedFilter();
|
||||
this.fetchNotifications();
|
||||
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
|
||||
},
|
||||
destroyed() {
|
||||
bus.$off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
|
||||
},
|
||||
methods: {
|
||||
fetchNotifications() {
|
||||
@@ -119,17 +135,15 @@ export default {
|
||||
this.$store.dispatch('notifications/index', filter);
|
||||
},
|
||||
redirectToInbox() {
|
||||
if (!this.conversationId) return;
|
||||
if (!this.notificationId) return;
|
||||
if (this.$route.name === 'inbox_view') return;
|
||||
this.$router.push({ name: 'inbox_view' });
|
||||
},
|
||||
loadMoreNotifications() {
|
||||
if (this.uiFlags.isAllNotificationsLoaded) return;
|
||||
this.$store.dispatch('notifications/index', {
|
||||
...this.inboxFilters,
|
||||
page: this.page + 1,
|
||||
status: this.status,
|
||||
type: this.type,
|
||||
sortOrder: this.sortOrder,
|
||||
});
|
||||
this.page += 1;
|
||||
},
|
||||
@@ -195,6 +209,47 @@ export default {
|
||||
this.type = type;
|
||||
this.sortOrder = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
|
||||
},
|
||||
openSnoozeNotificationModal(notificationItem) {
|
||||
this.notificationIdToSnooze = notificationItem.id;
|
||||
// There is an bus event to toggle snooze modal, because
|
||||
// We need to show the snooze notification items in the command bar Dynamically
|
||||
// If cmd + k is pressed from inbox view (In list screen) and search snooze notification items then we no need to show,
|
||||
// snooze notification because we don't get notification id.
|
||||
// Only show when the snooze button is clicked from the notification item card context menu
|
||||
if (!this.notificationId) bus.$emit(CMD_TOGGLE_SNOOZE_NOTIFICATION);
|
||||
this.$nextTick(() => {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'snooze_notification' });
|
||||
});
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
this.showCustomSnoozeModal = false;
|
||||
},
|
||||
snoozeNotification(snoozedUntil) {
|
||||
this.$store
|
||||
.dispatch('notifications/snooze', {
|
||||
id: this.notificationId || this.notificationIdToSnooze,
|
||||
snoozedUntil,
|
||||
})
|
||||
.then(() => {
|
||||
this.showAlert(this.$t('INBOX.ALERTS.SNOOZE'));
|
||||
});
|
||||
},
|
||||
onCmdSnoozeNotification(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomSnoozeModal = true;
|
||||
} else {
|
||||
const snoozedUntil = findSnoozeTime(snoozeType) || null;
|
||||
this.snoozeNotification(snoozedUntil);
|
||||
}
|
||||
},
|
||||
scheduleCustomSnooze(customSnoozeTime) {
|
||||
this.showCustomSnoozeModal = false;
|
||||
if (customSnoozeTime) {
|
||||
const snoozedUntil = getUnixTime(customSnoozeTime) || null;
|
||||
this.snoozeNotification(snoozedUntil);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,144 +1,25 @@
|
||||
<template>
|
||||
<section class="flex w-full h-full bg-white dark:bg-slate-900">
|
||||
<inbox-list
|
||||
v-show="showConversationList"
|
||||
:conversation-id="conversationId"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
/>
|
||||
<div
|
||||
v-if="showInboxMessageView"
|
||||
class="flex flex-col h-full"
|
||||
:class="isOnExpandedLayout ? 'w-full' : 'w-[calc(100%-360px)]'"
|
||||
>
|
||||
<inbox-item-header
|
||||
:total-length="totalNotifications"
|
||||
:current-index="activeNotificationIndex"
|
||||
:active-notification="activeNotification"
|
||||
@next="onClickNext"
|
||||
@prev="onClickPrev"
|
||||
/>
|
||||
<conversation-box
|
||||
class="h-[calc(100%-56px)]"
|
||||
is-inbox-view
|
||||
:inbox-id="inboxId"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!showInboxMessageView && !isOnExpandedLayout"
|
||||
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full flex items-center"
|
||||
>
|
||||
<span v-if="uiFlags.isFetching" class="spinner mt-4 mb-4" />
|
||||
<div v-else class="flex flex-col items-center gap-2">
|
||||
<fluent-icon
|
||||
icon="mail-inbox"
|
||||
size="40"
|
||||
class="text-slate-600 dark:text-slate-400"
|
||||
/>
|
||||
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
|
||||
{{ $t('INBOX.LIST.NOTE') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<router-view name="default" />
|
||||
<router-view name="detailView" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import InboxList from './InboxList.vue';
|
||||
import InboxItemHeader from './components/InboxItemHeader.vue';
|
||||
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
InboxList,
|
||||
InboxItemHeader,
|
||||
ConversationBox,
|
||||
},
|
||||
mixins: [uiSettingsMixin],
|
||||
props: {
|
||||
inboxId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
notifications: 'notifications/getNotifications',
|
||||
currentChat: 'getSelectedChat',
|
||||
allConversation: 'getAllConversations',
|
||||
uiFlags: 'notifications/getUIFlags',
|
||||
}),
|
||||
activeNotification() {
|
||||
return this.notifications.find(
|
||||
n => n.primary_actor.id === Number(this.conversationId)
|
||||
);
|
||||
},
|
||||
isInboxViewEnabled() {
|
||||
return this.$store.getters['accounts/isFeatureEnabledGlobally'](
|
||||
this.currentAccountId,
|
||||
FEATURE_FLAGS.INBOX_VIEW
|
||||
);
|
||||
},
|
||||
showConversationList() {
|
||||
return this.isOnExpandedLayout ? !this.conversationId : true;
|
||||
},
|
||||
isFetchingInitialData() {
|
||||
return this.uiFlags.isFetching && !this.notifications.length;
|
||||
},
|
||||
showInboxMessageView() {
|
||||
return (
|
||||
Boolean(this.conversationId) &&
|
||||
Boolean(this.currentChat.id) &&
|
||||
!this.isFetchingInitialData
|
||||
);
|
||||
},
|
||||
totalNotifications() {
|
||||
return this.notifications?.length ?? 0;
|
||||
},
|
||||
activeNotificationIndex() {
|
||||
const conversationId = Number(this.conversationId);
|
||||
const notificationIndex = this.notifications.findIndex(
|
||||
n => n.primary_actor.id === conversationId
|
||||
);
|
||||
return notificationIndex >= 0 ? notificationIndex + 1 : 0;
|
||||
},
|
||||
isOnExpandedLayout() {
|
||||
const {
|
||||
LAYOUT_TYPES: { CONDENSED },
|
||||
} = wootConstants;
|
||||
const { conversation_display_type: conversationDisplayType = CONDENSED } =
|
||||
this.uiSettings;
|
||||
return conversationDisplayType !== CONDENSED;
|
||||
},
|
||||
isContactPanelOpen() {
|
||||
if (this.currentChat.id) {
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } =
|
||||
this.uiSettings;
|
||||
return isContactSidebarOpen;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
conversationId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.fetchConversationById();
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Open inbox view if inbox view feature is enabled, else redirect to dashboard
|
||||
@@ -148,70 +29,6 @@ export default {
|
||||
name: 'home',
|
||||
});
|
||||
}
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
async fetchConversationById() {
|
||||
if (!this.conversationId) return;
|
||||
const chat = this.findConversation();
|
||||
if (!chat) {
|
||||
await this.$store.dispatch('getConversation', this.conversationId);
|
||||
}
|
||||
this.setActiveChat();
|
||||
},
|
||||
setActiveChat() {
|
||||
const selectedConversation = this.findConversation();
|
||||
if (!selectedConversation) return;
|
||||
this.$store
|
||||
.dispatch('setActiveChat', { data: selectedConversation })
|
||||
.then(() => {
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
});
|
||||
},
|
||||
findConversation() {
|
||||
const conversationId = Number(this.conversationId);
|
||||
return this.allConversation.find(c => c.id === conversationId);
|
||||
},
|
||||
navigateToConversation(activeIndex, direction) {
|
||||
const indexOffset = direction === 'next' ? 0 : -2;
|
||||
const targetNotification = this.notifications[activeIndex + indexOffset];
|
||||
if (targetNotification) {
|
||||
const {
|
||||
id,
|
||||
primary_actor_id: primaryActorId,
|
||||
primary_actor_type: primaryActorType,
|
||||
primary_actor: { id: conversationId, meta: { unreadCount } = {} },
|
||||
notification_type: notificationType,
|
||||
} = targetNotification;
|
||||
|
||||
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
|
||||
notificationType,
|
||||
});
|
||||
|
||||
this.$store.dispatch('notifications/read', {
|
||||
id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
unreadCount,
|
||||
});
|
||||
|
||||
this.$router.push({
|
||||
name: 'inbox_view_conversation',
|
||||
params: { conversation_id: conversationId },
|
||||
});
|
||||
}
|
||||
},
|
||||
onClickNext() {
|
||||
this.navigateToConversation(this.activeNotificationIndex, 'next');
|
||||
},
|
||||
onClickPrev() {
|
||||
this.navigateToConversation(this.activeNotificationIndex, 'prev');
|
||||
},
|
||||
onToggleContactPanel() {
|
||||
this.updateUISettings({
|
||||
is_contact_sidebar_open: !this.isContactPanelOpen,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -89,6 +89,7 @@ export default {
|
||||
return {
|
||||
isContextMenuOpen: false,
|
||||
contextMenuPosition: { x: null, y: null },
|
||||
activeNotificationId: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -96,7 +97,9 @@ export default {
|
||||
return this.notificationItem?.primary_actor;
|
||||
},
|
||||
isInboxCardActive() {
|
||||
return this.$route.params.conversation_id === this.primaryActor?.id;
|
||||
return (
|
||||
Number(this.activeNotificationId) === Number(this.notificationItem?.id)
|
||||
);
|
||||
},
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](
|
||||
@@ -109,6 +112,9 @@ export default {
|
||||
meta() {
|
||||
return this.primaryActor?.meta;
|
||||
},
|
||||
isNotSnoozed() {
|
||||
return !this.notificationItem?.snoozed_until;
|
||||
},
|
||||
assigneeMeta() {
|
||||
return this.meta?.assignee;
|
||||
},
|
||||
@@ -131,6 +137,12 @@ export default {
|
||||
},
|
||||
];
|
||||
|
||||
if (this.isNotSnoozed) {
|
||||
items.push({
|
||||
key: 'snooze',
|
||||
label: this.$t('INBOX.MENU_ITEM.SNOOZE'),
|
||||
});
|
||||
}
|
||||
if (!this.isUnread) {
|
||||
items.push({
|
||||
key: 'mark_as_unread',
|
||||
@@ -157,6 +169,14 @@ export default {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'$route.params.notification_id': {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.activeNotificationId = newVal;
|
||||
},
|
||||
},
|
||||
},
|
||||
unmounted() {
|
||||
this.closeContextMenu();
|
||||
},
|
||||
@@ -166,11 +186,11 @@ export default {
|
||||
id,
|
||||
primary_actor_id: primaryActorId,
|
||||
primary_actor_type: primaryActorType,
|
||||
primary_actor: { id: conversationId, inbox_id: inboxId },
|
||||
primary_actor: { inbox_id: inboxId },
|
||||
notification_type: notificationType,
|
||||
} = notification;
|
||||
|
||||
if (this.$route.params.conversation_id !== conversationId) {
|
||||
if (this.$route.params.notification_id !== id) {
|
||||
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
|
||||
notificationType,
|
||||
});
|
||||
@@ -184,13 +204,14 @@ export default {
|
||||
|
||||
this.$router.push({
|
||||
name: 'inbox_view_conversation',
|
||||
params: { inboxId, conversation_id: conversationId },
|
||||
params: { inboxId, notification_id: id },
|
||||
});
|
||||
}
|
||||
},
|
||||
closeContextMenu() {
|
||||
this.isContextMenuOpen = false;
|
||||
this.contextMenuPosition = { x: null, y: null };
|
||||
this.$emit('context-menu-close');
|
||||
},
|
||||
openContextMenu(e) {
|
||||
this.closeContextMenu();
|
||||
@@ -200,6 +221,7 @@ export default {
|
||||
y: e.pageY || e.clientY,
|
||||
};
|
||||
this.isContextMenuOpen = true;
|
||||
this.$emit('context-menu-open');
|
||||
},
|
||||
handleAction(key) {
|
||||
switch (key) {
|
||||
@@ -212,6 +234,9 @@ export default {
|
||||
case 'delete':
|
||||
this.$emit('delete-notification', this.notificationItem);
|
||||
break;
|
||||
case 'snooze':
|
||||
this.$emit('snooze-notification', this.notificationItem);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
<div
|
||||
class="flex gap-2 py-2 ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
|
||||
>
|
||||
<woot-button
|
||||
variant="clear link"
|
||||
class="flex md:hidden !pt-1 !pb-1 rounded-md ltr:pr-1 rtl:pl-1 !no-underline"
|
||||
size="medium"
|
||||
color-scheme="primary"
|
||||
icon="chevron-left"
|
||||
@click="onClickGoToInboxList"
|
||||
>
|
||||
{{ $t('INBOX.ACTION_HEADER.BACK') }}
|
||||
</woot-button>
|
||||
<pagination-button
|
||||
v-if="totalLength > 1"
|
||||
:total-length="totalLength"
|
||||
@@ -12,10 +22,12 @@
|
||||
<div v-else />
|
||||
<div class="flex items-center gap-2">
|
||||
<woot-button
|
||||
v-if="!isNotificationAlreadySnoozed"
|
||||
variant="hollow"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
icon="snooze"
|
||||
class="[&>span]:hidden md:[&>span]:inline-flex"
|
||||
@click="openSnoozeNotificationModal"
|
||||
>
|
||||
{{ $t('INBOX.ACTION_HEADER.SNOOZE') }}
|
||||
@@ -25,37 +37,23 @@
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
variant="hollow"
|
||||
class="[&>span]:hidden md:[&>span]:inline-flex"
|
||||
@click="deleteNotification"
|
||||
>
|
||||
{{ $t('INBOX.ACTION_HEADER.DELETE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
<woot-modal
|
||||
:show.sync="showCustomSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<custom-snooze-modal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="scheduleCustomSnooze"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import PaginationButton from './PaginationButton.vue';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PaginationButton,
|
||||
CustomSnoozeModal,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
@@ -72,55 +70,19 @@ export default {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showCustomSnoozeModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
meta: 'notifications/getMeta',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
|
||||
},
|
||||
destroyed() {
|
||||
bus.$off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
|
||||
isNotificationAlreadySnoozed() {
|
||||
return this.activeNotification?.snoozed_until;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openSnoozeNotificationModal() {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'snooze_notification' });
|
||||
},
|
||||
hideCustomSnoozeModal() {
|
||||
this.showCustomSnoozeModal = false;
|
||||
},
|
||||
snoozeNotification(snoozedUntil) {
|
||||
this.$store
|
||||
.dispatch('notifications/snooze', {
|
||||
id: this.activeNotification?.id,
|
||||
snoozedUntil,
|
||||
})
|
||||
.then(() => {
|
||||
this.showAlert(this.$t('INBOX.ALERTS.SNOOZE'));
|
||||
});
|
||||
},
|
||||
onCmdSnoozeNotification(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomSnoozeModal = true;
|
||||
} else {
|
||||
const snoozedUntil = findSnoozeTime(snoozeType) || null;
|
||||
this.snoozeNotification(snoozedUntil);
|
||||
}
|
||||
},
|
||||
scheduleCustomSnooze(customSnoozeTime) {
|
||||
this.showCustomSnoozeModal = false;
|
||||
if (customSnoozeTime) {
|
||||
const snoozedUntil = getUnixTime(customSnoozeTime) || null;
|
||||
this.snoozeNotification(snoozedUntil);
|
||||
}
|
||||
},
|
||||
deleteNotification() {
|
||||
this.$track(INBOX_EVENTS.DELETE_NOTIFICATION);
|
||||
this.$store
|
||||
@@ -140,6 +102,9 @@ export default {
|
||||
onClickPrev() {
|
||||
this.$emit('prev');
|
||||
},
|
||||
onClickGoToInboxList() {
|
||||
this.$router.push({ name: 'inbox_view' });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<inbox-option-menu
|
||||
v-if="showInboxOptionMenu"
|
||||
v-on-clickaway="openInboxOptionsMenu"
|
||||
class="absolute top-9"
|
||||
class="absolute top-9 ltr:right-0 ltr:md:right-[unset] rtl:left-0 rtl:md:left-[unset]"
|
||||
@option-click="onInboxOptionMenuClick"
|
||||
/>
|
||||
</div>
|
||||
@@ -69,12 +69,29 @@ export default {
|
||||
InboxDisplayMenu,
|
||||
},
|
||||
mixins: [clickaway, alertMixin],
|
||||
props: {
|
||||
isContextMenuOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showInboxDisplayMenu: false,
|
||||
showInboxOptionMenu: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
isContextMenuOpen: {
|
||||
handler(val) {
|
||||
if (val) {
|
||||
this.showInboxDisplayMenu = false;
|
||||
this.showInboxOptionMenu = false;
|
||||
}
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
markAllRead() {
|
||||
this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
|
||||
@@ -100,21 +117,19 @@ export default {
|
||||
},
|
||||
onInboxOptionMenuClick(key) {
|
||||
this.showInboxOptionMenu = false;
|
||||
if (key === 'mark_all_read') {
|
||||
this.markAllRead();
|
||||
}
|
||||
if (key === 'delete_all') {
|
||||
this.deleteAll();
|
||||
}
|
||||
if (key === 'delete_all_read') {
|
||||
this.deleteAllRead();
|
||||
}
|
||||
const actions = {
|
||||
mark_all_read: () => this.markAllRead(),
|
||||
delete_all: () => this.deleteAll(),
|
||||
delete_all_read: () => this.deleteAllRead(),
|
||||
};
|
||||
const action = actions[key];
|
||||
if (action) action();
|
||||
this.$emit('redirect');
|
||||
},
|
||||
onFilterChange(option) {
|
||||
this.$emit('filter', option);
|
||||
this.showInboxDisplayMenu = false;
|
||||
if (this.$route.name === 'inbox_view') return;
|
||||
this.$router.push({ name: 'inbox_view' });
|
||||
this.$emit('redirect');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 whitespace-nowrap">
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
<span class="text-sm font-medium text-gray-600 tabular-nums">
|
||||
{{ totalLength <= 1 ? '1' : currentIndex }}
|
||||
</span>
|
||||
<span
|
||||
@@ -28,7 +28,7 @@
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<span v-if="totalLength > 1" class="text-sm text-slate-400">
|
||||
<span v-if="totalLength > 1" class="text-sm text-slate-400 tabular-nums">
|
||||
{{ totalLength }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
const InboxView = () => import('./InboxView.vue');
|
||||
const InboxListView = () => import('./InboxList.vue');
|
||||
const InboxDetailView = () => import('./InboxDetail.vue');
|
||||
const InboxEmptyStateView = () => import('./InboxEmptyState.vue');
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/inbox-view'),
|
||||
component: InboxView,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'inbox_view',
|
||||
components: {
|
||||
default: InboxListView,
|
||||
detailView: InboxEmptyStateView,
|
||||
},
|
||||
roles: ['administrator', 'agent'],
|
||||
},
|
||||
{
|
||||
path: ':notification_id',
|
||||
name: 'inbox_view_conversation',
|
||||
components: {
|
||||
default: InboxListView,
|
||||
detailView: InboxDetailView,
|
||||
},
|
||||
roles: ['administrator', 'agent'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildConversationList,
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
isOnFoldersView,
|
||||
} from './helpers/actionHelpers';
|
||||
import messageReadActions from './actions/messageReadActions';
|
||||
import messageTranslateActions from './actions/messageTranslateActions';
|
||||
@@ -321,12 +322,12 @@ const actions = {
|
||||
inbox_id: inboxId,
|
||||
meta: { sender },
|
||||
} = conversation;
|
||||
|
||||
const hasAppliedFilters = !!appliedFilters.length;
|
||||
const isMatchingInboxFilter =
|
||||
!currentInbox || Number(currentInbox) === inboxId;
|
||||
if (
|
||||
!hasAppliedFilters &&
|
||||
!isOnFoldersView(rootState) &&
|
||||
!isOnMentionsView(rootState) &&
|
||||
!isOnUnattendedView(rootState) &&
|
||||
isMatchingInboxFilter
|
||||
|
||||
@@ -30,6 +30,14 @@ export const isOnUnattendedView = ({ route: { name: routeName } }) => {
|
||||
return UNATTENDED_ROUTES.includes(routeName);
|
||||
};
|
||||
|
||||
export const isOnFoldersView = ({ route: { name: routeName } }) => {
|
||||
const FOLDER_ROUTES = [
|
||||
'folder_conversations',
|
||||
'conversations_through_folders',
|
||||
];
|
||||
return FOLDER_ROUTES.includes(routeName);
|
||||
};
|
||||
|
||||
export const buildConversationList = (
|
||||
context,
|
||||
requestPayload,
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
import { isOnMentionsView } from '../actionHelpers';
|
||||
import { isOnMentionsView, isOnFoldersView } from '../actionHelpers';
|
||||
|
||||
describe('#isOnMentionsView', () => {
|
||||
it('return valid responses when passing the state', () => {
|
||||
@@ -10,3 +10,17 @@ describe('#isOnMentionsView', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isOnFoldersView', () => {
|
||||
it('return valid responses when passing the state', () => {
|
||||
expect(isOnFoldersView({ route: { name: 'folder_conversations' } })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isOnFoldersView({ route: { name: 'conversations_through_folders' } })
|
||||
).toBe(true);
|
||||
expect(isOnFoldersView({ route: { name: 'conversation_messages' } })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { applyInboxPageFilters, sortComparator } from './helpers';
|
||||
import { sortComparator } from './helpers';
|
||||
|
||||
export const getters = {
|
||||
getNotifications($state) {
|
||||
@@ -6,14 +6,14 @@ export const getters = {
|
||||
},
|
||||
getFilteredNotifications: $state => filters => {
|
||||
const sortOrder = filters.sortOrder === 'desc' ? 'newest' : 'oldest';
|
||||
const filteredNotifications = Object.values($state.records).filter(
|
||||
notification => applyInboxPageFilters(notification, filters)
|
||||
);
|
||||
const sortedNotifications = filteredNotifications.sort((a, b) =>
|
||||
sortComparator(a, b, sortOrder)
|
||||
const sortedNotifications = Object.values($state.records).sort((n1, n2) =>
|
||||
sortComparator(n1, n2, sortOrder)
|
||||
);
|
||||
return sortedNotifications;
|
||||
},
|
||||
getActiveNotificationById: $state => id => {
|
||||
return $state.records[id] || {};
|
||||
},
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
|
||||
@@ -1,31 +1,3 @@
|
||||
export const filterByStatus = (snoozedUntil, filterStatus) =>
|
||||
filterStatus === 'snoozed' ? !!snoozedUntil : !snoozedUntil;
|
||||
|
||||
export const filterByType = (readAt, filterType) =>
|
||||
filterType === 'read' ? !!readAt : !readAt;
|
||||
|
||||
export const filterByTypeAndStatus = (
|
||||
readAt,
|
||||
snoozedUntil,
|
||||
filterType,
|
||||
filterStatus
|
||||
) => {
|
||||
const shouldFilterByStatus = filterByStatus(snoozedUntil, filterStatus);
|
||||
const shouldFilterByType = filterByType(readAt, filterType);
|
||||
return shouldFilterByStatus && shouldFilterByType;
|
||||
};
|
||||
|
||||
export const applyInboxPageFilters = (notification, filters) => {
|
||||
const { status, type } = filters;
|
||||
const { read_at: readAt, snoozed_until: snoozedUntil } = notification;
|
||||
|
||||
if (status && type)
|
||||
return filterByTypeAndStatus(readAt, snoozedUntil, type, status);
|
||||
if (status && !type) return filterByStatus(snoozedUntil, status);
|
||||
if (!status && type) return filterByType(readAt, type);
|
||||
return true;
|
||||
};
|
||||
|
||||
const INBOX_SORT_OPTIONS = {
|
||||
newest: 'desc',
|
||||
oldest: 'asc',
|
||||
|
||||
@@ -181,6 +181,26 @@ describe('#actions', () => {
|
||||
expect(dispatch.mock.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('doesnot send mutation if the view is conversation folders', () => {
|
||||
const conversation = {
|
||||
id: 1,
|
||||
messages: [],
|
||||
meta: { sender: { id: 1, name: 'john-doe' } },
|
||||
inbox_id: 1,
|
||||
};
|
||||
actions.addConversation(
|
||||
{
|
||||
commit,
|
||||
rootState: { route: { name: 'folder_conversations' } },
|
||||
dispatch,
|
||||
state: { currentInbox: 1, appliedFilters: [{ id: 'random-filter' }] },
|
||||
},
|
||||
conversation
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
expect(dispatch.mock.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends correct mutations', () => {
|
||||
const conversation = {
|
||||
id: 1,
|
||||
|
||||
@@ -34,6 +34,8 @@ describe('#getters', () => {
|
||||
sortOrder: 'desc',
|
||||
};
|
||||
expect(getters.getFilteredNotifications(state)(filters)).toEqual([
|
||||
{ id: 1, read_at: '2024-02-07T11:42:39.988Z', snoozed_until: null },
|
||||
{ id: 2, read_at: null, snoozed_until: null },
|
||||
{
|
||||
id: 3,
|
||||
read_at: '2024-02-07T11:42:39.988Z',
|
||||
@@ -42,6 +44,16 @@ describe('#getters', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('getActiveNotificationById', () => {
|
||||
const state = {
|
||||
records: {
|
||||
1: { id: 1 },
|
||||
},
|
||||
};
|
||||
expect(getters.getActiveNotificationById(state)(1)).toEqual({ id: 1 });
|
||||
expect(getters.getActiveNotificationById(state)(2)).toEqual({});
|
||||
});
|
||||
|
||||
it('getUIFlags', () => {
|
||||
const state = {
|
||||
uiFlags: {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
filterByStatus,
|
||||
filterByType,
|
||||
filterByTypeAndStatus,
|
||||
applyInboxPageFilters,
|
||||
sortComparator,
|
||||
} from '../../notifications/helpers';
|
||||
import { sortComparator } from '../../notifications/helpers';
|
||||
|
||||
const notifications = [
|
||||
{
|
||||
@@ -45,126 +39,6 @@ const notifications = [
|
||||
},
|
||||
];
|
||||
|
||||
describe('#filterByStatus', () => {
|
||||
it('returns the notifications with snoozed status', () => {
|
||||
const filters = { status: 'snoozed' };
|
||||
notifications.forEach(notification => {
|
||||
expect(
|
||||
filterByStatus(notification.snoozed_until, filters.status)
|
||||
).toEqual(notification.snoozed_until !== null);
|
||||
});
|
||||
});
|
||||
it('returns true if the notification is snoozed', () => {
|
||||
const filters = { status: 'snoozed' };
|
||||
expect(
|
||||
filterByStatus(notifications[3].snoozed_until, filters.status)
|
||||
).toEqual(true);
|
||||
});
|
||||
it('returns false if the notification is not snoozed', () => {
|
||||
const filters = { status: 'snoozed' };
|
||||
expect(
|
||||
filterByStatus(notifications[2].snoozed_until, filters.status)
|
||||
).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#filterByType', () => {
|
||||
it('returns the notifications with read status', () => {
|
||||
const filters = { type: 'read' };
|
||||
notifications.forEach(notification => {
|
||||
expect(filterByType(notification.read_at, filters.type)).toEqual(
|
||||
notification.read_at !== null
|
||||
);
|
||||
});
|
||||
});
|
||||
it('returns true if the notification is read', () => {
|
||||
const filters = { type: 'read' };
|
||||
expect(filterByType(notifications[0].read_at, filters.type)).toEqual(true);
|
||||
});
|
||||
it('returns false if the notification is not read', () => {
|
||||
const filters = { type: 'read' };
|
||||
expect(filterByType(notifications[1].read_at, filters.type)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#filterByTypeAndStatus', () => {
|
||||
it('returns the notifications with type and status', () => {
|
||||
const filters = { type: 'read', status: 'snoozed' };
|
||||
notifications.forEach(notification => {
|
||||
expect(
|
||||
filterByTypeAndStatus(
|
||||
notification.read_at,
|
||||
notification.snoozed_until,
|
||||
filters.type,
|
||||
filters.status
|
||||
)
|
||||
).toEqual(
|
||||
notification.read_at !== null && notification.snoozed_until !== null
|
||||
);
|
||||
});
|
||||
});
|
||||
it('returns true if the notification is read and snoozed', () => {
|
||||
const filters = { type: 'read', status: 'snoozed' };
|
||||
expect(
|
||||
filterByTypeAndStatus(
|
||||
notifications[4].read_at,
|
||||
notifications[4].snoozed_until,
|
||||
filters.type,
|
||||
filters.status
|
||||
)
|
||||
).toEqual(true);
|
||||
});
|
||||
it('returns false if the notification is not read and snoozed', () => {
|
||||
const filters = { type: 'read', status: 'snoozed' };
|
||||
expect(
|
||||
filterByTypeAndStatus(
|
||||
notifications[3].read_at,
|
||||
notifications[3].snoozed_until,
|
||||
filters.type,
|
||||
filters.status
|
||||
)
|
||||
).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#applyInboxPageFilters', () => {
|
||||
it('returns the notifications with type and status', () => {
|
||||
const filters = { type: 'read', status: 'snoozed' };
|
||||
notifications.forEach(notification => {
|
||||
expect(applyInboxPageFilters(notification, filters)).toEqual(
|
||||
filterByTypeAndStatus(
|
||||
notification.read_at,
|
||||
notification.snoozed_until,
|
||||
filters.type,
|
||||
filters.status
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
it('returns the notifications with type only', () => {
|
||||
const filters = { type: 'read', status: null };
|
||||
notifications.forEach(notification => {
|
||||
expect(applyInboxPageFilters(notification, filters)).toEqual(
|
||||
filterByType(notification.read_at, filters.type)
|
||||
);
|
||||
});
|
||||
});
|
||||
it('returns the notifications with status only', () => {
|
||||
const filters = { type: null, status: 'snoozed' };
|
||||
notifications.forEach(notification => {
|
||||
expect(applyInboxPageFilters(notification, filters)).toEqual(
|
||||
filterByStatus(notification.snoozed_until, filters.status)
|
||||
);
|
||||
});
|
||||
});
|
||||
it('returns true if there are no filters', () => {
|
||||
const filters = { type: null, status: null };
|
||||
notifications.forEach(notification => {
|
||||
expect(applyInboxPageFilters(notification, filters)).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sortComparator', () => {
|
||||
it('returns the notifications sorted by newest', () => {
|
||||
const sortOrder = 'newest';
|
||||
|
||||
@@ -6,24 +6,29 @@
|
||||
:has-error="hasError"
|
||||
:error-message="errorMessage"
|
||||
>
|
||||
<template #rightOfLabel>
|
||||
<slot />
|
||||
</template>
|
||||
<input
|
||||
:id="name"
|
||||
:name="name"
|
||||
:type="type"
|
||||
autocomplete="off"
|
||||
:tabindex="tabindex"
|
||||
:required="required"
|
||||
:placeholder="placeholder"
|
||||
:data-testid="dataTestid"
|
||||
:value="value"
|
||||
:class="{
|
||||
'focus:ring-red-600 ring-red-600': hasError,
|
||||
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
|
||||
'focus:outline-red-600 outline-red-600 dark:focus:outline-red-600 dark:outline-red-600':
|
||||
hasError,
|
||||
'outline-slate-200 dark:outline-slate-600 dark:focus:outline-woot-500 focus:outline-woot-500':
|
||||
!hasError,
|
||||
'px-3 py-3': spacing === 'base',
|
||||
'px-3 py-2 mb-0': spacing === 'compact',
|
||||
'pl-9': icon,
|
||||
}"
|
||||
class="block w-full border-none rounded-xl shadow-sm appearance-none outline outline-1 outline-slate-200 dark:outline-slate-800 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
|
||||
class="block w-full border-none shadow-sm appearance-none rounded-xl outline outline-1 focus:outline-2 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 sm:text-sm sm:leading-6 dark:bg-slate-800"
|
||||
@input="onInput"
|
||||
@blur="$emit('blur')"
|
||||
/>
|
||||
@@ -48,6 +53,10 @@ export default {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
tabindex: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<with-label
|
||||
:label="label"
|
||||
:name="name"
|
||||
:has-error="hasError"
|
||||
:error-message="errorMessage"
|
||||
>
|
||||
<textarea
|
||||
:id="name"
|
||||
:name="name"
|
||||
autocomplete="off"
|
||||
:required="required"
|
||||
:placeholder="placeholder"
|
||||
:data-testid="dataTestid"
|
||||
:value="value"
|
||||
:rows="rows"
|
||||
:class="{
|
||||
'focus:outline-red-600 outline-red-600': hasError,
|
||||
'dark:outline-slate-600 dark:focus:outline-woot-500 outline-slate-200 focus:outline-woot-500':
|
||||
!hasError,
|
||||
'resize-none': !allowResize,
|
||||
}"
|
||||
class="block w-full p-3 border-none rounded-xl shadow-sm appearance-none outline outline-1 focus:outline focus:outline-2 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 bg-white dark:bg-slate-800"
|
||||
@input="onInput"
|
||||
@blur="$emit('blur')"
|
||||
/>
|
||||
</with-label>
|
||||
</template>
|
||||
<script>
|
||||
import WithLabel from './WithLabel.vue';
|
||||
export default {
|
||||
components: {
|
||||
WithLabel,
|
||||
},
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
allowResize: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dataTestid: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onInput(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -9,6 +9,7 @@
|
||||
<slot name="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
<slot name="rightOfLabel" />
|
||||
</label>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center relative w-full">
|
||||
|
||||
@@ -48,3 +48,13 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const isOnOnboardingView = route => {
|
||||
const { name = '' } = route || {};
|
||||
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return name.includes('onboarding_');
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { validateRouteAccess } from '../RouteHelper';
|
||||
import { validateRouteAccess, isOnOnboardingView } from '../RouteHelper';
|
||||
import { clearBrowserSessionCookies } from 'dashboard/store/utils/api';
|
||||
import { replaceRouteWithReload } from '../CommonHelper';
|
||||
import Cookies from 'js-cookie';
|
||||
@@ -67,3 +67,24 @@ describe('#validateRouteAccess', () => {
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnOnboardingView', () => {
|
||||
test('returns true for a route with onboarding name', () => {
|
||||
const route = { name: 'onboarding_welcome' };
|
||||
expect(isOnOnboardingView(route)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for a route without onboarding name', () => {
|
||||
const route = { name: 'home' };
|
||||
expect(isOnOnboardingView(route)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for a route with null name', () => {
|
||||
const route = { name: null };
|
||||
expect(isOnOnboardingView(route)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for an undefined route object', () => {
|
||||
expect(isOnOnboardingView()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
name="email_address"
|
||||
type="text"
|
||||
data-testid="email_input"
|
||||
:tabindex="1"
|
||||
required
|
||||
:label="$t('LOGIN.EMAIL.LABEL')"
|
||||
:placeholder="$t('LOGIN.EMAIL.PLACEHOLDER')"
|
||||
@@ -58,19 +59,25 @@
|
||||
name="password"
|
||||
data-testid="password_input"
|
||||
required
|
||||
:tabindex="2"
|
||||
:label="$t('LOGIN.PASSWORD.LABEL')"
|
||||
:placeholder="$t('LOGIN.PASSWORD.PLACEHOLDER')"
|
||||
:has-error="$v.credentials.password.$error"
|
||||
@input="$v.credentials.password.$touch"
|
||||
>
|
||||
<p v-if="!globalConfig.disableUserProfileUpdate">
|
||||
<router-link to="auth/reset/password" class="text-link">
|
||||
<router-link
|
||||
to="auth/reset/password"
|
||||
class="text-link text-sm"
|
||||
tabindex="4"
|
||||
>
|
||||
{{ $t('LOGIN.FORGOT_PASSWORD') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</form-input>
|
||||
<submit-button
|
||||
:disabled="loginApi.showLoading"
|
||||
:tabindex="3"
|
||||
:button-text="$t('LOGIN.SUBMIT')"
|
||||
:loading="loginApi.showLoading"
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
class Account::ContactsExportJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account_id, column_names)
|
||||
def perform(account_id, column_names, email_to)
|
||||
account = Account.find(account_id)
|
||||
headers = valid_headers(column_names)
|
||||
generate_csv(account, headers)
|
||||
file_url = account_contact_export_url(account)
|
||||
|
||||
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url)&.deliver_later
|
||||
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url, email_to)&.deliver_later
|
||||
end
|
||||
|
||||
def generate_csv(account, headers)
|
||||
|
||||
@@ -7,6 +7,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
return unless should_fetch_email?(channel)
|
||||
|
||||
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
|
||||
|
||||
with_lock(key, 5.minutes) do
|
||||
process_email_for_channel(channel)
|
||||
end
|
||||
@@ -28,128 +29,16 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
end
|
||||
|
||||
def process_email_for_channel(channel)
|
||||
if channel.microsoft?
|
||||
fetch_mail_for_ms_provider(channel)
|
||||
else
|
||||
fetch_mail_for_channel(channel)
|
||||
end
|
||||
# clearing old failures like timeouts since the mail is now successfully processed
|
||||
channel.reauthorized!
|
||||
end
|
||||
|
||||
def fetch_mail_for_channel(channel)
|
||||
imap_client = build_imap_client(channel, channel.imap_password, 'PLAIN')
|
||||
|
||||
message_ids_with_seq = fetch_message_ids_with_sequence(imap_client, channel)
|
||||
message_ids_with_seq.each do |message_id_with_seq|
|
||||
process_message_id(channel, imap_client, message_id_with_seq)
|
||||
end
|
||||
end
|
||||
|
||||
def process_message_id(channel, imap_client, message_id_with_seq)
|
||||
seq_no, message_id = message_id_with_seq
|
||||
|
||||
return if email_already_present?(channel, message_id)
|
||||
|
||||
if message_id.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Empty message id for #{channel.email} with seq no. <#{seq_no}>."
|
||||
return
|
||||
end
|
||||
|
||||
# Fetch the original mail content using the sequence no
|
||||
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
|
||||
|
||||
if mail_str.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
|
||||
return
|
||||
end
|
||||
|
||||
inbound_mail = build_mail_from_string(mail_str)
|
||||
mail_info_logger(channel, inbound_mail, seq_no)
|
||||
process_mail(inbound_mail, channel)
|
||||
end
|
||||
|
||||
# Sends a FETCH command to retrieve data associated with a message in the mailbox.
|
||||
# You can send batches of message sequence number in `.fetch` method.
|
||||
def fetch_message_ids_with_sequence(imap_client, channel)
|
||||
seq_nums = fetch_available_mail_sequence_numbers(imap_client)
|
||||
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
|
||||
|
||||
message_ids_with_seq = []
|
||||
seq_nums.each_slice(10).each do |batch|
|
||||
# Fetch only message-id only without mail body or contents.
|
||||
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
|
||||
|
||||
# .fetch returns an array of Net::IMAP::FetchData or nil
|
||||
# (instead of an empty array) if there is no matching message.
|
||||
# Check
|
||||
if batch_message_ids.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
|
||||
next
|
||||
end
|
||||
|
||||
batch_message_ids.each do |data|
|
||||
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
|
||||
message_ids_with_seq.push([data.seqno, message_id])
|
||||
end
|
||||
end
|
||||
|
||||
message_ids_with_seq
|
||||
end
|
||||
|
||||
# Sends a SEARCH command to search the mailbox for messages that were
|
||||
# created between yesterday and today and returns message sequence numbers.
|
||||
# Return <message set>
|
||||
def fetch_available_mail_sequence_numbers(imap_client)
|
||||
imap_client.search(['SINCE', yesterday])
|
||||
end
|
||||
|
||||
def fetch_mail_for_ms_provider(channel)
|
||||
return if channel.provider_config['access_token'].blank?
|
||||
|
||||
access_token = valid_access_token channel
|
||||
|
||||
return unless access_token
|
||||
|
||||
imap_client = build_imap_client(channel, access_token, 'XOAUTH2')
|
||||
process_mails(imap_client, channel)
|
||||
end
|
||||
|
||||
def process_mails(imap_client, channel)
|
||||
fetch_available_mail_sequence_numbers(imap_client).each do |seq_no|
|
||||
inbound_mail = Mail.read_from_string imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
|
||||
|
||||
mail_info_logger(channel, inbound_mail, seq_no)
|
||||
|
||||
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
|
||||
|
||||
inbound_emails = if channel.microsoft?
|
||||
Imap::MicrosoftFetchEmailService.new(channel: channel).perform
|
||||
else
|
||||
Imap::FetchEmailService.new(channel: channel).perform
|
||||
end
|
||||
inbound_emails.map do |inbound_mail|
|
||||
process_mail(inbound_mail, channel)
|
||||
end
|
||||
end
|
||||
|
||||
def mail_info_logger(channel, inbound_mail, uid)
|
||||
return if Rails.env.test?
|
||||
|
||||
Rails.logger.info("
|
||||
#{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{uid}")
|
||||
end
|
||||
|
||||
def build_imap_client(channel, access_token, auth_method)
|
||||
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
|
||||
imap.authenticate(auth_method, channel.imap_login, access_token)
|
||||
imap.select('INBOX')
|
||||
imap
|
||||
end
|
||||
|
||||
def email_already_present?(channel, message_id)
|
||||
channel.inbox.messages.find_by(source_id: message_id).present?
|
||||
end
|
||||
|
||||
def build_mail_from_string(raw_email_content)
|
||||
Mail.read_from_string(raw_email_content)
|
||||
end
|
||||
|
||||
def process_mail(inbound_mail, channel)
|
||||
Imap::ImapMailbox.new.process(inbound_mail, channel)
|
||||
rescue StandardError => e
|
||||
@@ -157,13 +46,4 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
Rails.logger.error("
|
||||
#{channel.provider} Email dropped: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}")
|
||||
end
|
||||
|
||||
# Making sure the access token is valid for microsoft provider
|
||||
def valid_access_token(channel)
|
||||
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
|
||||
end
|
||||
|
||||
def yesterday
|
||||
(Time.zone.today - 1).strftime('%d-%b-%Y')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,9 +2,24 @@ class Notification::ReopenSnoozedNotificationsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
Notification.where(snoozed_until: 3.days.ago..Time.current)
|
||||
.update_all(snoozed_until: nil, updated_at: Time.current, last_activity_at: Time.current)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
Notification.where(snoozed_until: 3.days.ago..Time.current).find_in_batches(batch_size: 100) do |notifications_batch|
|
||||
notifications_batch.each do |notification|
|
||||
update_notification(notification)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_notification(notification)
|
||||
updated_meta = (notification.meta || {}).merge('last_snoozed_at' => notification.snoozed_until)
|
||||
|
||||
notification.update!(
|
||||
snoozed_until: nil,
|
||||
updated_at: Time.current,
|
||||
last_activity_at: Time.current,
|
||||
meta: updated_meta,
|
||||
read_at: nil
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,10 +37,24 @@ class ApplicationMailbox < ActionMailbox::Base
|
||||
# checks if follow this pattern send it to reply_mailbox
|
||||
# reply+<conversation-uuid>@<mailer-domain.com>
|
||||
def reply_uuid_mail?(inbound_mail)
|
||||
validate_to_address(inbound_mail)
|
||||
|
||||
inbound_mail.mail.to&.any? do |email|
|
||||
conversation_uuid = email.split('@')[0]
|
||||
conversation_uuid.match?(REPLY_EMAIL_UUID_PATTERN)
|
||||
end
|
||||
end
|
||||
|
||||
# if mail.to returns a string, then it is a malformed `to` header
|
||||
# valid `to` header will be of type Mail::AddressContainer
|
||||
# validate if the to address is of type string
|
||||
def validate_to_address(inbound_mail)
|
||||
to_address_class = inbound_mail.mail.to&.class
|
||||
|
||||
return if to_address_class == Mail::AddressContainer
|
||||
|
||||
Rails.logger.error "Email to address header is malformed `#{inbound_mail.mail.to}`"
|
||||
raise StandardError, "Invalid email to address header #{inbound_mail.mail.to}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ module MailboxHelper
|
||||
private
|
||||
|
||||
def create_message
|
||||
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
|
||||
return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
|
||||
|
||||
@message = @conversation.messages.create!(
|
||||
@@ -36,6 +37,7 @@ module MailboxHelper
|
||||
end
|
||||
|
||||
def process_regular_attachments(attachments)
|
||||
Rails.logger.info "[MailboxHelper] Processing regular attachments for message with ID: #{processed_mail.message_id}"
|
||||
attachments.each do |mail_attachment|
|
||||
attachment = @message.attachments.new(
|
||||
account_id: @conversation.account_id,
|
||||
@@ -46,6 +48,8 @@ module MailboxHelper
|
||||
end
|
||||
|
||||
def process_inline_attachments(attachments)
|
||||
Rails.logger.info "[MailboxHelper] Processing inline attachments for message with ID: #{processed_mail.message_id}"
|
||||
|
||||
# create an instance variable here, the `embed_inline_image_source`
|
||||
# updates them directly. And then the value is eventaully used to update the message content
|
||||
@html_content = processed_mail.serialized_data[:html_content][:full]
|
||||
@@ -98,7 +102,9 @@ module MailboxHelper
|
||||
}
|
||||
}
|
||||
).perform
|
||||
|
||||
@contact = @contact_inbox.contact
|
||||
Rails.logger.info "[MailboxHelper] Contact created with ID: #{@contact.id} for inbox with ID: #{@inbox.id}"
|
||||
end
|
||||
|
||||
def notification_email_from_chatwoot?
|
||||
|
||||
@@ -60,12 +60,13 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
|
||||
send_mail_with_liquid(to: admin_emails, subject: subject) and return
|
||||
end
|
||||
|
||||
def contact_export_complete(file_url)
|
||||
def contact_export_complete(file_url, email_to)
|
||||
return unless smtp_config_set_or_development?
|
||||
|
||||
@action_url = file_url
|
||||
subject = "Your contact's export file is available to download."
|
||||
send_mail_with_liquid(to: admin_emails, subject: subject) and return
|
||||
|
||||
send_mail_with_liquid(to: email_to, subject: subject) and return
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -111,11 +111,12 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
def inbound_email_domain
|
||||
domain || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', false)
|
||||
domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN',
|
||||
false)
|
||||
end
|
||||
|
||||
def support_email
|
||||
super || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
|
||||
super.presence || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
|
||||
end
|
||||
|
||||
def usage_limits
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# last_activity_at :datetime
|
||||
# meta :jsonb
|
||||
# notification_type :integer not null
|
||||
# primary_actor_type :string not null
|
||||
# read_at :datetime
|
||||
@@ -63,24 +64,17 @@ class Notification < ApplicationRecord
|
||||
created_at: created_at.to_i,
|
||||
last_activity_at: last_activity_at.to_i,
|
||||
snoozed_until: snoozed_until,
|
||||
meta: meta,
|
||||
account_id: account_id
|
||||
|
||||
}
|
||||
if primary_actor.present?
|
||||
payload[:primary_actor] = primary_actor_data
|
||||
payload[:primary_actor] = primary_actor&.push_event_data
|
||||
payload[:push_message_title] = push_message_title
|
||||
end
|
||||
payload
|
||||
end
|
||||
|
||||
def primary_actor_data
|
||||
{
|
||||
id: primary_actor.push_event_data[:id],
|
||||
meta: primary_actor.push_event_data[:meta],
|
||||
inbox_id: primary_actor.push_event_data[:inbox_id]
|
||||
}
|
||||
end
|
||||
|
||||
def fcm_push_data
|
||||
{
|
||||
id: id,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class ReportPolicy < ApplicationPolicy
|
||||
def view?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
class Reports::TimeFormatPresenter
|
||||
include ActionView::Helpers::TextHelper
|
||||
|
||||
attr_reader :seconds
|
||||
|
||||
def initialize(seconds)
|
||||
@seconds = seconds.to_i
|
||||
end
|
||||
|
||||
def format
|
||||
return '--' if seconds.nil? || seconds.zero?
|
||||
|
||||
days, remainder = seconds.divmod(86_400)
|
||||
hours, remainder = remainder.divmod(3600)
|
||||
minutes, seconds = remainder.divmod(60)
|
||||
|
||||
format_components(days: days, hours: hours, minutes: minutes, seconds: seconds)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_components(components)
|
||||
formatted_components = components.filter_map do |unit, value|
|
||||
next if value.zero?
|
||||
|
||||
I18n.t("time_units.#{unit}", count: value)
|
||||
end
|
||||
|
||||
return I18n.t('time_units.seconds', count: 0) if formatted_components.empty?
|
||||
|
||||
formatted_components.first(2).join(' ')
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ class ActionService
|
||||
|
||||
def initialize(conversation)
|
||||
@conversation = conversation.reload
|
||||
@account = @conversation.account
|
||||
end
|
||||
|
||||
def mute_conversation(_params)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
require 'net/imap'
|
||||
|
||||
class Imap::BaseFetchEmailService
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
def perform
|
||||
# Override this method
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authentication_type
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def imap_password
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def imap_client
|
||||
@imap_client ||= build_imap_client
|
||||
end
|
||||
|
||||
def mail_info_logger(inbound_mail, seq_no)
|
||||
return if Rails.env.test?
|
||||
|
||||
Rails.logger.info("
|
||||
#{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{seq_no}")
|
||||
end
|
||||
|
||||
def email_already_present?(channel, message_id)
|
||||
channel.inbox.messages.find_by(source_id: message_id).present?
|
||||
end
|
||||
|
||||
def fetch_mail_for_channel
|
||||
message_ids_with_seq = fetch_message_ids_with_sequence
|
||||
message_ids_with_seq.filter_map do |message_id_with_seq|
|
||||
process_message_id(message_id_with_seq)
|
||||
end
|
||||
end
|
||||
|
||||
def process_message_id(message_id_with_seq)
|
||||
seq_no, message_id = message_id_with_seq
|
||||
|
||||
if message_id.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Empty message id for #{channel.email} with seq no. <#{seq_no}>."
|
||||
return
|
||||
end
|
||||
|
||||
return if email_already_present?(channel, message_id)
|
||||
|
||||
# Fetch the original mail content using the sequence no
|
||||
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
|
||||
|
||||
if mail_str.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
|
||||
return
|
||||
end
|
||||
|
||||
inbound_mail = build_mail_from_string(mail_str)
|
||||
mail_info_logger(inbound_mail, seq_no)
|
||||
inbound_mail
|
||||
end
|
||||
|
||||
# Sends a FETCH command to retrieve data associated with a message in the mailbox.
|
||||
# You can send batches of message sequence number in `.fetch` method.
|
||||
def fetch_message_ids_with_sequence
|
||||
seq_nums = fetch_available_mail_sequence_numbers
|
||||
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
|
||||
|
||||
message_ids_with_seq = []
|
||||
seq_nums.each_slice(10).each do |batch|
|
||||
# Fetch only message-id only without mail body or contents.
|
||||
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
|
||||
|
||||
# .fetch returns an array of Net::IMAP::FetchData or nil
|
||||
# (instead of an empty array) if there is no matching message.
|
||||
# Check
|
||||
if batch_message_ids.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
|
||||
next
|
||||
end
|
||||
|
||||
batch_message_ids.each do |data|
|
||||
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
|
||||
message_ids_with_seq.push([data.seqno, message_id])
|
||||
end
|
||||
end
|
||||
|
||||
message_ids_with_seq
|
||||
end
|
||||
|
||||
# Sends a SEARCH command to search the mailbox for messages that were
|
||||
# created between yesterday and today and returns message sequence numbers.
|
||||
# Return <message set>
|
||||
def fetch_available_mail_sequence_numbers
|
||||
imap_client.search(['SINCE', yesterday])
|
||||
end
|
||||
|
||||
def build_imap_client
|
||||
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
|
||||
imap.authenticate(authentication_type, channel.imap_login, imap_password)
|
||||
imap.select('INBOX')
|
||||
imap
|
||||
end
|
||||
|
||||
def build_mail_from_string(raw_email_content)
|
||||
Mail.read_from_string(raw_email_content)
|
||||
end
|
||||
|
||||
def yesterday
|
||||
(Time.zone.today - 1).strftime('%d-%b-%Y')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class Imap::FetchEmailService < Imap::BaseFetchEmailService
|
||||
def perform
|
||||
fetch_mail_for_channel
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authentication_type
|
||||
'PLAIN'
|
||||
end
|
||||
|
||||
def imap_password
|
||||
channel.imap_password
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class Imap::MicrosoftFetchEmailService < Imap::BaseFetchEmailService
|
||||
def perform
|
||||
return if channel.provider_config['access_token'].blank?
|
||||
|
||||
fetch_mail_for_channel
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authentication_type
|
||||
'XOAUTH2'
|
||||
end
|
||||
|
||||
def imap_password
|
||||
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
|
||||
end
|
||||
end
|
||||
@@ -21,6 +21,7 @@ json.data do
|
||||
json.created_at notification.created_at.to_i
|
||||
json.last_activity_at notification.last_activity_at.to_i
|
||||
json.snoozed_until notification.snoozed_until
|
||||
json.meta notification.meta
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,11 @@ if resource.custom_attributes.present?
|
||||
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
|
||||
json.subscription_status resource.custom_attributes['subscription_status']
|
||||
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
|
||||
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
|
||||
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
|
||||
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
|
||||
json.logo resource.custom_attributes['logo'] if resource.custom_attributes['logo'].present?
|
||||
json.onboarding_step resource.custom_attributes['onboarding_step'] if resource.custom_attributes['onboarding_step'].present?
|
||||
end
|
||||
end
|
||||
json.domain @account.domain
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||
|
||||
<% headers = [
|
||||
I18n.t('reports.agent_csv.agent_name'),
|
||||
I18n.t('reports.agent_csv.conversations_count'),
|
||||
@@ -9,4 +11,3 @@
|
||||
<% @report_data.each do |row| %>
|
||||
<%= CSVSafe.generate_line row -%>
|
||||
<% end %>
|
||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<%= CSVSafe.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
|
||||
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
|
||||
|
||||
<% @report_data.each do |row| %>
|
||||
<%= CSVSafe.generate_line row -%>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||
|
||||
<% headers = [
|
||||
I18n.t('reports.inbox_csv.inbox_name'),
|
||||
I18n.t('reports.inbox_csv.inbox_type'),
|
||||
@@ -10,4 +12,3 @@
|
||||
<% @report_data.each do |row| %>
|
||||
<%= CSVSafe.generate_line row -%>
|
||||
<% end %>
|
||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||
|
||||
<% headers = [
|
||||
I18n.t('reports.label_csv.label_title'),
|
||||
I18n.t('reports.label_csv.conversations_count'),
|
||||
@@ -9,4 +11,3 @@
|
||||
<% @report_data.each do |row| %>
|
||||
<%= CSVSafe.generate_line row -%>
|
||||
<% end %>
|
||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||
|
||||
<% headers = [
|
||||
I18n.t('reports.team_csv.team_name'),
|
||||
I18n.t('reports.team_csv.conversations_count'),
|
||||
@@ -9,4 +11,3 @@
|
||||
<% @report_data.each do |row| %>
|
||||
<%= CSVSafe.generate_line row -%>
|
||||
<% end %>
|
||||
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '3.5.2'
|
||||
version: '3.6.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Define a method to fetch the git commit hash
|
||||
def fetch_git_sha
|
||||
sha = `git rev-parse HEAD`
|
||||
sha = `git rev-parse HEAD` if File.directory?('.git')
|
||||
if sha.present?
|
||||
sha.strip
|
||||
elsif File.exist?('.git_sha')
|
||||
|
||||
@@ -148,6 +148,12 @@ class Rack::Attack
|
||||
match_data[:account_id] if match_data.present?
|
||||
end
|
||||
|
||||
## Prevent abuse of contact search api
|
||||
throttle('/api/v1/accounts/:account_id/contacts/search', limit: 5, period: 1.minute) do |req|
|
||||
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/contacts/search}.match(req.path)
|
||||
match_data[:account_id] if match_data.present?
|
||||
end
|
||||
|
||||
## ----------------------------------------------- ##
|
||||
end
|
||||
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
# value: the value of the config
|
||||
# display_title: the title of the config displayed in the dashboard UI
|
||||
# description: the description of the config displayed in the dashboard UI
|
||||
# locked: if you don't specify locked attribute in yaml, the default value will be true,
|
||||
# locked: if you don't specify locked attribute in yaml, the default value will be true,
|
||||
# which means the particular config will be locked and won't be available in `super_admin/installation_configs`
|
||||
# premium: These values get overwritten unless the user is on a premium plan
|
||||
# type: The type of the config. Default is text, boolean is also supported
|
||||
|
||||
|
||||
|
||||
# ------- Branding Related Config ------- #
|
||||
- name: INSTALLATION_NAME
|
||||
value: 'Chatwoot'
|
||||
@@ -58,8 +56,6 @@
|
||||
type: boolean
|
||||
# ------- End of Branding Related Config ------- #
|
||||
|
||||
|
||||
|
||||
# ------- Signup & Account Related Config ------- #
|
||||
- name: ENABLE_ACCOUNT_SIGNUP
|
||||
display_title: 'Enable Account Signup'
|
||||
@@ -89,8 +85,6 @@
|
||||
locked: false
|
||||
# ------- End of Account Related Config ------- #
|
||||
|
||||
|
||||
|
||||
# ------- Email Related Config ------- #
|
||||
- name: MAILER_INBOUND_EMAIL_DOMAIN
|
||||
value:
|
||||
@@ -101,12 +95,11 @@
|
||||
locked: false
|
||||
# ------- End of Email Related Config ------- #
|
||||
|
||||
|
||||
# ------- Facebook Channel Related Config ------- #
|
||||
- name: FB_APP_ID
|
||||
- name: FB_APP_ID
|
||||
display_title: 'Facebook App ID'
|
||||
locked: false
|
||||
- name: FB_VERIFY_TOKEN
|
||||
- name: FB_VERIFY_TOKEN
|
||||
display_title: 'Facebook Verify Token'
|
||||
description: 'The verify token used for Facebook Messenger Webhook'
|
||||
locked: false
|
||||
@@ -117,6 +110,11 @@
|
||||
display_title: 'Instagram Verify Token'
|
||||
description: 'The verify token used for Instagram Webhook'
|
||||
locked: false
|
||||
- name: FACEBOOK_API_VERSION
|
||||
display_title: 'Facebook API Version'
|
||||
description: 'Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`'
|
||||
value: 'v17.0'
|
||||
locked: false
|
||||
- name: ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT
|
||||
display_title: 'Enable human agent'
|
||||
value: false
|
||||
@@ -128,10 +126,12 @@
|
||||
# ------- Chatwoot Internal Config for Cloud ----#
|
||||
- name: CHATWOOT_INBOX_TOKEN
|
||||
value:
|
||||
display_title: 'Inbox Token'
|
||||
description: 'The Chatwoot Inbox Token for Contact Support in Cloud'
|
||||
locked: false
|
||||
- name: CHATWOOT_INBOX_HMAC_KEY
|
||||
value:
|
||||
display_title: 'Inbox HMAC Key'
|
||||
description: 'The Chatwoot Inbox HMAC Key for Contact Support in Cloud'
|
||||
locked: false
|
||||
- name: CHATWOOT_CLOUD_PLANS
|
||||
@@ -142,10 +142,14 @@
|
||||
description: 'The deployment environment of the installation, to differentiate between Chatwoot cloud and self-hosted'
|
||||
- name: ANALYTICS_TOKEN
|
||||
value:
|
||||
display_title: 'Analytics Token'
|
||||
description: 'The June.so analytics token for Chatwoot cloud'
|
||||
- name: CLEARBIT_API_KEY
|
||||
value:
|
||||
display_title: 'Clearbit API Key'
|
||||
description: 'This API key is used for onboarding the users, to pre-fill account data.'
|
||||
# ------- End of Chatwoot Internal Config for Cloud ----#
|
||||
|
||||
|
||||
# ------- Chatwoot Internal Config for Self Hosted ----#
|
||||
- name: INSTALLATION_PRICING_PLAN
|
||||
value: 'community'
|
||||
@@ -179,5 +183,4 @@
|
||||
value: false
|
||||
locked: false
|
||||
description: 'Disable rendering profile update page for users'
|
||||
|
||||
## ------ End of Configs added for enterprise clients ------ ##
|
||||
## ------ End of Configs added for enterprise clients ------ ##
|
||||
|
||||
+22
-9
@@ -83,25 +83,25 @@ en:
|
||||
utc_warning: The report generated is in UTC timezone
|
||||
agent_csv:
|
||||
agent_name: Agent name
|
||||
conversations_count: Conversations count
|
||||
avg_first_response_time: Avg first response time (Minutes)
|
||||
avg_resolution_time: Avg resolution time (Minutes)
|
||||
conversations_count: Assigned conversations
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
inbox_csv:
|
||||
inbox_name: Inbox name
|
||||
inbox_type: Inbox type
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time (Minutes)
|
||||
avg_resolution_time: Avg resolution time (Minutes)
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
label_csv:
|
||||
label_title: Label
|
||||
conversations_count: No. of conversations
|
||||
avg_first_response_time: Avg first response time (Minutes)
|
||||
avg_resolution_time: Avg resolution time (Minutes)
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
team_csv:
|
||||
team_name: Team name
|
||||
conversations_count: Conversations count
|
||||
avg_first_response_time: Avg first response time (Minutes)
|
||||
avg_resolution_time: Avg resolution time (Minutes)
|
||||
avg_first_response_time: Avg first response time
|
||||
avg_resolution_time: Avg resolution time
|
||||
conversation_traffic_csv:
|
||||
timezone: Timezone
|
||||
default_group_by: day
|
||||
@@ -245,3 +245,16 @@ en:
|
||||
inbox_name: Inbox
|
||||
inbox_type: Inbox Type
|
||||
button: Open conversation
|
||||
time_units:
|
||||
days:
|
||||
one: "%{count} day"
|
||||
other: "%{count} days"
|
||||
hours:
|
||||
one: "%{count} hour"
|
||||
other: "%{count} hours"
|
||||
minutes:
|
||||
one: "%{count} minute"
|
||||
other: "%{count} minutes"
|
||||
seconds:
|
||||
one: "%{count} second"
|
||||
other: "%{count} seconds"
|
||||
|
||||
@@ -294,6 +294,12 @@ Rails.application.routes.draw do
|
||||
namespace :v2 do
|
||||
resources :accounts, only: [:create] do
|
||||
scope module: :accounts do
|
||||
resources :summary_reports, only: [] do
|
||||
collection do
|
||||
get :agent
|
||||
get :team
|
||||
end
|
||||
end
|
||||
resources :reports, only: [:index] do
|
||||
collection do
|
||||
get :summary
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddMetaToNotifications < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :notifications, :meta, :jsonb, default: {}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class AddUniqueIndexToAppliedSlas < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_index :applied_slas,
|
||||
[:account_id, :sla_policy_id, :conversation_id],
|
||||
unique: true,
|
||||
name: 'index_applied_slas_on_account_sla_policy_conversation'
|
||||
end
|
||||
end
|
||||
+3
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_02_16_055809) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -122,6 +122,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "sla_status", default: 0
|
||||
t.index ["account_id", "sla_policy_id", "conversation_id"], name: "index_applied_slas_on_account_sla_policy_conversation", unique: true
|
||||
t.index ["account_id"], name: "index_applied_slas_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id"
|
||||
t.index ["sla_policy_id"], name: "index_applied_slas_on_sla_policy_id"
|
||||
@@ -750,6 +751,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "snoozed_until"
|
||||
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }
|
||||
t.jsonb "meta", default: {}
|
||||
t.index ["account_id"], name: "index_notifications_on_account_id"
|
||||
t.index ["last_activity_at"], name: "index_notifications_on_last_activity_at"
|
||||
t.index ["primary_actor_type", "primary_actor_id"], name: "uniq_primary_actor_per_account_notifications"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
module Enterprise::Api::V2::AccountsController
|
||||
private
|
||||
|
||||
def fetch_account_and_user_info
|
||||
@data = fetch_from_clearbit
|
||||
|
||||
return if @data.blank?
|
||||
|
||||
update_user_info
|
||||
end
|
||||
|
||||
def fetch_from_clearbit
|
||||
Enterprise::ClearbitLookupService.lookup(@user.email)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching data from clearbit: #{e}"
|
||||
nil
|
||||
end
|
||||
|
||||
def update_user_info
|
||||
@user.update!(name: @data[:name]) if @data[:name].present?
|
||||
end
|
||||
|
||||
def data_from_clearbit
|
||||
return {} if @data.blank?
|
||||
|
||||
{ name: @data[:company_name],
|
||||
custom_attributes: {
|
||||
'industry' => @data[:industry],
|
||||
'company_size' => @data[:company_size],
|
||||
'timezone' => @data[:timezone],
|
||||
'logo' => @data[:logo]
|
||||
} }
|
||||
end
|
||||
|
||||
def account_attributes
|
||||
super.deep_merge(
|
||||
data_from_clearbit
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -6,20 +6,30 @@ module Enterprise::SuperAdmin::AppConfigsController
|
||||
|
||||
case @config
|
||||
when 'custom_branding'
|
||||
@allowed_configs = %w[
|
||||
LOGO_THUMBNAIL
|
||||
LOGO
|
||||
LOGO_DARK
|
||||
BRAND_NAME
|
||||
INSTALLATION_NAME
|
||||
BRAND_URL
|
||||
WIDGET_BRAND_URL
|
||||
TERMS_URL
|
||||
PRIVACY_URL
|
||||
DISPLAY_MANIFEST
|
||||
]
|
||||
@allowed_configs = custom_branding_options
|
||||
when 'internal'
|
||||
@allowed_configs = internal_config_options
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def custom_branding_options
|
||||
%w[
|
||||
LOGO_THUMBNAIL
|
||||
LOGO
|
||||
LOGO_DARK
|
||||
BRAND_NAME
|
||||
INSTALLATION_NAME
|
||||
BRAND_URL
|
||||
WIDGET_BRAND_URL
|
||||
TERMS_URL
|
||||
PRIVACY_URL
|
||||
DISPLAY_MANIFEST
|
||||
]
|
||||
end
|
||||
|
||||
def internal_config_options
|
||||
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY]
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user