Merge branch 'release/1.3.0'

This commit is contained in:
Sojan
2020-04-06 22:58:20 +05:30
422 changed files with 8393 additions and 2214 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: circleci/ruby:2.6.5-node-browsers - image: circleci/ruby:2.7.0-node-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

View File

@@ -26,3 +26,7 @@ exclude_patterns:
- "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/*"
- "**/*.md"
- "**/*.yml"
- "app/javascript/dashboard/i18n/locale"

View File

@@ -22,17 +22,6 @@ POSTGRES_PASSWORD=
RAILS_ENV=development RAILS_ENV=development
RAILS_MAX_THREADS=5 RAILS_MAX_THREADS=5
#fb app
FB_VERIFY_TOKEN=
FB_APP_SECRET=
FB_APP_ID=
#twitter app
TWITTER_APP_ID=
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=
TWITTER_ENVIRONMENT=
#mail #mail
MAILER_SENDER_EMAIL=accounts@chatwoot.com MAILER_SENDER_EMAIL=accounts@chatwoot.com
SMTP_PORT=1025 SMTP_PORT=1025
@@ -59,13 +48,25 @@ AWS_REGION=
SENTRY_DSN= SENTRY_DSN=
#Log settings #Log settings
LOG_LEVEL= LOG_LEVEL=info
LOG_SIZE= LOG_SIZE=500
# Credentials to access sidekiq dashboard in production # Credentials to access sidekiq dashboard in production
SIDEKIQ_AUTH_USERNAME= SIDEKIQ_AUTH_USERNAME=
SIDEKIQ_AUTH_PASSWORD= SIDEKIQ_AUTH_PASSWORD=
### This environment variables are only required if you are setting up social media channels
#facebook
FB_VERIFY_TOKEN=
FB_APP_SECRET=
FB_APP_ID=
#twitter
TWITTER_APP_ID=
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=
TWITTER_ENVIRONMENT=
#### This environment variables are only required in hosted version which has billing #### This environment variables are only required in hosted version which has billing
ENABLE_BILLING= ENABLE_BILLING=

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
12.16.1

View File

@@ -41,14 +41,58 @@ RSpec/NestedGroups:
Max: 4 Max: 4
RSpec/MessageSpies: RSpec/MessageSpies:
Enabled: false Enabled: false
Metrics/MethodLength:
Exclude:
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
Rails/CreateTableWithTimestamps:
Exclude:
- 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb'
Style/GuardClause:
Exclude:
- 'app/builders/account_builder.rb'
- 'app/models/attachment.rb'
- 'app/models/message.rb'
- 'lib/webhooks/chargebee.rb'
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
Metrics/AbcSize:
Exclude:
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
Metrics/CyclomaticComplexity:
Exclude:
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
Rails/ReversibleMigration:
Exclude:
- 'db/migrate/20161025070152_removechannelsfrommodels.rb'
- 'db/migrate/20161025070645_remchannel.rb'
- 'db/migrate/20161025070645_remchannel.rb'
- 'db/migrate/20161110102609_removeinboxid.rb'
- 'db/migrate/20170519091539_add_avatar_to_fb.rb'
- 'db/migrate/20191020085608_rename_old_tables.rb'
- 'db/migrate/20191126185833_update_user_invite_foreign_key.rb'
- 'db/migrate/20191130164019_add_template_type_to_messages.rb'
Rails/BulkChangeTable:
Exclude:
- 'db/migrate/20161025070152_removechannelsfrommodels.rb'
- 'db/migrate/20200121190901_create_account_users.rb'
- 'db/migrate/20170211092540_notnullableusers.rb'
- 'db/migrate/20170403095203_contactadder.rb'
- 'db/migrate/20170406104018_add_default_status_conv.rb'
- 'db/migrate/20170511134418_latlong.rb'
- 'db/migrate/20191027054756_create_contact_inboxes.rb'
- 'db/migrate/20191130164019_add_template_type_to_messages.rb'
Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
AllCops: AllCops:
Exclude: Exclude:
- db/* - 'bin/**/*'
- bin/**/* - 'db/schema.rb'
- db/**/* - 'config/**/*'
- config/**/* - 'public/**/*'
- public/**/* - 'vendor/**/*'
- vendor/**/* - 'node_modules/**/*'
- node_modules/**/* - 'lib/tasks/auto_annotate_models.rake'
- lib/tasks/auto_annotate_models.rake - 'config/environments/**/*'
- config/environments/**/* - 'tmp/**/*'
- 'storage/**/*'

View File

@@ -282,15 +282,6 @@ Style/GlobalVars:
Exclude: Exclude:
- 'lib/redis/alfred.rb' - 'lib/redis/alfred.rb'
# Offense count: 7
# Configuration parameters: MinBodyLength.
Style/GuardClause:
Exclude:
- 'app/builders/account_builder.rb'
- 'app/models/attachment.rb'
- 'app/models/message.rb'
- 'lib/webhooks/chargebee.rb'
# Offense count: 4 # Offense count: 4
Style/IdenticalConditionalBranches: Style/IdenticalConditionalBranches:
Exclude: Exclude:

View File

@@ -1 +1 @@
2.6.5 2.7.0

View File

@@ -82,7 +82,7 @@ linters:
enabled: true enabled: true
ImportantRule: ImportantRule:
enabled: true enabled: false
ImportPath: ImportPath:
enabled: true enabled: true

View File

@@ -1,6 +1,6 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '2.6.5' ruby '2.7.0'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'
@@ -25,11 +25,12 @@ gem 'uglifier'
##-- for active storage --## ##-- for active storage --##
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'azure-storage', require: false gem 'azure-storage-blob', require: false
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'mini_magick' gem 'mini_magick'
##-- gems for database --# ##-- gems for database --#
gem 'groupdate'
gem 'pg' gem 'pg'
gem 'redis' gem 'redis'
gem 'redis-namespace' gem 'redis-namespace'
@@ -61,9 +62,9 @@ gem 'chargebee'
##--- gems for channels ---## ##--- gems for channels ---##
gem 'facebook-messenger' gem 'facebook-messenger'
gem 'telegram-bot-ruby' gem 'telegram-bot-ruby'
gem 'twilio-ruby', '~> 5.32.0'
# twitty will handle subscription of twitter account events # twitty will handle subscription of twitter account events
gem 'twitty', git: 'https://github.com/chatwoot/twitty' gem 'twitty', git: 'https://github.com/chatwoot/twitty'
# facebook client # facebook client
gem 'koala' gem 'koala'
# Random name generator # Random name generator

View File

