Merge branch 'develop' into chore/billing-plans

This commit is contained in:
Sojan Jose 2022-05-25 00:00:36 +05:30 committed by GitHub
commit 909ced1d0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
1422 changed files with 47802 additions and 10210 deletions

View file

@ -12,8 +12,8 @@ defaults: &defaults
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/
- image: circleci/postgres:alpine
- image: circleci/redis:alpine
- image: cimg/postgres:14.1
- image: cimg/redis:6.2.6
environment:
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
- RAILS_LOG_TO_STDOUT: false
@ -40,14 +40,13 @@ jobs:
- restore_cache:
keys:
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }}
- chatwoot-bundle
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
- run: bundle install --frozen --path ~/.bundle
- save_cache:
paths:
- ~/.bundle
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }}
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
# Only necessary if app uses webpacker or yarn in some other way
@ -110,7 +109,7 @@ jobs:
- run:
name: Run backend tests
command: |
bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10
bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 --format documentation
~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json
- persist_to_workspace:
root: ~/tmp

View file

@ -50,3 +50,6 @@ exclude_patterns:
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'

View file

@ -32,6 +32,11 @@ REDIS_SENTINELS=
# You can find list of master using "SENTINEL masters" command
REDIS_SENTINEL_MASTER_NAME=
# Redis premium breakage in heroku fix
# enable the following configuration
# ref: https://github.com/chatwoot/chatwoot/issues/2420
# REDIS_OPENSSL_VERIFY_MODE=none
# Postgres Database config variables
POSTGRES_HOST=postgres
POSTGRES_USERNAME=postgres
@ -156,13 +161,15 @@ USE_INBOX_AVATAR_FOR_BOT=true
## NewRelic
# https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/
# NEW_RELIC_LICENSE_KEY=
# Set this to true to allow newrelic apm to send logs.
# This is turned off by default.
# NEW_RELIC_APPLICATION_LOGGING_ENABLED=
## Datadog
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL=
## IP look up configuration
## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md
## works only on accounts with ip look up feature enabled

View file

@ -29,8 +29,8 @@ module.exports = {
'vue/html-self-closing': 'off',
"vue/no-v-html": 'off',
'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off']
'import/extensions': ['off'],
'no-console': 'error'
},
settings: {
'import/resolver': {

View file

@ -0,0 +1,62 @@
# #
# # This action will publish Chatwoot CE docker image.
# # This is set to run against merges to develop, master
# # and when tags are created.
# #
name: Publish Chatwoot CE docker images
on:
push:
branches:
- develop
- master
tags:
- v*
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
env:
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: set docker tag
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
- name: replace docker tag if master
if: github.ref_name == 'master'
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ env.DOCKER_TAG }}

73
.github/workflows/run_foss_spec.yml vendored Normal file
View file

@ -0,0 +1,73 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:10.8
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.0.2 # Not needed with a .ruby-version file
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: yarn check-files
run: yarn install --check-files
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation

View file

@ -16,6 +16,8 @@ Metrics/ClassLength:
- 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb'
RSpec/ExampleLength:
Max: 25
Style/Documentation:

13
Gemfile
View file

@ -42,7 +42,7 @@ gem 'down', '~> 5.0'
gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', require: false
gem 'google-cloud-storage', require: false
gem 'image_processing'
gem 'image_processing', '~> 1.12.2'
##-- gems for database --#
gem 'groupdate'
@ -97,14 +97,14 @@ gem 'brakeman'
gem 'ddtrace'
gem 'newrelic_rpm'
gem 'scout_apm'
gem 'sentry-rails'
gem 'sentry-ruby'
gem 'sentry-sidekiq'
gem 'sentry-rails', '~> 5.3'
gem 'sentry-ruby', '~> 5.3'
gem 'sentry-sidekiq', '~> 5.3'
##-- background job processing --##
gem 'sidekiq', '~> 6.4.0'
# We want cron jobs
gem 'sidekiq-cron'
gem 'sidekiq-cron', '~> 1.3'
##-- Push notification service --##
gem 'fcm'
@ -128,6 +128,9 @@ gem 'procore-sift'
gem 'email_reply_trimmer'
gem 'html2text'
# to calculate working hours
gem 'working_hours'
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'

View file

