From 1dc7ce526eda5410d14f1cb6e2fb9b68bede953e Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Tue, 19 Jul 2022 02:03:06 +0700 Subject: [PATCH 01/77] chore: Add ee helper, custom_attributes to account (#5058) --- .env.example | 4 ++++ Gemfile | 3 +++ Gemfile.lock | 4 +++- app/javascript/dashboard/api/ApiClient.js | 5 +++++ app/javascript/dashboard/store/modules/inboxes.js | 7 +------ app/javascript/dashboard/store/utils/api.js | 5 +++++ .../dashboard/store/utils/specs/api.spec.js | 12 ++++++++++++ app/models/account.rb | 1 + app/views/api/v1/accounts/show.json.jbuilder | 9 +-------- app/views/api/v1/accounts/update.json.jbuilder | 8 +------- app/views/api/v1/models/_account.json.jbuilder | 10 ++++++++++ config/initializers/stripe.rb | 3 +++ ...0220718123938_add_custom_attributes_to_account.rb | 5 +++++ db/schema.rb | 3 ++- 14 files changed, 56 insertions(+), 23 deletions(-) create mode 100644 app/views/api/v1/models/_account.json.jbuilder create mode 100644 config/initializers/stripe.rb create mode 100644 db/migrate/20220718123938_add_custom_attributes_to_account.rb 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/Gemfile b/Gemfile index 270e6527c..935db42e2 100644 --- a/Gemfile +++ b/Gemfile @@ -127,6 +127,9 @@ 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' diff --git a/Gemfile.lock b/Gemfile.lock index 7e0583757..17e021bf2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -616,6 +616,7 @@ GEM sprockets (>= 3.0.0) squasher (0.6.2) statsd-ruby (1.5.0) + stripe (6.5.0) telephone_number (1.4.16) thor (1.2.1) tilt (2.0.10) @@ -769,6 +770,7 @@ DEPENDENCIES spring spring-watcher-listen squasher + stripe telephone_number time_diff twilio-ruby (~> 5.66) @@ -787,4 +789,4 @@ RUBY VERSION ruby 3.0.4p208 BUNDLED WITH - 2.3.16 + 2.3.14 diff --git a/app/javascript/dashboard/api/ApiClient.js b/app/javascript/dashboard/api/ApiClient.js index 097caa906..25083dc99 100644 --- a/app/javascript/dashboard/api/ApiClient.js +++ b/app/javascript/dashboard/api/ApiClient.js @@ -15,6 +15,11 @@ class ApiClient { baseUrl() { let url = this.apiVersion; + + if (this.options.enterprise) { + url = `/enterprise${url}`; + } + if (this.options.accountScoped) { const isInsideAccountScopedURLs = window.location.pathname.includes( '/app/accounts' diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 7821e6c36..8d97f088a 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -5,7 +5,7 @@ import InboxesAPI from '../../api/inboxes'; import WebChannel from '../../api/channel/webChannel'; import FBChannel from '../../api/channel/fbChannel'; import TwilioChannel from '../../api/channel/twilioChannel'; -import { parseAPIErrorResponse } from '../utils/api'; +import { throwErrorMessage } from '../utils/api'; const buildInboxData = inboxParams => { const formData = new FormData(); @@ -43,11 +43,6 @@ export const state = { }, }; -const throwErrorMessage = error => { - const errorMessage = parseAPIErrorResponse(error); - throw new Error(errorMessage); -}; - export const getters = { getInboxes($state) { return $state.records; diff --git a/app/javascript/dashboard/store/utils/api.js b/app/javascript/dashboard/store/utils/api.js index 55e43fe2f..292ebca7e 100644 --- a/app/javascript/dashboard/store/utils/api.js +++ b/app/javascript/dashboard/store/utils/api.js @@ -55,3 +55,8 @@ export const parseAPIErrorResponse = error => { } return error; }; + +export const throwErrorMessage = error => { + const errorMessage = parseAPIErrorResponse(error); + throw new Error(errorMessage); +}; diff --git a/app/javascript/dashboard/store/utils/specs/api.spec.js b/app/javascript/dashboard/store/utils/specs/api.spec.js index b076b7be3..62a59dde5 100644 --- a/app/javascript/dashboard/store/utils/specs/api.spec.js +++ b/app/javascript/dashboard/store/utils/specs/api.spec.js @@ -2,6 +2,7 @@ import { getLoadingStatus, parseAPIErrorResponse, setLoadingStatus, + throwErrorMessage, } from '../api'; describe('#getLoadingStatus', () => { @@ -37,3 +38,14 @@ describe('#parseAPIErrorResponse', () => { ); }); }); + +describe('#throwErrorMessage', () => { + it('throws correct error', () => { + const errorFn = function throwErrorMessageFn() { + throwErrorMessage({ + response: { data: { message: 'Error Message [message]' } }, + }); + }; + expect(errorFn).toThrow('Error Message [message]'); + }); +}); diff --git a/app/models/account.rb b/app/models/account.rb index 5ece265bb..678cc3445 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -4,6 +4,7 @@ # # id :integer not null, primary key # auto_resolve_duration :integer +# custom_attributes :jsonb # domain :string(100) # feature_flags :integer default(0), not null # limits :jsonb diff --git a/app/views/api/v1/accounts/show.json.jbuilder b/app/views/api/v1/accounts/show.json.jbuilder index 39ca92955..2bbdda576 100644 --- a/app/views/api/v1/accounts/show.json.jbuilder +++ b/app/views/api/v1/accounts/show.json.jbuilder @@ -1,9 +1,2 @@ -json.id @account.id -json.name @account.name -json.locale @account.locale -json.domain @account.domain -json.custom_email_domain_enabled @account.custom_email_domain_enabled -json.support_email @account.support_email -json.features @account.all_features -json.auto_resolve_duration @account.auto_resolve_duration +json.partial! 'api/v1/models/account.json.jbuilder', resource: @account json.latest_chatwoot_version @latest_chatwoot_version diff --git a/app/views/api/v1/accounts/update.json.jbuilder b/app/views/api/v1/accounts/update.json.jbuilder index 68acc570a..c1905c972 100644 --- a/app/views/api/v1/accounts/update.json.jbuilder +++ b/app/views/api/v1/accounts/update.json.jbuilder @@ -1,7 +1 @@ -json.id @account.id -json.name @account.name -json.locale @account.locale -json.domain @account.domain -json.custom_email_domain_enabled @account.custom_email_domain_enabled -json.support_email @account.support_email -json.features @account.enabled_features +json.partial! 'api/v1/models/account.json.jbuilder', resource: @account diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder new file mode 100644 index 000000000..bc28f7a27 --- /dev/null +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -0,0 +1,10 @@ +json.auto_resolve_duration resource.auto_resolve_duration +json.created_at resource.created_at +json.custom_attributes resource.custom_attributes +json.custom_email_domain_enabled @account.custom_email_domain_enabled +json.domain @account.domain +json.features @account.enabled_features +json.id @account.id +json.locale @account.locale +json.name @account.name +json.support_email @account.support_email diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb new file mode 100644 index 000000000..585d9dad4 --- /dev/null +++ b/config/initializers/stripe.rb @@ -0,0 +1,3 @@ +require 'stripe' + +Stripe.api_key = ENV.fetch('STRIPE_SECRET_KEY', nil) diff --git a/db/migrate/20220718123938_add_custom_attributes_to_account.rb b/db/migrate/20220718123938_add_custom_attributes_to_account.rb new file mode 100644 index 000000000..39e9abcea --- /dev/null +++ b/db/migrate/20220718123938_add_custom_attributes_to_account.rb @@ -0,0 +1,5 @@ +class AddCustomAttributesToAccount < ActiveRecord::Migration[6.1] + def change + add_column :accounts, :custom_attributes, :jsonb, default: {} + end +end diff --git a/db/schema.rb b/db/schema.rb index cd7d4eac7..67de25ec9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_07_06_085458) do +ActiveRecord::Schema.define(version: 2022_07_18_123938) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -53,6 +53,7 @@ ActiveRecord::Schema.define(version: 2022_07_06_085458) do t.integer "feature_flags", default: 0, null: false t.integer "auto_resolve_duration" t.jsonb "limits", default: {} + t.jsonb "custom_attributes", default: {} end create_table "action_mailbox_inbound_emails", force: :cascade do |t| From 558e3c7499f917a94ad58e86a123857289b31ec1 Mon Sep 17 00:00:00 2001 From: Chad Burggraf Date: Mon, 18 Jul 2022 20:29:50 -0700 Subject: [PATCH 02/77] fix: Catch audio context errors (#5051) Prevent errors in decoding and playing sounds from propagating by catching them in the promise chain. Fixes #4281. --- .../shared/helpers/AudioNotificationHelper.js | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/app/javascript/shared/helpers/AudioNotificationHelper.js b/app/javascript/shared/helpers/AudioNotificationHelper.js index c3108f174..7db280fa2 100644 --- a/app/javascript/shared/helpers/AudioNotificationHelper.js +++ b/app/javascript/shared/helpers/AudioNotificationHelper.js @@ -16,19 +16,18 @@ export const getAlertAudio = async (baseUrl = '', type = 'dashboard') => { }; }; - try { - const resourceUrl = `${baseUrl}/audio/${type}/ding.mp3`; - const audioRequest = new Request(resourceUrl); + const resourceUrl = `${baseUrl}/audio/${type}/ding.mp3`; + const audioRequest = new Request(resourceUrl); - fetch(audioRequest) - .then(response => response.arrayBuffer()) - .then(buffer => { - audioCtx.decodeAudioData(buffer).then(playsound); - return new Promise(res => res()); - }); - } catch (error) { - // error - } + fetch(audioRequest) + .then(response => response.arrayBuffer()) + .then(buffer => { + audioCtx.decodeAudioData(buffer).then(playsound); + return new Promise(res => res()); + }) + .catch(() => { + // error + }); }; export const notificationEnabled = (enableAudioAlerts, id, userId) => { From c4b2005425f7a02003fb03d80bc45f445a50114b Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 19 Jul 2022 09:28:19 +0530 Subject: [PATCH 03/77] feat: Component for editing an article (#5032) * feat: Component for editing an article * chore: font fixes * chore: Review fixes * chore: fixes conflicts * Update app/javascript/dashboard/components/helpCenter/EditArticle.vue Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com> * chore: Review fixes Co-authored-by: Pranav Raj S Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com> --- .../helpCenter/EditArticle.stories.js | 34 +++++++ .../components/helpCenter/EditArticle.vue | 95 +++++++++++++++++++ .../dashboard/i18n/locale/en/helpCenter.json | 4 + 3 files changed, 133 insertions(+) create mode 100644 app/javascript/dashboard/components/helpCenter/EditArticle.stories.js create mode 100644 app/javascript/dashboard/components/helpCenter/EditArticle.vue 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/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index 0c04a6c37..dd6fdbbe5 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -27,6 +27,10 @@ "BY": "by" } }, + "EDIT_ARTICLE": { + "TITLE_PLACEHOLDER": "Article title goes here", + "CONTENT_PLACEHOLDER": "Write your article here" + }, "SIDEBAR": { "SEARCH": { "PLACEHOLDER": "Search for articles" From 0cee42a9f9faa1c6d6c89ee40a96c28f33b9f3bd Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Tue, 19 Jul 2022 17:37:00 +0530 Subject: [PATCH 04/77] feat: Macros CRUD api (#5047) --- .../api/v1/accounts/articles_controller.rb | 2 +- .../api/v1/accounts/macros_controller.rb | 51 +++++ app/models/account.rb | 3 +- app/models/macro.rb | 49 +++++ app/models/user.rb | 1 + app/policies/macro_policy.rb | 21 +++ .../v1/accounts/macros/create.json.jbuilder | 3 + .../v1/accounts/macros/index.json.jbuilder | 5 + .../api/v1/accounts/macros/show.json.jbuilder | 3 + .../v1/accounts/macros/update.json.jbuilder | 3 + app/views/api/v1/models/_macro.json.jbuilder | 18 ++ config/routes.rb | 1 + db/migrate/20220711090528_create_macros.rb | 14 ++ db/schema.rb | 16 ++ .../api/v1/accounts/macros_controller_spec.rb | 176 ++++++++++++++++++ spec/factories/macros.rb | 11 ++ spec/models/macro_spec.rb | 85 +++++++++ 17 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 app/controllers/api/v1/accounts/macros_controller.rb create mode 100644 app/models/macro.rb create mode 100644 app/policies/macro_policy.rb create mode 100644 app/views/api/v1/accounts/macros/create.json.jbuilder create mode 100644 app/views/api/v1/accounts/macros/index.json.jbuilder create mode 100644 app/views/api/v1/accounts/macros/show.json.jbuilder create mode 100644 app/views/api/v1/accounts/macros/update.json.jbuilder create mode 100644 app/views/api/v1/models/_macro.json.jbuilder create mode 100644 db/migrate/20220711090528_create_macros.rb create mode 100644 spec/controllers/api/v1/accounts/macros_controller_spec.rb create mode 100644 spec/factories/macros.rb create mode 100644 spec/models/macro_spec.rb diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 77a1fdf65..efe761501 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -46,7 +46,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController def list_params params.require(:payload).permit( - :category_slug, :locale, :query + :category_slug, :locale, :query, :page ) end end 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/models/account.rb b/app/models/account.rb index 678cc3445..d7c4718b0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -40,7 +40,8 @@ class Account < ApplicationRecord has_many :agent_bots, dependent: :destroy_async has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api' has_many :articles, dependent: :destroy_async, class_name: '::Article' - has_many :automation_rules, dependent: :destroy + has_many :automation_rules, dependent: :destroy_async + has_many :macros, dependent: :destroy_async has_many :campaigns, dependent: :destroy_async has_many :canned_responses, dependent: :destroy_async has_many :categories, dependent: :destroy_async, class_name: '::Category' diff --git a/app/models/macro.rb b/app/models/macro.rb new file mode 100644 index 000000000..2a6e44f5f --- /dev/null +++ b/app/models/macro.rb @@ -0,0 +1,49 @@ +# == Schema Information +# +# Table name: macros +# +# id :bigint not null, primary key +# actions :jsonb not null +# name :string not null +# visibility :integer default("user") +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# created_by_id :bigint not null +# updated_by_id :bigint not null +# +# Indexes +# +# index_macros_on_account_id (account_id) +# index_macros_on_created_by_id (created_by_id) +# index_macros_on_updated_by_id (updated_by_id) +# +# Foreign Keys +# +# fk_rails_... (created_by_id => users.id) +# fk_rails_... (updated_by_id => users.id) +# +class Macro < ApplicationRecord + belongs_to :account + belongs_to :created_by, + class_name: :User + belongs_to :updated_by, + class_name: :User + enum visibility: { personal: 0, global: 1 } + + def set_visibility(user, params) + self.visibility = params[:visibility] + self.visibility = :personal if user.agent? + end + + def self.with_visibility(user, params) + records = user.administrator? ? Current.account.macros : Current.account.macros.global + records = records.or(personal.where(created_by_id: user.id)) if user.agent? + records.page(current_page(params)) + records + end + + def self.current_page(params) + params[:page] || 1 + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 1da1fb3b7..6ed95ddb3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -100,6 +100,7 @@ class User < ApplicationRecord class_name: :Portal, dependent: :nullify, source: :portal + has_many :macros, foreign_key: 'created_by_id', dependent: :destroy_async before_validation :set_password_and_uid, on: :create diff --git a/app/policies/macro_policy.rb b/app/policies/macro_policy.rb new file mode 100644 index 000000000..3ad4cd2de --- /dev/null +++ b/app/policies/macro_policy.rb @@ -0,0 +1,21 @@ +class MacroPolicy < ApplicationPolicy + def index? + true + end + + def create? + true + end + + def show? + true + end + + def update? + true + end + + def destroy? + true + end +end diff --git a/app/views/api/v1/accounts/macros/create.json.jbuilder b/app/views/api/v1/accounts/macros/create.json.jbuilder new file mode 100644 index 000000000..5c8ef098f --- /dev/null +++ b/app/views/api/v1/accounts/macros/create.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'api/v1/models/macro.json.jbuilder', macro: @macro +end diff --git a/app/views/api/v1/accounts/macros/index.json.jbuilder b/app/views/api/v1/accounts/macros/index.json.jbuilder new file mode 100644 index 000000000..5f5c5a9c2 --- /dev/null +++ b/app/views/api/v1/accounts/macros/index.json.jbuilder @@ -0,0 +1,5 @@ +json.payload do + json.array! @macros do |macro| + json.partial! 'api/v1/models/macro.json.jbuilder', macro: macro + end +end diff --git a/app/views/api/v1/accounts/macros/show.json.jbuilder b/app/views/api/v1/accounts/macros/show.json.jbuilder new file mode 100644 index 000000000..5c8ef098f --- /dev/null +++ b/app/views/api/v1/accounts/macros/show.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'api/v1/models/macro.json.jbuilder', macro: @macro +end diff --git a/app/views/api/v1/accounts/macros/update.json.jbuilder b/app/views/api/v1/accounts/macros/update.json.jbuilder new file mode 100644 index 000000000..5c8ef098f --- /dev/null +++ b/app/views/api/v1/accounts/macros/update.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'api/v1/models/macro.json.jbuilder', macro: @macro +end diff --git a/app/views/api/v1/models/_macro.json.jbuilder b/app/views/api/v1/models/_macro.json.jbuilder new file mode 100644 index 000000000..f88d5922b --- /dev/null +++ b/app/views/api/v1/models/_macro.json.jbuilder @@ -0,0 +1,18 @@ +json.id macro.id +json.name macro.name +json.visibility macro.visibility + +if macro.created_by.present? + json.created_by do + json.partial! 'api/v1/models/agent.json.jbuilder', resource: macro.created_by + end +end + +if macro.updated_by.present? + json.updated_by do + json.partial! 'api/v1/models/agent.json.jbuilder', resource: macro.updated_by + end +end + +json.account_id macro.account_id +json.actions macro.actions diff --git a/config/routes.rb b/config/routes.rb index 6e31c4835..9e0acc196 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -57,6 +57,7 @@ Rails.application.routes.draw do post :clone post :attach_file, on: :collection end + resources :macros, only: [:index, :create, :show, :update, :destroy] resources :campaigns, only: [:index, :create, :show, :update, :destroy] resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy] namespace :channels do diff --git a/db/migrate/20220711090528_create_macros.rb b/db/migrate/20220711090528_create_macros.rb new file mode 100644 index 000000000..fcde5f9e3 --- /dev/null +++ b/db/migrate/20220711090528_create_macros.rb @@ -0,0 +1,14 @@ +class CreateMacros < ActiveRecord::Migration[6.1] + def change + create_table :macros do |t| + t.bigint :account_id, null: false + t.string :name, null: false + t.integer :visibility, default: 0 + t.references :created_by, null: false, index: true, foreign_key: { to_table: :users } + t.references :updated_by, null: false, index: true, foreign_key: { to_table: :users } + t.jsonb :actions, null: false, default: '{}' + t.timestamps + t.index :account_id, name: 'index_macros_on_account_id' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 67de25ec9..df5950031 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -563,6 +563,20 @@ ActiveRecord::Schema.define(version: 2022_07_18_123938) do t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true end + create_table "macros", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "name", null: false + t.integer "visibility", default: 0 + t.bigint "created_by_id", null: false + t.bigint "updated_by_id", null: false + t.jsonb "actions", default: "{}", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["account_id"], name: "index_macros_on_account_id" + t.index ["created_by_id"], name: "index_macros_on_created_by_id" + t.index ["updated_by_id"], name: "index_macros_on_updated_by_id" + end + create_table "mentions", force: :cascade do |t| t.bigint "user_id", null: false t.bigint "conversation_id", null: false @@ -869,6 +883,8 @@ ActiveRecord::Schema.define(version: 2022_07_18_123938) do add_foreign_key "dashboard_apps", "accounts" add_foreign_key "dashboard_apps", "users" add_foreign_key "data_imports", "accounts", on_delete: :cascade + add_foreign_key "macros", "users", column: "created_by_id" + add_foreign_key "macros", "users", column: "updated_by_id" add_foreign_key "mentions", "conversations", on_delete: :cascade add_foreign_key "mentions", "users", on_delete: :cascade add_foreign_key "notes", "accounts", on_delete: :cascade diff --git a/spec/controllers/api/v1/accounts/macros_controller_spec.rb b/spec/controllers/api/v1/accounts/macros_controller_spec.rb new file mode 100644 index 000000000..2da70ed1b --- /dev/null +++ b/spec/controllers/api/v1/accounts/macros_controller_spec.rb @@ -0,0 +1,176 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do + let(:account) { create(:account) } + let(:administrator) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:agent_1) { create(:user, account: account, role: :agent) } + + before do + create(:macro, account: account, created_by: administrator, updated_by: administrator, visibility: :global) + create(:macro, account: account, created_by: administrator, updated_by: administrator, visibility: :global) + create(:macro, account: account, created_by: administrator, updated_by: administrator, visibility: :personal) + create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :personal) + create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :personal) + create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal) + end + + describe 'GET /api/v1/accounts/{account.id}/macros' do + context 'when it is an authenticated administrator' do + it 'returns all records in the account' do + get "/api/v1/accounts/#{account.id}/macros", + headers: administrator.create_new_auth_token + + visible_macros = account.macros + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body['payload'].length).to eq(visible_macros.count) + expect(body['payload'].first['id']).to eq(Macro.first.id) + expect(body['payload'].last['id']).to eq(Macro.last.id) + end + end + + context 'when it is an authenticated agent' do + it 'returns all records in account and created_by the agent' do + get "/api/v1/accounts/#{account.id}/macros", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + + body = JSON.parse(response.body) + visible_macros = account.macros.global.or(account.macros.personal.where(created_by_id: agent.id)) + + expect(body['payload'].length).to eq(visible_macros.count) + expect(body['payload'].first['id']).to eq(visible_macros.first.id) + expect(body['payload'].last['id']).to eq(visible_macros.last.id) + end + end + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/macros" + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/accounts/{account.id}/macros' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/macros" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:params) do + { + 'name': 'Add label, send message and close the chat', + 'actions': [ + { + 'action_name': :add_label, + 'action_params': %w[support priority_customer] + }, + { + 'action_name': :send_message, + 'action_params': ['Welcome to the chatwoot platform.'] + }, + { + 'action_name': :resolved + } + ], + visibility: 'global', + created_by_id: administrator.id + }.with_indifferent_access + end + + it 'creates the macro' do + post "/api/v1/accounts/#{account.id}/macros", + params: params, + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + + expect(json_response['payload']['name']).to eql(params['name']) + expect(json_response['payload']['visibility']).to eql(params['visibility']) + expect(json_response['payload']['created_by']['id']).to eql(administrator.id) + end + + it 'sets visibility default to personal for agent' do + post "/api/v1/accounts/#{account.id}/macros", + params: params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + + expect(json_response['payload']['name']).to eql(params['name']) + expect(json_response['payload']['visibility']).to eql('personal') + expect(json_response['payload']['created_by']['id']).to eql(agent.id) + end + end + end + + describe 'PUT /api/v1/accounts/{account.id}/macros/{macro.id}' do + let!(:macro) { create(:macro, account: account, created_by: administrator, updated_by: administrator) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + put "/api/v1/accounts/#{account.id}/macros/#{macro.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:params) do + { + 'name': 'Add label, send message and close the chat' + } + end + + it 'Updates the macro' do + put "/api/v1/accounts/#{account.id}/macros/#{macro.id}", + params: params, + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['name']).to eql(params['name']) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/macros/{macro.id}' do + let!(:macro) { create(:macro, account: account, created_by: administrator, updated_by: administrator) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/macros/#{macro.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'fetch the macro' do + get "/api/v1/accounts/#{account.id}/macros/#{macro.id}", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + + expect(json_response['payload']['name']).to eql(macro.name) + expect(json_response['payload']['created_by']['id']).to eql(administrator.id) + end + end + end +end diff --git a/spec/factories/macros.rb b/spec/factories/macros.rb new file mode 100644 index 000000000..1730f58ef --- /dev/null +++ b/spec/factories/macros.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :macro do + account + name { 'wrong_message_actions' } + actions do + [ + { 'action_name' => 'add_label', 'action_params' => %w[wrong_chat] } + ] + end + end +end diff --git a/spec/models/macro_spec.rb b/spec/models/macro_spec.rb new file mode 100644 index 000000000..d58a7d9da --- /dev/null +++ b/spec/models/macro_spec.rb @@ -0,0 +1,85 @@ +require 'rails_helper' + +RSpec.describe Macro, type: :model do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + + describe 'associations' do + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:created_by) } + it { is_expected.to belong_to(:updated_by) } + end + + describe '#set_visibility' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:macro) { create(:macro, account: account, created_by: admin, updated_by: admin) } + + context 'when user is administrator' do + it 'set visibility with params' do + expect(macro.visibility).to eq('personal') + + macro.set_visibility(admin, { visibility: :global }) + + expect(macro.visibility).to eq('global') + + macro.set_visibility(admin, { visibility: :personal }) + + expect(macro.visibility).to eq('personal') + end + end + + context 'when user is agent' do + it 'set visibility always to agent' do + Current.user = agent + Current.account = account + + expect(macro.visibility).to eq('personal') + + macro.set_visibility(agent, { visibility: :global }) + + expect(macro.visibility).to eq('personal') + end + end + end + + describe '#with_visibility' do + let(:agent_1) { create(:user, account: account, role: :agent) } + let(:agent_2) { create(:user, account: account, role: :agent) } + + before do + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :global) + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :global) + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :personal) + create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :personal) + create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal) + create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal) + create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal) + create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal) + create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal) + end + + context 'when user is administrator' do + it 'return all macros in account' do + Current.user = admin + Current.account = account + + expect(described_class.with_visibility(admin, {}).count).to eq(account.macros.count) + end + end + + context 'when user is agent' do + it 'return all macros in account and created_by user' do + Current.user = agent_2 + Current.account = account + + macros_for_agent_2 = account.macros.global.count + agent_2.macros.personal.count + expect(described_class.with_visibility(agent_2, {}).count).to eq(macros_for_agent_2) + + Current.user = agent_1 + + macros_for_agent_1 = account.macros.global.count + agent_1.macros.personal.count + expect(described_class.with_visibility(agent_1, {}).count).to eq(macros_for_agent_1) + end + end + end +end From 7fc0d166e85ce34b7228307f9f2db4e1ba027dd8 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Tue, 19 Jul 2022 19:04:17 +0530 Subject: [PATCH 05/77] feat: Allow SaaS users to manage subscription within the dashboard (#5059) --- .../api/v1/accounts/agents_controller.rb | 2 +- app/controllers/application_controller.rb | 4 - app/controllers/dashboard_controller.rb | 6 +- .../dashboard/api/enterprise/account.js | 18 +++ .../api/enterprise/specs/account.spec.js | 31 +++++ .../dashboard/components/layout/Sidebar.vue | 2 + .../layout/config/sidebarItems/settings.js | 9 ++ .../layout/sidebarComponents/Secondary.vue | 12 +- .../dashboard/i18n/locale/en/settings.json | 20 +++ .../dashboard/settings/billing/Index.vue | 116 ++++++++++++++++++ .../settings/billing/billing.routes.js | 26 ++++ .../billing/components/BillingItem.vue | 38 ++++++ .../dashboard/settings/settings.routes.js | 10 +- .../dashboard/store/modules/accounts.js | 29 ++++- .../FluentIcon/dashboard-icons.json | 1 + app/javascript/shared/store/globalConfig.js | 3 + app/policies/account_policy.rb | 8 ++ .../api/v1/models/_account.json.jbuilder | 7 +- config/installation_config.yml | 4 + config/routes.rb | 17 +++ .../enterprise/api/v1/accounts_controller.rb | 47 +++++++ .../api/v1/webhooks/stripe_controller.rb | 21 ++++ .../enterprise/create_stripe_customer_job.rb | 7 ++ enterprise/app/models/enterprise/account.rb | 7 +- .../billing/create_session_service.rb | 10 ++ .../billing/create_stripe_customer_service.rb | 53 ++++++++ .../billing/handle_stripe_event_service.rb | 41 +++++++ .../v1/accounts/inboxes_controller_spec.rb | 0 .../api/v1/accounts_controller_spec.rb | 102 +++++++++++++++ .../create_stripe_customer_job_spec.rb | 27 ++++ spec/enterprise/models/account_spec.rb | 10 ++ .../billing/create_session_service_spec.rb | 22 ++++ .../create_stripe_customer_service_spec.rb | 81 ++++++++++++ 33 files changed, 773 insertions(+), 18 deletions(-) create mode 100644 app/javascript/dashboard/api/enterprise/account.js create mode 100644 app/javascript/dashboard/api/enterprise/specs/account.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue create mode 100644 enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb create mode 100644 enterprise/app/controllers/enterprise/api/v1/webhooks/stripe_controller.rb create mode 100644 enterprise/app/jobs/enterprise/create_stripe_customer_job.rb create mode 100644 enterprise/app/services/enterprise/billing/create_session_service.rb create mode 100644 enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb create mode 100644 enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb rename spec/enterprise/controllers/{ => enterprise}/api/v1/accounts/inboxes_controller_spec.rb (100%) create mode 100644 spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb create mode 100644 spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/create_session_service_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb 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/application_controller.rb b/app/controllers/application_controller.rb index 0762c3d91..d2960a699 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 36c789b52..bd144b0df 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -13,8 +13,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', @@ -29,7 +28,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 diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js new file mode 100644 index 000000000..bc0d51dfc --- /dev/null +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class EnterpriseAccountAPI extends ApiClient { + constructor() { + super('', { accountScoped: true, enterprise: true }); + } + + checkout() { + return axios.post(`${this.url}checkout`); + } + + subscription() { + return axios.post(`${this.url}subscription`); + } +} + +export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js new file mode 100644 index 000000000..28fd83cd1 --- /dev/null +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -0,0 +1,31 @@ +import accountAPI from '../account'; +import ApiClient from '../../ApiClient'; +import describeWithAPIMock from '../../specs/apiSpecHelper'; + +describe('#enterpriseAccountAPI', () => { + 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/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index 80b2486e6..fb0947de7 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -19,6 +19,7 @@ :custom-views="customViews" :menu-config="activeSecondaryMenu" :current-role="currentRole" + :is-on-chatwoot-cloud="isOnChatwootCloud" @add-label="showAddLabelPopup" @toggle-accounts="toggleAccountModal" /> @@ -67,6 +68,7 @@ export default { ...mapGetters({ currentUser: 'getCurrentUser', globalConfig: 'globalConfig/get', + isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', inboxes: 'inboxes/getInboxes', accountId: 'getCurrentAccountId', currentRole: 'getCurrentRole', diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js b/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js index 0b78e8a21..990b35d4a 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js @@ -30,6 +30,7 @@ const settings = accountId => ({ 'settings_teams_edit', 'settings_teams_edit_members', 'settings_teams_edit_finish', + 'billing_settings_index', 'automation_list', ], menuItems: [ @@ -100,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/Secondary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue index 4b19a3ec3..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 { diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 0307abe56..53dc6ffaa 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -167,6 +167,7 @@ "CUSTOM_ATTRIBUTES": "Custom Attributes", "AUTOMATION": "Automation", "TEAMS": "Teams", + "BILLING": "Billing", "CUSTOM_VIEWS_FOLDER": "Folders", "CUSTOM_VIEWS_SEGMENTS": "Segments", "ALL_CONTACTS": "All Contacts", @@ -195,6 +196,25 @@ "CATEGORY": "Category" } }, + "BILLING_SETTINGS": { + "TITLE": "Billing", + "CURRENT_PLAN" : { + "TITLE": "Current Plan", + "PLAN_NOTE": "You are currently subscribed to the **%{plan}** plan with **%{quantity}** licenses" + }, + "MANAGE_SUBSCRIPTION": { + "TITLE": "Manage your subscription", + "DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.", + "BUTTON_TXT": "Go to the billing portal" + }, + + "CHAT_WITH_US": { + "TITLE": "Need help?", + "DESCRIPTION": "Do you face any issues in billing? We are here to help.", + "BUTTON_TXT": "Chat with us" + }, + "NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again." + }, "CREATE_ACCOUNT": { "NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.", "NEW_ACCOUNT": "New Account", diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue new file mode 100644 index 000000000..a7c330527 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js b/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js new file mode 100644 index 000000000..db28fb844 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js @@ -0,0 +1,26 @@ +import SettingsContent from '../Wrapper'; +import Index from './Index.vue'; +import { frontendURL } from '../../../../helper/URLHelper'; + +export default { + routes: [ + { + path: frontendURL('accounts/:accountId/settings/billing'), + roles: ['administrator'], + component: SettingsContent, + props: { + headerTitle: 'BILLING_SETTINGS.TITLE', + icon: 'credit-card-person', + showNewButton: false, + }, + children: [ + { + path: '', + name: 'billing_settings_index', + component: Index, + roles: ['administrator'], + }, + ], + }, + ], +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue new file mode 100644 index 000000000..6f8ade8a0 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue @@ -0,0 +1,38 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js index 110f26fba..8f3de8f4e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js @@ -13,6 +13,7 @@ import teams from './teams/teams.routes'; import attributes from './attributes/attributes.routes'; import automation from './automation/automation.routes'; import store from '../../../store'; +import billing from './billing/billing.routes'; export default { routes: [ @@ -29,16 +30,17 @@ export default { }, ...account.routes, ...agent.routes, + ...attributes.routes, + ...automation.routes, + ...billing.routes, + ...campaigns.routes, ...canned.routes, ...inbox.routes, + ...integrationapps.routes, ...integrations.routes, ...labels.routes, ...profile.routes, ...reports.routes, ...teams.routes, - ...campaigns.routes, - ...integrationapps.routes, - ...attributes.routes, - ...automation.routes, ], }; diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js index a30e64e46..32bca04a8 100644 --- a/app/javascript/dashboard/store/modules/accounts.js +++ b/app/javascript/dashboard/store/modules/accounts.js @@ -1,9 +1,8 @@ -/* eslint no-console: 0 */ -/* eslint no-param-reassign: 0 */ -/* eslint no-shadow: 0 */ import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; import * as types from '../mutation-types'; import AccountAPI from '../../api/account'; +import EnterpriseAccountAPI from '../../api/enterprise/account'; +import { throwErrorMessage } from '../utils/api'; const state = { records: [], @@ -11,6 +10,7 @@ const state = { isFetching: false, isFetchingItem: false, isUpdating: false, + isCheckoutInProcess: false, }, }; @@ -60,6 +60,29 @@ export const actions = { throw error; } }, + + checkout: async ({ commit }) => { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true }); + try { + const response = await EnterpriseAccountAPI.checkout(); + window.location = response.data.redirect_url; + } catch (error) { + throwErrorMessage(error); + } finally { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false }); + } + }, + + subscription: async ({ commit }) => { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true }); + try { + await EnterpriseAccountAPI.subscription(); + } catch (error) { + throwErrorMessage(error); + } finally { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false }); + } + }, }; export const mutations = { diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json index ed818374d..08a452f88 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -63,6 +63,7 @@ "M10 13.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5z", "M4 6h1v6.5A2.5 2.5 0 0 0 7.5 15H14v1a2 2 0 0 1-2 2H5.5A3.5 3.5 0 0 1 2 14.5V8a2 2 0 0 1 2-2z" ], + "credit-card-person-outline": "M2 7.25A3.25 3.25 0 0 1 5.25 4h13.5A3.25 3.25 0 0 1 22 7.25V10h-.258A3.74 3.74 0 0 0 20.5 7.455V7.25a1.75 1.75 0 0 0-1.75-1.75H5.25A1.75 1.75 0 0 0 3.5 7.25v.25h11.95c-.44.409-.782.922-.987 1.5H3.5v5.75c0 .966.784 1.75 1.75 1.75h6.78c.06.522.217 1.028.458 1.5H5.25A3.25 3.25 0 0 1 2 14.75v-7.5Zm21 8.25a1.5 1.5 0 0 0-1.5-1.5h-7a1.5 1.5 0 0 0-1.5 1.5v.5c0 1.971 1.86 4 5 4 3.14 0 5-2.029 5-4v-.5Zm-2.25-5.25a2.75 2.75 0 1 0-5.5 0 2.75 2.75 0 0 0 5.5 0Z", "delete-outline": "M12 1.75a3.25 3.25 0 0 1 3.245 3.066L15.25 5h5.25a.75.75 0 0 1 .102 1.493L20.5 6.5h-.796l-1.28 13.02a2.75 2.75 0 0 1-2.561 2.474l-.176.006H8.313a2.75 2.75 0 0 1-2.714-2.307l-.023-.174L4.295 6.5H3.5a.75.75 0 0 1-.743-.648L2.75 5.75a.75.75 0 0 1 .648-.743L3.5 5h5.25A3.25 3.25 0 0 1 12 1.75Zm6.197 4.75H5.802l1.267 12.872a1.25 1.25 0 0 0 1.117 1.122l.127.006h7.374c.6 0 1.109-.425 1.225-1.002l.02-.126L18.196 6.5ZM13.75 9.25a.75.75 0 0 1 .743.648L14.5 10v7a.75.75 0 0 1-1.493.102L13 17v-7a.75.75 0 0 1 .75-.75Zm-3.5 0a.75.75 0 0 1 .743.648L11 10v7a.75.75 0 0 1-1.493.102L9.5 17v-7a.75.75 0 0 1 .75-.75Zm1.75-6a1.75 1.75 0 0 0-1.744 1.606L10.25 5h3.5A1.75 1.75 0 0 0 12 3.25Z", "dismiss-circle-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17Zm3.446 4.897.084.073a.75.75 0 0 1 .073.976l-.073.084L13.061 12l2.47 2.47a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L12 13.061l-2.47 2.47a.75.75 0 0 1-.976.072l-.084-.073a.75.75 0 0 1-.073-.976l.073-.084L10.939 12l-2.47-2.47a.75.75 0 0 1-.072-.976l.073-.084a.75.75 0 0 1 .976-.073l.084.073L12 10.939l2.47-2.47a.75.75 0 0 1 .976-.072Z", "dismiss-outline": "m4.397 4.554.073-.084a.75.75 0 0 1 .976-.073l.084.073L12 10.939l6.47-6.47a.75.75 0 1 1 1.06 1.061L13.061 12l6.47 6.47a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L12 13.061l-6.47 6.47a.75.75 0 0 1-1.06-1.061L10.939 12l-6.47-6.47a.75.75 0 0 1-.072-.976l.073-.084-.073.084Z", diff --git a/app/javascript/shared/store/globalConfig.js b/app/javascript/shared/store/globalConfig.js index d1f7aa64d..36babd0d9 100644 --- a/app/javascript/shared/store/globalConfig.js +++ b/app/javascript/shared/store/globalConfig.js @@ -15,6 +15,7 @@ const { TERMS_URL: termsURL, WIDGET_BRAND_URL: widgetBrandURL, DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate, + DEPLOYMENT_ENV: deploymentEnv, } = window.globalConfig || {}; const state = { @@ -23,6 +24,7 @@ const state = { appVersion, brandName, chatwootInboxToken, + deploymentEnv, createNewAccountFromDashboard, directUploadsEnabled: directUploadsEnabled === 'true', disableUserProfileUpdate: disableUserProfileUpdate === 'true', @@ -38,6 +40,7 @@ const state = { export const getters = { get: $state => $state, + isOnChatwootCloud: $state => $state.deploymentEnv === 'cloud', }; export const actions = {}; diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index ec5799587..f97418339 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -10,4 +10,12 @@ class AccountPolicy < ApplicationPolicy def update_active_at? true end + + def subscription? + @account_user.administrator? + end + + def checkout? + @account_user.administrator? + end end diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index bc28f7a27..ddf366597 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -1,6 +1,11 @@ json.auto_resolve_duration resource.auto_resolve_duration json.created_at resource.created_at -json.custom_attributes resource.custom_attributes +if resource.custom_attributes.present? + json.custom_attributes do + json.plan_name resource.custom_attributes['plan_name'] + json.subscribed_quantity resource.custom_attributes['subscribed_quantity'] + end +end json.custom_email_domain_enabled @account.custom_email_domain_enabled json.domain @account.domain json.features @account.enabled_features diff --git a/config/installation_config.yml b/config/installation_config.yml index ec3b0b635..74a45b1c5 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -68,3 +68,7 @@ - name: CSML_BOT_API_KEY value: locked: false +- name: CHATWOOT_CLOUD_PLANS + value: +- name: DEPLOYMENT_ENV + value: self-hosted diff --git a/config/routes.rb b/config/routes.rb index 9e0acc196..222ac8b83 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -224,6 +224,23 @@ Rails.application.routes.draw do end end + if ChatwootApp.enterprise? + namespace :enterprise, defaults: { format: 'json' } do + namespace :api do + namespace :v1 do + resources :accounts do + member do + post :checkout + post :subscription + end + end + + post 'webhooks/stripe', to: 'webhooks/stripe#process_payload' + end + end + end + end + # ---------------------------------------------------------------------- # Routes for platform APIs namespace :platform, defaults: { format: 'json' } do diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb new file mode 100644 index 000000000..bb24b5504 --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -0,0 +1,47 @@ +class Enterprise::Api::V1::AccountsController < Api::BaseController + before_action :fetch_account + before_action :check_authorization + + def subscription + Enterprise::CreateStripeCustomerJob.perform_later(@account) if stripe_customer_id.blank? + head :no_content + end + + def checkout + return create_stripe_billing_session(stripe_customer_id) if stripe_customer_id.present? + + render_invalid_billing_details + end + + private + + def fetch_account + @account = current_user.accounts.find(params[:id]) + @current_account_user = @account.account_users.find_by(user_id: current_user.id) + end + + def stripe_customer_id + @account.custom_attributes['stripe_customer_id'] + end + + def render_invalid_billing_details + render_could_not_create_error('Please subscribe to a plan before viewing the billing details') + end + + def create_stripe_billing_session(customer_id) + session = Enterprise::Billing::CreateSessionService.new.create_session(customer_id) + render_redirect_url(session.url) + end + + def render_redirect_url(redirect_url) + render json: { redirect_url: redirect_url } + end + + def pundit_user + { + user: current_user, + account: @account, + account_user: @current_account_user + } + end +end diff --git a/enterprise/app/controllers/enterprise/api/v1/webhooks/stripe_controller.rb b/enterprise/app/controllers/enterprise/api/v1/webhooks/stripe_controller.rb new file mode 100644 index 000000000..0dceeb346 --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/webhooks/stripe_controller.rb @@ -0,0 +1,21 @@ +class Enterprise::Api::V1::Webhooks::StripeController < ActionController::API + def process_payload + # Get the event payload and signature + payload = request.body.read + sig_header = request.headers['Stripe-Signature'] + + # Attempt to verify the signature. If successful, we'll handle the event + begin + event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)) + ::Enterprise::Billing::HandleStripeEventService.new.perform(event: event) + # If we fail to verify the signature, then something was wrong with the request + rescue JSON::ParserError, Stripe::SignatureVerificationError + # Invalid payload + head :bad_request + return + end + + # We've successfully processed the event without blowing up + head :ok + end +end diff --git a/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb new file mode 100644 index 000000000..6b90cbec5 --- /dev/null +++ b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb @@ -0,0 +1,7 @@ +class Enterprise::CreateStripeCustomerJob < ApplicationJob + queue_as :default + + def perform(account) + Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform + end +end diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index 99defb6ea..20a4dbfcd 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -1,13 +1,18 @@ module Enterprise::Account def usage_limits { - agents: get_limits(:agents).to_i, + agents: agent_limits, inboxes: get_limits(:inboxes).to_i } end private + def agent_limits + subscribed_quantity = custom_attributes['subscribed_quantity'] + subscribed_quantity || get_limits(:agents) + end + def get_limits(limit_name) config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT" self[:limits][limit_name.to_s] || GlobalConfig.get(config_name)[config_name] || ChatwootApp.max_limit diff --git a/enterprise/app/services/enterprise/billing/create_session_service.rb b/enterprise/app/services/enterprise/billing/create_session_service.rb new file mode 100644 index 000000000..ad4f8f45b --- /dev/null +++ b/enterprise/app/services/enterprise/billing/create_session_service.rb @@ -0,0 +1,10 @@ +class Enterprise::Billing::CreateSessionService + def create_session(customer_id, return_url = ENV.fetch('FRONTEND_URL')) + Stripe::BillingPortal::Session.create( + { + customer: customer_id, + return_url: return_url + } + ) + end +end diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb new file mode 100644 index 000000000..76c09a27a --- /dev/null +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -0,0 +1,53 @@ +class Enterprise::Billing::CreateStripeCustomerService + pattr_initialize [:account!] + + DEFAULT_QUANTITY = 2 + + def perform + customer_id = prepare_customer_id + subscription = Stripe::Subscription.create( + { + customer: customer_id, + items: [{ price: price_id, quantity: default_quantity }] + } + ) + account.update!( + custom_attributes: { + stripe_customer_id: customer_id, + stripe_price_id: subscription['plan']['id'], + stripe_product_id: subscription['plan']['product'], + plan_name: default_plan['name'], + subscribed_quantity: subscription['quantity'] + } + ) + end + + private + + def prepare_customer_id + customer_id = account.custom_attributes['stripe_customer_id'] + if customer_id.blank? + customer = Stripe::Customer.create({ name: account.name, email: billing_email }) + customer_id = customer.id + end + customer_id + end + + def default_quantity + default_plan['default_quantity'] || DEFAULT_QUANTITY + end + + def billing_email + account.administrators.first.email + end + + def default_plan + installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS') + @default_plan ||= installation_config.value.first + end + + def price_id + price_ids = default_plan['price_ids'] + price_ids.first + end +end diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb new file mode 100644 index 000000000..0942bbe1f --- /dev/null +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -0,0 +1,41 @@ +class Enterprise::Billing::HandleStripeEventService + def perform(event:) + ensure_event_context(event) + case @event.type + when 'customer.subscription.updated' + plan = find_plan(subscription['plan']['product']) + account.update( + custom_attributes: { + stripe_customer_id: subscription.customer, + stripe_price_id: subscription['plan']['id'], + stripe_product_id: subscription['plan']['product'], + plan_name: plan['name'], + subscribed_quantity: subscription['quantity'] + } + ) + when 'customer.subscription.deleted' + Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform + else + Rails.logger.debug { "Unhandled event type: #{event.type}" } + end + end + + private + + def ensure_event_context(event) + @event = event + end + + def subscription + @subscription ||= @event.data.object + end + + def account + @account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first + end + + def find_plan(plan_id) + installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS') + installation_config.value.find { |config| config['product_id'].include?(plan_id) } + end +end diff --git a/spec/enterprise/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb similarity index 100% rename from spec/enterprise/controllers/api/v1/accounts/inboxes_controller_spec.rb rename to spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb new file mode 100644 index 000000000..57ef52dbb --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -0,0 +1,102 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise Billing APIs', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + + describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/enterprise/api/v1/accounts/#{account.id}/subscription", as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + context 'when it is an agent' do + it 'returns unauthorized' do + post "/enterprise/api/v1/accounts/#{account.id}/subscription", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'enqueues a job' do + expect do + post "/enterprise/api/v1/accounts/#{account.id}/subscription", + headers: admin.create_new_auth_token, + as: :json + end.to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account) + end + + it 'does not enqueues a job if customer id is present' do + account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' }) + + expect do + post "/enterprise/api/v1/accounts/#{account.id}/subscription", + headers: admin.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account) + end + end + end + end + + describe 'POST /enterprise/api/v1/accounts/{account.id}/checkout' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/enterprise/api/v1/accounts/#{account.id}/checkout", as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + context 'when it is an agent' do + it 'returns unauthorized' do + post "/enterprise/api/v1/accounts/#{account.id}/checkout", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin and the stripe customer id is not present' do + it 'returns error' do + post "/enterprise/api/v1/accounts/#{account.id}/checkout", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Please subscribe to a plan before viewing the billing details') + end + end + + context 'when it is an admin and the stripe customer is present' do + it 'calls create session' do + account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' }) + + create_session_service = double + allow(Enterprise::Billing::CreateSessionService).to receive(:new).and_return(create_session_service) + allow(create_session_service).to receive(:create_session).and_return(create_session_service) + allow(create_session_service).to receive(:url).and_return('https://billing.stripe.com/random_string') + + post "/enterprise/api/v1/accounts/#{account.id}/checkout", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['redirect_url']).to eq('https://billing.stripe.com/random_string') + end + end + end + end +end diff --git a/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb b/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb new file mode 100644 index 000000000..b2c945800 --- /dev/null +++ b/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do + include ActiveJob::TestHelper + subject(:job) { described_class.perform_later(account) } + + let(:account) { create(:account) } + + it 'queues the job' do + expect { job }.to have_enqueued_job(described_class) + .with(account) + .on_queue('default') + end + + it 'executes perform' do + create_stripe_customer_service = double + allow(Enterprise::Billing::CreateStripeCustomerService) + .to receive(:new) + .with(account: account) + .and_return(create_stripe_customer_service) + allow(create_stripe_customer_service).to receive(:perform) + + perform_enqueued_jobs { job } + + expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account) + end +end diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb index 6f0fa5aa7..16a664ef5 100644 --- a/spec/enterprise/models/account_spec.rb +++ b/spec/enterprise/models/account_spec.rb @@ -28,5 +28,15 @@ RSpec.describe Account do } ) end + + it 'returns limits based on subscription' do + account.update(limits: { agents: 10 }, custom_attributes: { subscribed_quantity: 5 }) + expect(account.usage_limits).to eq( + { + agents: 5, + inboxes: ChatwootApp.max_limit + } + ) + end end end diff --git a/spec/enterprise/services/enterprise/billing/create_session_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_session_service_spec.rb new file mode 100644 index 000000000..0c7fabaa3 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/create_session_service_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +describe Enterprise::Billing::CreateSessionService do + subject(:create_session_service) { described_class } + + describe '#perform' do + it 'calls stripe billing portal session' do + customer_id = 'cus_random_number' + return_url = 'https://www.chatwoot.com' + allow(Stripe::BillingPortal::Session).to receive(:create).with({ customer: customer_id, return_url: return_url }) + + create_session_service.new.create_session(customer_id, return_url) + + expect(Stripe::BillingPortal::Session).to have_received(:create).with( + { + customer: customer_id, + return_url: return_url + } + ) + end + end +end diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb new file mode 100644 index 000000000..95edf73a1 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +describe Enterprise::Billing::CreateStripeCustomerService do + subject(:create_stripe_customer_service) { described_class } + + let(:account) { create(:account) } + let!(:admin1) { create(:user, account: account, role: :administrator) } + let(:admin2) { create(:user, account: account, role: :administrator) } + + describe '#perform' do + before do + create( + :installation_config, + { name: 'CHATWOOT_CLOUD_PLANS', value: [ + { 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] } + ] } + ) + end + + it 'does not call stripe methods if customer id is present' do + account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' }) + + allow(Stripe::Customer).to receive(:create) + allow(Stripe::Subscription).to receive(:create) + .and_return( + { + plan: { id: 'price_random_number', product: 'prod_random_number' }, + quantity: 2 + }.with_indifferent_access + ) + + create_stripe_customer_service.new(account: account).perform + + expect(Stripe::Customer).not_to have_received(:create) + expect(Stripe::Subscription) + .to have_received(:create) + .with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] }) + + expect(account.reload.custom_attributes).to eq( + { + stripe_customer_id: 'cus_random_number', + stripe_price_id: 'price_random_number', + stripe_product_id: 'prod_random_number', + subscribed_quantity: 2, + plan_name: 'A Plan Name' + }.with_indifferent_access + ) + end + + it 'calls stripe methods to create a customer and updates the account' do + customer = double + allow(Stripe::Customer).to receive(:create).and_return(customer) + allow(customer).to receive(:id).and_return('cus_random_number') + allow(Stripe::Subscription) + .to receive(:create) + .and_return( + { + plan: { id: 'price_random_number', product: 'prod_random_number' }, + quantity: 2 + }.with_indifferent_access + ) + + create_stripe_customer_service.new(account: account).perform + + expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email }) + expect(Stripe::Subscription) + .to have_received(:create) + .with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] }) + + expect(account.reload.custom_attributes).to eq( + { + stripe_customer_id: customer.id, + stripe_price_id: 'price_random_number', + stripe_product_id: 'prod_random_number', + subscribed_quantity: 2, + plan_name: 'A Plan Name' + }.with_indifferent_access + ) + end + end +end From cd5e0c548cbbc8e1ddbb4275c714b6b82018694a Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Tue, 19 Jul 2022 21:12:31 +0530 Subject: [PATCH 06/77] fix: Do not enqueue job if it is already enqueued (#5068) --- .../enterprise/api/v1/accounts_controller.rb | 5 ++++- .../enterprise/api/v1/accounts_controller_spec.rb | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index bb24b5504..b648b348c 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -3,7 +3,10 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController before_action :check_authorization def subscription - Enterprise::CreateStripeCustomerJob.perform_later(@account) if stripe_customer_id.blank? + if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank? + @account.update(custom_attributes: { is_creating_customer: true }) + Enterprise::CreateStripeCustomerJob.perform_later(@account) + end head :no_content end diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb index 57ef52dbb..354aa5a50 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -32,6 +32,17 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do headers: admin.create_new_auth_token, as: :json end.to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account) + expect(account.reload.custom_attributes).to eq({ 'is_creating_customer': true }.with_indifferent_access) + end + + it 'does not enqueue a job if a job is already enqueued' do + account.update!(custom_attributes: { is_creating_customer: true }) + + expect do + post "/enterprise/api/v1/accounts/#{account.id}/subscription", + headers: admin.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account) end it 'does not enqueues a job if customer id is present' do From 61055672384cb850415ebae70d7ccdc761bf3555 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 20 Jul 2022 14:41:03 +0530 Subject: [PATCH 07/77] enhancement: Uses bold font-weight for unread notifications (#5073) --- .../notifications/components/NotificationTable.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationTable.vue b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationTable.vue index 415e87592..fc68afb40 100644 --- a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationTable.vue +++ b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationTable.vue @@ -13,6 +13,9 @@ @@ -163,6 +166,10 @@ export default { } } +.is-unread { + font-weight: var(--font-weight-bold); +} + .notifications--loader { font-size: var(--font-size-default); display: flex; From 6a6a37a67b071e96fcd887d1bd85675477fd6e59 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 21 Jul 2022 19:27:12 +0200 Subject: [PATCH 08/77] chore: Ability to Disable Gravatars (#5027) fixes: #3853 - Introduced DISABLE_GRAVATAR Global Config, which will stop chatwoot from making API requests to gravatar - Cleaned up avatar-related logic and centralized it into the avatarable concern - Added specs for the missing cases - Added migration for existing installations to move the avatar to attachment, rather than making the API that results in 404. --- app/actions/contact_identify_action.rb | 2 +- app/builders/contact_builder.rb | 2 +- .../messages/facebook/message_builder.rb | 2 +- .../api/v1/accounts/callbacks_controller.rb | 6 +- .../api/v1/accounts/contacts_controller.rb | 8 +- app/jobs/avatar/avatar_from_gravatar_job.rb | 13 ++++ app/jobs/avatar/avatar_from_url_job.rb | 15 ++++ app/jobs/contact_avatar_job.rb | 15 ---- app/models/concerns/avatarable.rb | 15 ++-- .../instagram/webhooks_base_service.rb | 2 +- .../telegram/incoming_message_service.rb | 2 +- app/services/twitter/webhooks_base_service.rb | 2 +- ..._sync_gravatar_for_existing_avatarables.rb | 26 +++++++ spec/actions/contact_identify_action_spec.rb | 4 +- spec/builders/v2/report_builder_spec.rb | 76 +++++++++---------- .../accounts/bulk_actions_controller_spec.rb | 6 +- .../v1/accounts/contacts_controller_spec.rb | 8 ++ spec/factories/inbox_members.rb | 2 +- .../avatar/avatar_from_gravatar_job_spec.rb | 29 +++++++ spec/jobs/avatar/avatar_from_url_job_spec.rb | 20 +++++ spec/models/agent_bot_spec.rb | 2 + spec/models/concerns/avatarable_shared.rb | 41 ++++++++++ spec/models/contact_spec.rb | 6 ++ spec/models/inbox_spec.rb | 2 + spec/models/user_spec.rb | 2 + 25 files changed, 225 insertions(+), 83 deletions(-) create mode 100644 app/jobs/avatar/avatar_from_gravatar_job.rb create mode 100644 app/jobs/avatar/avatar_from_url_job.rb delete mode 100644 app/jobs/contact_avatar_job.rb create mode 100644 db/migrate/20220712145440_sync_gravatar_for_existing_avatarables.rb create mode 100644 spec/jobs/avatar/avatar_from_gravatar_job_spec.rb create mode 100644 spec/jobs/avatar/avatar_from_url_job_spec.rb create mode 100644 spec/models/concerns/avatarable_shared.rb diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index f19caac77..a1c39e2a0 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -104,7 +104,7 @@ class ContactIdentifyAction # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded @contact.discard_invalid_attrs if discard_invalid_attrs @contact.save! - ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? + Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? end def merge_contact(base_contact, merge_contact) diff --git a/app/builders/contact_builder.rb b/app/builders/contact_builder.rb index 10ce8ee26..938072643 100644 --- a/app/builders/contact_builder.rb +++ b/app/builders/contact_builder.rb @@ -23,7 +23,7 @@ class ContactBuilder end def update_contact_avatar(contact) - ::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url] + ::Avatar::AvatarFromUrlJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url] end def create_contact diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index f19d3c8b7..9f670602a 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -58,7 +58,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder return if contact_params[:remote_avatar_url].blank? return if @contact.avatar.attached? - ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) + Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url]) end def conversation diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 8a163f011..3575e28e6 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -90,9 +90,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController end def set_avatar(facebook_inbox, page_id) - avatar_file = Down.download( - "http://graph.facebook.com/#{page_id}/picture?type=large" - ) - facebook_inbox.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type) + avatar_url = "https://graph.facebook.com/#{page_id}/picture?type=large" + Avatar::AvatarFromUrlJob.perform_later(facebook_inbox, avatar_url) end end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 44b1280ee..afb26e055 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -166,13 +166,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController 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 + ::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? end def render_error(error, error_status) diff --git a/app/jobs/avatar/avatar_from_gravatar_job.rb b/app/jobs/avatar/avatar_from_gravatar_job.rb new file mode 100644 index 000000000..4a967d74f --- /dev/null +++ b/app/jobs/avatar/avatar_from_gravatar_job.rb @@ -0,0 +1,13 @@ +class Avatar::AvatarFromGravatarJob < ApplicationJob + queue_as :low + + def perform(avatarable, email) + return if GlobalConfigService.load('DISABLE_GRAVATAR', '').present? + return if email.blank? + return if avatarable.avatar_url.present? + + hash = Digest::MD5.hexdigest(email) + gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404" + Avatar::AvatarFromUrlJob.perform_later(avatarable, gravatar_url) + end +end diff --git a/app/jobs/avatar/avatar_from_url_job.rb b/app/jobs/avatar/avatar_from_url_job.rb new file mode 100644 index 000000000..a25698bc1 --- /dev/null +++ b/app/jobs/avatar/avatar_from_url_job.rb @@ -0,0 +1,15 @@ +class Avatar::AvatarFromUrlJob < ApplicationJob + queue_as :default + + def perform(avatarable, avatar_url) + return unless avatarable.respond_to?(:avatar) + + avatar_file = Down.download( + avatar_url, + max_size: 15 * 1024 * 1024 + ) + avatarable.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type) + rescue Down::NotFound, Down::Error => e + Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}" + end +end diff --git a/app/jobs/contact_avatar_job.rb b/app/jobs/contact_avatar_job.rb deleted file mode 100644 index 5f07f5f24..000000000 --- a/app/jobs/contact_avatar_job.rb +++ /dev/null @@ -1,15 +0,0 @@ -class ContactAvatarJob < ApplicationJob - queue_as :default - - def perform(contact, avatar_url) - avatar_file = Down.download( - avatar_url, - max_size: 15 * 1024 * 1024 - ) - contact.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type) - rescue Down::NotFound - contact.avatar.attachment.destroy! if contact.avatar.attached? - rescue Down::Error => e - Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}" - end -end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index b17a0bdbf..852dd311d 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -7,19 +7,24 @@ module Avatarable included do has_one_attached :avatar validate :acceptable_avatar, if: -> { avatar.changed? } + after_save :fetch_avatar_from_gravatar end def avatar_url return url_for(avatar.representation(resize: '250x250')) if avatar.attached? && avatar.representable? - if [SuperAdmin, User, Contact].include?(self.class) && email.present? - hash = Digest::MD5.hexdigest(email) - return "https://www.gravatar.com/avatar/#{hash}?d=404" - end - '' end + def fetch_avatar_from_gravatar + return unless saved_changes.key?(:email) + return if email.blank? + + # Incase avatar_url is supplied, we don't want to fetch avatar from gravatar + # So we will wait for it to be processed + Avatar::AvatarFromGravatarJob.set(wait: 30.seconds).perform_later(self, email) + end + def acceptable_avatar return unless avatar.attached? diff --git a/app/services/instagram/webhooks_base_service.rb b/app/services/instagram/webhooks_base_service.rb index 021a132e2..c89b67654 100644 --- a/app/services/instagram/webhooks_base_service.rb +++ b/app/services/instagram/webhooks_base_service.rb @@ -16,6 +16,6 @@ class Instagram::WebhooksBaseService ) @contact = @contact_inbox.contact - ContactAvatarJob.perform_later(@contact, user['profile_pic']) if user['profile_pic'] + Avatar::AvatarFromUrlJob.perform_later(@contact, user['profile_pic']) if user['profile_pic'] end end diff --git a/app/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb index 7ff7a6155..ae26db899 100644 --- a/app/services/telegram/incoming_message_service.rb +++ b/app/services/telegram/incoming_message_service.rb @@ -45,7 +45,7 @@ class Telegram::IncomingMessageService return if @contact.avatar.attached? avatar_url = inbox.channel.get_telegram_profile_image(params[:message][:from][:id]) - ::ContactAvatarJob.perform_later(@contact, avatar_url) if avatar_url + ::Avatar::AvatarFromUrlJob.perform_later(@contact, avatar_url) if avatar_url end def conversation_params diff --git a/app/services/twitter/webhooks_base_service.rb b/app/services/twitter/webhooks_base_service.rb index ea0abadf8..5ad822142 100644 --- a/app/services/twitter/webhooks_base_service.rb +++ b/app/services/twitter/webhooks_base_service.rb @@ -30,6 +30,6 @@ class Twitter::WebhooksBaseService user['id'], user['name'], additional_contact_attributes(user) ) @contact = @contact_inbox.contact - ContactAvatarJob.perform_later(@contact, user['profile_image_url']) if user['profile_image_url'] + Avatar::AvatarFromUrlJob.perform_later(@contact, user['profile_image_url']) if user['profile_image_url'] end end diff --git a/db/migrate/20220712145440_sync_gravatar_for_existing_avatarables.rb b/db/migrate/20220712145440_sync_gravatar_for_existing_avatarables.rb new file mode 100644 index 000000000..41405fcaa --- /dev/null +++ b/db/migrate/20220712145440_sync_gravatar_for_existing_avatarables.rb @@ -0,0 +1,26 @@ +class SyncGravatarForExistingAvatarables < ActiveRecord::Migration[6.1] + def change + return if GlobalConfigService.load('DISABLE_GRAVATAR', '').present? + + sync_user_avatars + sync_contact_avatars + end + + private + + def sync_user_avatars + ::User.find_in_batches do |users_batch| + users_batch.each do |user| + Avatar::AvatarFromGravatarJob.perform_later(user, user.email) + end + end + end + + def sync_contact_avatars + ::Contact.where.not(email: nil).find_in_batches do |contacts_batch| + contacts_batch.each do |contact| + Avatar::AvatarFromGravatarJob.perform_later(contact, contact.email) + end + end + end +end diff --git a/spec/actions/contact_identify_action_spec.rb b/spec/actions/contact_identify_action_spec.rb index db1c84e6c..64f81ef8f 100644 --- a/spec/actions/contact_identify_action_spec.rb +++ b/spec/actions/contact_identify_action_spec.rb @@ -13,7 +13,7 @@ describe ::ContactIdentifyAction do describe '#perform' do it 'updates the contact' do - expect(ContactAvatarJob).not_to receive(:perform_later).with(contact, params[:avatar_url]) + expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later).with(contact, params[:avatar_url]) contact_identify expect(contact.reload.name).to eq 'test' # custom attributes are merged properly without overwriting existing ones @@ -32,7 +32,7 @@ describe ::ContactIdentifyAction do it 'enques avatar job when avatar url parameter is passed' do params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' } - expect(ContactAvatarJob).to receive(:perform_later).with(contact, params[:avatar_url]).once + expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once described_class.new(contact: contact, params: params).perform end diff --git a/spec/builders/v2/report_builder_spec.rb b/spec/builders/v2/report_builder_spec.rb index c2c9977b3..5f63ccac2 100644 --- a/spec/builders/v2/report_builder_spec.rb +++ b/spec/builders/v2/report_builder_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' describe ::V2::ReportBuilder do + include ActiveJob::TestHelper let!(:account) { create(:account) } let!(:user) { create(:user, account: account) } let!(:inbox) { create(:inbox, account: account) } @@ -8,49 +9,44 @@ describe ::V2::ReportBuilder do let!(:label_1) { create(:label, title: 'Label_1', account: account) } let!(:label_2) { create(:label, title: 'Label_2', account: account) } - # Running jobs inline to calculate the exact metrics - around do |test| - current_adapter = ActiveJob::Base.queue_adapter - ActiveJob::Base.queue_adapter = :inline - - test.run - ensure - ActiveJob::Base.queue_adapter = current_adapter - end - describe '#timeseries' do before do - 10.times do - conversation = create(:conversation, account: account, - inbox: inbox, assignee: user, - created_at: Time.zone.today) - create_list(:message, 5, message_type: 'outgoing', - account: account, inbox: inbox, - conversation: conversation, created_at: Time.zone.today + 2.hours) - create_list(:message, 2, message_type: 'incoming', - account: account, inbox: inbox, - conversation: conversation, - created_at: Time.zone.today + 3.hours) - conversation.update_labels('label_1') - conversation.label_list - conversation.save! - end + gravatar_url = 'https://www.gravatar.com' + stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404) - 5.times do - conversation = create(:conversation, account: account, - inbox: inbox, assignee: user, - created_at: (Time.zone.today - 2.days)) - create_list(:message, 3, message_type: 'outgoing', - account: account, inbox: inbox, - conversation: conversation, - created_at: (Time.zone.today - 2.days)) - create_list(:message, 1, message_type: 'incoming', - account: account, inbox: inbox, - conversation: conversation, - created_at: (Time.zone.today - 2.days)) - conversation.update_labels('label_2') - conversation.label_list - conversation.save! + perform_enqueued_jobs do + 10.times do + conversation = create(:conversation, account: account, + inbox: inbox, assignee: user, + created_at: Time.zone.today) + create_list(:message, 5, message_type: 'outgoing', + account: account, inbox: inbox, + conversation: conversation, created_at: Time.zone.today + 2.hours) + create_list(:message, 2, message_type: 'incoming', + account: account, inbox: inbox, + conversation: conversation, + created_at: Time.zone.today + 3.hours) + conversation.update_labels('label_1') + conversation.label_list + conversation.save! + end + + 5.times do + conversation = create(:conversation, account: account, + inbox: inbox, assignee: user, + created_at: (Time.zone.today - 2.days)) + create_list(:message, 3, message_type: 'outgoing', + account: account, inbox: inbox, + conversation: conversation, + created_at: (Time.zone.today - 2.days)) + create_list(:message, 1, message_type: 'incoming', + account: account, inbox: inbox, + conversation: conversation, + created_at: (Time.zone.today - 2.days)) + conversation.update_labels('label_2') + conversation.label_list + conversation.save! + end end end diff --git a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb index cbc6661aa..434ae86d7 100644 --- a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do describe 'POST /api/v1/accounts/{account.id}/bulk_action' do context 'when it is an unauthenticated user' do - let(:agent) { create(:user) } + let!(:agent) { create(:user) } it 'returns unauthorized' do post "/api/v1/accounts/#{account.id}/bulk_actions", @@ -27,7 +27,7 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do end context 'when it is an authenticated user' do - let(:agent) { create(:user, account: account, role: :agent) } + let!(:agent) { create(:user, account: account, role: :agent) } it 'Ignores bulk_actions for wrong type' do post "/api/v1/accounts/#{account.id}/bulk_actions", @@ -117,7 +117,7 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do describe 'POST /api/v1/accounts/{account.id}/bulk_actions' do context 'when it is an authenticated user' do - let(:agent) { create(:user, account: account, role: :agent) } + let!(:agent) { create(:user, account: account, role: :agent) } it 'Bulk delete conversation labels' do Conversation.first.add_labels(%w[support priority_customer]) diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index 0c9b77443..b3390fe03 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -487,6 +487,14 @@ RSpec.describe 'Contacts API', type: :request do contact.reload expect(contact.avatar.attached?).to be(true) end + + it 'updated avatar with avatar_url' do + patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", + params: valid_params.merge(avatar_url: 'http://example.com/avatar.png'), + headers: admin.create_new_auth_token + expect(response).to have_http_status(:success) + expect(Avatar::AvatarFromUrlJob).to have_been_enqueued.with(contact, 'http://example.com/avatar.png') + end end end diff --git a/spec/factories/inbox_members.rb b/spec/factories/inbox_members.rb index 3ee68ebfc..14bf5edbe 100644 --- a/spec/factories/inbox_members.rb +++ b/spec/factories/inbox_members.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :inbox_member do - user + user { create(:user, :with_avatar) } inbox end end diff --git a/spec/jobs/avatar/avatar_from_gravatar_job_spec.rb b/spec/jobs/avatar/avatar_from_gravatar_job_spec.rb new file mode 100644 index 000000000..c190bf17b --- /dev/null +++ b/spec/jobs/avatar/avatar_from_gravatar_job_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe Avatar::AvatarFromGravatarJob, type: :job do + let(:avatarable) { create(:contact) } + let(:email) { 'test@test.com' } + let(:gravatar_url) { "https://www.gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}?d=404" } + + it 'enqueues the job' do + expect { described_class.perform_later(avatarable, email) }.to have_enqueued_job(described_class) + .on_queue('low') + end + + it 'will call AvatarFromUrlJob with gravatar url' do + expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(avatarable, gravatar_url) + described_class.perform_now(avatarable, email) + end + + it 'will not call AvatarFromUrlJob if DISABLE_GRAVATAR is configured' do + with_modified_env DISABLE_GRAVATAR: 'true' do + expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later).with(avatarable, gravatar_url) + described_class.perform_now(avatarable, '') + end + end + + it 'will not call AvatarFromUrlJob if email is blank' do + expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later).with(avatarable, gravatar_url) + described_class.perform_now(avatarable, '') + end +end diff --git a/spec/jobs/avatar/avatar_from_url_job_spec.rb b/spec/jobs/avatar/avatar_from_url_job_spec.rb new file mode 100644 index 000000000..9a4ebd646 --- /dev/null +++ b/spec/jobs/avatar/avatar_from_url_job_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +RSpec.describe Avatar::AvatarFromUrlJob, type: :job do + let(:avatarable) { create(:contact) } + let(:avatar_url) { 'https://example.com/avatar.png' } + + it 'enqueues the job' do + expect { described_class.perform_later(avatarable, avatar_url) }.to have_enqueued_job(described_class) + .on_queue('default') + end + + it 'will attach avatar from url' do + expect(avatarable.avatar).not_to be_attached + expect(Down).to receive(:download).with(avatar_url, + max_size: 15 * 1024 * 1024).and_return(fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), + 'image/png')) + described_class.perform_now(avatarable, avatar_url) + expect(avatarable.avatar).to be_attached + end +end diff --git a/spec/models/agent_bot_spec.rb b/spec/models/agent_bot_spec.rb index 682da1e74..87a3291b3 100644 --- a/spec/models/agent_bot_spec.rb +++ b/spec/models/agent_bot_spec.rb @@ -1,5 +1,6 @@ require 'rails_helper' require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb' +require Rails.root.join 'spec/models/concerns/avatarable_shared.rb' RSpec.describe AgentBot, type: :model do describe 'associations' do @@ -9,5 +10,6 @@ RSpec.describe AgentBot, type: :model do describe 'concerns' do it_behaves_like 'access_tokenable' + it_behaves_like 'avatarable' end end diff --git a/spec/models/concerns/avatarable_shared.rb b/spec/models/concerns/avatarable_shared.rb new file mode 100644 index 000000000..188868fa1 --- /dev/null +++ b/spec/models/concerns/avatarable_shared.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +shared_examples_for 'avatarable' do + let(:avatarable) { create(described_class.to_s.underscore) } + + it { is_expected.to have_one_attached(:avatar) } + + it 'add avatar_url method' do + expect(avatarable.respond_to?(:avatar_url)).to be true + end + + context 'when avatarable has an email attribute' do + it 'enques job when email is changed on avatarable create' do + avatarable = build(described_class.to_s.underscore, account: create(:account)) + if avatarable.respond_to?(:email) + avatarable.email = 'test@test.com' + avatarable.skip_reconfirmation! if avatarable.is_a? User + expect(Avatar::AvatarFromGravatarJob).to receive(:set).with(wait: 30.seconds).and_call_original + end + avatarable.save! + expect(Avatar::AvatarFromGravatarJob).to have_been_enqueued.with(avatarable, avatarable.email) if avatarable.respond_to?(:email) + end + + it 'enques job when email is changes on avatarable update' do + if avatarable.respond_to?(:email) + avatarable.email = 'xyc@test.com' + avatarable.skip_reconfirmation! if avatarable.is_a? User + expect(Avatar::AvatarFromGravatarJob).to receive(:set).with(wait: 30.seconds).and_call_original + end + avatarable.save! + expect(Avatar::AvatarFromGravatarJob).to have_been_enqueued.with(avatarable, avatarable.email) if avatarable.respond_to?(:email) + end + + it 'will not enqueu when email is not changed on avatarable update' do + avatarable.updated_at = Time.now.utc + expect do + avatarable.save! + end.not_to have_enqueued_job(Avatar::AvatarFromGravatarJob) + end + end +end diff --git a/spec/models/contact_spec.rb b/spec/models/contact_spec.rb index 900d96c2f..64095a830 100644 --- a/spec/models/contact_spec.rb +++ b/spec/models/contact_spec.rb @@ -2,6 +2,8 @@ require 'rails_helper' +require Rails.root.join 'spec/models/concerns/avatarable_shared.rb' + RSpec.describe Contact do context 'validations' do it { is_expected.to validate_presence_of(:account_id) } @@ -12,6 +14,10 @@ RSpec.describe Contact do it { is_expected.to have_many(:conversations).dependent(:destroy_async) } end + describe 'concerns' do + it_behaves_like 'avatarable' + end + context 'prepare contact attributes before validation' do it 'sets email to lowercase' do contact = create(:contact, email: 'Test@test.com') diff --git a/spec/models/inbox_spec.rb b/spec/models/inbox_spec.rb index f1aeca2e5..ced8eef2a 100644 --- a/spec/models/inbox_spec.rb +++ b/spec/models/inbox_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' require Rails.root.join 'spec/models/concerns/out_of_offisable_shared.rb' +require Rails.root.join 'spec/models/concerns/avatarable_shared.rb' RSpec.describe Inbox do describe 'validations' do @@ -37,6 +38,7 @@ RSpec.describe Inbox do describe 'concerns' do it_behaves_like 'out_of_offisable' + it_behaves_like 'avatarable' end describe '#add_member' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 3c8c26cd4..4882dc08f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb' +require Rails.root.join 'spec/models/concerns/avatarable_shared.rb' RSpec.describe User do let!(:user) { create(:user) } @@ -25,6 +26,7 @@ RSpec.describe User do describe 'concerns' do it_behaves_like 'access_tokenable' + it_behaves_like 'avatarable' end describe 'pubsub_token' do From 32291f4f7d0dd4262ff0ef0932d5a7417556a3d8 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Fri, 22 Jul 2022 13:14:17 +0530 Subject: [PATCH 09/77] fix: Update browser_language logic to include all languages (#5090) --- .../api/v1/widget/base_controller.rb | 5 +- .../advancedFilterItems/languages.js | 724 ++++++++++++++++-- .../{ => specs}/advancedFilterItems.spec.js | 4 +- .../specs/languages.spec.js | 10 + .../dashboard/i18n/locale/en/contact.json | 1 + .../conversation/ConversationInfo.vue | 10 + 6 files changed, 693 insertions(+), 61 deletions(-) rename app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/{ => specs}/advancedFilterItems.spec.js (71%) create mode 100644 app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/specs/languages.spec.js diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index d854114c4..9ec702e6c 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -36,9 +36,10 @@ class Api::V1::Widget::BaseController < ApplicationController contact_id: @contact.id, contact_inbox_id: @contact_inbox.id, additional_attributes: { + browser_language: browser.accept_language&.first&.code, browser: browser_params, - referer: permitted_params[:message][:referer_url], - initiated_at: timestamp_params + initiated_at: timestamp_params, + referer: permitted_params[:message][:referer_url] }, custom_attributes: permitted_params[:custom_attributes].presence || {} } diff --git a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js index 093eda3b9..31529f0c7 100644 --- a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js +++ b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js @@ -1,132 +1,742 @@ const languages = [ { - id: 'eng', - name: 'English (en)', + name: 'Abkhazian', + id: 'ab', }, { + name: 'Afar', + id: 'aa', + }, + { + name: 'Afrikaans', + id: 'af', + }, + { + name: 'Akan', + id: 'ak', + }, + { + name: 'Albanian', + id: 'sq', + }, + { + name: 'Amharic', + id: 'am', + }, + { + name: 'Arabic', id: 'ar', - name: 'العربية (ar)', }, { - id: 'nl', - name: 'Nederlands (nl)', + name: 'Aragonese', + id: 'an', }, { - id: 'fr', - name: 'Français (fr)', + name: 'Armenian', + id: 'hy', }, { - id: 'de', - name: 'Deutsch (de)', + name: 'Assamese', + id: 'as', }, { - id: 'हिन्दी (hi)', - name: 'hi', + name: 'Avaric', + id: 'av', }, { - id: 'it', - name: 'Italiano (it)', + name: 'Avestan', + id: 'ae', }, { - id: 'ja', - name: '日本語 (ja)', + name: 'Aymara', + id: 'ay', }, { - id: 'ko', - name: '한국어 (ko)', + name: 'Azerbaijani', + id: 'az', }, { - id: 'pt', - name: 'Português (pt)', + name: 'Bambara', + id: 'bm', }, { - id: 'ru', - name: 'русский (ru)', + name: 'Bashkir', + id: 'ba', }, { - id: 'zh', - name: '中文 (zh)', + name: 'Basque', + id: 'eu', }, { - id: 'es', - name: 'Español (es)', + name: 'Belarusian', + id: 'be', }, { - id: 'ml', - name: 'മലയാളം (ml)', + name: 'Bengali', + id: 'bn', }, { + name: 'Bislama', + id: 'bi', + }, + { + name: 'Bosnian', + id: 'bs', + }, + { + name: 'Breton', + id: 'br', + }, + { + name: 'Bulgarian', + id: 'bg', + }, + { + name: 'Burmese', + id: 'my', + }, + { + name: 'Catalan', id: 'ca', - name: 'Català (ca)', }, { - id: 'el', - name: 'ελληνικά (el)', + name: 'Chamorro', + id: 'ch', }, { - id: 'pt-BR', - name: 'Português Brasileiro (pt-BR)', + name: 'Chechen', + id: 'ce', }, { - id: 'ro', - name: 'Română (ro)', + name: 'Chichewa', + id: 'ny', }, { - id: 'ta', - name: 'தமிழ் (ta)', + name: 'Chinese', + id: 'zh', }, { - id: 'fa', - name: 'فارسی (fa)', + name: 'Church Slavonic', + id: 'cu', }, { - id: 'zh-TW', - name: '中文 (台湾) (zh-TW)', + name: 'Chuvash', + id: 'cv', }, { - id: 'vi', - name: 'Tiếng Việt (vi)', + name: 'Cornish', + id: 'kw', }, { - id: 'da', - name: 'dansk (da)', + name: 'Corsican', + id: 'co', }, { - id: 'tr', - name: 'Türkçe (tr)', + name: 'Cree', + id: 'cr', }, { + name: 'Croatian', + id: 'hr', + }, + { + name: 'Czech', id: 'cs', - name: 'čeština (cs)', }, { + name: 'Danish', + id: 'da', + }, + { + name: 'Divehi', + id: 'dv', + }, + { + name: 'Dutch', + id: 'nl', + }, + { + name: 'Dzongkha', + id: 'dz', + }, + { + name: 'English', + id: 'en', + }, + { + name: 'Esperanto', + id: 'eo', + }, + { + name: 'Estonian', + id: 'et', + }, + { + name: 'Ewe', + id: 'ee', + }, + { + name: 'Faroese', + id: 'fo', + }, + { + name: 'Fijian', + id: 'fj', + }, + { + name: 'Finnish', id: 'fi', - name: 'suomi, suomen kieli (fi)', }, { - id: 'id', - name: 'Bahasa Indonesia (id)', + name: 'French', + id: 'fr', }, { - id: 'sv', - name: 'Svenska (sv)', + name: 'Western Frisian', + id: 'fy', }, { + name: 'Fulah', + id: 'ff', + }, + { + name: 'Gaelic', + id: 'gd', + }, + { + name: 'Galician', + id: 'gl', + }, + { + name: 'Ganda', + id: 'lg', + }, + { + name: 'Georgian', + id: 'ka', + }, + { + name: 'German', + id: 'de', + }, + { + name: 'Greek', + id: 'el', + }, + { + name: 'Kalaallisut', + id: 'kl', + }, + { + name: 'Guarani', + id: 'gn', + }, + { + name: 'Gujarati', + id: 'gu', + }, + { + name: 'Haitian', + id: 'ht', + }, + { + name: 'Hausa', + id: 'ha', + }, + { + name: 'Hebrew', + id: 'he', + }, + { + name: 'Herero', + id: 'hz', + }, + { + name: 'Hindi', + id: 'hi', + }, + { + name: 'Hiri Motu', + id: 'ho', + }, + { + name: 'Hungarian', id: 'hu', - name: 'magyar nyelv (hu)', }, { + name: 'Icelandic', + id: 'is', + }, + { + name: 'Ido', + id: 'io', + }, + { + name: 'Igbo', + id: 'ig', + }, + { + name: 'Indonesian', + id: 'id', + }, + { + name: 'Interlingua', + id: 'ia', + }, + { + name: 'Interlingue', + id: 'ie', + }, + { + name: 'Inuktitut', + id: 'iu', + }, + { + name: 'Inupiaq', + id: 'ik', + }, + { + name: 'Irish', + id: 'ga', + }, + { + name: 'Italian', + id: 'it', + }, + { + name: 'Japanese', + id: 'ja', + }, + { + name: 'Javanese', + id: 'jv', + }, + { + name: 'Kannada', + id: 'kn', + }, + { + name: 'Kanuri', + id: 'kr', + }, + { + name: 'Kashmiri', + id: 'ks', + }, + { + name: 'Kazakh', + id: 'kk', + }, + { + name: 'Central Khmer', + id: 'km', + }, + { + name: 'Kikuyu', + id: 'ki', + }, + { + name: 'Kinyarwanda', + id: 'rw', + }, + { + name: 'Kirghiz', + id: 'ky', + }, + { + name: 'Komi', + id: 'kv', + }, + { + name: 'Kongo', + id: 'kg', + }, + { + name: 'Korean', + id: 'ko', + }, + { + name: 'Kuanyama', + id: 'kj', + }, + { + name: 'Kurdish', + id: 'ku', + }, + { + name: 'Lao', + id: 'lo', + }, + { + name: 'Latin', + id: 'la', + }, + { + name: 'Latvian', + id: 'lv', + }, + { + name: 'Limburgan', + id: 'li', + }, + { + name: 'Lingala', + id: 'ln', + }, + { + name: 'Lithuanian', + id: 'lt', + }, + { + name: 'Luba-Katanga', + id: 'lu', + }, + { + name: 'Luxembourgish', + id: 'lb', + }, + { + name: 'Macedonian', + id: 'mk', + }, + { + name: 'Malagasy', + id: 'mg', + }, + { + name: 'Malay', + id: 'ms', + }, + { + name: 'Malayalam', + id: 'ml', + }, + { + name: 'Maltese', + id: 'mt', + }, + { + name: 'Manx', + id: 'gv', + }, + { + name: 'Maori', + id: 'mi', + }, + { + name: 'Marathi', + id: 'mr', + }, + { + name: 'Marshallese', + id: 'mh', + }, + { + name: 'Mongolian', + id: 'mn', + }, + { + name: 'Nauru', + id: 'na', + }, + { + name: 'Navajo', + id: 'nv', + }, + { + name: 'North Ndebele', + id: 'nd', + }, + { + name: 'South Ndebele', + id: 'nr', + }, + { + name: 'Ndonga', + id: 'ng', + }, + { + name: 'Nepali', + id: 'ne', + }, + { + name: 'Norwegian', id: 'no', - name: 'norsk (no)', }, { - id: 'zh-CN', - name: '中文 (zh-CN)', + name: 'Norwegian Bokmål', + id: 'nb', }, { + name: 'Norwegian Nynorsk', + id: 'nn', + }, + { + name: 'Sichuan Yi', + id: 'ii', + }, + { + name: 'Occitan', + id: 'oc', + }, + { + name: 'Ojibwa', + id: 'oj', + }, + { + name: 'Oriya', + id: 'or', + }, + { + name: 'Oromo', + id: 'om', + }, + { + name: 'Ossetian', + id: 'os', + }, + { + name: 'Pali', + id: 'pi', + }, + { + name: 'Pashto, Pushto', + id: 'ps', + }, + { + name: 'Persian', + id: 'fa', + }, + { + name: 'Polish', id: 'pl', - name: 'język polski (pl)', + }, + { + name: 'Portuguese', + id: 'pt', + }, + { + name: 'Punjabi', + id: 'pa', + }, + { + name: 'Quechua', + id: 'qu', + }, + { + name: 'Romanian', + id: 'ro', + }, + { + name: 'Romansh', + id: 'rm', + }, + { + name: 'Rundi', + id: 'rn', + }, + { + name: 'Russian', + id: 'ru', + }, + { + name: 'Northern Sami', + id: 'se', + }, + { + name: 'Samoan', + id: 'sm', + }, + { + name: 'Sango', + id: 'sg', + }, + { + name: 'Sanskrit', + id: 'sa', + }, + { + name: 'Sardinian', + id: 'sc', + }, + { + name: 'Serbian', + id: 'sr', + }, + { + name: 'Shona', + id: 'sn', + }, + { + name: 'Sindhi', + id: 'sd', + }, + { + name: 'Sinhala', + id: 'si', + }, + { + name: 'Slovak', + id: 'sk', + }, + { + name: 'Slovenian', + id: 'sl', + }, + { + name: 'Somali', + id: 'so', + }, + { + name: 'Southern Sotho', + id: 'st', + }, + { + name: 'Spanish', + id: 'es', + }, + { + name: 'Sundanese', + id: 'su', + }, + { + name: 'Swahili', + id: 'sw', + }, + { + name: 'Swati', + id: 'ss', + }, + { + name: 'Swedish', + id: 'sv', + }, + { + name: 'Tagalog', + id: 'tl', + }, + { + name: 'Tahitian', + id: 'ty', + }, + { + name: 'Tajik', + id: 'tg', + }, + { + name: 'Tamil', + id: 'ta', + }, + { + name: 'Tatar', + id: 'tt', + }, + { + name: 'Telugu', + id: 'te', + }, + { + name: 'Thai', + id: 'th', + }, + { + name: 'Tibetan', + id: 'bo', + }, + { + name: 'Tigrinya', + id: 'ti', + }, + { + name: 'Tonga', + id: 'to', + }, + { + name: 'Tsonga', + id: 'ts', + }, + { + name: 'Tswana', + id: 'tn', + }, + { + name: 'Turkish', + id: 'tr', + }, + { + name: 'Turkmen', + id: 'tk', + }, + { + name: 'Twi', + id: 'tw', + }, + { + name: 'Uighur', + id: 'ug', + }, + { + name: 'Ukrainian', + id: 'uk', + }, + { + name: 'Urdu', + id: 'ur', + }, + { + name: 'Uzbek', + id: 'uz', + }, + { + name: 'Venda', + id: 've', + }, + { + name: 'Vietnamese', + id: 'vi', + }, + { + name: 'Volapük', + id: 'vo', + }, + { + name: 'Walloon', + id: 'wa', + }, + { + name: 'Welsh', + id: 'cy', + }, + { + name: 'Wolof', + id: 'wo', + }, + { + name: 'Xhosa', + id: 'xh', + }, + { + name: 'Yiddish', + id: 'yi', + }, + { + name: 'Yoruba', + id: 'yo', + }, + { + name: 'Zhuang, Chuang', + id: 'za', + }, + { + name: 'Zulu', + id: 'zu', }, ]; +export const getLanguageName = (languageCode = '') => { + const languageObj = + languages.find(language => language.id === languageCode) || {}; + return languageObj.name || ''; +}; + export default languages; diff --git a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/advancedFilterItems.spec.js b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/specs/advancedFilterItems.spec.js similarity index 71% rename from app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/advancedFilterItems.spec.js rename to app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/specs/advancedFilterItems.spec.js index 69eafa994..2132ebaf2 100644 --- a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/advancedFilterItems.spec.js +++ b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/specs/advancedFilterItems.spec.js @@ -1,5 +1,5 @@ -import defaultFilters from './index'; -import { filterAttributeGroups } from './index'; +import defaultFilters from '../index'; +import { filterAttributeGroups } from '../index'; describe('#filterItems', () => { it('Matches the correct filterItems', () => { diff --git a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/specs/languages.spec.js b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/specs/languages.spec.js new file mode 100644 index 000000000..52bc981fd --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/specs/languages.spec.js @@ -0,0 +1,10 @@ +import { getLanguageName } from '../languages'; + +describe('#getLanguageName', () => { + it('Returns correct language name', () => { + expect(getLanguageName('es')).toEqual('Spanish'); + expect(getLanguageName()).toEqual(''); + expect(getLanguageName('rrr')).toEqual(''); + expect(getLanguageName('')).toEqual(''); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 2833e37d8..0bc399ba1 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -6,6 +6,7 @@ "COPY_SUCCESSFUL": "Copied to clipboard successfully", "COMPANY": "Company", "LOCATION": "Location", + "BROWSER_LANGUAGE": "Browser Language", "CONVERSATION_TITLE": "Conversation Details", "VIEW_PROFILE": "View Profile", "BROWSER": "Browser", diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationInfo.vue index 07883b08a..16a41cc94 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationInfo.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationInfo.vue @@ -6,6 +6,12 @@ :value="initiatedAt.timestamp" class="conversation--attribute" /> + @@ -647,6 +673,13 @@ export default { margin-bottom: var(--space-normal); } +.conversations-list { + // Prevent the list from scrolling if the submenu is opened + &.is-context-menu-open { + overflow: hidden !important; + } +} + .conversations-list-wrap { flex-shrink: 0; width: 34rem; diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue index 810172ae5..afcdc0b1d 100644 --- a/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -113,6 +113,7 @@ import { mapGetters } from 'vuex'; import { mixin as clickaway } from 'vue-clickaway'; import alertMixin from 'shared/mixins/alertMixin'; +import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin.js'; import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import { hasPressedAltAndEKey, @@ -126,13 +127,6 @@ import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider'; import wootConstants from '../../constants'; -import { - getUnixTime, - addHours, - addWeeks, - startOfTomorrow, - startOfWeek, -} from 'date-fns'; import { CMD_REOPEN_CONVERSATION, CMD_RESOLVE_CONVERSATION, @@ -146,7 +140,7 @@ export default { WootDropdownSubMenu, WootDropdownDivider, }, - mixins: [clickaway, alertMixin, eventListenerMixins], + mixins: [clickaway, alertMixin, eventListenerMixins, snoozeTimesMixin], props: { conversationId: { type: [String, Number], required: true } }, data() { return { @@ -178,16 +172,6 @@ export default { showAdditionalActions() { return !this.isPending && !this.isSnoozed; }, - snoozeTimes() { - return { - // tomorrow = 9AM next day - tomorrow: getUnixTime(addHours(startOfTomorrow(), 9)), - // next week = 9AM Monday, next week - nextWeek: getUnixTime( - addHours(startOfWeek(addWeeks(new Date(), 1), { weekStartsOn: 1 }), 9) - ), - }; - }, }, mounted() { bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation); diff --git a/app/javascript/dashboard/components/index.js b/app/javascript/dashboard/components/index.js index d2614421c..5e58aa049 100644 --- a/app/javascript/dashboard/components/index.js +++ b/app/javascript/dashboard/components/index.js @@ -22,6 +22,7 @@ import Tabs from './ui/Tabs/Tabs'; import TabsItem from './ui/Tabs/TabsItem'; import Thumbnail from './widgets/Thumbnail.vue'; import ConfirmModal from './widgets/modal/ConfirmationModal.vue'; +import ContextMenu from './ui/ContextMenu.vue'; const WootUIKit = { AvatarUploader, @@ -47,6 +48,7 @@ const WootUIKit = { TabsItem, Thumbnail, ConfirmModal, + ContextMenu, install(Vue) { const keys = Object.keys(this); keys.pop(); // remove 'install' from keys diff --git a/app/javascript/dashboard/components/ui/ContextMenu.vue b/app/javascript/dashboard/components/ui/ContextMenu.vue new file mode 100644 index 000000000..b968cda19 --- /dev/null +++ b/app/javascript/dashboard/components/ui/ContextMenu.vue @@ -0,0 +1,53 @@ + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index 7b90ef6eb..b3c36928c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -10,6 +10,7 @@ @mouseenter="onCardHover" @mouseleave="onCardLeave" @click="cardClick(chat)" + @contextmenu="openContextMenu($event)" >