@@ -1,6 +1,6 @@
GIT GIT
remote: https://github.com/chatwoot/twitty remote: https://github.com/chatwoot/twitty
revision: c1edd557401d1e8a197b19e738f82e39507a8e2d revision: af4f3e45dca55e42c64f7741a1fedfaa94d36419
specs: specs:
twitty (0.1.0) twitty (0.1.0)
oauth oauth
@@ -16,7 +16,7 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action-cable-testing (0.6.0) action-cable-testing (0.6.1)
actioncable (>= 5.0) actioncable (>= 5.0)
actioncable (6.0.2.2) actioncable (6.0.2.2)
actionpack (= 6.0.2.2) actionpack (= 6.0.2.2)
@@ -77,46 +77,44 @@ GEM
activerecord (>= 5.0, < 6.1) activerecord (>= 5.0, < 6.1)
addressable (2.7.0) addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0) public_suffix (>= 2.0.2, < 5.0)
annotate (3.0.3) annotate (3.1.1)
activerecord (>= 3.2, < 7.0) activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.0) ast (2.4.0)
attr_extras (6.2.3) attr_extras (6.2.3)
aws-eventstream (1.0.3) aws-eventstream (1.0.3)
aws-partitions (1.269.0) aws-partitions (1.294.0)
aws-sdk-core (3.89.1) aws-sdk-core (3.92.0)
aws-eventstream (~> 1.0, >= 1.0.2) aws-eventstream (~> 1.0, >= 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.28.0) aws-sdk-kms (1.30.0)
aws-sdk-core (~> 3, >= 3.71.0) aws-sdk-core (~> 3, >= 3.71.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.60.1) aws-sdk-s3 (1.61.2)
aws-sdk-core (~> 3, >= 3.83.0) aws-sdk-core (~> 3, >= 3.83.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sigv4 (1.1.0) aws-sigv4 (1.1.1)
aws-eventstream (~> 1.0, >= 1.0.2) aws-eventstream (~> 1.0, >= 1.0.2)
axiom-types (0.1.1) axiom-types (0.1.1)
descendants_tracker (~> 0.0.4) descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0) ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1) thread_safe (~> 0.3, >= 0.3.1)
azure-core (0.1.15) azure-storage-blob (2.0.0)
faraday (~> 0.9) azure-storage-common (~> 2.0)
faraday_middleware (~> 0.10) nokogiri (~> 1.10.4)
nokogiri (~> 1.6) azure-storage-common (2.0.1)
azure-storage (0.15.0.preview) faraday (~> 1.0)
azure-core (~> 0.1) faraday_middleware (~> 1.0.0.rc1)
faraday (~> 0.9) nokogiri (~> 1.10.4)
faraday_middleware (~> 0.10)
nokogiri (~> 1.6, >= 1.6.8)
bcrypt (3.1.13) bcrypt (3.1.13)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.4.5) bootsnap (1.4.6)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.7.2) brakeman (4.8.0)
browser (3.0.3) browser (4.0.0)
builder (3.2.4) builder (3.2.4)
bullet (6.1.0) bullet (6.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@@ -127,7 +125,7 @@ GEM
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 0.18) thor (~> 0.18)
byebug (11.1.1) byebug (11.1.1)
chargebee (2.7.3) chargebee (2.7.5)
json_pure (~> 2.1) json_pure (~> 2.1)
rest-client (>= 1.8, < 3.0) rest-client (>= 1.8, < 3.0)
coderay (1.1.2) coderay (1.1.2)
@@ -151,7 +149,7 @@ GEM
devise (> 3.5.2, < 5) devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 6.1) rails (>= 4.2.0, < 6.1)
diff-lcs (1.3) diff-lcs (1.3)
digest-crc (0.4.1) digest-crc (0.5.1)
docile (1.3.2) docile (1.3.2)
domain_name (0.5.20190701) domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
@@ -165,23 +163,23 @@ GEM
facebook-messenger (1.4.1) facebook-messenger (1.4.1)
httparty (~> 0.13, >= 0.13.7) httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5) rack (>= 1.4.5)
factory_bot (5.1.1) factory_bot (5.1.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
factory_bot_rails (5.1.1) factory_bot_rails (5.1.1)
factory_bot (~> 5.1.0) factory_bot (~> 5.1.0)
railties (>= 4.2.0) railties (>= 4.2.0)
faker (2.10.1) faker (2.11.0)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
faraday (0.17.3) faraday (1.0.1)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
faraday_middleware (0.14.0) faraday_middleware (1.0.0)
faraday (>= 0.7.4, < 1.0) faraday (~> 1.0)
ffi (1.12.2) ffi (1.12.2)
flag_shih_tzu (0.3.23) flag_shih_tzu (0.3.23)
foreman (0.87.0) foreman (0.87.1)
globalid (0.4.2) globalid (0.4.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
google-api-client (0.36.4) google-api-client (0.37.2)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (~> 0.9) googleauth (~> 0.9)
httpclient (>= 2.8.1, < 3.0) httpclient (>= 2.8.1, < 3.0)
@@ -192,8 +190,8 @@ GEM
google-cloud-core (1.5.0) google-cloud-core (1.5.0)
google-cloud-env (~> 1.0) google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (1.3.0) google-cloud-env (1.3.1)
faraday (~> 0.11) faraday (>= 0.17.3, < 2.0)
google-cloud-errors (1.0.0) google-cloud-errors (1.0.0)
google-cloud-storage (1.25.1) google-cloud-storage (1.25.1)
addressable (~> 2.5) addressable (~> 2.5)
@@ -202,20 +200,22 @@ GEM
google-cloud-core (~> 1.2) google-cloud-core (~> 1.2)
googleauth (~> 0.9) googleauth (~> 0.9)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (0.10.0) googleauth (0.11.0)
faraday (~> 0.12) 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.12) signet (~> 0.12)
groupdate (5.0.0)
activesupport (>= 5)
haikunator (1.1.0) haikunator (1.1.0)
hana (1.3.5) hana (1.3.5)
hashie (4.1.0) hashie (4.1.0)
http-accept (1.7.0) http-accept (1.7.0)
http-cookie (1.0.3) http-cookie (1.0.3)
domain_name (~> 0.5) domain_name (~> 0.5)
httparty (0.17.3) httparty (0.18.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)
@@ -224,11 +224,11 @@ GEM
ice_nine (0.11.2) ice_nine (0.11.2)
inflecto (0.0.2) inflecto (0.0.2)
jaro_winkler (1.5.4) jaro_winkler (1.5.4)
jbuilder (2.9.1) jbuilder (2.10.0)
activesupport (>= 4.2.0) activesupport (>= 5.0.0)
jmespath (1.4.0) jmespath (1.4.0)
json (2.3.0) json (2.3.0)
json_pure (2.2.0) json_pure (2.3.0)
jwt (2.2.1) jwt (2.2.1)
kaminari (1.2.0) kaminari (1.2.0)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@@ -246,8 +246,8 @@ GEM
addressable addressable
faraday faraday
json (>= 1.8) json (>= 1.8)
launchy (2.4.3) launchy (2.5.0)
addressable (~> 2.3) addressable (~> 2.7)
letter_opener (1.7.0) letter_opener (1.7.0)
launchy (~> 2.2) launchy (~> 2.2)
listen (3.2.1) listen (3.2.1)
@@ -261,7 +261,7 @@ GEM
marcel (0.3.3) marcel (0.3.3)
mimemagic (~> 0.3.2) mimemagic (~> 0.3.2)
memoist (0.16.2) memoist (0.16.2)
method_source (0.9.2) 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.2019.1009) mime-types-data (3.2019.1009)
@@ -271,7 +271,7 @@ GEM
mini_portile2 (2.4.0) mini_portile2 (2.4.0)
minitest (5.14.0) minitest (5.14.0)
mock_redis (0.22.0) mock_redis (0.22.0)
msgpack (1.3.1) msgpack (1.3.3)
multi_json (1.14.1) multi_json (1.14.1)
multi_xml (0.6.0) multi_xml (0.6.0)
multipart-post (2.1.1) multipart-post (2.1.1)
@@ -282,14 +282,14 @@ GEM
mini_portile2 (~> 2.4.0) mini_portile2 (~> 2.4.0)
oauth (0.5.4) oauth (0.5.4)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.0.1) os (1.1.0)
parallel (1.19.1) parallel (1.19.1)
parser (2.7.0.2) parser (2.7.1.0)
ast (~> 2.4.0) ast (~> 2.4.0)
pg (1.2.2) pg (1.2.3)
pry (0.12.2) pry (0.13.0)
coderay (~> 1.1.0) coderay (~> 1.1)
method_source (~> 0.9.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.3) public_suffix (4.0.3)
@@ -345,7 +345,7 @@ GEM
redis-rack-cache (2.2.1) redis-rack-cache (2.2.1)
rack-cache (>= 1.10, < 2) rack-cache (>= 1.10, < 2)
redis-store (>= 1.6, < 2) redis-store (>= 1.6, < 2)
redis-store (1.8.1) redis-store (1.8.2)
redis (>= 4, < 5) redis (>= 4, < 5)
representable (3.0.4) representable (3.0.4)
declarative (< 0.1.0) declarative (< 0.1.0)
@@ -360,15 +360,16 @@ GEM
mime-types (>= 1.16, < 4.0) mime-types (>= 1.16, < 4.0)
netrc (~> 0.8) netrc (~> 0.8)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.4)
rspec-core (3.9.1) rspec-core (3.9.1)
rspec-support (~> 3.9.1) rspec-support (~> 3.9.1)
rspec-expectations (3.9.0) rspec-expectations (3.9.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0) rspec-support (~> 3.9.0)
rspec-mocks (3.9.1) rspec-mocks (3.9.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0) rspec-support (~> 3.9.0)
rspec-rails (4.0.0.beta4) rspec-rails (4.0.0)
actionpack (>= 4.2) actionpack (>= 4.2)
activesupport (>= 4.2) activesupport (>= 4.2)
railties (>= 4.2) railties (>= 4.2)
@@ -377,19 +378,21 @@ GEM
rspec-mocks (~> 3.9) rspec-mocks (~> 3.9)
rspec-support (~> 3.9) rspec-support (~> 3.9)
rspec-support (3.9.2) rspec-support (3.9.2)
rubocop (0.79.0) rubocop (0.81.0)
jaro_winkler (~> 1.5.1) jaro_winkler (~> 1.5.1)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.7.0.1) parser (>= 2.7.0.1)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
rexml
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.7) unicode-display_width (>= 1.4.0, < 2.0)
rubocop-performance (1.5.2) rubocop-performance (1.5.2)
rubocop (>= 0.71.0) rubocop (>= 0.71.0)
rubocop-rails (2.4.2) rubocop-rails (2.5.1)
activesupport
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 0.72.0) rubocop (>= 0.72.0)
rubocop-rspec (1.37.1) rubocop-rspec (1.38.1)
rubocop (>= 0.68.1) rubocop (>= 0.68.1)
ruby-progressbar (1.10.1) ruby-progressbar (1.10.1)
sass (3.7.4) sass (3.7.4)
@@ -397,25 +400,26 @@ GEM
sass-listen (4.0.0) sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4) rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7) rb-inotify (~> 0.9, >= 0.9.7)
scout_apm (2.6.6) scout_apm (2.6.7)
parser parser
scss_lint (0.59.0) scss_lint (0.59.0)
sass (~> 3.5, >= 3.5.5) sass (~> 3.5, >= 3.5.5)
seed_dump (3.3.1) seed_dump (3.3.1)
activerecord (>= 4) activerecord (>= 4)
activesupport (>= 4) activesupport (>= 4)
sentry-raven (2.13.0) semantic_range (2.3.0)
faraday (>= 0.7.6, < 1.0) sentry-raven (3.0.0)
shoulda-matchers (4.2.0) faraday (>= 1.0)
shoulda-matchers (4.3.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
sidekiq (6.0.4) sidekiq (6.0.6)
connection_pool (>= 2.2.2) connection_pool (>= 2.2.2)
rack (>= 2.0.0) rack (~> 2.0)
rack-protection (>= 2.0.0) rack-protection (>= 2.0.0)
redis (>= 4.1.0) redis (>= 4.1.0)
signet (0.12.0) signet (0.14.0)
addressable (~> 2.3) addressable (~> 2.3)
faraday (~> 0.9) faraday (>= 0.17.3, < 2.0)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 3.0)
multi_json (~> 1.10) multi_json (~> 1.10)
simplecov (0.17.1) simplecov (0.17.1)
@@ -443,7 +447,11 @@ GEM
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
tzinfo (1.2.6) twilio-ruby (5.32.0)
faraday (~> 1.0.0)
jwt (>= 1.5, <= 2.5)
nokogiri (>= 1.6, < 2.0)
tzinfo (1.2.7)
thread_safe (~> 0.1) thread_safe (~> 0.1)
tzinfo-data (1.2019.3) tzinfo-data (1.2019.3)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
@@ -452,10 +460,10 @@ GEM
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.6) unf_ext (0.0.7.7)
unicode-display_width (1.6.1) unicode-display_width (1.7.0)
uniform_notifier (1.13.0) uniform_notifier (1.13.0)
valid_email2 (3.1.3) valid_email2 (3.2.1)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
virtus (1.0.5) virtus (1.0.5)
@@ -470,10 +478,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)
webpacker (4.2.2) webpacker (5.0.1)
activesupport (>= 4.2) activesupport (>= 5.2)
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 4.2) railties (>= 5.2)
semantic_range (>= 2.3.0)
websocket-driver (0.7.1) websocket-driver (0.7.1)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.4) websocket-extensions (0.1.4)
@@ -489,7 +498,7 @@ DEPENDENCIES
annotate annotate
attr_extras attr_extras
aws-sdk-s3 aws-sdk-s3
azure-storage azure-storage-blob
bootsnap bootsnap
brakeman brakeman
browser browser
@@ -506,6 +515,7 @@ DEPENDENCIES
flag_shih_tzu flag_shih_tzu
foreman foreman
google-cloud-storage google-cloud-storage
groupdate
haikunator haikunator
hashie hashie
jbuilder jbuilder
@@ -545,6 +555,7 @@ DEPENDENCIES
spring-watcher-listen spring-watcher-listen
telegram-bot-ruby telegram-bot-ruby
time_diff time_diff
twilio-ruby (~> 5.32.0)
twitty! twitty!
tzinfo-data tzinfo-data
uglifier uglifier
@@ -554,7 +565,7 @@ DEPENDENCIES
wisper (= 2.0.0) wisper (= 2.0.0)
RUBY VERSION RUBY VERSION
ruby 2.6.5p114 ruby 2.7.0p0
BUNDLED WITH BUNDLED WITH
2.0.2 2.1.2

View File

@@ -0,0 +1,47 @@
class ContactIdentifyAction
pattr_initialize [:contact!, :params!]
def perform
ActiveRecord::Base.transaction do
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
update_contact
end
@contact
end
private
def account
@account ||= @contact.account
end
def existing_identified_contact
return if params[:identifier].blank?
@existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier])
end
def existing_email_contact
return if params[:email].blank?
@existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email])
end
def merge_contacts?(existing_contact, _contact)
existing_contact && existing_contact.id != @contact.id
end
def update_contact
@contact.update!(params.slice(:name, :email, :identifier))
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end
def merge_contact(base_contact, merge_contact)
ContactMergeAction.new(
account: account,
base_contact: base_contact,
mergee_contact: merge_contact
).perform
end
end

View File

@@ -9,6 +9,7 @@ class ContactMergeAction
merge_contact_inboxes merge_contact_inboxes
remove_mergee_contact remove_mergee_contact
end end
@base_contact
end end
private private

View File

@@ -42,18 +42,26 @@ class AccountBuilder
def create_and_link_user def create_and_link_user
password = Time.now.to_i password = Time.now.to_i
@user = @account.users.new(email: @email, @user = User.new(email: @email,
password: password, password: password,
password_confirmation: password, password_confirmation: password,
role: User.roles['administrator'],
name: email_to_name(@email)) name: email_to_name(@email))
if @user.save! if @user.save!
link_user_to_account(@user, @account)
@user @user
else else
raise UserErrors.new(errors: @user.errors) raise UserErrors.new(errors: @user.errors)
end end
end end
def link_user_to_account(user, account)
AccountUser.create!(
account_id: account.id,
user_id: user.id,
role: AccountUser.roles['administrator']
)
end
def email_to_name(email) def email_to_name(email)
name = email[/[^@]+/] name = email[/[^@]+/]
name.split('.').map(&:capitalize).join(' ') name.split('.').map(&:capitalize).join(' ')