@ -9,63 +9,63 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.4.7)
actionpack (= 6.1.4.7)
activesupport (= 6.1.4.7)
actioncable (6.1.5.1)
actionpack (= 6.1.5.1)
activesupport (= 6.1.5.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.7)
actionpack (= 6.1.4.7)
activejob (= 6.1.4.7)
activerecord (= 6.1.4.7)
activestorage (= 6.1.4.7)
activesupport (= 6.1.4.7)
actionmailbox (6.1.5.1)
actionpack (= 6.1.5.1)
activejob (= 6.1.5.1)
activerecord (= 6.1.5.1)
activestorage (= 6.1.5.1)
activesupport (= 6.1.5.1)
mail (>= 2.7.1)
actionmailer (6.1.4.7)
actionpack (= 6.1.4.7)
actionview (= 6.1.4.7)
activejob (= 6.1.4.7)
activesupport (= 6.1.4.7)
actionmailer (6.1.5.1)
actionpack (= 6.1.5.1)
actionview (= 6.1.5.1)
activejob (= 6.1.5.1)
activesupport (= 6.1.5.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4.7)
actionview (= 6.1.4.7)
activesupport (= 6.1.4.7)
actionpack (6.1.5.1)
actionview (= 6.1.5.1)
activesupport (= 6.1.5.1)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.7)
actionpack (= 6.1.4.7)
activerecord (= 6.1.4.7)
activestorage (= 6.1.4.7)
activesupport (= 6.1.4.7)
actiontext (6.1.5.1)
actionpack (= 6.1.5.1)
activerecord (= 6.1.5.1)
activestorage (= 6.1.5.1)
activesupport (= 6.1.5.1)
nokogiri (>= 1.8.5)
actionview (6.1.4.7)
activesupport (= 6.1.4.7)
actionview (6.1.5.1)
activesupport (= 6.1.5.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (6.1.4.7)
activesupport (= 6.1.4.7)
activejob (6.1.5.1)
activesupport (= 6.1.5.1)
globalid (>= 0.3.6)
activemodel (6.1.4.7)
activesupport (= 6.1.4.7)
activerecord (6.1.4.7)
activemodel (= 6.1.4.7)
activesupport (= 6.1.4.7)
activemodel (6.1.5.1)
activesupport (= 6.1.5.1)
activerecord (6.1.5.1)
activemodel (= 6.1.5.1)
activesupport (= 6.1.5.1)
activerecord-import (1.3.0)
activerecord (>= 4.2)
activestorage (6.1.4.7)
actionpack (= 6.1.4.7)
activejob (= 6.1.4.7)
activerecord (= 6.1.4.7)
activesupport (= 6.1.4.7)
marcel (~> 1.0.0)
activestorage (6.1.5.1)
actionpack (= 6.1.5.1)
activejob (= 6.1.5.1)
activerecord (= 6.1.5.1)
activesupport (= 6.1.5.1)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4.7)
activesupport (6.1.5.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -136,7 +136,7 @@ GEM
climate_control (1.0.1)
coderay (1.1.3)
commonmarker (0.23.4)
concurrent-ruby (1.1.9)
concurrent-ruby (1.1.10)
connection_pool (2.2.5)
crack (0.4.5)
rexml
@ -183,7 +183,7 @@ GEM
email_reply_trimmer (0.1.13)
erubi (1.10.0)
erubis (2.7.0)
et-orbi (1.2.6)
et-orbi (1.2.7)
tzinfo
execjs (2.8.1)
facebook-messenger (2.0.1)
@ -210,8 +210,8 @@ GEM
ruby_parser (~> 3.0)
sexp_processor (~> 4.0)
foreman (0.87.2)
fugit (1.5.2)
et-orbi (~> 1.1, >= 1.1.8)
fugit (1.5.3)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
gapic-common (0.3.4)
google-protobuf (~> 3.12, >= 3.12.2)
@ -349,7 +349,7 @@ GEM
listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.14.0)
loofah (2.17.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -376,16 +376,16 @@ GEM
net-http-persistent (4.0.1)
connection_pool (~> 2.2)
netrc (0.11.0)
newrelic_rpm (8.4.0)
newrelic_rpm (8.7.0)
nio4r (2.5.8)
nokogiri (1.13.3)
nokogiri (1.13.6)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.13.3-arm64-darwin)
nokogiri (1.13.6-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.3-x86_64-darwin)
nokogiri (1.13.6-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.3-x86_64-linux)
nokogiri (1.13.6-x86_64-linux)
racc (~> 1.4)
oauth (0.5.8)
orm_adapter (0.5.0)
@ -403,7 +403,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.6)
puma (5.6.2)
puma (5.6.4)
nio4r (~> 2.0)
pundit (2.2.0)
activesupport (>= 3.0.0)
@ -419,31 +419,31 @@ GEM
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.6.0)
rails (6.1.4.7)
actioncable (= 6.1.4.7)
actionmailbox (= 6.1.4.7)
actionmailer (= 6.1.4.7)
actionpack (= 6.1.4.7)
actiontext (= 6.1.4.7)
actionview (= 6.1.4.7)
activejob (= 6.1.4.7)
activemodel (= 6.1.4.7)
activerecord (= 6.1.4.7)
activestorage (= 6.1.4.7)
activesupport (= 6.1.4.7)
rails (6.1.5.1)
actioncable (= 6.1.5.1)
actionmailbox (= 6.1.5.1)
actionmailer (= 6.1.5.1)
actionpack (= 6.1.5.1)
actiontext (= 6.1.5.1)
actionview (= 6.1.5.1)
activejob (= 6.1.5.1)
activemodel (= 6.1.5.1)
activerecord (= 6.1.5.1)
activestorage (= 6.1.5.1)
activesupport (= 6.1.5.1)
bundler (>= 1.15.0)
railties (= 6.1.4.7)
railties (= 6.1.5.1)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2)
loofah (~> 2.3)
railties (6.1.4.7)
actionpack (= 6.1.4.7)
activesupport (= 6.1.4.7)
railties (6.1.5.1)
actionpack (= 6.1.5.1)
activesupport (= 6.1.5.1)
method_source
rake (>= 0.13)
rake (>= 12.2)
thor (~> 1.0)
rainbow (3.1.1)
rake (13.0.6)
@ -533,16 +533,16 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.1.0)
sentry-rails (5.3.0)
railties (>= 5.0)
sentry-ruby-core (~> 5.1.0)
sentry-ruby (5.1.0)
sentry-ruby-core (~> 5.3.0)
sentry-ruby (5.3.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-ruby-core (= 5.1.0)
sentry-ruby-core (5.1.0)
sentry-ruby-core (= 5.3.0)
sentry-ruby-core (5.3.0)
concurrent-ruby
sentry-sidekiq (5.1.0)
sentry-ruby-core (~> 5.1.0)
sentry-sidekiq (5.3.0)
sentry-ruby-core (~> 5.3.0)
sidekiq (>= 3.0)
sexp_processor (4.16.0)
shoulda-matchers (5.1.0)
@ -551,8 +551,8 @@ GEM
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
sidekiq-cron (1.2.0)
fugit (~> 1.1)
sidekiq-cron (1.4.0)
fugit (~> 1)
sidekiq (>= 4.2.1)
signet (0.16.0)
addressable (~> 2.8)
@ -637,6 +637,9 @@ GEM
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.0)
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.5.4)
PLATFORMS
@ -689,7 +692,7 @@ DEPENDENCIES
hairtrigger
hashie
html2text
image_processing
image_processing (~> 1.12.2)
jbuilder
json_refs
json_schemer
@ -724,12 +727,12 @@ DEPENDENCIES
rubocop-rspec
scout_apm
seed_dump
sentry-rails
sentry-ruby
sentry-sidekiq
sentry-rails (~> 5.3)
sentry-ruby (~> 5.3)
sentry-sidekiq (~> 5.3)
shoulda-matchers
sidekiq (~> 6.4.0)
sidekiq-cron
sidekiq-cron (~> 1.3)
simplecov (= 0.17.1)
slack-ruby-client
spring
@ -748,9 +751,10 @@ DEPENDENCIES
webpacker (~> 5.x)
webpush
wisper (= 2.0.0)
working_hours
RUBY VERSION
ruby 3.0.2p107
BUNDLED WITH
2.3.8
2.3.10

View file

@ -32,6 +32,10 @@
"INSTALLATION_ENV": {
"description": "Installation method used for Chatwoot.",
"value": "heroku"
},
"REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none"
}
},
"formation": {

View file

@ -1,5 +1,5 @@
class Campaigns::CampaignConversationBuilder
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes]
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes, :custom_attributes]
def perform
@contact_inbox = ContactInbox.find(@contact_inbox_id)
@ -21,7 +21,8 @@ class Campaigns::CampaignConversationBuilder
def message_params
ActionController::Parameters.new({
content: @campaign.message
content: @campaign.message,
campaign_id: @campaign.id
})
end
@ -32,7 +33,8 @@ class Campaigns::CampaignConversationBuilder
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
campaign_id: @campaign.id,
additional_attributes: conversation_additional_attributes
additional_attributes: conversation_additional_attributes,
custom_attributes: custom_attributes || {}
}
end
end

View file

@ -15,11 +15,10 @@ class ContactBuilder
end
def create_contact_inbox(contact)
::ContactInbox.create!(
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: source_id,
hmac_verified: hmac_verified || false
source_id: source_id
)
end
@ -70,7 +69,7 @@ class ContactBuilder
update_contact_avatar(contact)
contact_inbox
rescue StandardError => e
Rails.logger.info e
Rails.logger.error e
raise e
end
end

View file

@ -27,9 +27,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError
Rails.logger.info "Facebook Authorization expired for Inbox #{@inbox.id}"
Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}"
rescue StandardError => e
Sentry.capture_exception(e)
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end
@ -43,7 +43,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
@contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id)
end
def build_message
@ -128,10 +128,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
result = {}
# OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user
# We don't need to capture this error as we don't care about contact params in case of echo messages
Sentry.capture_exception(e) unless @outgoing_echo
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo
rescue StandardError => e
result = {}
Sentry.capture_exception(e)
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
end
process_contact_params_result(result)
end

View file

@ -24,7 +24,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
@inbox.channel.authorization_error!
raise
rescue StandardError => e
Sentry.capture_exception(e)
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end

View file

@ -9,6 +9,7 @@ class Messages::MessageBuilder
@user = user
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = @params&.dig(:content_attributes, :automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
@ -64,6 +65,14 @@ class Messages::MessageBuilder
@params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {}
end
def automation_rule_id
@automation_rule.present? ? { content_attributes: { automation_rule_id: @automation_rule } } : {}
end
def campaign_id
@params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {}
end
def message_sender
return if @params[:sender_type] != 'AgentBot'
@ -82,6 +91,6 @@ class Messages::MessageBuilder
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id]
}.merge(external_created_at)
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
end
end

View file

