feat: Add backend changes for whatsapp csat template (#12984)

This PR add the backend changes for the feature [sending CSAT surveys
via WhatsApp message templates
](https://github.com/chatwoot/chatwoot/pull/12787)

---------

Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2025-12-11 16:36:37 +05:30
committed by GitHub
parent 1de8d3e56d
commit 2bd8e76886
10 changed files with 1331 additions and 18 deletions

View File

@@ -0,0 +1,160 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
before_action :fetch_inbox
before_action :validate_whatsapp_channel
def show
template = @inbox.csat_config&.dig('template')
return render json: { template_exists: false } unless template
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = @inbox.channel.provider_service.get_template_status(template_name)
render_template_status_response(status_result, template_name)
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
# Delete existing template even though we are using a new one.
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
render json: { error: 'Template creation failed' }, status: :internal_server_error
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end
def validate_whatsapp_channel
return if @inbox.whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
status: :bad_request
end
def extract_template_params
params.require(:template).permit(:message, :button_text, :language)
end
def render_missing_message_error
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def create_template_via_provider(template_params)
template_config = {
message: template_params[:message],
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
language: template_params[:language] || DEFAULT_LANGUAGE,
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
}
@inbox.channel.provider_service.create_csat_template(template_config)
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || DEFAULT_LANGUAGE
}
}, status: :created
end
def render_failed_template_creation(result)
whatsapp_error = parse_whatsapp_error(result[:response_body])
error_message = whatsapp_error[:user_message] || result[:error]
render json: {
error: error_message,
details: whatsapp_error[:technical_details]
}, status: :unprocessable_entity
end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
template_name = template['name']
return true if template_name.blank?
template_status = @inbox.channel.provider_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
if deletion_result[:success]
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def render_template_status_response(status_result, template_name)
if status_result[:success]
render json: {
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
render json: {
template_exists: false,
error: 'Template not found'
}
end
end
def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank?
begin
error_data = JSON.parse(response_body)
whatsapp_error = error_data['error'] || {}
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
technical_details = {
code: whatsapp_error['code'],
subcode: whatsapp_error['error_subcode'],
type: whatsapp_error['type'],
title: whatsapp_error['error_user_title']
}.compact
{ user_message: user_message, technical_details: technical_details }
rescue JSON::ParserError
{ user_message: nil, technical_details: response_body }
end
end
end

View File

@@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def format_csat_config(config)
{
display_type: config['display_type'] || 'emoji',
message: config['message'] || '',
survey_rules: {
operator: config.dig('survey_rules', 'operator') || 'contains',
values: config.dig('survey_rules', 'values') || []
}
formatted = {
'display_type' => config['display_type'] || 'emoji',
'message' => config['message'] || '',
:survey_rules => {
'operator' => config.dig('survey_rules', 'operator') || 'contains',
'values' => config.dig('survey_rules', 'values') || []
},
'button_text' => config['button_text'] || 'Please rate us',
'language' => config['language'] || 'en'
}
format_template_config(config, formatted)
formatted
end
def format_template_config(config, formatted)
formatted['template'] = config['template'] if config['template'].present?
end
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :created_at, :language] }] }]
end
def permitted_params(channel_attributes = [])
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
params.permit(
*inbox_attributes,
channel: [:type, *channel_attributes]
)
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
end
def channel_type_from_params
@@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def get_channel_attributes(channel_type)
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
channel_type.constantize::EDITABLE_ATTRS.presence
else
[]
end
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end
def whatsapp_channel?

View File

