feat: Add CSAT response APIs (#2503)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Sojan Jose 2021-06-29 20:59:41 +05:30 committed by GitHub
parent 2e71006f9d
commit dd9d5e410c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 358 additions and 23 deletions

View file

@ -0,0 +1,28 @@
class CsatSurveys::ResponseBuilder
pattr_initialize [:message]
def perform
raise 'Invalid Message' unless message.input_csat?
conversation = message.conversation
rating = message.content_attributes.dig('submitted_values', 'csat_survey_response', 'rating')
feedback_message = message.content_attributes.dig('submitted_values', 'csat_survey_response', 'feedback_message')
return if rating.blank?
process_csat_response(conversation, rating, feedback_message)
end
private
def process_csat_response(conversation, rating, feedback_message)
csat_survey_response = message.csat_survey_response || CsatSurveyResponse.new(
message_id: message.id, account_id: message.account_id, conversation_id: message.conversation_id,
contact_id: conversation.contact_id, assigned_agent: conversation.assignee
)
csat_survey_response.rating = rating
csat_survey_response.feedback_message = feedback_message
csat_survey_response.save!
csat_survey_response
end
end

View file

@ -1,4 +1,5 @@
class V2::ReportBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@ -83,10 +84,6 @@ class V2::ReportBuilder
.average(:value)
end
def range
parse_date_time(params[:since])..parse_date_time(params[:until])
end
# Taking average of average is not too accurate
# https://en.wikipedia.org/wiki/Simpson's_paradox
# TODO: Will optimize this later
@ -101,11 +98,4 @@ class V2::ReportBuilder
(avg_first_response_time.values.sum / avg_first_response_time.values.length)
end
def parse_date_time(datetime)
return datetime if datetime.is_a?(DateTime)
return datetime.to_datetime if datetime.is_a?(Time) || datetime.is_a?(Date)
DateTime.strptime(datetime, '%s')
end
end

View file

@ -0,0 +1,22 @@
class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::BaseController
include DateRangeHelper
RESULTS_PER_PAGE = 25
before_action :check_authorization
before_action :csat_survey_responses, only: [:index]
def index; end
private
def csat_survey_responses
@csat_survey_responses = Current.account.csat_survey_responses
@csat_survey_responses = @csat_survey_responses.where(created_at: range) if range.present?
@csat_survey_responses.page(@current_page).per(RESULTS_PER_PAGE)
end
def set_current_page
@current_page = params[:page] || 1
end
end

View file

@ -54,7 +54,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def message_update_params
params.permit(message: [{ submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback, :rating] }] }])
params.permit(message: [{ submitted_values: [:name, :title, :value, { csat_survey_response: [:feedback_message, :rating] }] }])
end
def permitted_params

View file

@ -10,10 +10,12 @@ class AsyncDispatcher < BaseDispatcher
def listeners
[
CampaignListener.instance,
CsatSurveyListener.instance,
EventListener.instance,
WebhookListener.instance,
InstallationWebhookListener.instance, HookListener.instance,
CampaignListener.instance
HookListener.instance,
InstallationWebhookListener.instance,
WebhookListener.instance
]
end
end

View file

@ -0,0 +1,19 @@
##############################################
# Helpers to implement date range filtering to APIs
# Include in your controller or service class where params is available
##############################################
module DateRangeHelper
def range
return if params[:since].blank? || params[:until].blank?
parse_date_time(params[:since])..parse_date_time(params[:until])
end
def parse_date_time(datetime)
return datetime if datetime.is_a?(DateTime)
return datetime.to_datetime if datetime.is_a?(Time) || datetime.is_a?(Date)
DateTime.strptime(datetime, '%s')
end
end

View file