@ -53,16 +53,7 @@ class Messages::Messenger::MessageBuilder
def fetch_story_link(attachment)
message = attachment.message
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(message.source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
result = {}
Sentry.capture_exception(e)
end
result = get_story_object_from_source_id(message.source_id)
story_id = result['story']['mention']['id']
story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender
@ -70,4 +61,15 @@ class Messages::Messenger::MessageBuilder
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save!
end
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
end

View file

@ -1,8 +1,10 @@
class V2::ReportBuilder
include DateRangeHelper
include ReportHelper
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
AGENT_RESULTS_PER_PAGE = 25
def initialize(account, params)
@account = account
@ -18,8 +20,14 @@ class V2::ReportBuilder
# For backward compatible with old report
def build
timeseries.each_with_object([]) do |p, arr|
arr << { value: p[1], timestamp: p[0].to_time.to_i }
if %w[avg_first_response_time avg_resolution_time].include?(params[:metric])
timeseries.each_with_object([]) do |p, arr|
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] }
end
else
timeseries.each_with_object([]) do |p, arr|
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i }
end
end
end
@ -34,23 +42,16 @@ class V2::ReportBuilder
}
end
private
def scope
case params[:type]
when :account
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
def conversation_metrics
if params[:type].equal?(:account)
conversations
else
agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse
end
end
private
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
@ -68,7 +69,7 @@ class V2::ReportBuilder
end
def get_grouped_values(object_scope)
object_scope.group_by_period(
@grouped_values = object_scope.group_by_period(
params[:group_by] || DEFAULT_GROUP_BY,
:created_at,
default_value: 0,
@ -78,47 +79,29 @@ class V2::ReportBuilder
)
end
def conversations_count
(get_grouped_values scope.conversations).count
def agent_metrics
account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE)
account_users.each_with_object([]) do |account_user, arr|
@user = account_user.user
arr << {
id: @user.id,
name: @user.name,
email: @user.email,
thumbnail: @user.avatar_url,
availability: account_user.availability_status,
metric: conversations
}
end
end
def incoming_messages_count
(get_grouped_values scope.messages.incoming.unscope(:order)).count
end
def outgoing_messages_count
(get_grouped_values scope.messages.outgoing.unscope(:order)).count
end
def resolutions_count
(get_grouped_values scope.conversations.resolved).count
end
def avg_first_response_time
(get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value)
end
def avg_resolution_time
(get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value)
end
def avg_resolution_time_summary
avg_rt = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range)
.average(:value)
return 0 if avg_rt.blank?
avg_rt
end
def avg_first_response_time_summary
avg_frt = scope.reporting_events
.where(name: 'first_response', created_at: range)
.average(:value)
return 0 if avg_frt.blank?
avg_frt
def conversations
@open_conversations = scope.conversations.where(account_id: @account.id).open
first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = {
open: @open_conversations.count,
unattended: @open_conversations.count - first_response_count
}
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
metric
end
end

View file

@ -1,5 +1,8 @@
class RoomChannel < ApplicationCable::Channel
def subscribed
# TODO: should we only do ensure stream if current account is present?
# for now going ahead with guard clauses in update_subscription and broadcast_presence
ensure_stream
current_user
current_account
@ -15,6 +18,8 @@ class RoomChannel < ApplicationCable::Channel
private
def broadcast_presence
return if @current_account.blank?
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
@ -26,6 +31,8 @@ class RoomChannel < ApplicationCable::Channel
end
def update_subscription
return if @current_account.blank?
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
end
@ -38,6 +45,8 @@ class RoomChannel < ApplicationCable::Channel
end
def current_account
return if current_user.blank?
@current_account ||= if @current_user.is_a? Contact
@current_user.account
else

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end
def destroy
@agent_bot.destroy
@agent_bot.destroy!
head :ok
end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def destroy
@agent.current_account_user.destroy
@agent.current_account_user.destroy!
head :ok
end
@ -68,7 +68,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def agents
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end
def validate_limit

View file

@ -0,0 +1,24 @@
class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseController
before_action :fetch_inboxes
def index
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
member_ids
end
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
end
private
def fetch_inboxes
@inboxes = Current.account.inboxes.find(permitted_params[:inbox_ids])
end
def permitted_params
params.permit(inbox_ids: [])
end
end

View file

@ -7,13 +7,39 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
end
def create
@automation_rule = Current.account.automation_rules.create(automation_rules_permit)
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions]
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
@automation_rule.save!
process_attachments
@automation_rule
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def show; end
def update
@automation_rule.update(automation_rules_permit)
ActiveRecord::Base.transaction do
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.save!
process_attachments
rescue StandardError => e
Rails.logger.error e
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
end
end
def destroy
@ -28,6 +54,17 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule
end
def process_attachments
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@automation_rule.files.attach(blob)
end
end
private
def automation_rules_permit

View file

@ -1,32 +1,6 @@
class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
around_action :switch_locale_using_account_locale
private
def current_account
@current_account ||= ensure_current_account
Current.account = @current_account
end
def ensure_current_account
account = Account.find(params[:account_id])
if current_user
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
end
account
end
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
render_unauthorized('You are not authorized to access this account') unless @current_account_user
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)
end
end

View file

@ -15,7 +15,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
rescue StandardError => e
Sentry.capture_exception(e)
ChatwootExceptionTracker.new(e).capture_exception
end
end
@ -60,7 +60,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(access_token, fb_page)
fb_page&.reauthorized!
rescue StandardError => e
Sentry.capture_exception(e)
ChatwootExceptionTracker.new(e).capture_exception
end
end
@ -77,7 +77,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', ''))
koala.exchange_access_token_info(omniauth_token)['access_token']
rescue StandardError => e
Rails.logger.info e
Rails.logger.error e
end
def mark_already_existing_facebook_pages(data)

View file

@ -11,7 +11,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
end
def destroy
@campaign.destroy
@campaign.destroy!
head :ok
end

View file

@ -17,7 +17,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
end
def destroy
@canned_response.destroy
@canned_response.destroy!
head :ok
end

View file

@ -1,4 +1,5 @@
class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase::BaseController
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :fetch_category, except: [:index, :create]
def index
@ -14,7 +15,7 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase:
end
def destroy
@category.destroy
@category.destroy!
head :ok
end
@ -24,6 +25,10 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase:
@category = @portal.categories.find(params[:id])
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end
def category_params
params.require(:category).permit(
:name, :description, :position

View file

@ -7,7 +7,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
build_inbox
setup_webhooks if @twilio_channel.sms?
rescue StandardError => e
Sentry.capture_exception(e)
render_could_not_create_error(e.message)
end
end

View file

@ -10,7 +10,7 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
end
def destroy
@note.destroy
@note.destroy!
head :ok
end

View file

@ -1,10 +1,11 @@
class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController
include EnsureCurrentAccountHelper
before_action :conversation
private
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation.inbox, :show?
end
end

View file

@ -0,0 +1,17 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include EnsureCurrentAccountHelper
before_action :current_account
before_action :conversation
def create
return if @conversation.nil? || @current_account.nil?
super
end
private
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end
end

View file

@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def destroy
ActiveRecord::Base.transaction do
message.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
message.attachments.destroy_all
end
end

View file

@ -5,7 +5,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
RESULTS_PER_PAGE = 25
before_action :check_authorization
before_action :set_csat_survey_responses, only: [:index, :metrics]
before_action :set_csat_survey_responses, only: [:index, :metrics, :download]
before_action :set_current_page, only: [:index]
before_action :set_current_page_surveys, only: [:index]
before_action :set_total_sent_messages_count, only: [:metrics]
@ -19,6 +19,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@ratings_count = @csat_survey_responses.group(:rating).count
end
def download
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=csat_report.csv'
render layout: false, template: 'api/v1/accounts/csat_survey_responses/download.csv.erb', format: 'csv'
end
private
def set_total_sent_messages_count

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
end
def destroy
@custom_attribute_definition.destroy
@custom_attribute_definition.destroy!
head :no_content
end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
end
def destroy
@custom_filter.destroy
@custom_filter.destroy!
head :no_content
end

View file

@ -12,6 +12,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def show; end
# Deprecated: This API will be removed in 2.7.0
def assignable_agents
@assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq
end
@ -48,7 +49,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
# Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank?
validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
if @inbox.inbox_type == 'Email'
validate_email_channel(channel_attributes)
@inbox.channel.reauthorized!
end
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
update_channel_feature_flags
end
@ -69,7 +74,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def destroy
@inbox.destroy
@inbox.destroy!
head :ok
end

View file

@ -11,7 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
end
def destroy
@hook.destroy
@hook.destroy!
head :ok
end

View file

@ -20,7 +20,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
end
def destroy
@hook.destroy
@hook.destroy!
head :ok
end

View file

@ -1,9 +0,0 @@
class Api::V1::Accounts::Kbase::BaseController < Api::V1::Accounts::BaseController
before_action :portal
private
def portal
@portal ||= Current.account.kbase_portals.find_by(id: params[:portal_id])
end
end

View file

@ -1,32 +0,0 @@
class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::BaseController
before_action :fetch_portal, except: [:index, :create]
def index
@portals = Current.account.kbase_portals
end
def create
@portal = Current.account.kbase_portals.create!(portal_params)
end
def update
@portal.update!(portal_params)
end
def destroy
@portal.destroy
head :ok
end
private
def fetch_portal
@portal = current_account.kbase_portals.find(params[:id])
end
def portal_params
params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug
)
end
end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end
def destroy
@label.destroy
@label.destroy!
head :ok
end

