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