diff --git a/.circleci/config.yml b/.circleci/config.yml index 87572fdc3..27918bfd6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -100,6 +100,10 @@ jobs: - run: name: Rubocop command: bundle exec rubocop + + # - run: + # name: Brakeman + # command: bundle exec brakeman - run: name: eslint diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e3eb2fd01..2418bd621 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -5,4 +5,4 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest # Do the set up required for chatwoot app WORKDIR /workspace COPY . /workspace -RUN yarn && gem install bundler && bundle install +RUN yarn && gem install bundler && bundle install diff --git a/.devcontainer/Dockerfile.base b/.devcontainer/Dockerfile.base index de007e202..769be24f8 100644 --- a/.devcontainer/Dockerfile.base +++ b/.devcontainer/Dockerfile.base @@ -1,6 +1,6 @@ -# pre-build stage -ARG VARIANT=3 -FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT} + +ARG VARIANT=ubuntu-20.04 +FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT} # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. ARG USER_UID=1000 @@ -11,23 +11,36 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ && chmod -R $USER_UID:$USER_GID /home/vscode; \ fi -# [Option] Install Node.js -ARG INSTALL_NODE="true" -ARG NODE_VERSION="lts/*" -RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi - RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends \ + build-essential \ libssl-dev \ + zlib1g-dev \ + gnupg2 \ tar \ tzdata \ postgresql-client \ + libpq-dev \ yarn \ git \ imagemagick \ tmux \ - zsh + zsh \ + git-flow \ + npm +# Install rbenv and ruby +ARG RUBY_VERSION="3.0.4" +RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \ + && echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \ + && echo 'eval "$(rbenv init -)"' >> ~/.bashrc +ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH" +RUN git clone https://github.com/rbenv/ruby-build.git && \ + PREFIX=/usr/local ./ruby-build/install.sh + +RUN rbenv install $RUBY_VERSION && \ + rbenv global $RUBY_VERSION && \ + rbenv versions # Install overmind RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \ @@ -35,11 +48,25 @@ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmi && sudo mv overmind /usr/local/bin \ && chmod +x /usr/local/bin/overmind + +# Install gh +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && sudo apt update \ + && sudo apt install gh + + # Do the set up required for chatwoot app WORKDIR /workspace COPY . /workspace -RUN yarn +# set up ruby COPY Gemfile Gemfile.lock ./ RUN gem install bundler && bundle install +# set up node js +RUN npm install npm@latest -g && \ + npm install n -g && \ + n latest +RUN npm install --global yarn +RUN yarn diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c0301d87b..a70ba3788 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,17 +23,18 @@ // 5432 postgres // 6379 redis // 1025,8025 mailhog - "forwardPorts": [8025], - //your application may need to listen on all interfaces (0.0.0.0) not just localhost for it to be available externally. Defaults to [] - "appPort": [3000, 3035], + "forwardPorts": [8025, 3000, 3035], "postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn", "portsAttributes": { "3000": { "label": "Rails Server" }, + "3035": { + "label": "Webpack Dev Server" + }, "8025": { "label": "Mailhog UI" } - }, + } } diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh index 1b5842603..4ffee2d3a 100755 --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -6,3 +6,8 @@ sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.de sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env # uncomment the webpacker env variable sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env +# fix the error with webpacker +echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc + +# codespaces make the ports public +gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME diff --git a/.env.example b/.env.example index d46acac9f..5291be660 100644 --- a/.env.example +++ b/.env.example @@ -195,3 +195,7 @@ USE_INBOX_AVATAR_FOR_BOT=true # If you want to use official mobile app, # the notifications would be relayed via a Chatwoot server ENABLE_PUSH_RELAY_SERVER=true + +# Stripe API key +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= diff --git a/.eslintrc.js b/.eslintrc.js index f30e21e03..0fae1bc44 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,10 @@ module.exports = { - extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/recommended'], + extends: [ + 'airbnb-base/legacy', + 'prettier', + 'plugin:vue/recommended', + 'plugin:storybook/recommended', + ], parserOptions: { parser: 'babel-eslint', ecmaVersion: 2020, @@ -19,18 +24,32 @@ module.exports = { 'jsx-a11y/label-has-for': 'off', 'jsx-a11y/anchor-is-valid': 'off', 'import/no-unresolved': 'off', - 'vue/max-attributes-per-line': ['error', { - 'singleline': 20, - 'multiline': { - 'max': 1, - 'allowFirstLine': false + 'vue/max-attributes-per-line': [ + 'error', + { + singleline: 20, + multiline: { + max: 1, + allowFirstLine: false, + }, }, - }], - 'vue/html-self-closing': 'off', - "vue/no-v-html": 'off', + ], + 'vue/html-self-closing': [ + 'error', + { + html: { + void: 'always', + normal: 'always', + component: 'always', + }, + svg: 'always', + math: 'always', + }, + ], + 'vue/no-v-html': 'off', 'vue/singleline-html-element-content-newline': 'off', 'import/extensions': ['off'], - 'no-console': 'error' + 'no-console': 'error', }, settings: { 'import/resolver': { @@ -41,12 +60,10 @@ module.exports = { }, env: { browser: true, - node: true, jest: true, - jasmine: true + node: true, }, globals: { - __WEBPACK_ENV__: true, bus: true, }, }; diff --git a/.storybook/preview.js b/.storybook/preview.js index e0c27a1ac..e553e514a 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -4,6 +4,7 @@ import Vuex from 'vuex'; import VueI18n from 'vue-i18n'; import Vuelidate from 'vuelidate'; import Multiselect from 'vue-multiselect'; +import FluentIcon from 'shared/components/FluentIcon/DashboardIcon'; import WootUiKit from '../app/javascript/dashboard/components'; import i18n from '../app/javascript/dashboard/i18n'; @@ -15,6 +16,7 @@ Vue.use(Vuelidate); Vue.use(WootUiKit); Vue.use(Vuex); Vue.component('multiselect', Multiselect); +Vue.component('fluent-icon', FluentIcon); const store = new Vuex.Store({}); const i18nConfig = new VueI18n({ diff --git a/Gemfile b/Gemfile index f7cac3c70..935db42e2 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ ruby '3.0.4' ##-- base gems for rails --## gem 'rack-cors', require: 'rack/cors' -gem 'rails' +gem 'rails', '~>6.1' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false @@ -78,7 +78,7 @@ gem 'wisper', '2.0.0' # TODO: bump up gem to 2.0 gem 'facebook-messenger' gem 'line-bot-api' -gem 'twilio-ruby', '~> 5.32.0' +gem 'twilio-ruby', '~> 5.66' # twitty will handle subscription of twitter account events # gem 'twitty', git: 'https://github.com/chatwoot/twitty' gem 'twitty' @@ -89,10 +89,6 @@ gem 'slack-ruby-client' # for dialogflow integrations gem 'google-cloud-dialogflow' -##--- gems for debugging and error reporting ---## -# static analysis -gem 'brakeman' - ##-- apm and error monitoring ---# gem 'ddtrace' gem 'newrelic_rpm' @@ -128,6 +124,12 @@ gem 'html2text' # to calculate working hours gem 'working_hours' +# full text search for articles +gem 'pg_search' + +# Subscriptions, Billing +gem 'stripe' + group :production, :staging do # we dont want request timing out in development while using byebug gem 'rack-timeout' @@ -156,12 +158,10 @@ group :test do end 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' + ##--- gems for debugging and error reporting ---## + # static analysis + gem 'brakeman' gem 'bundle-audit', require: false gem 'byebug', platform: :mri gem 'climate_control' diff --git a/Gemfile.lock b/Gemfile.lock index b12a2fc5c..17e021bf2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/chatwoot/devise-secure_password - revision: de11e8765654b8242d42101ee9c8ffc8126f7975 + revision: d777b04f12652d576b1272b8f39857e3e0b3fc26 specs: devise-secure_password (2.0.1) devise (>= 4.0.0, < 5.0.0) @@ -9,63 +9,63 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.1.5.1) - actionpack (= 6.1.5.1) - activesupport (= 6.1.5.1) + actioncable (6.1.6.1) + actionpack (= 6.1.6.1) + activesupport (= 6.1.6.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.5.1) - actionpack (= 6.1.5.1) - activejob (= 6.1.5.1) - activerecord (= 6.1.5.1) - activestorage (= 6.1.5.1) - activesupport (= 6.1.5.1) + actionmailbox (6.1.6.1) + actionpack (= 6.1.6.1) + activejob (= 6.1.6.1) + activerecord (= 6.1.6.1) + activestorage (= 6.1.6.1) + activesupport (= 6.1.6.1) mail (>= 2.7.1) - actionmailer (6.1.5.1) - actionpack (= 6.1.5.1) - actionview (= 6.1.5.1) - activejob (= 6.1.5.1) - activesupport (= 6.1.5.1) + actionmailer (6.1.6.1) + actionpack (= 6.1.6.1) + actionview (= 6.1.6.1) + activejob (= 6.1.6.1) + activesupport (= 6.1.6.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.5.1) - actionview (= 6.1.5.1) - activesupport (= 6.1.5.1) + actionpack (6.1.6.1) + actionview (= 6.1.6.1) + activesupport (= 6.1.6.1) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.5.1) - actionpack (= 6.1.5.1) - activerecord (= 6.1.5.1) - activestorage (= 6.1.5.1) - activesupport (= 6.1.5.1) + actiontext (6.1.6.1) + actionpack (= 6.1.6.1) + activerecord (= 6.1.6.1) + activestorage (= 6.1.6.1) + activesupport (= 6.1.6.1) nokogiri (>= 1.8.5) - actionview (6.1.5.1) - activesupport (= 6.1.5.1) + actionview (6.1.6.1) + activesupport (= 6.1.6.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) active_record_query_trace (1.8) - activejob (6.1.5.1) - activesupport (= 6.1.5.1) + activejob (6.1.6.1) + activesupport (= 6.1.6.1) globalid (>= 0.3.6) - activemodel (6.1.5.1) - activesupport (= 6.1.5.1) - activerecord (6.1.5.1) - activemodel (= 6.1.5.1) - activesupport (= 6.1.5.1) - activerecord-import (1.3.0) + activemodel (6.1.6.1) + activesupport (= 6.1.6.1) + activerecord (6.1.6.1) + activemodel (= 6.1.6.1) + activesupport (= 6.1.6.1) + activerecord-import (1.4.0) activerecord (>= 4.2) - activestorage (6.1.5.1) - actionpack (= 6.1.5.1) - activejob (= 6.1.5.1) - activerecord (= 6.1.5.1) - activesupport (= 6.1.5.1) + activestorage (6.1.6.1) + actionpack (= 6.1.6.1) + activejob (= 6.1.6.1) + activerecord (= 6.1.6.1) + activesupport (= 6.1.6.1) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.5.1) + activesupport (6.1.6.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -91,20 +91,20 @@ GEM ast (2.4.2) attr_extras (6.2.5) aws-eventstream (1.2.0) - aws-partitions (1.556.0) - aws-sdk-core (3.126.2) + aws-partitions (1.605.0) + aws-sdk-core (3.131.2) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.525.0) aws-sigv4 (~> 1.1) - jmespath (~> 1.0) - aws-sdk-kms (1.54.0) - aws-sdk-core (~> 3, >= 3.126.0) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.57.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.112.0) - aws-sdk-core (~> 3, >= 3.126.0) + aws-sdk-s3 (1.114.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.4) - aws-sigv4 (1.4.0) + aws-sigv4 (1.5.0) aws-eventstream (~> 1, >= 1.0.2) azure-storage-blob (2.0.3) azure-storage-common (~> 2.0) @@ -117,31 +117,31 @@ GEM barnes (0.0.9) multi_json (~> 1) statsd-ruby (~> 1.1) - bcrypt (3.1.16) + bcrypt (3.1.18) bindex (0.8.1) - bootsnap (1.10.3) + bootsnap (1.12.0) msgpack (~> 1.2) - brakeman (5.2.1) + brakeman (5.2.3) browser (5.3.1) builder (3.2.4) - bullet (7.0.1) + bullet (7.0.2) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundle-audit (0.1.0) bundler-audit - bundler-audit (0.9.0.1) + bundler-audit (0.9.1) bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) - climate_control (1.0.1) + climate_control (1.1.1) coderay (1.1.3) - commonmarker (0.23.4) + commonmarker (0.23.5) concurrent-ruby (1.1.10) connection_pool (2.2.5) crack (0.4.5) rexml crass (1.0.6) - cypress-on-rails (1.12.1) + cypress-on-rails (1.13.1) rack database_cleaner (2.0.1) database_cleaner-active_record (~> 2.0.0) @@ -151,10 +151,12 @@ GEM database_cleaner-core (2.0.1) datetime_picker_rails (0.0.7) momentjs-rails (>= 2.8.1) - ddtrace (0.54.2) - debase-ruby_core_source (<= 0.10.14) + ddtrace (1.2.0) + debase-ruby_core_source (= 0.10.16) + libddprof (~> 0.6.0.1.0) + libddwaf (~> 1.3.0.2.0) msgpack - debase-ruby_core_source (0.10.14) + debase-ruby_core_source (0.10.16) declarative (0.0.20) devise (4.8.1) bcrypt (~> 3.0) @@ -176,54 +178,72 @@ GEM dotenv-rails (2.7.6) dotenv (= 2.7.6) railties (>= 3.2) - down (5.3.0) + down (5.3.1) addressable (~> 2.8) ecma-re-validator (0.4.0) regexp_parser (~> 2.2) email_reply_trimmer (0.1.13) erubi (1.10.0) - erubis (2.7.0) et-orbi (1.2.7) tzinfo execjs (2.8.1) facebook-messenger (2.0.1) httparty (~> 0.13, >= 0.13.7) rack (>= 1.4.5) - factory_bot (6.2.0) + factory_bot (6.2.1) activesupport (>= 5.0.0) factory_bot_rails (6.2.0) factory_bot (~> 6.2.0) railties (>= 5.0.0) - faker (2.19.0) - i18n (>= 1.6, < 2) - faraday (1.0.1) - multipart-post (>= 1.2, < 3) + faker (2.21.0) + i18n (>= 1.8.11, < 2) + faraday (1.10.0) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) faraday_middleware (1.2.0) faraday (~> 1.0) - fcm (1.0.5) - faraday (~> 1) + fcm (1.0.8) + faraday (>= 1.0.0, < 3.0) + googleauth (~> 1) ffi (1.15.5) 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) fugit (1.5.3) et-orbi (~> 1, >= 1.2.7) raabro (~> 1.4) - gapic-common (0.3.4) - google-protobuf (~> 3.12, >= 3.12.2) - googleapis-common-protos (>= 1.3.9, < 2.0) - googleapis-common-protos-types (>= 1.0.4, < 2.0) - googleauth (~> 0.9) - grpc (~> 1.25) - geocoder (1.7.3) + gapic-common (0.10.0) + faraday (>= 1.9, < 3.a) + faraday-retry (>= 1.0, < 3.a) + google-protobuf (~> 3.14) + googleapis-common-protos (>= 1.3.12, < 2.a) + googleapis-common-protos-types (>= 1.3.1, < 2.a) + googleauth (~> 1.0) + grpc (~> 1.36) + geocoder (1.8.0) gli (2.21.0) globalid (1.0.0) activesupport (>= 5.0) - google-apis-core (0.4.2) + google-apis-core (0.7.0) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -232,23 +252,27 @@ GEM retriable (>= 2.0, < 4.a) rexml webrick - google-apis-iamcredentials_v1 (0.10.0) - google-apis-core (>= 0.4, < 2.a) - google-apis-storage_v1 (0.11.0) - google-apis-core (>= 0.4, < 2.a) + google-apis-iamcredentials_v1 (0.13.0) + google-apis-core (>= 0.7, < 2.a) + google-apis-storage_v1 (0.18.0) + google-apis-core (>= 0.7, < 2.a) google-cloud-core (1.6.0) google-cloud-env (~> 1.0) google-cloud-errors (~> 1.0) - google-cloud-dialogflow (1.2.0) - google-cloud-core (~> 1.5) - google-cloud-dialogflow-v2 (~> 0.1) - google-cloud-dialogflow-v2 (0.6.4) - gapic-common (~> 0.3) + google-cloud-dialogflow (1.5.0) + google-cloud-core (~> 1.6) + google-cloud-dialogflow-v2 (>= 0.15, < 2.a) + google-cloud-dialogflow-v2 (0.17.0) + gapic-common (>= 0.10, < 2.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.5.0) - faraday (>= 0.17.3, < 2.0) + google-cloud-location (>= 0.0, < 2.a) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) google-cloud-errors (1.2.0) - google-cloud-storage (1.36.1) + google-cloud-location (0.2.0) + gapic-common (>= 0.10, < 2.a) + google-cloud-errors (~> 1.0) + google-cloud-storage (1.37.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) @@ -256,32 +280,32 @@ GEM google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - google-protobuf (3.19.4) - google-protobuf (3.19.4-x86_64-darwin) - google-protobuf (3.19.4-x86_64-linux) + google-protobuf (3.21.2) + google-protobuf (3.21.2-x86_64-darwin) + google-protobuf (3.21.2-x86_64-linux) googleapis-common-protos (1.3.12) google-protobuf (~> 3.14) googleapis-common-protos-types (~> 1.2) grpc (~> 1.27) - googleapis-common-protos-types (1.3.0) + googleapis-common-protos-types (1.3.2) google-protobuf (~> 3.14) - googleauth (0.17.1) - faraday (>= 0.17.3, < 2.0) + googleauth (1.2.0) + faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) - signet (~> 0.15) - groupdate (6.0.1) + signet (>= 0.16, < 2.a) + groupdate (6.1.0) activesupport (>= 5.2) - grpc (1.43.1) - google-protobuf (~> 3.18) + grpc (1.47.0) + google-protobuf (~> 3.19) googleapis-common-protos-types (~> 1.0) - grpc (1.43.1-universal-darwin) - google-protobuf (~> 3.18) + grpc (1.47.0-x86_64-darwin) + google-protobuf (~> 3.19) googleapis-common-protos-types (~> 1.0) - grpc (1.43.1-x86_64-linux) - google-protobuf (~> 3.18) + grpc (1.47.0-x86_64-linux) + google-protobuf (~> 3.19) googleapis-common-protos-types (~> 1.0) haikunator (1.1.1) hairtrigger (0.2.25) @@ -295,13 +319,13 @@ GEM html2text (0.2.1) nokogiri (~> 1.6) http-accept (1.7.0) - http-cookie (1.0.4) + http-cookie (1.0.5) domain_name (~> 0.5) httparty (0.20.0) mime-types (~> 3.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.10.0) + i18n (1.11.0) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) @@ -309,20 +333,20 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) - jmespath (1.6.0) - jquery-rails (4.4.0) + jmespath (1.6.1) + jquery-rails (4.5.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.6.1) + json (2.6.2) json_refs (0.1.7) hana - json_schemer (0.2.19) + json_schemer (0.2.21) ecma-re-validator (~> 0.3) hana (~> 1.3) regexp_parser (~> 2.0) uri_template (~> 0.7) - jwt (2.3.0) + jwt (2.4.1) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -335,21 +359,31 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - koala (3.1.0) + koala (3.2.0) addressable faraday (< 2) json (>= 1.8) rexml launchy (2.5.0) addressable (~> 2.7) - letter_opener (1.7.0) - launchy (~> 2.2) - line-bot-api (1.23.0) - liquid (5.1.0) + letter_opener (1.8.1) + launchy (>= 2.2, < 3) + libddprof (0.6.0.1.0) + libddprof (0.6.0.1.0-x86_64-linux) + libddwaf (1.3.0.2.0) + ffi (~> 1.0) + libddwaf (1.3.0.2.0-arm64-darwin) + ffi (~> 1.0) + libddwaf (1.3.0.2.0-x86_64-darwin) + ffi (~> 1.0) + libddwaf (1.3.0.2.0-x86_64-linux) + ffi (~> 1.0) + line-bot-api (1.25.0) + liquid (5.3.0) listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.17.0) + loofah (2.18.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -364,37 +398,39 @@ GEM mini_magick (4.11.0) mini_mime (1.1.2) mini_portile2 (2.8.0) - minitest (5.15.0) - mock_redis (0.30.0) + minitest (5.16.2) + mock_redis (0.32.0) ruby2_keywords momentjs-rails (2.29.1.1) railties (>= 3.1) - msgpack (1.4.5) + msgpack (1.5.3) multi_json (1.15.0) multi_xml (0.6.0) - multipart-post (2.1.1) + multipart-post (2.2.3) net-http-persistent (4.0.1) connection_pool (~> 2.2) netrc (0.11.0) - newrelic_rpm (8.7.0) + newrelic_rpm (8.9.0) nio4r (2.5.8) - nokogiri (1.13.6) + nokogiri (1.13.7) mini_portile2 (~> 2.8.0) racc (~> 1.4) - nokogiri (1.13.6-arm64-darwin) + nokogiri (1.13.7-arm64-darwin) racc (~> 1.4) - nokogiri (1.13.6-x86_64-darwin) + nokogiri (1.13.7-x86_64-darwin) racc (~> 1.4) - nokogiri (1.13.6-x86_64-linux) + nokogiri (1.13.7-x86_64-linux) racc (~> 1.4) - oauth (0.5.8) + oauth (0.5.10) orm_adapter (0.5.0) os (1.1.4) - parallel (1.21.0) - parser (3.1.1.0) + parallel (1.22.1) + parser (3.1.2.0) ast (~> 2.4.1) - path_expander (1.1.0) - pg (1.3.2) + pg (1.4.1) + pg_search (2.3.6) + activerecord (>= 5.2) + activesupport (>= 5.2) procore-sift (0.16.0) rails (> 4.2.0) pry (0.14.1) @@ -402,46 +438,46 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.6) + public_suffix (4.0.7) puma (5.6.4) nio4r (~> 2.0) pundit (2.2.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.6.0) - rack (2.2.3.1) - rack-attack (6.6.0) + rack (2.2.4) + rack-attack (6.6.1) rack (>= 1.0, < 3) rack-cors (1.1.1) rack (>= 2.0.0) rack-proxy (0.7.2) rack - rack-test (1.1.0) - rack (>= 1.0, < 3) - rack-timeout (0.6.0) - rails (6.1.5.1) - actioncable (= 6.1.5.1) - actionmailbox (= 6.1.5.1) - actionmailer (= 6.1.5.1) - actionpack (= 6.1.5.1) - actiontext (= 6.1.5.1) - actionview (= 6.1.5.1) - activejob (= 6.1.5.1) - activemodel (= 6.1.5.1) - activerecord (= 6.1.5.1) - activestorage (= 6.1.5.1) - activesupport (= 6.1.5.1) + rack-test (2.0.2) + rack (>= 1.3) + rack-timeout (0.6.3) + rails (6.1.6.1) + actioncable (= 6.1.6.1) + actionmailbox (= 6.1.6.1) + actionmailer (= 6.1.6.1) + actionpack (= 6.1.6.1) + actiontext (= 6.1.6.1) + actionview (= 6.1.6.1) + activejob (= 6.1.6.1) + activemodel (= 6.1.6.1) + activerecord (= 6.1.6.1) + activestorage (= 6.1.6.1) + activesupport (= 6.1.6.1) bundler (>= 1.15.0) - railties (= 6.1.5.1) + railties (= 6.1.6.1) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.4.2) + rails-html-sanitizer (1.4.3) loofah (~> 2.3) - railties (6.1.5.1) - actionpack (= 6.1.5.1) - activesupport (= 6.1.5.1) + railties (6.1.6.1) + actionpack (= 6.1.6.1) + activesupport (= 6.1.6.1) method_source rake (>= 12.2) thor (~> 1.0) @@ -450,11 +486,11 @@ GEM rb-fsevent (0.11.1) rb-inotify (0.10.1) ffi (~> 1.0) - redis (4.6.0) - redis-namespace (1.8.1) + redis (4.7.1) + redis-namespace (1.8.2) redis (>= 3.0.4) - regexp_parser (2.2.1) - representable (3.1.1) + regexp_parser (2.5.0) + representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) @@ -468,16 +504,12 @@ GEM netrc (~> 0.8) retriable (3.1.2) rexml (3.2.5) - rspec (3.11.0) - rspec-core (~> 3.11.0) - rspec-expectations (~> 3.11.0) - rspec-mocks (~> 3.11.0) rspec-core (3.11.0) rspec-support (~> 3.11.0) rspec-expectations (3.11.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.11.0) - rspec-mocks (3.11.0) + rspec-mocks (3.11.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.11.0) rspec-rails (5.0.3) @@ -489,26 +521,27 @@ GEM rspec-mocks (~> 3.10) rspec-support (~> 3.10) rspec-support (3.11.0) - rubocop (1.25.1) + rubocop (1.31.2) + json (~> 2.3) parallel (~> 1.10) parser (>= 3.1.0.0) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) - rexml - rubocop-ast (>= 1.15.1, < 2.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.18.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.16.0) + rubocop-ast (1.19.1) parser (>= 3.1.1.0) - rubocop-performance (1.13.2) + rubocop-performance (1.14.2) rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) - rubocop-rails (2.13.2) + rubocop-rails (2.15.2) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.7.0, < 2.0) - rubocop-rspec (2.8.0) - rubocop (~> 1.19) + rubocop-rspec (2.12.1) + rubocop (~> 1.31) ruby-progressbar (1.11.0) ruby-vips (2.1.4) ffi (~> 1.12) @@ -516,7 +549,7 @@ GEM ruby2ruby (2.4.4) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_parser (3.18.1) + ruby_parser (3.19.1) sexp_processor (~> 4.16) sassc (2.4.0) ffi (~> 1.9) @@ -526,37 +559,37 @@ GEM sprockets (> 3.0) sprockets-rails tilt - scout_apm (5.1.1) + scout_apm (5.2.0) parser seed_dump (3.3.1) activerecord (>= 4) activesupport (>= 4) selectize-rails (0.12.6) semantic_range (3.0.0) - sentry-rails (5.3.0) + sentry-rails (5.3.1) railties (>= 5.0) - sentry-ruby-core (~> 5.3.0) - sentry-ruby (5.3.0) + sentry-ruby-core (~> 5.3.1) + sentry-ruby (5.3.1) concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-ruby-core (= 5.3.0) - sentry-ruby-core (5.3.0) + sentry-ruby-core (= 5.3.1) + sentry-ruby-core (5.3.1) concurrent-ruby - sentry-sidekiq (5.3.0) - sentry-ruby-core (~> 5.3.0) + sentry-sidekiq (5.3.1) + sentry-ruby-core (~> 5.3.1) sidekiq (>= 3.0) - sexp_processor (4.16.0) + sexp_processor (4.16.1) shoulda-matchers (5.1.0) activesupport (>= 5.2.0) - sidekiq (6.4.1) + sidekiq (6.4.2) connection_pool (>= 2.2.2) rack (~> 2.0) redis (>= 4.2.0) - sidekiq-cron (1.4.0) + sidekiq-cron (1.6.0) fugit (~> 1) sidekiq (>= 4.2.1) - signet (0.16.0) + signet (0.17.0) addressable (~> 2.8) - faraday (>= 0.17.3, < 2.0) + faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) simplecov (0.17.1) @@ -574,7 +607,7 @@ GEM spring-watcher-listen (2.0.1) listen (>= 2.7, < 4.0) spring (>= 1.2, < 3.0) - sprockets (4.0.3) + sprockets (4.1.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) sprockets-rails (3.4.2) @@ -583,31 +616,32 @@ GEM sprockets (>= 3.0.0) squasher (0.6.2) statsd-ruby (1.5.0) - telephone_number (1.4.13) + stripe (6.5.0) + telephone_number (1.4.16) thor (1.2.1) tilt (2.0.10) time_diff (0.3.0) activesupport i18n trailblazer-option (0.1.2) - twilio-ruby (5.32.0) - faraday (~> 1.0.0) + twilio-ruby (5.68.0) + faraday (>= 0.9, < 3.0) jwt (>= 1.5, <= 2.5) nokogiri (>= 1.6, < 2.0) twitty (0.1.4) oauth tzinfo (2.0.4) concurrent-ruby (~> 1.0) - tzinfo-data (1.2021.5) + tzinfo-data (1.2022.1) tzinfo (>= 1.0.0) uber (0.1.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) unf (0.1.4) unf_ext - unf_ext (0.0.8) - unicode-display_width (2.1.0) - uniform_notifier (1.14.2) + unf_ext (0.0.8.2) + unicode-display_width (2.2.0) + uniform_notifier (1.16.0) uri_template (0.7.0) valid_email2 (4.0.3) activemodel (>= 3.2) @@ -639,7 +673,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.5.4) + zeitwerk (2.6.0) PLATFORMS arm64-darwin-20 @@ -681,7 +715,6 @@ DEPENDENCIES faker fcm flag_shih_tzu - flay foreman geocoder google-cloud-dialogflow @@ -706,6 +739,7 @@ DEPENDENCIES mock_redis newrelic_rpm pg + pg_search procore-sift pry-rails puma @@ -713,12 +747,11 @@ DEPENDENCIES rack-attack rack-cors rack-timeout - rails + rails (~> 6.1) redis redis-namespace responders rest-client - rspec rspec-rails (~> 5.0.0) rubocop rubocop-performance @@ -737,9 +770,10 @@ DEPENDENCIES spring spring-watcher-listen squasher + stripe telephone_number time_diff - twilio-ruby (~> 5.32.0) + twilio-ruby (~> 5.66) twitty tzinfo-data uglifier @@ -755,4 +789,4 @@ RUBY VERSION ruby 3.0.4p208 BUNDLED WITH - 2.3.10 + 2.3.14 diff --git a/README.md b/README.md index c3ad01a5e..09017fb47 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ ___ Huntr uptime response time + Artifact HUB