View file

@ -0,0 +1,38 @@
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
before_action :fetch_portal, except: [:index, :create]
def index
@portals = Current.account.portals
end
def show; end
def create
@portal = Current.account.portals.create!(portal_params)
end
def update
@portal.update!(portal_params)
end
def destroy
@portal.destroy!
head :ok
end
private
def fetch_portal
@portal = Current.account.portals.find_by(slug: permitted_params[:id])
end
def permitted_params
params.permit(:id)
end
def portal_params
params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived
)
end
end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def destroy
@team.destroy
@team.destroy!
head :ok
end

View file

@ -16,14 +16,14 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
end
def destroy
@webhook.destroy
@webhook.destroy!
head :ok
end
private
def webhook_params
params.require(:webhook).permit(:inbox_id, :url)
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
end
def fetch_webhook

View file

@ -9,7 +9,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
def destroy
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy
notification_subscription.destroy!
head :ok
end

View file

@ -10,7 +10,7 @@ class Api::V1::WebhooksController < ApplicationController
twitter_consumer.consume
head :ok
rescue StandardError => e
Sentry.capture_exception(e)
ChatwootExceptionTracker.new(e).capture_exception
head :ok
end

View file

@ -1,5 +1,6 @@
class Api::V1::Widget::BaseController < ApplicationController
include SwitchLocale
include WebsiteTokenHelper
before_action :set_web_widget
before_action :set_contact
@ -19,23 +20,6 @@ class Api::V1::Widget::BaseController < ApplicationController
@conversation ||= conversations.last
end
def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
end
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@current_account = @web_widget.account
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox&.contact
raise ActiveRecord::RecordNotFound unless @contact
end
def create_conversation
::Conversation.create!(conversation_params)
end
@ -55,7 +39,8 @@ class Api::V1::Widget::BaseController < ApplicationController
browser: browser_params,
referer: permitted_params[:message][:referer_url],
initiated_at: timestamp_params
}
},
custom_attributes: permitted_params[:custom_attributes].presence || {}
}
end
@ -68,20 +53,33 @@ class Api::V1::Widget::BaseController < ApplicationController
mergee_contact: @contact
).perform
else
@contact.update!(email: email, name: contact_name, phone_number: contact_phone_number)
@contact.update!(email: email)
end
end
def update_contact_phone_number(phone_number)
contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number)
if contact_with_phone_number
@contact = ::ContactMergeAction.new(
account: @current_account,
base_contact: contact_with_phone_number,
mergee_contact: @contact
).perform
else
@contact.update!(phone_number: phone_number)
end
end
def contact_email
permitted_params[:contact][:email].downcase
permitted_params.dig(:contact, :email)&.downcase
end
def contact_name
params[:contact][:name] || contact_email.split('@')[0]
params[:contact][:name] || contact_email.split('@')[0] if contact_email.present?
end
def contact_phone_number
params[:contact][:phone_number]
permitted_params.dig(:contact, :phone_number)
end
def browser_params
@ -98,10 +96,6 @@ class Api::V1::Widget::BaseController < ApplicationController
{ timestamp: permitted_params[:message][:timestamp] }
end
def permitted_params
params.permit(:website_token)
end
def message_params
{
account_id: conversation.account_id,

View file

@ -7,12 +7,18 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def create
ActiveRecord::Base.transaction do
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
process_update_contact
@conversation = create_conversation
conversation.messages.create(message_params)
end
end
def process_update_contact
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present?
@contact.update!(name: contact_name) if contact_name.present?
end
def update_last_seen
head :ok && return if conversation.nil?
@ -44,6 +50,18 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
head :ok
end
def toggle_status
return head :not_found if conversation.nil?
return head :forbidden unless @web_widget.end_conversation?
unless conversation.resolved?
conversation.status = :resolved
conversation.save
end
head :ok
end
private
def trigger_typing_event(event)
@ -52,6 +70,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
message: [:content, :referer_url, :timestamp, :echo_id])
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end
end

View file

@ -0,0 +1,11 @@
class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController
include WebsiteTokenHelper
before_action :set_web_widget
before_action :set_contact
def create
return if @contact.nil? || @current_account.nil?
super
end
end

View file

@ -35,41 +35,55 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv'
end
def conversations
return head :unprocessable_entity if params[:type].blank?
render json: conversation_metrics
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
def current_summary_params
def common_params
{
type: params[:type].to_sym,
id: params[:id],
since: range[:current][:since],
until: range[:current][:until],
group_by: params[:group_by]
group_by: params[:group_by],
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
end
def current_summary_params
common_params.merge({
since: range[:current][:since],
until: range[:current][:until]
})
end
def previous_summary_params
{
type: params[:type].to_sym,
id: params[:id],
since: range[:previous][:since],
until: range[:previous][:until],
group_by: params[:group_by]
}
common_params.merge({
since: range[:previous][:since],
until: range[:previous][:until]
})
end
def report_params
common_params.merge({
metric: params[:metric],
since: params[:since],
until: params[:until],
timezone_offset: params[:timezone_offset]
})
end
def conversation_params
{
metric: params[:metric],
type: params[:type].to_sym,
since: params[:since],
until: params[:until],
id: params[:id],
group_by: params[:group_by],
timezone_offset: params[:timezone_offset]
user_id: params[:user_id],
page: params[:page].presence || 1
}
end
@ -91,4 +105,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
summary
end
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end

View file

@ -0,0 +1,28 @@
module EnsureCurrentAccountHelper
private
def current_account
@current_account ||= ensure_current_account
Current.account = @current_account
end
def ensure_current_account
account = Account.find(params[:account_id])
if current_user
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
end
account
end
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
render_unauthorized('You are not authorized to access this account') unless @current_account_user
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)
end
end

View file

@ -9,8 +9,7 @@ module RequestExceptionHandler
def handle_with_exception
yield
rescue ActiveRecord::RecordNotFound => e
Sentry.capture_exception(e)
rescue ActiveRecord::RecordNotFound
render_not_found_error('Resource could not be found')
rescue Pundit::NotAuthorizedError
render_unauthorized('You are not authorized to do this action')

View file

@ -0,0 +1,24 @@
module WebsiteTokenHelper
def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
end
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@current_account = @web_widget.account
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox&.contact
raise ActiveRecord::RecordNotFound unless @contact
Current.contact = @contact
end
def permitted_params
params.permit(:website_token)
end
end

View file

@ -38,9 +38,12 @@ class DashboardController < ActionController::Base
end
def app_config
{ APP_VERSION: Chatwoot.config[:version],
{
APP_VERSION: Chatwoot.config[:version],
VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', '') }
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: 'v13.0'
}
end
end

View file

@ -13,7 +13,7 @@ class Platform::Api::V1::AccountUsersController < PlatformController
end
def destroy
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy!
head :ok
end

View file

@ -7,9 +7,9 @@ class Platform::Api::V1::UsersController < PlatformController
def create
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
@resource.skip_confirmation!
@resource.save!
@resource.confirm
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
@platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
end
def login
@ -21,6 +21,10 @@ class Platform::Api::V1::UsersController < PlatformController
def update
@resource.assign_attributes(user_update_params)
# We are using devise's reconfirmable flow for changing emails
# But in case of platform APIs we don't want user to go through this extra step
@resource.skip_reconfirmation! if user_update_params[:email].present?
@resource.save!
end

View file

@ -43,6 +43,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def permitted_params
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {})
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
end
end

View file

@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
rescue StandardError => e
Rails.logger.info e
Rails.logger.error e
redirect_to twitter_app_redirect_url
end