@@ -0,0 +1,49 @@
class Whatsapp::CsatTemplateNameService
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
def self.csat_template_name(inbox_id, version = nil)
base_name = csat_base_name_for_inbox(inbox_id)
version ? "#{base_name}_#{version}" : base_name
end
def self.extract_version(template_name, inbox_id)
return nil if template_name.blank?
pattern = versioned_pattern_for_inbox(inbox_id)
match = template_name.match(pattern)
match ? match[1].to_i : nil
end
def self.generate_next_template_name(base_name, inbox_id, current_template_name)
return base_name if current_template_name.blank?
current_version = extract_version(current_template_name, inbox_id)
next_version = current_version ? current_version + 1 : 1
csat_template_name(inbox_id, next_version)
end
def self.matches_csat_pattern?(template_name, inbox_id)
return false if template_name.blank?
base_pattern = base_pattern_for_inbox(inbox_id)
versioned_pattern = versioned_pattern_for_inbox(inbox_id)
template_name.match?(base_pattern) || template_name.match?(versioned_pattern)
end
def self.csat_base_name_for_inbox(inbox_id)
"#{CSAT_BASE_NAME}_#{inbox_id}"
end
def self.base_pattern_for_inbox(inbox_id)
/^#{CSAT_BASE_NAME}_#{inbox_id}$/
end
def self.versioned_pattern_for_inbox(inbox_id)
/^#{CSAT_BASE_NAME}_#{inbox_id}_(\d+)$/
end
private_class_method :csat_base_name_for_inbox, :base_pattern_for_inbox, :versioned_pattern_for_inbox
end

View File

@@ -0,0 +1,139 @@
class Whatsapp::CsatTemplateService
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
WHATSAPP_API_VERSION = 'v14.0'.freeze
TEMPLATE_CATEGORY = 'MARKETING'.freeze
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
def initialize(whatsapp_channel)
@whatsapp_channel = whatsapp_channel
end
def create_template(template_config)
base_name = template_config[:template_name]
template_name = generate_template_name(base_name)
template_config_with_name = template_config.merge(template_name: template_name)
request_body = build_template_request_body(template_config_with_name)
response = send_template_creation_request(request_body)
process_template_creation_response(response, template_config_with_name)
end
def delete_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
response = HTTParty.delete(
"#{business_account_path}/message_templates?name=#{template_name}",
headers: api_headers
)
{ success: response.success?, response_body: response.body }
end
def get_template_status(template_name)
response = HTTParty.get("#{business_account_path}/message_templates?name=#{template_name}", headers: api_headers)
if response.success? && response['data']&.any?
template_data = response['data'].first
{
success: true,
template: {
id: template_data['id'], name: template_data['name'],
status: template_data['status'], language: template_data['language']
}
}
else
{ success: false, error: 'Template not found' }
end
rescue StandardError => e
Rails.logger.error "Error fetching template status: #{e.message}"
{ success: false, error: e.message }
end
private
def generate_template_name(base_name)
current_template_name = current_template_name_from_config
Whatsapp::CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
end
def current_template_name_from_config
@whatsapp_channel.inbox.csat_config&.dig('template', 'name')
end
def build_template_request_body(template_config)
{
name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
category: TEMPLATE_CATEGORY,
components: build_template_components(template_config)
}
end
def build_template_components(template_config)
[
build_body_component(template_config[:message]),
build_buttons_component(template_config)
]
end
def build_body_component(message)
{
type: 'BODY',
text: message
}
end
def build_buttons_component(template_config)
{
type: 'BUTTONS',
buttons: [
{
type: 'URL',
text: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
url: "#{template_config[:base_url]}/survey/responses/{{1}}",
example: ['12345']
}
]
}
end
def send_template_creation_request(request_body)
HTTParty.post(
"#{business_account_path}/message_templates",
headers: api_headers,
body: request_body.to_json
)
end
def process_template_creation_response(response, template_config = {})
if response.success?
{
success: true,
template_id: response['id'],
template_name: response['name'] || template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
status: TEMPLATE_STATUS_PENDING
}
else
Rails.logger.error "WhatsApp template creation failed: #{response.code} - #{response.body}"
{
success: false,
error: 'Template creation failed',
response_body: response.body
}
end
end
def business_account_path
"#{api_base_path}/#{WHATSAPP_API_VERSION}/#{@whatsapp_channel.provider_config['business_account_id']}"
end
def api_headers
{
'Authorization' => "Bearer #{@whatsapp_channel.provider_config['api_key']}",
'Content-Type' => 'application/json'
}
end
def api_base_path
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
end
end

View File

@@ -62,12 +62,31 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
end
def create_csat_template(template_config)
csat_template_service.create_template(template_config)
end
def delete_csat_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
csat_template_service.delete_template(template_name)
end
def get_template_status(template_name)
csat_template_service.get_template_status(template_name)
end
def media_url(media_id, phone_number_id = nil)
url = "#{api_base_path}/v13.0/#{media_id}"
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
url
end
private
def csat_template_service
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
end
def api_base_path
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
end

