Merge branch 'develop' into report-rewrite
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
+15
-1
@@ -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,
|
||||
|
||||
@@ -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')"
|
||||
/>
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user