View file

@ -17,7 +17,7 @@ class Webhooks::InstagramController < ApplicationController
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
render json: :ok
else
Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}")
Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}")
head :unprocessable_entity
end
end

View file

@ -3,6 +3,7 @@ class WidgetTestsController < ActionController::Base
before_action :ensure_widget_position
before_action :ensure_widget_type
before_action :ensure_widget_style
before_action :ensure_dark_mode
def index
render
@ -14,6 +15,10 @@ class WidgetTestsController < ActionController::Base
@widget_style = params[:widget_style] || 'standard'
end
def ensure_dark_mode
@dark_mode = params[:dark_mode] || 'light'
end
def ensure_widget_position
@widget_position = params[:position] || 'left'
end

View file

@ -51,6 +51,7 @@ class ConversationFinder
filter_by_team if @team
filter_by_labels if params[:labels]
filter_by_query if params[:q]
filter_by_reply_status
end
def set_inboxes
@ -90,6 +91,10 @@ class ConversationFinder
@conversations
end
def filter_by_reply_status
@conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended'
end
def filter_by_query
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")

View file

@ -0,0 +1,15 @@
class EmailChannelFinder
def initialize(email_object)
@email_object = email_object
end
def perform
channel = nil
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
recipient_mails.each do |email|
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase)
break if channel.present?
end
channel
end
end

View file

@ -22,7 +22,7 @@ class MessageFinder
def current_messages
if @params[:before].present?
messages.reorder('created_at desc').where('id < ?', @params[:before]).limit(20).reverse
messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse
else
messages.reorder('created_at desc').limit(20).reverse
end

View file

@ -14,7 +14,7 @@ module Api::V1::InboxesHelper
Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_email],
user_name: channel_data[:imap_login],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
@ -29,8 +29,12 @@ module Api::V1::InboxesHelper
smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port])
set_smtp_encryption(channel_data, smtp)
check_smtp_connection(channel_data, smtp)
end
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_email], channel_data[:smtp_password], :login)
def check_smtp_connection(channel_data, smtp)
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish unless smtp&.nil?
end

View file

@ -0,0 +1,68 @@
module ReportHelper
private
def scope
case params[:type]
when :account
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end
end
def conversations_count
(get_grouped_values scope.conversations.where(account_id: account.id)).count
end
def incoming_messages_count
(get_grouped_values scope.messages.where(account_id: account.id).incoming.unscope(:order)).count
end
def outgoing_messages_count
(get_grouped_values scope.messages.where(account_id: account.id).outgoing.unscope(:order)).count
end
def resolutions_count
(get_grouped_values scope.conversations.where(account_id: account.id).resolved).count
end
def avg_first_response_time
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
grouped_reporting_events.average(:value)
end
def avg_resolution_time
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved', account_id: account.id))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
grouped_reporting_events.average(:value)
end
def avg_resolution_time_summary
reporting_events = scope.reporting_events
.where(name: 'conversation_resolved', account_id: account.id, created_at: range)
avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_rt.blank?
avg_rt
end
def avg_first_response_time_summary
reporting_events = scope.reporting_events
.where(name: 'first_response', account_id: account.id, created_at: range)
avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_frt.blank?
avg_frt
end
end

View file

@ -0,0 +1,50 @@
module ReportingEventHelper
def business_hours(inbox, from, to)
return 0 unless inbox.working_hours_enabled?
inbox_working_hours = configure_working_hours(inbox.working_hours)
return 0 if inbox_working_hours.blank?
# Configure working hours
WorkingHours::Config.working_hours = inbox_working_hours
# Configure timezone
WorkingHours::Config.time_zone = inbox.timezone
# Use inbox timezone to change from & to values.
from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time
to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time
from_in_inbox_timezone.working_time_until(to_in_inbox_timezone)
end
private
def configure_working_hours(working_hours)
working_hours.each_with_object({}) do |working_hour, object|
object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day?
end
end
def day(day_of_week)
week_days = {
0 => :sun,
1 => :mon,
2 => :tue,
3 => :wed,
4 => :thu,
5 => :fri,
6 => :sat
}
week_days[day_of_week]
end
def working_hour_range(working_hour)
{ format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) }
end
def format_time(hour, minute)
hour = hour < 10 ? "0#{hour}" : hour
minute = minute < 10 ? "0#{minute}" : minute
"#{hour}:#{minute}"
end
end

View file

