Date: Fri, 27 Aug 2021 22:46:32 +0530
Subject: [PATCH 21/72] feat: APIs to filter reports (#2889)
Fixes #2823
---
app/builders/v2/report_builder.rb | 17 +-
.../api/v2/accounts/reports_controller.rb | 32 ++--
app/javascript/dashboard/api/reports.js | 8 +-
.../dashboard/api/specs/reports.spec.js | 6 +-
app/models/label.rb | 12 ++
.../api/v2/accounts/reports/labels.csv.erb | 12 ++
config/routes.rb | 6 +-
spec/builders/v2/report_builder_spec.rb | 163 +++++++++++++---
.../api/v2/accounts/report_controller_spec.rb | 49 ++++-
swagger/definitions/index.yml | 9 +
swagger/definitions/resource/report.yml | 6 +
swagger/index.yml | 1 +
swagger/parameters/index.yml | 6 +
swagger/parameters/report_metric.yml | 7 +
swagger/parameters/report_type.yml | 7 +
swagger/paths/index.yml | 51 ++++-
swagger/paths/reports/index.yml | 17 ++
swagger/paths/reports/summary.yml | 17 ++
swagger/swagger.json | 177 +++++++++++++++++-
19 files changed, 542 insertions(+), 61 deletions(-)
create mode 100644 app/views/api/v2/accounts/reports/labels.csv.erb
create mode 100644 swagger/definitions/resource/report.yml
create mode 100644 swagger/parameters/report_metric.yml
create mode 100644 swagger/parameters/report_type.yml
create mode 100644 swagger/paths/reports/index.yml
create mode 100644 swagger/paths/reports/summary.yml
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"
]
},
{
From 588f49cc34aacb147e3a41eed211d152d98b1623 Mon Sep 17 00:00:00 2001
From: Pranav Raj S
Date: Tue, 31 Aug 2021 11:27:40 +0530
Subject: [PATCH 22/72] fix: Update param name for attachments (#2921)
---
swagger/paths/conversation/messages/create_attachment.yml | 4 ++--
swagger/swagger.json | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/swagger/paths/conversation/messages/create_attachment.yml b/swagger/paths/conversation/messages/create_attachment.yml
index e6bbd6854..155876e13 100644
--- a/swagger/paths/conversation/messages/create_attachment.yml
+++ b/swagger/paths/conversation/messages/create_attachment.yml
@@ -3,7 +3,7 @@ post:
- Messages
operationId: conversationNewMessageAttachment
summary: Create New Message Attachment
- description: Create an attachment Message
+ description: Create an attachment message. Refer to this discussion if you have any further doubts. https://github.com/chatwoot/chatwoot/discussions/1809#discussioncomment-1211845
consumes:
- multipart/form-data
security:
@@ -27,7 +27,7 @@ post:
type: boolean
description: Flag to identify if it is a private note
- in: formData
- name: attachments
+ name: attachments[]
type: array
description: The files to be uploaded.
items:
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 027a7e0f9..102e7f8d5 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -2109,7 +2109,7 @@
],
"operationId": "conversationNewMessageAttachment",
"summary": "Create New Message Attachment",
- "description": "Create an attachment Message",
+ "description": "Create an attachment message. Refer to this discussion if you have any further doubts. https://github.com/chatwoot/chatwoot/discussions/1809#discussioncomment-1211845",
"consumes": [
"multipart/form-data"
],
@@ -2156,7 +2156,7 @@
},
{
"in": "formData",
- "name": "attachments",
+ "name": "attachments[]",
"type": "array",
"description": "The files to be uploaded.",
"items": {
From 75329e5de1778587ad60d51909422312b208b64e Mon Sep 17 00:00:00 2001
From: Pranav Raj S
Date: Tue, 31 Aug 2021 11:46:15 +0530
Subject: [PATCH 23/72] fix: Add missing referer & initiated_at.timestamp to
events (#2920)
---
app/javascript/widget/api/endPoints.js | 2 ++
app/javascript/widget/api/events.js | 12 +++++++++++-
app/javascript/widget/api/specs/endPoints.spec.js | 11 +++++++++++
3 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js
index 7a0a3c94f..fddcfa3d6 100755
--- a/app/javascript/widget/api/endPoints.js
+++ b/app/javascript/widget/api/endPoints.js
@@ -1,4 +1,5 @@
import { buildSearchParamsWithLocale } from '../helpers/urlParamsHelper';
+import { generateEventParams } from './events';
const createConversation = params => {
const referrerURL = window.referrerURL || '';
@@ -76,6 +77,7 @@ const triggerCampaign = ({ websiteToken, campaignId }) => ({
name: 'campaign.triggered',
event_info: {
campaign_id: campaignId,
+ ...generateEventParams(),
},
},
params: {
diff --git a/app/javascript/widget/api/events.js b/app/javascript/widget/api/events.js
index 0c8fcfb23..b1757ce5d 100644
--- a/app/javascript/widget/api/events.js
+++ b/app/javascript/widget/api/events.js
@@ -1,9 +1,19 @@
import { API } from 'widget/helpers/axios';
import { buildSearchParamsWithLocale } from '../helpers/urlParamsHelper';
+export const generateEventParams = () => ({
+ initiated_at: {
+ timestamp: new Date().toString(),
+ },
+ referer: window.referrerURL || '',
+});
+
export default {
create(name) {
const search = buildSearchParamsWithLocale(window.location.search);
- return API.post(`/api/v1/widget/events${search}`, { name });
+ return API.post(`/api/v1/widget/events${search}`, {
+ name,
+ event_info: generateEventParams(),
+ });
},
};
diff --git a/app/javascript/widget/api/specs/endPoints.spec.js b/app/javascript/widget/api/specs/endPoints.spec.js
index 7e4ad4dd1..21ed98a5a 100644
--- a/app/javascript/widget/api/specs/endPoints.spec.js
+++ b/app/javascript/widget/api/specs/endPoints.spec.js
@@ -47,6 +47,10 @@ describe('#getConversation', () => {
describe('#triggerCampaign', () => {
it('should returns correct payload', () => {
+ const spy = jest.spyOn(global, 'Date').mockImplementation(() => ({
+ toString: () => 'mock date',
+ }));
+ const windowSpy = jest.spyOn(window, 'window', 'get');
const websiteToken = 'ADSDJ2323MSDSDFMMMASDM';
const campaignId = 12;
expect(
@@ -60,11 +64,18 @@ describe('#triggerCampaign', () => {
name: 'campaign.triggered',
event_info: {
campaign_id: campaignId,
+ referer: '',
+ initiated_at: {
+ timestamp: 'mock date',
+ },
},
},
params: {
website_token: websiteToken,
},
});
+ windowSpy.mockRestore();
+
+ spy.mockRestore();
});
});
From fdcc322660731a4334865f09564ef21bdab4fa2f Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 31 Aug 2021 13:54:34 +0530
Subject: [PATCH 24/72] feat: Add the ability to create custom attribute
(#2903)
---
app/javascript/dashboard/api/attributes.js | 9 +
.../i18n/locale/en/attributesMgmt.json | 34 ++-
.../settings/attributes/AddAttribute.vue | 201 ++++++++++++++++++
.../dashboard/settings/attributes/Index.vue | 24 ++-
app/javascript/dashboard/store/index.js | 2 +
.../dashboard/store/modules/attributes.js | 91 ++++++++
.../modules/specs/attributes/actions.spec.js | 93 ++++++++
.../modules/specs/attributes/fixtures.js | 16 ++
.../modules/specs/attributes/getters.spec.js | 30 +++
.../specs/attributes/mutations.spec.js | 44 ++++
.../dashboard/store/mutation-types.js | 7 +
app/models/custom_attribute_definition.rb | 2 +-
12 files changed, 550 insertions(+), 3 deletions(-)
create mode 100644 app/javascript/dashboard/api/attributes.js
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue
create mode 100644 app/javascript/dashboard/store/modules/attributes.js
create mode 100644 app/javascript/dashboard/store/modules/specs/attributes/actions.spec.js
create mode 100644 app/javascript/dashboard/store/modules/specs/attributes/fixtures.js
create mode 100644 app/javascript/dashboard/store/modules/specs/attributes/getters.spec.js
create mode 100644 app/javascript/dashboard/store/modules/specs/attributes/mutations.spec.js
diff --git a/app/javascript/dashboard/api/attributes.js b/app/javascript/dashboard/api/attributes.js
new file mode 100644
index 000000000..c93807837
--- /dev/null
+++ b/app/javascript/dashboard/api/attributes.js
@@ -0,0 +1,9 @@
+import ApiClient from './ApiClient';
+
+class AttributeAPI extends ApiClient {
+ constructor() {
+ super('custom_attribute_definitions', { accountScoped: true });
+ }
+}
+
+export default new AttributeAPI();
diff --git a/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json b/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json
index af8c41e6f..275cdf81d 100644
--- a/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json
@@ -1,6 +1,38 @@
{
"ATTRIBUTES_MGMT": {
"HEADER": "Attributes",
- "HEADER_BTN_TXT": "Add Attribute"
+ "HEADER_BTN_TXT": "Add Attribute",
+ "ADD": {
+ "TITLE": "Add attribute",
+ "SUBMIT": "Create",
+ "CANCEL_BUTTON_TEXT": "Cancel",
+ "FORM": {
+ "NAME": {
+ "LABEL": "Display Name",
+ "PLACEHOLDER": "Enter attribute display name"
+ },
+ "DESC": {
+ "LABEL": "Description",
+ "PLACEHOLDER": "Enter attribute description"
+ },
+ "MODEL": {
+ "LABEL": "Model",
+ "PLACEHOLDER": "Please select a model",
+ "ERROR": "Model is required"
+ },
+ "TYPE": {
+ "LABEL": "Type",
+ "PLACEHOLDER": "Please select a type",
+ "ERROR": "Type is required"
+ },
+ "KEY": {
+ "LABEL": "Key"
+ }
+ },
+ "API": {
+ "SUCCESS_MESSAGE": "Attribute added successfully",
+ "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
+ }
+ }
}
}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue
new file mode 100644
index 000000000..ba00f54b1
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue
index 2a62e3363..a8491d315 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue
@@ -4,14 +4,36 @@
color-scheme="success"
class-names="button--fixed-right-top"
icon="ion-android-add-circle"
+ @click="openAddPopup()"
>
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
+
+
+
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index fa5678eee..b14b9d0b0 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -30,6 +30,7 @@ import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
+import attributes from './modules/attributes';
Vue.use(Vuex);
export default new Vuex.Store({
@@ -63,5 +64,6 @@ export default new Vuex.Store({
teams,
userNotificationSettings,
webhooks,
+ attributes,
},
});
diff --git a/app/javascript/dashboard/store/modules/attributes.js b/app/javascript/dashboard/store/modules/attributes.js
new file mode 100644
index 000000000..669a22a6a
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/attributes.js
@@ -0,0 +1,91 @@
+import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
+import types from '../mutation-types';
+import AttributeAPI from '../../api/attributes';
+
+export const state = {
+ records: [],
+ uiFlags: {
+ isFetching: false,
+ isCreating: false,
+ },
+};
+
+export const getters = {
+ getUIFlags(_state) {
+ return _state.uiFlags;
+ },
+ getAttributes: _state => attributeType => {
+ return _state.records.filter(
+ record => record.attribute_display_type === attributeType
+ );
+ },
+};
+
+export const actions = {
+ get: async function getAttributes({ commit }) {
+ commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isFetching: true });
+ try {
+ const response = await AttributeAPI.get();
+ commit(types.SET_CUSTOM_ATTRIBUTE, response.data);
+ } catch (error) {
+ // Ignore error
+ } finally {
+ commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isFetching: false });
+ }
+ },
+ create: async function createAttribute({ commit }, attributeObj) {
+ commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isCreating: true });
+ try {
+ const response = await AttributeAPI.create(attributeObj);
+ commit(types.ADD_CUSTOM_ATTRIBUTE, response.data);
+ } catch (error) {
+ throw new Error(error);
+ } finally {
+ commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isCreating: false });
+ }
+ },
+ update: async ({ commit }, { id, ...updateObj }) => {
+ commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isUpdating: true });
+ try {
+ const response = await AttributeAPI.update(id, updateObj);
+ commit(types.EDIT_CUSTOM_ATTRIBUTE, response.data);
+ } catch (error) {
+ throw new Error(error);
+ } finally {
+ commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isUpdating: false });
+ }
+ },
+ delete: async ({ commit }, id) => {
+ commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isDeleting: true });
+ try {
+ await AttributeAPI.delete(id);
+ commit(types.DELETE_CUSTOM_ATTRIBUTE, id);
+ } catch (error) {
+ throw new Error(error);
+ } finally {
+ commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isDeleting: false });
+ }
+ },
+};
+
+export const mutations = {
+ [types.SET_CUSTOM_ATTRIBUTE_UI_FLAG](_state, data) {
+ _state.uiFlags = {
+ ..._state.uiFlags,
+ ...data,
+ };
+ },
+
+ [types.ADD_CUSTOM_ATTRIBUTE]: MutationHelpers.create,
+ [types.SET_CUSTOM_ATTRIBUTE]: MutationHelpers.set,
+ [types.EDIT_CUSTOM_ATTRIBUTE]: MutationHelpers.update,
+ [types.DELETE_CUSTOM_ATTRIBUTE]: MutationHelpers.destroy,
+};
+
+export default {
+ namespaced: true,
+ actions,
+ state,
+ getters,
+ mutations,
+};
diff --git a/app/javascript/dashboard/store/modules/specs/attributes/actions.spec.js b/app/javascript/dashboard/store/modules/specs/attributes/actions.spec.js
new file mode 100644
index 000000000..38ad67c86
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/attributes/actions.spec.js
@@ -0,0 +1,93 @@
+import axios from 'axios';
+import { actions } from '../../attributes';
+import * as types from '../../../mutation-types';
+import attributesList from './fixtures';
+
+const commit = jest.fn();
+global.axios = axios;
+jest.mock('axios');
+
+describe('#actions', () => {
+ describe('#get', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.get.mockResolvedValue({ data: attributesList });
+ await actions.get({ commit }, { inboxId: 23 });
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isFetching: true }],
+ [types.default.SET_CUSTOM_ATTRIBUTE, attributesList],
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isFetching: false }],
+ ]);
+ });
+ it('sends correct actions if API is error', async () => {
+ axios.get.mockRejectedValue({ message: 'Incorrect header' });
+ await actions.get({ commit }, { inboxId: 23 });
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isFetching: true }],
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isFetching: false }],
+ ]);
+ });
+ });
+ describe('#create', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.post.mockResolvedValue({ data: attributesList[0] });
+ await actions.create({ commit }, attributesList[0]);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isCreating: true }],
+ [types.default.ADD_CUSTOM_ATTRIBUTE, attributesList[0]],
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ it('sends correct actions if API is error', async () => {
+ axios.post.mockRejectedValue({ message: 'Incorrect header' });
+ await expect(actions.create({ commit })).rejects.toThrow(Error);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isCreating: true }],
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ });
+
+ describe('#update', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.patch.mockResolvedValue({ data: attributesList[0] });
+ await actions.update({ commit }, attributesList[0]);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isUpdating: true }],
+ [types.default.EDIT_CUSTOM_ATTRIBUTE, attributesList[0]],
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isUpdating: false }],
+ ]);
+ });
+ it('sends correct actions if API is error', async () => {
+ axios.patch.mockRejectedValue({ message: 'Incorrect header' });
+ await expect(
+ actions.update({ commit }, attributesList[0])
+ ).rejects.toThrow(Error);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isUpdating: true }],
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isUpdating: false }],
+ ]);
+ });
+ });
+
+ describe('#delete', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.delete.mockResolvedValue({ data: attributesList[0] });
+ await actions.delete({ commit }, attributesList[0].id);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isDeleting: true }],
+ [types.default.DELETE_CUSTOM_ATTRIBUTE, attributesList[0].id],
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isDeleting: false }],
+ ]);
+ });
+ it('sends correct actions if API is error', async () => {
+ axios.delete.mockRejectedValue({ message: 'Incorrect header' });
+ await expect(
+ actions.delete({ commit }, attributesList[0].id)
+ ).rejects.toThrow(Error);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isDeleting: true }],
+ [types.default.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isDeleting: false }],
+ ]);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/attributes/fixtures.js b/app/javascript/dashboard/store/modules/specs/attributes/fixtures.js
new file mode 100644
index 000000000..1a5cb48e3
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/attributes/fixtures.js
@@ -0,0 +1,16 @@
+export default [
+ {
+ attribute_display_name: 'Language',
+ attribute_display_type: 0,
+ attribute_description: 'The conversation language',
+ attribute_key: 'language',
+ attribute_model: 0,
+ },
+ {
+ attribute_display_name: 'Language one',
+ attribute_display_type: 1,
+ attribute_description: 'The conversation language one',
+ attribute_key: 'language_one',
+ attribute_model: 3,
+ },
+];
diff --git a/app/javascript/dashboard/store/modules/specs/attributes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/attributes/getters.spec.js
new file mode 100644
index 000000000..a6e94839c
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/attributes/getters.spec.js
@@ -0,0 +1,30 @@
+import { getters } from '../../attributes';
+import attributesList from './fixtures';
+
+describe('#getters', () => {
+ it('getAttributes', () => {
+ const state = { records: attributesList };
+ expect(getters.getAttributes(state)(1)).toEqual([
+ {
+ attribute_display_name: 'Language one',
+ attribute_display_type: 1,
+ attribute_description: 'The conversation language one',
+ attribute_key: 'language_one',
+ attribute_model: 3,
+ },
+ ]);
+ });
+
+ it('getUIFlags', () => {
+ const state = {
+ uiFlags: {
+ isFetching: true,
+ isCreating: false,
+ },
+ };
+ expect(getters.getUIFlags(state)).toEqual({
+ isFetching: true,
+ isCreating: false,
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/attributes/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/attributes/mutations.spec.js
new file mode 100644
index 000000000..de3435c1c
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/attributes/mutations.spec.js
@@ -0,0 +1,44 @@
+import types from '../../../mutation-types';
+import { mutations } from '../../attributes';
+import attributesList from './fixtures';
+
+describe('#mutations', () => {
+ describe('#SET_CUSTOM_ATTRIBUTE', () => {
+ it('set attribute records', () => {
+ const state = { records: [] };
+ mutations[types.SET_CUSTOM_ATTRIBUTE](state, attributesList);
+ expect(state.records).toEqual(attributesList);
+ });
+ });
+
+ describe('#ADD_CUSTOM_ATTRIBUTE', () => {
+ it('push newly created attributes to the store', () => {
+ const state = { records: [attributesList[0]] };
+ mutations[types.ADD_CUSTOM_ATTRIBUTE](state, attributesList[1]);
+ expect(state.records).toEqual([attributesList[0], attributesList[1]]);
+ });
+ });
+ describe('#EDIT_CUSTOM_ATTRIBUTE', () => {
+ it('update attribute record', () => {
+ const state = { records: [attributesList[0]] };
+ mutations[types.EDIT_CUSTOM_ATTRIBUTE](state, {
+ attribute_display_name: 'Language',
+ attribute_display_type: 0,
+ attribute_description: 'The conversation language',
+ attribute_key: 'language',
+ attribute_model: 0,
+ });
+ expect(state.records[0].attribute_description).toEqual(
+ 'The conversation language'
+ );
+ });
+ });
+
+ describe('#DELETE_CUSTOM_ATTRIBUTE', () => {
+ it('delete attribute record', () => {
+ const state = { records: [attributesList[0]] };
+ mutations[types.DELETE_CUSTOM_ATTRIBUTE](state, attributesList[0]);
+ expect(state.records).toEqual([attributesList[0]]);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index df0f8fff6..c364f7e4d 100755
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -172,4 +172,11 @@ export default {
SET_CSAT_RESPONSE_UI_FLAG: 'SET_CSAT_RESPONSE_UI_FLAG',
SET_CSAT_RESPONSE: 'SET_CSAT_RESPONSE',
SET_CSAT_RESPONSE_METRICS: 'SET_CSAT_RESPONSE_METRICS',
+
+ // Custom Attributes
+ SET_CUSTOM_ATTRIBUTE_UI_FLAG: 'SET_CUSTOM_ATTRIBUTE_UI_FLAG',
+ SET_CUSTOM_ATTRIBUTE: 'SET_CUSTOM_ATTRIBUTE',
+ ADD_CUSTOM_ATTRIBUTE: 'ADD_CUSTOM_ATTRIBUTE',
+ EDIT_CUSTOM_ATTRIBUTE: 'EDIT_CUSTOM_ATTRIBUTE',
+ DELETE_CUSTOM_ATTRIBUTE: 'DELETE_CUSTOM_ATTRIBUTE',
};
diff --git a/app/models/custom_attribute_definition.rb b/app/models/custom_attribute_definition.rb
index 3c861696f..1e43b5e09 100644
--- a/app/models/custom_attribute_definition.rb
+++ b/app/models/custom_attribute_definition.rb
@@ -29,7 +29,7 @@ class CustomAttributeDefinition < ApplicationRecord
validates :attribute_model, presence: true
enum attribute_model: { conversation_attribute: 0, contact_attribute: 1 }
- enum attribute_display_type: { text: 0, number: 1, currency: 2, percent: 3, link: 4 }
+ enum attribute_display_type: { text: 0, number: 1, currency: 2, percent: 3, link: 4, date: 5 }
belongs_to :account
end
From 1ff9939a80ad02ef50c3dadefc453da4f0b751c1 Mon Sep 17 00:00:00 2001
From: "Aswin Dev P.S"
Date: Tue, 31 Aug 2021 15:12:05 +0530
Subject: [PATCH 25/72] feat: Ability to remove inbox avatar (#2885)
* Delete inbox avatar
1) New API endpoint added for deleting inbox avatar.
2) Delete avatar button in the inbox settings page.
Co-authored-by: Sojan Jose
Co-authored-by: Muhsin Keloth
---
.../api/v1/accounts/inboxes_controller.rb | 5 ++
app/javascript/dashboard/api/inboxes.js | 4 ++
.../dashboard/api/specs/inboxes.spec.js | 7 +++
.../widgets/forms/AvatarUploader.vue | 52 +++++++++++++++----
.../dashboard/i18n/locale/en/inboxMgmt.json | 5 +-
.../dashboard/settings/inbox/Settings.vue | 19 +++++++
.../dashboard/store/modules/inboxes.js | 7 +++
.../modules/specs/inboxes/actions.spec.js | 15 ++++++
app/policies/inbox_policy.rb | 4 ++
config/routes.rb | 1 +
.../v1/accounts/inboxes_controller_spec.rb | 36 +++++++++++++
11 files changed, 143 insertions(+), 12 deletions(-)
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 2621a4a33..d492b0d9e 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -15,6 +15,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@campaigns = @inbox.campaigns
end
+ def avatar
+ @inbox.avatar.attachment.destroy! if @inbox.avatar.attached?
+ head :ok
+ end
+
def create
ActiveRecord::Base.transaction do
channel = create_channel
diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js
index b6d8ab860..1cf6ba113 100644
--- a/app/javascript/dashboard/api/inboxes.js
+++ b/app/javascript/dashboard/api/inboxes.js
@@ -13,6 +13,10 @@ class Inboxes extends ApiClient {
getCampaigns(inboxId) {
return axios.get(`${this.url}/${inboxId}/campaigns`);
}
+
+ deleteInboxAvatar(inboxId) {
+ return axios.delete(`${this.url}/${inboxId}/avatar`);
+ }
}
export default new Inboxes();
diff --git a/app/javascript/dashboard/api/specs/inboxes.spec.js b/app/javascript/dashboard/api/specs/inboxes.spec.js
index 6c5cf38ea..b261bb930 100644
--- a/app/javascript/dashboard/api/specs/inboxes.spec.js
+++ b/app/javascript/dashboard/api/specs/inboxes.spec.js
@@ -27,5 +27,12 @@ describe('#InboxesAPI', () => {
'/api/v1/inboxes/2/campaigns'
);
});
+
+ it('#deleteInboxAvatar', () => {
+ inboxesAPI.deleteInboxAvatar(2);
+ expect(context.axiosMock.delete).toHaveBeenCalledWith(
+ '/api/v1/inboxes/2/avatar'
+ );
+ });
});
});
diff --git a/app/javascript/dashboard/components/widgets/forms/AvatarUploader.vue b/app/javascript/dashboard/components/widgets/forms/AvatarUploader.vue
index 9af6b4029..89ca4f56a 100644
--- a/app/javascript/dashboard/components/widgets/forms/AvatarUploader.vue
+++ b/app/javascript/dashboard/components/widgets/forms/AvatarUploader.vue
@@ -1,16 +1,31 @@
-
+
+
diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
index 3731e79a9..603ec58e9 100644
--- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
@@ -232,6 +232,7 @@
},
"DELETE": {
"BUTTON_TEXT": "Delete",
+ "AVATAR_DELETE_BUTTON_TEXT": "Delete Avatar",
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure to delete ",
@@ -241,7 +242,9 @@
},
"API": {
"SUCCESS_MESSAGE": "Inbox deleted successfully",
- "ERROR_MESSAGE": "Could not delete inbox. Please try again later."
+ "ERROR_MESSAGE": "Could not delete inbox. Please try again later.",
+ "AVATAR_SUCCESS_MESSAGE": "Inbox avatar deleted successfully",
+ "AVATAR_ERROR_MESSAGE": "Could not delete the inbox avatar. Please try again later."
}
},
"TABS": {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index b41c13a97..6357f67fd 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -22,7 +22,9 @@
{
+ try {
+ await InboxesAPI.deleteInboxAvatar(inboxId);
+ } catch (error) {
+ throw new Error(error);
+ }
+ },
};
export const mutations = {
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
index c2d32976c..da3424267 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
@@ -128,4 +128,19 @@ describe('#actions', () => {
]);
});
});
+
+ describe('#deleteInboxAvatar', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.delete.mockResolvedValue();
+ await expect(
+ actions.deleteInboxAvatar({}, inboxList[0].id)
+ ).resolves.toBe();
+ });
+ it('sends correct actions if API is error', async () => {
+ axios.delete.mockRejectedValue({ message: 'Incorrect header' });
+ await expect(
+ actions.deleteInboxAvatar({}, inboxList[0].id)
+ ).rejects.toThrow(Error);
+ });
+ });
});
diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb
index dd3aa9305..851cac7d0 100644
--- a/app/policies/inbox_policy.rb
+++ b/app/policies/inbox_policy.rb
@@ -53,4 +53,8 @@ class InboxPolicy < ApplicationPolicy
def set_agent_bot?
@account_user.administrator?
end
+
+ def avatar?
+ @account_user.administrator?
+ end
end
diff --git a/config/routes.rb b/config/routes.rb
index a4a2f5459..d89332be4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -102,6 +102,7 @@ Rails.application.routes.draw do
get :campaigns, on: :member
get :agent_bot, on: :member
post :set_agent_bot, on: :member
+ delete :avatar, on: :member
end
resources :inbox_members, only: [:create, :show], param: :inbox_id
resources :labels, only: [:index, :show, :create, :update, :destroy]
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index 1a5c31088..1b7262ba8 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -113,6 +113,42 @@ RSpec.describe 'Inboxes API', type: :request do
end
end
+ describe 'DELETE /api/v1/accounts/{account.id}/inboxes/{inbox.id}/avatar' do
+ let(:inbox) { create(:inbox, account: account) }
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/avatar"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ before do
+ create(:inbox_member, user: agent, inbox: inbox)
+ inbox.avatar.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png')
+ end
+
+ it 'delete inbox avatar for administrator user' do
+ delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/avatar",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect { inbox.avatar.attachment.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'returns unauthorized for agent user' do
+ delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/avatar",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
describe 'DELETE /api/v1/accounts/{account.id}/inboxes/:id' do
let(:inbox) { create(:inbox, account: account) }
From 5319af3dfce9d17374851ae4cf817d77811fd378 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Tue, 31 Aug 2021 15:30:18 +0530
Subject: [PATCH 26/72] chore: Merge contact copy over information (#2812)
fixes: #2767, #2773
Co-authored-by: Nithin David <1277421+nithindavid@users.noreply.github.com>
---
app/actions/contact_merge_action.rb | 16 ++++++++--
app/javascript/widget/helpers/actionCable.js | 31 +++++++++++++-------
app/listeners/action_cable_listener.rb | 7 +++++
app/models/contact.rb | 2 ++
lib/events/types.rb | 1 +
spec/actions/contact_merge_action_spec.rb | 22 ++++++++++++--
6 files changed, 64 insertions(+), 15 deletions(-)
diff --git a/app/actions/contact_merge_action.rb b/app/actions/contact_merge_action.rb
index 3a3ef11ed..f20e2ffb4 100644
--- a/app/actions/contact_merge_action.rb
+++ b/app/actions/contact_merge_action.rb
@@ -1,4 +1,5 @@
class ContactMergeAction
+ include Events::Types
pattr_initialize [:account!, :base_contact!, :mergee_contact!]
def perform
@@ -11,7 +12,7 @@ class ContactMergeAction
merge_conversations
merge_messages
merge_contact_inboxes
- remove_mergee_contact
+ merge_and_remove_mergee_contact
end
@base_contact
end
@@ -40,7 +41,18 @@ class ContactMergeAction
ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
end
- def remove_mergee_contact
+ def merge_and_remove_mergee_contact
+ mergable_attribute_keys = %w[identifier name email phone_number custom_attributes]
+ base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank
+ mergee_contact_attributes = mergee_contact.attributes.slice(*mergable_attribute_keys).compact_blank
+
+ # attributes in base contact are given preference
+ merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
+ # retaining old pubsub token to notify the contacts that are listening
+ mergee_pubsub_token = mergee_contact.pubsub_token
+
@mergee_contact.destroy!
+ Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact, tokens: [mergee_pubsub_token])
+ @base_contact.update!(merged_attributes)
end
end
diff --git a/app/javascript/widget/helpers/actionCable.js b/app/javascript/widget/helpers/actionCable.js
index 9b6cf2211..32bdd644f 100644
--- a/app/javascript/widget/helpers/actionCable.js
+++ b/app/javascript/widget/helpers/actionCable.js
@@ -10,9 +10,22 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.typing_off': this.onTypingOff,
'conversation.status_changed': this.onStatusChange,
'presence.update': this.onPresenceUpdate,
+ 'contact.merged': this.onContactMerge,
};
}
+ static refreshConnector = pubsubToken => {
+ if (!pubsubToken || window.chatwootPubsubToken === pubsubToken) {
+ return;
+ }
+ window.chatwootPubsubToken = pubsubToken;
+ window.actionCable.disconnect();
+ window.actionCable = new ActionCableConnector(
+ window.WOOT_WIDGET,
+ window.chatwootPubsubToken
+ );
+ };
+
onStatusChange = data => {
this.app.$store.dispatch('conversationAttributes/update', data);
};
@@ -33,6 +46,11 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('agent/updatePresence', data.users);
};
+ onContactMerge = data => {
+ const { pubsub_token: pubsubToken } = data;
+ ActionCableConnector.refreshConnector(pubsubToken);
+ };
+
onTypingOn = () => {
this.clearTimer();
this.app.$store.dispatch('conversation/toggleAgentTyping', {
@@ -63,16 +81,7 @@ class ActionCableConnector extends BaseActionCableConnector {
};
}
-export const refreshActionCableConnector = pubsubToken => {
- if (!pubsubToken || window.chatwootPubsubToken === pubsubToken) {
- return;
- }
- window.chatwootPubsubToken = pubsubToken;
- window.actionCable.disconnect();
- window.actionCable = new ActionCableConnector(
- window.WOOT_WIDGET,
- window.chatwootPubsubToken
- );
-};
+export const refreshActionCableConnector =
+ ActionCableConnector.refreshConnector;
export default ActionCableConnector;
diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb
index 3efae0b30..dfedc5ba7 100644
--- a/app/listeners/action_cable_listener.rb
+++ b/app/listeners/action_cable_listener.rb
@@ -104,6 +104,13 @@ class ActionCableListener < BaseListener
broadcast(account, tokens, CONTACT_UPDATED, contact.push_event_data)
end
+ def contact_merged(event)
+ contact, account = extract_contact_and_account(event)
+ tokens = event.data[:tokens]
+
+ broadcast(account, tokens, CONTACT_MERGED, contact.push_event_data)
+ end
+
private
def typing_event_listener_tokens(account, conversation, user)
diff --git a/app/models/contact.rb b/app/models/contact.rb
index 391c0a2bf..b81b520f2 100644
--- a/app/models/contact.rb
+++ b/app/models/contact.rb
@@ -77,6 +77,8 @@ class Contact < ApplicationRecord
}
end
+ private
+
def ip_lookup
return unless account.feature_enabled?('ip_lookup')
diff --git a/lib/events/types.rb b/lib/events/types.rb
index 4d027f6f8..c47691cd2 100644
--- a/lib/events/types.rb
+++ b/lib/events/types.rb
@@ -34,6 +34,7 @@ module Events::Types
# contact events
CONTACT_CREATED = 'contact.created'
CONTACT_UPDATED = 'contact.updated'
+ CONTACT_MERGED = 'contact.merged'
# agent events
AGENT_ADDED = 'agent.added'
diff --git a/spec/actions/contact_merge_action_spec.rb b/spec/actions/contact_merge_action_spec.rb
index 62ff81a52..a00f908fa 100644
--- a/spec/actions/contact_merge_action_spec.rb
+++ b/spec/actions/contact_merge_action_spec.rb
@@ -4,8 +4,14 @@ describe ::ContactMergeAction do
subject(:contact_merge) { described_class.new(account: account, base_contact: base_contact, mergee_contact: mergee_contact).perform }
let!(:account) { create(:account) }
- let!(:base_contact) { create(:contact, account: account) }
- let!(:mergee_contact) { create(:contact, account: account) }
+ let!(:base_contact) do
+ create(:contact, identifier: 'base_contact', email: 'old@old.com', phone_number: '', custom_attributes: { val_test: 'old', val_empty_old: '' },
+ account: account)
+ end
+ let!(:mergee_contact) do
+ create(:contact, identifier: '', email: 'new@new.com', phone_number: '+12212345',
+ custom_attributes: { val_test: 'new', val_new: 'new', val_empty_new: '' }, account: account)
+ end
before do
2.times.each do
@@ -21,6 +27,18 @@ describe ::ContactMergeAction do
expect { mergee_contact.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
+ it 'copies information from mergee contact to base contact' do
+ contact_merge
+ base_contact.reload
+ expect(base_contact.identifier).to eq('base_contact')
+ expect(base_contact.email).to eq('old@old.com')
+ expect(base_contact.phone_number).to eq('+12212345')
+ expect(base_contact.custom_attributes['val_test']).to eq('old')
+ expect(base_contact.custom_attributes['val_new']).to eq('new')
+ expect(base_contact.custom_attributes['val_empty_old']).to eq('')
+ expect(base_contact.custom_attributes['val_empty_new']).to eq('')
+ end
+
context 'when base contact and merge contact are same' do
it 'does not delete contact' do
mergee_contact = base_contact
From 6563cccf34b08dc0d42a05ce080986b4f752e032 Mon Sep 17 00:00:00 2001
From: "Aswin Dev P.S"
Date: Tue, 31 Aug 2021 19:06:12 +0530
Subject: [PATCH 27/72] Add attachment button for email conversation (#2927)
---
.../dashboard/components/widgets/conversation/ReplyBox.vue | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index e29e48506..99b63a1f2 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -222,7 +222,8 @@ export default {
this.isAWebWidgetInbox ||
this.isAFacebookInbox ||
this.isATwilioWhatsappChannel ||
- this.isAPIInbox
+ this.isAPIInbox ||
+ this.isAnEmailChannel
);
},
replyButtonLabel() {
From 06d89163414aeddba5d5486a8db6ae5371d1173a Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Tue, 31 Aug 2021 19:45:11 +0530
Subject: [PATCH 28/72] fix: Handle more rest client exceptions (#2930)
---
lib/exception_list.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/exception_list.rb b/lib/exception_list.rb
index b0703626d..00b3e26a7 100644
--- a/lib/exception_list.rb
+++ b/lib/exception_list.rb
@@ -2,7 +2,7 @@ module ExceptionList
REST_CLIENT_EXCEPTIONS = [RestClient::NotFound, RestClient::GatewayTimeout, RestClient::BadRequest,
RestClient::MethodNotAllowed, RestClient::Forbidden, RestClient::InternalServerError,
RestClient::Exceptions::OpenTimeout, RestClient::Exceptions::ReadTimeout,
- RestClient::MovedPermanently, SocketError].freeze
+ RestClient::MovedPermanently, RestClient::ServiceUnavailable, Errno::ECONNREFUSED, SocketError].freeze
SMTP_EXCEPTIONS = [
Net::SMTPSyntaxError
].freeze
From 6fdd4a29969be8423f31890b807d27d13627c50c Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Wed, 1 Sep 2021 15:08:05 +0530
Subject: [PATCH 29/72] chore: Security Improvements to the API (#2893)
- Devise auth tokens are reset on password update
- Avatar attachment file type is limited to jpeg,gif and png
- Avatar attachment file size is limited to 15 mb
- Widget Message attachments are limited to types ['image/png', 'image/jpeg', 'image/gif', 'image/bmp', 'image/tiff', 'application/pdf', 'audio/mpeg', 'video/mp4', 'audio/ogg', 'text/csv']
- Widget Message attachments are limited to 40Mb size limit.
---
app/builders/messages/message_builder.rb | 24 +++++++++++--------
.../api/v1/widget/messages_controller.rb | 9 ++++---
.../api/v1/inboxes/messages_controller.rb | 9 ++++---
app/helpers/file_type_helper.rb | 2 +-
.../widgets/WootWriter/ReplyBottomPanel.vue | 3 ++-
.../widgets/forms/AvatarUploader.vue | 3 ++-
app/models/attachment.rb | 19 +++++++++++++++
app/models/concerns/avatarable.rb | 10 ++++++++
config/initializers/devise_token_auth.rb | 4 ++++
9 files changed, 60 insertions(+), 23 deletions(-)
diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb
index ba78d76d7..b6ae5409d 100644
--- a/app/builders/messages/message_builder.rb
+++ b/app/builders/messages/message_builder.rb
@@ -15,21 +15,25 @@ class Messages::MessageBuilder
def perform
@message = @conversation.messages.build(message_params)
- if @attachments.present?
- @attachments.each do |uploaded_attachment|
- attachment = @message.attachments.new(
- account_id: @message.account_id,
- file_type: file_type(uploaded_attachment&.content_type)
- )
- attachment.file.attach(uploaded_attachment)
- end
- end
- @message.save
+ process_attachments
+ @message.save!
@message
end
private
+ def process_attachments
+ return if @attachments.blank?
+
+ @attachments.each do |uploaded_attachment|
+ @message.attachments.build(
+ account_id: @message.account_id,
+ file_type: file_type(uploaded_attachment&.content_type),
+ file: uploaded_attachment
+ )
+ end
+ end
+
def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb
index 9e6f770ad..0d9ab6323 100644
--- a/app/controllers/api/v1/widget/messages_controller.rb
+++ b/app/controllers/api/v1/widget/messages_controller.rb
@@ -8,8 +8,8 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def create
@message = conversation.messages.new(message_params)
- @message.save
build_attachment
+ @message.save!
end
def update
@@ -29,13 +29,12 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
return if params[:message][:attachments].blank?
params[:message][:attachments].each do |uploaded_attachment|
- attachment = @message.attachments.new(
+ @message.attachments.new(
account_id: @message.account_id,
- file_type: helpers.file_type(uploaded_attachment&.content_type)
+ file_type: helpers.file_type(uploaded_attachment&.content_type),
+ file: uploaded_attachment
)
- attachment.file.attach(uploaded_attachment)
end
- @message.save!
end
def set_conversation
diff --git a/app/controllers/public/api/v1/inboxes/messages_controller.rb b/app/controllers/public/api/v1/inboxes/messages_controller.rb
index 68c0f5223..925c16d38 100644
--- a/app/controllers/public/api/v1/inboxes/messages_controller.rb
+++ b/app/controllers/public/api/v1/inboxes/messages_controller.rb
@@ -7,8 +7,8 @@ class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesCon
def create
@message = @conversation.messages.new(message_params)
- @message.save
build_attachment
+ @message.save!
end
def update
@@ -23,13 +23,12 @@ class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesCon
return if params[:attachments].blank?
params[:attachments].each do |uploaded_attachment|
- attachment = @message.attachments.new(
+ @message.attachments.new(
account_id: @message.account_id,
- file_type: helpers.file_type(uploaded_attachment&.content_type)
+ file_type: helpers.file_type(uploaded_attachment&.content_type),
+ file: uploaded_attachment
)
- attachment.file.attach(uploaded_attachment)
end
- @message.save!
end
def message_finder_params
diff --git a/app/helpers/file_type_helper.rb b/app/helpers/file_type_helper.rb
index 4936fa155..64d67701a 100644
--- a/app/helpers/file_type_helper.rb
+++ b/app/helpers/file_type_helper.rb
@@ -3,12 +3,12 @@ module FileTypeHelper
return :image if [
'image/jpeg',
'image/png',
- 'image/svg+xml',
'image/gif',
'image/tiff',
'image/bmp'
].include?(content_type)
+ return :video if content_type.include?('video/')
return :audio if content_type.include?('audio/')
:file
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
index 1ffb09e04..1ffc9be65 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
@@ -11,10 +11,11 @@
@click="toggleEmojiPicker"
/>
+
+