Merge branch 'master' into feat/reorder-sidebar

This commit is contained in:
Pranav Raj S 2022-01-12 13:57:58 -08:00
commit 02c66e5c1d
1460 changed files with 55576 additions and 11284 deletions

View file

@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build working_directory: ~/build
docker: docker:
# specify the version you desire here # specify the version you desire here
- image: cimg/ruby:3.0.2-node - image: cimg/ruby:3.0.2-browsers
# Specify service dependencies here if necessary # Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images # CircleCI maintains a library of pre-built images
@ -77,6 +77,18 @@ jobs:
paths: paths:
- cc-test-reporter - cc-test-reporter
# verify swagger specification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/5.3.0/openapi-generator-cli-5.3.0.jar > ~/tmp/openapi-generator-cli-5.3.0.jar
java -jar ~/tmp/openapi-generator-cli-5.3.0.jar validate -i swagger/swagger.json
# Database setup # Database setup
- run: yarn install --check-files - run: yarn install --check-files
- run: bundle exec rake db:create - run: bundle exec rake db:create

View file

@ -1,4 +1,4 @@
version: "2" version: '2'
plugins: plugins:
rubocop: rubocop:
enabled: false enabled: false
@ -14,21 +14,33 @@ plugins:
checks: checks:
similar-code: similar-code:
enabled: false enabled: false
method-count:
enabled: true
config:
threshold: 32
file-lines:
enabled: true
config:
threshold: 300
exclude_patterns: exclude_patterns:
- "spec/" - 'spec/'
- "**/specs/" - '**/specs/'
- "db/*" - 'db/*'
- "bin/**/*" - 'bin/**/*'
- "db/**/*" - 'db/**/*'
- "config/**/*" - 'config/**/*'
- "public/**/*" - 'public/**/*'
- "vendor/**/*" - 'vendor/**/*'
- "node_modules/**/*" - 'node_modules/**/*'
- "lib/tasks/auto_annotate_models.rake" - 'lib/tasks/auto_annotate_models.rake'
- "app/test-matchers.js" - 'app/test-matchers.js'
- "docs/*" - 'docs/*'
- "**/*.md" - '**/*.md'
- "**/*.yml" - '**/*.yml'
- "app/javascript/dashboard/i18n/locale" - 'app/javascript/dashboard/i18n/locale'
- "**/*.stories.js" - '**/*.stories.js'
- "stories/" - 'stories/'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js'
- 'app/javascript/shared/constants/countries.js'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js'
- 'app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js'

View file