@ -1,5 +1,6 @@
<template>
<div id="app" class="app-wrapper app-root">
<div v-if="!authUIFlags.isFetching" id="app" class="app-wrapper app-root">
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<transition name="fade" mode="out-in">
<router-view></router-view>
</transition>
@ -10,27 +11,37 @@
<woot-snackbar-box />
<network-notification />
</div>
<loading-state v-else />
</template>
<script>
import { mapGetters } from 'vuex';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
import WootSnackbarBox from './components/SnackbarContainer';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification';
import { accountIdFromPathname } from './helper/URLHelper';
import UpdateBanner from './components/app/UpdateBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer';
import {
registerSubscription,
verifyServiceWorkerExistence,
} from './helper/pushHelper';
export default {
name: 'App',
components: {
WootSnackbarBox,
AddAccountModal,
LoadingState,
NetworkNotification,
UpdateBanner,
WootSnackbarBox,
},
data() {
return {
showAddAccountModal: false,
latestChatwootVersion: null,
};
},
@ -38,13 +49,13 @@ export default {
...mapGetters({
getAccount: 'accounts/getAccount',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
authUIFlags: 'getAuthUIFlags',
currentAccountId: 'getCurrentAccountId',
}),
hasAccounts() {
return (
this.currentUser &&
this.currentUser.accounts &&
this.currentUser.accounts.length !== 0
);
const { accounts = [] } = this.currentUser || {};
return accounts.length > 0;
},
},
@ -53,28 +64,37 @@ export default {
if (!this.hasAccounts) {
this.showAddAccountModal = true;
}
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
registerSubscription();
}
})
);
},
currentAccountId() {
if (this.currentAccountId) {
this.initializeAccount();
}
},
},
mounted() {
this.$store.dispatch('setUser');
this.setLocale(window.chatwootConfig.selectedLocale);
this.initializeAccount();
},
methods: {
setLocale(locale) {
this.$root.$i18n.locale = locale;
},
async initializeAccount() {
const { pathname } = window.location;
const accountId = accountIdFromPathname(pathname);
if (accountId) {
await this.$store.dispatch('accounts/get');
const { locale } = this.getAccount(accountId);
this.setLocale(locale);
}
await this.$store.dispatch('accounts/get');
const {
locale,
latest_chatwoot_version: latestChatwootVersion,
} = this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
},
},
};
@ -82,6 +102,11 @@ export default {
<style lang="scss">
@import './assets/scss/app';
.update-banner {
height: var(--space-larger);
align-items: center;
font-size: var(--font-size-small) !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>

View file

@ -0,0 +1,16 @@
/* global axios */
import ApiClient from './ApiClient';
class AssignableAgents extends ApiClient {
constructor() {
super('assignable_agents', { accountScoped: true });
}
get(inboxIds) {
return axios.get(this.url, {
params: { inbox_ids: inboxIds },
});
}
}
export default new AssignableAgents();

View file

@ -1,6 +1,4 @@
/* eslint no-console: 0 */
/* global axios */
/* eslint no-undef: "error" */
import Cookies from 'js-cookie';
import endPoints from './endPoints';
@ -61,41 +59,15 @@ export default {
});
return fetchPromise;
},
isLoggedIn() {
const hasAuthCookie = !!Cookies.getJSON('auth_data');
const hasUserCookie = !!Cookies.getJSON('user');
return hasAuthCookie && hasUserCookie;
hasAuthCookie() {
return !!Cookies.getJSON('cw_d_session_info');
},
isAdmin() {
if (this.isLoggedIn()) {
return Cookies.getJSON('user').role === 'administrator';
}
return false;
},
getAuthData() {
if (this.isLoggedIn()) {
return Cookies.getJSON('auth_data');
if (this.hasAuthCookie()) {
return Cookies.getJSON('cw_d_session_info');
}
return false;
},
getPubSubToken() {
if (this.isLoggedIn()) {
const user = Cookies.getJSON('user') || {};
const { pubsub_token: pubsubToken } = user;
return pubsubToken;
}
return null;
},
getCurrentUser() {
if (this.isLoggedIn()) {
return Cookies.getJSON('user');
}
return null;
},
verifyPasswordToken({ confirmationToken }) {
return new Promise((resolve, reject) => {
axios

View file

@ -9,6 +9,14 @@ class AutomationsAPI extends ApiClient {
clone(automationId) {
return axios.post(`${this.url}/${automationId}/clone`);
}
attachment(file) {
return axios.post(`${this.url}/attach_file`, file, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
}
export default new AutomationsAPI();

View file

@ -18,6 +18,17 @@ class CSATReportsAPI extends ApiClient {
});
}
download({ from, to, user_ids } = {}) {
return axios.get(`${this.url}/download`, {
params: {
since: from,
until: to,
sort: '-created_at',
user_ids,
},
});
}
getMetrics({ from, to, user_ids } = {}) {
return axios.get(`${this.url}/metrics`, {
params: { since: from, until: to, user_ids },

View file

@ -6,10 +6,6 @@ class Inboxes extends ApiClient {
super('inboxes', { accountScoped: true });
}
getAssignableAgents(inboxId) {
return axios.get(`${this.url}/${inboxId}/assignable_agents`);
}
getCampaigns(inboxId) {
return axios.get(`${this.url}/${inboxId}/campaigns`);
}

View file

@ -8,7 +8,15 @@ class ReportsAPI extends ApiClient {
super('reports', { accountScoped: true, apiVersion: 'v2' });
}
getReports(metric, since, until, type = 'account', id, group_by) {
getReports(
metric,
since,
until,
type = 'account',
id,
group_by,
business_hours
) {
return axios.get(`${this.url}`, {
params: {
metric,
@ -17,12 +25,13 @@ class ReportsAPI extends ApiClient {
type,
id,
group_by,
business_hours,
timezone_offset: getTimeOffset(),
},
});
}
getSummary(since, until, type = 'account', id, group_by) {
getSummary(since, until, type = 'account', id, group_by, business_hours) {
return axios.get(`${this.url}/summary`, {
params: {
since,
@ -30,6 +39,16 @@ class ReportsAPI extends ApiClient {
type,
id,
group_by,
business_hours,
},
});
}
getConversationMetric(type = 'account', page = 1) {
return axios.get(`${this.url}/conversations`, {
params: {
type,
page,
},
});
}

View file

@ -0,0 +1,18 @@
import assignableAgentsAPI from '../assignableAgents';
import describeWithAPIMock from './apiSpecHelper';
describe('#AssignableAgentsAPI', () => {
describeWithAPIMock('API calls', context => {
it('#getAssignableAgents', () => {
assignableAgentsAPI.get([1]);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/assignable_agents',
{
params: {
inbox_ids: [1],
},
}
);
});
});
});

View file

@ -33,5 +33,23 @@ describe('#Reports API', () => {
}
);
});
it('#download', () => {
csatReportsAPI.download({
from: 1622485800,
to: 1623695400,
user_ids: 1,
});
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/csat_survey_responses/download',
{
params: {
since: 1622485800,
until: 1623695400,
user_ids: 1,
sort: '-created_at',
},
}
);
});
});
});

View file

@ -10,17 +10,9 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('create');
expect(inboxesAPI).toHaveProperty('update');
expect(inboxesAPI).toHaveProperty('delete');
expect(inboxesAPI).toHaveProperty('getAssignableAgents');
expect(inboxesAPI).toHaveProperty('getCampaigns');
});
describeWithAPIMock('API calls', context => {
it('#getAssignableAgents', () => {
inboxesAPI.getAssignableAgents(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/inboxes/1/assignable_agents'
);
});
it('#getCampaigns', () => {
inboxesAPI.getCampaigns(2);
expect(context.axiosMock.get).toHaveBeenCalledWith(

View file

@ -97,5 +97,18 @@ describe('#Reports API', () => {
}
);
});
it('#getConversationMetric', () => {
reportsAPI.getConversationMetric('account');
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/conversations',
{
params: {
type: 'account',
page: 1,
},
}
);
});
});
});

View file

@ -26,7 +26,8 @@
code {
border: 0;
font-family: 'Monaco', Verdana;
font-family: 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas',
'"Liberation Mono"', '"Courier New"', 'monospace';
font-size: $font-size-mini;
&.hljs {
@ -55,7 +56,6 @@ code {
padding-right: var(--space-normal);
}
.badge {
border-radius: var(--border-radius-normal);
}

View file

@ -10,10 +10,24 @@ body {
.app-wrapper {
@include full-height;
flex-grow: 0;
min-height: 0;
width: 100%;
}
.app-root {
.banner + .app-wrapper {
.button--fixed-right-top {
top: 5.6 * $space-one;
}
.off-canvas-content {
.button--fixed-right-top {
top: $space-small;
}
}
}
is-closed .app-root {
@include flex;
flex-direction: column;
}
@ -21,6 +35,7 @@ body {
.app-content {
@include flex;
@include full-height;
min-height: 0;
overflow: hidden;
}

View file

@ -2,6 +2,10 @@
margin-right: var(--space-small);
}
.margin-bottom-small {
margin-bottom: var(--space-small);
}
.margin-right-smaller {
margin-right: var(--space-smaller);
}
@ -51,10 +55,6 @@
background-color: var(--white);
}
.text-y-800 {
color: var(--y-800);
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;

View file

@ -62,13 +62,13 @@ $default-button-height: 4.0rem;
}
&.warning {
@include button-style(var(--y-100), var(--y-200), var(--y-900));
@include button-style(var(--y-100), var(--y-200), var(--y-700));
}
}
&.clear {
&.warning {
color: var(--y-800);
color: var(--y-600);
}
&.button--only-icon:hover {
@ -87,7 +87,7 @@ $default-button-height: 4.0rem;
}
&.warning {
background: var(--y-100);
background: var(--y-50);
}
}
}

View file

@ -27,6 +27,16 @@
padding: 0 $space-small;
}
.video-js {
background: transparent;
// Override min-height : 50px in foundation
//
max-height: $space-mega * 2.4;
min-height: 4.8rem;
padding: var(--space-normal) 0 0;
resize: none;
}
>textarea {
@include ghost-input();
@include margin(0);

View file

@ -18,6 +18,13 @@
font-size: $font-size-small;
font-weight: $font-weight-bold;
color: $color-heading;
display: flex;
align-items: center;
}
.info-icon {
color: var(--b-400);
margin-left: var(--space-micro);
}
.metric-wrap {
@ -71,5 +78,10 @@
font-size: $font-size-default;
color: $color-gray;
}
.business-hours {
margin: $space-normal;
text-align: center;
}
}
}

View file

@ -25,3 +25,21 @@
align-items: center;
display: flex;
}
.business-hours {
align-items: center;
display: flex;
justify-content: end;
margin-bottom: var(--space-normal);
margin-left: auto;
padding-right: var(--space-normal);
}
.business-hours-text {
font-size: var(--font-size-small);
}
.switch {
margin-bottom: var(--space-zero);
margin-left: var(--space-small);
}

View file

@ -15,9 +15,9 @@
{{ $t('NETWORK.BUTTON.REFRESH') }}
</woot-button>
<woot-button
variant="clear"
variant="smooth"
size="small"
color-scheme="secondary"
color-scheme="warning"
icon="dismiss-circle"
@click="closeNotification"
>

View file

@ -7,6 +7,10 @@
<p class="sub-head">
{{ subTitle }}
</p>
<p v-if="note">
<span class="note">{{ $t('INBOX_MGMT.NOTE') }}</span>
{{ note }}
</p>
</div>
<div class="medium-6 small-12">
<slot></slot>
@ -25,6 +29,10 @@ export default {
type: String,
required: true,
},
note: {
type: String,
default: '',
},
},
};
</script>
@ -46,5 +54,9 @@ export default {
.title--section {
padding-right: var(--space-large);
}
.note {
font-weight: var(--font-weight-bold);
}
}
</style>

