Merge branch 'develop' into report-rewrite

This commit is contained in:
Pranav
2024-02-13 10:13:10 -08:00
49 changed files with 975 additions and 75 deletions
-1
View File
@@ -254,7 +254,6 @@ AZURE_APP_SECRET=
# Sentiment analysis model file path
SENTIMENT_FILE_PATH=
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
+9 -9
View File
@@ -316,16 +316,16 @@ GEM
google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.22.3)
google-protobuf (3.22.3-arm64-darwin)
google-protobuf (3.22.3-x86_64-darwin)
google-protobuf (3.22.3-x86_64-linux)
google-protobuf (3.25.2)
google-protobuf (3.25.2-arm64-darwin)
google-protobuf (3.25.2-x86_64-darwin)
google-protobuf (3.25.2-x86_64-linux)
googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos-types (1.6.0)
google-protobuf (~> 3.14)
googleapis-common-protos-types (1.11.0)
google-protobuf (~> 3.18)
googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
@@ -335,13 +335,13 @@ GEM
signet (>= 0.16, < 2.a)
groupdate (6.2.1)
activesupport (>= 5.2)
grpc (1.54.0)
grpc (1.54.3)
google-protobuf (~> 3.21)
googleapis-common-protos-types (~> 1.0)
grpc (1.54.0-x86_64-darwin)
grpc (1.54.3-x86_64-darwin)
google-protobuf (~> 3.21)
googleapis-common-protos-types (~> 1.0)
grpc (1.54.0-x86_64-linux)
grpc (1.54.3-x86_64-linux)
google-protobuf (~> 3.21)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
@@ -0,0 +1,53 @@
class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
end
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end
def group_by_key
:user_id
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
end
def prepare_report
account.account_users.each_with_object([]) do |account_user, arr|
arr << {
id: account_user.user_id,
conversations_count: @grouped_conversations_count[account_user.user_id],
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
}
end
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -0,0 +1,17 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
private
def group_by_key
# Override this method
end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key
params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -0,0 +1,49 @@
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
end
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
end
def group_by_key
'conversations.team_id'
end
def prepare_report
account.teams.each_with_object([]) do |team, arr|
arr << {
id: team.id,
conversations_count: @grouped_conversations_count[team.id],
avg_resolution_time: @grouped_avg_resolution_time[team.id],
avg_first_response_time: @grouped_avg_first_response_time[team.id],
avg_reply_time: @grouped_avg_reply_time[team.id]
}
end
end
end
@@ -43,7 +43,11 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
inviter: current_user,
account: Current.account
)
builder.perform
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
head :ok
end
@@ -44,7 +44,9 @@ class Api::V1::AccountsController < Api::BaseController
end
def update
@account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
@account.custom_attributes.merge!(custom_attributes_params)
@account.save!
end
def update_active_at
@@ -83,6 +85,10 @@ class Api::V1::AccountsController < Api::BaseController
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone)
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end
@@ -10,7 +10,9 @@ class Api::V1::ProfilesController < Api::BaseController
@user.update!(password_params.except(:current_password))
end
@user.update!(profile_params)
@user.assign_attributes(profile_params)
@user.custom_attributes.merge!(custom_attributes_params)
@user.save!
end
def avatar
@@ -62,6 +64,10 @@ class Api::V1::ProfilesController < Api::BaseController
)
end
def custom_attributes_params
params.require(:profile).permit(:phone_number)
end
def password_params
params.require(:profile).permit(
:current_password,
@@ -0,0 +1,36 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
end
def team
render_report_with(V2::Reports::TeamSummaryBuilder)
end
private
def check_authorization
authorize :report, :view?
end
def prepare_builder_params
@builder_params = {
since: permitted_params[:since],
until: permitted_params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
}
end
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
data = builder.build
render json: data
end
def permitted_params
params.permit(:since, :until, :business_hours)
end
end
@@ -17,8 +17,12 @@ class Api::V2::AccountsController < Api::BaseController
@user, @account = AccountBuilder.new(
email: account_params[:email],
user_password: account_params[:password],
locale: account_params[:locale],
user: current_user
).perform
fetch_account_and_user_info
if @user
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
@@ -29,6 +33,8 @@ class Api::V2::AccountsController < Api::BaseController
private
def fetch_account_and_user_info; end
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
@@ -46,3 +52,5 @@ class Api::V2::AccountsController < Api::BaseController
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
end
end
Api::V2::AccountsController.prepend_mod_with('Api::V2::AccountsController')
@@ -1,7 +1,8 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status create],
'api/v1/accounts/conversations/messages' => ['create']
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create],
'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create']
}.freeze
def ensure_access_token
@@ -25,6 +25,6 @@ module EnsureCurrentAccountHelper
end
def account_accessible_for_bot?(account)
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end
end
@@ -28,8 +28,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
end
def sha
sha = `git rev-parse HEAD`
@metrics['Git SHA'] = sha.presence || 'n/a'
@metrics['Git SHA'] = GIT_HASH
end
def postgres_status
@@ -45,12 +45,8 @@ module Api::V2::Accounts::ReportsHelper
def generate_readable_report_metrics(report_metric)
[
report_metric[:conversations_count],
time_to_minutes(report_metric[:avg_first_response_time]),
time_to_minutes(report_metric[:avg_resolution_time])
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format
]
end
def time_to_minutes(time_in_seconds)
(time_in_seconds / 60).to_i
end
end
@@ -8,6 +8,7 @@ import {
buildConversationList,
isOnMentionsView,
isOnUnattendedView,
isOnFoldersView,
} from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
@@ -321,12 +322,12 @@ const actions = {
inbox_id: inboxId,
meta: { sender },
} = conversation;
const hasAppliedFilters = !!appliedFilters.length;
const isMatchingInboxFilter =
!currentInbox || Number(currentInbox) === inboxId;
if (
!hasAppliedFilters &&
!isOnFoldersView(rootState) &&
!isOnMentionsView(rootState) &&
!isOnUnattendedView(rootState) &&
isMatchingInboxFilter
@@ -30,6 +30,14 @@ export const isOnUnattendedView = ({ route: { name: routeName } }) => {
return UNATTENDED_ROUTES.includes(routeName);
};
export const isOnFoldersView = ({ route: { name: routeName } }) => {
const FOLDER_ROUTES = [
'folder_conversations',
'conversations_through_folders',
];
return FOLDER_ROUTES.includes(routeName);
};
export const buildConversationList = (
context,
requestPayload,
@@ -1,4 +1,4 @@
import { isOnMentionsView } from '../actionHelpers';
import { isOnMentionsView, isOnFoldersView } from '../actionHelpers';
describe('#isOnMentionsView', () => {
it('return valid responses when passing the state', () => {
@@ -10,3 +10,17 @@ describe('#isOnMentionsView', () => {
);
});
});
describe('#isOnFoldersView', () => {
it('return valid responses when passing the state', () => {
expect(isOnFoldersView({ route: { name: 'folder_conversations' } })).toBe(
true
);
expect(
isOnFoldersView({ route: { name: 'conversations_through_folders' } })
).toBe(true);
expect(isOnFoldersView({ route: { name: 'conversation_messages' } })).toBe(
false
);
});
});
@@ -181,6 +181,26 @@ describe('#actions', () => {
expect(dispatch.mock.calls).toEqual([]);
});
it('doesnot send mutation if the view is conversation folders', () => {
const conversation = {
id: 1,
messages: [],
meta: { sender: { id: 1, name: 'john-doe' } },
inbox_id: 1,
};
actions.addConversation(
{
commit,
rootState: { route: { name: 'folder_conversations' } },
dispatch,
state: { currentInbox: 1, appliedFilters: [{ id: 'random-filter' }] },
},
conversation
);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([]);
});
it('sends correct mutations', () => {
const conversation = {
id: 1,
+1 -1
View File
@@ -23,7 +23,7 @@
'px-3 py-2 mb-0': spacing === 'compact',
'pl-9': icon,
}"
class="block w-full border-none rounded-xl shadow-sm appearance-none outline outline-1 outline-slate-200 dark:outline-slate-800 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
class="block w-full border-none shadow-sm appearance-none rounded-xl outline outline-1 outline-slate-200 dark:outline-slate-200/20 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
@input="onInput"
@blur="$emit('blur')"
/>
+6
View File
@@ -2,6 +2,7 @@ module MailboxHelper
private
def create_message
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
@message = @conversation.messages.create!(
@@ -36,6 +37,7 @@ module MailboxHelper
end
def process_regular_attachments(attachments)
Rails.logger.info "[MailboxHelper] Processing regular attachments for message with ID: #{processed_mail.message_id}"
attachments.each do |mail_attachment|
attachment = @message.attachments.new(
account_id: @conversation.account_id,
@@ -46,6 +48,8 @@ module MailboxHelper
end
def process_inline_attachments(attachments)
Rails.logger.info "[MailboxHelper] Processing inline attachments for message with ID: #{processed_mail.message_id}"
# create an instance variable here, the `embed_inline_image_source`
# updates them directly. And then the value is eventaully used to update the message content
@html_content = processed_mail.serialized_data[:html_content][:full]
@@ -98,7 +102,9 @@ module MailboxHelper
}
}
).perform
@contact = @contact_inbox.contact
Rails.logger.info "[MailboxHelper] Contact created with ID: #{@contact.id} for inbox with ID: #{@inbox.id}"
end
def notification_email_from_chatwoot?
+5
View File
@@ -0,0 +1,5 @@
class ReportPolicy < ApplicationPolicy
def view?
@account_user.administrator?
end
end
@@ -0,0 +1,33 @@
class Reports::TimeFormatPresenter
include ActionView::Helpers::TextHelper
attr_reader :seconds
def initialize(seconds)
@seconds = seconds.to_i
end
def format
return '--' if seconds.nil? || seconds.zero?
days, remainder = seconds.divmod(86_400)
hours, remainder = remainder.divmod(3600)
minutes, seconds = remainder.divmod(60)
format_components(days: days, hours: hours, minutes: minutes, seconds: seconds)
end
private
def format_components(components)
formatted_components = components.filter_map do |unit, value|
next if value.zero?
I18n.t("time_units.#{unit}", count: value)
end
return I18n.t('time_units.seconds', count: 0) if formatted_components.empty?
formatted_components.first(2).join(' ')
end
end
@@ -1,3 +1,5 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.agent_csv.agent_name'),
I18n.t('reports.agent_csv.conversations_count'),
@@ -9,4 +11,3 @@
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
@@ -1,4 +1,5 @@
<%= CSV.generate_line [I18n.t('reports.conversation_traffic_csv.timezone'), @timezone] %>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
@@ -1,3 +1,5 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.inbox_csv.inbox_name'),
I18n.t('reports.inbox_csv.inbox_type'),
@@ -10,4 +12,3 @@
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
@@ -1,3 +1,5 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.label_csv.label_title'),
I18n.t('reports.label_csv.conversations_count'),
@@ -9,4 +11,3 @@
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
@@ -1,3 +1,5 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.team_csv.team_name'),
I18n.t('reports.team_csv.conversations_count'),
@@ -9,4 +11,3 @@
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
+1 -1
View File
@@ -1,6 +1,6 @@
# Define a method to fetch the git commit hash
def fetch_git_sha
sha = `git rev-parse HEAD`
sha = `git rev-parse HEAD` if File.directory?('.git')
if sha.present?
sha.strip
elsif File.exist?('.git_sha')
+6
View File
@@ -148,6 +148,12 @@ class Rack::Attack
match_data[:account_id] if match_data.present?
end
## Prevent abuse of contact search api
throttle('/api/v1/accounts/:account_id/contacts/search', limit: 5, period: 1.minute) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/contacts/search}.match(req.path)
match_data[:account_id] if match_data.present?
end
## ----------------------------------------------- ##
end
+11 -13
View File
@@ -7,13 +7,11 @@
# value: the value of the config
# display_title: the title of the config displayed in the dashboard UI
# description: the description of the config displayed in the dashboard UI
# locked: if you don't specify locked attribute in yaml, the default value will be true,
# locked: if you don't specify locked attribute in yaml, the default value will be true,
# which means the particular config will be locked and won't be available in `super_admin/installation_configs`
# premium: These values get overwritten unless the user is on a premium plan
# type: The type of the config. Default is text, boolean is also supported
# ------- Branding Related Config ------- #
- name: INSTALLATION_NAME
value: 'Chatwoot'
@@ -58,8 +56,6 @@
type: boolean
# ------- End of Branding Related Config ------- #
# ------- Signup & Account Related Config ------- #
- name: ENABLE_ACCOUNT_SIGNUP
display_title: 'Enable Account Signup'
@@ -89,8 +85,6 @@
locked: false
# ------- End of Account Related Config ------- #
# ------- Email Related Config ------- #
- name: MAILER_INBOUND_EMAIL_DOMAIN
value:
@@ -101,12 +95,11 @@
locked: false
# ------- End of Email Related Config ------- #
# ------- Facebook Channel Related Config ------- #
- name: FB_APP_ID
- name: FB_APP_ID
display_title: 'Facebook App ID'
locked: false
- name: FB_VERIFY_TOKEN
- name: FB_VERIFY_TOKEN
display_title: 'Facebook Verify Token'
description: 'The verify token used for Facebook Messenger Webhook'
locked: false
@@ -128,10 +121,12 @@
# ------- Chatwoot Internal Config for Cloud ----#
- name: CHATWOOT_INBOX_TOKEN
value:
display_title: 'Inbox Token'
description: 'The Chatwoot Inbox Token for Contact Support in Cloud'
locked: false
- name: CHATWOOT_INBOX_HMAC_KEY
value:
display_title: 'Inbox HMAC Key'
description: 'The Chatwoot Inbox HMAC Key for Contact Support in Cloud'
locked: false
- name: CHATWOOT_CLOUD_PLANS
@@ -142,10 +137,14 @@
description: 'The deployment environment of the installation, to differentiate between Chatwoot cloud and self-hosted'
- name: ANALYTICS_TOKEN
value:
display_title: 'Analytics Token'
description: 'The June.so analytics token for Chatwoot cloud'
- name: CLEARBIT_API_KEY
value:
display_title: 'Clearbit API Key'
description: 'This API key is used for onboarding the users, to pre-fill account data.'
# ------- End of Chatwoot Internal Config for Cloud ----#
# ------- Chatwoot Internal Config for Self Hosted ----#
- name: INSTALLATION_PRICING_PLAN
value: 'community'
@@ -179,5 +178,4 @@
value: false
locked: false
description: 'Disable rendering profile update page for users'
## ------ End of Configs added for enterprise clients ------ ##
## ------ End of Configs added for enterprise clients ------ ##
+22 -9
View File
@@ -83,25 +83,25 @@ en:
utc_warning: The report generated is in UTC timezone
agent_csv:
agent_name: Agent name
conversations_count: Conversations count
avg_first_response_time: Avg first response time (Minutes)
avg_resolution_time: Avg resolution time (Minutes)
conversations_count: Assigned conversations
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
inbox_csv:
inbox_name: Inbox name
inbox_type: Inbox type
conversations_count: No. of conversations
avg_first_response_time: Avg first response time (Minutes)
avg_resolution_time: Avg resolution time (Minutes)
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
label_csv:
label_title: Label
conversations_count: No. of conversations
avg_first_response_time: Avg first response time (Minutes)
avg_resolution_time: Avg resolution time (Minutes)
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
team_csv:
team_name: Team name
conversations_count: Conversations count
avg_first_response_time: Avg first response time (Minutes)
avg_resolution_time: Avg resolution time (Minutes)
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
conversation_traffic_csv:
timezone: Timezone
default_group_by: day
@@ -245,3 +245,16 @@ en:
inbox_name: Inbox
inbox_type: Inbox Type
button: Open conversation
time_units:
days:
one: "%{count} day"
other: "%{count} days"
hours:
one: "%{count} hour"
other: "%{count} hours"
minutes:
one: "%{count} minute"
other: "%{count} minutes"
seconds:
one: "%{count} second"
other: "%{count} seconds"
+6
View File
@@ -300,6 +300,12 @@ Rails.application.routes.draw do
get :grouped_conversation_metrics
end
end
resources :summary_reports, only: [] do
collection do
get :agent
get :team
end
end
resources :reports, only: [:index] do
collection do
get :summary
@@ -0,0 +1,35 @@
module Enterprise::Api::V2::AccountsController
private
def fetch_account_and_user_info
data = fetch_from_clearbit
return if data.blank?
update_user_info(data)
update_account_info(data)
end
def fetch_from_clearbit
Enterprise::ClearbitLookupService.lookup(@user.email)
rescue StandardError => e
Rails.logger.error "Error fetching data from clearbit: #{e}"
nil
end
def update_user_info(data)
@user.update!(name: data[:name])
end
def update_account_info(data)
@account.update!(
name: data[:company_name],
custom_attributes: @account.custom_attributes.merge(
'industry' => data[:industry],
'company_size' => data[:company_size],
'timezone' => data[:timezone],
'logo' => data[:logo]
)
)
end
end
@@ -6,20 +6,30 @@ module Enterprise::SuperAdmin::AppConfigsController
case @config
when 'custom_branding'
@allowed_configs = %w[
LOGO_THUMBNAIL
LOGO
LOGO_DARK
BRAND_NAME
INSTALLATION_NAME
BRAND_URL
WIDGET_BRAND_URL
TERMS_URL
PRIVACY_URL
DISPLAY_MANIFEST
]
@allowed_configs = custom_branding_options
when 'internal'
@allowed_configs = internal_config_options
else
super
end
end
def custom_branding_options
%w[
LOGO_THUMBNAIL
LOGO
LOGO_DARK
BRAND_NAME
INSTALLATION_NAME
BRAND_URL
WIDGET_BRAND_URL
TERMS_URL
PRIVACY_URL
DISPLAY_MANIFEST
]
end
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY]
end
end
@@ -0,0 +1,92 @@
# The Enterprise::ClearbitLookupService class is responsible for interacting with the Clearbit API.
# It provides methods to lookup a person's information using their email.
# Clearbit API documentation: {https://dashboard.clearbit.com/docs?ruby#api-reference}
# We use the combined API which returns both the person and comapnies together
# Combined API: {https://dashboard.clearbit.com/docs?ruby=#enrichment-api-combined-api}
# Persons API: {https://dashboard.clearbit.com/docs?ruby=#enrichment-api-person-api}
# Companies API: {https://dashboard.clearbit.com/docs?ruby=#enrichment-api-company-api}
#
# Note: The Clearbit gem is not used in this service, since it is not longer maintained
# GitHub: {https://github.com/clearbit/clearbit-ruby}
#
# @example
# Enterprise::ClearbitLookupService.lookup('test@example.com')
class Enterprise::ClearbitLookupService
# Clearbit API endpoint for combined lookup
CLEARBIT_ENDPOINT = 'https://person.clearbit.com/v2/combined/find'.freeze
# Performs a lookup on the Clearbit API using the provided email.
#
# @param email [String] The email address to lookup.
# @return [Hash, nil] A hash containing the person's full name, company name, and company timezone, or nil if an error occurs.
def self.lookup(email)
return nil unless clearbit_enabled?
response = perform_request(email)
process_response(response)
rescue StandardError => e
Rails.logger.error "[ClearbitLookup] #{e.message}"
nil
end
# Performs a request to the Clearbit API using the provided email.
#
# @param email [String] The email address to lookup.
# @return [HTTParty::Response] The response from the Clearbit API.
def self.perform_request(email)
options = {
headers: { 'Authorization' => "Bearer #{clearbit_token}" },
query: { email: email }
}
HTTParty.get(CLEARBIT_ENDPOINT, options)
end
# Handles an error response from the Clearbit API.
#
# @param response [HTTParty::Response] The response from the Clearbit API.
# @return [nil] Always returns nil.
def self.handle_error(response)
Rails.logger.error "[ClearbitLookup] API Error: #{response.message} (Status: #{response.code})"
nil
end
# Checks if Clearbit is enabled by checking for the presence of the CLEARBIT_API_KEY environment variable.
#
# @return [Boolean] True if Clearbit is enabled, false otherwise.
def self.clearbit_enabled?
clearbit_token.present?
end
def self.clearbit_token
GlobalConfigService.load('CLEARBIT_API_KEY', '')
end
# Processes the response from the Clearbit API.
#
# @param response [HTTParty::Response] The response from the Clearbit API.
# @return [Hash, nil] A hash containing the person's full name, company name, and company timezone, or nil if an error occurs.
def self.process_response(response)
return handle_error(response) unless response.success?
format_response(response)
end
# Formats the response data from the Clearbit API.
#
# @param data [Hash] The raw data from the Clearbit API.
# @return [Hash] A hash containing the person's full name, company name, and company timezone.
def self.format_response(response)
data = response.parsed_response
{
name: data.dig('person', 'name', 'fullName'),
avatar: data.dig('person', 'avatar'),
company_name: data.dig('company', 'name'),
timezone: data.dig('company', 'timeZone'),
logo: data.dig('company', 'logo'),
industry: data.dig('company', 'category', 'industry'),
company_size: data.dig('company', 'metrics', 'employees')
}
end
end
@@ -5,7 +5,7 @@ RSpec.describe 'Agents API', type: :request do
let(:account) { create(:account) }
let!(:admin) { create(:user, custom_attributes: { test: 'test' }, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:agent) { create(:user, account: account, email: 'exists@example.com', role: :agent) }
describe 'GET /api/v1/accounts/{account.id}/agents' do
context 'when it is an unauthenticated user' do
@@ -196,6 +196,17 @@ RSpec.describe 'Agents API', type: :request do
expect(response).to have_http_status(:ok)
end
it 'ignores errors if account_user already exists' do
params = { emails: ['exists@example.com', 'test1@example.com', 'test2@example.com'] }
expect do
post "/api/v1/accounts/#{account.id}/agents/bulk_create", params: params,
headers: admin.create_new_auth_token
end.to change(User, :count).by(2)
expect(response).to have_http_status(:ok)
end
end
end
end
@@ -14,6 +14,26 @@ RSpec.describe 'Conversation Assignment API', type: :request do
end
end
context 'when it is an authenticated bot with out access to the inbox' do
let(:agent_bot) { create(:agent_bot, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
before do
create(:inbox_member, inbox: conversation.inbox, user: agent)
end
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/assignments",
headers: { api_access_token: agent_bot.access_token.token },
params: {
assignee_id: agent.id
},
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user with access to the inbox' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:team) { create(:team, account: account) }
@@ -52,6 +72,50 @@ RSpec.describe 'Conversation Assignment API', type: :request do
end
end
context 'when it is an authenticated bot with access to the inbox' do
let(:agent_bot) { create(:agent_bot, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:team) { create(:team, account: account) }
before do
create(:agent_bot_inbox, inbox: conversation.inbox, agent_bot: agent_bot)
end
it 'assignment of an agent in the conversation by bot agent' do
create(:inbox_member, user: agent, inbox: conversation.inbox)
conversation.update!(assignee_id: nil)
expect(conversation.reload.assignee).to be_nil
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/assignments",
headers: { api_access_token: agent_bot.access_token.token },
params: {
assignee_id: agent.id
},
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.assignee).to eq(agent)
end
it 'assignment of an team in the conversation by bot agent' do
create(:inbox_member, user: agent, inbox: conversation.inbox)
conversation.update!(team_id: nil)
expect(conversation.reload.team).to be_nil
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/assignments",
headers: { api_access_token: agent_bot.access_token.token },
params: {
team_id: team.id
},
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.team).to eq(team)
end
end
context 'when conversation already has an assignee' do
let(:agent) { create(:user, account: account, role: :agent) }
@@ -466,7 +466,11 @@ RSpec.describe 'Conversations API', type: :request do
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_priority' do
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account) }
let(:pending_conversation) { create(:conversation, inbox: inbox, account: account, status: 'pending') }
let(:agent) { create(:user, account: account, role: :agent) }
let(:agent_bot) { create(:agent_bot, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -477,7 +481,6 @@ RSpec.describe 'Conversations API', type: :request do
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
before do
@@ -509,6 +512,23 @@ RSpec.describe 'Conversations API', type: :request do
expect(conversation.reload.priority).to be_nil
end
end
context 'when it is an authenticated bot' do
it 'toggle the priority of the bot agent conversation' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
conversation.update!(priority: 'low')
expect(conversation.reload.priority).to eq('low')
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_priority",
headers: { api_access_token: agent_bot.access_token.token },
params: { priority: 'high' },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.priority).to eq('high')
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_typing_status' do
@@ -189,7 +189,10 @@ RSpec.describe 'Accounts API', type: :request do
locale: 'en',
domain: 'example.com',
support_email: 'care@example.com',
auto_resolve_duration: 40
auto_resolve_duration: 40,
timezone: 'Asia/Kolkata',
industry: 'Technology',
company_size: '1-10'
}
it 'modifies an account' do
@@ -204,6 +207,10 @@ RSpec.describe 'Accounts API', type: :request do
expect(account.reload.domain).to eq(params[:domain])
expect(account.reload.support_email).to eq(params[:support_email])
expect(account.reload.auto_resolve_duration).to eq(params[:auto_resolve_duration])
%w[timezone industry company_size].each do |attribute|
expect(account.reload.custom_attributes[attribute]).to eq(params[attribute.to_sym])
end
end
it 'Throws error 422' do
@@ -57,6 +57,18 @@ RSpec.describe 'Profile API', type: :request do
expect(agent.name).to eq('test')
end
it 'updates custom attributes' do
put '/api/v1/profile',
params: { profile: { phone_number: '+123456789' } },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
agent.reload
expect(agent.custom_attributes['phone_number']).to eq('+123456789')
end
it 'updates the message_signature' do
put '/api/v1/profile',
params: { profile: { name: 'test', message_signature: 'Thanks\nMy Signature' } },
@@ -0,0 +1,112 @@
require 'rails_helper'
RSpec.describe 'Summary Reports API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:default_timezone) { ActiveSupport::TimeZone[0]&.name }
let(:start_of_today) { Time.current.in_time_zone(default_timezone).beginning_of_day.to_i }
let(:end_of_today) { Time.current.in_time_zone(default_timezone).end_of_day.to_i }
describe 'GET /api/v2/accounts/:account_id/summary_reports/agent' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v2/accounts/#{account.id}/summary_reports/agent"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:params) do
{
since: start_of_today.to_s,
until: end_of_today.to_s,
business_hours: true
}
end
it 'returns unauthorized for agents' do
get "/api/v2/accounts/#{account.id}/summary_reports/agent",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'calls V2::Reports::AgentSummaryBuilder with the right params if the user is an admin' do
agent_summary_builder = double
allow(V2::Reports::AgentSummaryBuilder).to receive(:new).and_return(agent_summary_builder)
allow(agent_summary_builder).to receive(:build).and_return([{ id: 1, conversations_count: 110 }])
get "/api/v2/accounts/#{account.id}/summary_reports/agent",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(agent_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(1)
expect(json_response.first['conversations_count']).to eq(110)
expect(json_response.first['avg_reply_time']).to be_nil
end
end
end
describe 'GET /api/v2/accounts/:account_id/summary_reports/team' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v2/accounts/#{account.id}/summary_reports/team"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:params) do
{
since: start_of_today.to_s,
until: end_of_today.to_s,
business_hours: true
}
end
it 'returns unauthorized for agents' do
get "/api/v2/accounts/#{account.id}/summary_reports/team",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'calls V2::Reports::TeamSummaryBuilder with the right params if the user is an admin' do
team_summary_builder = double
allow(V2::Reports::TeamSummaryBuilder).to receive(:new).and_return(team_summary_builder)
allow(team_summary_builder).to receive(:build).and_return([{ id: 1, conversations_count: 110 }])
get "/api/v2/accounts/#{account.id}/summary_reports/team",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(team_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(1)
expect(json_response.first['conversations_count']).to eq(110)
expect(json_response.first['avg_reply_time']).to be_nil
end
end
end
end
@@ -17,7 +17,7 @@ RSpec.describe 'Accounts API', type: :request do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return([user, account])
params = { email: email, user: nil, password: 'Password1!' }
params = { email: email, user: nil, locale: nil, password: 'Password1!' }
post api_v2_accounts_url,
params: params,
@@ -37,7 +37,7 @@ RSpec.describe 'Accounts API', type: :request do
allow(ChatwootCaptcha).to receive(:new).and_return(captcha)
allow(captcha).to receive(:valid?).and_return(true)
params = { email: email, user: nil, password: 'Password1!', h_captcha_client_response: '123' }
params = { email: email, user: nil, password: 'Password1!', locale: nil, h_captcha_client_response: '123' }
post api_v2_accounts_url,
params: params,
@@ -53,7 +53,7 @@ RSpec.describe 'Accounts API', type: :request do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return(nil)
params = { email: nil, user: nil }
params = { email: nil, user: nil, locale: nil }
post api_v2_accounts_url,
params: params,
@@ -89,7 +89,7 @@ RSpec.describe 'Accounts API', type: :request do
allow(AccountBuilder).to receive(:new).and_return(account_builder)
allow(account_builder).to receive(:perform).and_return([user, account])
params = { email: email, user: nil, password: 'Password1!' }
params = { email: email, user: nil, password: 'Password1!', locale: nil }
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'api_only' do
post api_v2_accounts_url,
params: params,
@@ -17,8 +17,7 @@ RSpec.describe 'Super Admin Instance status', type: :request do
get '/super_admin/instance_status'
expect(response).to have_http_status(:success)
expect(response.body).to include('Chatwoot version')
sha = `git rev-parse HEAD`
expect(response.body).to include(sha)
expect(response.body).to include(GIT_HASH)
end
end
end
@@ -0,0 +1,72 @@
require 'rails_helper'
RSpec.describe Enterprise::Api::V2::AccountsController, type: :request do
let(:email) { Faker::Internet.email }
let(:user) { create(:user) }
let(:account) { create(:account) }
let(:clearbit_data) do
{
name: 'John Doe',
company_name: 'Acme Inc',
industry: 'Software',
company_size: '51-200',
timezone: 'America/Los_Angeles',
logo: 'https://logo.clearbit.com/acme.com'
}
end
before do
allow(Enterprise::ClearbitLookupService).to receive(:lookup).and_return(clearbit_data)
end
describe 'POST /api/v1/accounts' do
let(:account_builder) { double }
let(:account) { create(:account) }
let(:user) { create(:user, email: email, account: account) }
before do
allow(AccountBuilder).to receive(:new).and_return(account_builder)
end
it 'fetches data from clearbit and updates user and account info' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return([user, account])
params = { email: email, user: nil, locale: nil, password: 'Password1!' }
post api_v2_accounts_url,
params: params,
as: :json
expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password]))
expect(account_builder).to have_received(:perform)
expect(Enterprise::ClearbitLookupService).to have_received(:lookup).with(email)
custom_attributes = account.custom_attributes
expect(account.name).to eq('Acme Inc')
expect(custom_attributes['industry']).to eq('Software')
expect(custom_attributes['company_size']).to eq('51-200')
expect(custom_attributes['timezone']).to eq('America/Los_Angeles')
end
end
it 'handles errors when fetching data from clearbit' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return([user, account])
allow(Enterprise::ClearbitLookupService).to receive(:lookup).and_raise(StandardError)
params = { email: email, user: nil, locale: nil, password: 'Password1!' }
post api_v2_accounts_url,
params: params,
as: :json
expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password]))
expect(account_builder).to have_received(:perform)
expect(Enterprise::ClearbitLookupService).to have_received(:lookup).with(email)
expect(response).to have_http_status(:success)
end
end
end
end
@@ -0,0 +1,60 @@
require 'rails_helper'
RSpec.describe Enterprise::ClearbitLookupService do
describe '.lookup' do
let(:email) { 'test@example.com' }
let(:api_key) { 'clearbit_api_key' }
let(:clearbit_endpoint) { described_class::CLEARBIT_ENDPOINT }
let(:response_body) { build(:clearbit_combined_response) }
context 'when Clearbit is enabled' do
before do
stub_request(:get, "#{clearbit_endpoint}?email=#{email}")
.with(headers: { 'Authorization' => "Bearer #{api_key}" })
.to_return(status: 200, body: response_body, headers: { 'content-type' => ['application/json'] })
end
context 'when the API is working as expected' do
it 'returns the person and company information' do
with_modified_env CLEARBIT_API_KEY: api_key do
result = described_class.lookup(email)
expect(result).to eq({
:avatar => 'https://example.com/avatar.png',
:company_name => 'Doe Inc.',
:company_size => '1-10',
:industry => 'Software',
:logo => nil,
:name => 'John Doe',
:timezone => 'Asia/Kolkata'
})
end
end
end
context 'when the API returns an error' do
before do
stub_request(:get, "#{clearbit_endpoint}?email=#{email}")
.with(headers: { 'Authorization' => "Bearer #{api_key}" })
.to_return(status: 404, body: '', headers: {})
end
it 'logs the error and returns nil' do
with_modified_env CLEARBIT_API_KEY: api_key do
expect(Rails.logger).to receive(:error)
expect(described_class.lookup(email)).to be_nil
end
end
end
end
context 'when Clearbit is not enabled' do
it 'returns nil without making an API call' do
with_modified_env CLEARBIT_API_KEY: nil do
expect(Net::HTTP).not_to receive(:start)
expect(described_class.lookup(email)).to be_nil
end
end
end
end
end
+29
View File
@@ -0,0 +1,29 @@
# spec/factories/response_bodies.rb
FactoryBot.define do
factory :clearbit_combined_response, class: Hash do
skip_create
initialize_with do
{
'person' => {
'name' => {
'fullName' => 'John Doe'
},
'avatar' => 'https://example.com/avatar.png'
},
'company' => {
'name' => 'Doe Inc.',
'timeZone' => 'Asia/Kolkata',
'category' => {
'sector' => 'Technology',
'industryGroup' => 'Software',
'industry' => 'Software'
},
'metrics' => {
'employees' => '1-10'
}
}
}.to_json
end
end
end
@@ -0,0 +1,73 @@
require 'rails_helper'
RSpec.describe Reports::TimeFormatPresenter do
describe '#format' do
context 'when formatting days' do
it 'formats single day correctly' do
expect(described_class.new(86_400).format).to eq '1 day'
end
it 'formats multiple days correctly' do
expect(described_class.new(172_800).format).to eq '2 days'
end
it 'includes seconds with days correctly' do
expect(described_class.new(86_401).format).to eq '1 day 1 second'
end
it 'includes hours with days correctly' do
expect(described_class.new(93_600).format).to eq '1 day 2 hours'
end
it 'includes minutes with days correctly' do
expect(described_class.new(86_461).format).to eq '1 day 1 minute'
end
end
context 'when formatting hours' do
it 'formats single hour correctly' do
expect(described_class.new(3600).format).to eq '1 hour'
end
it 'formats multiple hours correctly' do
expect(described_class.new(7200).format).to eq '2 hours'
end
it 'includes seconds with hours correctly' do
expect(described_class.new(3601).format).to eq '1 hour 1 second'
end
it 'includes minutes with hours correctly' do
expect(described_class.new(3660).format).to eq '1 hour 1 minute'
end
end
context 'when formatting minutes' do
it 'formats single minute correctly' do
expect(described_class.new(60).format).to eq '1 minute'
end
it 'formats multiple minutes correctly' do
expect(described_class.new(120).format).to eq '2 minutes'
end
it 'includes seconds with minutes correctly' do
expect(described_class.new(62).format).to eq '1 minute 2 seconds'
end
end
context 'when formatting seconds' do
it 'formats multiple seconds correctly' do
expect(described_class.new(56).format).to eq '56 seconds'
end
it 'handles floating-point seconds by truncating to the nearest lower second' do
expect(described_class.new(55.2).format).to eq '55 seconds'
end
it 'formats single second correctly' do
expect(described_class.new(1).format).to eq '1 second'
end
end
end
end
@@ -3,6 +3,9 @@ tags:
operationId: assign-a-conversation
summary: Assign Conversation
description: Assign a conversation to an agent or a team
security:
- userApiKey: []
- agentBotApiKey: []
parameters:
- name: data
in: body
+12
View File
@@ -3327,6 +3327,18 @@
"operationId": "assign-a-conversation",
"summary": "Assign Conversation",
"description": "Assign a conversation to an agent or a team",
"security": [
{
"userApiKey": [
]
},
{
"agentBotApiKey": [
]
}
],
"parameters": [
{
"name": "data",