View File

@@ -203,6 +203,8 @@ Rails.application.routes.draw do
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do

View File

@@ -0,0 +1,383 @@
require 'rails_helper'
RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
let(:web_widget_inbox) { create(:inbox, account: account) }
let(:mock_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_service)
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is not a WhatsApp channel' do
it 'returns bad request' do
get "/api/v1/accounts/#{account.id}/inboxes/#{web_widget_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:bad_request)
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels')
end
end
context 'when it is a WhatsApp channel' do
it 'returns template not found when no configuration exists' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['template_exists']).to be false
end
it 'returns template status when template exists on WhatsApp' do
template_config = {
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789',
'language' => 'en'
}
}
whatsapp_inbox.update!(csat_config: template_config)
allow(mock_service).to receive(:get_template_status)
.with('custom_survey_template')
.and_return({
success: true,
template: { id: '123456789', status: 'APPROVED' }
})
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_data = response.parsed_body
expect(response_data['template_exists']).to be true
expect(response_data['template_name']).to eq('custom_survey_template')
expect(response_data['status']).to eq('APPROVED')
expect(response_data['template_id']).to eq('123456789')
end
it 'returns template not found when template does not exist on WhatsApp' do
template_config = { 'template' => { 'name' => 'custom_survey_template' } }
whatsapp_inbox.update!(csat_config: template_config)
allow(mock_service).to receive(:get_template_status)
.with('custom_survey_template')
.and_return({ success: false, error: 'Template not found' })
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_data = response.parsed_body
expect(response_data['template_exists']).to be false
expect(response_data['error']).to eq('Template not found')
end
it 'handles service errors gracefully' do
template_config = { 'template' => { 'name' => 'custom_survey_template' } }
whatsapp_inbox.update!(csat_config: template_config)
allow(mock_service).to receive(:get_template_status)
.and_raise(StandardError, 'API connection failed')
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:internal_server_error)
expect(response.parsed_body['error']).to eq('API connection failed')
end
it 'returns unauthorized when agent is not assigned to inbox' do
other_agent = create(:user, account: account, role: :agent)
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: other_agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'allows access when agent is assigned to inbox' do
whatsapp_inbox.update!(csat_config: { 'template' => { 'name' => 'test' } })
allow(mock_service).to receive(:get_template_status)
.and_return({ success: true, template: { id: '123', status: 'APPROVED' } })
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
let(:valid_template_params) do
{
template: {
message: 'How would you rate your experience?',
button_text: 'Rate Us',
language: 'en'
}
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
params: valid_template_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is not a WhatsApp channel' do
it 'returns bad request' do
post "/api/v1/accounts/#{account.id}/inboxes/#{web_widget_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:bad_request)
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels')
end
end
context 'when it is a WhatsApp channel' do
it 'returns error when message is missing' do
invalid_params = {
template: {
button_text: 'Rate Us',
language: 'en'
}
}
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: invalid_params,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Message is required')
end
it 'returns error when template parameters are completely missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: {},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Template parameters are required')
end
it 'creates template successfully' do
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
allow(mock_service).to receive(:create_csat_template).and_return({
success: true,
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
template_id: '987654321'
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:created)
response_data = response.parsed_body
expect(response_data['template']['name']).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}")
expect(response_data['template']['template_id']).to eq('987654321')
expect(response_data['template']['status']).to eq('PENDING')
expect(response_data['template']['language']).to eq('en')
end
it 'uses default values for optional parameters' do
minimal_params = {
template: {
message: 'How would you rate your experience?'
}
}
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
expect(mock_service).to receive(:create_csat_template) do |config|
expect(config[:button_text]).to eq('Please rate us')
expect(config[:language]).to eq('en')
expect(config[:template_name]).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}")
{ success: true, template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", template_id: '123' }
end
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: minimal_params,
as: :json
expect(response).to have_http_status(:created)
end
it 'handles WhatsApp API errors with user-friendly messages' do
whatsapp_error_response = {
'error' => {
'code' => 100,
'error_subcode' => 2_388_092,
'message' => 'Invalid parameter',
'error_user_title' => 'Template Creation Failed',
'error_user_msg' => 'The template message contains invalid content. Please review your message and try again.'
}
}
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
allow(mock_service).to receive(:create_csat_template).and_return({
success: false,
error: 'Template creation failed',
response_body: whatsapp_error_response.to_json
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
response_data = response.parsed_body
expect(response_data['error']).to eq('The template message contains invalid content. Please review your message and try again.')
expect(response_data['details']).to include({
'code' => 100,
'subcode' => 2_388_092,
'title' => 'Template Creation Failed'
})
end
it 'handles generic API errors' do
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
allow(mock_service).to receive(:create_csat_template).and_return({
success: false,
error: 'Network timeout',
response_body: nil
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Network timeout')
end
it 'handles unexpected service errors' do
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
allow(mock_service).to receive(:create_csat_template)
.and_raise(StandardError, 'Unexpected error')
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:internal_server_error)
expect(response.parsed_body['error']).to eq('Template creation failed')
end
it 'deletes existing template before creating new one' do
whatsapp_inbox.update!(csat_config: {
'template' => {
'name' => 'existing_template',
'template_id' => '111111111'
}
})
allow(mock_service).to receive(:get_template_status)
.with('existing_template')
.and_return({ success: true, template: { id: '111111111' } })
expect(mock_service).to receive(:delete_csat_template)
.with('existing_template')
.and_return({ success: true })
expect(mock_service).to receive(:create_csat_template)
.and_return({
success: true,
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
template_id: '222222222'
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:created)
end
it 'continues with creation even if deletion fails' do
whatsapp_inbox.update!(csat_config: {
'template' => { 'name' => 'existing_template' }
})
allow(mock_service).to receive(:get_template_status).and_return({ success: true })
allow(mock_service).to receive(:delete_csat_template)
.and_return({ success: false, response_body: 'Delete failed' })
allow(mock_service).to receive(:create_csat_template).and_return({
success: true,
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
template_id: '333333333'
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:created)
end
it 'returns unauthorized when agent is not assigned to inbox' do
other_agent = create(:user, account: account, role: :agent)
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: other_agent.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'allows access when agent is assigned to inbox' do
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
allow(mock_service).to receive(:create_csat_template).and_return({
success: true,
template_name: 'customer_satisfaction_survey',
template_id: '444444444'
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: agent.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:created)
end
end
end
end

View File

@@ -804,6 +804,101 @@ RSpec.describe 'Inboxes API', type: :request do
expect(found_inbox['csat_config']['display_type']).to eq('emoji')
end
end
it 'successfully updates inbox with template configuration' do
csat_config_with_template = csat_config.merge({
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789',
'language' => 'en',
'created_at' => Time.current.iso8601
}
})
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config_with_template
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
inbox.reload
template_config = inbox.csat_config['template']
expect(template_config).to be_present
expect(template_config['name']).to eq('custom_survey_template')
expect(template_config['template_id']).to eq('123456789')
expect(template_config['language']).to eq('en')
end
it 'returns template configuration in inbox details' do
csat_config_with_template = csat_config.merge({
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789',
'language' => 'en',
'created_at' => Time.current.iso8601
}
})
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config_with_template
},
headers: admin.create_new_auth_token,
as: :json
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
template_config = json_response['csat_config']['template']
expect(template_config).to be_present
expect(template_config['name']).to eq('custom_survey_template')
expect(template_config['template_id']).to eq('123456789')
expect(template_config['language']).to eq('en')
expect(template_config['created_at']).to be_present
end
it 'removes template configuration when not provided in update' do
# First set up template configuration
csat_config_with_template = csat_config.merge({
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789'
}
})
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config_with_template
},
headers: admin.create_new_auth_token,
as: :json
# Then update without template
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config.merge({ 'message' => 'Updated message' })
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
inbox.reload
config = inbox.csat_config
expect(config['message']).to eq('Updated message')
expect(config['template']).to be_nil # Template should be removed when not provided
end
end
end

View File

@@ -0,0 +1,376 @@
require 'rails_helper'
RSpec.describe Whatsapp::CsatTemplateService do
let(:account) { create(:account) }
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
end
let(:inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
let(:service) { described_class.new(whatsapp_channel) }
let(:expected_template_name) { "customer_satisfaction_survey_#{whatsapp_channel.inbox.id}" }
let(:template_config) do
{
message: 'How would you rate your experience?',
button_text: 'Rate Us',
language: 'en',
base_url: 'https://example.com',
template_name: expected_template_name
}
end
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('WHATSAPP_CLOUD_BASE_URL', anything).and_return('https://graph.facebook.com')
end
describe '#generate_template_name' do
context 'when no existing template' do
it 'returns base name as-is' do
whatsapp_channel.inbox.update!(csat_config: {})
result = service.send(:generate_template_name, 'new_template_name')
expect(result).to eq('new_template_name')
end
it 'returns base name when template key is missing' do
whatsapp_channel.inbox.update!(csat_config: { 'other_config' => 'value' })
result = service.send(:generate_template_name, 'new_template_name')
expect(result).to eq('new_template_name')
end
end
context 'when existing template has no versioned name' do
it 'starts versioning from 1' do
whatsapp_channel.inbox.update!(csat_config: {
'template' => { 'name' => expected_template_name }
})
result = service.send(:generate_template_name, 'new_template_name')
expect(result).to eq("#{expected_template_name}_1")
end
it 'starts versioning from 1 for custom name' do
whatsapp_channel.inbox.update!(csat_config: {
'template' => { 'name' => 'custom_survey' }
})
result = service.send(:generate_template_name, 'new_template_name')
expect(result).to eq("#{expected_template_name}_1")
end
end
context 'when existing template has versioned name' do
it 'increments version number' do
whatsapp_channel.inbox.update!(csat_config: {
'template' => { 'name' => "#{expected_template_name}_1" }
})
result = service.send(:generate_template_name, 'new_template_name')
expect(result).to eq("#{expected_template_name}_2")
end
it 'increments higher version numbers' do
whatsapp_channel.inbox.update!(csat_config: {
'template' => { 'name' => "#{expected_template_name}_5" }
})
result = service.send(:generate_template_name, 'new_template_name')
expect(result).to eq("#{expected_template_name}_6")
end
it 'handles double digit version numbers' do
whatsapp_channel.inbox.update!(csat_config: {
'template' => { 'name' => "#{expected_template_name}_12" }
})
result = service.send(:generate_template_name, 'new_template_name')
expect(result).to eq("#{expected_template_name}_13")
end
end
context 'when existing template has non-matching versioned name' do
it 'starts versioning from 1' do
whatsapp_channel.inbox.update!(csat_config: {
'template' => { 'name' => 'different_survey_3' }
})
result = service.send(:generate_template_name, 'new_template_name')
expect(result).to eq("#{expected_template_name}_1")
end
end
context 'when template name is blank' do
it 'returns base name' do
whatsapp_channel.inbox.update!(csat_config: {
'template' => { 'name' => '' }
})
result = service.send(:generate_template_name, 'new_template_name')
expect(result).to eq('new_template_name')
end
it 'returns base name when template name is nil' do
whatsapp_channel.inbox.update!(csat_config: {
'template' => { 'name' => nil }
})
result = service.send(:generate_template_name, 'new_template_name')
expect(result).to eq('new_template_name')
end
end
end
describe '#build_template_request_body' do
it 'builds correct request structure' do
result = service.send(:build_template_request_body, template_config)
expect(result).to eq({
name: expected_template_name,
language: 'en',
category: 'MARKETING',
components: [
{
type: 'BODY',
text: 'How would you rate your experience?'
},
{
type: 'BUTTONS',
buttons: [
{
type: 'URL',
text: 'Rate Us',
url: 'https://example.com/survey/responses/{{1}}',
example: ['12345']
}
]
}
]
})
end
it 'uses default language when not provided' do
config_without_language = template_config.except(:language)
result = service.send(:build_template_request_body, config_without_language)
expect(result[:language]).to eq('en')
end
it 'uses default button text when not provided' do
config_without_button = template_config.except(:button_text)
result = service.send(:build_template_request_body, config_without_button)
expect(result[:components][1][:buttons][0][:text]).to eq('Please rate us')
end
end
describe '#create_template' do
let(:mock_response) do
# rubocop:disable RSpec/VerifiedDoubles
double('response', :success? => true, :body => '{}', '[]' => { 'id' => '123', 'name' => 'template_name' })
# rubocop:enable RSpec/VerifiedDoubles
end
before do
allow(HTTParty).to receive(:post).and_return(mock_response)
inbox.update!(csat_config: {})
end
it 'creates template with generated name' do
expected_body = {
name: expected_template_name,
language: 'en',
category: 'MARKETING',
components: [
{
type: 'BODY',
text: 'How would you rate your experience?'
},
{
type: 'BUTTONS',
buttons: [
{
type: 'URL',
text: 'Rate Us',
url: 'https://example.com/survey/responses/{{1}}',
example: ['12345']
}
]
}
]
}
expect(HTTParty).to receive(:post).with(
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates",
headers: {
'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}",
'Content-Type' => 'application/json'
},
body: expected_body.to_json
)
service.create_template(template_config)
end
it 'returns success response on successful creation' do
allow(mock_response).to receive(:[]).with('id').and_return('template_123')
allow(mock_response).to receive(:[]).with('name').and_return(expected_template_name)
result = service.create_template(template_config)
expect(result).to eq({
success: true,
template_id: 'template_123',
template_name: expected_template_name,
language: 'en',
status: 'PENDING'
})
end
context 'when API call fails' do
let(:error_response) do
# rubocop:disable RSpec/VerifiedDoubles
double('response', success?: false, code: 400, body: '{"error": "Invalid template"}')
# rubocop:enable RSpec/VerifiedDoubles
end
before do
allow(HTTParty).to receive(:post).and_return(error_response)
allow(Rails.logger).to receive(:error)
end
it 'returns error response' do
result = service.create_template(template_config)
expect(result).to eq({
success: false,
error: 'Template creation failed',
response_body: '{"error": "Invalid template"}'
})
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with('WhatsApp template creation failed: 400 - {"error": "Invalid template"}')
service.create_template(template_config)
end
end
end
describe '#delete_template' do
it 'makes DELETE request to correct endpoint' do
# rubocop:disable RSpec/VerifiedDoubles
mock_response = double('response', success?: true, body: '{}')
# rubocop:enable RSpec/VerifiedDoubles
expect(HTTParty).to receive(:delete).with(
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=test_template",
headers: {
'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}",
'Content-Type' => 'application/json'
}
).and_return(mock_response)
result = service.delete_template('test_template')
expect(result).to eq({ success: true, response_body: '{}' })
end
it 'uses default template name when none provided' do
# rubocop:disable RSpec/VerifiedDoubles
mock_response = double('response', success?: true, body: '{}')
# rubocop:enable RSpec/VerifiedDoubles
expect(HTTParty).to receive(:delete).with(
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=#{expected_template_name}",
anything
).and_return(mock_response)
service.delete_template
end
it 'returns failure response when API call fails' do
# rubocop:disable RSpec/VerifiedDoubles
mock_response = double('response', success?: false, body: '{"error": "Template not found"}')
# rubocop:enable RSpec/VerifiedDoubles
allow(HTTParty).to receive(:delete).and_return(mock_response)
result = service.delete_template('test_template')
expect(result).to eq({ success: false, response_body: '{"error": "Template not found"}' })
end
end
describe '#get_template_status' do
it 'makes GET request to correct endpoint' do
# rubocop:disable RSpec/VerifiedDoubles
mock_response = double('response', success?: true, body: '{}')
# rubocop:enable RSpec/VerifiedDoubles
allow(mock_response).to receive(:[]).with('data').and_return([{
'id' => '123',
'name' => 'test_template',
'status' => 'APPROVED',
'language' => 'en'
}])
expect(HTTParty).to receive(:get).with(
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=test_template",
headers: {
'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}",
'Content-Type' => 'application/json'
}
).and_return(mock_response)
service.get_template_status('test_template')
end
it 'returns success response when template exists' do
# rubocop:disable RSpec/VerifiedDoubles
mock_response = double('response', success?: true, body: '{}')
# rubocop:enable RSpec/VerifiedDoubles
allow(mock_response).to receive(:[]).with('data').and_return([{
'id' => '123',
'name' => 'test_template',
'status' => 'APPROVED',
'language' => 'en'
}])
allow(HTTParty).to receive(:get).and_return(mock_response)
result = service.get_template_status('test_template')
expect(result).to eq({
success: true,
template: {
id: '123',
name: 'test_template',
status: 'APPROVED',
language: 'en'
}
})
end
it 'returns failure response when template not found' do
# rubocop:disable RSpec/VerifiedDoubles
mock_response = double('response', success?: true, body: '{}')
# rubocop:enable RSpec/VerifiedDoubles
allow(mock_response).to receive(:[]).with('data').and_return([])
allow(HTTParty).to receive(:get).and_return(mock_response)
result = service.get_template_status('test_template')
expect(result).to eq({ success: false, error: 'Template not found' })
end
it 'returns failure response when API call fails' do
# rubocop:disable RSpec/VerifiedDoubles
mock_response = double('response', success?: false, body: '{}')
# rubocop:enable RSpec/VerifiedDoubles
allow(HTTParty).to receive(:get).and_return(mock_response)
result = service.get_template_status('test_template')
expect(result).to eq({ success: false, error: 'Template not found' })
end
context 'when API raises an exception' do
before do
allow(HTTParty).to receive(:get).and_raise(StandardError, 'Network error')
allow(Rails.logger).to receive(:error)
end
it 'handles exceptions gracefully' do
result = service.get_template_status('test_template')
expect(result).to eq({ success: false, error: 'Network error' })
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with('Error fetching template status: Network error')
service.get_template_status('test_template')
end
end
end
end

View File

@@ -312,4 +312,92 @@ describe Whatsapp::Providers::WhatsappCloudService do
end
end
end
describe 'CSAT template methods' do
let(:mock_csat_template_service) { instance_double(Whatsapp::CsatTemplateService) }
let(:expected_template_name) { "customer_satisfaction_survey_#{whatsapp_channel.inbox.id}" }
let(:template_config) do
{
name: expected_template_name,
language: 'en',
category: 'UTILITY'
}
end
before do
allow(Whatsapp::CsatTemplateService).to receive(:new)
.with(whatsapp_channel)
.and_return(mock_csat_template_service)
end
describe '#create_csat_template' do
it 'delegates to csat_template_service with correct config' do
allow(mock_csat_template_service).to receive(:create_template)
.with(template_config)
.and_return({ success: true, template_id: '123' })
result = service.create_csat_template(template_config)
expect(mock_csat_template_service).to have_received(:create_template).with(template_config)
expect(result).to eq({ success: true, template_id: '123' })
end
end
describe '#delete_csat_template' do
it 'delegates to csat_template_service with default template name' do
allow(mock_csat_template_service).to receive(:delete_template)
.with(expected_template_name)
.and_return({ success: true })
result = service.delete_csat_template
expect(mock_csat_template_service).to have_received(:delete_template).with(expected_template_name)
expect(result).to eq({ success: true })
end
it 'delegates to csat_template_service with custom template name' do
custom_template_name = 'custom_csat_template'
allow(mock_csat_template_service).to receive(:delete_template)
.with(custom_template_name)
.and_return({ success: true })
result = service.delete_csat_template(custom_template_name)
expect(mock_csat_template_service).to have_received(:delete_template).with(custom_template_name)
expect(result).to eq({ success: true })
end
end
describe '#get_template_status' do
it 'delegates to csat_template_service with template name' do
template_name = 'customer_survey_template'
expected_response = { success: true, template: { status: 'APPROVED' } }
allow(mock_csat_template_service).to receive(:get_template_status)
.with(template_name)
.and_return(expected_response)
result = service.get_template_status(template_name)
expect(mock_csat_template_service).to have_received(:get_template_status).with(template_name)
expect(result).to eq(expected_response)
end
end
describe 'csat_template_service memoization' do
it 'creates and memoizes the csat_template_service instance' do
allow(Whatsapp::CsatTemplateService).to receive(:new)
.with(whatsapp_channel)
.and_return(mock_csat_template_service)
allow(mock_csat_template_service).to receive(:get_template_status)
.and_return({ success: true })
# Call multiple methods that use the service
service.get_template_status('test1')
service.get_template_status('test2')
# Verify the service was only instantiated once
expect(Whatsapp::CsatTemplateService).to have_received(:new).once
end
end
end
end