View file

@ -0,0 +1,74 @@
<template>
<banner
v-if="shouldShowBanner"
class="update-banner"
color-scheme="primary"
:banner-message="bannerMessage"
href-link="https://github.com/chatwoot/chatwoot/releases"
:href-link-text="$t('GENERAL_SETTINGS.LEARN_MORE')"
has-close-button
@close="dismissUpdateBanner"
/>
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import LocalStorage from '../../helper/localStorage';
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
const semver = require('semver');
const dismissedUpdates = new LocalStorage('dismissedUpdates');
export default {
components: {
Banner,
},
mixins: [adminMixin],
props: {
latestChatwootVersion: {
type: String,
default: '',
},
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
hasAnUpdateAvailable() {
if (!semver.valid(this.latestChatwootVersion)) {
return false;
}
return semver.lt(
this.globalConfig.appVersion,
this.latestChatwootVersion
);
},
bannerMessage() {
return this.$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: this.latestChatwootVersion,
});
},
shouldShowBanner() {
return (
this.globalConfig.displayManifest &&
this.hasAnUpdateAvailable &&
!this.isVersionNotificationDismissed(this.latestChatwootVersion) &&
this.isAdmin
);
},
},
methods: {
isVersionNotificationDismissed(version) {
return dismissedUpdates.get().includes(version);
},
dismissUpdateBanner() {
let updatedDismissedItems = dismissedUpdates.get();
if (updatedDismissedItems instanceof Array) {
updatedDismissedItems.push(this.latestChatwootVersion);
} else {
updatedDismissedItems = [this.latestChatwootVersion];
}
dismissedUpdates.store(updatedDismissedItems);
this.latestChatwootVersion = this.globalConfig.appVersion;
},
},
};
</script>

View file

@ -57,3 +57,13 @@ export default {
},
};
</script>
<style lang="scss" scoped>
button:disabled {
opacity: 1;
background-color: var(--w-100);
&:hover {
background-color: var(--w-100);
}
}
</style>

View file