@ -43,7 +43,7 @@ const generateCSATContent = (
const {
submitted_values: { csat_survey_response: surveyResponse = {} } = {},
} = contentAttributes;
const { rating, feedback } = surveyResponse || {};
const { rating, feedback_message } = surveyResponse || {};
let messageContent = '';
if (rating) {
@ -53,9 +53,9 @@ const generateCSATContent = (
messageContent += `<div><strong>${ratingTitle}</strong></div>`;
messageContent += `<p>${ratingObject.emoji}</p>`;
}
if (feedback) {
if (feedback_message) {
messageContent += `<div><strong>${feedbackTitle}</strong></div>`;
messageContent += `<p>${feedback}</p>`;
messageContent += `<p>${feedback_message}</p>`;
}
return messageContent;
};

View file

@ -21,7 +21,10 @@ describe('#generateBotMessageContent', () => {
expect(
generateBotMessageContent('input_csat', {
submitted_values: {
csat_survey_response: { rating: 5, feedback: 'Great Service' },
csat_survey_response: {
rating: 5,
feedback_message: 'Great Service',
},
},
})
).toEqual(
@ -33,7 +36,7 @@ describe('#generateBotMessageContent', () => {
'input_csat',
{
submitted_values: {
csat_survey_response: { rating: 1, feedback: '' },
csat_survey_response: { rating: 1, feedback_message: '' },
},
},
{ csat: { ratingTitle: 'റേറ്റിംഗ്', feedbackTitle: 'പ്രതികരണം' } }

View file

@ -130,7 +130,7 @@ export default {
submittedValues: {
csat_survey_response: {
rating,
feedback,
feedback_message: feedback,
},
},
messageId: this.messageId,

View file

@ -0,0 +1,8 @@
class CsatSurveyListener < BaseListener
def message_updated(event)
message = extract_message_and_account(event)[0]
return unless message.input_csat?
CsatSurveys::ResponseBuilder.new(message: message).perform
end
end

View file

@ -34,6 +34,7 @@ class Account < ApplicationRecord
has_many :account_users, dependent: :destroy
has_many :agent_bot_inboxes, dependent: :destroy
has_many :agent_bots, dependent: :destroy
has_many :csat_survey_responses, dependent: :destroy
has_many :data_imports, dependent: :destroy
has_many :users, through: :account_users
has_many :inboxes, dependent: :destroy

View file

@ -39,6 +39,7 @@ class Contact < ApplicationRecord
belongs_to :account
has_many :conversations, dependent: :destroy
has_many :contact_inboxes, dependent: :destroy
has_many :csat_survey_responses, dependent: :destroy
has_many :inboxes, through: :contact_inboxes
has_many :messages, as: :sender, dependent: :destroy
has_many :notes, dependent: :destroy

View file

@ -60,6 +60,7 @@ class Conversation < ApplicationRecord
belongs_to :campaign, optional: true
has_many :messages, dependent: :destroy, autosave: true
has_one :csat_survey_response, dependent: :destroy
before_create :set_bot_conversation

View file

@ -0,0 +1,43 @@
# == Schema Information
#
# Table name: csat_survey_responses
#
# id :bigint not null, primary key
# feedback_message :text
# rating :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assigned_agent_id :bigint
# contact_id :bigint not null
# conversation_id :bigint not null
# message_id :bigint not null
#
# Indexes
#
# index_csat_survey_responses_on_account_id (account_id)
# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id)
# index_csat_survey_responses_on_contact_id (contact_id)
# index_csat_survey_responses_on_conversation_id (conversation_id)
# index_csat_survey_responses_on_message_id (message_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (assigned_agent_id => users.id)
# fk_rails_... (contact_id => contacts.id)
# fk_rails_... (conversation_id => conversations.id)
# fk_rails_... (message_id => messages.id)
#
class CsatSurveyResponse < ApplicationRecord
belongs_to :account
belongs_to :conversation
belongs_to :contact
belongs_to :message
belongs_to :assigned_agent, class_name: 'User', optional: true
validates :rating, presence: true, inclusion: { in: [1, 2, 3, 4, 5] }
validates :account_id, presence: true
validates :contact_id, presence: true
validates :conversation_id, presence: true
end

View file

@ -79,6 +79,7 @@ class Message < ApplicationRecord
belongs_to :sender, polymorphic: true, required: false
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
has_one :csat_survey_response, dependent: :destroy
after_create :reopen_conversation,
:notify_via_mail

View file

@ -71,6 +71,7 @@ class User < ApplicationRecord
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify
alias_attribute :conversations, :assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify
has_many :inbox_members, dependent: :destroy
has_many :inboxes, through: :inbox_members, source: :inbox

View file

@ -0,0 +1,5 @@
class CsatSurveyResponsePolicy < ApplicationPolicy
def index?
@account_user.administrator?
end
end

View file

@ -0,0 +1,3 @@
json.array! @csat_survey_responses do |csat_survey_response|
json.partial! 'api/v1/models/csat_survey_response.json.jbuilder', resource: csat_survey_response
end

View file

@ -0,0 +1,9 @@
json.id resource.id
json.rating resource.rating
json.feedback_message resource.feedback_message
json.account_id resource.account_id
json.message_id resource.message_id
json.contact resource.contact
json.conversation_id resource.conversation.display_id
json.assigned_agent resource.assigned_agent
json.created_at resource.created_at

View file

@ -87,6 +87,7 @@ Rails.application.routes.draw do
resources :labels, only: [:create, :index]
end
end
resources :csat_survey_responses, only: [:index]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :create, :update, :destroy] do
get :assignable_agents, on: :member

View file

@ -0,0 +1,15 @@
class CreateCsatSurveyResponses < ActiveRecord::Migration[6.0]
def change
create_table :csat_survey_responses do |t|
t.references :account, null: false, foreign_key: true
t.references :conversation, null: false, foreign_key: true
t.references :message, null: false, foreign_key: true, index: { unique: true }
t.integer :rating, null: false
t.text :feedback_message
t.references :contact, null: false, foreign_key: true
t.references :assigned_agent, foreign_key: { to_table: :users }
t.timestamps
end
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_06_23_150613) do
ActiveRecord::Schema.define(version: 2021_06_23_155413) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
@ -267,6 +267,23 @@ ActiveRecord::Schema.define(version: 2021_06_23_150613) do
t.index ["team_id"], name: "index_conversations_on_team_id"
end
create_table "csat_survey_responses", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "conversation_id", null: false
t.bigint "message_id", null: false
t.integer "rating", null: false
t.text "feedback_message"
t.bigint "contact_id", null: false
t.bigint "assigned_agent_id"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["account_id"], name: "index_csat_survey_responses_on_account_id"
t.index ["assigned_agent_id"], name: "index_csat_survey_responses_on_assigned_agent_id"
t.index ["contact_id"], name: "index_csat_survey_responses_on_contact_id"
t.index ["conversation_id"], name: "index_csat_survey_responses_on_conversation_id"
t.index ["message_id"], name: "index_csat_survey_responses_on_message_id", unique: true
end
create_table "custom_filters", force: :cascade do |t|
t.string "name", null: false
t.integer "filter_type", default: 0, null: false
@ -652,6 +669,11 @@ ActiveRecord::Schema.define(version: 2021_06_23_150613) do
add_foreign_key "conversations", "campaigns"
add_foreign_key "conversations", "contact_inboxes"
add_foreign_key "conversations", "teams"
add_foreign_key "csat_survey_responses", "accounts"
add_foreign_key "csat_survey_responses", "contacts"
add_foreign_key "csat_survey_responses", "conversations"
add_foreign_key "csat_survey_responses", "messages"
add_foreign_key "csat_survey_responses", "users", column: "assigned_agent_id"
add_foreign_key "data_imports", "accounts"
add_foreign_key "notes", "accounts"
add_foreign_key "notes", "contacts"

View file

@ -0,0 +1,30 @@
require 'rails_helper'
describe ::CsatSurveys::ResponseBuilder do
let(:message) do
create(
:message, content_type: :input_csat,
content_attributes: { 'submitted_values': { 'csat_survey_response': { 'rating': 5, 'feedback_message': 'hello' } } }
)
end
describe '#perform' do
it 'creates a new csat survey response' do
csat_survey_response = described_class.new(
message: message
).perform
expect(csat_survey_response.valid?).to eq(true)
end
it 'updates the value of csat survey response if response already exists' do
existing_survey_response = create(:csat_survey_response, message: message)
csat_survey_response = described_class.new(
message: message
).perform
expect(csat_survey_response.id).to eq(existing_survey_response.id)
expect(csat_survey_response.rating).to eq(5)
end
end
end

View file

@ -18,7 +18,9 @@ RSpec.describe 'Campaigns API', type: :request do
let!(:campaign) { create(:campaign, account: account) }
it 'returns unauthorized for agents' do
get "/api/v1/accounts/#{account.id}/campaigns"
get "/api/v1/accounts/#{account.id}/campaigns",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end

View file

@ -0,0 +1,52 @@
require 'rails_helper'
RSpec.describe 'CSAT Survey Responses API', type: :request do
let(:account) { create(:account) }
let!(:csat_survey_response) { create(:csat_survey_response, account: account) }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
describe 'GET /api/v1/accounts/{account.id}/csat_survey_responses' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/csat_survey_responses"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'returns unauthorized for agents' do
get "/api/v1/accounts/#{account.id}/csat_survey_responses",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns all the csat survey responses for administrators' do
get "/api/v1/accounts/#{account.id}/csat_survey_responses",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body).first['feedback_message']).to eq(csat_survey_response.feedback_message)
end
it 'filters csat responsed based on a date range' do
csat_10_days_ago = create(:csat_survey_response, account: account, created_at: 10.days.ago)
csat_3_days_ago = create(:csat_survey_response, account: account, created_at: 3.days.ago)
get "/api/v1/accounts/#{account.id}/csat_survey_responses",
params: { since: 5.days.ago.to_time.to_i.to_s, until: Time.zone.today.to_time.to_i.to_s },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body)
expect(response_data.pluck('id')).to include(csat_3_days_ago.id)
expect(response_data.pluck('id')).not_to include(csat_10_days_ago.id)
end
end
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
FactoryBot.define do
factory :csat_survey_response do
rating { 1 }
feedback_message { Faker::Movie.quote }
account
conversation
message
contact
end
end

View file

@ -0,0 +1,36 @@
require 'rails_helper'
describe CsatSurveyListener do
let(:listener) { described_class.instance }
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
let!(:message) do
create(
:message, message_type: 'outgoing', account: account, inbox: inbox, conversation: conversation,
content_type: :input_csat,
content_attributes: { 'submitted_values': { 'csat_survey_response': { 'rating': 5, 'feedback_message': 'hello' } } }
)
end
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
describe '#message_updated' do
let(:event_name) { 'message.updated' }
let(:response_builder) { double }
context 'when CsatSurveys::ResponseBuilder' do
it 'triggers if message is input csat' do
expect(response_builder).to receive(:perform)
expect(CsatSurveys::ResponseBuilder).to receive(:new).with(message: message).and_return(response_builder).once
listener.message_updated(event)
end
it 'will not trigger if message is not input csat' do
message = create(:message)
event = Events::Base.new(event_name, Time.zone.now, message: message)
expect(CsatSurveys::ResponseBuilder).not_to receive(:new).with(message: message)
listener.message_updated(event)
end
end
end
end

View file

@ -0,0 +1,28 @@
require 'rails_helper'
RSpec.describe CsatSurveyResponse, type: :model do
describe 'validations' do
it { is_expected.to validate_presence_of(:rating) }
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
it { is_expected.to validate_presence_of(:contact_id) }
it 'validates that the rating can only be in range 1-5' do
csat_survey_response = build(:csat_survey_response, rating: 6)
expect(csat_survey_response.valid?).to eq false
end
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:conversation) }
it { is_expected.to belong_to(:contact) }
end
describe 'validates_factory' do
it 'creates valid csat_survey_response object' do
csat_survey_response = create(:csat_survey_response)
expect(csat_survey_response.valid?).to eq true
end
end
end