diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 2a7a55a8a..30804b8e8 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -32,9 +32,16 @@ class V2::ReportBuilder private def scope - return account if params[:type].match?('account') - return inbox if params[:type].match?('inbox') - return user if params[:type].match?('agent') + case params[:type] + when :account + account + when :inbox + inbox + when :agent + user + when :label + label + end end def inbox @@ -45,6 +52,10 @@ class V2::ReportBuilder @user ||= account.users.where(id: params[:id]).first end + def label + @label ||= account.labels.where(id: params[:id]).first + end + def conversations_count scope.conversations .group_by_day(:created_at, range: range, default_value: 0) diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 8fc980255..af28fe544 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -1,14 +1,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController before_action :check_authorization - def account - builder = V2::ReportBuilder.new(Current.account, account_report_params) + def index + builder = V2::ReportBuilder.new(Current.account, report_params) data = builder.build render json: data end - def account_summary - render json: account_summary_metrics + def summary + render json: summary_metrics end def agents @@ -23,31 +23,39 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render layout: false, template: 'api/v2/accounts/reports/inboxes.csv.erb', format: 'csv' end + def labels + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = 'attachment; filename=labels_report.csv' + render layout: false, template: 'api/v2/accounts/reports/labels.csv.erb', format: 'csv' + end + private def check_authorization raise Pundit::NotAuthorizedError unless Current.account_user.administrator? end - def account_summary_params + def summary_params { - type: :account, + type: params[:type].to_sym, since: params[:since], - until: params[:until] + until: params[:until], + id: params[:id] } end - def account_report_params + def report_params { metric: params[:metric], - type: :account, + type: params[:type].to_sym, since: params[:since], - until: params[:until] + until: params[:until], + id: params[:id] } end - def account_summary_metrics - builder = V2::ReportBuilder.new(Current.account, account_summary_params) + def summary_metrics + builder = V2::ReportBuilder.new(Current.account, summary_params) builder.summary end end diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 9b7eeaf45..9f7875c79 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -7,14 +7,14 @@ class ReportsAPI extends ApiClient { } getAccountReports(metric, since, until) { - return axios.get(`${this.url}/account`, { - params: { metric, since, until }, + return axios.get(`${this.url}`, { + params: { metric, since, until, type: 'account' }, }); } getAccountSummary(since, until) { - return axios.get(`${this.url}/account_summary`, { - params: { since, until }, + return axios.get(`${this.url}/summary`, { + params: { since, until, type: 'account' }, }); } diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index 0ca5f4be7..72d5b7a90 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -23,12 +23,13 @@ describe('#Reports API', () => { 1621621800 ); expect(context.axiosMock.get).toHaveBeenCalledWith( - '/api/v2/reports/account', + '/api/v2/reports', { params: { metric: 'conversations_count', since: 1621103400, until: 1621621800, + type: 'account' }, } ); @@ -37,11 +38,12 @@ describe('#Reports API', () => { it('#getAccountSummary', () => { reportsAPI.getAccountSummary(1621103400, 1621621800); expect(context.axiosMock.get).toHaveBeenCalledWith( - '/api/v2/reports/account_summary', + '/api/v2/reports/summary', { params: { since: 1621103400, until: 1621621800, + type: 'account' }, } ); diff --git a/app/models/label.rb b/app/models/label.rb index edecf5922..b53260f58 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -28,4 +28,16 @@ class Label < ApplicationRecord before_validation do self.title = title.downcase if attribute_present?('title') end + + def conversations + account.conversations.tagged_with(title) + end + + def messages + account.messages.where(conversation_id: conversations.pluck(:id)) + end + + def events + account.events.where(conversation_id: conversations.pluck(:id)) + end end diff --git a/app/views/api/v2/accounts/reports/labels.csv.erb b/app/views/api/v2/accounts/reports/labels.csv.erb new file mode 100644 index 000000000..70ebbdf35 --- /dev/null +++ b/app/views/api/v2/accounts/reports/labels.csv.erb @@ -0,0 +1,12 @@ +<% headers = ['Label Title', 'Conversations count', 'Avg first response time (Minutes)', 'Avg resolution time (Minutes)'] %> +<%= CSV.generate_line headers %> +<% Current.account.labels.each do |label| %> + <% label_report = V2::ReportBuilder.new(Current.account, { + type: :label, + id: label.id, + since: params[:since], + until: params[:until] + }).summary %> + <% row = [ label.title, label_report[:conversations_count], (label_report[:avg_first_response_time]/60).to_i, (label_report[:avg_resolution_time]/60).to_i ] %> +<%= CSV.generate_line row %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 99251c754..a4a2f5459 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -175,12 +175,12 @@ Rails.application.routes.draw do namespace :v2 do resources :accounts, only: [], module: :accounts do - resources :reports, only: [] do + resources :reports, only: [:index] do collection do - get :account - get :account_summary + get :summary get :agents get :inboxes + get :labels end end end diff --git a/spec/builders/v2/report_builder_spec.rb b/spec/builders/v2/report_builder_spec.rb index 4aef6c425..2cf75103a 100644 --- a/spec/builders/v2/report_builder_spec.rb +++ b/spec/builders/v2/report_builder_spec.rb @@ -5,6 +5,8 @@ describe ::V2::ReportBuilder do let!(:user) { create(:user, account: account) } let!(:inbox) { create(:inbox, account: account) } let(:inbox_member) { create(:inbox_member, user: user, inbox: inbox) } + let!(:label_1) { create(:label, title: 'Label_1', account: account) } + let!(:label_2) { create(:label, title: 'Label_2', account: account) } # Running jobs inline to calculate the exact metrics around do |test| @@ -17,35 +19,42 @@ describe ::V2::ReportBuilder do end describe '#timeseries' do - context 'when report type is account' do - before do - 10.times do - conversation = create(:conversation, account: account, - inbox: inbox, assignee: user, - created_at: Time.zone.today) - create_list(:message, 5, message_type: 'outgoing', - account: account, inbox: inbox, - conversation: conversation, created_at: Time.zone.today + 2.hours) - create_list(:message, 2, message_type: 'incoming', - account: account, inbox: inbox, - conversation: conversation, - created_at: Time.zone.today + 3.hours) - end - 5.times do - conversation = create(:conversation, account: account, - inbox: inbox, assignee: user, - created_at: (Time.zone.today - 2.days)) - create_list(:message, 3, message_type: 'outgoing', - account: account, inbox: inbox, - conversation: conversation, - created_at: (Time.zone.today - 2.days)) - create_list(:message, 1, message_type: 'incoming', - account: account, inbox: inbox, - conversation: conversation, - created_at: (Time.zone.today - 2.days)) - end + before do + 10.times do + conversation = create(:conversation, account: account, + inbox: inbox, assignee: user, + created_at: Time.zone.today) + create_list(:message, 5, message_type: 'outgoing', + account: account, inbox: inbox, + conversation: conversation, created_at: Time.zone.today + 2.hours) + create_list(:message, 2, message_type: 'incoming', + account: account, inbox: inbox, + conversation: conversation, + created_at: Time.zone.today + 3.hours) + conversation.update_labels('label_1') + conversation.label_list + conversation.save! end + 5.times do + conversation = create(:conversation, account: account, + inbox: inbox, assignee: user, + created_at: (Time.zone.today - 2.days)) + create_list(:message, 3, message_type: 'outgoing', + account: account, inbox: inbox, + conversation: conversation, + created_at: (Time.zone.today - 2.days)) + create_list(:message, 1, message_type: 'incoming', + account: account, inbox: inbox, + conversation: conversation, + created_at: (Time.zone.today - 2.days)) + conversation.update_labels('label_2') + conversation.label_list + conversation.save! + end + end + + context 'when report type is account' do it 'return conversations count' do params = { metric: 'conversations_count', @@ -139,5 +148,105 @@ describe ::V2::ReportBuilder do expect(metrics[:resolutions_count]).to be 0 end end + + context 'when report type is label' do + it 'return conversations count' do + params = { + metric: 'conversations_count', + type: :label, + id: label_2.id, + since: (Time.zone.today - 3.days).to_time.to_i.to_s, + until: Time.zone.today.to_time.to_i.to_s + } + + builder = V2::ReportBuilder.new(account, params) + metrics = builder.timeseries + + expect(metrics[Time.zone.today - 2.days]).to be 5 + end + + it 'return incoming messages count' do + params = { + metric: 'incoming_messages_count', + type: :label, + id: label_1.id, + since: (Time.zone.today - 3.days).to_time.to_i.to_s, + until: Time.zone.today.to_time.to_i.to_s + } + + builder = V2::ReportBuilder.new(account, params) + metrics = builder.timeseries + + expect(metrics[Time.zone.today]).to be 20 + expect(metrics[Time.zone.today - 2.days]).to be 5 + end + + it 'return outgoing messages count' do + params = { + metric: 'outgoing_messages_count', + type: :label, + id: label_1.id, + since: (Time.zone.today - 3.days).to_time.to_i.to_s, + until: Time.zone.today.to_time.to_i.to_s + } + + builder = V2::ReportBuilder.new(account, params) + metrics = builder.timeseries + + expect(metrics[Time.zone.today]).to be 50 + expect(metrics[Time.zone.today - 2.days]).to be 15 + end + + it 'return resolutions count' do + params = { + metric: 'resolutions_count', + type: :label, + id: label_2.id, + since: (Time.zone.today - 3.days).to_time.to_i.to_s, + until: Time.zone.today.to_time.to_i.to_s + } + + conversations = account.conversations.where('created_at < ?', 1.day.ago) + conversations.each(&:resolved!) + builder = V2::ReportBuilder.new(account, params) + metrics = builder.timeseries + + expect(metrics[Time.zone.today - 2.days]).to be 5 + end + + it 'returns average first response time' do + FactoryBot.create(:event, conversation: label_2.conversations.last, account: account, name: 'first_response') + + params = { + metric: 'avg_first_response_time', + type: :label, + id: label_2.id, + since: (Time.zone.today - 3.days).to_time.to_i.to_s, + until: Time.zone.today.to_time.to_i.to_s + } + + builder = V2::ReportBuilder.new(account, params) + metrics = builder.timeseries + expect(metrics[Time.zone.today].to_f).to be 0.15e1 + end + + it 'returns summary' do + params = { + type: :label, + id: label_2.id, + since: (Time.zone.today - 3.days).to_time.to_i.to_s, + until: Time.zone.today.to_time.to_i.to_s + } + + builder = V2::ReportBuilder.new(account, params) + metrics = builder.summary + + expect(metrics[:conversations_count]).to be 5 + expect(metrics[:incoming_messages_count]).to be 25 + expect(metrics[:outgoing_messages_count]).to be 65 + expect(metrics[:avg_resolution_time]).to be 0 + expect(metrics[:resolutions_count]).to be 0 + end + end end end diff --git a/spec/controllers/api/v2/accounts/report_controller_spec.rb b/spec/controllers/api/v2/accounts/report_controller_spec.rb index e6152dc8b..201c53f16 100644 --- a/spec/controllers/api/v2/accounts/report_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/report_controller_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'Reports API', type: :request do describe 'GET /api/v2/accounts/:account_id/reports/account' do context 'when it is an unauthenticated user' do it 'returns unauthorized' do - get "/api/v2/accounts/#{account.id}/reports/account" + get "/api/v2/accounts/#{account.id}/reports" expect(response).to have_http_status(:unauthorized) end @@ -31,7 +31,7 @@ RSpec.describe 'Reports API', type: :request do } it 'returns unauthorized for agents' do - get "/api/v2/accounts/#{account.id}/reports/account", + get "/api/v2/accounts/#{account.id}/reports", params: params, headers: agent.create_new_auth_token, as: :json @@ -40,7 +40,7 @@ RSpec.describe 'Reports API', type: :request do end it 'return timeseries metrics' do - get "/api/v2/accounts/#{account.id}/reports/account", + get "/api/v2/accounts/#{account.id}/reports", params: params, headers: admin.create_new_auth_token, as: :json @@ -55,10 +55,10 @@ RSpec.describe 'Reports API', type: :request do end end - describe 'GET /api/v2/accounts/:account_id/reports/account_summary' do + describe 'GET /api/v2/accounts/:account_id/reports/summary' do context 'when it is an unauthenticated user' do it 'returns unauthorized' do - get "/api/v2/accounts/#{account.id}/reports/account_summary" + get "/api/v2/accounts/#{account.id}/reports/summary" expect(response).to have_http_status(:unauthorized) end @@ -72,7 +72,7 @@ RSpec.describe 'Reports API', type: :request do } it 'returns unauthorized for agents' do - get "/api/v2/accounts/#{account.id}/reports/account_summary", + get "/api/v2/accounts/#{account.id}/reports/summary", params: params, headers: agent.create_new_auth_token, as: :json @@ -81,7 +81,7 @@ RSpec.describe 'Reports API', type: :request do end it 'returns summary metrics' do - get "/api/v2/accounts/#{account.id}/reports/account_summary", + get "/api/v2/accounts/#{account.id}/reports/summary", params: params, headers: admin.create_new_auth_token, as: :json @@ -142,7 +142,7 @@ RSpec.describe 'Reports API', type: :request do until: Time.zone.today.to_time.to_i.to_s } - it 'returns unauthorized for agents' do + it 'returns unauthorized for inboxes' do get "/api/v2/accounts/#{account.id}/reports/inboxes", params: params, headers: agent.create_new_auth_token @@ -159,4 +159,37 @@ RSpec.describe 'Reports API', type: :request do end end end + + describe 'GET /api/v2/accounts/:account_id/reports/labels' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/labels.csv" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + params = { + since: 30.days.ago.to_i.to_s, + until: Time.zone.today.to_time.to_i.to_s + } + + it 'returns unauthorized for labels' do + get "/api/v2/accounts/#{account.id}/reports/labels.csv", + params: params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns summary' do + get "/api/v2/accounts/#{account.id}/reports/labels.csv", + params: params, + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:success) + end + end + end end diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml index a330326bf..a2fd247d1 100644 --- a/swagger/definitions/index.yml +++ b/swagger/definitions/index.yml @@ -140,3 +140,12 @@ extended_message: - $ref: '#/definitions/generic_id' - $ref: '#/definitions/message' - $ref: ./resource/extension/message/with_source_sender.yml + + +## report list +report: + type: array + description: 'array of conversation count based on date' + items: + allOf: + - $ref: './resource/report.yml' diff --git a/swagger/definitions/resource/report.yml b/swagger/definitions/resource/report.yml new file mode 100644 index 000000000..9a4bffe9c --- /dev/null +++ b/swagger/definitions/resource/report.yml @@ -0,0 +1,6 @@ +type: object +properties: + value: + type: number + timestamp: + type: string diff --git a/swagger/index.yml b/swagger/index.yml index 2010f1de6..565d66f94 100644 --- a/swagger/index.yml +++ b/swagger/index.yml @@ -63,6 +63,7 @@ x-tagGroups: - Profile - Teams - Custom Filter + - Reports - name: Public tags: - Contacts API diff --git a/swagger/parameters/index.yml b/swagger/parameters/index.yml index 4926fe166..7d2f196e1 100644 --- a/swagger/parameters/index.yml +++ b/swagger/parameters/index.yml @@ -31,6 +31,12 @@ platform_user_id: custom_filter_id: $ref: ./custom_filter_id.yml +report_type: + $ref: ./report_type.yml + +report_metric: + $ref: ./report_metric.yml + public_inbox_identifier: $ref: ./public/inbox_identifier.yml diff --git a/swagger/parameters/report_metric.yml b/swagger/parameters/report_metric.yml new file mode 100644 index 000000000..d40cadc9c --- /dev/null +++ b/swagger/parameters/report_metric.yml @@ -0,0 +1,7 @@ +in: query +name: metric +schema: + type: string + enum: [conversations_count, incoming_messages_count, outgoing_messages_count, avg_first_response_time, avg_resolution_time, resolutions_count] +required: true +description: The type of metric diff --git a/swagger/parameters/report_type.yml b/swagger/parameters/report_type.yml new file mode 100644 index 000000000..6c78f964d --- /dev/null +++ b/swagger/parameters/report_type.yml @@ -0,0 +1,7 @@ +in: query +name: report_type +schema: + type: string + enum: [account,agent,inbox,label] +required: true +description: Type of report diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index b0b27f27f..10062dc42 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -269,7 +269,7 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat delete: $ref: ./teams/delete.yml -### Custom Filters +### Custom Filters goes here # Teams /api/v1/accounts/{account_id}/custom_filters: @@ -296,3 +296,52 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat $ref: ./custom_filters/update.yml delete: $ref: ./custom_filters/delete.yml + +### Reports + +# List +/api/v1/accounts/{id}/reports: + parameters: + - $ref: '#/parameters/account_id' + - $ref: '#/parameters/report_metric' + - $ref: '#/parameters/report_type' + - in: query + name: id + schema: + type: string + description: The Id of specific object in case of agent/inbox/label + - in: query + name: since + schema: + type: string + description: The timestamp from where report should start. + - in: query + name: until + schema: + type: string + description: The timestamp from where report should stop. + get: + $ref: './reports/index.yml' + +# Summary +/api/v1/accounts/{id}/reports/summary: + parameters: + - $ref: '#/parameters/account_id' + - $ref: '#/parameters/report_type' + - in: query + name: id + schema: + type: string + description: The Id of specific object in case of agent/inbox/label + - in: query + name: since + schema: + type: string + description: The timestamp from where report should start. + - in: query + name: until + schema: + type: string + description: The timestamp from where report should stop. + get: + $ref: './reports/summary.yml' diff --git a/swagger/paths/reports/index.yml b/swagger/paths/reports/index.yml new file mode 100644 index 000000000..dacf77a13 --- /dev/null +++ b/swagger/paths/reports/index.yml @@ -0,0 +1,17 @@ +tags: + - Reports +operationId: list-all-conversation-statistics +summary: Get Account reports +description: Get Account reports for a specific type, metric and date range +responses: + 200: + description: Success + schema: + type: array + description: 'Array of date based conversation statistics' + items: + $ref: '#/definitions/report' + 404: + description: reports not found + 403: + description: Access denied diff --git a/swagger/paths/reports/summary.yml b/swagger/paths/reports/summary.yml new file mode 100644 index 000000000..ec659e8e8 --- /dev/null +++ b/swagger/paths/reports/summary.yml @@ -0,0 +1,17 @@ +tags: + - Reports +operationId: list-all-conversation-statistics-summary +summary: Get Account reports summary +description: Get Account reports summary for a specific type and date range +responses: + 200: + description: Success + schema: + type: array + description: 'Array of date based conversation statistics' + items: + $ref: '#/definitions/report' + 404: + description: reports not found + 403: + description: Access denied diff --git a/swagger/swagger.json b/swagger/swagger.json index 43129026e..027a7e0f9 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -2757,6 +2757,129 @@ } } } + }, + "/api/v1/accounts/{id}/reports": { + "parameters": [ + { + "$ref": "#/parameters/account_id" + }, + { + "$ref": "#/parameters/report_metric" + }, + { + "$ref": "#/parameters/report_type" + }, + { + "in": "query", + "name": "id", + "schema": { + "type": "string" + }, + "description": "The Id of specific object in case of agent/inbox/label" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "list-all-conversation-statistics", + "summary": "Get Account reports", + "description": "Get Account reports for a specific type, metric and date range", + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "array", + "description": "Array of date based conversation statistics", + "items": { + "$ref": "#/definitions/report" + } + } + }, + "404": { + "description": "reports not found" + }, + "403": { + "description": "Access denied" + } + } + } + }, + "/api/v1/accounts/{id}/reports/summary": { + "parameters": [ + { + "$ref": "#/parameters/account_id" + }, + { + "$ref": "#/parameters/report_type" + }, + { + "in": "query", + "name": "id", + "schema": { + "type": "string" + }, + "description": "The Id of specific object in case of agent/inbox/label" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "list-all-conversation-statistics-summary", + "summary": "Get Account reports summary", + "description": "Get Account reports summary for a specific type and date range", + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "array", + "description": "Array of date based conversation statistics", + "items": { + "$ref": "#/definitions/report" + } + } + }, + "404": { + "description": "reports not found" + }, + "403": { + "description": "Access denied" + } + } + } } }, "definitions": { @@ -3844,6 +3967,25 @@ } } ] + }, + "report": { + "type": "array", + "description": "array of conversation count based on date", + "items": { + "allOf": [ + { + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "timestamp": { + "type": "string" + } + } + } + ] + } } }, "parameters": { @@ -3952,6 +4094,38 @@ "required": true, "description": "The numeric ID of the custom filter" }, + "report_type": { + "in": "query", + "name": "report_type", + "schema": { + "type": "string", + "enum": [ + "account", + "agent", + "inbox", + "label" + ] + }, + "required": true, + "description": "Type of report" + }, + "report_metric": { + "in": "query", + "name": "metric", + "schema": { + "type": "string", + "enum": [ + "conversations_count", + "incoming_messages_count", + "outgoing_messages_count", + "avg_first_response_time", + "avg_resolution_time", + "resolutions_count" + ] + }, + "required": true, + "description": "The type of metric" + }, "public_inbox_identifier": { "in": "path", "name": "inbox_identifier", @@ -3994,7 +4168,8 @@ "Integrations", "Profile", "Teams", - "Custom Filter" + "Custom Filter", + "Reports" ] }, {