@ -1,65 +0,0 @@
<template>
<button
type="button"
class="toggle-button"
:class="{ active }"
role="switch"
:aria-checked="active.toString()"
@click="onClick"
>
<span aria-hidden="true" :class="{ active }"></span>
</button>
</template>
<script>
export default {
props: {
active: { type: Boolean, default: false },
},
methods: {
onClick(e) {
this.$emit('click', e);
},
},
};
</script>
<style lang="scss" scoped>
.toggle-button {
background-color: var(--s-200);
position: relative;
display: inline-flex;
height: 19px;
width: 34px;
border: 2px solid transparent;
border-radius: var(--border-radius-large);
cursor: pointer;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
flex-shrink: 0;
}
.toggle-button.active {
background-color: var(--w-500);
}
.toggle-button span {
--space-one-point-five: 1.5rem;
height: var(--space-one-point-five);
width: var(--space-one-point-five);
display: inline-block;
background-color: var(--white);
box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
transform: translate(0, 0);
border-radius: 100%;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.toggle-button span.active {
transform: translate(var(--space-one-point-five), var(--space-zero));
}
</style>

View file

@ -53,7 +53,7 @@ export default {
computed: {
...mapGetters({
getCurrentUserAvailability: 'getCurrentUserAvailability',
getCurrentAccountId: 'getCurrentAccountId',
currentAccountId: 'getCurrentAccountId',
}),
availabilityDisplayLabel() {
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
@ -63,9 +63,6 @@ export default {
availabilityIndex
];
},
currentAccountId() {
return this.getCurrentAccountId;
},
currentUserAvailability() {
return this.getCurrentUserAvailability;
},

View file

@ -8,6 +8,7 @@
:active-menu-item="activePrimaryMenu.key"
@toggle-accounts="toggleAccountModal"
@key-shortcut-modal="toggleKeyShortcutModal"
@open-notification-panel="openNotificationPanel"
/>
<secondary-sidebar
:account-id="accountId"
@ -18,6 +19,7 @@
:menu-config="activeSecondaryMenu"
:current-role="currentRole"
@add-label="showAddLabelPopup"
@toggle-accounts="toggleAccountModal"
/>
</aside>
</template>
@ -176,6 +178,9 @@ export default {
showAddLabelPopup() {
this.$emit('show-add-label-popup');
},
openNotificationPanel() {
this.$emit('open-notification-panel');
},
},
};
</script>
@ -184,6 +189,8 @@ export default {
.woot-sidebar {
background: var(--white);
display: flex;
min-height: 0;
height: 100%;
}
</style>

View file

@ -3,7 +3,8 @@ import { frontendURL } from '../../../../helper/URLHelper';
const reports = accountId => ({
parentNav: 'reports',
routes: [
'settings_account_reports',
'account_overview_reports',
'conversation_reports',
'csat_reports',
'agent_reports',
'label_reports',
@ -16,7 +17,14 @@ const reports = accountId => ({
label: 'REPORTS_OVERVIEW',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`),
toStateName: 'settings_account_reports',
toStateName: 'account_overview_reports',
},
{
icon: 'chat',
label: 'REPORTS_CONVERSATION',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/conversation`),
toStateName: 'conversation_reports',
},
{
icon: 'emoji',

View file

@ -1,14 +1,34 @@
<template>
<div v-if="showShowCurrentAccountContext" class="account-context--group">
<div
v-if="showShowCurrentAccountContext"
class="account-context--group"
@mouseover="setShowSwitch"
@mouseleave="resetShowSwitch"
>
{{ $t('SIDEBAR.CURRENTLY_VIEWING_ACCOUNT') }}
<p class="account-context--name text-ellipsis">
{{ account.name }}
</p>
<transition name="fade">
<div v-if="showSwitchButton" class="account-context--switch-group">
<woot-button
variant="clear"
icon="arrow-swap"
class="cursor-pointer"
@click="$emit('toggle-accounts')"
>
{{ $t('SIDEBAR.SWITCH') }}
</woot-button>
</div>
</transition>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
data() {
return { showSwitchButton: false };
},
computed: {
...mapGetters({
account: 'getCurrentAccount',
@ -18,6 +38,14 @@ export default {
return this.userAccounts.length > 1 && this.account.name;
},
},
methods: {
setShowSwitch() {
this.showSwitchButton = true;
},
resetShowSwitch() {
this.showSwitchButton = false;
},
},
};
</script>
<style scoped lang="scss">
@ -27,10 +55,49 @@ export default {
font-size: var(--font-size-mini);
padding: var(--space-small);
margin-bottom: var(--space-small);
width: 100%;
position: relative;
&:hover {
background: var(--b-100);
}
.account-context--name {
font-weight: var(--font-weight-medium);
margin-bottom: 0;
}
}
.account-context--switch-group {
--overlay-shadow: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 50%
);
align-items: center;
background-image: var(--overlay-shadow);
border-top-left-radius: 0;
border-top-right-radius: var(--border-radius-normal);
border-bottom-left-radius: 0;
border-bottom-right-radius: var(--border-radius-normal);
display: flex;
height: 100%;
justify-content: end;
opacity: 1;
position: absolute;
right: 0;
top: 0;
width: 100%;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 300ms ease;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -1,19 +1,21 @@
<template>
<div class="notifications-link">
<primary-nav-item
name="NOTIFICATIONS"
icon="alert"
:to="`/app/accounts/${accountId}/notifications`"
:count="unreadCount"
/>
<woot-button
class-names="notifications-link--button"
variant="clear"
color-scheme="secondary"
:class="{ 'is-active': isNotificationPanelActive }"
@click="openNotificationPanel"
>
<fluent-icon icon="alert" />
<span v-if="unreadCount" class="badge warning">{{ unreadCount }}</span>
</woot-button>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import PrimaryNavItem from './PrimaryNavItem';
export default {
components: { PrimaryNavItem },
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
@ -28,8 +30,17 @@ export default {
? `${this.notificationMetadata.unreadCount}`
: '99+';
},
isNotificationPanelActive() {
return this.$route.name === 'notifications_index';
},
},
methods: {
openNotificationPanel() {
if (this.$route.name !== 'notifications_index') {
this.$emit('open-notification-panel');
}
},
},
methods: {},
};
</script>
@ -37,4 +48,32 @@ export default {
.notifications-link {
margin-bottom: var(--space-small);
}
.badge {
position: absolute;
right: var(--space-minus-smaller);
top: var(--space-minus-smaller);
}
.notifications-link--button {
display: flex;
position: relative;
border-radius: var(--border-radius-large);
border: 1px solid transparent;
color: var(--s-600);
margin: var(--space-small) 0;
&:hover {
background: var(--w-50);
color: var(--s-600);
}
&:focus {
border-color: var(--w-500);
}
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
</style>

View file

@ -16,7 +16,7 @@
/>
</nav>
<div class="menu vertical user-menu">
<notification-bell />
<notification-bell @open-notification-panel="openNotificationPanel" />
<agent-details @toggle-menu="toggleOptions" />
<options-menu
:show="showOptionsMenu"
@ -83,6 +83,9 @@ export default {
toggleSupportChatWindow() {
window.$chatwoot.toggle();
},
openNotificationPanel() {
this.$emit('open-notification-panel');
},
},
};
</script>
@ -93,7 +96,7 @@ export default {
width: var(--space-jumbo);
border-right: 1px solid var(--s-50);
box-sizing: content-box;
height: 100vh;
height: 100%;
flex-shrink: 0;
}

View file

@ -1,6 +1,6 @@
<template>
<div v-if="hasSecondaryMenu" class="main-nav secondary-menu">
<account-context />
<account-context @toggle-accounts="toggleAccountModal" />
<transition-group name="menu-list" tag="ul" class="menu vertical">
<secondary-nav-item
v-for="menuItem in accessibleMenuItems"
@ -85,14 +85,20 @@ export default {
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
toStateName: 'settings_inbox_new',
newLinkRouteName: 'settings_inbox_new',
children: this.inboxes.map(inbox => ({
id: inbox.id,
label: inbox.name,
truncateLabel: true,
toState: frontendURL(`accounts/${this.accountId}/inbox/${inbox.id}`),
type: inbox.channel_type,
phoneNumber: inbox.phone_number,
})),
children: this.inboxes
.map(inbox => ({
id: inbox.id,
label: inbox.name,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/inbox/${inbox.id}`
),
type: inbox.channel_type,
phoneNumber: inbox.phone_number,
}))
.sort((a, b) =>
a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1
),
};
},
labelSection() {
@ -218,6 +224,9 @@ export default {
showAddLabelPopup() {
this.$emit('add-label');
},
toggleAccountModal() {
this.$emit('toggle-accounts');
},
},
};
</script>
@ -225,7 +234,7 @@ export default {
.secondary-menu {
background: var(--white);
border-right: 1px solid var(--s-50);
height: 100vh;
height: 100%;
width: 19rem;
flex-shrink: 0;
overflow: hidden;

View file

@ -14,6 +14,10 @@ const i18nConfig = new VueI18n({
messages: i18n,
});
const $route = {
name: 'notifications_index',
};
describe('notificationBell', () => {
const accountId = 1;
const notificationMetadata = { unreadCount: 19 };
@ -45,24 +49,40 @@ describe('notificationBell', () => {
});
it('it should return unread count 19 ', () => {
const notificationBell = shallowMount(NotificationBell, {
store,
const wrapper = shallowMount(NotificationBell, {
localVue,
i18n: i18nConfig,
store,
mocks: {
$route,
},
});
const statusViewTitle = notificationBell.find('primary-nav-item-stub');
expect(statusViewTitle.vm.count).toBe('19');
expect(wrapper.vm.unreadCount).toBe('19');
});
it('it should return unread count 99+ ', async () => {
notificationMetadata.unreadCount = 101;
notificationMetadata.unreadCount = 100;
const wrapper = shallowMount(NotificationBell, {
localVue,
i18n: i18nConfig,
store,
mocks: {
$route,
},
});
expect(wrapper.vm.unreadCount).toBe('99+');
});
it('isNotificationPanelActive', async () => {
const notificationBell = shallowMount(NotificationBell, {
store,
localVue,
i18n: i18nConfig,
mocks: {
$route,
},
});
const statusViewTitle = notificationBell.find('primary-nav-item-stub');
expect(statusViewTitle.vm.count).toBe('99+');
expect(notificationBell.vm.isNotificationPanelActive).toBe(true);
});
});

View file

@ -1,6 +1,6 @@
<template>
<div class="banner" :class="bannerClasses">
<span>
<span class="banner-message">
{{ bannerMessage }}
<a
v-if="hrefLink"
@ -26,7 +26,7 @@
v-if="hasCloseButton"
size="small"
variant="link"
color-scheme="warning"
color-scheme="secondary"
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
@ -92,8 +92,19 @@ export default {
justify-content: center;
position: sticky;
&.primary {
background: var(--w-500);
.banner-action__button {
color: var(--white);
}
}
&.secondary {
background: var(--s-300);
background: var(--s-200);
color: var(--s-800);
a {
color: var(--s-800);
}
}
&.alert {
@ -101,18 +112,22 @@ export default {
}
&.warning {
background: var(--y-800);
color: var(--s-600);
background: var(--y-600);
color: var(--y-500);
a {
color: var(--s-600);
color: var(--y-500);
}
}
&.gray {
background: var(--b-500);
.banner-action__button {
color: var(--white);
}
}
a {
margin-left: var(--space-smaller);
text-decoration: underline;
color: var(--white);
font-size: var(--font-size-mini);
@ -125,5 +140,10 @@ export default {
white-space: nowrap;
}
}
.banner-message {
display: flex;
align-items: center;
}
}
</style>

View file

@ -157,7 +157,7 @@ export default {
&.warning {
background: var(--y-100);
color: var(--y-900);
border: 1px solid var(--y-300);
border: 1px solid var(--y-200);
a {
color: var(--y-900);
}

View file

@ -1,54 +1,66 @@
<template>
<label class="switch" :class="classObject">
<input
:id="id"
v-model="value"
class="switch-input"
:name="name"
:disabled="disabled"
type="checkbox"
/>
<div class="switch-paddle" :for="name">
<span class="show-for-sr">on off</span>
</div>
</label>
<button
type="button"
class="toggle-button"
:class="{ active: value }"
role="switch"
:aria-checked="value.toString()"
@click="onClick"
>
<span aria-hidden="true" :class="{ active: value }"></span>
</button>
</template>
<script>
export default {
props: {
disabled: Boolean,
type: { type: String, default: '' },
size: { type: String, default: '' },
checked: Boolean,
name: { type: String, default: '' },
id: { type: String, default: '' },
value: { type: Boolean, default: false },
},
data() {
return {
value: null,
};
},
computed: {
classObject() {
const { type, size, value } = this;
return {
[`is-${type}`]: type,
[`${size}`]: size,
checked: value,
};
methods: {
onClick() {
this.$emit('input', !this.value);
},
},
watch: {
value(val) {
this.$emit('input', val);
},
},
beforeMount() {
this.value = this.checked;
},
mounted() {
this.$emit('input', (this.value = !!this.checked));
},
};
</script>
<style lang="scss" scoped>
.toggle-button {
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
background-color: var(--s-200);
border-radius: var(--border-radius-large);
border: 2px solid transparent;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: 19px;
position: relative;
transition-duration: 200ms;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: 34px;
&.active {
background-color: var(--w-500);
}
span {
--space-one-point-five: 1.5rem;
background-color: var(--white);
border-radius: 100%;
box-shadow: var(--toggle-button-box-shadow);
display: inline-block;
height: var(--space-one-point-five);
transform: translate(0, 0);
transition-duration: 200ms;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: var(--space-one-point-five);
&.active {
transform: translate(var(--space-one-point-five), var(--space-zero));
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show more