From 2bd8e76886e23f90ee67e36be8ce8ba746c7c37c Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 11 Dec 2025 16:36:37 +0530 Subject: [PATCH] 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> --- .../inbox_csat_templates_controller.rb | 160 ++++++++ .../api/v1/accounts/inboxes_controller.rb | 38 +- .../whatsapp/csat_template_name_service.rb | 49 +++ .../whatsapp/csat_template_service.rb | 139 +++++++ .../providers/whatsapp_cloud_service.rb | 19 + config/routes.rb | 2 + .../inbox_csat_templates_controller_spec.rb | 383 ++++++++++++++++++ .../v1/accounts/inboxes_controller_spec.rb | 95 +++++ .../whatsapp/csat_template_service_spec.rb | 376 +++++++++++++++++ .../providers/whatsapp_cloud_service_spec.rb | 88 ++++ 10 files changed, 1331 insertions(+), 18 deletions(-) create mode 100644 app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb create mode 100644 app/services/whatsapp/csat_template_name_service.rb create mode 100644 app/services/whatsapp/csat_template_service.rb create mode 100644 spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb create mode 100644 spec/services/whatsapp/csat_template_service_spec.rb diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb new file mode 100644 index 000000000..d17fe35fb --- /dev/null +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index ae1d4369a..1c8845c04 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -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? diff --git a/app/services/whatsapp/csat_template_name_service.rb b/app/services/whatsapp/csat_template_name_service.rb new file mode 100644 index 000000000..223efda04 --- /dev/null +++ b/app/services/whatsapp/csat_template_name_service.rb @@ -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 diff --git a/app/services/whatsapp/csat_template_service.rb b/app/services/whatsapp/csat_template_service.rb new file mode 100644 index 000000000..43009b484 --- /dev/null +++ b/app/services/whatsapp/csat_template_service.rb @@ -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 diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 1968693ff..e13ee8979 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 9b22cb333..0ac001612 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb new file mode 100644 index 000000000..7ca9083c0 --- /dev/null +++ b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index a44f60391..fafd6788e 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -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 diff --git a/spec/services/whatsapp/csat_template_service_spec.rb b/spec/services/whatsapp/csat_template_service_spec.rb new file mode 100644 index 000000000..f21d87227 --- /dev/null +++ b/spec/services/whatsapp/csat_template_service_spec.rb @@ -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 diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb index 69ba69379..156e46349 100644 --- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -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