@ -41,7 +41,7 @@ RAILS_MAX_THREADS=5
# The email from which all outgoing emails are sent # The email from which all outgoing emails are sent
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>` # could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com> MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
#SMTP domain key is set up for HELO checking #SMTP domain key is set up for HELO checking
@ -57,6 +57,9 @@ SMTP_AUTHENTICATION=
SMTP_ENABLE_STARTTLS_AUTO=true SMTP_ENABLE_STARTTLS_AUTO=true
# Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert', see http://api.rubyonrails.org/classes/ActionMailer/Base.html # Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert', see http://api.rubyonrails.org/classes/ActionMailer/Base.html
SMTP_OPENSSL_VERIFY_MODE=peer SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server
# SMTP_TLS=
# SMTP_SSL=
# Mail Incoming # Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled # This is the domain set for the reply emails when conversation continuity is enabled
@ -100,6 +103,9 @@ FB_VERIFY_TOKEN=
FB_APP_SECRET= FB_APP_SECRET=
FB_APP_ID= FB_APP_ID=
# https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard
IG_VERIFY_TOKEN=
# Twitter # Twitter
# documentation: https://www.chatwoot.com/docs/twitter-app-setup # documentation: https://www.chatwoot.com/docs/twitter-app-setup
TWITTER_APP_ID= TWITTER_APP_ID=
@ -113,7 +119,7 @@ SLACK_CLIENT_SECRET=
### Change this env variable only if you are using a custom build mobile app ### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables ## Mobile app env variables
IOS_APP_ID=6C953F3RX2.com.chatwoot.app IOS_APP_ID=L7YLMN4634.com.chatwoot.app
ANDROID_BUNDLE_ID=com.chatwoot.app ANDROID_BUNDLE_ID=com.chatwoot.app
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section) # https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
@ -166,7 +172,7 @@ USE_INBOX_AVATAR_FOR_BOT=true
## Rack Attack configuration ## Rack Attack configuration
## To prevent and throttle abusive requests ## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=false # ENABLE_RACK_ATTACK=true
## Running chatwoot as an API only server ## Running chatwoot as an API only server

View file

@ -28,6 +28,7 @@ module.exports = {
}], }],
'vue/html-self-closing': 'off', 'vue/html-self-closing': 'off',
"vue/no-v-html": 'off', "vue/no-v-html": 'off',
'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'] 'import/extensions': ['off']
}, },

5
.husky/pre-commit Executable file
View file

@ -0,0 +1,5 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run eslint
bundle exec rubocop -a
git add

4
.husky/pre-push Executable file
View file

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
sh bin/validate_push

View file

@ -11,9 +11,11 @@ Metrics/ClassLength:
Max: 125 Max: 125
Exclude: Exclude:
- 'app/models/conversation.rb' - 'app/models/conversation.rb'
- 'app/models/contact.rb'
- 'app/mailers/conversation_reply_mailer.rb' - 'app/mailers/conversation_reply_mailer.rb'
- 'app/models/message.rb' - 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb' - 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:
@ -56,7 +58,7 @@ Metrics/BlockLength:
- db/schema.rb - db/schema.rb
Metrics/ModuleLength: Metrics/ModuleLength:
Exclude: Exclude:
- lib/woot_message_seeder.rb - lib/seeders/message_seeder.rb
Rails/ApplicationController: Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb' - 'app/controllers/api/v1/widget/messages_controller.rb'
@ -86,6 +88,7 @@ Naming/VariableNumber:
Metrics/MethodLength: Metrics/MethodLength:
Exclude: Exclude:
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb' - 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
Rails/CreateTableWithTimestamps: Rails/CreateTableWithTimestamps:
Exclude: Exclude:
- 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb' - 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb'
@ -101,6 +104,7 @@ Metrics/AbcSize:
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb' - 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
- 'app/controllers/api/v1/accounts/inboxes_controller.rb' - 'app/controllers/api/v1/accounts/inboxes_controller.rb'
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Max: 7 Max: 7
Exclude: Exclude:

128
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
hello@chatwoot.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

5
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,5 @@
# Contributing to Chatwoot
Thanks for taking the time to contribute! :tada::+1:
Please refer to our [Contributing Guide](https://www.chatwoot.com/docs/contributing-guide) for detailed instructions on how to contribute.

13
Gemfile
View file

@ -121,6 +121,10 @@ gem 'hairtrigger'
gem 'procore-sift' gem 'procore-sift'
# parse email
gem 'email_reply_trimmer'
gem 'html2text'
group :production, :staging do group :production, :staging do
# we dont want request timing out in development while using byebug # we dont want request timing out in development while using byebug
gem 'rack-timeout' gem 'rack-timeout'
@ -144,12 +148,20 @@ group :test do
gem 'cypress-on-rails', '~> 1.0' gem 'cypress-on-rails', '~> 1.0'
# fast cleaning of database # fast cleaning of database
gem 'database_cleaner' gem 'database_cleaner'
# mock http calls
gem 'webmock'
end end
group :development, :test do group :development, :test do
# TODO: is this needed ?
# errors thrown by devise password gem
gem 'flay'
gem 'rspec'
# for error thrown by devise password gem
gem 'active_record_query_trace' gem 'active_record_query_trace'
gem 'bundle-audit', require: false gem 'bundle-audit', require: false
gem 'byebug', platform: :mri gem 'byebug', platform: :mri
gem 'climate_control'
gem 'factory_bot_rails' gem 'factory_bot_rails'
gem 'faker' gem 'faker'
gem 'listen' gem 'listen'
@ -165,5 +177,4 @@ group :development, :test do
gem 'simplecov', '0.17.1', require: false gem 'simplecov', '0.17.1', require: false
gem 'spring' gem 'spring'
gem 'spring-watcher-listen' gem 'spring-watcher-listen'
gem 'webmock'
end end

View file

@ -9,63 +9,63 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (6.1.4.1) actioncable (6.1.4.3)
actionpack (= 6.1.4.1) actionpack (= 6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.1) actionmailbox (6.1.4.3)
actionpack (= 6.1.4.1) actionpack (= 6.1.4.3)
activejob (= 6.1.4.1) activejob (= 6.1.4.3)
activerecord (= 6.1.4.1) activerecord (= 6.1.4.3)
activestorage (= 6.1.4.1) activestorage (= 6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
mail (>= 2.7.1) mail (>= 2.7.1)
actionmailer (6.1.4.1) actionmailer (6.1.4.3)
actionpack (= 6.1.4.1) actionpack (= 6.1.4.3)
actionview (= 6.1.4.1) actionview (= 6.1.4.3)
activejob (= 6.1.4.1) activejob (= 6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (6.1.4.1) actionpack (6.1.4.3)
actionview (= 6.1.4.1) actionview (= 6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
rack (~> 2.0, >= 2.0.9) rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.1) actiontext (6.1.4.3)
actionpack (= 6.1.4.1) actionpack (= 6.1.4.3)
activerecord (= 6.1.4.1) activerecord (= 6.1.4.3)
activestorage (= 6.1.4.1) activestorage (= 6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (6.1.4.1) actionview (6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (6.1.4.1) activejob (6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (6.1.4.1) activemodel (6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
activerecord (6.1.4.1) activerecord (6.1.4.3)
activemodel (= 6.1.4.1) activemodel (= 6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
activerecord-import (1.2.0) activerecord-import (1.2.0)
activerecord (>= 3.2) activerecord (>= 3.2)
activestorage (6.1.4.1) activestorage (6.1.4.3)
actionpack (= 6.1.4.1) actionpack (= 6.1.4.3)
activejob (= 6.1.4.1) activejob (= 6.1.4.3)
activerecord (= 6.1.4.1) activerecord (= 6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
marcel (~> 1.0.0) marcel (~> 1.0.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (6.1.4.1) activesupport (6.1.4.3)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
@ -90,21 +90,21 @@ GEM
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.2) ast (2.4.2)
attr_extras (6.2.4) attr_extras (6.2.4)
aws-eventstream (1.1.1) aws-eventstream (1.2.0)
aws-partitions (1.482.0) aws-partitions (1.513.0)
aws-sdk-core (3.119.0) aws-sdk-core (3.121.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0) aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-kms (1.46.0) aws-sdk-kms (1.49.0)
aws-sdk-core (~> 3, >= 3.119.0) aws-sdk-core (~> 3, >= 3.120.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.98.0) aws-sdk-s3 (1.103.0)
aws-sdk-core (~> 3, >= 3.119.0) aws-sdk-core (~> 3, >= 3.120.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.4)
aws-sigv4 (1.2.4) aws-sigv4 (1.4.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.1) azure-storage-blob (2.0.1)
azure-storage-common (~> 2.0) azure-storage-common (~> 2.0)
@ -119,28 +119,29 @@ GEM
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
bcrypt (3.1.16) bcrypt (3.1.16)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.7.7) bootsnap (1.9.1)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (5.1.1) brakeman (5.1.1)
browser (5.3.1) browser (5.3.1)
builder (3.2.4) builder (3.2.4)
bullet (6.1.4) bullet (6.1.5)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
bundle-audit (0.1.0) bundle-audit (0.1.0)
bundler-audit bundler-audit
bundler-audit (0.8.0) bundler-audit (0.9.0.1)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
climate_control (1.0.1)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.22.0) commonmarker (0.23.2)
concurrent-ruby (1.1.9) concurrent-ruby (1.1.9)
connection_pool (2.2.5) connection_pool (2.2.5)
crack (0.4.5) crack (0.4.5)
rexml rexml
crass (1.0.6) crass (1.0.6)
cypress-on-rails (1.10.1) cypress-on-rails (1.11.0)
rack rack
database_cleaner (2.0.1) database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0) database_cleaner-active_record (~> 2.0.0)
@ -150,7 +151,7 @@ GEM
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
datetime_picker_rails (0.0.7) datetime_picker_rails (0.0.7)
momentjs-rails (>= 2.8.1) momentjs-rails (>= 2.8.1)
ddtrace (0.51.1) ddtrace (0.53.0)
ffi (~> 1.0) ffi (~> 1.0)
msgpack msgpack
declarative (0.0.20) declarative (0.0.20)
@ -174,12 +175,14 @@ GEM
dotenv-rails (2.7.6) dotenv-rails (2.7.6)
dotenv (= 2.7.6) dotenv (= 2.7.6)
railties (>= 3.2) railties (>= 3.2)
down (5.2.3) down (5.2.4)
addressable (~> 2.8) addressable (~> 2.8)
ecma-re-validator (0.3.0) ecma-re-validator (0.3.0)
regexp_parser (~> 2.0) regexp_parser (~> 2.0)
email_reply_trimmer (0.1.13)
erubi (1.10.0) erubi (1.10.0)
et-orbi (1.2.4) erubis (2.7.0)
et-orbi (1.2.5)
tzinfo tzinfo
execjs (2.8.1) execjs (2.8.1)
facebook-messenger (2.0.1) facebook-messenger (2.0.1)
@ -190,7 +193,7 @@ GEM
factory_bot_rails (6.2.0) factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0) factory_bot (~> 6.2.0)
railties (>= 5.0.0) railties (>= 5.0.0)
faker (2.18.0) faker (2.19.0)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
faraday (1.0.1) faraday (1.0.1)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
@ -198,10 +201,15 @@ GEM
faraday (~> 1.0) faraday (~> 1.0)
fcm (1.0.3) fcm (1.0.3)
faraday (~> 1) faraday (~> 1)
ffi (1.15.3) ffi (1.15.4)
flag_shih_tzu (0.3.23) flag_shih_tzu (0.3.23)
flay (2.12.1)
erubis (~> 2.7.0)
path_expander (~> 1.0)
ruby_parser (~> 3.0)
sexp_processor (~> 4.0)
foreman (0.87.2) foreman (0.87.2)
fugit (1.5.0) fugit (1.5.2)
et-orbi (~> 1.1, >= 1.1.8) et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.4) raabro (~> 1.4)
gapic-common (0.3.4) gapic-common (0.3.4)
@ -210,9 +218,9 @@ GEM
googleapis-common-protos-types (>= 1.0.4, < 2.0) googleapis-common-protos-types (>= 1.0.4, < 2.0)
googleauth (~> 0.9) googleauth (~> 0.9)
grpc (~> 1.25) grpc (~> 1.25)
geocoder (1.6.7) geocoder (1.7.0)
gli (2.20.1) gli (2.20.1)
globalid (0.5.2) globalid (1.0.0)
activesupport (>= 5.0) activesupport (>= 5.0)
google-apis-core (0.4.1) google-apis-core (0.4.1)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
@ -223,9 +231,9 @@ GEM
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
rexml rexml
webrick webrick
google-apis-iamcredentials_v1 (0.6.0) google-apis-iamcredentials_v1 (0.7.0)
google-apis-core (>= 0.4, < 2.a) google-apis-core (>= 0.4, < 2.a)
google-apis-storage_v1 (0.6.0) google-apis-storage_v1 (0.8.0)
google-apis-core (>= 0.4, < 2.a) google-apis-core (>= 0.4, < 2.a)
google-cloud-core (1.6.0) google-cloud-core (1.6.0)
google-cloud-env (~> 1.0) google-cloud-env (~> 1.0)
@ -238,7 +246,7 @@ GEM
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (1.5.0) google-cloud-env (1.5.0)
faraday (>= 0.17.3, < 2.0) faraday (>= 0.17.3, < 2.0)
google-cloud-errors (1.1.0) google-cloud-errors (1.2.0)
google-cloud-storage (1.34.1) google-cloud-storage (1.34.1)
addressable (~> 2.5) addressable (~> 2.5)
digest-crc (~> 0.4) digest-crc (~> 0.4)
@ -247,32 +255,32 @@ GEM
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
google-protobuf (3.17.3) google-protobuf (3.19.2)
google-protobuf (3.17.3-universal-darwin) google-protobuf (3.19.2-x86_64-darwin)
google-protobuf (3.17.3-x86_64-linux) google-protobuf (3.19.2-x86_64-linux)
googleapis-common-protos (1.3.11) googleapis-common-protos (1.3.12)
google-protobuf (~> 3.14) google-protobuf (~> 3.14)
googleapis-common-protos-types (>= 1.0.6, < 2.0) googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27) grpc (~> 1.27)
googleapis-common-protos-types (1.1.0) googleapis-common-protos-types (1.2.0)
google-protobuf (~> 3.14) google-protobuf (~> 3.14)
googleauth (0.17.0) googleauth (0.17.1)
faraday (>= 0.17.3, < 2.0) faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
memoist (~> 0.16) memoist (~> 0.16)
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (~> 0.14) signet (~> 0.15)
groupdate (5.2.2) groupdate (5.2.2)
activesupport (>= 5) activesupport (>= 5)
grpc (1.38.0) grpc (1.41.0)
google-protobuf (~> 3.15) google-protobuf (~> 3.17)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.38.0-universal-darwin) grpc (1.41.0-universal-darwin)
google-protobuf (~> 3.15) google-protobuf (~> 3.17)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.38.0-x86_64-linux) grpc (1.41.0-x86_64-linux)
google-protobuf (~> 3.15) google-protobuf (~> 3.17)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1) haikunator (1.1.1)
hairtrigger (0.2.24) hairtrigger (0.2.24)
@ -283,14 +291,16 @@ GEM
hashdiff (1.0.1) hashdiff (1.0.1)
hashie (4.1.0) hashie (4.1.0)
hkdf (0.3.0) hkdf (0.3.0)
html2text (0.2.1)
nokogiri (~> 1.6)
http-accept (1.7.0) http-accept (1.7.0)
http-cookie (1.0.4) http-cookie (1.0.4)
domain_name (~> 0.5) domain_name (~> 0.5)
httparty (0.18.1) httparty (0.20.0)
mime-types (~> 3.0) mime-types (~> 3.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.8.10) i18n (1.8.11)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.12.1) image_processing (1.12.1)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
@ -310,7 +320,7 @@ GEM
hana (~> 1.3) hana (~> 1.3)
regexp_parser (~> 2.0) regexp_parser (~> 2.0)
uri_template (~> 0.7) uri_template (~> 0.7)
jwt (2.2.3) jwt (2.3.0)
kaminari (1.2.1) kaminari (1.2.1)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1) kaminari-actionview (= 1.2.1)
@ -331,28 +341,28 @@ GEM
addressable (~> 2.7) addressable (~> 2.7)
letter_opener (1.7.0) letter_opener (1.7.0)
launchy (~> 2.2) launchy (~> 2.2)
line-bot-api (1.21.0) line-bot-api (1.22.0)
liquid (5.0.1) liquid (5.1.0)
listen (3.6.0) listen (3.7.0)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.12.0) loofah (2.13.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.1) mail (2.7.1)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
marcel (1.0.1) marcel (1.0.2)
maxminddb (0.1.22) maxminddb (0.1.22)
memoist (0.16.2) memoist (0.16.2)
method_source (1.0.0) method_source (1.0.0)
mime-types (3.3.1) mime-types (3.3.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2021.0704) mime-types-data (3.2021.0901)
mini_magick (4.11.0) mini_magick (4.11.0)
mini_mime (1.1.1) mini_mime (1.1.2)
mini_portile2 (2.5.3) mini_portile2 (2.5.3)
minitest (5.14.4) minitest (5.15.0)
mock_redis (0.28.0) mock_redis (0.29.0)
ruby2_keywords ruby2_keywords
momentjs-rails (2.20.1) momentjs-rails (2.20.1)
railties (>= 3.1) railties (>= 3.1)
@ -363,7 +373,7 @@ GEM
net-http-persistent (4.0.1) net-http-persistent (4.0.1)
connection_pool (~> 2.2) connection_pool (~> 2.2)
netrc (0.11.0) netrc (0.11.0)
newrelic_rpm (7.2.0) newrelic_rpm (8.0.0)
nio4r (2.5.8) nio4r (2.5.8)
nokogiri (1.11.7) nokogiri (1.11.7)
mini_portile2 (~> 2.5.0) mini_portile2 (~> 2.5.0)
@ -377,9 +387,10 @@ GEM
oauth (0.5.6) oauth (0.5.6)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.1) os (1.1.1)
parallel (1.20.1) parallel (1.21.0)
parser (3.0.2.0) parser (3.0.2.0)
ast (~> 2.4.1) ast (~> 2.4.1)
path_expander (1.1.0)
pg (1.2.3) pg (1.2.3)
procore-sift (0.16.0) procore-sift (0.16.0)
rails (> 4.2.0) rails (> 4.2.0)
@ -389,12 +400,12 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.6)
puma (5.4.0) puma (5.5.2)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.1.0) pundit (2.1.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.5.2) racc (1.6.0)
rack (2.2.3) rack (2.2.3)
rack-attack (6.5.0) rack-attack (6.5.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
@ -405,29 +416,29 @@ GEM
rack-test (1.1.0) rack-test (1.1.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-timeout (0.6.0) rack-timeout (0.6.0)
rails (6.1.4.1) rails (6.1.4.3)
actioncable (= 6.1.4.1) actioncable (= 6.1.4.3)
actionmailbox (= 6.1.4.1) actionmailbox (= 6.1.4.3)
actionmailer (= 6.1.4.1) actionmailer (= 6.1.4.3)
actionpack (= 6.1.4.1) actionpack (= 6.1.4.3)
actiontext (= 6.1.4.1) actiontext (= 6.1.4.3)
actionview (= 6.1.4.1) actionview (= 6.1.4.3)
activejob (= 6.1.4.1) activejob (= 6.1.4.3)
activemodel (= 6.1.4.1) activemodel (= 6.1.4.3)
activerecord (= 6.1.4.1) activerecord (= 6.1.4.3)
activestorage (= 6.1.4.1) activestorage (= 6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 6.1.4.1) railties (= 6.1.4.3)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.4.1) rails-html-sanitizer (1.4.2)
loofah (~> 2.3) loofah (~> 2.3)
railties (6.1.4.1) railties (6.1.4.3)
actionpack (= 6.1.4.1) actionpack (= 6.1.4.3)
activesupport (= 6.1.4.1) activesupport (= 6.1.4.3)
method_source method_source
rake (>= 0.13) rake (>= 0.13)
thor (~> 1.0) thor (~> 1.0)
@ -454,6 +465,10 @@ GEM
netrc (~> 0.8) netrc (~> 0.8)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.5) rexml (3.2.5)
rspec (3.10.0)
rspec-core (~> 3.10.0)
rspec-expectations (~> 3.10.0)
rspec-mocks (~> 3.10.0)
rspec-core (3.10.1) rspec-core (3.10.1)
rspec-support (~> 3.10.0) rspec-support (~> 3.10.0)
rspec-expectations (3.10.1) rspec-expectations (3.10.1)
@ -462,7 +477,7 @@ GEM
rspec-mocks (3.10.2) rspec-mocks (3.10.2)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0) rspec-support (~> 3.10.0)
rspec-rails (5.0.1) rspec-rails (5.0.2)
actionpack (>= 5.2) actionpack (>= 5.2)
activesupport (>= 5.2) activesupport (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
@ -471,35 +486,34 @@ GEM
rspec-mocks (~> 3.10) rspec-mocks (~> 3.10)
rspec-support (~> 3.10) rspec-support (~> 3.10)
rspec-support (3.10.2) rspec-support (3.10.2)
rubocop (1.18.4) rubocop (1.22.1)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.0.0.0) parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml rexml
rubocop-ast (>= 1.8.0, < 2.0) rubocop-ast (>= 1.12.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0) unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.8.0) rubocop-ast (1.12.0)
parser (>= 3.0.1.1) parser (>= 3.0.1.1)
rubocop-performance (1.11.4) rubocop-performance (1.11.5)
rubocop (>= 1.7.0, < 2.0) rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0) rubocop-ast (>= 0.4.0)
rubocop-rails (2.11.3) rubocop-rails (2.12.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0) rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (2.4.0) rubocop-rspec (2.5.0)
rubocop (~> 1.0) rubocop (~> 1.19)
rubocop-ast (>= 1.1.0)
ruby-progressbar (1.11.0) ruby-progressbar (1.11.0)
ruby-vips (2.1.2) ruby-vips (2.1.3)
ffi (~> 1.12) ffi (~> 1.12)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
ruby2ruby (2.4.4) ruby2ruby (2.4.4)
ruby_parser (~> 3.1) ruby_parser (~> 3.1)
sexp_processor (~> 4.6) sexp_processor (~> 4.6)
ruby_parser (3.16.0) ruby_parser (3.17.0)
sexp_processor (~> 4.15, >= 4.15.1) sexp_processor (~> 4.15, >= 4.15.1)
sassc (2.4.0) sassc (2.4.0)
ffi (~> 1.9) ffi (~> 1.9)
@ -509,38 +523,38 @@ GEM
sprockets (> 3.0) sprockets (> 3.0)
sprockets-rails sprockets-rails
tilt tilt
scout_apm (4.1.1) scout_apm (4.1.2)
parser parser
seed_dump (3.3.1) seed_dump (3.3.1)
activerecord (>= 4) activerecord (>= 4)
activesupport (>= 4) activesupport (>= 4)
selectize-rails (0.12.6) selectize-rails (0.12.6)
semantic_range (3.0.0) semantic_range (3.0.0)
sentry-rails (4.6.4) sentry-rails (4.7.3)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby-core (~> 4.6.0) sentry-ruby-core (~> 4.7.0)
sentry-ruby (4.6.4) sentry-ruby (4.7.3)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
faraday (>= 1.0) faraday (>= 1.0)
sentry-ruby-core (= 4.6.4) sentry-ruby-core (= 4.7.3)
sentry-ruby-core (4.6.4) sentry-ruby-core (4.7.3)
concurrent-ruby concurrent-ruby
faraday faraday
sentry-sidekiq (4.6.4) sentry-sidekiq (4.7.3)
sentry-ruby-core (~> 4.6.0) sentry-ruby-core (~> 4.7.0)
sidekiq (>= 3.0) sidekiq (>= 3.0)
sexp_processor (4.15.3) sexp_processor (4.15.3)
shoulda-matchers (5.0.0) shoulda-matchers (5.0.0)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
sidekiq (6.2.1) sidekiq (6.2.2)
connection_pool (>= 2.2.2) connection_pool (>= 2.2.2)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.2.0) redis (>= 4.2.0)
sidekiq-cron (1.2.0) sidekiq-cron (1.2.0)
fugit (~> 1.1) fugit (~> 1.1)
sidekiq (>= 4.2.1) sidekiq (>= 4.2.1)
signet (0.15.0) signet (0.16.0)
addressable (~> 2.3) addressable (~> 2.8)
faraday (>= 0.17.3, < 2.0) faraday (>= 0.17.3, < 2.0)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 3.0)
multi_json (~> 1.10) multi_json (~> 1.10)
@ -562,9 +576,9 @@ GEM
sprockets (4.0.2) sprockets (4.0.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
sprockets-rails (3.2.2) sprockets-rails (3.4.2)
actionpack (>= 4.0) actionpack (>= 5.2)
activesupport (>= 4.0) activesupport (>= 5.2)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
squasher (0.6.2) squasher (0.6.2)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
@ -583,15 +597,15 @@ GEM
oauth oauth
tzinfo (2.0.4) tzinfo (2.0.4)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.1) tzinfo-data (1.2021.3)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uber (0.1.0) uber (0.1.0)
uglifier (4.2.0) uglifier (4.2.0)
execjs (>= 0.3.0, < 3) execjs (>= 0.3.0, < 3)
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.7.7) unf_ext (0.0.8)
unicode-display_width (2.0.0) unicode-display_width (2.1.0)
uniform_notifier (1.14.2) uniform_notifier (1.14.2)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (4.0.0) valid_email2 (4.0.0)
@ -604,11 +618,11 @@ GEM
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
webmock (3.13.0) webmock (3.14.0)
addressable (>= 2.3.6) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.4.0) webpacker (5.4.3)
activesupport (>= 5.2) activesupport (>= 5.2)
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
@ -621,7 +635,7 @@ GEM
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.0) wisper (2.0.0)
zeitwerk (2.4.2) zeitwerk (2.5.1)
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin-20
@ -647,6 +661,7 @@ DEPENDENCIES
bullet bullet
bundle-audit bundle-audit
byebug byebug
climate_control
commonmarker commonmarker
cypress-on-rails (~> 1.0) cypress-on-rails (~> 1.0)
database_cleaner database_cleaner
@ -656,11 +671,13 @@ DEPENDENCIES
devise_token_auth devise_token_auth
dotenv-rails dotenv-rails
down (~> 5.0) down (~> 5.0)
email_reply_trimmer
facebook-messenger facebook-messenger
factory_bot_rails factory_bot_rails
faker faker
fcm fcm
flag_shih_tzu flag_shih_tzu
flay
foreman foreman
geocoder geocoder
google-cloud-dialogflow google-cloud-dialogflow
@ -669,6 +686,7 @@ DEPENDENCIES
haikunator haikunator
hairtrigger hairtrigger
hashie hashie
html2text
image_processing image_processing
jbuilder jbuilder
json_refs json_refs
@ -696,6 +714,7 @@ DEPENDENCIES
redis-namespace redis-namespace
responders responders
rest-client rest-client
rspec
rspec-rails (~> 5.0.0) rspec-rails (~> 5.0.0)
rubocop rubocop
rubocop-performance rubocop-performance

View file

@ -1,7 +1,11 @@
The MIT License (MIT)
Copyright (c) 2017-2021 Chatwoot Inc. Copyright (c) 2017-2021 Chatwoot Inc.
Portions of this software are licensed as follows:
* All content that resides under the "enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "enterprise/LICENSE".
* All third party components incorporated into the Chatwoot Software are licensed under the original license provided by the owner of the applicable component.
* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights

View file

@ -6,7 +6,10 @@
<p align="center"> <p align="center">
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku"> <a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/> <img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a> </a>
</p> </p>
@ -17,7 +20,6 @@ ___
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge"> <img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
<img src="https://img.shields.io/github/license/chatwoot/chatwoot" alt="License">
<img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month"> <img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month">
<a title="Crowdin" target="_self" href="https://chatwoot.crowdin.com/chatwoot"><img src="https://badges.crowdin.net/e/37ced7eba411064bd792feb3b7a28b16/localized.svg"></a> <a title="Crowdin" target="_self" href="https://chatwoot.crowdin.com/chatwoot"><img src="https://badges.crowdin.net/e/37ced7eba411064bd792feb3b7a28b16/localized.svg"></a>
<a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/discord/647412545203994635" alt="Discord"></a> <a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/discord/647412545203994635" alt="Discord"></a>
@ -26,34 +28,39 @@ ___
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a> <a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a>
</p> </p>
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/chatwoot-dashboard-assets.png" width="100%" alt="Chat dashboard"/> <img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>
Chatwoot is an open-source omnichannel customer support software. The development of Chatwoot started in 2016. It failed to succeed as a business and eventually shut up shop in 2017. During 2019 #Hacktoberfest, the maintainers decided to make it open-source, instead of letting the code rust in a private repo. With a pleasant surprise, Chatwoot became a trending project on Hacker News and best of all, got lots of love from the community.
Now, a failed project is back on track and the prospects are looking great. The team is back to working on the project and this time, we are building it in the open. Thanks to the ideas and contributions from the community.
Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
## Features ## Features
Chatwoot gives an integrated view of conversations happening in different communication channels. Chatwoot supports the following conversation channels:
It supports the following conversation channels:
- **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support. - **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support.
- **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page. - **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page.
- **Instagram**: Connect your Instagram profile and start replying to the direct messages.
- **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned. - **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned.
- **Whatsapp**: Connect your Whatsapp business account and manage the conversation in Chatwoot - **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard.
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot - **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot.
- **Line**: Connect your Line account and manage the conversations in Chatwoot.
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
- **API Channel**: Build custom communication channels using our API channel. - **API Channel**: Build custom communication channels using our API channel.
- **Email (beta)**: Forward all your email queries to Chatwoot and view it in our integrated dashboard. - **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
And more.
Other features include: Other features include:
- **Multi-brand inboxes**: Manage multiple brands or pages using a single dashboard. - **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes.
- **Private notes**: Inter team communication is possible using private notes in a conversation. - **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow.
- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions. - **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
- **Conversation Labels**: Use conversation labelling to create custom workflows. - **Conversation Labels**: Use conversation labels to create custom workflows.
- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load. - **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot would send an email to the customer under the agent name so that the user can continue the conversation over the email. - **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email.
- **Multi-lingual support**: Chatwoot supports 10+ languages. - **Multi-lingual support**: Chatwoot supports 10+ languages.
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoots webhooks and APIs. - **Powerful API & Webhooks**: Extend the capability of the software using Chatwoots webhooks and APIs.
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard. - **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
@ -81,9 +88,18 @@ Deploying Chatwoot to Heroku is a breeze. It's as simple as clicking this button
Follow this [link](https://www.chatwoot.com/docs/environment-variables) to understand setting the correct environment variables for the app to work with all the features. There might be breakages if you do not set the relevant environment variables. Follow this [link](https://www.chatwoot.com/docs/environment-variables) to understand setting the correct environment variables for the app to work with all the features. There might be breakages if you do not set the relevant environment variables.
### DigitalOcean 1-Click Kubernetes deployment
Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app.
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
### Other deployment options ### Other deployment options
Please follow [deployment architecture guide](https://www.chatwoot.com/docs/deployment/architecture) to deploy with Docker or Caprover. For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
## Security ## Security
@ -102,4 +118,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2021, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2022, Chatwoot Inc - Released under the MIT License.

View file

@ -42,17 +42,16 @@ class ContactMergeAction
end end
def merge_and_remove_mergee_contact def merge_and_remove_mergee_contact
mergable_attribute_keys = %w[identifier name email phone_number custom_attributes] mergable_attribute_keys = %w[identifier name email phone_number additional_attributes custom_attributes]
base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank
mergee_contact_attributes = mergee_contact.attributes.slice(*mergable_attribute_keys).compact_blank mergee_contact_attributes = mergee_contact.attributes.slice(*mergable_attribute_keys).compact_blank
# attributes in base contact are given preference # attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes) merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
# retaining old pubsub token to notify the contacts that are listening
mergee_pubsub_token = mergee_contact.pubsub_token
@mergee_contact.destroy! @mergee_contact.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact, tokens: [mergee_pubsub_token]) Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes) @base_contact.update!(merged_attributes)
end end
end end

View file

@ -33,7 +33,8 @@ class ContactBuilder
phone_number: contact_attributes[:phone_number], phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email], email: contact_attributes[:email],
identifier: contact_attributes[:identifier], identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:additional_attributes] additional_attributes: contact_attributes[:additional_attributes],
custom_attributes: contact_attributes[:custom_attributes]
) )
end end

View file

@ -4,7 +4,7 @@ class ContactInboxBuilder
def perform def perform
@contact = Contact.find(contact_id) @contact = Contact.find(contact_id)
@inbox = @contact.account.inboxes.find(inbox_id) @inbox = @contact.account.inboxes.find(inbox_id)
return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api'].include? @inbox.channel_type return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
source_id = @source_id || generate_source_id source_id = @source_id || generate_source_id
create_contact_inbox(source_id) if source_id.present? create_contact_inbox(source_id) if source_id.present?
@ -14,12 +14,20 @@ class ContactInboxBuilder
def generate_source_id def generate_source_id
return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms' return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms'
return wa_source_id if @inbox.channel_type == 'Channel::Whatsapp'
return @contact.email if @inbox.channel_type == 'Channel::Email' return @contact.email if @inbox.channel_type == 'Channel::Email'
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api' return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api'
nil nil
end end
def wa_source_id
return unless @contact.phone_number
# whatsapp doesn't want the + in e164 format
"#{@contact.phone_number}.delete('+')"
end
def twilio_source_id def twilio_source_id
return unless @contact.phone_number return unless @contact.phone_number

View file

@ -4,10 +4,11 @@
# based on this we are showing "not sent from chatwoot" message in frontend # based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages. # Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Facebook::MessageBuilder class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :response attr_reader :response
def initialize(response, inbox, outgoing_echo: false) def initialize(response, inbox, outgoing_echo: false)
super()
@response = response @response = response
@inbox = inbox @inbox = inbox
@outgoing_echo = outgoing_echo @outgoing_echo = outgoing_echo
@ -47,30 +48,12 @@ class Messages::Facebook::MessageBuilder
def build_message def build_message
@message = conversation.messages.create!(message_params) @message = conversation.messages.create!(message_params)
@attachments.each do |attachment| @attachments.each do |attachment|
process_attachment(attachment) process_attachment(attachment)
end end
end end
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
def attach_file(attachment, file_url)
attachment_file = Down.download(
file_url
)
attachment.file.attach(
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
def ensure_contact_avatar def ensure_contact_avatar
return if contact_params[:remote_avatar_url].blank? return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached? return if @contact.avatar.attached?
@ -89,28 +72,6 @@ class Messages::Facebook::MessageBuilder
)) ))
end end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
def location_params(attachment) def location_params(attachment)
lat = attachment['payload']['coordinates']['lat'] lat = attachment['payload']['coordinates']['lat']
long = attachment['payload']['coordinates']['long'] long = attachment['payload']['coordinates']['long']

View file

@ -0,0 +1,145 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
Sentry.capture_exception(e)
true
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def message_content
@messaging[:message][:text]
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
@message = conversation.messages.create!(message_params)
attachments.each do |attachment|
process_attachment(attachment)
end
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
}
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact
}
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end

View file

@ -8,14 +8,17 @@ class Messages::MessageBuilder
@conversation = conversation @conversation = conversation
@user = user @user = user
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
@attachments = params[:attachments] @attachments = params[:attachments]
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to) @in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
end end
def perform def perform
@message = @conversation.messages.build(message_params) @message = @conversation.messages.build(message_params)
process_attachments process_attachments
process_emails
@message.save! @message.save!
@message @message
end end
@ -34,6 +37,16 @@ class Messages::MessageBuilder
end end
end end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails]
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails]
@message.content_attributes[:cc_emails] = cc_emails
@message.content_attributes[:bcc_emails] = bcc_emails
end
def message_type def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes' raise StandardError, 'Incoming messages are only allowed in Api inboxes'

View file

@ -0,0 +1,42 @@
class Messages::Messenger::MessageBuilder
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
def attach_file(attachment, file_url)
attachment_file = Down.download(
file_url
)
attachment.file.attach(
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
end

View file

@ -68,15 +68,14 @@ class V2::ReportBuilder
.count .count
end end
# unscoped removes all scopes added to a model previously
def incoming_messages_count def incoming_messages_count
scope.messages.unscoped.where(account_id: account.id).incoming scope.messages.incoming.unscope(:order)
.group_by_day(:created_at, range: range, default_value: 0) .group_by_day(:created_at, range: range, default_value: 0)
.count .count
end end
def outgoing_messages_count def outgoing_messages_count
scope.messages.unscoped.where(account_id: account.id).outgoing scope.messages.outgoing.unscope(:order)
.group_by_day(:created_at, range: range, default_value: 0) .group_by_day(:created_at, range: range, default_value: 0)
.count .count
end end

View file

@ -31,7 +31,7 @@ class RoomChannel < ApplicationCable::Channel
def current_user def current_user
@current_user ||= if params[:user_id].blank? @current_user ||= if params[:user_id].blank?
Contact.find_by!(pubsub_token: @pubsub_token) ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
else else
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id]) User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
end end

View file

@ -2,6 +2,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index] before_action :fetch_agent, except: [:create, :index]
before_action :check_authorization before_action :check_authorization
before_action :find_user, only: [:create] before_action :find_user, only: [:create]
before_action :validate_limit, only: [:create]
before_action :create_user, only: [:create] before_action :create_user, only: [:create]
before_action :save_account_user, only: [:create] before_action :save_account_user, only: [:create]
@ -9,21 +10,18 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents = agents @agents = agents
end end
def create; end
def update
@agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
end
def destroy def destroy
@agent.current_account_user.destroy @agent.current_account_user.destroy
head :ok head :ok
end end
def update
@agent.update!(agent_params.except(:role))
@agent.current_account_user.update!(role: agent_params[:role]) if agent_params[:role]
render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @agent }
end
def create
render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @user }
end
private private
def check_authorization def check_authorization
@ -47,26 +45,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end end
def save_account_user def save_account_user
AccountUser.create!( AccountUser.create!({
account_id: Current.account.id, account_id: Current.account.id,
user_id: @user.id, user_id: @user.id,
role: new_agent_params[:role],
inviter_id: current_user.id inviter_id: current_user.id
) }.merge({
role: new_agent_params[:role],
availability: new_agent_params[:availability],
auto_offline: new_agent_params[:auto_offline]
}.compact))
end end
def agent_params def agent_params
params.require(:agent).permit(:email, :name, :role) params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
end end
def new_agent_params def new_agent_params
# intial string ensures the password requirements are met # intial string ensures the password requirements are met
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
params.require(:agent).permit(:email, :name, :role) params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
.merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user) .merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user)
end end
def agents def agents
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] }) @agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
end end
def validate_limit
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents]
end
end end

View file

@ -0,0 +1,21 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
before_action :check_authorization
def index
@automation_rules = Current.account.automation_rules
end
def create
@automation_rule = Current.account.automation_rules.create(automation_rules_permit)
end
private
def automation_rules_permit
params.permit(
:name, :description, :event_name, :account_id,
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
actions: [:action_name, { action_params: [:intiated_at] }]
)
end
end

View file

@ -12,9 +12,10 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token: page_access_token page_access_token: page_access_token
) )
@facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel) @facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel)
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id) set_avatar(@facebook_inbox, page_id)
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Sentry.capture_exception(e)
end end
end end
@ -22,6 +23,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts')) @page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
end end
def set_instagram_id(page_access_token, facebook_channel)
fb_object = Koala::Facebook::API.new(page_access_token)
response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' })
return if response['instagram_business_account'].blank?
instagram_id = response['instagram_business_account']['id']
facebook_channel.update(instagram_id: instagram_id)
end
# get params[:inbox_id], current_account. params[:omniauth_token] # get params[:inbox_id], current_account. params[:omniauth_token]
def reauthorize_page def reauthorize_page
if @inbox&.facebook? if @inbox&.facebook?
@ -45,8 +55,13 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
def update_fb_page(fb_page_id, access_token) def update_fb_page(fb_page_id, access_token)
fb_page = get_fb_page(fb_page_id) fb_page = get_fb_page(fb_page_id)
ActiveRecord::Base.transaction do
fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token) fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token)
set_instagram_id(access_token, fb_page)
fb_page&.reauthorized! fb_page&.reauthorized!
rescue StandardError => e
Sentry.capture_exception(e)
end
end end
def get_fb_page(fb_page_id) def get_fb_page(fb_page_id)
@ -59,7 +74,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end end
def long_lived_token(omniauth_token) def long_lived_token(omniauth_token)
koala = Koala::Facebook::OAuth.new(ENV['FB_APP_ID'], ENV['FB_APP_SECRET']) koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', ''))
koala.exchange_access_token_info(omniauth_token)['access_token'] koala.exchange_access_token_info(omniauth_token)['access_token']
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.info e

View file

@ -28,7 +28,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
end end
def campaign_params def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :inbox_id, :sender_id, params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {}) :scheduled_at, audience: [:type, :id], trigger_rules: {})
end end
end end

View file

@ -33,7 +33,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
def canned_responses def canned_responses
if params[:search] if params[:search]
Current.account.canned_responses.where('short_code ILIKE ?', "#{params[:search]}%") Current.account.canned_responses.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%")
else else
Current.account.canned_responses Current.account.canned_responses
end end

View file

@ -0,0 +1,9 @@
class Api::V1::Accounts::Contacts::BaseController < Api::V1::Accounts::BaseController
before_action :ensure_contact
private
def ensure_contact
@contact = Current.account.contacts.find(params[:contact_id])
end
end

View file

@ -1,5 +1,4 @@
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController
before_action :ensure_contact
before_action :ensure_inbox, only: [:create] before_action :ensure_inbox, only: [:create]
def create def create
@ -13,8 +12,4 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
@inbox = Current.account.inboxes.find(params[:inbox_id]) @inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show? authorize @inbox, :show?
end end
def ensure_contact
@contact = Current.account.contacts.find(params[:contact_id])
end
end end

View file

@ -1,8 +1,8 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
def index def index
@conversations = Current.account.conversations.includes( @conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings :assignee, :contact, :inbox, :taggings
).where(inbox_id: inbox_ids, contact_id: permitted_params[:contact_id]) ).where(inbox_id: inbox_ids, contact_id: @contact.id)
end end
private private
@ -14,8 +14,4 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
[] []
end end
end end
def permitted_params
params.permit(:contact_id)
end
end end

View file

@ -1,13 +1,13 @@
class Api::V1::Accounts::Contacts::LabelsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Contacts::LabelsController < Api::V1::Accounts::Contacts::BaseController
include LabelConcern include LabelConcern
private private
def model def model
@model ||= Current.account.contacts.find(permitted_params[:contact_id]) @model ||= @contact
end end
def permitted_params def permitted_params
params.permit(:contact_id, labels: []) params.permit(labels: [])
end end
end end

View file

@ -0,0 +1,32 @@
class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts::BaseController
before_action :note, except: [:index, :create]
def index
@notes = @contact.notes.latest.includes(:user)
end
def create
@note = @contact.notes.create!(note_params)
end
def destroy
@note.destroy
head :ok
end
def show; end
def update
@note.update(note_params)
end
private
def note
@note ||= @contact.notes.find(params[:id])
end
def note_params
params.require(:note).permit(:content).merge({ contact_id: @contact.id, user_id: Current.user.id })
end
end

View file

@ -1,17 +1,19 @@
class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
include Sift include Sift
sort_on :email, type: :string sort_on :email, type: :string
sort_on :name, type: :string sort_on :name, internal_name: :order_on_name, type: :scope, scope_params: [:direction]
sort_on :phone_number, type: :string sort_on :phone_number, type: :string
sort_on :last_activity_at, type: :datetime sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
RESULTS_PER_PAGE = 15 RESULTS_PER_PAGE = 15
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes] before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search] before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@ -50,11 +52,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end def show; end
def filter
result = ::Contacts::FilterService.new(params.permit!, current_user).perform
contacts = result[:contacts]
@contacts_count = result[:count]
@contacts = fetch_contacts_with_conversation_count(contacts)
end
def contactable_inboxes def contactable_inboxes
@all_contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get @all_contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
@contactable_inboxes = @all_contactable_inboxes.select { |contactable_inbox| policy(contactable_inbox[:inbox]).show? } @contactable_inboxes = @all_contactable_inboxes.select { |contactable_inbox| policy(contactable_inbox[:inbox]).show? }
end end
# TODO : refactor this method into dedicated contacts/custom_attributes controller class and routes
def destroy_custom_attributes
@contact.custom_attributes = @contact.custom_attributes.excluding(params[:custom_attributes])
@contact.save!
end
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(contact_params) @contact = Current.account.contacts.new(contact_params)
@ -66,11 +81,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def update def update
@contact.assign_attributes(contact_update_params) @contact.assign_attributes(contact_update_params)
@contact.save! @contact.save!
rescue ActiveRecord::RecordInvalid => e
render json: {
message: e.record.errors.full_messages.join(', '),
contact: Current.account.contacts.find_by(email: contact_params[:email])
}, status: :unprocessable_entity
end end
def destroy def destroy
@ -91,10 +101,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def resolved_contacts def resolved_contacts
return @resolved_contacts if @resolved_contacts return @resolved_contacts if @resolved_contacts
@resolved_contacts = Current.account.contacts @resolved_contacts = Current.account.contacts.resolved_contacts
.where.not(email: [nil, ''])
.or(Current.account.contacts.where.not(phone_number: [nil, '']))
.or(Current.account.contacts.where.not(identifier: [nil, '']))
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present? @resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
@resolved_contacts @resolved_contacts
end end

View file

@ -2,7 +2,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include Events::Types include Events::Types
include DateRangeHelper include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create] before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :contact_inbox, only: [:create] before_action :contact_inbox, only: [:create]
def index def index
@ -31,6 +31,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def show; end def show; end
def filter
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations]
@conversations_count = result[:count]
end
def mute def mute
@conversation.mute! @conversation.mute!
head :ok head :ok
@ -60,17 +66,18 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def toggle_typing_status def toggle_typing_status
case params[:typing_status] case params[:typing_status]
when 'on' when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON) trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private])
when 'off' when 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF) trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private])
end end
head :ok head :ok
end end
def update_last_seen def update_last_seen
@conversation.agent_last_seen_at = DateTime.now.utc # rubocop:disable Rails/SkipsModelValidations
@conversation.assignee_last_seen_at = DateTime.now.utc if assignee? @conversation.update_column(:agent_last_seen_at, DateTime.now.utc)
@conversation.save! @conversation.update_column(:assignee_last_seen_at, DateTime.now.utc) if assignee?
# rubocop:enable Rails/SkipsModelValidations
end end
def custom_attributes def custom_attributes
@ -86,9 +93,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until] @conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end end
def trigger_typing_event(event) def trigger_typing_event(event, is_private)
user = current_user.presence || @resource user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)
end end
def conversation def conversation
@ -130,7 +137,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
contact_inbox_id: @contact_inbox.id, contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes, additional_attributes: additional_attributes,
custom_attributes: custom_attributes, custom_attributes: custom_attributes,
snoozed_until: params[:snoozed_until] snoozed_until: params[:snoozed_until],
assignee_id: params[:assignee_id],
team_id: params[:team_id]
}.merge(status) }.merge(status)
end end

View file

@ -25,9 +25,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
private private
def fetch_custom_attributes_definitions def fetch_custom_attributes_definitions
@custom_attribute_definitions = Current.account.custom_attribute_definitions.where( @custom_attribute_definitions = Current.account.custom_attribute_definitions.with_attribute_model(permitted_params[:attribute_model])
attribute_model: permitted_params[:attribute_model] || DEFAULT_ATTRIBUTE_MODEL
)
end end
def fetch_custom_attribute_definition def fetch_custom_attribute_definition
@ -41,7 +39,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
:attribute_display_type, :attribute_display_type,
:attribute_key, :attribute_key,
:attribute_model, :attribute_model,
:default_value attribute_values: []
) )
end end

View file

@ -1,11 +1,11 @@
class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseController class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseController
before_action :fetch_inbox before_action :fetch_inbox
before_action :current_agents_ids, only: [:update] before_action :current_agents_ids, only: [:create, :update]
def create def create
authorize @inbox, :create? authorize @inbox, :create?
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @inbox.add_member(user_id) } agents_to_be_added_ids.map { |user_id| @inbox.add_member(user_id) }
end end
fetch_updated_agents fetch_updated_agents
end end

View file

@ -1,4 +1,5 @@
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create] before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
# we are already handling the authorization in fetch inbox # we are already handling the authorization in fetch inbox
@ -41,9 +42,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def update def update
@inbox.update(permitted_params.except(:channel)) @inbox.update(permitted_params.except(:channel))
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
channel_attributes = get_channel_attributes(@inbox.channel_type) channel_attributes = get_channel_attributes(@inbox.channel_type)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel]) if permitted_params(channel_attributes)[:channel].present?
# 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'
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
update_channel_feature_flags update_channel_feature_flags
end end
@ -96,6 +102,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
Current.account.line_channels.create!(permitted_params(Channel::Line::EDITABLE_ATTRS)[:channel].except(:type)) Current.account.line_channels.create!(permitted_params(Channel::Line::EDITABLE_ATTRS)[:channel].except(:type))
when 'telegram' when 'telegram'
Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type)) Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type))
when 'whatsapp'
Current.account.whatsapp_channels.create!(permitted_params(Channel::Whatsapp::EDITABLE_ATTRS)[:channel].except(:type))
end end
end end
@ -110,23 +118,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def permitted_params(channel_attributes = []) def permitted_params(channel_attributes = [])
params.permit( params.permit(
:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
channel: [:type, *channel_attributes] channel: [:type, *channel_attributes]
) )
end end
def get_channel_attributes(channel_type) def get_channel_attributes(channel_type)
case channel_type if channel_type.constantize.const_defined?('EDITABLE_ATTRS')
when 'Channel::WebWidget' channel_type.constantize::EDITABLE_ATTRS.presence
Channel::WebWidget::EDITABLE_ATTRS
when 'Channel::Api'
Channel::Api::EDITABLE_ATTRS
when 'Channel::Email'
Channel::Email::EDITABLE_ATTRS
when 'Channel::Telegram'
Channel::Telegram::EDITABLE_ATTRS
when 'Channel::Line'
Channel::Line::EDITABLE_ATTRS
else else
[] []
end end

View file

@ -8,7 +8,7 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@team_members = params[:user_ids].map { |user_id| @team.add_member(user_id) } @team_members = members_to_be_added_ids.map { |user_id| @team.add_member(user_id) }
end end
end end

View file

@ -55,7 +55,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def check_signup_enabled def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if ENV.fetch('ENABLE_ACCOUNT_SIGNUP', true) == 'false' raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end end
def pundit_user def pundit_user

View file

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

View file

@ -1,9 +1,7 @@
class Api::V1::ProfilesController < Api::BaseController class Api::V1::ProfilesController < Api::BaseController
before_action :set_user before_action :set_user
def show def show; end
render partial: 'api/v1/models/user.json.jbuilder', locals: { resource: @user }
end
def update def update
if password_params[:password].present? if password_params[:password].present?
@ -15,19 +13,31 @@ class Api::V1::ProfilesController < Api::BaseController
@user.update!(profile_params) @user.update!(profile_params)
end end
def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached?
head :ok
end
def availability
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
end
private private
def set_user def set_user
@user = current_user @user = current_user
end end
def availability_params
params.require(:profile).permit(:account_id, :availability)
end
def profile_params def profile_params
params.require(:profile).permit( params.require(:profile).permit(
:email, :email,
:name, :name,
:display_name, :display_name,
:avatar, :avatar,
:availability,
ui_settings: {} ui_settings: {}
) )
end end

View file

@ -1,5 +1,5 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
before_action :process_hmac before_action :process_hmac, only: [:update]
def show; end def show; end
@ -8,18 +8,35 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact: @contact, contact: @contact,
params: permitted_params.to_h.deep_symbolize_keys params: permitted_params.to_h.deep_symbolize_keys
) )
render json: contact_identify_action.perform @contact = contact_identify_action.perform
end
# TODO : clean up this with proper routes delete contacts/custom_attributes
def destroy_custom_attributes
@contact.custom_attributes = @contact.custom_attributes.excluding(params[:custom_attributes])
@contact.save!
render json: @contact
end end
private private
def process_hmac def process_hmac
return if params[:identifier_hash].blank? return unless should_verify_hmac?
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac?
@contact_inbox.update(hmac_verified: true) @contact_inbox.update(hmac_verified: true)
end end
def should_verify_hmac?
return false if params[:identifier_hash].blank? && !@web_widget.hmac_mandatory
# Taking an extra caution that the hmac is triggered whenever identifier is present
return false if params[:custom_attributes].present? && params[:identifier].blank?
true
end
def valid_hmac? def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest( params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256', 'sha256',

View file

@ -31,13 +31,18 @@ module RequestExceptionHandler
render json: { error: message }, status: :unprocessable_entity render json: { error: message }, status: :unprocessable_entity
end end
def render_payment_required(message)
render json: { error: message }, status: :payment_required
end
def render_internal_server_error(message) def render_internal_server_error(message)
render json: { error: message }, status: :internal_server_error render json: { error: message }, status: :internal_server_error
end end
def render_record_invalid(exception) def render_record_invalid(exception)
render json: { render json: {
message: exception.record.errors.full_messages.join(', ') message: exception.record.errors.full_messages.join(', '),
attributes: exception.record.errors.attribute_names
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end

View file

@ -27,9 +27,7 @@ class DashboardController < ActionController::Base
'API_CHANNEL_THUMBNAIL', 'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN', 'ANALYTICS_TOKEN',
'ANALYTICS_HOST' 'ANALYTICS_HOST'
).merge( ).merge(app_config)
APP_VERSION: Chatwoot.config[:version]
)
end end
def ensure_installation_onboarding def ensure_installation_onboarding
@ -39,4 +37,11 @@ class DashboardController < ActionController::Base
def allow_iframe_requests def allow_iframe_requests
response.headers.delete('X-Frame-Options') response.headers.delete('X-Frame-Options')
end end
def app_config
{ 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', '') }
end
end end

View file

@ -28,10 +28,7 @@ class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
end end
def create_reset_token_link(user) def create_reset_token_link(user)
raw, enc = Devise.token_generator.generate(user.class, :reset_password_token) token = user.send(:set_reset_password_token)
user.reset_password_token = enc "/app/auth/password/edit?config=default&redirect_url=&reset_password_token=#{token}"
user.reset_password_sent_at = Time.now.utc
user.save(validate: false)
"/app/auth/password/edit?config=default&redirect_url=&reset_password_token=#{raw}"
end end
end end

View file

@ -0,0 +1,21 @@
class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
def show
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
# ref: https://github.com/rubocop/rubocop/issues/7767
# rubocop:disable Style/HashTransformValues
@fb_config = InstallationConfig.where(name: @allowed_configs)
.pluck(:name, :serialized_value)
.map { |name, serialized_value| [name, serialized_value['value']] }
.to_h
# rubocop:enable Style/HashTransformValues
end
def create
params['app_config'].each do |key, value|
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
i.value = value
i.save!
end
redirect_to super_admin_app_config_url
end
end

View file

@ -0,0 +1,30 @@
class Webhooks::InstagramController < ApplicationController
skip_before_action :authenticate_user!, raise: false
skip_before_action :set_current_user
def verify
if valid_instagram_token?(params['hub.verify_token'])
Rails.logger.info('Instagram webhook verified')
render json: params['hub.challenge']
else
render json: { error: 'Error; wrong verify token', status: 403 }
end
end
def events
Rails.logger.info('Instagram webhook received events')
if params['object'].casecmp('instagram').zero?
::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']}")
head :unprocessable_entity
end
end
private
def valid_instagram_token?(token)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
end
end

View file

@ -0,0 +1,6 @@
class Webhooks::WhatsappController < ActionController::API
def process_payload
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok
end
end

View file

@ -29,21 +29,21 @@ class WidgetsController < ActionController::Base
def set_contact def set_contact
return if @auth_token_params[:source_id].nil? return if @auth_token_params[:source_id].nil?
contact_inbox = ::ContactInbox.find_by( @contact_inbox = ::ContactInbox.find_by(
inbox_id: @web_widget.inbox.id, inbox_id: @web_widget.inbox.id,
source_id: @auth_token_params[:source_id] source_id: @auth_token_params[:source_id]
) )
@contact = contact_inbox ? contact_inbox.contact : nil @contact = @contact_inbox ? @contact_inbox.contact : nil
end end
def build_contact def build_contact
return if @contact.present? return if @contact.present?
contact_inbox = @web_widget.create_contact_inbox(additional_attributes) @contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
@contact = contact_inbox.contact @contact = @contact_inbox.contact
payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id } payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
@token = ::Widget::TokenService.new(payload: payload).generate_token @token = ::Widget::TokenService.new(payload: payload).generate_token
end end

View file

@ -12,6 +12,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
avatar_url: AvatarField, avatar_url: AvatarField,
id: Field::Number, id: Field::Number,
name: Field::String, name: Field::String,
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
description: Field::String, description: Field::String,
outgoing_url: Field::String, outgoing_url: Field::String,
created_at: Field::DateTime, created_at: Field::DateTime,
@ -26,6 +27,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
COLLECTION_ATTRIBUTES = %i[ COLLECTION_ATTRIBUTES = %i[
id id
avatar_url avatar_url
account
name name
outgoing_url outgoing_url
].freeze ].freeze
@ -34,7 +36,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# an array of attributes that will be displayed on the model's show page. # an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[ SHOW_PAGE_ATTRIBUTES = %i[
id id
avatar_url account
name name
description description
outgoing_url outgoing_url
@ -45,6 +47,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages. # on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[ FORM_ATTRIBUTES = %i[
name name
account
description description
outgoing_url outgoing_url
].freeze ].freeze

View file

@ -15,7 +15,9 @@ class AsyncDispatcher < BaseDispatcher
EventListener.instance, EventListener.instance,
HookListener.instance, HookListener.instance,
InstallationWebhookListener.instance, InstallationWebhookListener.instance,
WebhookListener.instance NotificationListener.instance,
WebhookListener.instance,
AutomationRuleListener.instance
] ]
end end
end end

View file

@ -5,6 +5,6 @@ class SyncDispatcher < BaseDispatcher
end end
def listeners def listeners
[ActionCableListener.instance, AgentBotListener.instance, NotificationListener.instance] [ActionCableListener.instance, AgentBotListener.instance]
end end
end end

View file

@ -1,5 +1,30 @@
class ConversationDrop < BaseDrop class ConversationDrop < BaseDrop
include MessageFormatHelper
def display_id def display_id
@obj.try(:display_id) @obj.try(:display_id)
end end
def contact_name
@obj.try(:contact).name.capitalize || 'Customer'
end
def recent_messages
@obj.try(:recent_messages).map do |message|
{
'sender' => message_sender_name(message.sender),
'content' => render_message_content(transform_user_mention_content(message.content)),
'attachments' => message.attachments.map(&:file_url)
}
end
end
private
def message_sender_name(sender)
return 'Bot' if sender.blank?
return contact_name if sender.instance_of?(Contact)
sender&.available_name || sender&.name
end
end end

View file

@ -1,2 +1,5 @@
class InboxDrop < BaseDrop class InboxDrop < BaseDrop
def name
@obj.try(:name)
end
end end

View file

@ -6,7 +6,7 @@ class MessageDrop < BaseDrop
end end
def text_content def text_content
content = @obj.try(:content) content = @obj.try(:content) || ''
transform_user_mention_content content render_message_content(transform_user_mention_content(content))
end end
end end

View file

@ -2,6 +2,6 @@ require 'administrate/field/base'
class AvatarField < Administrate::Field::Base class AvatarField < Administrate::Field::Base
def avatar_url def avatar_url
data.presence || '/admin/avatar.png' data.presence&.gsub('?d=404', '?d=mp')
end end
end end

View file

@ -70,8 +70,13 @@ class ConversationFinder
end end
def find_all_conversations def find_all_conversations
if params[:conversation_type] == 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = current_account.conversations.where(id: conversation_ids)
else
@conversations = current_account.conversations.where(inbox_id: @inbox_ids) @conversations = current_account.conversations.where(inbox_id: @inbox_ids)
end end
end
def filter_by_assignee_type def filter_by_assignee_type
case @assignee_type case @assignee_type
@ -121,8 +126,12 @@ class ConversationFinder
def conversations def conversations
@conversations = @conversations.includes( @conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
) )
if params[:conversation_type] == 'mention'
@conversations.page(current_page)
else
@conversations.latest.page(current_page) @conversations.latest.page(current_page)
end end
end end
end

View file

@ -0,0 +1,33 @@
module Api::V1::InboxesHelper
def validate_email_channel(attributes)
channel_data = permitted_params(attributes)[:channel]
validate_imap(channel_data)
validate_smtp(channel_data)
end
private
def validate_imap(channel_data)
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_email],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
Mail.connection do # rubocop:disable:block
end
end
def validate_smtp(channel_data)
return unless channel_data.key?('smtp_enabled') && channel_data[:smtp_enabled]
smtp = Net::SMTP.start(channel_data[:smtp_address], channel_data[:smtp_port], channel_data[:smtp_domain], channel_data[:smtp_email],
channel_data[:smtp_password], :login)
smtp.finish unless smtp&.nil?
end
end

View file

@ -1,16 +1,29 @@
module FileTypeHelper module FileTypeHelper
# NOTE: video, audio, image, etc are filetypes previewable in frontend
def file_type(content_type) def file_type(content_type)
return :image if [ return :image if image_file?(content_type)
'image/jpeg', return :video if video_file?(content_type)
'image/png',
'image/gif',
'image/tiff',
'image/bmp'
].include?(content_type)
return :video if content_type.include?('video/')
return :audio if content_type.include?('audio/') return :audio if content_type.include?('audio/')
:file :file
end end
def image_file?(content_type)
[
'image/jpeg',
'image/png',
'image/gif',
'image/bmp',
'image/webp'
].include?(content_type)
end
def video_file?(content_type)
[
'video/ogg',
'video/mp4',
'video/webm',
'video/quicktime'
].include?(content_type)
end
end end

View file

@ -1,6 +1,13 @@
module MessageFormatHelper module MessageFormatHelper
include RegexHelper include RegexHelper
def transform_user_mention_content(message_content) def transform_user_mention_content(message_content)
message_content.gsub(MENTION_REGEX, '\1') message_content.gsub(MENTION_REGEX, '\1')
end end
def render_message_content(message_content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(message_content).html_safe
# rubocop:enable Rails/OutputSafety
end
end end

View file

@ -8,6 +8,7 @@
:has-accounts="hasAccounts" :has-accounts="hasAccounts"
/> />
<woot-snackbar-box /> <woot-snackbar-box />
<network-notification />
</div> </div>
</template> </template>
@ -15,6 +16,7 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal'; import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
import WootSnackbarBox from './components/SnackbarContainer'; import WootSnackbarBox from './components/SnackbarContainer';
import NetworkNotification from './components/NetworkNotification';
import { accountIdFromPathname } from './helper/URLHelper'; import { accountIdFromPathname } from './helper/URLHelper';
export default { export default {
@ -23,6 +25,7 @@ export default {
components: { components: {
WootSnackbarBox, WootSnackbarBox,
AddAccountModal, AddAccountModal,
NetworkNotification,
}, },
data() { data() {

View file

@ -0,0 +1,18 @@
/* global axios */
import ApiClient from './ApiClient';
class AccountActions extends ApiClient {
constructor() {
super('actions', { accountScoped: true });
}
merge(parentId, childId) {
return axios.post(`${this.url}/contact_merge`, {
base_contact_id: parentId,
mergee_contact_id: childId,
});
}
}
export default new AccountActions();

View file

@ -6,8 +6,8 @@ class AttributeAPI extends ApiClient {
super('custom_attribute_definitions', { accountScoped: true }); super('custom_attribute_definitions', { accountScoped: true });
} }
getAttributesByModel(modelId) { getAttributesByModel() {
return axios.get(`${this.url}?attribute_model=${modelId}`); return axios.get(this.url);
} }
} }

View file

@ -161,9 +161,13 @@ export default {
}); });
}, },
updateAvailability({ availability }) { updateAvailability(availabilityData) {
return axios.put(endPoints('profileUpdate').url, { return axios.post(endPoints('availabilityUpdate').url, {
profile: { availability }, profile: { ...availabilityData },
}); });
}, },
deleteAvatar() {
return axios.delete(endPoints('deleteAvatar').url);
},
}; };

View file

@ -2,7 +2,27 @@ import ApiClient from './ApiClient';
class ContactNotes extends ApiClient { class ContactNotes extends ApiClient {
constructor() { constructor() {
super('contact_notes', { accountScoped: true }); super('notes', { accountScoped: true });
this.contactId = null;
}
get url() {
return `${this.baseUrl()}/contacts/${this.contactId}/notes`;
}
get(contactId) {
this.contactId = contactId;
return super.get();
}
create(contactId, content) {
this.contactId = contactId;
return super.create({ content });
}
delete(contactId, id) {
this.contactId = contactId;
return super.delete(id);
} }
} }

View file

@ -53,6 +53,11 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL); return axios.get(requestURL);
} }
filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
return axios.post(requestURL, queryPayload);
}
importContacts(file) { importContacts(file) {
const formData = new FormData(); const formData = new FormData();
formData.append('import_file', file); formData.append('import_file', file);
@ -60,6 +65,12 @@ class ContactAPI extends ApiClient {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
} }
destroyCustomAttributes(contactId, customAttributes) {
return axios.post(`${this.url}/${contactId}/destroy_custom_attributes`, {
custom_attributes: customAttributes,
});
}
} }
export default new ContactAPI(); export default new ContactAPI();

View file

@ -13,6 +13,9 @@ const endPoints = {
profileUpdate: { profileUpdate: {
url: '/api/v1/profile', url: '/api/v1/profile',
}, },
availabilityUpdate: {
url: '/api/v1/profile/availability',
},
logout: { logout: {
url: 'auth/sign_out', url: 'auth/sign_out',
}, },
@ -33,6 +36,10 @@ const endPoints = {
}, },
params: { omniauth_token: '' }, params: { omniauth_token: '' },
}, },
deleteAvatar: {
url: '/api/v1/profile/avatar',
},
}; };
export default page => { export default page => {

View file

@ -6,7 +6,15 @@ class ConversationApi extends ApiClient {
super('conversations', { accountScoped: true }); super('conversations', { accountScoped: true });
} }
get({ inboxId, status, assigneeType, page, labels, teamId }) { get({
inboxId,
status,
assigneeType,
page,
labels,
teamId,
conversationType,
}) {
return axios.get(this.url, { return axios.get(this.url, {
params: { params: {
inbox_id: inboxId, inbox_id: inboxId,
@ -15,6 +23,15 @@ class ConversationApi extends ApiClient {
assignee_type: assigneeType, assignee_type: assigneeType,
page, page,
labels, labels,
conversation_type: conversationType,
},
});
}
filter(payload) {
return axios.post(`${this.url}/filter`, payload.queryData, {
params: {
page: payload.page,
}, },
}); });
} }
@ -51,9 +68,10 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${id}/update_last_seen`); return axios.post(`${this.url}/${id}/update_last_seen`);
} }
toggleTyping({ conversationId, status }) { toggleTyping({ conversationId, status, isPrivate }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, { return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
typing_status: status, typing_status: status,
is_private: isPrivate,
}); });
} }
@ -65,7 +83,7 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${conversationId}/unmute`); return axios.post(`${this.url}/${conversationId}/unmute`);
} }
meta({ inboxId, status, assigneeType, labels, teamId }) { meta({ inboxId, status, assigneeType, labels, teamId, conversationType }) {
return axios.get(`${this.url}/meta`, { return axios.get(`${this.url}/meta`, {
params: { params: {
inbox_id: inboxId, inbox_id: inboxId,
@ -73,6 +91,7 @@ class ConversationApi extends ApiClient {
assignee_type: assigneeType, assignee_type: assigneeType,
labels, labels,
team_id: teamId, team_id: teamId,
conversation_type: conversationType,
}, },
}); });
} }
@ -80,6 +99,12 @@ class ConversationApi extends ApiClient {
sendEmailTranscript({ conversationId, email }) { sendEmailTranscript({ conversationId, email }) {
return axios.post(`${this.url}/${conversationId}/transcript`, { email }); return axios.post(`${this.url}/${conversationId}/transcript`, { email });
} }
updateCustomAttributes({ conversationId, customAttributes }) {
return axios.post(`${this.url}/${conversationId}/custom_attributes`, {
custom_attributes: customAttributes,
});
}
} }
export default new ConversationApi(); export default new ConversationApi();

View file

@ -8,6 +8,8 @@ export const buildCreatePayload = ({
contentAttributes, contentAttributes,
echoId, echoId,
file, file,
ccEmails = '',
bccEmails = '',
}) => { }) => {
let payload; let payload;
if (file) { if (file) {
@ -18,12 +20,16 @@ export const buildCreatePayload = ({
} }
payload.append('private', isPrivate); payload.append('private', isPrivate);
payload.append('echo_id', echoId); payload.append('echo_id', echoId);
payload.append('cc_emails', ccEmails);
payload.append('bcc_emails', bccEmails);
} else { } else {
payload = { payload = {
content: message, content: message,
private: isPrivate, private: isPrivate,
echo_id: echoId, echo_id: echoId,
content_attributes: contentAttributes, content_attributes: contentAttributes,
cc_emails: ccEmails,
bcc_emails: bccEmails,
}; };
} }
return payload; return payload;
@ -41,6 +47,8 @@ class MessageApi extends ApiClient {
contentAttributes, contentAttributes,
echo_id: echoId, echo_id: echoId,
file, file,
ccEmails = '',
bccEmails = '',
}) { }) {
return axios({ return axios({
method: 'post', method: 'post',
@ -51,6 +59,8 @@ class MessageApi extends ApiClient {
contentAttributes, contentAttributes,
echoId, echoId,
file, file,
ccEmails,
bccEmails,
}), }),
}); });
} }

View file

@ -35,6 +35,12 @@ class ReportsAPI extends ApiClient {
params: { since, until }, params: { since, until },
}); });
} }
getTeamReports(since, until) {
return axios.get(`${this.url}/teams`, {
params: { since, until },
});
}
} }
export default new ReportsAPI(); export default new ReportsAPI();

View file

@ -0,0 +1,23 @@
import accountActionsAPI from '../accountActions';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#ContactsAPI', () => {
it('creates correct instance', () => {
expect(accountActionsAPI).toBeInstanceOf(ApiClient);
expect(accountActionsAPI).toHaveProperty('merge');
});
describeWithAPIMock('API calls', context => {
it('#merge', () => {
accountActionsAPI.merge(1, 2);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/actions/contact_merge',
{
base_contact_id: 1,
mergee_contact_id: 2,
}
);
});
});
});

View file

@ -11,6 +11,7 @@ describe('#ContactsAPI', () => {
expect(contactAPI).toHaveProperty('update'); expect(contactAPI).toHaveProperty('update');
expect(contactAPI).toHaveProperty('delete'); expect(contactAPI).toHaveProperty('delete');
expect(contactAPI).toHaveProperty('getConversations'); expect(contactAPI).toHaveProperty('getConversations');
expect(contactAPI).toHaveProperty('filter');
}); });
describeWithAPIMock('API calls', context => { describeWithAPIMock('API calls', context => {
@ -60,6 +61,16 @@ describe('#ContactsAPI', () => {
); );
}); });
it('#destroyCustomAttributes', () => {
contactAPI.destroyCustomAttributes(1, ['cloudCustomer']);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/1/destroy_custom_attributes',
{
custom_attributes: ['cloudCustomer'],
}
);
});
it('#importContacts', () => { it('#importContacts', () => {
const file = 'file'; const file = 'file';
contactAPI.importContacts(file); contactAPI.importContacts(file);
@ -71,6 +82,24 @@ describe('#ContactsAPI', () => {
} }
); );
}); });
it('#filter', () => {
const queryPayload = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['fayaz'],
query_operator: null,
},
],
};
contactAPI.filter(1, 'name', queryPayload);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/filter?include_contact_inboxes=false&page=1&sort=name',
queryPayload
);
});
}); });
}); });

View file

@ -19,6 +19,7 @@ describe('#ConversationAPI', () => {
expect(conversationAPI).toHaveProperty('unmute'); expect(conversationAPI).toHaveProperty('unmute');
expect(conversationAPI).toHaveProperty('meta'); expect(conversationAPI).toHaveProperty('meta');
expect(conversationAPI).toHaveProperty('sendEmailTranscript'); expect(conversationAPI).toHaveProperty('sendEmailTranscript');
expect(conversationAPI).toHaveProperty('filter');
}); });
describeWithAPIMock('API calls', context => { describeWithAPIMock('API calls', context => {
@ -160,5 +161,54 @@ describe('#ConversationAPI', () => {
} }
); );
}); });
it('#updateCustomAttributes', () => {
conversationAPI.updateCustomAttributes({
conversationId: 45,
customAttributes: { order_d: '1001' },
});
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/45/custom_attributes',
{
custom_attributes: { order_d: '1001' },
}
);
});
it('#filter', () => {
const payload = {
page: 1,
queryData: {
payload: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['pending', 'resolved'],
query_operator: 'and',
},
{
attribute_key: 'assignee',
filter_operator: 'equal_to',
values: [3],
query_operator: 'and',
},
{
attribute_key: 'id',
filter_operator: 'equal_to',
values: ['This is a test'],
query_operator: null,
},
],
},
};
conversationAPI.filter(payload);
expect(
context.axiosMock.post
).toHaveBeenCalledWith(
'/api/v1/conversations/filter',
payload.queryData,
{ params: { page: payload.page } }
);
});
}); });
}); });

View file

@ -35,12 +35,14 @@ describe('#ConversationAPI', () => {
message: 'test content', message: 'test content',
echoId: 12, echoId: 12,
isPrivate: true, isPrivate: true,
file: new Blob(['test-content'], { type: 'application/pdf' }), file: new Blob(['test-content'], { type: 'application/pdf' }),
}); });
expect(formPayload).toBeInstanceOf(FormData); expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('content')).toEqual('test content'); expect(formPayload.get('content')).toEqual('test content');
expect(formPayload.get('echo_id')).toEqual('12'); expect(formPayload.get('echo_id')).toEqual('12');
expect(formPayload.get('private')).toEqual('true'); expect(formPayload.get('private')).toEqual('true');
expect(formPayload.get('cc_emails')).toEqual('');
}); });
it('builds object payload if file is not available', () => { it('builds object payload if file is not available', () => {
@ -56,6 +58,8 @@ describe('#ConversationAPI', () => {
private: false, private: false,
echo_id: 12, echo_id: 12,
content_attributes: { in_reply_to: 12 }, content_attributes: { in_reply_to: 12 },
bcc_emails: '',
cc_emails: '',
}); });
}); });
}); });

View file

@ -16,6 +16,7 @@ describe('#Reports API', () => {
expect(reportsAPI).toHaveProperty('getAgentReports'); expect(reportsAPI).toHaveProperty('getAgentReports');
expect(reportsAPI).toHaveProperty('getLabelReports'); expect(reportsAPI).toHaveProperty('getLabelReports');
expect(reportsAPI).toHaveProperty('getInboxReports'); expect(reportsAPI).toHaveProperty('getInboxReports');
expect(reportsAPI).toHaveProperty('getTeamReports');
}); });
describeWithAPIMock('API calls', context => { describeWithAPIMock('API calls', context => {
it('#getAccountReports', () => { it('#getAccountReports', () => {
@ -82,5 +83,18 @@ describe('#Reports API', () => {
} }
); );
}); });
it('#getTeamReports', () => {
reportsAPI.getTeamReports(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/teams',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
}); });
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,4 +1,3 @@
/* Enter and leave animations can use different */ /* Enter and leave animations can use different */
/* durations and timing functions. */ /* durations and timing functions. */
.slide-fade-enter-active { .slide-fade-enter-active {
@ -9,7 +8,8 @@
transition: all .3s $ease-out-cubic; transition: all .3s $ease-out-cubic;
} }
.slide-fade-enter, .slide-fade-leave-to { .slide-fade-enter,
.slide-fade-leave-to {
opacity: 0; opacity: 0;
transform: translateX(10px); transform: translateX(10px);
} }
@ -22,22 +22,33 @@
transform: translateX($space-medium); transform: translateX($space-medium);
} }
.conversations-list-enter-active, .conversations-list-leave-active { .conversations-list-enter-active,
.conversations-list-leave-active {
transition: all .25s $ease-out-cubic; transition: all .25s $ease-out-cubic;
} }
.conversations-list-enter, .conversations-list-leave-to /* .conversations-list-leave-active for <2.1.8 */ { .conversations-list-enter,
.conversations-list-leave-to {
opacity: 0; opacity: 0;
transform: translateX($space-medium); transform: translateX($space-medium);
} }
.menu-list-enter-active, .menu-list-leave-active { .menu-list-enter-active,
transition: all .2s $ease-out-cubic; .menu-list-leave-active {
transition: opacity .3s $ease-out-cubic,
transform .2s $ease-out-cubic;
} }
.menu-list-enter, .menu-list-leave-to /* .conversations-list-leave-active for <2.1.8 */ {
.menu-list-leave-to {
opacity: 0; opacity: 0;
transform: translateX($space-medium); position: absolute;
transform: translateX($space-small);
}
.menu-list-enter {
opacity: 0;
transform: translateX(-$space-small);
} }
.slide-up-enter-active { .slide-up-enter-active {
@ -48,8 +59,8 @@
transition: all .3s $ease-out-cubic; transition: all .3s $ease-out-cubic;
} }
.slide-up-enter, .slide-up-leave-to .slide-up-enter,
/* .slide-fade-leave-active for <2.1.8 */ { .slide-up-leave-to {
transform: translateY(-$space-medium); transform: translateY(-$space-medium);
opacity: 0; opacity: 0;
} }
@ -60,10 +71,10 @@
transition: transform 0.25s $ease-in-cubic, opacity 0.15s $ease-in-cubic; transition: transform 0.25s $ease-in-cubic, opacity 0.15s $ease-in-cubic;
} }
.menu-slide-enter, .menu-slide-leave-to .menu-slide-enter,
/* .slide-fade-leave-active for <2.1.8 */ { .menu-slide-leave-to {
transform: translateY($space-small);
opacity: 0; opacity: 0;
transform: translateY($space-small);
} }
@ -75,10 +86,10 @@
transition: all .1s $ease-out-sine; transition: all .1s $ease-out-sine;
} }
.toast-fade-enter, .toast-fade-leave-to .toast-fade-enter,
/* .toast-fade-leave-active for <2.1.8 */ { .toast-fade-leave-to {
transform: translateY(-$space-small);
opacity: 0; opacity: 0;
transform: translateY(-$space-small);
} }
.modal-fade-enter-active { .modal-fade-enter-active {
@ -89,7 +100,21 @@
transition: all .1s $ease-out-sine; transition: all .1s $ease-out-sine;
} }
.modal-fade-enter, .modal-fade-leave-to .modal-fade-enter,
/* .slide-fade-leave-active for <2.1.8 */ { .modal-fade-leave-to {
opacity: 0;
}
.network-notification-fade-enter-active {
transition: all .1s $ease-in-sine;
}
.network-notification-fade-leave-active {
transition: all .1s $ease-out-sine;
}
.network-notification-fade-enter,
.network-notification-fade-leave-to {
transform: translateY(-$space-small);
opacity: 0; opacity: 0;
} }

View file

@ -9,10 +9,10 @@
.card { .card {
margin-bottom: var(--space-small); margin-bottom: var(--space-small);
padding: var(--space-small); padding: var(--space-normal);
} }
.button-wrapper .button.link.grey-btn { .button-wrapper .button.grey-btn {
margin-left: var(--space-normal); margin-left: var(--space-normal);
} }
@ -21,7 +21,7 @@
font-size: $font-size-mini; font-size: $font-size-mini;
max-width: 15rem; max-width: 15rem;
padding: $space-smaller $space-small; padding: $space-smaller $space-small;
z-index: 9999; z-index: 999;
} }
code { code {
@ -49,7 +49,21 @@ code {
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
// remove when grid gutters are fixed // remove when grid gutters are fixed
.columns.with-right-space { .columns.with-right-space {
padding-right: var(--space-normal); padding-right: var(--space-normal);
} }
.badge {
border-radius: var(--border-radius-normal);
}
.padding-right-small {
padding-right: var(--space-one);
}
.margin-right-small {
margin-right: var(--space-small);
}

View file

@ -45,6 +45,9 @@
// 1. Global // 1. Global
// --------- // ---------
// Disable contrast warnings in Foundation.
$contrast-warnings: false;
$global-font-size: 10px; $global-font-size: 10px;
$global-width: 100%; $global-width: 100%;
$global-lineheight: 1.5; $global-lineheight: 1.5;
@ -219,9 +222,9 @@ $badge-background: $primary-color;
$badge-color: $white; $badge-color: $white;
$badge-color-alt: $black; $badge-color-alt: $black;
$badge-palette: $foundation-palette; $badge-palette: $foundation-palette;
$badge-padding: 0.3em; $badge-padding: var(--space-smaller);
$badge-minwidth: 2.1em; $badge-minwidth: 2.1em;
$badge-font-size: 0.6rem; $badge-font-size: var(--font-size-nano);
// 10. Breadcrumbs // 10. Breadcrumbs
// --------------- // ---------------
@ -400,7 +403,7 @@ $mediaobject-image-width-stacked: 100%;
$menu-margin: 0; $menu-margin: 0;
$menu-margin-nested: $space-medium; $menu-margin-nested: $space-medium;
$menu-item-padding: $space-one; $menu-item-padding: $space-slab;
$menu-item-color-active: $white; $menu-item-color-active: $white;
$menu-item-background-active: $color-background; $menu-item-background-active: $color-background;
$menu-icon-spacing: 0.25rem; $menu-icon-spacing: 0.25rem;

View file

@ -55,6 +55,10 @@
justify-content: space-between; justify-content: space-between;
} }
.w-100 { .w-full {
width: 100%; width: 100%;
} }
.h-full {
height: 100%;
}

View file

@ -1,3 +1,56 @@
.margin-right-small { .margin-right-small {
margin-right: var(--space-small); margin-right: var(--space-small);
} }
.margin-right-smaller {
margin-right: var(--space-smaller);
}
.margin-left-minus-slab {
margin-left: var(--space-minus-slab);
}
.fs-small {
font-size: var(--font-size-small);
}
.fs-default {
font-size: var(--font-size-default);
}
.fw-medium {
font-weight: var(--font-weight-medium);
}
.p-normal {
padding: var(--space-normal);
}
.overflow-scroll {
overflow: scroll;
}
.overflow-auto {
overflow: auto;
}
.overflow-hidden {
overflow: hidden;
}
.border-right {
border-right: 1px solid var(--color-border);
}
.border-left {
border-left: 1px solid var(--color-border);
}
.bg-white {
background-color: var(--white);
}
.text-y-800 {
color: var(--y-800);
}

View file

@ -44,11 +44,14 @@ $woot-logo-padding: $space-large $space-two;
$color-woot: #1f93ff; $color-woot: #1f93ff;
$color-gray: #6e6f73; $color-gray: #6e6f73;
$color-light-gray: #999a9b; $color-light-gray: #999a9b;
$color-border: #e0e6ed;
$color-border-light: #f0f4f5; $color-border: var(--s-75);
$color-border-dark: #cad0d4; $color-border-light: var(--s-50);
$color-background: #f4f6fb; $color-border-dark: var(--s-100);
$color-background-light: #f9fafc;
$color-background: var(--s-50);
$color-background-light: var(--s-25);
$color-white: #fff; $color-white: #fff;
$color-body: #3c4858; $color-body: #3c4858;
$color-heading: #1f2d3d; $color-heading: #1f2d3d;

View file

@ -14,7 +14,6 @@
@import 'helper-classes'; @import 'helper-classes';
@import 'formulate'; @import 'formulate';
@import 'date-picker'; @import 'date-picker';
@import 'utility-helpers';
@import 'foundation-sites/scss/foundation'; @import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon'; @import '~bourbon/core/bourbon';
@ -50,3 +49,4 @@
@import 'plugins/multiselect'; @import 'plugins/multiselect';
@import 'plugins/dropdown'; @import 'plugins/dropdown';
@import '~shared/assets/stylesheets/ionicons'; @import '~shared/assets/stylesheets/ionicons';
@import 'utility-helpers';

View file

@ -2,8 +2,9 @@
@include elegant-card; @include elegant-card;
@include border-light; @include border-light;
box-sizing: content-box; box-sizing: content-box;
padding: var(--space-small);
width: fit-content; width: fit-content;
z-index: 999; z-index: var(--z-index-very-high);
&.dropdown-pane--open { &.dropdown-pane--open {
display: block; display: block;

View file

@ -15,6 +15,10 @@
.multiselect { .multiselect {
margin-bottom: var(--space-normal); margin-bottom: var(--space-normal);
&.multiselect--disabled {
opacity: .8;
}
.multiselect--active { .multiselect--active {
>.multiselect__tags { >.multiselect__tags {
border-color: $color-woot; border-color: $color-woot;
@ -47,6 +51,10 @@
width: 100%; width: 100%;
} }
p {
margin-bottom: 0;
}
&.multiselect__option--highlight { &.multiselect__option--highlight {
background: var(--white); background: var(--white);
color: var(--color-body); color: var(--color-body);
@ -209,3 +217,53 @@
flex-shrink: 0; flex-shrink: 0;
} }
} }
.multiselect-wrap--medium {
$multiselect-height: 4.8rem;
.multiselect__tags,
.multiselect__input {
align-items: center;
display: flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
background: var(--white);
font-size: var(--font-size-small);
height: $multiselect-height;
min-height: $multiselect-height;
}
.multiselect__input {
height: $multiselect-height - $space-micro;
min-height: $multiselect-height - $space-micro;
}
.multiselect__single {
align-items: center;
display: flex;
font-size: var(--font-size-small);
margin: 0;
padding: var(--space-smaller) var(--space-micro);
}
.multiselect__placeholder {
margin: 0;
padding: var(--space-smaller) var(--space-micro);
}
.multiselect__select {
min-height: $multiselect-height;
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
background: transparent;
}
.multiselect__tags-wrap {
flex-shrink: 0;
}
}

View file

@ -8,7 +8,7 @@
@include background-white; @include background-white;
@include flex; @include flex;
@include flex-align($x: justify, $y: middle); @include flex-align($x: justify, $y: middle);
@include border-normal-bottom; border-bottom: 1px solid var(--s-50);
height: $header-height; height: $header-height;
min-height: $header-height; min-height: $header-height;

View file

@ -25,6 +25,7 @@ $default-button-height: 4.0rem;
// @TODDO - Remove after moving all buttons to woot-button // @TODDO - Remove after moving all buttons to woot-button
.icon+.button__content { .icon+.button__content {
padding-left: var(--space-small); padding-left: var(--space-small);
width: auto;
} }
&.expanded { &.expanded {
@ -65,6 +66,32 @@ $default-button-height: 4.0rem;
} }
} }
&.clear {
&.warning {
color: var(--y-800);
}
&.button--only-icon:hover {
background: var(--w-50);
&.secondary {
background: var(--s-50);
}
&.success {
background: var(--g-50);
}
&.alert {
background: var(--r-50);
}
&.warning {
background: var(--y-100);
}
}
}
// Sizes // Sizes
&.tiny { &.tiny {
height: var(--space-medium); height: var(--space-medium);
@ -103,7 +130,6 @@ $default-button-height: 4.0rem;
padding: 0; padding: 0;
} }
} }

View file

@ -85,11 +85,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
width: 27rem; width: 27rem;
.small-icon {
font-size: $font-size-mini;
vertical-align: top;
}
} }
.conversation--meta { .conversation--meta {

View file

@ -17,7 +17,8 @@
} }
} }
.image { .image,
.video {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
@ -26,7 +27,13 @@
} }
.modal-image { .modal-image {
max-width: 85%; max-height: 90%;
max-width: 90%;
}
.modal-video {
max-height: 75vh;
max-width: 100%;
} }
&::before { &::before {
@ -35,11 +42,21 @@
content: ''; content: '';
height: 20%; height: 20%;
left: 0; left: 0;
opacity: .8; opacity: 0.8;
position: absolute; position: absolute;
width: 100%; width: 100%;
} }
} }
.video {
.modal-container {
width: auto;
.modal--close {
z-index: var(--z-index-low);
}
}
}
} }
.conversations-list-wrap { .conversations-list-wrap {
@ -76,7 +93,7 @@
.status--filter { .status--filter {
@include padding($zero null $zero $space-normal); @include padding($zero null $zero $space-normal);
@include margin($space-smaller $space-slab $zero $zero); @include margin($zero);
background-color: $color-background-light; background-color: $color-background-light;
border: 1px solid $color-border; border: 1px solid $color-border;
float: right; float: right;
@ -93,7 +110,7 @@
.conversation-panel { .conversation-panel {
@include flex; @include flex;
@include flex-weight(1); @include flex-weight(1 1 1px);
@include margin($zero); @include margin($zero);
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
@ -118,7 +135,7 @@
&.unread--toast { &.unread--toast {
+.right { +.right {
margin-bottom: 0; margin-bottom: var(--space-micro);
} }
+.left { +.left {

View file

@ -1,5 +1,4 @@
.error { .error {
#{$all-text-inputs}, #{$all-text-inputs},
select, select,
.multiselect > .multiselect__tags { .multiselect > .multiselect__tags {
@ -40,4 +39,8 @@ input {
font-size: var(--font-size-small); font-size: var(--font-size-small);
height: var(--space-large); height: var(--space-large);
} }
.error {
border-color: var(--r-400);
}
} }

View file

@ -19,7 +19,7 @@
cursor: pointer; cursor: pointer;
font-size: $font-size-big; font-size: $font-size-big;
line-height: $space-normal; line-height: $space-normal;
padding: $space-normal $space-two; padding: $space-normal;
position: absolute; position: absolute;
right: $space-micro; right: $space-micro;
top: $space-micro; top: $space-micro;
@ -29,7 +29,6 @@
} }
} }
.page-top-bar { .page-top-bar {
@include padding($space-large $space-large $zero); @include padding($space-large $space-large $zero);
@ -48,13 +47,16 @@
position: relative; position: relative;
width: 60rem; width: 60rem;
&.medium {
max-width: 80%;
width: 90rem;
}
.content-box { .content-box {
@include padding($zero); @include padding($zero);
height: auto; height: auto;
} }
h2 { h2 {
color: $color-heading; color: $color-heading;
font-size: $font-size-medium; font-size: $font-size-medium;
@ -89,15 +91,19 @@
button { button {
font-size: $font-size-small; font-size: $font-size-small;
} }
&.justify-content-end {
justify-content: end;
}
} }
.delete-item { .delete-item {
@include padding($space-large); @include padding($space-large);
button { button {
@include margin($zero); @include margin($zero);
} }
} }
} }
.modal-enter, .modal-enter,

View file

@ -21,10 +21,7 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
.margin-right-small { .reports-option__wrap {
margin-right: var(--space-small); align-items: center;
}
.display-flex {
display: flex; display: flex;
} }

View file

@ -6,12 +6,6 @@
} }
.sidebar { .sidebar {
@include border-normal-right;
@include background-white;
@include full-height;
@include margin(0);
@include space-between-column;
width: $nav-bar-width;
z-index: 1024 - 1; z-index: 1024 - 1;
//logo //logo
@ -22,26 +16,6 @@
} }
} }
.main-nav {
a {
border-radius: $space-smaller;
color: $color-gray;
font-size: $font-size-default;
font-weight: $font-weight-medium;
.wrap,
.child-icon {
&:hover {
color: $color-woot;
}
}
}
.active a .wrap {
color: $color-woot;
}
}
.nested { .nested {
a { a {
font-size: $font-size-small; font-size: $font-size-small;
@ -83,34 +57,6 @@
} }
} }
.main-nav {
@include flex-weight(1);
@include scroll-on-hover;
padding: 0 $space-medium - $space-one;
a {
&::before {
margin-right: $space-slab;
}
}
.menu-title {
color: $color-gray;
font-size: $font-size-medium;
margin-top: $space-medium;
>span {
margin-left: $space-one;
}
}
}
.menu-title+ul>li>a {
@include padding($space-micro null);
color: $medium-gray;
line-height: $global-lineheight;
}
.hamburger--menu { .hamburger--menu {
cursor: pointer; cursor: pointer;
display: none; display: none;

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="cw-accordion"> <div class="cw-accordion">
<button class="cw-accordion--title" @click="$emit('click')"> <button class="cw-accordion--title drag-handle" @click="$emit('click')">
<div class="cw-accordion--title-wrap"> <div class="cw-accordion--title-wrap">
<emoji-or-icon class="icon-or-emoji" :icon="icon" :emoji="emoji" /> <emoji-or-icon class="icon-or-emoji" :icon="icon" :emoji="emoji" />
<h5> <h5>
@ -10,12 +10,16 @@
<div class="button-icon--wrap"> <div class="button-icon--wrap">
<slot name="button" /> <slot name="button" />
<div class="chevron-icon__wrap"> <div class="chevron-icon__wrap">
<i v-if="isOpen" class="ion-minus chevron-icon"></i> <fluent-icon v-if="isOpen" size="24" icon="subtract" type="solid" />
<i v-else class="ion-plus chevron-icon"></i> <fluent-icon v-else size="24" icon="add" type="solid" />
</div> </div>
</div> </div>
</button> </button>
<div v-if="isOpen" class="cw-accordion--content"> <div
v-if="isOpen"
class="cw-accordion--content"
:class="{ compact: compact }"
>
<slot /> <slot />
</div> </div>
</div> </div>
@ -33,6 +37,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
compact: {
type: Boolean,
default: false,
},
icon: { icon: {
type: String, type: String,
default: '', default: '',
@ -58,10 +66,10 @@ export default {
} }
.cw-accordion--title { .cw-accordion--title {
align-items: center; align-items: center;
background: var(--b-50); background: var(--s-50);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--s-100);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--s-100);
cursor: pointer; cursor: grab;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin: 0; margin: 0;
@ -106,5 +114,9 @@ export default {
.cw-accordion--content { .cw-accordion--content {
padding: var(--space-normal); padding: var(--space-normal);
&.compact {
padding: 0;
}
} }
</style> </style>

View file

@ -1,15 +1,40 @@
<template> l<template>
<div class="conversations-list-wrap"> <div class="conversations-list-wrap">
<slot></slot> <slot></slot>
<div class="chat-list__top"> <div class="chat-list__top" :class="{ filter__applied: hasAppliedFilters }">
<h1 class="page-title text-truncate" :title="pageTitle"> <h1 class="page-title text-truncate" :title="pageTitle">
{{ pageTitle }} {{ pageTitle }}
</h1> </h1>
<chat-filter @statusFilterChange="updateStatusType" />
<div class="filter--actions">
<chat-filter
v-if="!hasAppliedFilters"
@statusFilterChange="updateStatusType"
/>
<woot-button
v-else
size="small"
variant="clear"
color-scheme="alert"
@click="resetAndFetchData"
>
{{ $t('FILTER.CLEAR_BUTTON_LABEL') }}
</woot-button>
<woot-button
v-tooltip.top-end="$t('FILTER.TOOLTIP_LABEL')"
variant="clear"
color-scheme="secondary"
icon="filter"
size="small"
class="btn-filter"
@click="onToggleAdvanceFiltersModal"
>
</woot-button>
</div>
</div> </div>
<chat-type-tabs <chat-type-tabs
v-if="!isIframe" v-if="!hasAppliedFilters && !isIframe"
:items="assigneeTabItems" :items="assigneeTabItems"
:active-tab="activeAssigneeTab" :active-tab="activeAssigneeTab"
class="tab--chat-type" class="tab--chat-type"
@ -27,6 +52,7 @@
:active-label="label" :active-label="label"
:team-id="teamId" :team-id="teamId"
:chat="chat" :chat="chat"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard" :show-assignee="showAssigneeInConversationCard"
/> />
@ -38,7 +64,7 @@
v-if="!hasCurrentPageEndReached && !chatListLoading" v-if="!hasCurrentPageEndReached && !chatListLoading"
variant="clear" variant="clear"
size="expanded" size="expanded"
@click="fetchConversations" @click="loadMoreConversations"
> >
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }} {{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}
</woot-button> </woot-button>
@ -54,6 +80,18 @@
{{ $t('CHAT_LIST.EOF') }} {{ $t('CHAT_LIST.EOF') }}
</p> </p>
</div> </div>
<woot-modal
:show.sync="showAdvancedFilters"
:on-close="onToggleAdvanceFiltersModal"
size="medium"
>
<conversation-advanced-filter
v-if="showAdvancedFilters"
:filter-types="advancedFilterTypes"
:on-close="onToggleAdvanceFiltersModal"
@applyFilter="onApplyFilter"
/>
</woot-modal>
</div> </div>
</template> </template>
@ -61,12 +99,16 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ChatFilter from './widgets/conversation/ChatFilter'; import ChatFilter from './widgets/conversation/ChatFilter';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
import ChatTypeTabs from './widgets/ChatTypeTabs'; import ChatTypeTabs from './widgets/ChatTypeTabs';
import ConversationCard from './widgets/conversation/ConversationCard'; import ConversationCard from './widgets/conversation/ConversationCard';
import timeMixin from '../mixins/time'; import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations'; import conversationMixin from '../mixins/conversations';
import wootConstants from '../constants'; import wootConstants from '../constants';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import { import {
hasPressedAltAndJKey, hasPressedAltAndJKey,
hasPressedAltAndKKey, hasPressedAltAndKKey,
@ -77,6 +119,7 @@ export default {
ChatTypeTabs, ChatTypeTabs,
ConversationCard, ConversationCard,
ChatFilter, ChatFilter,
ConversationAdvancedFilter,
}, },
mixins: [timeMixin, conversationMixin, eventListenerMixins], mixins: [timeMixin, conversationMixin, eventListenerMixins],
props: { props: {
@ -92,11 +135,20 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
conversationType: {
type: String,
default: '',
},
}, },
data() { data() {
return { return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME, activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
activeStatus: wootConstants.STATUS_TYPE.OPEN, activeStatus: wootConstants.STATUS_TYPE.OPEN,
showAdvancedFilters: false,
advancedFilterTypes: advancedFilterTypes.map(filter => ({
...filter,
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
})),
}; };
}, },
computed: { computed: {
@ -110,10 +162,14 @@ export default {
currentUserID: 'getCurrentUserID', currentUserID: 'getCurrentUserID',
activeInbox: 'getSelectedInbox', activeInbox: 'getSelectedInbox',
conversationStats: 'conversationStats/getStats', conversationStats: 'conversationStats/getStats',
appliedFilters: 'getAppliedConversationFilters',
}), }),
isIframe() { isIframe() {
return window.self !== window.top; return window.self !== window.top;
}, },
hasAppliedFilters() {
return this.appliedFilters.length;
},
assigneeTabItems() { assigneeTabItems() {
return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => { return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => {
const count = this.conversationStats[item.COUNT_KEY] || 0; const count = this.conversationStats[item.COUNT_KEY] || 0;
@ -135,9 +191,17 @@ export default {
this.activeAssigneeTab this.activeAssigneeTab
); );
}, },
currentPageFilterKey() {
return this.hasAppliedFilters ? 'appliedFilters' : this.activeAssigneeTab;
},
currentFiltersPage() {
return this.$store.getters['conversationPage/getCurrentPageFilter'](
this.currentPageFilterKey
);
},
hasCurrentPageEndReached() { hasCurrentPageEndReached() {
return this.$store.getters['conversationPage/getHasEndReached']( return this.$store.getters['conversationPage/getHasEndReached'](
this.activeAssigneeTab this.currentPageFilterKey
); );
}, },
conversationFilters() { conversationFilters() {
@ -148,6 +212,9 @@ export default {
page: this.currentPage + 1, page: this.currentPage + 1,
labels: this.label ? [this.label] : undefined, labels: this.label ? [this.label] : undefined,
teamId: this.teamId ? this.teamId : undefined, teamId: this.teamId ? this.teamId : undefined,
conversationType: this.conversationType
? this.conversationType
: undefined,
}; };
}, },
pageTitle() { pageTitle() {
@ -160,10 +227,14 @@ export default {
if (this.label) { if (this.label) {
return `#${this.label}`; return `#${this.label}`;
} }
if (this.conversationType === 'mention') {
return this.$t('CHAT_LIST.MENTION_HEADING');
}
return this.$t('CHAT_LIST.TAB_HEADING'); return this.$t('CHAT_LIST.TAB_HEADING');
}, },
conversationList() { conversationList() {
let conversationList = []; let conversationList = [];
if (!this.hasAppliedFilters) {
const filters = this.conversationFilters; const filters = this.conversationFilters;
if (this.activeAssigneeTab === 'me') { if (this.activeAssigneeTab === 'me') {
conversationList = [...this.mineChatsList(filters)]; conversationList = [...this.mineChatsList(filters)];
@ -172,6 +243,9 @@ export default {
} else { } else {
conversationList = [...this.allChatList(filters)]; conversationList = [...this.allChatList(filters)];
} }
} else {
conversationList = [...this.chatLists];
}
return conversationList; return conversationList;
}, },
@ -192,6 +266,9 @@ export default {
label() { label() {
this.resetAndFetchData(); this.resetAndFetchData();
}, },
conversationType() {
this.resetAndFetchData();
},
}, },
mounted() { mounted() {
this.$store.dispatch('setChatFilter', this.activeStatus); this.$store.dispatch('setChatFilter', this.activeStatus);
@ -202,6 +279,17 @@ export default {
}); });
}, },
methods: { methods: {
onApplyFilter(payload) {
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' });
}
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
this.fetchFilteredConversations(payload);
},
onToggleAdvanceFiltersModal() {
this.showAdvancedFilters = !this.showAdvancedFilters;
},
getKeyboardListenerParams() { getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll( const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation' 'div.conversations-list div.conversation'
@ -249,6 +337,7 @@ export default {
resetAndFetchData() { resetAndFetchData() {
this.$store.dispatch('conversationPage/reset'); this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations'); this.$store.dispatch('emptyAllConversations');
this.$store.dispatch('clearConversationFilters');
this.fetchConversations(); this.fetchConversations();
}, },
fetchConversations() { fetchConversations() {
@ -256,6 +345,23 @@ export default {
.dispatch('fetchAllConversations', this.conversationFilters) .dispatch('fetchAllConversations', this.conversationFilters)
.then(() => this.$emit('conversation-load')); .then(() => this.$emit('conversation-load'));
}, },
loadMoreConversations() {
if (!this.hasAppliedFilters) {
this.fetchConversations();
} else {
this.fetchFilteredConversations(this.appliedFilters);
}
},
fetchFilteredConversations(payload) {
let page = this.currentFiltersPage + 1;
this.$store
.dispatch('fetchFilteredConversations', {
queryData: filterQueryGenerator(payload),
page,
})
.then(() => this.$emit('conversation-load'));
this.showAdvancedFilters = false;
},
updateAssigneeTab(selectedTab) { updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) { if (this.activeAssigneeTab !== selectedTab) {
bus.$emit('clearSearchInput'); bus.$emit('clearSearchInput');
@ -277,6 +383,7 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
@import '~dashboard/assets/scss/woot'; @import '~dashboard/assets/scss/woot';
.spinner { .spinner {
margin-top: var(--space-normal); margin-top: var(--space-normal);
margin-bottom: var(--space-normal); margin-bottom: var(--space-normal);
@ -299,4 +406,17 @@ export default {
flex-basis: 46rem; flex-basis: 46rem;
} }
} }
.filter--actions {
display: flex;
align-items: center;
}
.btn-filter {
margin: 0 var(--space-smaller);
}
.filter__applied {
padding: var(--space-slab) 0 !important;
border-bottom: 1px solid var(--color-border);
}
</style> </style>

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