Chat dashboard diff --git a/VERSION_CW b/VERSION_CW new file mode 100644 index 000000000..e70b4523a --- /dev/null +++ b/VERSION_CW @@ -0,0 +1 @@ +2.6.0 diff --git a/VERSION_CWCTL b/VERSION_CWCTL new file mode 100644 index 000000000..7ec1d6db4 --- /dev/null +++ b/VERSION_CWCTL @@ -0,0 +1 @@ +2.1.0 diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index 746d1bfec..f19caac77 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -1,7 +1,16 @@ +# retain_original_contact_name: false / true +# In case of setUser we want to update the name of the identified contact, +# which is the default behaviour +# +# But, In case of contact merge during prechat form contact update. +# We don't want to update the name of the identified original contact. + class ContactIdentifyAction - pattr_initialize [:contact!, :params!] + pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }] def perform + @attributes_to_update = [:identifier, :name, :email, :phone_number] + ActiveRecord::Base.transaction do merge_if_existing_identified_contact merge_if_existing_email_contact @@ -18,49 +27,89 @@ class ContactIdentifyAction end def merge_if_existing_identified_contact - @contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact) + return unless merge_contacts?(existing_identified_contact, :identifier) + + process_contact_merge(existing_identified_contact) end def merge_if_existing_email_contact - @contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact) + return unless merge_contacts?(existing_email_contact, :email) + + process_contact_merge(existing_email_contact) end def merge_if_existing_phone_number_contact - @contact = merge_contact(existing_phone_number_contact, @contact) if merge_contacts?(existing_phone_number_contact, @contact) + return unless merge_contacts?(existing_phone_number_contact, :phone_number) + return unless mergable_phone_contact? + + process_contact_merge(existing_phone_number_contact) + end + + def process_contact_merge(mergee_contact) + @contact = merge_contact(mergee_contact, @contact) + @attributes_to_update.delete(:name) if retain_original_contact_name end def existing_identified_contact return if params[:identifier].blank? - @existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier]) + @existing_identified_contact ||= account.contacts.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]) + @existing_email_contact ||= account.contacts.find_by(email: params[:email]) end def existing_phone_number_contact return if params[:phone_number].blank? - @existing_phone_number_contact ||= Contact.where(account_id: account.id).find_by(phone_number: params[:phone_number]) + @existing_phone_number_contact ||= account.contacts.find_by(phone_number: params[:phone_number]) end - def merge_contacts?(existing_contact, _contact) - existing_contact && existing_contact.id != @contact.id + def merge_contacts?(existing_contact, key) + return if existing_contact.blank? + + return true if params[:identifier].blank? + + # we want to prevent merging contacts with different identifiers + if existing_contact.identifier.present? && existing_contact.identifier != params[:identifier] + # we will remove attribute from update list + @attributes_to_update.delete(key) + return false + end + + true + end + + # case: contact 1: email: 1@test.com, phone: 123456789 + # params: email: 2@test.com, phone: 123456789 + # we don't want to overwrite 1@test.com since email parameter takes higer priority + def mergable_phone_contact? + return true if params[:email].blank? + + if existing_phone_number_contact.email.present? && existing_phone_number_contact.email != params[:email] + @attributes_to_update.delete(:phone_number) + return false + end + true end def update_contact + @contact.attributes = params.slice(*@attributes_to_update).reject do |_k, v| + v.blank? + end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes }) # blank identifier or email will throw unique index error # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded - @contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v| - v.blank? - end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes })) + @contact.discard_invalid_attrs if discard_invalid_attrs + @contact.save! ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? end def merge_contact(base_contact, merge_contact) + return base_contact if base_contact.id == merge_contact.id + ContactMergeAction.new( account: account, base_contact: base_contact, @@ -69,14 +118,14 @@ class ContactIdentifyAction end def custom_attributes - params[:custom_attributes] ? @contact.custom_attributes.deep_merge(params[:custom_attributes].stringify_keys) : @contact.custom_attributes + return @contact.custom_attributes if params[:custom_attributes].blank? + + (@contact.custom_attributes || {}).deep_merge(params[:custom_attributes].stringify_keys) end def additional_attributes - if params[:additional_attributes] - @contact.additional_attributes.deep_merge(params[:additional_attributes].stringify_keys) - else - @contact.additional_attributes - end + return @contact.additional_attributes if params[:additional_attributes].blank? + + (@contact.additional_attributes || {}).deep_merge(params[:additional_attributes].stringify_keys) end end diff --git a/app/builders/campaigns/campaign_conversation_builder.rb b/app/builders/campaigns/campaign_conversation_builder.rb index b04c6d077..3b3f262c9 100644 --- a/app/builders/campaigns/campaign_conversation_builder.rb +++ b/app/builders/campaigns/campaign_conversation_builder.rb @@ -9,12 +9,15 @@ class Campaigns::CampaignConversationBuilder @contact_inbox.lock! # We won't send campaigns if a conversation is already present - return if @contact_inbox.reload.conversations.present? + raise 'Conversation alread present' if @contact_inbox.reload.conversations.present? @conversation = ::Conversation.create!(conversation_params) Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform end @conversation + rescue StandardError => e + Rails.logger.info(e.message) + nil end private diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 3b3248ed1..f19d3c8b7 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -27,7 +27,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end ensure_contact_avatar rescue Koala::Facebook::AuthenticationError - Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}" + @inbox.channel.authorization_error! rescue StandardError => e ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception true diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 5c8cadbcd..e9bf0802b 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -73,6 +73,10 @@ class Messages::MessageBuilder @params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {} end + def template_params + @params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {} + end + def message_sender return if @params[:sender_type] != 'AgentBot' @@ -91,6 +95,6 @@ class Messages::MessageBuilder items: @items, in_reply_to: @in_reply_to, echo_id: @params[:echo_id] - }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id) + }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) end end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 6d3ed0179..3c406f1e0 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -54,6 +54,9 @@ class Messages::Messenger::MessageBuilder def fetch_story_link(attachment) message = attachment.message result = get_story_object_from_source_id(message.source_id) + + return if result.blank? + story_id = result['story']['mention']['id'] story_sender = result['from']['username'] message.content_attributes[:story_sender] = story_sender @@ -68,6 +71,11 @@ class Messages::Messenger::MessageBuilder rescue Koala::Facebook::AuthenticationError @inbox.channel.authorization_error! raise + rescue Koala::Facebook::ClientError => e + # The exception occurs when we are trying fetch the deleted story or blocked story. + @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) + Rails.logger.error e + {} rescue StandardError => e ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception {} diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index 9f4ef2cd9..efb48c5c6 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -30,6 +30,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController end def permitted_params - params.permit(:name, :description, :outgoing_url) + params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content]) end end diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 09b648a6f..6b2f9ea75 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -72,6 +72,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def validate_limit - render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents] + render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents] end end diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb new file mode 100644 index 000000000..efe761501 --- /dev/null +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -0,0 +1,52 @@ +class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController + before_action :portal + before_action :check_authorization + before_action :fetch_article, except: [:index, :create] + + def index + @articles = @portal.articles + @articles = @articles.search(list_params) if params[:payload].present? + end + + def create + @article = @portal.articles.create!(article_params) + @article.associate_root_article(article_params[:associated_article_id]) + @article.draft! + render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? + end + + def edit; end + + def show; end + + def update + @article.update!(article_params) + end + + def destroy + @article.destroy! + head :ok + end + + private + + def fetch_article + @article = @portal.articles.find(params[:id]) + end + + def portal + @portal ||= Current.account.portals.find_by(slug: params[:portal_id]) + end + + def article_params + params.require(:article).permit( + :title, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status + ) + end + + def list_params + params.require(:payload).permit( + :category_slug, :locale, :query, :page + ) + end +end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 5e649b6e0..859c8a4bf 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -9,6 +9,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def create @automation_rule = Current.account.automation_rules.new(automation_rules_permit) @automation_rule.actions = params[:actions] + @automation_rule.conditions = params[:conditions] render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid? @@ -31,9 +32,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def update ActiveRecord::Base.transaction do - @automation_rule.update!(automation_rules_permit) - @automation_rule.actions = params[:actions] if params[:actions] - @automation_rule.save! + automation_rule_update process_attachments rescue StandardError => e @@ -67,10 +66,17 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont private + def automation_rule_update + @automation_rule.update!(automation_rules_permit) + @automation_rule.actions = params[:actions] if params[:actions] + @automation_rule.conditions = params[:conditions] if params[:conditions] + @automation_rule.save! + end + def automation_rules_permit params.permit( :name, :description, :event_name, :account_id, :active, - conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }], + conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }], actions: [:action_name, { action_params: [] }] ) end diff --git a/app/controllers/api/v1/accounts/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb index 246eeb2a2..c4491b2e2 100644 --- a/app/controllers/api/v1/accounts/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -1,17 +1,28 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController before_action :portal + before_action :check_authorization before_action :fetch_category, except: [:index, :create] def index - @categories = @portal.categories + @categories = @portal.categories.search(params) end def create @category = @portal.categories.create!(category_params) + @category.related_categories << related_categories_records + render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid? + + @category.save! end + def show; end + def update @category.update!(category_params) + @category.related_categories = related_categories_records if related_categories_records.any? + render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid? + + @category.save! end def destroy @@ -29,9 +40,13 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle @portal ||= Current.account.portals.find_by(slug: params[:portal_id]) end + def related_categories_records + @portal.categories.where(id: params[:category][:related_category_ids]) + end + def category_params params.require(:category).permit( - :name, :description, :position + :name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id ) end end diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index f5a3c6a6d..55a9456cf 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -27,6 +27,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: end def phone_number + return if permitted_params[:phone_number].blank? + medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}" end @@ -38,6 +40,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: @twilio_channel = Current.account.twilio_sms.create!( account_sid: permitted_params[:account_sid], auth_token: permitted_params[:auth_token], + messaging_service_sid: permitted_params[:messaging_service_sid].presence, phone_number: phone_number, medium: medium ) @@ -49,7 +52,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: def permitted_params params.require(:twilio_channel).permit( - :account_id, :phone_number, :account_sid, :auth_token, :name, :medium + :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium ) end end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 76fd206f4..44b1280ee 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -12,7 +12,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search, :filter] - before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes] + before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :set_include_contact_inboxes, only: [:index, :search, :filter] def index @@ -72,15 +72,17 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def create ActiveRecord::Base.transaction do - @contact = Current.account.contacts.new(contact_params) + @contact = Current.account.contacts.new(permitted_params.except(:avatar_url)) @contact.save! @contact_inbox = build_contact_inbox + process_avatar end end def update @contact.assign_attributes(contact_update_params) @contact.save! + process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present? end def destroy @@ -95,6 +97,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController head :ok end + def avatar + @contact.avatar.purge if @contact.avatar.attached? + @contact + end + private # TODO: Move this to a finder class @@ -131,19 +138,19 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id) end - def contact_params - params.require(:contact).permit(:name, :identifier, :email, :phone_number, additional_attributes: {}, custom_attributes: {}) + def permitted_params + params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, additional_attributes: {}, custom_attributes: {}) end def contact_custom_attributes - return @contact.custom_attributes.merge(contact_params[:custom_attributes]) if contact_params[:custom_attributes] + return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes] @contact.custom_attributes end def contact_update_params # we want the merged custom attributes not the original one - contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes }) + permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes }) end def set_include_contact_inboxes @@ -158,6 +165,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) end + def process_avatar + if permitted_params[:avatar].blank? && permitted_params[:avatar_url].present? + ::ContactAvatarJob.perform_later(@contact, params[:avatar_url]) + elsif permitted_params[:avatar].blank? && permitted_params[:email].present? + hash = Digest::MD5.hexdigest(params[:email]) + gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404" + ::ContactAvatarJob.perform_later(@contact, gravatar_url) + end + end + def render_error(error, error_status) render json: error, status: error_status end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 4bc85546a..95662e29b 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -42,15 +42,19 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def update - @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!(permitted_params.except(:channel)) + update_inbox_working_hours channel_attributes = get_channel_attributes(@inbox.channel_type) # Inbox update doesn't necessarily need channel attributes return if permitted_params(channel_attributes)[:channel].blank? if @inbox.inbox_type == 'Email' - validate_email_channel(channel_attributes) + begin + validate_email_channel(channel_attributes) + rescue StandardError => e + render json: { message: e }, status: :unprocessable_entity and return + end @inbox.channel.reauthorized! end @@ -58,6 +62,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController update_channel_feature_flags end + def update_inbox_working_hours + @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] + end + def agent_bot @agent_bot = @inbox.agent_bot end @@ -89,12 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] end - def inbox_name(channel) - return channel.try(:bot_name) if channel.is_a?(Channel::Telegram) - - permitted_params[:name] - end - def create_channel return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type]) @@ -109,10 +111,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @inbox.channel.save! end + def inbox_attributes + [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, + :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved] + end + def permitted_params(channel_attributes = []) params.permit( - :name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, - :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, + *inbox_attributes, channel: [:type, *channel_attributes] ) end @@ -129,18 +135,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController }[permitted_params[:channel][:type]] end - def account_channels_method - { - 'web_widget' => Current.account.web_widgets, - 'api' => Current.account.api_channels, - 'email' => Current.account.email_channels, - 'line' => Current.account.line_channels, - 'telegram' => Current.account.telegram_channels, - 'whatsapp' => Current.account.whatsapp_channels, - 'sms' => Current.account.sms_channels - }[permitted_params[:channel][:type]] - end - def get_channel_attributes(channel_type) if channel_type.constantize.const_defined?(:EDITABLE_ATTRS) channel_type.constantize::EDITABLE_ATTRS.presence @@ -148,10 +142,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController [] end end - - def validate_limit - return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes] - - render_payment_required('Account limit exceeded. Upgrade to a higher plan') - end end + +Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb new file mode 100644 index 000000000..9a37faa4b --- /dev/null +++ b/app/controllers/api/v1/accounts/macros_controller.rb @@ -0,0 +1,51 @@ +class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController + before_action :check_authorization + before_action :fetch_macro, only: [:show, :update, :destroy] + + def index + @macros = Macro.with_visibility(current_user, params) + end + + def create + @macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id)) + @macro.set_visibility(current_user, permitted_params) + @macro.actions = params[:actions] + + render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid? + + @macro.save! + end + + def show; end + + def destroy + @macro.destroy! + head :ok + end + + def update + ActiveRecord::Base.transaction do + @macro.update!(macros_with_user) + @macro.set_visibility(current_user, permitted_params) + @macro.save! + rescue StandardError => e + Rails.logger.error e + render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity + end + end + + def permitted_params + params.permit( + :name, :account_id, :visibility, + actions: [:action_name, { action_params: [] }] + ) + end + + def macros_with_user + permitted_params.merge(updated_by_id: current_user.id) + end + + def fetch_macro + @macro = Current.account.macros.find_by(id: params[:id]) + end +end diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 75ffc35e9..b081978b9 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -1,18 +1,36 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController + include ::FileTypeHelper + before_action :fetch_portal, except: [:index, :create] + before_action :check_authorization def index @portals = Current.account.portals end + def add_members + agents = Current.account.agents.where(id: portal_member_params[:member_ids]) + @portal.members << agents + end + def show; end def create - @portal = Current.account.portals.create!(portal_params) + @portal = Current.account.portals.build(portal_params) + render json: { error: @portal.errors.messages }, status: :unprocessable_entity and return unless @portal.valid? + + @portal.save! + process_attached_logo end def update - @portal.update!(portal_params) + ActiveRecord::Base.transaction do + @portal.update!(portal_params) if params[:portal].present? + process_attached_logo + rescue StandardError => e + Rails.logger.error e + render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity + end end def destroy @@ -20,6 +38,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController head :ok end + def archive + @portal.update(archive: true) + head :ok + end + + def process_attached_logo + @portal.logo.attach(params[:logo]) + end + private def fetch_portal @@ -32,7 +59,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def portal_params params.require(:portal).permit( - :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived + :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, config: { allowed_locales: [] } ) end + + def portal_member_params + params.require(:portal).permit(:account_id, member_ids: []) + end end diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index fc4d33fc3..d854114c4 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -44,38 +44,6 @@ class Api::V1::Widget::BaseController < ApplicationController } end - def update_contact(email) - contact_with_email = @current_account.contacts.find_by(email: email) - if contact_with_email - @contact = ::ContactMergeAction.new( - account: @current_account, - base_contact: contact_with_email, - mergee_contact: @contact - ).perform - else - @contact.update!(email: email) - update_contact_name - end - end - - def update_contact_phone_number(phone_number) - contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number) - if contact_with_phone_number - @contact = ::ContactMergeAction.new( - account: @current_account, - base_contact: contact_with_phone_number, - mergee_contact: @contact - ).perform - else - @contact.update!(phone_number: phone_number) - update_contact_name - end - end - - def update_contact_name - @contact.update!(name: contact_name) if contact_name.present? - end - def contact_email permitted_params.dig(:contact, :email)&.downcase end diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index fbc303a4f..5138fe675 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -1,14 +1,27 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController - before_action :process_hmac, only: [:update] + include WidgetHelper + + before_action :validate_hmac, only: [:set_user] def show; end def update - contact_identify_action = ContactIdentifyAction.new( - contact: @contact, - params: permitted_params.to_h.deep_symbolize_keys - ) - @contact = contact_identify_action.perform + identify_contact(@contact) + end + + def set_user + contact = nil + + if a_different_contact? + @contact_inbox, @widget_auth_token = build_contact_inbox_with_token(@web_widget) + contact = @contact_inbox.contact + else + contact = @contact + end + + @contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac? + + identify_contact(contact) end # TODO : clean up this with proper routes delete contacts/custom_attributes @@ -20,12 +33,23 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController private - def process_hmac + def identify_contact(contact) + contact_identify_action = ContactIdentifyAction.new( + contact: contact, + params: permitted_params.to_h.deep_symbolize_keys, + discard_invalid_attrs: true + ) + @contact = contact_identify_action.perform + end + + def a_different_contact? + @contact.identifier.present? && @contact.identifier != permitted_params[:identifier] + end + + def validate_hmac return unless should_verify_hmac? render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac? - - @contact_inbox.update(hmac_verified: true) end def should_verify_hmac? diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 8d054229c..a01d33cdf 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -14,8 +14,11 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def process_update_contact - update_contact(contact_email) if @contact.email.blank? && contact_email.present? - update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present? + @contact = ContactIdentifyAction.new( + contact: @contact, + params: { email: contact_email, phone_number: contact_phone_number, name: contact_name }, + retain_original_contact_name: true + ).perform end def update_last_seen diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index c2fa6304b..7ff77be32 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -15,7 +15,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def update if @message.content_type == 'input_email' @message.update!(submitted_email: contact_email) - update_contact(contact_email) + ContactIdentifyAction.new( + contact: @contact, + params: { email: contact_email } + ).perform else @message.update!(message_update_params[:message]) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0a1c672ec..d2960a699 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,7 @@ class ApplicationController < ActionController::Base include DeviseTokenAuth::Concerns::SetUserByToken include RequestExceptionHandler - include Pundit + include Pundit::Authorization include SwitchLocale skip_before_action :verify_authenticity_token @@ -17,10 +17,6 @@ class ApplicationController < ActionController::Base Current.user = @user end - def current_subscription - @subscription ||= Current.account.subscription - end - def pundit_user { user: Current.user, diff --git a/app/controllers/concerns/meta_token_verify_concern.rb b/app/controllers/concerns/meta_token_verify_concern.rb new file mode 100644 index 000000000..b3f920644 --- /dev/null +++ b/app/controllers/concerns/meta_token_verify_concern.rb @@ -0,0 +1,20 @@ +# services from Meta (Prev: Facebook) needs a token verification step for webhook subscriptions, +# This concern handles the token verification step. + +module MetaTokenVerifyConcern + def verify + service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram' + if valid_token?(params['hub.verify_token']) + Rails.logger.info("#{service.capitalize} webhook verified") + render json: params['hub.challenge'] + else + render status: :unauthorized, json: { error: 'Error; wrong verify token' } + end + end + + private + + def valid_token?(_token) + raise 'Overwrite this method your controller' + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index fb4fde40c..ec2db37bd 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -14,8 +14,7 @@ class DashboardController < ActionController::Base def set_global_config @global_config = GlobalConfig.get( - 'LOGO', - 'LOGO_THUMBNAIL', + 'LOGO', 'LOGO_THUMBNAIL', 'INSTALLATION_NAME', 'WIDGET_BRAND_URL', 'TERMS_URL', @@ -30,7 +29,8 @@ class DashboardController < ActionController::Base 'DIRECT_UPLOADS_ENABLED', 'HCAPTCHA_SITE_KEY', 'LOGOUT_REDIRECT_LINK', - 'DISABLE_USER_PROFILE_UPDATE' + 'DISABLE_USER_PROFILE_UPDATE', + 'DEPLOYMENT_ENV' ).merge(app_config) end @@ -48,7 +48,8 @@ class DashboardController < ActionController::Base VAPID_PUBLIC_KEY: VapidService.public_key, ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), - FACEBOOK_API_VERSION: 'v13.0' + FACEBOOK_API_VERSION: 'v14.0', + IS_ENTERPRISE: ChatwootApp.enterprise? } end end diff --git a/app/controllers/platform/api/v1/users_controller.rb b/app/controllers/platform/api/v1/users_controller.rb index 4a7eafc9c..12c87deb5 100644 --- a/app/controllers/platform/api/v1/users_controller.rb +++ b/app/controllers/platform/api/v1/users_controller.rb @@ -14,7 +14,7 @@ class Platform::Api::V1::UsersController < PlatformController def login encoded_email = ERB::Util.url_encode(@resource.email) - render json: { url: "#{ENV['FRONTEND_URL']}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" } + render json: { url: "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" } end def show; end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb new file mode 100644 index 000000000..2a961b7a9 --- /dev/null +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -0,0 +1,25 @@ +class Public::Api::V1::Portals::ArticlesController < ApplicationController + before_action :set_portal + before_action :set_article, only: [:show] + + def index + @articles = @portal.articles + @articles = @articles.search(list_params) if params[:payload].present? + end + + def show; end + + private + + def set_article + @article = @portal.articles.find(params[:id]) + end + + def set_portal + @portal = ::Portal.find_by!(slug: params[:portal_slug], archived: false) + end + + def list_params + params.require(:payload).permit(:query) + end +end diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb new file mode 100644 index 000000000..cf57d73ff --- /dev/null +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -0,0 +1,20 @@ +class Public::Api::V1::Portals::CategoriesController < PublicController + before_action :set_portal + before_action :set_category, only: [:show] + + def index + @categories = @portal.categories + end + + def show; end + + private + + def set_category + @category = @portal.categories.find_by!(slug: params[:slug]) + end + + def set_portal + @portal = ::Portal.find_by!(slug: params[:portal_slug], archived: false) + end +end diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb new file mode 100644 index 000000000..aedf1a09d --- /dev/null +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -0,0 +1,11 @@ +class Public::Api::V1::PortalsController < PublicController + before_action :set_portal + + def show; end + + private + + def set_portal + @portal = ::Portal.find_by!(slug: params[:slug], archived: false) + end +end diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index 44dcc9b6f..7723e5dd2 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -7,7 +7,7 @@ class Twilio::CallbackController < ApplicationController private - def permitted_params + def permitted_params # rubocop:disable Metrics/MethodLength params.permit( :ApiVersion, :SmsSid, @@ -25,7 +25,8 @@ class Twilio::CallbackController < ApplicationController :ToCountry, :FromState, :MediaUrl0, - :MediaContentType0 + :MediaContentType0, + :MessagingServiceSid ) end end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index e6fe93566..b658915ed 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -1,15 +1,5 @@ -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 +class Webhooks::InstagramController < ActionController::API + include MetaTokenVerifyConcern def events Rails.logger.info('Instagram webhook received events') @@ -24,7 +14,7 @@ class Webhooks::InstagramController < ApplicationController private - def valid_instagram_token?(token) + def valid_token?(token) token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index 7560da1e4..8f408d2b0 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -1,6 +1,16 @@ class Webhooks::WhatsappController < ActionController::API + include MetaTokenVerifyConcern + def process_payload Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) head :ok end + + private + + def valid_token?(token) + channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number]) + whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present? + token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present? + end end diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index ad7d2e3c0..653d3360f 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -1,5 +1,7 @@ # TODO : Delete this and associated spec once 'api/widget/config' end point is merged class WidgetsController < ActionController::Base + include WidgetHelper + before_action :set_global_config before_action :set_web_widget before_action :set_token @@ -40,11 +42,8 @@ class WidgetsController < ActionController::Base def build_contact return if @contact.present? - @contact_inbox = @web_widget.create_contact_inbox(additional_attributes) + @contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes) @contact = @contact_inbox.contact - - payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id } - @token = ::Widget::TokenService.new(payload: payload).generate_token end def additional_attributes diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index ecbccfb88..58e911362 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -2,6 +2,11 @@ class ConversationFinder attr_reader :current_user, :current_account, :params DEFAULT_STATUS = 'open'.freeze + SORT_OPTIONS = { + latest: 'latest', + sort_on_created_at: 'sort_on_created_at', + last_user_message_at: 'last_user_message_at' + }.with_indifferent_access # assumptions # inbox_id if not given, take from all conversations, else specific to inbox @@ -133,10 +138,7 @@ class ConversationFinder @conversations = @conversations.includes( :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) - end + sort_by = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['latest'] + @conversations.send(sort_by).page(current_page) end end diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 56fb79908..c88f2fb73 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -1,4 +1,10 @@ module Api::V1::InboxesHelper + def inbox_name(channel) + return channel.try(:bot_name) if channel.is_a?(Channel::Telegram) + + permitted_params[:name] + end + def validate_email_channel(attributes) channel_data = permitted_params(attributes)[:channel] @@ -19,8 +25,7 @@ module Api::V1::InboxesHelper enable_ssl: channel_data[:imap_enable_ssl] } end - Mail.connection do # rubocop:disable:block - end + check_imap_connection(channel_data) end def validate_smtp(channel_data) @@ -32,10 +37,29 @@ module Api::V1::InboxesHelper check_smtp_connection(channel_data, smtp) end + def check_imap_connection(channel_data) + Mail.connection {} # rubocop:disable:block + rescue SocketError => e + raise StandardError, I18n.t('errors.inboxes.imap.socket_error') + rescue Net::IMAP::NoResponseError => e + raise StandardError, I18n.t('errors.inboxes.imap.no_response_error') + rescue Errno::EHOSTUNREACH => e + raise StandardError, I18n.t('errors.inboxes.imap.host_unreachable_error') + rescue Net::OpenTimeout => e + raise StandardError, + I18n.t('errors.inboxes.imap.connection_timed_out_error', address: channel_data[:imap_address], port: channel_data[:imap_port]) + rescue Net::IMAP::Error => e + raise StandardError, I18n.t('errors.inboxes.imap.connection_closed_error') + rescue StandardError => e + raise StandardError, e.message + ensure + Rails.logger.error e if e.present? + end + def check_smtp_connection(channel_data, smtp) smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], channel_data[:smtp_authentication]&.to_sym || :login) - smtp.finish unless smtp&.nil? + smtp.finish end def set_smtp_encryption(channel_data, smtp) @@ -74,4 +98,22 @@ module Api::V1::InboxesHelper context.verify_mode = openssl_verify_mode context end + + def account_channels_method + { + 'web_widget' => Current.account.web_widgets, + 'api' => Current.account.api_channels, + 'email' => Current.account.email_channels, + 'line' => Current.account.line_channels, + 'telegram' => Current.account.telegram_channels, + 'whatsapp' => Current.account.whatsapp_channels, + 'sms' => Current.account.sms_channels + }[permitted_params[:channel][:type]] + end + + def validate_limit + return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes] + + render_payment_required('Account limit exceeded. Upgrade to a higher plan') + end end diff --git a/app/helpers/widget_helper.rb b/app/helpers/widget_helper.rb new file mode 100644 index 000000000..0798789eb --- /dev/null +++ b/app/helpers/widget_helper.rb @@ -0,0 +1,9 @@ +module WidgetHelper + def build_contact_inbox_with_token(web_widget, additional_attributes = {}) + contact_inbox = web_widget.create_contact_inbox(additional_attributes) + payload = { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } + token = ::Widget::TokenService.new(payload: payload).generate_token + + [contact_inbox, token] + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 1e262f1ff..7243e566e 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -2,7 +2,7 @@
- + { + it('creates correct instance', () => { + expect(accountAPI).toBeInstanceOf(ApiClient); + expect(accountAPI).toHaveProperty('get'); + expect(accountAPI).toHaveProperty('show'); + expect(accountAPI).toHaveProperty('create'); + expect(accountAPI).toHaveProperty('update'); + expect(accountAPI).toHaveProperty('delete'); + expect(accountAPI).toHaveProperty('checkout'); + }); + + describeWithAPIMock('API calls', context => { + it('#checkout', () => { + accountAPI.checkout(); + expect(context.axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/checkout' + ); + }); + + it('#subscription', () => { + accountAPI.subscription(); + expect(context.axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/subscription' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index f3c5e83e9..f0096cf23 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -10,6 +10,7 @@ export const buildCreatePayload = ({ files, ccEmails = '', bccEmails = '', + templateParams, }) => { let payload; if (files && files.length !== 0) { @@ -32,6 +33,7 @@ export const buildCreatePayload = ({ content_attributes: contentAttributes, cc_emails: ccEmails, bcc_emails: bccEmails, + template_params: templateParams, }; } return payload; @@ -51,6 +53,7 @@ class MessageApi extends ApiClient { files, ccEmails = '', bccEmails = '', + templateParams, }) { return axios({ method: 'post', @@ -63,6 +66,7 @@ class MessageApi extends ApiClient { files, ccEmails, bccEmails, + templateParams, }), }); } diff --git a/app/javascript/dashboard/api/specs/bulkAction.spec.js b/app/javascript/dashboard/api/specs/bulkAction.spec.js new file mode 100644 index 000000000..aec0b1e3e --- /dev/null +++ b/app/javascript/dashboard/api/specs/bulkAction.spec.js @@ -0,0 +1,9 @@ +import bulkActions from '../bulkActions'; +import ApiClient from '../ApiClient'; + +describe('#BulkActionsAPI', () => { + it('creates correct instance', () => { + expect(bulkActions).toBeInstanceOf(ApiClient); + expect(bulkActions).toHaveProperty('create'); + }); +}); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index dfdc3a48b..dc034480a 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -12,6 +12,7 @@ describe('#ContactsAPI', () => { expect(contactAPI).toHaveProperty('delete'); expect(contactAPI).toHaveProperty('getConversations'); expect(contactAPI).toHaveProperty('filter'); + expect(contactAPI).toHaveProperty('destroyAvatar'); }); describeWithAPIMock('API calls', context => { @@ -100,6 +101,13 @@ describe('#ContactsAPI', () => { queryPayload ); }); + + it('#destroyAvatar', () => { + contactAPI.destroyAvatar(1); + expect(context.axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/contacts/1/avatar' + ); + }); }); }); diff --git a/app/javascript/dashboard/assets/scss/_foundation-settings.scss b/app/javascript/dashboard/assets/scss/_foundation-settings.scss index c6b7f6fd3..d13dcbe5e 100644 --- a/app/javascript/dashboard/assets/scss/_foundation-settings.scss +++ b/app/javascript/dashboard/assets/scss/_foundation-settings.scss @@ -383,8 +383,8 @@ $form-button-radius: var(--border-radius-normal); // 20. Label // --------- -$label-background: $primary-color; -$label-color: $white; +$label-background: $white; +$label-color: $black; $label-color-alt: $black; $label-palette: $foundation-palette; $label-font-size: $font-size-mini; diff --git a/app/javascript/dashboard/assets/scss/_typography.scss b/app/javascript/dashboard/assets/scss/_typography.scss index abab48564..7db0f2a46 100644 --- a/app/javascript/dashboard/assets/scss/_typography.scss +++ b/app/javascript/dashboard/assets/scss/_typography.scss @@ -4,6 +4,7 @@ .page-sub-title { font-size: $font-size-large; + word-wrap: break-word; } .block-title { diff --git a/app/javascript/dashboard/assets/scss/_utility-helpers.scss b/app/javascript/dashboard/assets/scss/_utility-helpers.scss index 71977cf2b..4120af396 100644 --- a/app/javascript/dashboard/assets/scss/_utility-helpers.scss +++ b/app/javascript/dashboard/assets/scss/_utility-helpers.scss @@ -60,3 +60,9 @@ text-overflow: ellipsis; white-space: nowrap; } + +.flex-between { + align-items: center; + display: flex; + justify-content: space-between; +} diff --git a/app/javascript/dashboard/assets/scss/_variables.scss b/app/javascript/dashboard/assets/scss/_variables.scss index ea8334c57..f24f00830 100644 --- a/app/javascript/dashboard/assets/scss/_variables.scss +++ b/app/javascript/dashboard/assets/scss/_variables.scss @@ -99,3 +99,7 @@ $ionicons-font-path: '~ionicons/fonts'; // Transitions $transition-ease-in: all 0.250s ease-in; + +:root { + --dashboard-app-tabs-height: 3.9rem; +} diff --git a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss index dbb68f6d5..234d7e171 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss @@ -1,9 +1,34 @@ +.tabs--container { + display: flex; +} + +.tabs--container--with-border { + @include border-normal-bottom; +} + .tabs { @include padding($zero $space-normal); - @include border-normal-bottom; border-left-width: 0; border-right-width: 0; border-top-width: 0; + display: flex; + min-width: var(--space-mega); +} + +.tabs--with-scroll { + max-width: calc(100% - 64px); + overflow: hidden; + padding: 0 var(--space-smaller); +} + +.tabs--scroll-button { + align-items: center; + border-radius: 0; + cursor: pointer; + display: flex; + height: auto; + justify-content: center; + min-width: var(--space-large); } // Tab chat type @@ -22,6 +47,7 @@ .tabs-title { @include margin($zero $space-slab); + flex-shrink: 0; .badge { background: $color-background; diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 2dbeb6a2b..9be60f596 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -1,6 +1,6 @@ @@ -29,3 +29,8 @@ export default { }, }; + diff --git a/app/javascript/dashboard/components/NetworkNotification.vue b/app/javascript/dashboard/components/NetworkNotification.vue index 115a674a2..6f29dbb74 100644 --- a/app/javascript/dashboard/components/NetworkNotification.vue +++ b/app/javascript/dashboard/components/NetworkNotification.vue @@ -20,8 +20,7 @@ color-scheme="warning" icon="dismiss-circle" @click="closeNotification" - > - + />
diff --git a/app/javascript/dashboard/components/SettingsSection.vue b/app/javascript/dashboard/components/SettingsSection.vue index e700e9467..b3348ae04 100644 --- a/app/javascript/dashboard/components/SettingsSection.vue +++ b/app/javascript/dashboard/components/SettingsSection.vue @@ -13,7 +13,7 @@

- +
diff --git a/app/javascript/dashboard/components/buttons/Button.vue b/app/javascript/dashboard/components/buttons/Button.vue index 69dbc3658..733b84639 100644 --- a/app/javascript/dashboard/components/buttons/Button.vue +++ b/app/javascript/dashboard/components/buttons/Button.vue @@ -7,7 +7,7 @@ :icon="icon" /> - + diff --git a/app/javascript/dashboard/components/helpCenter/ArticleItem.stories.js b/app/javascript/dashboard/components/helpCenter/ArticleItem.stories.js new file mode 100644 index 000000000..18dc8295c --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/ArticleItem.stories.js @@ -0,0 +1,63 @@ +import ArticleItemComponent from './ArticleItem.vue'; +const STATUS_LIST = { + published: 'published', + draft: 'draft', + archived: 'archived', +}; + +export default { + title: 'Components/Help Center', + component: ArticleItemComponent, + argTypes: { + title: { + defaultValue: 'Setup your account', + control: { + type: 'text', + }, + }, + readCount: { + defaultValue: 13, + control: { + type: 'number', + }, + }, + category: { + defaultValue: 'Getting started', + control: { + type: 'text', + }, + }, + status: { + defaultValue: 'Status', + control: { + type: 'select', + options: STATUS_LIST, + }, + }, + updatedAt: { + defaultValue: '1657255863', + control: { + type: 'number', + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { ArticleItemComponent }, + template: + '', +}); + +export const ArticleItem = Template.bind({}); +ArticleItem.args = { + title: 'Setup your account', + author: { + name: 'John Doe', + }, + category: 'Getting started', + readCount: 12, + status: 'published', + updatedAt: 1657255863, +}; diff --git a/app/javascript/dashboard/components/helpCenter/ArticleItem.vue b/app/javascript/dashboard/components/helpCenter/ArticleItem.vue new file mode 100644 index 000000000..252215c8c --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/ArticleItem.vue @@ -0,0 +1,129 @@ + + + + diff --git a/app/javascript/dashboard/components/helpCenter/ArticleTable.stories.js b/app/javascript/dashboard/components/helpCenter/ArticleTable.stories.js new file mode 100644 index 000000000..f921c7eb1 --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/ArticleTable.stories.js @@ -0,0 +1,72 @@ +import ArticleTableComponent from './ArticleTable.vue'; +import { action } from '@storybook/addon-actions'; +export default { + title: 'Components/Help Center', + component: ArticleTableComponent, + argTypes: { + articles: { + defaultValue: [], + control: { + type: 'array', + }, + }, + articleCount: { + defaultValue: 10, + control: { + type: 'number', + }, + }, + currentPage: { + defaultValue: 1, + control: { + type: 'number', + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { ArticleTableComponent }, + template: + '', +}); + +export const ArticleTable = Template.bind({}); +ArticleTable.args = { + articles: [ + { + title: 'Setup your account', + author: { + name: 'John Doe', + }, + readCount: 13, + category: 'Getting started', + status: 'published', + updatedAt: 1657255863, + }, + { + title: 'Docker Configuration', + author: { + name: 'Sam Manuel', + }, + readCount: 13, + category: 'Engineering', + status: 'draft', + updatedAt: 1656658046, + }, + { + title: 'Campaigns', + author: { + name: 'Sam Manuel', + }, + readCount: 28, + category: 'Engineering', + status: 'archived', + updatedAt: 1657590446, + }, + ], + articleCount: 10, + currentPage: 1, + onPageChange: action('onPageChange'), +}; diff --git a/app/javascript/dashboard/components/helpCenter/ArticleTable.vue b/app/javascript/dashboard/components/helpCenter/ArticleTable.vue new file mode 100644 index 000000000..b6b0f34fe --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/ArticleTable.vue @@ -0,0 +1,84 @@ + + + + diff --git a/app/javascript/dashboard/components/helpCenter/EditArticle.stories.js b/app/javascript/dashboard/components/helpCenter/EditArticle.stories.js new file mode 100644 index 000000000..b7e13e1d1 --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/EditArticle.stories.js @@ -0,0 +1,34 @@ +import { action } from '@storybook/addon-actions'; +import EditArticle from './EditArticle.vue'; + +export default { + title: 'Components/Help Center', + component: EditArticle, + argTypes: { + article: { + defaultValue: {}, + control: { + type: 'object', + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { EditArticle }, + template: + '', +}); + +export const EditArticleView = Template.bind({}); +EditArticleView.args = { + article: { + id: '1', + title: 'Lorem ipsum', + content: + 'L**orem ipsum** dolor sit amet, consectetur adipiscing elit. Congue diam orci tellus *varius per cras turpis aliquet commodo dolor justo* rutrum lorem venenatis aliquet orci curae hac. Sagittis ultrices felis **`ante placerat condimentum parturient erat consequat`** sollicitudin *sagittis potenti sollicitudin* quis velit at placerat mi torquent. Dignissim luctus nulla suspendisse purus cras commodo ipsum orci tempus morbi metus conubia et hac potenti quam suspendisse feugiat. Turpis eros dictum tellus natoque laoreet lacus dolor cras interdum **vitae gravida tincidunt ultricies tempor convallis tortor rhoncus suspendisse.** Nisi lacinia etiam vivamus tellus sed taciti potenti quam praesent congue euismod mauris est eu risus convallis taciti etiam. Inceptos iaculis turpis leo porta pellentesque dictum `bibendum blandit parturient nulla leo pretium` rhoncus litora dapibus fringilla hac litora.', + }, + onFocus: action('focus'), + onBlur: action('blur'), +}; diff --git a/app/javascript/dashboard/components/helpCenter/EditArticle.vue b/app/javascript/dashboard/components/helpCenter/EditArticle.vue new file mode 100644 index 000000000..d3abe35dd --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/EditArticle.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/app/javascript/dashboard/components/helpCenter/Header/ArticleHeader.stories.js b/app/javascript/dashboard/components/helpCenter/Header/ArticleHeader.stories.js new file mode 100644 index 000000000..8de3d12b6 --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/Header/ArticleHeader.stories.js @@ -0,0 +1,44 @@ +import { action } from '@storybook/addon-actions'; +import ArticleHeader from './ArticleHeader'; + +export default { + title: 'Components/Help Center/Header', + component: ArticleHeader, + argTypes: { + headerTitle: { + defaultValue: 'All articles', + control: { + type: 'text', + }, + }, + count: { + defaultValue: 112, + control: { + type: 'number', + }, + }, + selectedValue: { + defaultValue: 'Status', + control: { + type: 'text', + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { ArticleHeader }, + template: + '', +}); + +export const ArticleHeaderView = Template.bind({}); +ArticleHeaderView.args = { + headerTitle: 'All articles', + count: 112, + selectedValue: 'Status', + openFilterModal: action('openedFilterModal'), + openDropdown: action('opened'), + closeDropdown: action('closed'), +}; diff --git a/app/javascript/dashboard/components/helpCenter/Header/ArticleHeader.vue b/app/javascript/dashboard/components/helpCenter/Header/ArticleHeader.vue new file mode 100644 index 000000000..d106bd93c --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/Header/ArticleHeader.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/app/javascript/dashboard/components/helpCenter/Header/EditArticleHeader.stories.js b/app/javascript/dashboard/components/helpCenter/Header/EditArticleHeader.stories.js new file mode 100644 index 000000000..56e0f8770 --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/Header/EditArticleHeader.stories.js @@ -0,0 +1,39 @@ +import { action } from '@storybook/addon-actions'; +import EditArticleHeader from './EditArticleHeader'; + +export default { + title: 'Components/Help Center/Header', + component: EditArticleHeader, + argTypes: { + backButtonLabel: { + defaultValue: 'Articles', + control: { + type: 'text', + }, + }, + draftState: { + defaultValue: 'saving', + control: { + type: 'text', + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { EditArticleHeader }, + template: + '', +}); + +export const EditArticleHeaderView = Template.bind({}); +EditArticleHeaderView.args = { + backButtonLabel: 'Articles', + draftState: 'saving', + onClickGoBack: action('goBack'), + showPreview: action('previewOpened'), + onClickAdd: action('added'), + openSidebar: action('openedSidebar'), + closeSidebar: action('closedSidebar'), +}; diff --git a/app/javascript/dashboard/components/helpCenter/Header/EditArticleHeader.vue b/app/javascript/dashboard/components/helpCenter/Header/EditArticleHeader.vue new file mode 100644 index 000000000..83f2bf987 --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/Header/EditArticleHeader.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/app/javascript/dashboard/components/helpCenter/Sidebar/Sidebar.stories.js b/app/javascript/dashboard/components/helpCenter/Sidebar/Sidebar.stories.js new file mode 100644 index 000000000..245eded8b --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/Sidebar/Sidebar.stories.js @@ -0,0 +1,134 @@ +import { action } from '@storybook/addon-actions'; +import Sidebar from './Sidebar'; +import Thumbnail from 'dashboard/components/widgets/Thumbnail'; + +export default { + title: 'Components/Help Center/Sidebar', + component: { Sidebar, Thumbnail }, + argTypes: { + thumbnailSrc: { + defaultValue: '', + control: { + type: 'text', + }, + }, + headerTitle: { + defaultValue: '', + control: { + type: 'text', + }, + }, + subTitle: { + defaultValue: '', + control: { + type: 'text', + }, + }, + accessibleMenuItems: [], + additionalSecondaryMenuItems: [], + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { Sidebar }, + template: '', +}); + +export const HelpCenterSidebarView = Template.bind({}); +HelpCenterSidebarView.args = { + onSearch: action('search'), + thumbnailSrc: '', + headerTitle: 'Help Center', + subTitle: 'English', + accessibleMenuItems: [ + { + icon: 'book', + label: 'HELP_CENTER.ALL_ARTICLES', + key: 'helpcenter_all', + count: 199, + toState: 'accounts/1/articles/all', + toolTip: 'All Articles', + toStateName: 'helpcenter_all', + }, + { + icon: 'pen', + label: 'HELP_CENTER.MY_ARTICLES', + key: 'helpcenter_mine', + count: 112, + toState: 'accounts/1/articles/mine', + toolTip: 'My articles', + toStateName: 'helpcenter_mine', + }, + { + icon: 'draft', + label: 'HELP_CENTER.DRAFT', + key: 'helpcenter_draft', + count: 32, + toState: 'accounts/1/articles/draft', + toolTip: 'Draft', + toStateName: 'helpcenter_draft', + }, + { + icon: 'archive', + label: 'HELP_CENTER.ARCHIVED', + key: 'helpcenter_archive', + count: 10, + toState: 'accounts/1/articles/archived', + toolTip: 'Archived', + toStateName: 'helpcenter_archive', + }, + ], + additionalSecondaryMenuItems: [ + { + icon: 'folder', + label: 'HELP_CENTER.CATEGORY', + hasSubMenu: true, + key: 'category', + children: [ + { + id: 1, + label: 'Getting started', + count: 12, + truncateLabel: true, + toState: 'accounts/1/articles/categories/new', + }, + { + id: 2, + label: 'Channel', + count: 19, + truncateLabel: true, + toState: 'accounts/1/articles/categories/channel', + }, + { + id: 3, + label: 'Feature', + count: 24, + truncateLabel: true, + toState: 'accounts/1/articles/categories/feature', + }, + { + id: 4, + label: 'Advanced', + count: 8, + truncateLabel: true, + toState: 'accounts/1/articles/categories/advanced', + }, + { + id: 5, + label: 'Mobile app', + count: 3, + truncateLabel: true, + toState: 'accounts/1/articles/categories/mobile-app', + }, + { + id: 6, + label: 'Others', + count: 39, + truncateLabel: true, + toState: 'accounts/1/articles/categories/others', + }, + ], + }, + ], +}; diff --git a/app/javascript/dashboard/components/helpCenter/Sidebar/Sidebar.vue b/app/javascript/dashboard/components/helpCenter/Sidebar/Sidebar.vue new file mode 100644 index 000000000..2f8fdd2fd --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/Sidebar/Sidebar.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/app/javascript/dashboard/components/helpCenter/Sidebar/SidebarHeader.vue b/app/javascript/dashboard/components/helpCenter/Sidebar/SidebarHeader.vue new file mode 100644 index 000000000..a68ea6b63 --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/Sidebar/SidebarHeader.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/app/javascript/dashboard/components/helpCenter/Sidebar/SidebarSearch.vue b/app/javascript/dashboard/components/helpCenter/Sidebar/SidebarSearch.vue new file mode 100644 index 000000000..8af29af17 --- /dev/null +++ b/app/javascript/dashboard/components/helpCenter/Sidebar/SidebarSearch.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/app/javascript/dashboard/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index 6b3acd61a..74987263c 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -11,6 +11,7 @@ @open-notification-panel="openNotificationPanel" /> @@ -50,6 +52,12 @@ export default { SecondarySidebar, }, mixins: [adminMixin, alertMixin, eventListenerMixins], + props: { + showSecondarySidebar: { + type: Boolean, + default: true, + }, + }, data() { return { showOptionsMenu: false, @@ -63,6 +71,7 @@ export default { ...mapGetters({ currentUser: 'getCurrentUser', globalConfig: 'globalConfig/get', + isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', inboxes: 'inboxes/getInboxes', accountId: 'getCurrentAccountId', currentRole: 'getCurrentRole', @@ -194,6 +203,7 @@ export default { display: flex; min-height: 0; height: 100%; + width: fit-content; } diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js b/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js index 176fd152c..990b35d4a 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js @@ -18,6 +18,7 @@ const settings = accountId => ({ 'settings_integrations_webhook', 'settings_integrations_integration', 'settings_applications', + 'settings_integrations_dashboard_apps', 'settings_applications_webhook', 'settings_applications_integration', 'general_settings', @@ -29,6 +30,7 @@ const settings = accountId => ({ 'settings_teams_edit', 'settings_teams_edit_members', 'settings_teams_edit_finish', + 'billing_settings_index', 'automation_list', ], menuItems: [ @@ -99,6 +101,14 @@ const settings = accountId => ({ toState: frontendURL(`accounts/${accountId}/settings/applications`), toStateName: 'settings_applications', }, + { + icon: 'credit-card-person', + label: 'BILLING', + hasSubMenu: false, + toState: frontendURL(`accounts/${accountId}/settings/billing`), + toStateName: 'billing_settings_index', + showOnlyOnCloud: true, + }, { icon: 'settings', label: 'ACCOUNT_SETTINGS', diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue b/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue index 71382d5e6..60b85cb22 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue @@ -83,7 +83,7 @@ export default { border-bottom-right-radius: var(--border-radius-normal); display: flex; height: 100%; - justify-content: end; + justify-content: flex-end; opacity: 1; position: absolute; right: 0; diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue index 724576347..4f0a5e38d 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue @@ -55,6 +55,10 @@ export default { type: String, default: '', }, + isOnChatwootCloud: { + type: Boolean, + default: false, + }, }, computed: { hasSecondaryMenu() { @@ -67,12 +71,18 @@ export default { if (!this.currentRole) { return []; } - return this.menuConfig.menuItems.filter( + const menuItemsFilteredByRole = this.menuConfig.menuItems.filter( menuItem => window.roleWiseRoutes[this.currentRole].indexOf( menuItem.toStateName ) > -1 ); + return menuItemsFilteredByRole.filter(item => { + if (item.showOnlyOnCloud) { + return this.isOnChatwootCloud; + } + return true; + }); }, inboxSection() { return { @@ -95,6 +105,7 @@ export default { ), type: inbox.channel_type, phoneNumber: inbox.phone_number, + reauthorizationRequired: inbox.reauthorization_required, })) .sort((a, b) => a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1 diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue index 7bf57df1a..de10dda4a 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue @@ -26,10 +26,21 @@ :class="{ 'text-truncate': shouldTruncate }" > {{ label }} + + {{ childItemCount }} + {{ count }} + + + @@ -57,10 +68,22 @@ export default { type: String, default: '', }, + warningIcon: { + type: String, + default: '', + }, count: { type: String, default: '', }, + isHelpCenterSidebar: { + type: Boolean, + default: false, + }, + childItemCount: { + type: Number, + default: 0, + }, }, computed: { showIcon() { @@ -134,6 +157,7 @@ $label-badge-size: var(--space-slab); height: $label-badge-size; min-width: $label-badge-size; margin-left: var(--space-smaller); + border: 1px solid var(--color-border-light); } .badge.secondary { @@ -142,4 +166,19 @@ $label-badge-size: var(--space-slab); color: var(--s-600); font-weight: var(--font-weight-bold); } + +.count-view { + background: var(--s-50); + border-radius: var(--border-radius-normal); + color: var(--s-600); + font-size: var(--font-size-micro); + font-weight: var(--font-weight-bold); + margin-left: var(--space-smaller); + padding: var(--space-zero) var(--space-smaller); + + &.is-active { + background: var(--w-50); + color: var(--w-500); + } +} diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue index ffd8018d5..e89a1ab51 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue @@ -1,8 +1,14 @@ diff --git a/app/javascript/dashboard/components/ui/Label.vue b/app/javascript/dashboard/components/ui/Label.vue index 504f6fc9e..d4059d21c 100644 --- a/app/javascript/dashboard/components/ui/Label.vue +++ b/app/javascript/dashboard/components/ui/Label.vue @@ -1,13 +1,18 @@ diff --git a/app/javascript/dashboard/components/ui/Tabs/Tabs.js b/app/javascript/dashboard/components/ui/Tabs/Tabs.js index 87abd41f7..cae81b91f 100644 --- a/app/javascript/dashboard/components/ui/Tabs/Tabs.js +++ b/app/javascript/dashboard/components/ui/Tabs/Tabs.js @@ -5,8 +5,61 @@ export default { type: Number, default: 0, }, + border: { + type: Boolean, + default: true, + }, }, - render() { + data() { + return { hasScroll: false }; + }, + created() { + window.addEventListener('resize', this.computeScrollWidth); + }, + beforeDestroy() { + window.removeEventListener('resize', this.computeScrollWidth); + }, + mounted() { + this.computeScrollWidth(); + }, + methods: { + computeScrollWidth() { + const tabElement = this.$el.getElementsByClassName('tabs')[0]; + this.hasScroll = tabElement.scrollWidth > tabElement.clientWidth; + }, + onScrollClick(direction) { + const tabElement = this.$el.getElementsByClassName('tabs')[0]; + let scrollPosition = tabElement.scrollLeft; + if (direction === 'left') { + scrollPosition -= 100; + } else { + scrollPosition += 100; + } + tabElement.scrollTo({ + top: 0, + left: scrollPosition, + behavior: 'smooth', + }); + }, + createScrollButton(createElement, direction) { + if (!this.hasScroll) { + return false; + } + return createElement( + 'button', + { + class: 'tabs--scroll-button button clear secondary button--only-icon', + on: { click: () => this.onScrollClick(direction) }, + }, + [ + createElement('fluent-icon', { + props: { icon: `chevron-${direction}`, size: 16 }, + }), + ] + ); + }, + }, + render(createElement) { const Tabs = this.$slots.default .filter( node => @@ -18,14 +71,21 @@ export default { data.index = index; return node; }); + const leftButton = this.createScrollButton(createElement, 'left'); + const rightButton = this.createScrollButton(createElement, 'right'); return ( -
    - {Tabs} -
+ {leftButton} +
    + {Tabs} +
+ {rightButton} + ); }, }; diff --git a/app/javascript/dashboard/components/ui/WootButton.vue b/app/javascript/dashboard/components/ui/WootButton.vue index 3d08ec928..16cb240be 100644 --- a/app/javascript/dashboard/components/ui/WootButton.vue +++ b/app/javascript/dashboard/components/ui/WootButton.vue @@ -1,6 +1,7 @@ @@ -272,6 +304,10 @@ export default { } } +.conversation-selected { + background: var(--color-background-light); +} + .has-inbox-name { &::v-deep .user-thumbnail-box { margin-top: var(--space-normal); @@ -320,4 +356,22 @@ export default { margin-top: var(--space-minus-micro); vertical-align: middle; } +.checkbox-wrapper { + height: 40px; + width: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 100%; + margin-top: var(--space-normal); + cursor: pointer; + &:hover { + background-color: var(--w-100); + } + + input[type='checkbox'] { + margin: var(--space-zero); + cursor: pointer; + } +} diff --git a/app/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue index e10573fe3..adead17cb 100644 --- a/app/javascript/dashboard/components/widgets/conversation/Message.vue +++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue @@ -66,7 +66,7 @@
- - - + />
    @@ -160,7 +151,6 @@ export default { hasSelectedTweetId() { return !!this.selectedTweetId; }, - tweetBannerText() { return !this.selectedTweetId ? this.$t('CONVERSATION.SELECT_A_TWEET_TO_REPLY') @@ -238,12 +228,6 @@ export default { } return ''; }, - facebookReplyPolicy() { - return REPLY_POLICY.FACEBOOK; - }, - twilioWhatsAppReplyPolicy() { - return REPLY_POLICY.TWILIO_WHATSAPP; - }, isRightOrLeftIcon() { if (this.isContactPanelOpen) { return 'arrow-chevron-right'; @@ -255,6 +239,41 @@ export default { const { contact_last_seen_at: contactLastSeenAt } = this.currentChat; return contactLastSeenAt; }, + + replyWindowBannerMessage() { + if (this.isAWhatsappChannel) { + return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY'); + } + if (this.isAPIInbox) { + const { additional_attributes: additionalAttributes = {} } = this.inbox; + if (additionalAttributes) { + const { + agent_reply_time_window_message: agentReplyTimeWindowMessage, + } = additionalAttributes; + return agentReplyTimeWindowMessage; + } + return ''; + } + return this.$t('CONVERSATION.CANNOT_REPLY'); + }, + replyWindowLink() { + if (this.isAWhatsappChannel) { + return REPLY_POLICY.FACEBOOK; + } + if (!this.isAPIInbox) { + return REPLY_POLICY.TWILIO_WHATSAPP; + } + return ''; + }, + replyWindowLinkText() { + if (this.isAWhatsappChannel) { + return this.$t('CONVERSATION.24_HOURS_WINDOW'); + } + if (!this.isAPIInbox) { + return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW'); + } + return ''; + }, }, watch: { diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 9ecb8d15c..4df0891e4 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -101,7 +101,7 @@ :toggle-audio-recorder="toggleAudioRecorder" :toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause" :show-emoji-picker="showEmojiPicker" - :on-send="sendMessage" + :on-send="onSendReply" :is-send-disabled="isReplyButtonDisabled" :recording-audio-duration-text="recordingAudioDurationText" :recording-audio-state="recordingAudioState" @@ -112,7 +112,16 @@ :enable-rich-editor="isRichEditorEnabled" :enter-to-send-enabled="enterToSendEnabled" :enable-multiple-file-upload="enableMultipleFileUpload" + :has-whatsapp-templates="hasWhatsappTemplates" @toggleEnterToSend="toggleEnterToSend" + @selectWhatsappTemplate="openWhatsappTemplateModal" + /> +
@@ -137,7 +146,7 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages'; import { BUS_EVENTS } from 'shared/constants/busEvents'; - +import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import { isEscape, isEnter, @@ -162,6 +171,7 @@ export default { WootMessageEditor, WootAudioRecorder, Banner, + WhatsappTemplates, }, mixins: [ clickaway, @@ -201,6 +211,7 @@ export default { hasSlashCommand: false, bccEmails: '', ccEmails: '', + showWhatsAppTemplatesModal: false, }; }, computed: { @@ -212,7 +223,6 @@ export default { globalConfig: 'globalConfig/get', accountId: 'getCurrentAccountId', }), - showRichContentEditor() { if (this.isOnPrivateNote) { return true; @@ -256,7 +266,10 @@ export default { return false; }, - + hasWhatsappTemplates() { + return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId) + .length; + }, enterToSendEnabled() { return !!this.uiSettings.enter_to_send_enabled; }, @@ -484,7 +497,7 @@ export default { hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused; if (shouldSendMessage) { e.preventDefault(); - this.sendMessage(); + this.onSendReply(); } } else if (hasPressedCommandPlusKKey(e)) { this.openCommandBar(); @@ -497,6 +510,12 @@ export default { toggleEnterToSend(enterToSendEnabled) { this.updateUISettings({ enter_to_send_enabled: enterToSendEnabled }); }, + openWhatsappTemplateModal() { + this.showWhatsAppTemplatesModal = true; + }, + hideWhatsappTemplatesModal() { + this.showWhatsAppTemplatesModal = false; + }, onClickSelfAssign() { const { account_id, @@ -520,7 +539,7 @@ export default { }; this.assignedAgent = selfAssign; }, - async sendMessage() { + async onSendReply() { if (this.isReplyButtonDisabled) { return; } @@ -531,22 +550,31 @@ export default { } const messagePayload = this.getMessagePayload(newMessage); this.clearMessage(); - try { - await this.$store.dispatch( - 'createPendingMessageAndSend', - messagePayload - ); - bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE); - } catch (error) { - const errorMessage = - error?.response?.data?.error || - this.$t('CONVERSATION.MESSAGE_ERROR'); - this.showAlert(errorMessage); - } + this.sendMessage(messagePayload); this.hideEmojiPicker(); this.$emit('update:popoutReplyBox', false); } }, + async sendMessage(messagePayload) { + try { + await this.$store.dispatch( + 'createPendingMessageAndSend', + messagePayload + ); + bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE); + } catch (error) { + const errorMessage = + error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR'); + this.showAlert(errorMessage); + } + }, + async onSendWhatsAppReply(messagePayload) { + this.sendMessage({ + conversationId: this.currentChat.id, + ...messagePayload, + }); + this.hideWhatsappTemplatesModal(); + }, replaceText(message) { setTimeout(() => { this.message = message; diff --git a/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue new file mode 100644 index 000000000..1c888408b --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue new file mode 100644 index 000000000..985c62138 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue @@ -0,0 +1,173 @@ + diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactCustomAttributes.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactCustomAttributes.vue index a9a809385..bf3ee8140 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactCustomAttributes.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactCustomAttributes.vue @@ -9,9 +9,7 @@ {{ attribute }}
- +

diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue index c2987fe9c..e19d2990d 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue @@ -4,7 +4,7 @@ {{ title }} - +

diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue index 294f2b6ba..4405ed2ad 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue @@ -9,6 +9,7 @@ - + />
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationView.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationView.vue index 5f8d3b6f8..13515ac21 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationView.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationView.vue @@ -14,8 +14,7 @@ :inbox-id="inboxId" :is-contact-panel-open="isContactPanelOpen" @contact-panel-toggle="onToggleContactPanel" - > - + /> diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue index 11e0fb675..d3a45a629 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue @@ -1,5 +1,18 @@