diff --git a/.env.example b/.env.example index d76338e28..177cb7fc8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 74b281b43..35e1e2a48 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/builders/v2/reports/agent_summary_builder.rb b/app/builders/v2/reports/agent_summary_builder.rb new file mode 100644 index 000000000..382c67bf5 --- /dev/null +++ b/app/builders/v2/reports/agent_summary_builder.rb @@ -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 diff --git a/app/builders/v2/reports/base_summary_builder.rb b/app/builders/v2/reports/base_summary_builder.rb new file mode 100644 index 000000000..33fe5e400 --- /dev/null +++ b/app/builders/v2/reports/base_summary_builder.rb @@ -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 diff --git a/app/builders/v2/reports/team_summary_builder.rb b/app/builders/v2/reports/team_summary_builder.rb new file mode 100644 index 000000000..3d7e765b3 --- /dev/null +++ b/app/builders/v2/reports/team_summary_builder.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 768d1a3ff..221c96b85 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 0e5615bae..481594ee4 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -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 diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index a77652ff8..bd3c2673c 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -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, diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb new file mode 100644 index 000000000..0cbd6dd8e --- /dev/null +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -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 diff --git a/app/controllers/api/v2/accounts_controller.rb b/app/controllers/api/v2/accounts_controller.rb index e38863c07..ea19727ad 100644 --- a/app/controllers/api/v2/accounts_controller.rb +++ b/app/controllers/api/v2/accounts_controller.rb @@ -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') diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index 0a6c89860..cd760f7ce 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -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 diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb index 5d02e96c8..3baf9ee1e 100644 --- a/app/controllers/concerns/ensure_current_account_helper.rb +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -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 diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb index a1dc458ae..b0e97b95d 100644 --- a/app/controllers/super_admin/instance_statuses_controller.rb +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -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 diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 3d87154fd..74c7af43e 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -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 diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 6bf5501ad..0a9d23c6b 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -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 diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js index 88ee39fd8..f1c594b26 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js @@ -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, diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/actionHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/actionHelpers.spec.js index 32c6ff373..2dcf0b26b 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/specs/actionHelpers.spec.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/actionHelpers.spec.js @@ -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 + ); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js index 0b375a543..648ab2a46 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js @@ -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, diff --git a/app/javascript/v3/components/Form/Input.vue b/app/javascript/v3/components/Form/Input.vue index f48708a26..b9e73387d 100644 --- a/app/javascript/v3/components/Form/Input.vue +++ b/app/javascript/v3/components/Form/Input.vue @@ -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')" /> diff --git a/app/mailboxes/mailbox_helper.rb b/app/mailboxes/mailbox_helper.rb index 5d1033d20..6b3afc133 100644 --- a/app/mailboxes/mailbox_helper.rb +++ b/app/mailboxes/mailbox_helper.rb @@ -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? diff --git a/app/policies/report_policy.rb b/app/policies/report_policy.rb new file mode 100644 index 000000000..7e7eeffdf --- /dev/null +++ b/app/policies/report_policy.rb @@ -0,0 +1,5 @@ +class ReportPolicy < ApplicationPolicy + def view? + @account_user.administrator? + end +end diff --git a/app/presenters/reports/time_format_presenter.rb b/app/presenters/reports/time_format_presenter.rb new file mode 100644 index 000000000..b69618499 --- /dev/null +++ b/app/presenters/reports/time_format_presenter.rb @@ -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 diff --git a/app/views/api/v2/accounts/reports/agents.csv.erb b/app/views/api/v2/accounts/reports/agents.csv.erb index 99dbacd38..dfc90f016 100644 --- a/app/views/api/v2/accounts/reports/agents.csv.erb +++ b/app/views/api/v2/accounts/reports/agents.csv.erb @@ -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'))] %> diff --git a/app/views/api/v2/accounts/reports/conversation_traffic.erb b/app/views/api/v2/accounts/reports/conversation_traffic.erb index 6616101f0..d0cb245f4 100644 --- a/app/views/api/v2/accounts/reports/conversation_traffic.erb +++ b/app/views/api/v2/accounts/reports/conversation_traffic.erb @@ -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 %> diff --git a/app/views/api/v2/accounts/reports/inboxes.csv.erb b/app/views/api/v2/accounts/reports/inboxes.csv.erb index e6466cf01..df038d5fe 100644 --- a/app/views/api/v2/accounts/reports/inboxes.csv.erb +++ b/app/views/api/v2/accounts/reports/inboxes.csv.erb @@ -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'))] %> diff --git a/app/views/api/v2/accounts/reports/labels.csv.erb b/app/views/api/v2/accounts/reports/labels.csv.erb index 5da742790..fdf578415 100644 --- a/app/views/api/v2/accounts/reports/labels.csv.erb +++ b/app/views/api/v2/accounts/reports/labels.csv.erb @@ -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'))] %> diff --git a/app/views/api/v2/accounts/reports/teams.csv.erb b/app/views/api/v2/accounts/reports/teams.csv.erb index d9ae31dfa..1c2e6084a 100644 --- a/app/views/api/v2/accounts/reports/teams.csv.erb +++ b/app/views/api/v2/accounts/reports/teams.csv.erb @@ -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'))] %> diff --git a/config/initializers/git_sha.rb b/config/initializers/git_sha.rb index a11e38215..ff4cc7d71 100644 --- a/config/initializers/git_sha.rb +++ b/config/initializers/git_sha.rb @@ -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') diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 99974f4f7..440fc43cb 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -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/(?\d+)/contacts/search}.match(req.path) + match_data[:account_id] if match_data.present? + end + ## ----------------------------------------------- ## end diff --git a/config/installation_config.yml b/config/installation_config.yml index 2643461e2..60eae3bc2 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -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 ------ ## \ No newline at end of file +## ------ End of Configs added for enterprise clients ------ ## diff --git a/config/locales/en.yml b/config/locales/en.yml index da4f35032..78f1f9490 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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" diff --git a/config/routes.rb b/config/routes.rb index 10895aadf..df0ae0d07 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/api/v2/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v2/accounts_controller.rb new file mode 100644 index 000000000..d0d76c1ae --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v2/accounts_controller.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb index 106dbb4a8..ae1394a24 100644 --- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb +++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb @@ -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 diff --git a/enterprise/app/services/enterprise/clearbit_lookup_service.rb b/enterprise/app/services/enterprise/clearbit_lookup_service.rb new file mode 100644 index 000000000..c108ca153 --- /dev/null +++ b/enterprise/app/services/enterprise/clearbit_lookup_service.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/agents_controller_spec.rb b/spec/controllers/api/v1/accounts/agents_controller_spec.rb index 10693443a..82d5fe9c6 100644 --- a/spec/controllers/api/v1/accounts/agents_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/agents_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb index a4e9d8cb0..8ed70b7a2 100644 --- a/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/assignments_controller_spec.rb @@ -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) } diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 4c4bb9dc9..72e6603f7 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index 060e241a7..95fa2ae7f 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v1/profiles_controller_spec.rb b/spec/controllers/api/v1/profiles_controller_spec.rb index 80f6833f1..28b4c5ed5 100644 --- a/spec/controllers/api/v1/profiles_controller_spec.rb +++ b/spec/controllers/api/v1/profiles_controller_spec.rb @@ -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' } }, diff --git a/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb b/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb new file mode 100644 index 000000000..23ed3e65c --- /dev/null +++ b/spec/controllers/api/v2/accounts/summary_reports_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v2/accounts_controller_spec.rb b/spec/controllers/api/v2/accounts_controller_spec.rb index e0181a282..82693ed2b 100644 --- a/spec/controllers/api/v2/accounts_controller_spec.rb +++ b/spec/controllers/api/v2/accounts_controller_spec.rb @@ -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, diff --git a/spec/controllers/super_admin/instance_statuses_controller_spec.rb b/spec/controllers/super_admin/instance_statuses_controller_spec.rb index c00ea5984..d1714be1e 100644 --- a/spec/controllers/super_admin/instance_statuses_controller_spec.rb +++ b/spec/controllers/super_admin/instance_statuses_controller_spec.rb @@ -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 diff --git a/spec/enterprise/controllers/enterprise/api/v2/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v2/accounts_controller_spec.rb new file mode 100644 index 000000000..8ad75b42f --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v2/accounts_controller_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/clearbit_lookup_service_spec.rb b/spec/enterprise/services/enterprise/clearbit_lookup_service_spec.rb new file mode 100644 index 000000000..725aa6523 --- /dev/null +++ b/spec/enterprise/services/enterprise/clearbit_lookup_service_spec.rb @@ -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 diff --git a/spec/factories/clearbit_response.rb b/spec/factories/clearbit_response.rb new file mode 100644 index 000000000..fbe8e9a5a --- /dev/null +++ b/spec/factories/clearbit_response.rb @@ -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 diff --git a/spec/presenters/reports/time_format_presenter_spec.rb b/spec/presenters/reports/time_format_presenter_spec.rb new file mode 100644 index 000000000..64d75bb08 --- /dev/null +++ b/spec/presenters/reports/time_format_presenter_spec.rb @@ -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 diff --git a/swagger/paths/application/conversation/assignments.yml b/swagger/paths/application/conversation/assignments.yml index a7b001e5c..a67ce59ad 100644 --- a/swagger/paths/application/conversation/assignments.yml +++ b/swagger/paths/application/conversation/assignments.yml @@ -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 diff --git a/swagger/swagger.json b/swagger/swagger.json index 9bee2c952..4859a785f 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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",