View File

@@ -0,0 +1,37 @@
class ContactBuilder
pattr_initialize [:source_id!, :inbox!, :contact_attributes!]
def perform
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
return contact_inbox if contact_inbox
build_contact
end
private
def account
@account ||= inbox.account
end
def build_contact
ActiveRecord::Base.transaction do
contact = account.contacts.create!(
name: contact_attributes[:name],
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:identifier]
)
contact_inbox = ::ContactInbox.create!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: source_id
)
::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
contact_inbox
rescue StandardError => e
Rails.logger e
end
end
end

View File

@@ -1,5 +1,3 @@
require 'open-uri'
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` # This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions # Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil, # 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
@@ -36,9 +34,7 @@ class Messages::MessageBuilder
return if contact.present? return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url)) @contact = Contact.create!(contact_params.except(:remote_avatar_url))
avatar_resource = LocalResource.new(contact_params[:remote_avatar_url]) ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url]
@contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
end end

View File

@@ -1,16 +1,27 @@
class Messages::Outgoing::NormalBuilder class Messages::Outgoing::NormalBuilder
include ::FileTypeHelper
attr_reader :message attr_reader :message
def initialize(user, conversation, params) def initialize(user, conversation, params)
@content = params[:message] @content = params[:message]
@private = ['1', 'true', 1, true].include? params[:private] @private = params[:private] || false
@conversation = conversation @conversation = conversation
@user = user @user = user
@fb_id = params[:fb_id] @fb_id = params[:fb_id]
@attachment = params[:attachment]
end end
def perform def perform
@message = @conversation.messages.create!(message_params) @message = @conversation.messages.build(message_params)
if @attachment
@message.attachment = Attachment.new(
account_id: message.account_id,
file_type: file_type(@attachment[:file]&.content_type)
)
@message.attachment.file.attach(@attachment[:file])
end
@message.save
@message
end end
private private
@@ -22,7 +33,7 @@ class Messages::Outgoing::NormalBuilder
message_type: :outgoing, message_type: :outgoing,
content: @content, content: @content,
private: @private, private: @private,
user_id: @user.id, user_id: @user&.id,
source_id: @fb_id source_id: @fb_id
} }
end end

View File

@@ -0,0 +1,110 @@
class V2::ReportBuilder
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def timeseries
send(params[:metric])
end
# For backward compatible with old report
def build
timeseries.each_with_object([]) do |p, arr|
arr << { value: p[1], timestamp: p[0].to_time.to_i }
end
end
def summary
{
conversations_count: conversations_count.values.sum,
incoming_messages_count: incoming_messages_count.values.sum,
outgoing_messages_count: outgoing_messages_count.values.sum,
avg_first_response_time: avg_first_response_time_summary,
avg_resolution_time: avg_resolution_time_summary,
resolutions_count: resolutions_count.values.sum
}
end
private
def scope
return account if params[:type].match?('account')
return inbox if params[:type].match?('inbox')
return user if params[:type].match?('agent')
end
def inbox
@inbox ||= account.inboxes.where(id: params[:id]).first
end
def user
@user ||= account.users.where(id: params[:id]).first
end
def conversations_count
scope.conversations
.group_by_day(:created_at, range: range, default_value: 0)
.count
end
def incoming_messages_count
scope.messages.unscoped.incoming
.group_by_day(:created_at, range: range, default_value: 0)
.count
end
def outgoing_messages_count
scope.messages.unscoped.outgoing
.group_by_day(:created_at, range: range, default_value: 0)
.count
end
def resolutions_count
scope.conversations
.resolved
.group_by_day(:created_at, range: range, default_value: 0)
.count
end
def avg_first_response_time
scope.events
.where(name: 'first_response')
.group_by_day(:created_at, range: range, default_value: 0)
.average(:value)
end
def avg_resolution_time
scope.events.where(name: 'conversation_resolved')
.group_by_day(:created_at, range: range, default_value: 0)
.average(:value)
end
def range
parse_date_time(params[:since])..parse_date_time(params[:until])
end
# Taking average of average is not too accurate
# https://en.wikipedia.org/wiki/Simpson's_paradox
# TODO: Will optimize this later
def avg_resolution_time_summary
return 0 if avg_resolution_time.values.empty?
(avg_resolution_time.values.sum / avg_resolution_time.values.length)
end
def avg_first_response_time_summary
return 0 if avg_first_response_time.values.empty?
(avg_first_response_time.values.sum / avg_first_response_time.values.length)
end
def parse_date_time(datetime)
return datetime if datetime.is_a?(DateTime)
return datetime.to_datetime if datetime.is_a?(Time) || datetime.is_a?(Date)
DateTime.strptime(datetime, '%s')
end
end

View File

@@ -1,9 +1,16 @@
class Api::BaseController < ApplicationController class Api::BaseController < ApplicationController
include AccessTokenAuthHelper
respond_to :json respond_to :json
before_action :authenticate_user! before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
before_action :authenticate_user!, unless: :authenticate_by_access_token?
private private
def authenticate_by_access_token?
request.headers[:api_access_token].present?
end
def set_conversation def set_conversation
@conversation ||= current_account.conversations.find_by(display_id: params[:conversation_id]) @conversation ||= current_account.conversations.find_by(display_id: params[:conversation_id])
end end

View File

@@ -1,10 +1,12 @@
class Api::V1::AccountsController < Api::BaseController class Api::V1::Accounts::AccountsController < Api::BaseController
include AuthHelper include AuthHelper
skip_before_action :verify_authenticity_token, only: [:create] skip_before_action :verify_authenticity_token, only: [:create]
skip_before_action :authenticate_user!, :set_current_user, :check_subscription, :handle_with_exception, skip_before_action :authenticate_user!, :set_current_user, :check_subscription, :handle_with_exception,
only: [:create], raise: false only: [:create], raise: false
before_action :check_signup_enabled before_action :check_signup_enabled, only: [:create]
before_action :check_authorization, except: [:create]
before_action :fetch_account, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail, rescue_from CustomExceptions::Account::InvalidEmail,
CustomExceptions::Account::UserExists, CustomExceptions::Account::UserExists,
@@ -18,18 +20,32 @@ class Api::V1::AccountsController < Api::BaseController
).perform ).perform
if @user if @user
send_auth_headers(@user) send_auth_headers(@user)
render json: { render 'devise/auth.json', locals: { resource: @user }
data: @user.token_validation_response
}
else else
render_error_response(CustomExceptions::Account::SignupFailed.new({})) render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end end
end end
def show
render 'api/v1/accounts/show.json'
end
def update
@account.update!(account_params.slice(:name, :locale))
end
private private
def check_authorization
authorize(Account)
end
def fetch_account
@account = current_user.accounts.find(params[:id])
end
def account_params def account_params
params.permit(:account_name, :email) params.permit(:account_name, :email, :name, :locale)
end end
def check_signup_enabled def check_signup_enabled

View File

@@ -1,4 +1,4 @@
class Api::V1::Actions::ContactMergesController < Api::BaseController class Api::V1::Accounts::Actions::ContactMergesController < Api::BaseController
before_action :set_base_contact, only: [:create] before_action :set_base_contact, only: [:create]
before_action :set_mergee_contact, only: [:create] before_action :set_mergee_contact, only: [:create]

View File

@@ -0,0 +1,69 @@
class Api::V1::Accounts::AgentsController < Api::BaseController
before_action :fetch_agent, except: [:create, :index]
before_action :check_authorization
before_action :find_user, only: [:create]
before_action :create_user, only: [:create]
before_action :save_account_user, only: [:create]
def index
@agents = agents
end
def destroy
@agent.account_user.destroy
head :ok
end
def update
@agent.update!(agent_params.except(:role))
@agent.account_user.update!(role: agent_params[:role]) if agent_params[:role]
render 'api/v1/models/user.json', locals: { resource: @agent }
end
def create
render 'api/v1/models/user.json', locals: { resource: @user }
end
private
def check_authorization
authorize(User)
end
def fetch_agent
@agent = agents.find(params[:id])
end
def find_user
@user = User.find_by(email: new_agent_params[:email])
end
def create_user
return if @user
@user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation))
end
def save_account_user
AccountUser.create!(
account_id: current_account.id,
user_id: @user.id,
role: new_agent_params[:role],
inviter_id: current_user.id
)
end
def agent_params
params.require(:agent).permit(:email, :name, :role)
end
def new_agent_params
time = Time.now.to_i
params.require(:agent).permit(:email, :name, :role)
.merge!(password: time, password_confirmation: time, inviter: current_user)
end
def agents
@agents ||= current_account.users
end
end

View File

@@ -1,6 +1,4 @@
require 'rest-client' class Api::V1::Accounts::CallbacksController < Api::BaseController
require 'telegram/bot'
class Api::V1::CallbacksController < Api::BaseController
before_action :inbox, only: [:reauthorize_page] before_action :inbox, only: [:reauthorize_page]
def register_facebook_page def register_facebook_page
@@ -18,7 +16,7 @@ class Api::V1::CallbacksController < Api::BaseController
render json: inbox render json: inbox
end end
def get_facebook_pages def facebook_pages
@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
@@ -61,13 +59,15 @@ class Api::V1::CallbacksController < Api::BaseController
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(ENV['FB_APP_ID'], ENV['FB_APP_SECRET'])
koala.exchange_access_token_info(omniauth_token)['access_token'] koala.exchange_access_token_info(omniauth_token)['access_token']
rescue StandardError => e
Rails.logger e
end end
def mark_already_existing_facebook_pages(data) def mark_already_existing_facebook_pages(data)
return [] if data.empty? return [] if data.empty?
data.inject([]) do |result, page_detail| data.inject([]) do |result, page_detail|
current_account.facebook_pages.exists?(page_id: page_detail['id']) ? page_detail.merge!(exists: true) : page_detail.merge!(exists: false) page_detail[:exists] = current_account.facebook_pages.exists?(page_id: page_detail['id']) ? true : false
result << page_detail result << page_detail
end end
end end
@@ -90,11 +90,12 @@ class Api::V1::CallbacksController < Api::BaseController
response = uri.open(redirect: false) response = uri.open(redirect: false)
rescue OpenURI::HTTPRedirect => e rescue OpenURI::HTTPRedirect => e
uri = e.uri # assigned from the "Location" response header uri = e.uri # assigned from the "Location" response header
retry if (tries -= 1) > 0 retry if (tries -= 1).positive?
raise raise
end end
pic_url = response.base_uri.to_s pic_url = response.base_uri.to_s
rescue StandardError => e rescue StandardError => e
Rails.logger.debug "Rescued: #{e.inspect}"
pic_url = nil pic_url = nil
end end
pic_url pic_url

View File

@@ -1,4 +1,4 @@
class Api::V1::CannedResponsesController < Api::BaseController class Api::V1::Accounts::CannedResponsesController < Api::BaseController
before_action :fetch_canned_response, only: [:update, :destroy] before_action :fetch_canned_response, only: [:update, :destroy]
def index def index

View File

@@ -0,0 +1,50 @@
class Api::V1::Accounts::Channels::TwilioChannelsController < Api::BaseController
before_action :authorize_request
def create
authenticate_twilio
build_inbox
setup_webhooks
rescue Twilio::REST::TwilioError => e
render_could_not_create_error(e.message)
rescue StandardError => e
render_could_not_create_error(e.message)
end
private
def authorize_request
authorize ::Inbox
end
def authenticate_twilio
client = Twilio::REST::Client.new(permitted_params[:account_sid], permitted_params[:auth_token])
client.messages.list(limit: 1)
end
def setup_webhooks
::Twilio::WebhookSetupService.new(inbox: @inbox).perform
end
def build_inbox
ActiveRecord::Base.transaction do
twilio_sms = current_account.twilio_sms.create(
account_sid: permitted_params[:account_sid],
auth_token: permitted_params[:auth_token],
phone_number: permitted_params[:phone_number]
)
@inbox = current_account.inboxes.create(
name: permitted_params[:name],
channel: twilio_sms
)
rescue StandardError => e
render_could_not_create_error(e.message)
end
end
def permitted_params
params.require(:twilio_channel).permit(
:account_id, :phone_number, :account_sid, :auth_token, :name
)
end
end

View File

@@ -1,4 +1,4 @@
class Api::V1::Contacts::ConversationsController < Api::BaseController class Api::V1::Accounts::Contacts::ConversationsController < Api::BaseController
def index def index
@conversations = current_account.conversations.includes( @conversations = current_account.conversations.includes(
:assignee, :contact, :inbox :assignee, :contact, :inbox

View File

@@ -1,4 +1,4 @@
class Api::V1::ContactsController < Api::BaseController class Api::V1::Accounts::ContactsController < Api::BaseController
protect_from_forgery with: :null_session protect_from_forgery with: :null_session
before_action :check_authorization before_action :check_authorization

View File

@@ -1,7 +1,8 @@
class Api::V1::Conversations::AssignmentsController < Api::BaseController class Api::V1::Accounts::Conversations::AssignmentsController < Api::BaseController
before_action :set_conversation, only: [:create] before_action :set_conversation, only: [:create]
def create # assign agent to a conversation # assign agent to a conversation
def create
# if params[:assignee_id] is not a valid id, it will set to nil, hence unassigning the conversation # if params[:assignee_id] is not a valid id, it will set to nil, hence unassigning the conversation
assignee = current_account.users.find_by(id: params[:assignee_id]) assignee = current_account.users.find_by(id: params[:assignee_id])
@conversation.update_assignee(assignee) @conversation.update_assignee(assignee)

View File

@@ -1,4 +1,4 @@
class Api::V1::Conversations::LabelsController < Api::BaseController class Api::V1::Accounts::Conversations::LabelsController < Api::BaseController
before_action :set_conversation, only: [:create, :index] before_action :set_conversation, only: [:create, :index]
def create def create
@@ -6,7 +6,8 @@ class Api::V1::Conversations::LabelsController < Api::BaseController
@labels = @conversation.label_list @labels = @conversation.label_list
end end
def index # all labels of the current conversation # all labels of the current conversation
def index
@labels = @conversation.label_list @labels = @conversation.label_list
end end
end end

View File

@@ -1,4 +1,4 @@
class Api::V1::Conversations::MessagesController < Api::BaseController class Api::V1::Accounts::Conversations::MessagesController < Api::BaseController
before_action :set_conversation, only: [:index, :create] before_action :set_conversation, only: [:index, :create]
def index def index

View File

@@ -1,5 +1,5 @@
class Api::V1::ConversationsController < Api::BaseController class Api::V1::Accounts::ConversationsController < Api::BaseController
before_action :set_conversation, except: [:index] before_action :conversation, except: [:index]
def index def index
result = conversation_finder.perform result = conversation_finder.perform
@@ -25,7 +25,7 @@ class Api::V1::ConversationsController < Api::BaseController
DateTime.strptime(params[:agent_last_seen_at].to_s, '%s') DateTime.strptime(params[:agent_last_seen_at].to_s, '%s')
end end
def set_conversation def conversation
@conversation ||= current_account.conversations.find_by(display_id: params[:id]) @conversation ||= current_account.conversations.find_by(display_id: params[:id])
end end

View File

@@ -1,4 +1,4 @@
class Api::V1::FacebookIndicatorsController < Api::BaseController class Api::V1::Accounts::FacebookIndicatorsController < Api::BaseController
before_action :set_access_token before_action :set_access_token
around_action :handle_with_exception around_action :handle_with_exception
@@ -26,6 +26,7 @@ class Api::V1::FacebookIndicatorsController < Api::BaseController
def handle_with_exception def handle_with_exception
yield yield
rescue Facebook::Messenger::Error => e rescue Facebook::Messenger::Error => e
Rails.logger.debug "Rescued: #{e.inspect}"
true true
end end

View File

@@ -1,4 +1,4 @@
class Api::V1::InboxMembersController < Api::BaseController class Api::V1::Accounts::InboxMembersController < Api::BaseController
before_action :fetch_inbox, only: [:create, :show] before_action :fetch_inbox, only: [:create, :show]
before_action :current_agents_ids, only: [:create] before_action :current_agents_ids, only: [:create]

View File

@@ -1,4 +1,4 @@
class Api::V1::InboxesController < Api::BaseController class Api::V1::Accounts::InboxesController < Api::BaseController
before_action :check_authorization before_action :check_authorization
before_action :fetch_inbox, only: [:destroy, :update] before_action :fetch_inbox, only: [:destroy, :update]
@@ -6,15 +6,15 @@ class Api::V1::InboxesController < Api::BaseController
@inboxes = policy_scope(current_account.inboxes) @inboxes = policy_scope(current_account.inboxes)
end end
def update
@inbox.update(inbox_update_params)
end
def destroy def destroy
@inbox.destroy @inbox.destroy
head :ok head :ok
end end
def update
@inbox.update(inbox_update_params)
end
private private
def fetch_inbox def fetch_inbox

View File

@@ -1,5 +1,6 @@
class Api::V1::LabelsController < Api::BaseController class Api::V1::Accounts::LabelsController < Api::BaseController
def index # list all labels in account # list all labels in account
def index
@labels = current_account.all_conversation_tags @labels = current_account.all_conversation_tags
end end

View File

@@ -1,4 +1,4 @@
class Api::V1::User::NotificationSettingsController < Api::BaseController class Api::V1::Accounts::NotificationSettingsController < Api::BaseController
before_action :set_user, :load_notification_setting before_action :set_user, :load_notification_setting
def show; end def show; end

View File

@@ -1,4 +1,4 @@
class Api::V1::ReportsController < Api::BaseController class Api::V1::Accounts::ReportsController < Api::BaseController
include CustomExceptions::Report include CustomExceptions::Report
include Constants::Report include Constants::Report
@@ -36,10 +36,6 @@ class Api::V1::ReportsController < Api::BaseController
current_user.account current_user.account
end end
def agent
@agent ||= current_account.users.find(params[:agent_id])
end
def account_summary_metrics def account_summary_metrics
summary_metrics(ACCOUNT_METRICS, :account_summary_params, AVG_ACCOUNT_METRICS) summary_metrics(ACCOUNT_METRICS, :account_summary_params, AVG_ACCOUNT_METRICS)
end end
@@ -51,16 +47,16 @@ class Api::V1::ReportsController < Api::BaseController
def summary_metrics(metrics, calc_function, avg_metrics) def summary_metrics(metrics, calc_function, avg_metrics)
metrics.each_with_object({}) do |metric, result| metrics.each_with_object({}) do |metric, result|
data = ReportBuilder.new(current_account, send(calc_function, metric)).build data = ReportBuilder.new(current_account, send(calc_function, metric)).build
result[metric] = calculate_metric(data, metric, avg_metrics)
end
end
def calculate_metric(data, metric, avg_metrics)
sum = data.inject(0) { |val, hash| val + hash[:value].to_i }
if avg_metrics.include?(metric) if avg_metrics.include?(metric)
sum = data.inject(0) { |sum, hash| sum + hash[:value].to_i }
sum /= data.length unless sum.zero? sum /= data.length unless sum.zero?
else
sum = data.inject(0) { |sum, hash| sum + hash[:value].to_i }
end
result[metric] = sum
end end
sum
end end
def account_summary_params(metric) def account_summary_params(metric)

View File

@@ -1,4 +1,4 @@
class Api::V1::SubscriptionsController < Api::BaseController class Api::V1::Accounts::SubscriptionsController < Api::BaseController
skip_before_action :check_subscription skip_before_action :check_subscription
before_action :check_billing_enabled before_action :check_billing_enabled

View File

@@ -1,4 +1,4 @@
class Api::V1::Account::WebhooksController < Api::BaseController class Api::V1::Accounts::WebhooksController < Api::BaseController
before_action :check_authorization before_action :check_authorization
before_action :fetch_webhook, only: [:update, :destroy] before_action :fetch_webhook, only: [:update, :destroy]

View File

@@ -1,4 +1,4 @@
class Api::V1::Widget::InboxesController < Api::BaseController class Api::V1::Accounts::Widget::InboxesController < Api::BaseController
before_action :authorize_request before_action :authorize_request
before_action :set_web_widget_channel, only: [:update] before_action :set_web_widget_channel, only: [:update]
before_action :set_inbox, only: [:update] before_action :set_inbox, only: [:update]

View File

@@ -1,52 +0,0 @@
class Api::V1::AgentsController < Api::BaseController
before_action :fetch_agent, except: [:create, :index]
before_action :check_authorization
before_action :build_agent, only: [:create]
def index
@agents = agents
end
def destroy
@agent.destroy
head :ok
end
def update
@agent.update!(agent_params)
render json: @agent
end
def create
@agent.save!
render json: @agent
end
private
def check_authorization
authorize(User)
end
def fetch_agent
@agent = agents.find(params[:id])
end
def build_agent
@agent = agents.new(new_agent_params)
end
def agent_params
params.require(:agent).permit(:email, :name, :role)
end
def new_agent_params
time = Time.now.to_i
params.require(:agent).permit(:email, :name, :role)
.merge!(password: time, password_confirmation: time, inviter: current_user)
end
def agents
@agents ||= current_account.users
end
end

View File

@@ -5,6 +5,7 @@ class Api::V1::WebhooksController < ApplicationController
before_action :login_from_basic_auth, only: [:chargebee] before_action :login_from_basic_auth, only: [:chargebee]
before_action :check_billing_enabled, only: [:chargebee] before_action :check_billing_enabled, only: [:chargebee]
def chargebee def chargebee
chargebee_consumer.consume chargebee_consumer.consume
head :ok head :ok

View File

@@ -0,0 +1,18 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
before_action :set_web_widget
before_action :set_contact
def update
contact_identify_action = ContactIdentifyAction.new(
contact: @contact,
params: permitted_params.to_h.deep_symbolize_keys
)
render json: contact_identify_action.perform
end
private
def permitted_params
params.permit(:website_token, :identifier, :email, :name, :avatar_url)
end
end

View File

@@ -0,0 +1,24 @@
class Api::V1::Widget::LabelsController < Api::V1::Widget::BaseController
before_action :set_web_widget
before_action :set_contact
def create
conversation.label_list.add(permitted_params[:label])
conversation.save!
head :no_content
end
def destroy
conversation.label_list.remove(permitted_params[:id])
conversation.save!
head :no_content
end
private
def permitted_params
params.permit(:id, :label, :website_token)
end
end

View File

@@ -10,20 +10,29 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def create def create
@message = conversation.messages.new(message_params) @message = conversation.messages.new(message_params)
build_attachment
@message.save! @message.save!
render json: @message
end end
def update def update
@message.update!(input_submitted_email: contact_email) @message.update!(input_submitted_email: contact_email)
update_contact(contact_email) update_contact(contact_email)
head :no_content
rescue StandardError => e rescue StandardError => e
render json: { error: @contact.errors, message: e.message }.to_json, status: 500 render json: { error: @contact.errors, message: e.message }.to_json, status: 500
end end
private private
def build_attachment
return if params[:message][:attachment].blank?
@message.attachment = Attachment.new(
account_id: @message.account_id,
file_type: helpers.file_type(params[:message][:attachment][:file]&.content_type)
)
@message.attachment.file.attach(params[:message][:attachment][:file])
end
def set_conversation def set_conversation
@conversation = ::Conversation.create!(conversation_params) if conversation.nil? @conversation = ::Conversation.create!(conversation_params) if conversation.nil?
end end
@@ -86,7 +95,11 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def update_contact(email) def update_contact(email)
contact_with_email = @account.contacts.find_by(email: email) contact_with_email = @account.contacts.find_by(email: email)
if contact_with_email if contact_with_email
::ContactMergeAction.new(account: @account, base_contact: contact_with_email, mergee_contact: @contact).perform @contact = ::ContactMergeAction.new(
account: @account,
base_contact: contact_with_email,
mergee_contact: @contact
).perform
else else
@contact.update!( @contact.update!(
email: email, email: email,

View File

@@ -0,0 +1,39 @@
class Api::V2::Accounts::ReportsController < Api::BaseController
def account
builder = V2::ReportBuilder.new(current_account, account_report_params)
data = builder.build
render json: data
end
def account_summary
render json: account_summary_metrics
end
private
def current_account
current_user.account
end
def account_summary_params
{
type: :account,
since: params[:since],
until: params[:until]
}
end
def account_report_params
{
metric: params[:metric],
type: :account,
since: params[:since],
until: params[:until]
}
end
def account_summary_metrics
builder = V2::ReportBuilder.new(current_account, account_summary_params)
builder.summary
end
end

View File

@@ -14,7 +14,25 @@ class ApplicationController < ActionController::Base
private private
def current_account def current_account
@_ ||= current_user.account @_ ||= find_current_account
end
def find_current_account
account = Account.find(params[:account_id])
if current_user
account_accessible_for_user?(account)
elsif @resource&.is_a?(AgentBot)
account_accessible_for_bot?(account)
end
account
end
def account_accessible_for_user?(account)
render_unauthorized('You are not authorized to access this account') unless account.account_users.find_by(user_id: current_user.id)
end
def account_accessible_for_bot?(account)
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end end
def handle_with_exception def handle_with_exception

View File

@@ -0,0 +1,25 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => ['toggle_status'],
'api/v1/accounts/conversations/messages' => ['create']
}.freeze
def authenticate_access_token!
access_token = AccessToken.find_by(token: request.headers[:api_access_token])
render_unauthorized('Invalid Access Token') && return unless access_token
token_owner = access_token.owner
@resource = token_owner
end
def validate_bot_access_token!
return if current_user.is_a?(User)
return if agent_bot_accessible?
render_unauthorized('Access to this endpoint is not authorized for bots')
end
def agent_bot_accessible?
BOT_ACCESSIBLE_ENDPOINTS.fetch(params[:controller], []).include?(params[:action])
end
end

View File

@@ -11,9 +11,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
@recoverable = User.find_by(reset_password_token: reset_password_token) @recoverable = User.find_by(reset_password_token: reset_password_token)
if @recoverable && reset_password_and_confirmation(@recoverable) if @recoverable && reset_password_and_confirmation(@recoverable)
send_auth_headers(@recoverable) send_auth_headers(@recoverable)
render json: { render 'devise/auth.json', locals: { resource: @recoverable }
data: @recoverable.token_validation_response
}
else else
render json: { "message": 'Invalid token', "redirect_url": '/' }, status: 422 render json: { "message": 'Invalid token', "redirect_url": '/' }, status: 422
end end

View File

@@ -4,6 +4,6 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle
wrap_parameters format: [] wrap_parameters format: []
def render_create_success def render_create_success
render 'devise/auth.json' render 'devise/auth.json', locals: { resource: @resource }
end end
end end

View File

@@ -0,0 +1,29 @@
class Twilio::CallbackController < ApplicationController
def create
::Twilio::IncomingMessageService.new(params: permitted_params).perform
head :no_content
end
private
def permitted_params
params.permit(
:ApiVersion,
:SmsSid,
:From,
:ToState,
:ToZip,
:AccountSid,
:MessageSid,
:FromCountry,
:ToCity,
:FromCity,
:To,
:FromZip,
:Body,
:ToCountry,
:FromState
)
end
end

View File

@@ -1,6 +1,6 @@
class Twitter::CallbacksController < Twitter::BaseController class Twitter::CallbacksController < Twitter::BaseController
def show def show
return redirect_to app_new_twitter_inbox_url if permitted_params[:denied] return redirect_to twitter_app_redirect_url if permitted_params[:denied]
@response = twitter_client.access_token( @response = twitter_client.access_token(
oauth_token: permitted_params[:oauth_token], oauth_token: permitted_params[:oauth_token],
@@ -10,9 +10,9 @@ class Twitter::CallbacksController < Twitter::BaseController
inbox = build_inbox inbox = build_inbox
::Redis::Alfred.delete(permitted_params[:oauth_token]) ::Redis::Alfred.delete(permitted_params[:oauth_token])
::Twitter::WebhookSubscribeService.new(inbox_id: inbox.id).perform ::Twitter::WebhookSubscribeService.new(inbox_id: inbox.id).perform
redirect_to app_twitter_inbox_agents_url(inbox_id: inbox.id) redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
else else
redirect_to app_new_twitter_inbox_url redirect_to twitter_app_redirect_url
end end
end end
@@ -30,6 +30,10 @@ class Twitter::CallbacksController < Twitter::BaseController
@account ||= Account.find_by!(id: account_id) @account ||= Account.find_by!(id: account_id)
end end
def twitter_app_redirect_url
app_new_twitter_inbox_url(account_id: account.id)
end
def build_inbox def build_inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
twitter_profile = account.twitter_profiles.create( twitter_profile = account.twitter_profiles.create(

View File

@@ -1,11 +1,16 @@
class AsyncDispatcher < BaseDispatcher class AsyncDispatcher < BaseDispatcher
def dispatch(event_name, timestamp, data) def dispatch(event_name, timestamp, data)
EventDispatcherJob.perform_later(event_name, timestamp, data)
end
def publish_event(event_name, timestamp, data)
event_object = Events::Base.new(event_name, timestamp, data) event_object = Events::Base.new(event_name, timestamp, data)
publish(event_object.method_name, event_object) publish(event_object.method_name, event_object)
end end
def listeners def listeners
listeners = [EmailNotificationListener.instance, ReportingListener.instance, WebhookListener.instance] listeners = [AgentBotListener.instance, EmailNotificationListener.instance, ReportingListener.instance, WebhookListener.instance]
listeners << EventListener.instance
listeners << SubscriptionListener.instance if ENV['BILLING_ENABLED'] listeners << SubscriptionListener.instance if ENV['BILLING_ENABLED']
listeners listeners
end end

View File

@@ -0,0 +1,14 @@
module FileTypeHelper
def file_type(content_type)
return :image if [
'image/jpeg',
'image/png',
'image/svg+xml',
'image/gif',
'image/tiff',
'image/bmp'
].include?(content_type)
:file
end
end

View File

@@ -8,7 +8,10 @@
</template> </template>
<script> <script>
import Vue from 'vue';
import { mapGetters } from 'vuex';
import WootSnackbarBox from './components/SnackbarContainer'; import WootSnackbarBox from './components/SnackbarContainer';
import { accountIdFromPathname } from './helper/URLHelper';
export default { export default {
name: 'App', name: 'App',
@@ -17,8 +20,28 @@ export default {
WootSnackbarBox, WootSnackbarBox,
}, },
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
}),
},
mounted() { mounted() {
this.$store.dispatch('setUser'); this.$store.dispatch('setUser');
this.initializeAccount();
},
methods: {
async initializeAccount() {
const { pathname } = window.location;
const accountId = accountIdFromPathname(pathname);
if (accountId) {
await this.$store.dispatch('accounts/get');
const { locale } = this.getAccount(accountId);
Vue.config.lang = locale;
}
},
}, },
}; };
</script> </script>

View File

@@ -3,9 +3,25 @@
const API_VERSION = `/api/v1`; const API_VERSION = `/api/v1`;
class ApiClient { class ApiClient {
constructor(url) { constructor(resource, options = {}) {
this.apiVersion = API_VERSION; this.apiVersion = API_VERSION;
this.url = `${this.apiVersion}/${url}`; this.options = options;
this.resource = resource;
}
get url() {
let url = this.apiVersion;
if (this.options.accountScoped) {
const isInsideAccountScopedURLs = window.location.pathname.includes(
'/app/accounts'
);
if (isInsideAccountScopedURLs) {
const accountId = window.location.pathname.split('/')[3];
url = `${url}/accounts/${accountId}`;
}
}
return `${url}/${this.resource}`;
} }
get() { get() {

View File

@@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class AccountAPI extends ApiClient {
constructor() {
super('', { accountScoped: true });
}
}
export default new AccountAPI();

View File

@@ -2,7 +2,7 @@ import ApiClient from './ApiClient';
class Agents extends ApiClient { class Agents extends ApiClient {
constructor() { constructor() {
super('agents'); super('agents', { accountScoped: true });
} }
} }

View File

@@ -4,7 +4,7 @@ import ApiClient from './ApiClient';
class CannedResponse extends ApiClient { class CannedResponse extends ApiClient {
constructor() { constructor() {
super('canned_responses'); super('canned_responses', { accountScoped: true });
} }
get({ searchKey }) { get({ searchKey }) {

View File

@@ -3,7 +3,7 @@ import ApiClient from '../ApiClient';
class FBChannel extends ApiClient { class FBChannel extends ApiClient {
constructor() { constructor() {
super('facebook_indicators'); super('facebook_indicators', { accountScoped: true });
} }
markSeen({ inboxId, contactId }) { markSeen({ inboxId, contactId }) {
@@ -22,7 +22,7 @@ class FBChannel extends ApiClient {
create(params) { create(params) {
return axios.post( return axios.post(
`${this.apiVersion}/callbacks/register_facebook_page`, `${this.url.replace(this.resource, '')}callbacks/register_facebook_page`,
params params
); );
} }

View File

@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class TwilioChannel extends ApiClient {
constructor() {
super('channels/twilio_channel', { accountScoped: true });
}
}
export default new TwilioChannel();

View File

@@ -2,7 +2,7 @@ import ApiClient from '../ApiClient';
class WebChannel extends ApiClient { class WebChannel extends ApiClient {
constructor() { constructor() {
super('widget/inboxes'); super('widget/inboxes', { accountScoped: true });
} }
} }

View File

@@ -5,9 +5,9 @@
import endPoints from './endPoints'; import endPoints from './endPoints';
export default { export default {
fetchFacebookPages(token) { fetchFacebookPages(token, accountId) {
const urlData = endPoints('fetchFacebookPages'); const urlData = endPoints('fetchFacebookPages');
urlData.params.omniauth_token = token; urlData.params.omniauth_token = token;
return axios.post(urlData.url, urlData.params); return axios.post(urlData.url(accountId), urlData.params);
}, },
}; };

View File

@@ -3,7 +3,7 @@ import ApiClient from './ApiClient';
class ContactAPI extends ApiClient { class ContactAPI extends ApiClient {
constructor() { constructor() {
super('contacts'); super('contacts', { accountScoped: true });
} }
getConversations(contactId) { getConversations(contactId) {

View File

@@ -3,7 +3,7 @@ import ApiClient from './ApiClient';
class ConversationApi extends ApiClient { class ConversationApi extends ApiClient {
constructor() { constructor() {
super('conversations'); super('conversations', { accountScoped: true });
} }
getLabels(conversationID) { getLabels(conversationID) {

View File

@@ -28,23 +28,12 @@ const endPoints = {
}, },
fetchFacebookPages: { fetchFacebookPages: {
url: 'api/v1/callbacks/get_facebook_pages.json', url(accountId) {
return `api/v1/accounts/${accountId}/callbacks/facebook_pages.json`;
},
params: { omniauth_token: '' }, params: { omniauth_token: '' },
}, },
reports: {
account(metric, from, to) {
return {
url: `/api/v1/reports/account?metric=${metric}&since=${from}&to=${to}`,
};
},
accountSummary(accountId, from, to) {
return {
url: `/api/v1/reports/${accountId}/account_summary?since=${from}&to=${to}`,
};
},
},
subscriptions: { subscriptions: {
get() { get() {
return { return {

View File

@@ -3,7 +3,7 @@ import ApiClient from '../ApiClient';
class ConversationApi extends ApiClient { class ConversationApi extends ApiClient {
constructor() { constructor() {
super('conversations'); super('conversations', { accountScoped: true });
} }
get({ inboxId, status, assigneeType, page }) { get({ inboxId, status, assigneeType, page }) {

View File

@@ -4,7 +4,7 @@ import ApiClient from '../ApiClient';
class MessageApi extends ApiClient { class MessageApi extends ApiClient {
constructor() { constructor() {
super('conversations'); super('conversations', { accountScoped: true });
} }
create({ conversationId, message, private: isPrivate }) { create({ conversationId, message, private: isPrivate }) {
@@ -19,6 +19,16 @@ class MessageApi extends ApiClient {
params: { before }, params: { before },
}); });
} }
sendAttachment([conversationId, { file }]) {
const formData = new FormData();
formData.append('attachment[file]', file);
return axios({
method: 'post',
url: `${this.url}/${conversationId}/messages`,
data: formData,
});
}
} }
export default new MessageApi(); export default new MessageApi();

View File

@@ -3,7 +3,7 @@ import ApiClient from './ApiClient';
class InboxMembers extends ApiClient { class InboxMembers extends ApiClient {
constructor() { constructor() {
super('inbox_members'); super('inbox_members', { accountScoped: true });
} }
create({ inboxId, agentList }) { create({ inboxId, agentList }) {

View File

@@ -2,7 +2,7 @@ import ApiClient from './ApiClient';
class Inboxes extends ApiClient { class Inboxes extends ApiClient {
constructor() { constructor() {
super('inboxes'); super('inboxes', { accountScoped: true });
} }
} }

View File

@@ -1,14 +1,22 @@
/* global axios */ /* global axios */
import ApiClient from './ApiClient';
import endPoints from './endPoints'; class ReportsAPI extends ApiClient {
constructor() {
super('reports', { accountScoped: true });
}
export default { getAccountReports(metric, since, until) {
getAccountReports(metric, from, to) { return axios.get(`${this.url}/account`, {
const { url } = endPoints('reports').account(metric, from, to); params: { metric, since, until },
return axios.get(url); });
}, }
getAccountSummary(accountId, from, to) {
const urlData = endPoints('reports').accountSummary(accountId, from, to); getAccountSummary(accountId, since, until) {
return axios.get(urlData.url); return axios.get(`${this.url}/${accountId}/account_summary`, {
}, params: { since, until },
}; });
}
}
export default new ReportsAPI();

View File

@@ -0,0 +1,15 @@
import fbChannel from '../../channel/fbChannel';
import ApiClient from '../../ApiClient';
describe('#FBChannel', () => {
it('creates correct instance', () => {
expect(fbChannel).toBeInstanceOf(ApiClient);
expect(fbChannel).toHaveProperty('get');
expect(fbChannel).toHaveProperty('show');
expect(fbChannel).toHaveProperty('create');
expect(fbChannel).toHaveProperty('update');
expect(fbChannel).toHaveProperty('delete');
expect(fbChannel).toHaveProperty('markSeen');
expect(fbChannel).toHaveProperty('toggleTyping');
});
});

View File

@@ -3,7 +3,7 @@ import ApiClient from './ApiClient';
class UserNotificationSettings extends ApiClient { class UserNotificationSettings extends ApiClient {
constructor() { constructor() {
super('user/notification_settings'); super('notification_settings', { accountScoped: true });
} }
update(params) { update(params) {

View File

@@ -2,7 +2,7 @@ import ApiClient from './ApiClient';
class WebHooks extends ApiClient { class WebHooks extends ApiClient {
constructor() { constructor() {
super('account/webhooks'); super('webhooks', { accountScoped: true });
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -3,8 +3,8 @@
} }
.flex-center { .flex-center {
display: flex;
@include flex-align(center, middle); @include flex-align(center, middle);
display: flex;
} }
.bottom-space-fix { .bottom-space-fix {
@@ -17,42 +17,43 @@
.spinner { .spinner {
@include color-spinner(); @include color-spinner();
position: relative;
display: inline-block; display: inline-block;
width: $space-medium;
height: $space-medium; height: $space-medium;
padding: $zero $space-medium; padding: $zero $space-medium;
position: relative;
vertical-align: middle; vertical-align: middle;
width: $space-medium;
&.message { &.message {
padding: $space-normal; @include elegent-shadow;
top: 0;
left: 0;
margin: 0 auto;
margin-top: $space-slab;
background: $color-white; background: $color-white;
border-radius: $space-large; border-radius: $space-large;
@include elegent-shadow; left: 0;
margin: $space-slab 0 auto;
padding: $space-normal;
top: 0;
&:before { &::before {
margin-top: -$space-slab;
margin-left: -$space-slab; margin-left: -$space-slab;
margin-top: -$space-slab;
} }
} }
&.small { &.small {
width: $space-normal;
height: $space-normal; height: $space-normal;
&:before {
width: $space-normal; width: $space-normal;
&::before {
height: $space-normal; height: $space-normal;
margin-top: -$space-small; margin-top: -$space-small;
width: $space-normal;
} }
} }
} }
input, textarea { input,
textarea,
select {
border-radius: 4px !important; border-radius: 4px !important;
} }

View File

@@ -18,6 +18,10 @@
font-size: $font-size-small; font-size: $font-size-small;
} }
.text-muted {
color: $color-gray;
}
a { a {
font-size: $font-size-small; font-size: $font-size-small;
} }

View File

@@ -20,12 +20,12 @@
border-radius: $space-smaller; border-radius: $space-smaller;
margin-right: $space-normal; margin-right: $space-normal;
&:before { &::before {
line-height: 3.8rem; color: $medium-gray;
font-size: $font-size-default; font-size: $font-size-default;
line-height: 3.8rem;
padding-left: $space-slab; padding-left: $space-slab;
padding-right: $space-smaller; padding-right: $space-smaller;
color: $medium-gray;
} }
.multiselect { .multiselect {
@@ -49,33 +49,32 @@
} }
.user--profile__meta { .user--profile__meta {
align-items: flex-start;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start;
justify-content: center; justify-content: center;
margin-left: $space-slab; margin-left: $space-slab;
} }
.user--profile__button { .user--profile__button {
color: $color-woot;
font-size: $font-size-mini; font-size: $font-size-mini;
margin-top: $space-micro; margin-top: $space-micro;
cursor: pointer; padding: 0;
} }
} }
} }
.button.resolve--button { .button.resolve--button {
>.icon { >.icon {
padding-right: $space-small;
font-size: $font-size-default; font-size: $font-size-default;
padding-right: $space-small;
} }
.spinner { .spinner {
padding: 0 $space-one;
margin-right: $space-smaller; margin-right: $space-smaller;
padding: 0 $space-one;
&:before { &::before {
border-top-color: $color-white; border-top-color: $color-white;
} }
} }

View File

@@ -43,6 +43,11 @@
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

@@ -31,26 +31,36 @@
} }
.image { .image {
@include flex; cursor: pointer;
align-items: flex-end; position: relative;
justify-content: center;
text-align: center;
img {
@include padding($space-small);
max-height: 30rem;
max-width: 20rem;
}
.time { .time {
margin-left: -$space-large; bottom: $space-smaller;
color: $color-white;
position: absolute;
right: $space-small;
white-space: nowrap; white-space: nowrap;
} }
.modal-container {
text-align: center;
}
.modal-image { .modal-image {
max-height: 80%;
max-width: 80%; max-width: 80%;
} }
&::before {
$color-black: #000;
background-image: linear-gradient(-180deg, transparent 3%, $color-black 70%);
bottom: 0;
content: '';
height: 20%;
left: 0;
opacity: .8;
position: absolute;
width: 100%;
}
} }
.map { .map {
@@ -83,18 +93,12 @@
flex-direction: column; flex-direction: column;
.load-more-conversations { .load-more-conversations {
color: $color-woot;
cursor: pointer;
font-size: $font-size-small; font-size: $font-size-small;
padding: $space-normal; padding: $space-normal;
width: 100%;
&:hover {
background: $color-background;
}
} }
.end-of-list-text { .end-of-list-text {
font-style: italic;
padding: $space-normal; padding: $space-normal;
} }

View File

@@ -47,7 +47,7 @@
} }
} }
>.icon { .icon {
color: $medium-gray; color: $medium-gray;
cursor: pointer; cursor: pointer;
font-size: $font-size-medium; font-size: $font-size-medium;
@@ -58,6 +58,16 @@
} }
} }
.file-uploads>label {
cursor: pointer;
}
.attachment {
cursor: pointer;
margin-right: $space-one;
padding: 0 $space-small;
}
>textarea { >textarea {
@include ghost-input(); @include ghost-input();
@include margin(0); @include margin(0);

View File

@@ -28,9 +28,16 @@
color: $color-gray; color: $color-gray;
font-size: $font-size-default; font-size: $font-size-default;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
.wrap,
.child-icon {
&:hover {
color: $color-woot;
}
}
} }
.active a { .active a .wrap {
color: $color-woot; color: $color-woot;
} }
} }
@@ -100,7 +107,7 @@
margin-top: $space-medium; margin-top: $space-medium;
>span { >span {
margin-left: auto; margin-left: $space-one;
} }
} }
} }

View File

@@ -21,7 +21,17 @@
} }
.tabs-title { .tabs-title {
@include margin($zero $space-one); @include margin($zero $space-slab);
.badge {
background: $color-background;
border-radius: $space-small;
color: $color-gray;
font-size: $font-size-micro;
font-weight: $font-weight-black;
margin-left: $space-smaller;
padding: $space-smaller;
}
&:first-child { &:first-child {
margin-left: 0; margin-left: 0;
@@ -40,10 +50,13 @@
a { a {
@include position(relative, 1px null null null); @include position(relative, 1px null null null);
transition: all .15s $ease-in-out-cubic; align-items: center;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
color: $medium-gray; color: $medium-gray;
display: flex;
flex-direction: row;
font-size: $font-size-small; font-size: $font-size-small;
transition: all .15s $ease-in-out-cubic;
} }
&.is-active { &.is-active {
@@ -51,5 +64,10 @@
border-bottom-color: $color-woot; border-bottom-color: $color-woot;
color: $color-woot; color: $color-woot;
} }
.badge {
background: $color-extra-light-blue;
color: $color-woot;
}
} }
} }

View File

@@ -32,7 +32,7 @@
<div <div
v-if="!hasCurrentPageEndReached && !chatListLoading" v-if="!hasCurrentPageEndReached && !chatListLoading"
class="text-center load-more-conversations" class="clear button load-more-conversations"
@click="fetchConversations" @click="fetchConversations"
> >
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }} {{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}

View File

@@ -26,7 +26,8 @@ export default {
}, },
}, },
methods: { methods: {
onCopy() { onCopy(e) {
e.preventDefault();
copy(this.script); copy(this.script);
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL')); bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
}, },

View File

@@ -43,13 +43,13 @@
> >
<ul class="vertical dropdown menu"> <ul class="vertical dropdown menu">
<li> <li>
<router-link to="/app/profile/settings"> <router-link :to="`/app/accounts/${accountId}/profile/settings`">
{{ $t('SIDEBAR.PROFILE_SETTINGS') }} {{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
</router-link> </router-link>
</li> </li>
<li> <li>
<a href="#" @click.prevent="logout()"> <a href="#" @click.prevent="logout()">
{{ $t('SIDEBAR.LOGOUT') }} {{ $t('SIDEBAR_ITEMS.LOGOUT') }}
</a> </a>
</li> </li>
</ul> </ul>
@@ -139,23 +139,23 @@ export default {
inboxSection() { inboxSection() {
return { return {
icon: 'ion-folder', icon: 'ion-folder',
label: 'Inboxes', label: 'INBOXES',
hasSubMenu: true, hasSubMenu: true,
newLink: true, newLink: true,
key: 'inbox', key: 'inbox',
cssClass: 'menu-title align-justify', cssClass: 'menu-title align-justify',
toState: frontendURL('settings/inboxes'), toState: frontendURL(`accounts/${this.accountId}/settings/inboxes`),
toStateName: 'settings_inbox_list', toStateName: 'settings_inbox_list',
children: this.inboxes.map(inbox => ({ children: this.inboxes.map(inbox => ({
id: inbox.id, id: inbox.id,
label: inbox.name, label: inbox.name,
toState: frontendURL(`inbox/${inbox.id}`), toState: frontendURL(`accounts/${this.accountId}/inbox/${inbox.id}`),
type: inbox.channel_type, type: inbox.channel_type,
})), })),
}; };
}, },
dashboardPath() { dashboardPath() {
return frontendURL('dashboard'); return frontendURL(`accounts/${this.accountId}/dashboard`);
}, },
shouldShowStatusBox() { shouldShowStatusBox() {
return ( return (
@@ -176,6 +176,9 @@ export default {
trialMessage() { trialMessage() {
return `${this.daysLeft} ${this.$t('APP_GLOBAL.TRIAL_MESSAGE')}`; return `${this.daysLeft} ${this.$t('APP_GLOBAL.TRIAL_MESSAGE')}`;
}, },
accountId() {
return this.currentUser.account_id;
},
}, },
mounted() { mounted() {
this.$store.dispatch('inboxes/get'); this.$store.dispatch('inboxes/get');

View File

@@ -6,16 +6,19 @@
:class="computedClass" :class="computedClass"
> >
<a <a
class="sub-menu-title"
:class="getMenuItemClass" :class="getMenuItemClass"
data-tooltip data-tooltip
aria-haspopup="true" aria-haspopup="true"
:title="menuItem.toolTip" :title="menuItem.toolTip"
> >
<div class="wrap">
<i :class="menuItem.icon" /> <i :class="menuItem.icon" />
{{ menuItem.label }} {{ $t(`SIDEBAR.${menuItem.label}`) }}
</div>
<span <span
v-if="showItem(menuItem)" v-if="showItem(menuItem)"
class="ion-ios-plus-outline" class="child-icon ion-android-add-circle"
@click.prevent="newLinkClick" @click.prevent="newLinkClick"
/> />
</a> </a>
@@ -28,12 +31,14 @@
:to="child.toState" :to="child.toState"
> >
<a href="#"> <a href="#">
<div class="wrap">
<i <i
v-if="computedInboxClass(child)" v-if="computedInboxClass(child)"
class="inbox-icon" class="inbox-icon"
:class="computedInboxClass(child)" :class="computedInboxClass(child)"
></i> ></i>
{{ child.label }} {{ child.label }}
</div>
</a> </a>
</router-link> </router-link>
</ul> </ul>
@@ -51,6 +56,7 @@ const INBOX_TYPES = {
WEB: 'Channel::WebWidget', WEB: 'Channel::WebWidget',
FB: 'Channel::FacebookPage', FB: 'Channel::FacebookPage',
TWITTER: 'Channel::TwitterProfile', TWITTER: 'Channel::TwitterProfile',
TWILIO: 'Channel::TwilioSms',
}; };
const getInboxClassByType = type => { const getInboxClassByType = type => {
switch (type) { switch (type) {
@@ -63,6 +69,9 @@ const getInboxClassByType = type => {
case INBOX_TYPES.TWITTER: case INBOX_TYPES.TWITTER:
return 'ion-social-twitter'; return 'ion-social-twitter';
case INBOX_TYPES.TWILIO:
return 'ion-android-textsms';
default: default:
return ''; return '';
} }
@@ -115,3 +124,9 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss" scoped>
.sub-menu-title {
display: flex;
justify-content: space-between;
}
</style>

View File

@@ -80,7 +80,8 @@ export default {
} }
}} }}
> >
{`${this.name} (${this.getItemCount})`} {`${this.name}`}
<span class="badge">{this.getItemCount}</span>
</a> </a>
</li> </li>
); );

View File

@@ -29,12 +29,6 @@
/* eslint no-console: 0 */ /* eslint no-console: 0 */
export default { export default {
props: { props: {
items: {
type: Array,
default() {
return [];
},
},
isFullwidth: Boolean, isFullwidth: Boolean,
}, },
@@ -45,6 +39,9 @@ export default {
activeIndex() { activeIndex() {
return this.items.findIndex(i => i.route === this.$route.name); return this.items.findIndex(i => i.route === this.$route.name);
}, },
items() {
return this.$t('INBOX_MGMT.CREATE_FLOW');
},
}, },
methods: { methods: {
isActive(item) { isActive(item) {

View File

@@ -81,5 +81,6 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
background-image: linear-gradient(to top, #4481eb 0%, #04befe 100%);
} }
</style> </style>

View File

@@ -24,6 +24,10 @@
v-if="channel === 'website'" v-if="channel === 'website'"
src="~dashboard/assets/images/channels/website.png" src="~dashboard/assets/images/channels/website.png"
/> />
<img
v-if="channel === 'twilio'"
src="~dashboard/assets/images/channels/twilio.png"
/>
<h3 class="channel__title"> <h3 class="channel__title">
{{ channel }} {{ channel }}
</h3> </h3>
@@ -39,7 +43,7 @@ export default {
}, },
methods: { methods: {
isActive(channel) { isActive(channel) {
return ['facebook', 'website', 'twitter'].includes(channel); return ['facebook', 'website', 'twitter', 'twilio'].includes(channel);
}, },
onItemClick() { onItemClick() {
if (this.isActive(this.channel)) { if (this.isActive(this.channel)) {

View File

@@ -11,7 +11,6 @@
v-else v-else
:username="username" :username="username"
:class="thumbnailClass" :class="thumbnailClass"
background-color="#1f93ff"
color="white" color="white"
:size="avatarSize" :size="avatarSize"
/> />

View File

@@ -74,6 +74,7 @@ export default {
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
inboxesList: 'inboxes/getInboxes', inboxesList: 'inboxes/getInboxes',
activeInbox: 'getSelectedInbox', activeInbox: 'getSelectedInbox',
currentUser: 'getCurrentUser',
}), }),
isActiveChat() { isActiveChat() {
@@ -96,7 +97,11 @@ export default {
methods: { methods: {
cardClick(chat) { cardClick(chat) {
const { activeInbox } = this; const { activeInbox } = this;
const path = conversationUrl(activeInbox, chat.id); const path = conversationUrl(
this.currentUser.account_id,
activeInbox,
chat.id
);
router.push({ path: frontendURL(path) }); router.push({ path: frontendURL(path) });
}, },
extractMessageText(chatItem) { extractMessageText(chatItem) {
@@ -111,7 +116,7 @@ export default {
} }
const key = `CHAT_LIST.ATTACHMENTS.${fileType}`; const key = `CHAT_LIST.ATTACHMENTS.${fileType}`;
return ` return `
<i class="${this.$t(`${key}.ICON`)}"></i> <i class="small-icon ${this.$t(`${key}.ICON`)}"></i>
${this.$t(`${key}.CONTENT`)} ${this.$t(`${key}.CONTENT`)}
`; `;
}, },

View File

@@ -12,7 +12,7 @@
{{ chat.meta.sender.name }} {{ chat.meta.sender.name }}
</h3> </h3>
<button <button
class="user--profile__button" class="user--profile__button clear button small"
@click="$emit('contactPanelToggle')" @click="$emit('contactPanelToggle')"
> >
{{ viewProfileButtonLabel }} {{ viewProfileButtonLabel }}

View File

@@ -1,27 +1,17 @@
<template> <template>
<li v-if="data.attachment || data.content" :class="alignBubble"> <li v-if="data.attachment || data.content" :class="alignBubble">
<div :class="wrapClass"> <div :class="wrapClass">
<p <p v-tooltip.top-start="sentByMessage" :class="bubbleClass">
v-tooltip.top-start="sentByMessage"
:class="{ bubble: isBubble, 'is-private': isPrivate }"
>
<bubble-image <bubble-image
v-if="data.attachment && data.attachment.file_type === 'image'" v-if="data.attachment && data.attachment.file_type === 'image'"
:url="data.attachment.data_url" :url="data.attachment.data_url"
:readable-time="readableTime" :readable-time="readableTime"
/> />
<bubble-audio <bubble-file
v-if="data.attachment && data.attachment.file_type === 'audio'" v-if="data.attachment && data.attachment.file_type !== 'image'"
:url="data.attachment.data_url" :url="data.attachment.data_url"
:readable-time="readableTime" :readable-time="readableTime"
/> />
<bubble-map
v-if="data.attachment && data.attachment.file_type === 'location'"
:lat="data.attachment.coordinates_lat"
:lng="data.attachment.coordinates_long"
:label="data.attachment.fallback_title"
:readable-time="readableTime"
/>
<bubble-text <bubble-text
v-if="data.content" v-if="data.content"
:message="message" :message="message"
@@ -36,25 +26,25 @@
/> />
</p> </p>
</div> </div>
<!-- <img v-if="showSenderData" src="https://chatwoot-staging.s3-us-west-2.amazonaws.com/uploads/avatar/contact/3415/thumb_10418362_10201264050880840_6087258728802054624_n.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&amp;X-Amz-Credential=AKIAI3KBM2ES3VRHQHPQ%2F20170422%2Fus-west-2%2Fs3%2Faws4_request&amp;X-Amz-Date=20170422T075421Z&amp;X-Amz-Expires=604800&amp;X-Amz-SignedHeaders=host&amp;X-Amz-Signature=8d5ff60e41415515f59ff682b9a4e4c0574d9d9aabfeff1dc5a51087a9b49e03" class="sender--thumbnail"> --> <!-- <img
src="https://randomuser.me/api/portraits/women/94.jpg"
class="sender--thumbnail"
/> -->
</li> </li>
</template> </template>
<script> <script>
/* eslint-disable no-named-as-default */
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import getEmojiSVG from '../emoji/utils'; import getEmojiSVG from '../emoji/utils';
import timeMixin from '../../../mixins/time'; import timeMixin from '../../../mixins/time';
import BubbleText from './bubble/Text'; import BubbleText from './bubble/Text';
import BubbleImage from './bubble/Image'; import BubbleImage from './bubble/Image';
import BubbleMap from './bubble/Map'; import BubbleFile from './bubble/File';
import BubbleAudio from './bubble/Audio';
export default { export default {
components: { components: {
BubbleText, BubbleText,
BubbleImage, BubbleImage,
BubbleMap, BubbleFile,
BubbleAudio,
}, },
mixins: [timeMixin, messageFormatterMixin], mixins: [timeMixin, messageFormatterMixin],
props: { props: {
@@ -81,6 +71,11 @@ export default {
isBubble() { isBubble() {
return [0, 1, 3].includes(this.data.message_type); return [0, 1, 3].includes(this.data.message_type);
}, },
hasImageAttachment() {
const { attachment = {} } = this.data;
const { file_type: fileType } = attachment;
return fileType === 'image';
},
isPrivate() { isPrivate() {
return this.data.private; return this.data.private;
}, },
@@ -102,9 +97,30 @@ export default {
'activity-wrap': !this.isBubble, 'activity-wrap': !this.isBubble,
}; };
}, },
bubbleClass() {
return {
bubble: this.isBubble,
'is-private': this.isPrivate,
'is-image': this.hasImageAttachment,
};
},
}, },
methods: { methods: {
getEmojiSVG, getEmojiSVG,
}, },
}; };
</script> </script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables.scss';
.wrap {
.is-image {
padding: 0;
overflow: hidden;
}
.image {
max-width: 32rem;
padding: 0;
}
}
</style>

View File

@@ -23,6 +23,17 @@
@click="onClick()" @click="onClick()"
@blur="onBlur()" @blur="onBlur()"
/> />
<file-upload
v-if="showFileUpload"
:size="4096 * 4096"
@input-file="onFileUpload"
>
<i
v-if="!isUploading.image"
class="icon ion-android-attach attachment"
/>
<woot-spinner v-if="isUploading.image" />
</file-upload>
<i <i
class="icon ion-happy-outline" class="icon ion-happy-outline"
:class="{ active: showEmojiPicker }" :class="{ active: showEmojiPicker }"
@@ -77,6 +88,7 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import emojione from 'emojione'; import emojione from 'emojione';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import FileUpload from 'vue-upload-component';
import EmojiInput from '../emoji/EmojiInput'; import EmojiInput from '../emoji/EmojiInput';
import CannedResponse from './CannedResponse'; import CannedResponse from './CannedResponse';
@@ -85,6 +97,7 @@ export default {
components: { components: {
EmojiInput, EmojiInput,
CannedResponse, CannedResponse,
FileUpload,
}, },
mixins: [clickaway], mixins: [clickaway],
data() { data() {
@@ -93,6 +106,11 @@ export default {
isPrivate: false, isPrivate: false,
showEmojiPicker: false, showEmojiPicker: false,
showCannedResponsesList: false, showCannedResponsesList: false,
isUploading: {
audio: false,
video: false,
image: false,
},
}; };
}, },
computed: { computed: {
@@ -123,6 +141,9 @@ export default {
} }
return 10000; return 10000;
}, },
showFileUpload() {
return this.channelType === 'Channel::WebWidget';
},
replyButtonLabel() { replyButtonLabel() {
if (this.isPrivate) { if (this.isPrivate) {
return this.$t('CONVERSATION.REPLYBOX.CREATE'); return this.$t('CONVERSATION.REPLYBOX.CREATE');
@@ -180,21 +201,21 @@ export default {
isEscape(e) { isEscape(e) {
return e.keyCode === 27; // ESCAPE return e.keyCode === 27; // ESCAPE
}, },
sendMessage() { async sendMessage() {
const isMessageEmpty = !this.message.replace(/\n/g, '').length; const isMessageEmpty = !this.message.replace(/\n/g, '').length;
if (isMessageEmpty) { if (isMessageEmpty) return;
return;
}
if (!this.showCannedResponsesList) { if (!this.showCannedResponsesList) {
this.$store try {
.dispatch('sendMessage', { await this.$store.dispatch('sendMessage', {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message: this.message, message: this.message,
private: this.isPrivate, private: this.isPrivate,
})
.then(() => {
this.$emit('scrollToMessage');
}); });
this.$emit('scrollToMessage');
} catch (error) {
// Error
}
this.clearMessage(); this.clearMessage();
this.hideEmojiPicker(); this.hideEmojiPicker();
} }
@@ -272,6 +293,20 @@ export default {
: 'CONVERSATION.FOOTER.MSG_INPUT'; : 'CONVERSATION.FOOTER.MSG_INPUT';
return placeHolder; return placeHolder;
}, },
onFileUpload(file) {
this.isUploading.image = true;
this.$store
.dispatch('sendAttachment', [this.currentChat.id, { file: file.file }])
.then(() => {
this.isUploading.image = false;
this.$emit('scrollToMessage');
})
.catch(() => {
this.isUploading.image = false;
this.$emit('scrollToMessage');
});
},
}, },
}; };
</script> </script>

View File

@@ -0,0 +1,71 @@
<template>
<div class="file message-text__wrap" @click="openLink">
<div class="icon-wrap">
<i class="ion-document-text"></i>
</div>
<div class="meta">
<h5 class="text-block-title">
{{ decodeURI(fileName) }}
</h5>
<a
class="download clear button small"
rel="noreferrer noopener nofollow"
target="_blank"
:href="url"
>
{{ $t('CONVERSATION.DOWNLOAD') }}
</a>
</div>
<span class="time">{{ readableTime }}</span>
</div>
</template>
<script>
export default {
props: ['url', 'readableTime'],
computed: {
fileName() {
const filename = this.url.substring(this.url.lastIndexOf('/') + 1);
return filename;
},
},
methods: {
openLink() {
const win = window.open(this.url, '_blank');
win.focus();
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables';
.file {
display: flex;
flex-direction: row;
padding: $space-normal;
cursor: pointer;
.icon-wrap {
font-size: $font-size-giga;
color: $color-woot;
line-height: 1;
margin-left: $space-smaller;
margin-right: $space-slab;
}
.text-block-title {
margin: 0;
}
.button {
padding: 0;
margin: 0;
}
.meta {
padding-right: $space-two;
}
}
</style>

View File

@@ -5,9 +5,18 @@ export const frontendURL = (path, params) => {
return `/app/${path}${stringifiedParams}`; return `/app/${path}${stringifiedParams}`;
}; };
export const conversationUrl = (activeInbox, id) => { export const conversationUrl = (accountId, activeInbox, id) => {
const path = activeInbox const path = activeInbox
? `inbox/${activeInbox}/conversations/${id}` ? `accounts/${accountId}/inbox/${activeInbox}/conversations/${id}`
: `conversations/${id}`; : `accounts/${accountId}/conversations/${id}`;
return path; return path;
}; };
export const accountIdFromPathname = pathname => {
const isInsideAccountScopedURLs = pathname.includes('/app/accounts');
const urlParam = pathname.split('/')[3];
// eslint-disable-next-line no-restricted-globals
const isScoped = isInsideAccountScopedURLs && !isNaN(urlParam);
const accountId = isScoped ? Number(urlParam) : '';
return accountId;
};

View File

@@ -1,20 +1,3 @@
export const createWebsiteWidgetScript = websiteToken => `
<script>
(function(d,t) {
var BASE_URL = '${window.location.origin}';
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src= BASE_URL + "/packs/js/sdk.js";
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken: '${websiteToken}',
baseUrl: BASE_URL
})
}
})(document,"script");
</script>
`;
export const createMessengerScript = pageId => ` export const createMessengerScript = pageId => `
<script> <script>
window.fbAsyncInit = function() { window.fbAsyncInit = function() {

View File

@@ -1,12 +1,20 @@
import { frontendURL, conversationUrl } from '../URLHelper'; import {
frontendURL,
conversationUrl,
accountIdFromPathname,
} from '../URLHelper';
describe('#URL Helpers', () => { describe('#URL Helpers', () => {
describe('conversationUrl', () => { describe('conversationUrl', () => {
it('should return direct conversation URL if activeInbox is nil', () => { it('should return direct conversation URL if activeInbox is nil', () => {
expect(conversationUrl(undefined, 1)).toBe('conversations/1'); expect(conversationUrl(1, undefined, 1)).toBe(
'accounts/1/conversations/1'
);
}); });
it('should return ibox conversation URL if activeInbox is not nil', () => { it('should return ibox conversation URL if activeInbox is not nil', () => {
expect(conversationUrl(2, 1)).toBe('inbox/2/conversations/1'); expect(conversationUrl(1, 2, 1)).toBe(
'accounts/1/inbox/2/conversations/1'
);
}); });
}); });
@@ -18,4 +26,26 @@ describe('#URL Helpers', () => {
expect(frontendURL('main', { ping: 'pong' })).toBe('/app/main?ping=pong'); expect(frontendURL('main', { ping: 'pong' })).toBe('/app/main?ping=pong');
}); });
}); });
/*
export const accountIdFromPathname = pathname => {
const isInsideAccountScopedURLs = pathname.includes('/app/accounts');
const accountId = isInsideAccountScopedURLs ? pathname.split('/')[3] : '';
return Number(accountId);
};
*/
describe('accountIdFromPathname', () => {
it('should return account id if accont scoped url is passed', () => {
expect(accountIdFromPathname('/app/accounts/1/settings/general')).toBe(1);
});
it('should return empty string if accont scoped url not is passed', () => {
expect(accountIdFromPathname('/app/accounts/settings/general')).toBe('');
});
it('should return empty string if empty string is passed', () => {
expect(accountIdFromPathname('')).toBe('');
});
});
}); });

View File

@@ -0,0 +1,37 @@
import de from './locale/de';
export default {
...de,
APP_GLOBAL: {
TRIAL_MESSAGE: 'verbleibende Tage Probezeit.',
TRAIL_BUTTON: 'Kaufe jetzt',
},
COMPONENTS: {
CODE: {
BUTTON_TEXT: 'Kopieren',
COPY_SUCCESSFUL: 'Code erfolgreich in die Zwischenablage kopiert',
},
FILE_BUBBLE: {
DOWNLOAD: 'Herunterladen',
UPLOADING: 'Hochladen...',
},
},
CONFIRM_EMAIL: 'Überprüfen...',
SETTINGS: {
INBOXES: {
NEW_INBOX: 'Posteingang hinzufügen',
},
},
SIDEBAR: {
CONVERSATIONS: 'Gespräche',
REPORTS: 'Berichte',
SETTINGS: 'Die Einstellungen',
HOME: 'Zuhause',
AGENTS: 'Agenten',
INBOXES: 'Posteingänge',
CANNED_RESPONSES: 'Vorgefertigte Antworten',
BILLING: 'Abrechnung',
INTEGRATIONS: 'Integrationen',
ACCOUNT_SETTINGS: 'Kontoeinstellungen',
},
};

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