Compare commits

..
Author SHA1 Message Date
Shivam Mishra 29415571e9 fix: specs for labels and inboxes 2023-04-06 20:18:51 +05:30
Shivam Mishra 7b2e581cd3 fix: specs failing 2023-04-06 20:04:17 +05:30
1439 changed files with 7392 additions and 24544 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build
docker:
# specify the version you desire here
- image: cimg/ruby:3.2.2-browsers
- image: cimg/ruby:3.1.3-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
+1 -1
View File
@@ -30,7 +30,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
npm
# Install rbenv and ruby
ARG RUBY_VERSION="3.2.2"
ARG RUBY_VERSION="3.1.3"
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
+2
View File
@@ -1,6 +1,8 @@
.bundle
.env
.env.*
.git
.gitignore
docker-compose.*
docker/Dockerfile
docker/dockerfiles
+3 -2
View File
@@ -110,8 +110,6 @@ AWS_REGION=
RAILS_LOG_TO_STDOUT=true
LOG_LEVEL=info
LOG_SIZE=500
# Configure this environment variable if you want to use lograge instead of rails logger
#LOGRAGE_ENABLED=true
### This environment variables are only required if you are setting up social media channels
@@ -161,6 +159,9 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# for mobile apps
# FCM_SERVER_KEY=
## Bot Customizations
USE_INBOX_AVATAR_FOR_BOT=true
### APM and Error Monitoring configurations
## Elastic APM
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html
+78
View File
@@ -0,0 +1,78 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: 'Bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See the error
**Expected behavior**
Share a clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Browser logs**
Share the browser logs to debug the issue further.
**Server logs**
Share the server logs to debug the issue further.
**Environment**
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
- [ ] app.chatwoot.com (Chatwoot Cloud)
- [ ] Self-hosted
- - [ ] Linux VM
- - [ ] Docker
- - [ ] Kubernetes
- - [ ] Heroku
- - [ ] Other (Please specify)
**Desktop (please complete the following information)** (If applicable)
- OS: [e.g. Linux, Windows, MacOS]
- Browser [e.g. chrome, firefox, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information)** (If applicable)
- Device: [e.g. iPhone6, Pixel7]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, firefox, safari]
- Version [e.g. 22]
**Docker** (If applicable)
Please share the output of the following.
- `docker version`
- `docker info`
- `docker-compose version`
**Cloud Provider** (If applicable)
- [ ] AWS
- [ ] GCP
- [ ] Azure
- [ ] DigitalOcean
- [ ] Others
**Additional context**
Add any other context about the problem here.
-78
View File
@@ -1,78 +0,0 @@
name: 🐞 Bug report
description: Create a report to help us improve
labels: 'Bug'
body:
- type: textarea
attributes:
label: Describe the bug
description: A concise description of what you expected to happen along with screenshots if applicable.
validations:
required: true
- type: textarea
attributes:
label: To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
2. With this config...
3. Run '...'
4. See error...
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: A concise description of what you expected to happen.
- type: dropdown
id: environment
attributes:
label: Environment
description: Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
options:
- app.chatwoot.com
- Linux VM
- Docker
- Kubernetes
- Heroku
- Other [please specify in the description]
validations:
required: true
- type: dropdown
id: provider
attributes:
label: Cloud Provider
description:
options:
- AWS
- GCP
- Azure
- DigitalOcean
- Other [please specify in the description]
- type: dropdown
id: platform
attributes:
label: Platform
description: Describe the platform you are using
options:
- Browser
- Mobile
- type: input
attributes:
label: Operating system
description: The operating system and the version you are using.
- type: input
attributes:
label: Browser and version
description: The name of the browser and version you are using.
- type: textarea
attributes:
label: Docker (if applicable)
description: |
Please share the output of the following.
- `docker version`
- `docker info`
- `docker-compose version`
- type: textarea
attributes:
label: Additional context
description: Add any other context about the problem here.
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Report a security issue
url: https://www.chatwoot.com/docs/contributing-guide/security-reports/
about: Guidelines and steps to report a security vulnerability. Please report security vulnerabilities here.
- name: Product Documentation
url: https://www.chatwoot.com/help-center
about: If you have questions, are confused, or just want to understand our product better, please check out our documentation.
@@ -0,0 +1,20 @@
---
name: Enhancement request
about: Suggest any enhancements for this project
title: ''
labels: ''
assignees: ''
---
**Is your enhancement request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions you've considered.
**Additional context**
Add any other context or screenshots about the enhancement request here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -1,28 +0,0 @@
name: 🧙 Feature request
description: Suggest an idea for this project
labels: 'feature-request'
body:
- type: textarea
attributes:
label: Is your feature or enhancement related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
+2 -10
View File
@@ -61,16 +61,8 @@ test/cypress/videos/*
/config/master.key
/config/*.enc
#ignore files under .vscode directory
.vscode
.vscode/settings.json
# yalc for local testing
.yalc
yalc.lock
/public/packs
/public/packs-test
/node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity
yalc.lock
-29
View File
@@ -71,10 +71,6 @@ Rails/ApplicationController:
- 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb'
Rails/FindEach:
Enabled: true
Include:
- 'app/**/*.rb'
Rails/CompactBlank:
Enabled: false
Rails/EnvironmentVariableAccess:
@@ -162,33 +158,8 @@ Rails/RenderInline:
Performance/CollectionLiteralInLoop:
Exclude:
- 'db/migrate/20210315101919_enable_email_channel.rb'
Rails/ThreeStateBooleanColumn:
Exclude:
- 'db/migrate/20200509044639_add_hide_input_flag_to_bot_config.rb'
- 'db/migrate/20200605130625_agent_away_message_to_auto_reply.rb'
- 'db/migrate/20200606132552_create_labels.rb'
- 'db/migrate/20201027135006_create_working_hours.rb'
- 'db/migrate/20210112174124_add_hmac_token_to_inbox.rb'
- 'db/migrate/20210114202310_create_teams.rb'
- 'db/migrate/20210212154240_add_request_for_email_on_channel_web_widget.rb'
- 'db/migrate/20210428135041_add_campaigns.rb'
- 'db/migrate/20210602182058_add_hmac_to_api_channel.rb'
- 'db/migrate/20210609133433_add_email_collect_to_inboxes.rb'
- 'db/migrate/20210618095823_add_csat_toggle_for_inbox.rb'
- 'db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb'
- 'db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb'
- 'db/migrate/20211109143122_add_tweet_enabled_flag_to_twitter_channel.rb'
- 'db/migrate/20211216110209_add_allow_messages_after_resolved_to_inbox.rb'
- 'db/migrate/20220116103902_add_open_ssl_verify_mode_to_channel_email.rb'
- 'db/migrate/20220216151613_add_open_all_day_to_working_hour.rb'
- 'db/migrate/20220511072655_add_archive_column_to_portal.rb'
- 'db/migrate/20230503101201_create_sla_policies.rb'
RSpec/IndexedLet:
Enabled: false
RSpec/NamedSubject:
Enabled: false
# we should bring this down
RSpec/MultipleMemoizedHelpers:
Max: 14
+1 -1
View File
@@ -1 +1 @@
3.2.2
3.1.3
-1
View File
@@ -1 +0,0 @@
/spec
+33 -42
View File
@@ -1,10 +1,10 @@
source 'https://rubygems.org'
ruby '3.2.2'
ruby '3.1.3'
##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 7'
gem 'rails', '~> 6.1', '>= 6.1.7.3'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -36,19 +36,19 @@ gem 'json_schemer'
# Rack middleware for blocking & throttling abusive requests
gem 'rack-attack'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down'
gem 'down', '~> 5.0'
# authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth'
# Prevent CSV injection
gem 'csv-safe'
# Support message translation
gem 'google-cloud-translate'
##-- for active storage --##
gem 'aws-sdk-s3', require: false
# original gem isn't maintained actively
# we wanted updated version of faraday which is a dependency for slack-ruby-client
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'azure-storage-blob', require: false
gem 'google-cloud-storage', require: false
gem 'image_processing'
gem 'image_processing', '~> 1.12.2'
##-- gems for database --#
gem 'groupdate'
@@ -62,13 +62,13 @@ gem 'activerecord-import'
gem 'dotenv-rails'
gem 'foreman'
gem 'puma'
gem 'webpacker'
gem 'webpacker', '~> 5.4', '>= 5.4.3'
# metrics on heroku
gem 'barnes'
##--- gems for authentication & authorization ---##
gem 'devise'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise-secure_password', '~> 2.0', git: 'https://github.com/chatwoot/devise-secure_password'
gem 'devise_token_auth'
# authorization
gem 'jwt'
@@ -81,23 +81,19 @@ gem 'administrate'
gem 'wisper', '2.0.0'
##--- gems for channels ---##
# TODO: bump up gem to 2.0
gem 'facebook-messenger'
gem 'line-bot-api'
gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty', '~> 0.1.5'
gem 'twitty'
# facebook client
gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.0.0'
gem 'slack-ruby-client'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2'
gem 'grpc'
# Translate integrations
# 'google-cloud-translate' gem depends on faraday 2.0 version
# this dependency breaks the slack-ruby-client gem
gem 'google-cloud-translate-v3'
gem 'google-cloud-dialogflow'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
@@ -112,9 +108,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', require: false
##-- background job processing --##
gem 'sidekiq'
gem 'sidekiq', '~> 6.4.2'
# We want cron jobs
gem 'sidekiq-cron'
gem 'sidekiq-cron', '~> 1.6', '>= 1.6.0'
##-- Push notification service --##
gem 'fcm'
@@ -133,10 +129,7 @@ gem 'procore-sift'
# parse email
gem 'email_reply_trimmer'
# TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10.
# and this gem hasn't been updated for a while.
gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot'
gem 'html2text'
# to calculate working hours
gem 'working_hours'
@@ -151,23 +144,12 @@ gem 'stripe'
## to populate db with sample data
gem 'faker'
# Include logrange conditionally in intializer using env variable
gem 'lograge', '~> 0.12.0', require: false
# Can remove this in rails 7
gem 'net-imap', require: false
gem 'net-pop', require: false
gem 'net-smtp', require: false
# worked with microsoft refresh token
gem 'omniauth-oauth2'
gem 'audited', '~> 5.3'
# need for google auth
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
### Gems required only in specific deployment environments ###
##############################################################
group :production do
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
end
@@ -176,7 +158,6 @@ group :development do
gem 'annotate'
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
gem 'web-console'
# used in swagger build
@@ -192,7 +173,7 @@ end
group :test do
# Cypress in rails.
gem 'cypress-on-rails'
gem 'cypress-on-rails', '~> 1.13', '>= 1.13.1'
# fast cleaning of database
gem 'database_cleaner'
# mock http calls
@@ -214,7 +195,7 @@ group :development, :test do
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails'
gem 'rspec-rails', '~> 5.0.3'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
@@ -225,3 +206,13 @@ group :development, :test do
gem 'spring'
gem 'spring-watcher-listen'
end
# worked with microsoft refresh token
gem 'omniauth-oauth2'
gem 'audited', '~> 5.2'
# need for google auth
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
+331 -357
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
# Variables
APP_NAME := chatwoot
RAILS_ENV ?= development
# Targets
setup:
gem install bundler
bundle install
yarn install
db_create:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
db_migrate:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:migrate
db_seed:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed
db:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare
console:
RAILS_ENV=$(RAILS_ENV) bundle exec rails console
server:
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
burn:
bundle && yarn
run:
overmind start -f Procfile.dev
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db console server burn docker run
+1 -1
View File
@@ -1,3 +1,3 @@
release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare && echo $SOURCE_VERSION > .git_sha
release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare
web: bundle exec rails ip_lookup:setup && bin/rails server -p $PORT -e $RAILS_ENV
worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq.yml
+1 -1
View File
@@ -1 +1 @@
2.17.0
2.2.0
+1 -1
View File
@@ -1 +1 @@
2.3.0
2.1.0
@@ -63,42 +63,28 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end
def conversation
@conversation ||= Conversation.where(
"additional_attributes ->> 'type' = 'instagram_direct_message'"
).find_by(conversation_params) || build_conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
contact_inbox_id: @contact_inbox.id
))
end
@@ -106,7 +92,10 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
contact_id: contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
}
end
+2 -2
View File
@@ -49,10 +49,10 @@ class Messages::MessageBuilder
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = []
cc_emails = @params[:cc_emails].gsub(/\s+/, '').split(',') if @params[:cc_emails].present?
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails].present?
bcc_emails = []
bcc_emails = @params[:bcc_emails].gsub(/\s+/, '').split(',') if @params[:bcc_emails].present?
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails].present?
all_email_addresses = cc_emails + bcc_emails
validate_email_addresses(all_email_addresses)
@@ -1,24 +1,16 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_article, except: [:index, :create, :attach_file, :reorder]
before_action :fetch_article, except: [:index, :create, :attach_file]
before_action :set_current_page, only: [:index]
def index
@portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
@articles = if list_params[:category_slug].present?
@all_articles.order_by_position.page(@current_page).per(50)
else
@all_articles.order_by_updated_at.page(@current_page)
end
@articles = @all_articles.order_by_updated_at.page(@current_page)
end
def show; end
def edit; end
def create
@article = @portal.articles.create!(article_params)
@article.associate_root_article(article_params[:associated_article_id])
@@ -26,6 +18,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
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) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
@@ -47,11 +43,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
render json: { file_url: url_for(file_blob) }
end
def reorder
Article.update_positions(params[:positions_hash])
head :ok
end
private
def fetch_article
@@ -6,8 +6,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rules = Current.account.automation_rules
end
def show; end
def create
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions]
@@ -30,6 +28,8 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def show; end
def update
ActiveRecord::Base.transaction do
automation_rule_update
@@ -6,21 +6,21 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
@campaigns = Current.account.campaigns
end
def show; end
def create
@campaign = Current.account.campaigns.create!(campaign_params)
end
def update
@campaign.update!(campaign_params)
end
def destroy
@campaign.destroy!
head :ok
end
def show; end
def update
@campaign.update!(campaign_params)
end
private
def campaign
@@ -9,8 +9,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
@categories = @portal.categories.search(params)
end
def show; end
def create
@category = @portal.categories.create!(category_params)
@category.related_categories << related_categories_records
@@ -19,6 +17,8 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
@category.save!
end
def show; end
def update
@category.update!(category_params)
@category.related_categories = related_categories_records if related_categories_records.any?
@@ -5,21 +5,21 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
@notes = @contact.notes.latest.includes(:user)
end
def show; end
def create
@note = @contact.notes.create!(note_params)
end
def update
@note.update(note_params)
end
def destroy
@note.destroy!
head :ok
end
def show; end
def update
@note.update(note_params)
end
private
def note
@@ -22,12 +22,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversations_count = result[:count]
end
def attachments
@attachments = @conversation.attachments
end
def show; end
def create
ActiveRecord::Base.transaction do
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
@@ -35,6 +29,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
end
def show; end
def filter
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations]
@@ -68,14 +64,13 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
end
def toggle_priority
@conversation.toggle_priority(params[:priority])
head :ok
end
def toggle_typing_status
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
typing_status_manager.toggle_typing_status
case params[:typing_status]
when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private])
when 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private])
end
head :ok
end
@@ -116,6 +111,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.update_assignee(@agent)
end
def trigger_typing_event(event, is_private)
user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)
end
def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
authorize @conversation.inbox, :show?
@@ -34,12 +34,9 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
end
def set_csat_survey_responses
base_query = Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
@csat_survey_responses = filtrate(base_query).filter_by_created_at(range)
.filter_by_assigned_agent_id(params[:user_ids])
.filter_by_inbox_id(params[:inbox_id])
.filter_by_team_id(params[:team_id])
.filter_by_rating(params[:rating])
@csat_survey_responses = filtrate(
Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
).filter_by_created_at(range).filter_by_assigned_agent_id(params[:user_ids])
end
def set_current_page_surveys
@@ -2,11 +2,6 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
before_action :fetch_inbox
before_action :current_agents_ids, only: [:create, :update]
def show
authorize @inbox, :show?
fetch_updated_agents
end
def create
authorize @inbox, :create?
ActiveRecord::Base.transaction do
@@ -15,6 +10,11 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
fetch_updated_agents
end
def show
authorize @inbox, :show?
fetch_updated_agents
end
def update
authorize @inbox, :update?
update_agents_list
@@ -114,13 +114,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
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,
:lock_to_single_conversation, :portal_id]
:lock_to_single_conversation]
end
def permitted_params(channel_attributes = [])
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
params.permit(
*inbox_attributes,
channel: [:type, *channel_attributes]
@@ -1,5 +1,5 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
before_action :fetch_hook, except: [:create]
before_action :fetch_hook, only: [:update, :destroy]
before_action :check_authorization
def create
@@ -10,10 +10,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
@hook.update!(permitted_params.slice(:status, :settings))
end
def process_event
render json: { message: @hook.process_event(params[:event]) }
end
def destroy
@hook.destroy!
head :ok
@@ -6,10 +6,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
@macros = Macro.with_visibility(current_user, params)
end
def show
head :not_found if @macro.nil?
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)
@@ -22,16 +18,8 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
@macro
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.save!
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
def show
head :not_found if @macro.nil?
end
def destroy
@@ -49,6 +37,18 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.save!
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
end
def execute
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
@@ -30,7 +30,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
ActiveRecord::Base.transaction do
@portal.update!(portal_params) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present?
process_attached_logo
rescue StandardError => e
Rails.logger.error e
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
@@ -14,11 +14,6 @@ class Api::V1::AccountsController < Api::BaseController
CustomExceptions::Account::UserErrors,
with: :render_error_response
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json
end
def create
@user, @account = AccountBuilder.new(
account_name: account_params[:account_name],
@@ -40,6 +35,11 @@ class Api::V1::AccountsController < Api::BaseController
render json: { cache_keys: get_cache_keys }, status: :ok
end
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json
end
def update
@account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
end
@@ -48,8 +48,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def message_finder_params
{
filter_internal_messages: true,
before: permitted_params[:before],
after: permitted_params[:after]
before: permitted_params[:before]
}
end
@@ -63,7 +62,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# timestamp parameter is used in create conversation method
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
params.permit(:id, :before, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
end
def set_message
@@ -1,7 +1,5 @@
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
include Api::V2::Accounts::ReportsHelper
include Api::V2::Accounts::HeatmapHelper
before_action :check_authorization
def index
@@ -36,9 +34,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
end
@@ -72,16 +67,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def current_summary_params
common_params.merge({
since: range[:current][:since],
until: range[:current][:until],
timezone_offset: params[:timezone_offset]
until: range[:current][:until]
})
end
def previous_summary_params
common_params.merge({
since: range[:previous][:since],
until: range[:previous][:until],
timezone_offset: params[:timezone_offset]
until: range[:previous][:until]
})
end
+1 -1
View File
@@ -3,7 +3,7 @@ class ApiController < ApplicationController
def index
render json: { version: Chatwoot.config[:version],
timestamp: Time.now.utc.to_fs(:db),
timestamp: Time.now.utc.to_formatted_s(:db),
queue_services: redis_status,
data_services: postgres_status }
end
+1 -2
View File
@@ -56,8 +56,7 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: 'v14.0',
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', '')
}
end
end
@@ -4,16 +4,6 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
skip_before_action :require_no_authentication, raise: false
skip_before_action :authenticate_user!, raise: false
def create
@user = User.find_by(email: params[:email])
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
end
def update
# params: reset_password_token, password, password_confirmation
original_token = params[:reset_password_token]
@@ -27,6 +17,16 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
end
end
def create
@user = User.find_by(email: params[:email])
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
end
private
def reset_password_and_confirmation(recoverable)
@@ -1,4 +1,4 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsController
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -1,4 +1,4 @@
class DeviseOverrides::TokenValidationsController < DeviseTokenAuth::TokenValidationsController
class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenValidationsController
def validate_token
# @resource will have been set by set_user_by_token concern
if @resource
@@ -1,12 +1,12 @@
class Platform::Api::V1::AccountsController < PlatformController
def show; end
def create
@resource = Account.create!(account_params)
update_resource_features
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
def show; end
def update
@resource.assign_attributes(account_params)
update_resource_features
@@ -6,14 +6,14 @@ class Platform::Api::V1::AgentBotsController < PlatformController
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
end
def show; end
def create
@resource = AgentBot.new(agent_bot_params)
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
def show; end
def update
@resource.update!(agent_bot_params)
end
@@ -5,8 +5,6 @@ class Platform::Api::V1::UsersController < PlatformController
before_action(only: [:login]) { set_resource }
before_action(only: [:login]) { validate_platform_app_permissible }
def show; end
def create
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
@resource.skip_confirmation!
@@ -18,6 +16,8 @@ class Platform::Api::V1::UsersController < PlatformController
render json: { url: @resource.generate_sso_link }
end
def show; end
def update
@resource.assign_attributes(user_update_params)
@@ -2,8 +2,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
before_action :contact_inbox, except: [:create]
before_action :process_hmac
def show; end
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ::ContactInboxWithContactBuilder.new(
@@ -13,6 +11,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
).perform
end
def show; end
def update
contact_identify_action = ContactIdentifyAction.new(
contact: @contact_inbox.contact,
@@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category, except: [:index, :show]
before_action :set_category, except: [:index]
before_action :set_article, only: [:show]
layout 'portal'
@@ -16,7 +16,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
private
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article = @category.articles.find(permitted_params[:id])
@article.increment_view_count
@parsed_content = render_article_content(@article.content)
end
@@ -39,7 +39,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def permitted_params
params.permit(:slug, :category_slug, :locale, :id, :article_slug)
params.permit(:slug, :category_slug, :locale, :id)
end
def render_article_content(content)
@@ -5,7 +5,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
def set_locale(&)
switch_locale_with_portal(&) if params[:locale].present?
switch_locale_with_article(&) if params[:article_slug].present?
end
def switch_locale_with_portal(&)
@@ -20,16 +19,4 @@ class Public::Api::V1::Portals::BaseController < PublicController
I18n.with_locale(@locale, &)
end
def switch_locale_with_article(&)
article = Article.find_by(slug: params[:article_slug])
@locale = if article.category.present?
article.category.locale
else
'en'
end
I18n.with_locale(@locale, &)
end
end
@@ -45,17 +45,6 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
def seed
Internal::SeedAccountJob.perform_later(requested_resource)
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
# rubocop:enable Rails/I18nLocaleTexts
end
def destroy
account = Account.find(params[:id])
DeleteObjectJob.perform_later(account) if account.present?
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
# rubocop:enable Rails/I18nLocaleTexts
end
end
+1 -1
View File
@@ -1,7 +1,7 @@
class SwaggerController < ApplicationController
def respond
if Rails.env.development? || Rails.env.test?
render inline: Rails.root.join('swagger', derived_path).read
render inline: File.read(Rails.root.join('swagger', derived_path))
else
head :not_found
end
@@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error e
redirect_to twitter_app_redirect_url
end
@@ -49,22 +49,10 @@ class Twitter::CallbacksController < Twitter::BaseController
twitter_access_token_secret: parsed_body['oauth_token_secret'],
profile_id: parsed_body['user_id']
)
inbox = account.inboxes.create!(
account.inboxes.create!(
name: parsed_body['screen_name'],
channel: twitter_profile
)
save_profile_image(inbox)
inbox
end
def save_profile_image(inbox)
response = twitter_client.user_show(screen_name: inbox.name)
return unless response.status.to_i == 200
parsed_user_profile = response.body
::Avatar::AvatarFromUrlJob.perform_later(inbox, parsed_user_profile['profile_image_url_https'])
end
def permitted_params
+1 -1
View File
@@ -41,7 +41,7 @@ class WidgetsController < ActionController::Base
source_id: @auth_token_params[:source_id]
)
@contact = @contact_inbox&.contact
@contact = @contact_inbox ? @contact_inbox.contact : nil
end
def build_contact
-3
View File
@@ -1,5 +1,2 @@
class AccountDrop < BaseDrop
def name
@obj.try(:name)
end
end
+4 -19
View File
@@ -5,8 +5,7 @@ class ConversationFinder
SORT_OPTIONS = {
latest: 'latest',
sort_on_created_at: 'sort_on_created_at',
last_user_message_at: 'last_user_message_at',
sort_on_priority: 'sort_on_priority'
last_user_message_at: 'last_user_message_at'
}.with_indifferent_access
# assumptions
@@ -54,10 +53,9 @@ class ConversationFinder
find_all_conversations
filter_by_status unless params[:q]
filter_by_team
filter_by_labels
filter_by_query
filter_by_source_id
filter_by_team if @team
filter_by_labels if params[:labels]
filter_by_query if params[:q]
end
def set_inboxes
@@ -108,8 +106,6 @@ class ConversationFinder
end
def filter_by_query
return unless params[:q]
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
.where(messages: { message_type: allowed_message_types }).includes(:messages)
@@ -124,24 +120,13 @@ class ConversationFinder
end
def filter_by_team
return unless @team
@conversations = @conversations.where(team: @team)
end
def filter_by_labels
return unless params[:labels]
@conversations = @conversations.tagged_with(params[:labels], any: true)
end
def filter_by_source_id
return unless params[:source_id]
@conversations = @conversations.joins(:contact_inbox)
@conversations = @conversations.where(contact_inboxes: { source_id: params[:source_id] })
end
def set_count_for_all_conversations
[
@conversations.assigned_to(current_user).count,
+1 -7
View File
@@ -7,18 +7,12 @@ class EmailChannelFinder
def perform
channel = nil
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
recipient_mails.each do |email|
normalized_email = normalize_email_with_plus_addressing(email)
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
break if channel.present?
end
channel
end
def recipient_mails
recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
recipient_addresses.flatten.compact
end
end
+3 -21
View File
@@ -22,29 +22,11 @@ class MessageFinder
def current_messages
if @params[:after].present? && @params[:before].present?
messages_between(@params[:after].to_i, @params[:before].to_i)
messages.reorder('created_at asc').where('id >= ? AND id < ?', @params[:after].to_i, @params[:before].to_i).limit(1000)
elsif @params[:before].present?
messages_before(@params[:before].to_i)
elsif @params[:after].present?
messages_after(@params[:after].to_i)
messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse
else
messages_latest
messages.reorder('created_at desc').limit(20).reverse
end
end
def messages_after(after_id)
messages.reorder('created_at asc').where('id > ?', after_id).limit(100)
end
def messages_before(before_id)
messages.reorder('created_at desc').where('id < ?', before_id).limit(20).reverse
end
def messages_between(after_id, before_id)
messages.reorder('created_at asc').where('id >= ? AND id < ?', after_id, before_id).limit(1000)
end
def messages_latest
messages.reorder('created_at desc').limit(20).reverse
end
end
@@ -1,103 +0,0 @@
module Api::V2::Accounts::HeatmapHelper
def generate_conversations_heatmap_report
timezone_data = generate_heatmap_data_for_timezone(params[:timezone_offset])
group_traffic_data(timezone_data)
end
private
def group_traffic_data(data)
# start with an empty array
result_arr = []
# pick all the unique dates from the data in ascending order
dates = data.pluck(:date).uniq.sort
# add the dates as the first row, leave an empty cell for the hour column
# e.g. [nil, '2023-01-01', '2023-1-02', '2023-01-03']
result_arr << ([nil] + dates)
# group the data by hour, we do not need to sort it, because the data is already sorted
# given it starts from the beginning of the day
# here each hour is a key, and the value is an array of all the items for that hour at each date
# e.g. hour = 1
# value = [{date: 2023-01-01, value: 1}, {date: 2023-01-02, value: 1}, {date: 2023-01-03, value: 1}, ...]
data.group_by { |d| d[:hour] }.each do |hour, items|
# create a new row for each hour
row = [hour]
# group the items by date, so we can easily access the value for each date
# grouped values will be a hasg with the date as the key, and the value as the value
# e.g. { '2023-01-01' => [{date: 2023-01-01, value: 1}], '2023-01-02' => [{date: 2023-01-02, value: 1}], ... }
grouped_values = items.group_by { |d| d[:date] }
# now for each unique date we have, we can access the value for that date and append it to the array
dates.each do |date|
row << (grouped_values[date][0][:value] if grouped_values[date].is_a?(Array))
end
# row will look like [22, 0, 0, 1, 4, 6, 7, 4]
# add the row to the result array
result_arr << row
end
# return the resultant array
# the result looks like this
# [
# [nil, '2023-01-01', '2023-1-02', '2023-01-03'],
# [0, 0, 0, 0],
# [1, 0, 0, 0],
# [2, 0, 0, 0],
# [3, 0, 0, 0],
# [4, 0, 0, 0],
# ]
result_arr
end
def generate_heatmap_data_for_timezone(offset)
timezone = ActiveSupport::TimeZone[offset]&.name
timezone_today = DateTime.now.in_time_zone(timezone).beginning_of_day
timezone_data_raw = generate_heatmap_data(timezone_today, offset)
transform_data(timezone_data_raw, false)
end
def generate_heatmap_data(date, offset)
report_params = {
type: :account,
group_by: 'hour',
metric: 'conversations_count',
business_hours: false
}
V2::ReportBuilder.new(Current.account, report_params.merge({
since: since_timestamp(date),
until: until_timestamp(date),
timezone_offset: offset
})).build
end
def transform_data(data, zone_transform)
# rubocop:disable Rails/TimeZone
data.map do |d|
date = zone_transform ? Time.zone.at(d[:timestamp]) : Time.at(d[:timestamp])
{
date: date.to_date.to_s,
hour: date.hour,
value: d[:value]
}
end
# rubocop:enable Rails/TimeZone
end
def since_timestamp(date)
(date - 6.days).to_i.to_s
end
def until_timestamp(date)
date.to_i.to_s
end
end
@@ -27,6 +27,27 @@ module Api::V2::Accounts::ReportsHelper
end
end
def generate_conversations_heatmap_report
report_params = {
type: :account,
group_by: 'hour',
since: params[:since],
until: params[:until],
metric: 'conversations_count',
business_hours: false
}
data = V2::ReportBuilder.new(Current.account, report_params).build
# data format is { timestamp: 1231242342, value: 3}
# we need to convert it to { date: "2020-01-01", hour: 12, value: 3}
#
# the generated report is **always** in UTC timezone
data.map do |d|
date = Time.zone.at(d[:timestamp]).to_s
[date, d[:value]]
end
end
def generate_report(report_params)
V2::ReportBuilder.new(
Current.account,
+1 -1
View File
@@ -7,7 +7,7 @@ module DateRangeHelper
def range
return if params[:since].blank? || params[:until].blank?
parse_date_time(params[:since])...parse_date_time(params[:until])
parse_date_time(params[:since])..parse_date_time(params[:until])
end
def parse_date_time(datetime)
-22
View File
@@ -10,26 +10,4 @@ module EmailHelper
def normalize_email_with_plus_addressing(email)
"#{email.split('@').first.split('+').first}@#{email.split('@').last}".downcase
end
def parse_email_variables(conversation, email)
case email
when modified_liquid_content(email)
template = Liquid::Template.parse(modified_liquid_content(email))
template.render(message_drops(conversation))
when URI::MailTo::EMAIL_REGEXP
email
end
end
def modified_liquid_content(email)
# This regex is used to match the code blocks in the content
# We don't want to process liquid in code blocks
email.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
end
def message_drops(conversation)
{
'contact' => ContactDrop.new(conversation.contact)
}
end
end
-16
View File
@@ -1,16 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class AuditLogs extends ApiClient {
constructor() {
super('audit_logs', { accountScoped: true });
}
get({ page }) {
const url = page ? `${this.url}?page=${page}` : this.url;
return axios.get(url);
}
}
export default new AuditLogs();
+4 -11
View File
@@ -6,7 +6,7 @@ class CSATReportsAPI extends ApiClient {
super('csat_survey_responses', { accountScoped: true });
}
get({ page, from, to, user_ids, inbox_id, team_id, rating } = {}) {
get({ page, from, to, user_ids } = {}) {
return axios.get(this.url, {
params: {
page,
@@ -14,31 +14,24 @@ class CSATReportsAPI extends ApiClient {
until: to,
sort: '-created_at',
user_ids,
inbox_id,
team_id,
rating,
},
});
}
download({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
download({ from, to, user_ids } = {}) {
return axios.get(`${this.url}/download`, {
params: {
since: from,
until: to,
sort: '-created_at',
user_ids,
inbox_id,
team_id,
rating,
},
});
}
getMetrics({ from, to, user_ids, inbox_id, team_id } = {}) {
// no ratings for metrics
getMetrics({ from, to, user_ids } = {}) {
return axios.get(`${this.url}/metrics`, {
params: { since: from, until: to, user_ids, inbox_id, team_id },
params: { since: from, until: to, user_ids },
});
}
}
@@ -60,13 +60,6 @@ class ArticlesAPI extends PortalsAPI {
}
);
}
reorderArticles({ portalSlug, reorderedGroup, categorySlug }) {
return axios.post(`${this.url}/${portalSlug}/articles/reorder`, {
positions_hash: reorderedGroup,
category_slug: categorySlug,
});
}
}
export default new ArticlesAPI();
@@ -14,7 +14,6 @@ class ConversationApi extends ApiClient {
labels,
teamId,
conversationType,
sortBy,
}) {
return axios.get(this.url, {
params: {
@@ -25,7 +24,6 @@ class ConversationApi extends ApiClient {
page,
labels,
conversation_type: conversationType,
sort_by: sortBy,
},
});
}
@@ -54,12 +52,6 @@ class ConversationApi extends ApiClient {
});
}
togglePriority({ conversationId, priority }) {
return axios.post(`${this.url}/${conversationId}/toggle_priority`, {
priority,
});
}
assignAgent({ conversationId, agentId }) {
return axios.post(
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
@@ -1,31 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class OpenAIAPI extends ApiClient {
constructor() {
super('integrations', { accountScoped: true });
}
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
let data = {
tone,
content,
};
if (type === 'reply_suggestion' || type === 'summarize') {
data = {
conversation_display_id: conversationId,
};
}
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
event: {
name: type,
data,
},
});
}
}
export default new OpenAIAPI();
+2 -3
View File
@@ -40,7 +40,6 @@ class ReportsAPI extends ApiClient {
id,
group_by: groupBy,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -60,9 +59,9 @@ class ReportsAPI extends ApiClient {
});
}
getConversationTrafficCSV() {
getConversationTrafficCSV({ from: since, to: until }) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset() },
params: { since, until },
});
}
-24
View File
@@ -13,30 +13,6 @@ class SearchAPI extends ApiClient {
},
});
}
contacts({ q }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
},
});
}
conversations({ q }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
},
});
}
messages({ q }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
},
});
}
}
export default new SearchAPI();
@@ -42,13 +42,9 @@ describe('#Reports API', () => {
'/api/v2/reports/summary',
{
params: {
business_hours: undefined,
group_by: undefined,
id: undefined,
since: 1621103400,
timezone_offset: -0,
type: 'account',
until: 1621621800,
type: 'account',
},
}
);
+1 -1
View File
@@ -1,5 +1,5 @@
/* global axios */
import wootConstants from 'dashboard/constants/globals';
import wootConstants from 'dashboard/constants';
export const getTestimonialContent = () => {
return axios.get(wootConstants.TESTIMONIAL_URL);
@@ -5,22 +5,14 @@
}
.date-picker {
&.no-margin {
.mx-input {
margin-bottom: 0;
}
}
&:not(.auto-width) {
.mx-datepicker-range {
width: 320px;
}
}
.mx-datepicker {
width: 100%;
}
.mx-datepicker-range {
width: 320px;
}
.mx-input {
border: 1px solid var(--s-200);
border-radius: var(--border-radius-normal);
@@ -254,7 +254,7 @@ $button-sizes: (tiny: var(--font-size-micro),
default: var(--font-size-small),
large: var(--font-size-medium));
$button-palette: $foundation-palette;
$button-opacity-disabled: 0.4;
$button-opacity-disabled: 0.25;
$button-background-hover-lightness: -20%;
$button-hollow-hover-lightness: -50%;
$button-transition: background-color 0.25s ease-out,
@@ -85,7 +85,7 @@
}
}
.header-section.back-button {
.settings.back-button {
direction: initial;
margin-left: var(--space-normal);
margin-right: var(--space-smaller);
@@ -261,12 +261,6 @@
}
}
// Basic filter dropdown
.basic-filter {
left: 0;
right: unset;
}
// Card label
.label-container {
.label {
@@ -13,9 +13,7 @@
}
.multiselect {
&:not(.no-margin) {
margin-bottom: var(--space-normal);
}
margin-bottom: var(--space-normal);
&.multiselect--disabled {
opacity: 0.8;
@@ -23,7 +21,7 @@
.multiselect--active {
>.multiselect__tags {
border-color: var(--w-500);
border-color: $color-woot;
}
}
@@ -77,13 +75,16 @@
}
&.multiselect__option--selected {
background: var(--w-75);
background: var(--w-400);
color: var(--white);
&.multiselect__option--highlight:hover {
background: var(--w-75);
background: var(--w-600);
color: var(--white);
&::after {
background: transparent;
color: var(--white);
}
&::after:hover {
@@ -195,7 +196,6 @@
display: flex;
font-size: var(--font-size-small);
margin: 0;
max-height: 3.8rem;
padding: var(--space-smaller) var(--space-micro);
}
@@ -95,6 +95,10 @@
align-items: center;
justify-content: space-between;
padding: 0 var(--space-normal);
.page-title {
margin-bottom: 0;
}
}
.content-box {
+20 -103
View File
@@ -11,23 +11,15 @@
class="chat-list__top"
:class="{ filter__applied: hasAppliedFiltersOrActiveFolders }"
>
<div class="flex-center chat-list__title">
<h1
class="page-sub-title text-truncate margin-bottom-0"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="conversation--status-pill"
>
{{
this.$t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`)
}}
</span>
</div>
<h1 class="page-title text-truncate" :title="pageTitle">
{{ pageTitle }}
</h1>
<div class="filter--actions">
<chat-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@statusFilterChange="updateStatusType"
/>
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
@@ -56,6 +48,7 @@
@click="onClickOpenDeleteFoldersModal"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
@@ -65,10 +58,6 @@
size="tiny"
@click="onToggleAdvanceFiltersModal"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
@@ -136,7 +125,6 @@
@update-conversation-status="toggleConversationStatus"
@context-menu-toggle="onContextMenuToggle"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
<div v-if="chatListLoading" class="text-center">
@@ -178,14 +166,14 @@
<script>
import { mapGetters } from 'vuex';
import ChatFilter from './widgets/conversation/ChatFilter';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter';
import ChatTypeTabs from './widgets/ChatTypeTabs';
import ConversationCard from './widgets/conversation/ConversationCard';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from 'dashboard/constants/globals';
import wootConstants from '../constants';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
@@ -203,17 +191,16 @@ import {
isOnMentionsView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
export default {
components: {
AddCustomViews,
ChatTypeTabs,
ConversationCard,
ChatFilter,
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
ConversationBasicFilter,
},
mixins: [
timeMixin,
@@ -256,15 +243,11 @@ export default {
return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
activeStatus: wootConstants.STATUS_TYPE.OPEN,
activeSortBy: wootConstants.SORT_BY_TYPE.LATEST,
showAdvancedFilters: false,
advancedFilterTypes: advancedFilterTypes.map(filter => ({
...filter,
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
})),
// chatsOnView is to store the chats that are currently visible on the screen,
// which mirrors the conversationList.
chatsOnView: [],
foldersQuery: {},
showAddFoldersModal: false,
showDeleteFoldersModal: false,
@@ -364,40 +347,18 @@ export default {
this.currentPageFilterKey
);
},
activeAssigneeTabCount() {
const { activeAssigneeTab } = this;
const count = this.assigneeTabItems.find(
item => item.key === activeAssigneeTab
).count;
return count;
},
conversationFilters() {
return {
inboxId: this.conversationInbox ? this.conversationInbox : undefined,
assigneeType: this.activeAssigneeTab,
status: this.activeStatus,
sortBy: this.activeSortBy,
page: this.conversationListPagination,
page: this.currentPage + 1,
labels: this.label ? [this.label] : undefined,
teamId: this.teamId || undefined,
conversationType: this.conversationType || undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
};
},
conversationListPagination() {
const conversationsPerPage = 25;
const isNoFiltersOrFoldersAndChatListNotEmpty =
!this.hasAppliedFiltersOrActiveFolders && this.chatsOnView !== [];
const isUnderPerPage =
this.chatsOnView.length < conversationsPerPage &&
this.activeAssigneeTabCount < conversationsPerPage &&
this.activeAssigneeTabCount > this.chatsOnView.length;
if (isNoFiltersOrFoldersAndChatListNotEmpty && isUnderPerPage) {
return 1;
}
return this.currentPage + 1;
},
pageTitle() {
if (this.hasAppliedFilters) {
return this.$t('CHAT_LIST.TAB_HEADING');
@@ -439,6 +400,7 @@ export default {
} else {
conversationList = [...this.chatLists];
}
return conversationList;
},
activeFolder() {
@@ -487,13 +449,9 @@ export default {
this.resetAndFetchData();
}
},
chatLists() {
this.chatsOnView = this.conversationList;
},
},
mounted() {
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
this.$store.dispatch('setChatFilter', this.activeStatus);
this.resetAndFetchData();
bus.$on('fetch_conversation_stats', () => {
@@ -639,13 +597,11 @@ export default {
this.selectedConversations = [];
this.selectedInboxes = [];
},
onBasicFilterChange(value, type) {
if (type === 'status') {
this.activeStatus = value;
} else {
this.activeSortBy = value;
updateStatusType(index) {
if (this.activeStatus !== index) {
this.activeStatus = index;
this.resetAndFetchData();
}
this.resetAndFetchData();
},
openLastSavedItemInFolder() {
const lastItemOfFolder = this.folders[this.folders.length - 1];
@@ -714,26 +670,6 @@ export default {
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
}
},
async assignPriority(priority, conversationId = null) {
this.$store.dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
this.$store
.dispatch('assignPriority', { conversationId, priority })
.then(() => {
this.$track(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
newValue: priority,
from: 'Context menu',
});
this.showAlert(
this.$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority,
conversationId,
})
);
});
},
async markAsUnread(conversationId) {
try {
await this.$store.dispatch('markMessagesUnread', {
@@ -894,15 +830,11 @@ export default {
&.list--full-width {
flex-basis: 100%;
}
.page-sub-title {
font-size: var(--font-size-two);
}
}
.filter--actions {
display: flex;
align-items: center;
gap: var(--space-smaller);
gap: var(--space-micro);
}
.filter__applied {
@@ -919,19 +851,4 @@ export default {
}
}
}
.conversation--status-pill {
background: var(--color-background);
border-radius: var(--border-radius-small);
color: var(--color-medium-gray);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
margin: var(--space-micro) var(--space-small) 0;
padding: var(--space-smaller);
text-transform: capitalize;
}
.chat-list__title {
max-width: 85%;
}
</style>
+6 -43
View File
@@ -1,23 +1,8 @@
<template>
<div class="code--container">
<div class="code--action-area">
<form
v-if="enableCodePen"
class="code--codeopen-form"
action="https://codepen.io/pen/define"
method="POST"
target="_blank"
>
<input type="hidden" name="data" :value="codepenScriptValue" />
<button type="submit" class="button secondary tiny">
{{ $t('COMPONENTS.CODE.CODEPEN') }}
</button>
</form>
<button class="button secondary tiny" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</button>
</div>
<button class="button small button--copy-code" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</button>
<highlightjs v-if="script" :language="lang" :code="script" />
</div>
</template>
@@ -36,24 +21,6 @@ export default {
type: String,
default: 'javascript',
},
enableCodePen: {
type: Boolean,
default: false,
},
codepenTitle: {
type: String,
default: 'Chatwoot Codepen',
},
},
computed: {
codepenScriptValue() {
const lang = this.lang === 'javascript' ? 'js' : this.lang;
return JSON.stringify({
title: this.codepenTitle,
private: true,
[lang]: this.script,
});
},
},
methods: {
async onCopy(e) {
@@ -70,14 +37,10 @@ export default {
position: relative;
text-align: left;
.code--action-area {
top: var(--space-small);
.button--copy-code {
margin-top: 0;
position: absolute;
right: var(--space-small);
}
.code--codeopen-form {
display: inline-block;
right: 0;
}
}
</style>
@@ -5,9 +5,7 @@
{{ title }}
</p>
<p class="sub-head">
<slot name="subTitle">
{{ subTitle }}
</slot>
{{ subTitle }}
</p>
<p v-if="note">
<span class="note">{{ $t('INBOX_MGMT.NOTE') }}</span>
@@ -12,8 +12,7 @@
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../helper/localStorage';
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
import { hasAnUpdateAvailable } from './versionCheckHelper';
@@ -126,7 +126,7 @@ import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.v
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import wootConstants from 'dashboard/constants/globals';
import wootConstants from '../../constants';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
@@ -13,7 +13,6 @@ import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import FeatureToggle from './widgets/FeatureToggle';
import HorizontalBar from './widgets/chart/HorizontalBarChart';
import Input from './widgets/forms/Input.vue';
import PhoneInput from './widgets/forms/PhoneInput.vue';
import Label from './ui/Label';
import LoadingState from './widgets/LoadingState';
import Modal from './Modal';
@@ -41,7 +40,6 @@ const WootUIKit = {
FeatureToggle,
HorizontalBar,
Input,
PhoneInput,
Label,
LoadingState,
Modal,
@@ -52,9 +52,8 @@ import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge';
import wootConstants from 'dashboard/constants/globals';
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
const AVAILABILITY_STATUS_KEYS = ['online', 'busy', 'offline'];
export default {
components: {
@@ -8,7 +8,6 @@ const settings = accountId => ({
'agent_list',
'attributes_list',
'automation_list',
'auditlogs_list',
'billing_settings_index',
'canned_list',
'general_settings_index',
@@ -151,15 +150,6 @@ const settings = accountId => ({
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
{
icon: 'key',
label: 'AUDIT_LOGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
toStateName: 'auditlogs_list',
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
beta: true,
},
],
});
@@ -41,7 +41,7 @@ import PrimaryNavItem from './PrimaryNavItem';
import OptionsMenu from './OptionsMenu';
import AgentDetails from './AgentDetails';
import NotificationBell from './NotificationBell';
import wootConstants from 'dashboard/constants/globals';
import wootConstants from 'dashboard/constants';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
@@ -6,11 +6,7 @@
:disabled="isDisabled || isLoading"
@click="handleClick"
>
<spinner
v-if="isLoading"
size="small"
:color-scheme="showDarkSpinner ? 'dark' : ''"
/>
<spinner v-if="isLoading" size="small" />
<emoji-or-icon
v-else-if="icon || emoji"
class="icon"
@@ -112,14 +108,6 @@ export default {
return 16;
}
},
showDarkSpinner() {
return (
this.colorScheme === 'secondary' ||
this.variant === 'clear' ||
this.variant === 'link' ||
this.variant === 'hollow'
);
},
},
methods: {
handleClick(evt) {
@@ -1,216 +0,0 @@
<template>
<div v-if="isAIIntegrationEnabled" class="position-relative">
<div v-if="!message">
<woot-button
v-if="isPrivateNote"
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.SUMMARY_TITLE')"
icon="book-pulse"
color-scheme="secondary"
variant="smooth"
size="small"
:is-loading="uiFlags.summarize"
@click="processEvent('summarize')"
/>
<woot-button
v-else
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_TITLE')"
icon="wand"
color-scheme="secondary"
variant="smooth"
size="small"
:is-loading="uiFlags.reply_suggestion"
@click="processEvent('reply_suggestion')"
/>
</div>
<div v-else>
<woot-button
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
icon="text-grammar-wand"
color-scheme="secondary"
variant="smooth"
size="small"
@click="toggleDropdown"
/>
<div
v-if="showDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open ai-modal"
>
<h4 class="sub-block-title margin-top-1">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }}
</h4>
<p>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }}
</p>
<label>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }}
</label>
<div class="tone__item">
<select v-model="activeTone" class="status--filter small">
<option v-for="tone in tones" :key="tone.key" :value="tone.key">
{{ tone.value }}
</option>
</select>
</div>
<div class="modal-footer flex-container align-right">
<woot-button variant="clear" size="small" @click="closeDropdown">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }}
</woot-button>
<woot-button
:is-loading="uiFlags.rephrase"
size="small"
@click="processEvent('rephrase')"
>
{{ buttonText }}
</woot-button>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import OpenAPI from 'dashboard/api/integrations/openapi';
import alertMixin from 'shared/mixins/alertMixin';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
mixins: [alertMixin, clickaway],
props: {
conversationId: {
type: Number,
default: 0,
},
message: {
type: String,
default: '',
},
isPrivateNote: {
type: Boolean,
default: false,
},
},
data() {
return {
uiFlags: {
rephrase: false,
reply_suggestion: false,
summarize: false,
},
showDropdown: false,
activeTone: 'professional',
tones: [
{
key: 'professional',
value: this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.PROFESSIONAL'
),
},
{
key: 'friendly',
value: this.$t('INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.FRIENDLY'),
},
],
};
},
computed: {
...mapGetters({ appIntegrations: 'integrations/getAppIntegrations' }),
isAIIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
);
},
hookId() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
},
buttonText() {
return this.uiFlags.isRephrasing
? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING')
: this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE');
},
},
mounted() {
if (!this.appIntegrations.length) {
this.$store.dispatch('integrations/get');
}
},
methods: {
toggleDropdown() {
this.showDropdown = !this.showDropdown;
},
closeDropdown() {
this.showDropdown = false;
},
async recordAnalytics({ type, tone }) {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
this.$track(event, {
type,
tone,
});
}
},
async processEvent(type = 'rephrase') {
this.uiFlags[type] = true;
try {
const result = await OpenAPI.processEvent({
hookId: this.hookId,
type,
content: this.message,
tone: this.activeTone,
conversationId: this.conversationId,
});
const {
data: { message: generatedMessage },
} = result;
this.$emit('replace-text', generatedMessage || this.message);
this.closeDropdown();
this.recordAnalytics({ type, tone: this.activeTone });
} catch (error) {
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
} finally {
this.uiFlags[type] = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.ai-modal {
width: 400px;
right: 0;
left: 0;
padding: var(--space-normal);
bottom: 34px;
position: absolute;
span {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
p {
color: var(--s-600);
}
label {
margin-bottom: var(--space-smaller);
}
.status--filter {
background-color: var(--color-background-light);
border: 1px solid var(--color-border);
font-size: var(--font-size-small);
height: var(--space-large);
padding: 0 var(--space-medium) 0 var(--space-small);
}
.modal-footer {
gap: var(--space-smaller);
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<button class="header-section back-button" @click.capture="goBack">
<button class="settings back-button" @click.capture="goBack">
<fluent-icon icon="chevron-left" />
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button>
@@ -9,7 +9,7 @@
</woot-tabs>
</template>
<script>
import wootConstants from 'dashboard/constants/globals';
import wootConstants from '../../constants';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
@@ -1,5 +1,5 @@
<template>
<div v-if="hasOpenedAtleastOnce" class="dashboard-app--container">
<div class="dashboard-app--container">
<div
v-for="(configItem, index) in config"
:key="index"
@@ -35,14 +35,9 @@ export default {
type: Object,
default: () => ({}),
},
isVisible: {
type: Boolean,
default: false,
},
},
data() {
return {
hasOpenedAtleastOnce: false,
iframeLoading: true,
};
},
@@ -62,13 +57,6 @@ export default {
return { id, name, email };
},
},
watch: {
isVisible() {
if (this.isVisible) {
this.hasOpenedAtleastOnce = true;
}
},
},
mounted() {
window.onmessage = e => {
@@ -1,37 +0,0 @@
import InboxDropdownItem from './InboxDropdownItem';
export default {
title: 'Components/DropDowns/InboxDropdownItem',
component: InboxDropdownItem,
argTypes: {
name: {
defaultValue: 'My new inbox',
control: {
type: 'text',
},
},
inboxIdentifier: {
defaultValue: 'nithin@mail.com',
control: {
type: 'text',
},
},
channelType: {
defaultValue: 'email',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { InboxDropdownItem },
template: '<inbox-dropdown-item v-bind="$props" ></inbox-dropdown-item>',
});
export const Banner = Template.bind({});
Banner.args = {};
@@ -1,102 +0,0 @@
<template>
<div class="option-item--inbox">
<span class="badge--icon">
<fluent-icon :icon="computedInboxIcon" size="14" />
</span>
<div class="option__user-data">
<h5 class="option__title">
{{ name }}
</h5>
<p class="option__body text-truncate" :title="inboxIdentifier">
{{ inboxIdentifier || computedInboxType }}
</p>
</div>
</div>
</template>
<script>
import {
getInboxClassByType,
getReadableInboxByType,
} from 'dashboard/helper/inbox';
export default {
components: {},
props: {
name: {
type: String,
default: '',
},
inboxIdentifier: {
type: String,
default: '',
},
channelType: {
type: String,
default: '',
},
},
computed: {
computedInboxIcon() {
if (!this.channelType) return 'chat';
const classByType = getInboxClassByType(
this.channelType,
this.inboxIdentifier
);
return classByType;
},
computedInboxType() {
if (!this.channelType) return 'chat';
const classByType = getReadableInboxByType(
this.channelType,
this.inboxIdentifier
);
return classByType;
},
},
};
</script>
<style lang="scss" scoped>
.option-item--inbox {
display: flex;
align-items: center;
height: 3.8rem;
min-width: 0;
padding: 0 var(--space-smaller);
}
.badge--icon {
display: inline-flex;
border-radius: var(--border-radius-small);
margin-right: var(--space-smaller);
background: var(--s-25);
padding: var(--space-micro);
align-items: center;
flex-shrink: 0;
justify-content: center;
width: var(--space-medium);
height: var(--space-medium);
}
.option__user-data {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
margin-left: var(--space-smaller);
margin-right: var(--space-smaller);
}
.option__body {
display: inline-block;
color: var(--s-600);
font-size: var(--font-size-small);
line-height: 1.3;
min-width: 0;
margin: 0;
}
.option__title {
line-height: 1.1;
font-size: var(--font-size-mini);
margin: 0;
}
</style>
@@ -13,19 +13,15 @@ import videojs from 'video.js';
import alertMixin from '../../../../shared/mixins/alertMixin';
import Recorder from 'opus-recorder';
// Workers to record Audio .ogg and .wav
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
import waveWorker from 'opus-recorder/dist/waveWorker.min';
import WaveSurfer from 'wavesurfer.js';
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
import 'videojs-record/dist/videojs.record.js';
import OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
import { format, addSeconds } from 'date-fns';
import { AUDIO_FORMATS } from 'shared/constants/messages';
@@ -37,7 +33,7 @@ export default {
props: {
audioRecordFormat: {
type: String,
default: AUDIO_FORMATS.WAV,
default: AUDIO_FORMATS.WEBM,
},
},
data() {
@@ -83,18 +79,24 @@ export default {
maxLength: 900,
timeSlice: 1000,
maxFileSize: 15 * 1024 * 1024,
displayMilliseconds: false,
audioChannels: 1,
audioSampleRate: 48000,
audioBitRate: 128,
audioEngine: 'opus-recorder',
...(this.audioRecordFormat === AUDIO_FORMATS.WAV && {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
...(this.audioRecordFormat === AUDIO_FORMATS.WEBM && {
monitorGain: 0,
recordingGain: 1,
numberOfChannels: 1,
encoderSampleRate: 16000,
originalSampleRateOverride: 16000,
streamPages: true,
maxFramesPerPage: 1,
encoderFrameSize: 1,
encoderPath: waveWorker,
}),
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
audioMimeType: 'audio/ogg',
displayMilliseconds: false,
audioEngine: 'opus-recorder',
audioWorkerURL: encoderWorker,
audioChannels: 1,
audioSampleRate: 48000,
audioBitRate: 128,
}),
},
},
@@ -132,11 +134,6 @@ export default {
},
methods: {
deviceReady() {
if (this.player.record().engine instanceof OpusRecorderEngine) {
if (this.audioRecordFormat === AUDIO_FORMATS.WAV) {
this.player.record().engine.audioType = 'audio/wav';
}
}
this.player.record().start();
},
startRecord() {
@@ -49,7 +49,7 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import { replaceVariablesInMessage } from 'dashboard/helper/messageHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
const createState = (content, placeholder, plugins = []) => {
@@ -38,12 +38,12 @@
</file-upload>
<woot-button
v-if="showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
emoji="🎤"
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
variant="smooth"
size="small"
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
@click="toggleAudioRecorder"
/>
<woot-button
@@ -91,12 +91,6 @@
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
/>
<AIAssistanceButton
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@replace-text="replaceText"
/>
<transition name="modal-fade">
<div
v-show="$refs.upload && $refs.upload.dropActive"
@@ -135,13 +129,12 @@ import {
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
} from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { REPLY_EDITOR_MODES } from './constants';
import { mapGetters } from 'vuex';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton, AIAssistanceButton },
components: { FileUpload, VideoCallButton },
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin],
props: {
mode: {
@@ -224,10 +217,6 @@ export default {
type: Number,
required: true,
},
message: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
@@ -314,9 +303,6 @@ export default {
send_with_signature: !this.sendWithSignature,
});
},
replaceText(text) {
this.$emit('replace-text', text);
},
},
};
</script>
@@ -11,7 +11,7 @@
</template>
<script>
import wootConstants from 'dashboard/constants/globals';
import wootConstants from '../../../constants';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndBKey } from 'shared/helpers/KeyboardHelpers';
@@ -38,8 +38,8 @@ export default {
this.onTabChange();
},
onTabChange() {
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
this.$emit('onChangeFilter', this.activeStatus);
this.$store.dispatch('setChatFilter', this.activeStatus);
this.$emit('statusFilterChange', this.activeStatus);
},
},
};
@@ -1,122 +0,0 @@
<template>
<div class="position-relative">
<woot-button
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
variant="smooth"
size="tiny"
color-scheme="secondary"
class="selector-button"
icon="sort-icon"
@click="toggleDropdown"
/>
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open basic-filter"
>
<div class="filter__item">
<span>{{ this.$t('CHAT_LIST.CHAT_SORT.STATUS') }}</span>
<filter-item
type="status"
:selected-value="chatStatus"
:items="chatStatusItems"
path-prefix="CHAT_LIST.CHAT_STATUS_FILTER_ITEMS"
@onChangeFilter="onChangeFilter"
/>
</div>
<div class="filter__item">
<span>{{ this.$t('CHAT_LIST.CHAT_SORT.ORDER_BY') }}</span>
<filter-item
type="sort"
:selected-value="chatSortFilter"
:items="chatSortItems"
path-prefix="CHAT_LIST.CHAT_SORT_FILTER_ITEMS"
@onChangeFilter="onChangeFilter"
/>
</div>
</div>
</div>
</template>
<script>
import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import FilterItem from './FilterItem';
export default {
components: {
FilterItem,
},
mixins: [clickaway],
data() {
return {
showActionsDropdown: false,
chatStatusItems: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS'),
chatSortItems: this.$t('CHAT_LIST.CHAT_SORT_FILTER_ITEMS'),
};
},
computed: {
...mapGetters({
chatStatusFilter: 'getChatStatusFilter',
chatSortFilter: 'getChatSortFilter',
}),
chatStatus() {
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
},
sortFilter() {
return this.chatSortFilter || wootConstants.SORT_BY_TYPE.LATEST;
},
},
methods: {
onTabChange(value) {
this.$emit('changeFilter', value);
this.closeDropdown();
},
toggleDropdown() {
this.showActionsDropdown = !this.showActionsDropdown;
},
closeDropdown() {
this.showActionsDropdown = false;
},
onChangeFilter(type, value) {
this.$emit('changeFilter', type, value);
},
},
};
</script>
<style lang="scss" scoped>
.basic-filter {
margin-top: var(--space-smaller);
padding: var(--space-normal);
right: 0;
width: 21rem;
span {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
.filter__item {
align-items: center;
display: flex;
justify-content: space-between;
&:last-child {
margin-top: var(--space-normal);
}
span {
font-size: var(--font-size-mini);
}
}
}
.icon {
margin-right: var(--space-smaller);
}
.dropdown-icon {
margin-left: var(--space-smaller);
}
</style>
@@ -23,7 +23,7 @@
:show-badge="false"
/>
</woot-tabs>
<div v-show="!activeIndex" class="messages-and-sidebar">
<div v-if="!activeIndex" class="messages-and-sidebar">
<messages-view
v-if="currentChat.id"
:inbox-id="inboxId"
@@ -41,11 +41,9 @@
</div>
</div>
<dashboard-app-frame
v-for="(dashboardApp, index) in dashboardApps"
v-show="activeIndex - 1 === index"
:key="currentChat.id + '-' + dashboardApp.id"
:is-visible="activeIndex - 1 === index"
:config="dashboardApps[index].content"
v-else
:key="currentChat.id + '-' + activeIndex"
:config="dashboardApps[activeIndex - 1].content"
:current-chat="currentChat"
/>
</div>
@@ -114,7 +112,6 @@ export default {
},
'currentChat.id'() {
this.fetchLabels();
this.activeIndex = 0;
},
},
mounted() {
@@ -9,7 +9,7 @@
}"
@mouseenter="onCardHover"
@mouseleave="onCardLeave"
@click="onCardClick"
@click="cardClick(chat)"
@contextmenu="openContextMenu($event)"
>
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
@@ -33,16 +33,13 @@
<div class="conversation--details columns">
<div class="conversation--metadata">
<inbox-name v-if="showInboxName" :inbox="inbox" />
<div class="conversation-metadata-attributes">
<span
v-if="showAssignee && assignee.name"
class="label assignee-label text-truncate"
>
<fluent-icon icon="person" size="12" />
{{ assignee.name }}
</span>
<priority-mark :priority="chat.priority" />
</div>
<span
v-if="showAssignee && assignee.name"
class="label assignee-label text-truncate"
>
<fluent-icon icon="person" size="12" />
{{ assignee.name }}
</span>
</div>
<h4 class="conversation--user">
{{ currentContact.name }}
@@ -109,14 +106,12 @@
<conversation-context-menu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:has-unread-messages="hasUnread"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
</woot-context-menu>
</div>
@@ -136,7 +131,6 @@ import ConversationContextMenu from './contextMenu/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import TimeAgo from 'dashboard/components/ui/TimeAgo';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
const ATTACHMENT_ICONS = {
image: 'image',
audio: 'headphones-sound-wave',
@@ -153,7 +147,6 @@ export default {
Thumbnail,
ConversationContextMenu,
TimeAgo,
PriorityMark,
},
mixins: [
@@ -313,33 +306,21 @@ export default {
},
},
methods: {
onCardClick(e) {
const { activeInbox, chat } = this;
const path = frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
})
);
if (e.metaKey || e.ctrlKey) {
window.open(
window.chatwootConfig.hostURL + path,
'_blank',
'noopener noreferrer nofollow'
);
return;
}
cardClick(chat) {
const { activeInbox } = this;
const path = conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
});
if (this.isActiveChat) {
return;
}
router.push({ path });
router.push({ path: frontendURL(path) });
},
onCardHover() {
this.hovered = !this.hideThumbnail;
@@ -389,10 +370,6 @@ export default {
this.$emit('mark-as-unread', this.chat.id);
this.closeContextMenu();
},
async assignPriority(priority) {
this.$emit('assign-priority', priority, this.chat.id);
this.closeContextMenu();
},
},
};
</script>
@@ -452,14 +429,9 @@ export default {
padding: var(--space-micro) 0 var(--space-micro) 0;
}
.conversation-metadata-attributes {
display: flex;
gap: var(--space-small);
margin-left: var(--space-small);
}
.assignee-label {
display: inline-flex;
margin-left: var(--space-small);
max-width: 50%;
}
}
@@ -63,7 +63,7 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName';
import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import wootConstants from 'dashboard/constants/globals';
import wootConstants from '../../../constants';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
export default {
components: {
@@ -1,55 +0,0 @@
<template>
<select v-model="activeValue" class="status--filter" @change="onTabChange()">
<option v-for="(value, status) in items" :key="status" :value="status">
{{ $t(`${pathPrefix}.${status}.TEXT`) }}
</option>
</select>
</template>
<script>
export default {
props: {
selectedValue: {
type: String,
required: true,
},
items: {
type: Object,
required: true,
},
type: {
type: String,
required: true,
},
pathPrefix: {
type: String,
required: true,
},
},
data() {
return {
activeValue: this.selectedValue,
};
},
methods: {
onTabChange() {
if (this.type === 'status') {
this.$store.dispatch('setChatStatusFilter', this.activeValue);
} else {
this.$store.dispatch('setChatSortFilter', this.activeValue);
}
this.$emit('onChangeFilter', this.activeValue, this.type);
},
},
};
</script>
<style lang="scss" scoped>
.status--filter {
background-color: var(--color-background-light);
border: 1px solid var(--color-border);
font-size: var(--font-size-mini);
height: var(--space-medium);
margin: 0 var(--space-smaller);
padding: 0 var(--space-medium) 0 var(--space-small);
width: 126px;
}
</style>
@@ -22,14 +22,6 @@
:bcc="emailHeadAttributes.bcc"
:is-incoming="isIncoming"
/>
<blockquote v-if="storyReply" class="story-reply-quote">
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
<bubble-image
v-if="!hasStoryError"
:url="storyUrl"
@error="onStoryLoadError"
/>
</blockquote>
<bubble-text
v-if="data.content"
:message="message"
@@ -59,7 +51,7 @@
controls
class="skip-context-menu"
>
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
<source :src="attachment.data_url" />
</audio>
<bubble-video
v-else-if="attachment.file_type === 'video'"
@@ -87,7 +79,7 @@
:sender="data.sender"
:story-sender="storySender"
:external-error="externalError"
:story-id="`${storyId}`"
:story-id="storyId"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory"
@@ -200,7 +192,6 @@ export default {
hasImageError: false,
contextMenuPosition: {},
showBackgroundHighlight: false,
hasStoryError: false,
};
},
computed: {
@@ -286,12 +277,6 @@ export default {
storyId() {
return this.contentAttributes.story_id || null;
},
storyUrl() {
return this.contentAttributes.story_url || null;
},
storyReply() {
return this.storyUrl && this.hasInstagramStory;
},
contentType() {
const {
data: { content_type: contentType },
@@ -429,12 +414,10 @@ export default {
watch: {
data() {
this.hasImageError = false;
this.hasStoryError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasStoryError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
@@ -449,9 +432,6 @@ export default {
const { file_type: fileType } = attachments[0];
return fileType === type && !this.hasImageError;
}
if (this.storyReply) {
return true;
}
return false;
},
handleContextMenuClick() {
@@ -463,13 +443,10 @@ export default {
onImageLoadError() {
this.hasImageError = true;
},
onStoryLoadError() {
this.hasStoryError = true;
},
openContextMenu(e) {
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
e.target?.tagName.toLowerCase() === 'a';
const shouldSkipContextMenu = e.target?.classList.contains(
'skip-context-menu'
);
if (shouldSkipContextMenu || getSelection().toString()) {
return;
}
@@ -695,6 +672,7 @@ li.right {
blockquote {
border-left: var(--space-micro) solid var(--s-75);
color: var(--s-800);
padding: var(--space-smaller) var(--space-small);
margin: var(--space-smaller) 0;
padding: var(--space-small) var(--space-small) 0 var(--space-normal);
}
@@ -726,11 +704,4 @@ li.right {
}
}
}
.story-reply-quote {
border-left: var(--space-micro) solid var(--s-75);
color: var(--s-600);
margin: var(--space-small) var(--space-normal) 0;
padding: var(--space-small) var(--space-small) 0 var(--space-small);
}
</style>

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