From 2bd8e76886e23f90ee67e36be8ce8ba746c7c37c Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Thu, 11 Dec 2025 16:36:37 +0530
Subject: [PATCH 1/4] 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
From df4c8cf58b7b593e0eb0e0206dd8ef048ddaff77 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Thu, 11 Dec 2025 19:58:59 +0530
Subject: [PATCH 2/4] chore: Strip unsupported signature formatting by channel
(#13046)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Pull Request Template
## Description
1. This PR is an enhancement to
https://github.com/chatwoot/chatwoot/pull/13045
It strips unsupported formatting from **message signatures** based on
each channel’s formatting capabilities defined in the `FORMATTING`
config
2. Remove usage of plain editor in Compose new conversation modal
Only the following signature elements are considered:
bold (strong), italic (em), links (link), images (image).
Any formatting not supported by the target channel is automatically
removed before the signature is appended.
Channel-wise Signature Formatting Support
Channel | Keeps in Signature | Strips from Signature
-- | -- | --
Email | bold, italic, links, images | —
WebWidget | bold, italic, links, images | —
API | bold, italic | links, images
WhatsApp | bold, italic | links, images
Telegram | bold, italic, links | images
Facebook | bold, italic | links, images
Instagram | bold, italic | links, images
Line | bold, italic | links, images
SMS | — | everything
Twilio SMS | — | everything
Twitter/X | — | everything
📝 Note
Message signatures only support bold, italic, links, and
images.
Other formatting options available in the editor (lists, code blocks,
strike-through, etc.) do not apply to signatures and
are ignored.
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
### Loom video
https://www.loom.com/share/d325ab86ca514c6d8f90dfe72a8928dd
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Muhsin Keloth
---
.../components-next/Editor/Editor.vue | 2 +
.../components/ComposeNewConversationForm.vue | 25 +++-
.../components/MessageEditor.vue | 119 ++++--------------
.../components/widgets/WootWriter/Editor.vue | 31 ++++-
.../widgets/conversation/ReplyBox.vue | 22 +++-
app/javascript/dashboard/constants/editor.js | 12 +-
.../dashboard/helper/editorHelper.js | 111 ++++++++++++----
app/javascript/dashboard/helper/inbox.js | 5 +
.../helper/specs/editorHelper.spec.js | 110 ++++++++++++----
9 files changed, 270 insertions(+), 167 deletions(-)
diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue
index ff1c616de..90b7a0c31 100644
--- a/app/javascript/dashboard/components-next/Editor/Editor.vue
+++ b/app/javascript/dashboard/components-next/Editor/Editor.vue
@@ -24,6 +24,7 @@ const props = defineProps({
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
+ medium: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
@@ -106,6 +107,7 @@ watch(
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
+ :medium="medium"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
index 52ca95a07..77267707b 100644
--- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
@@ -6,6 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
+ getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -86,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
+const inboxMedium = computed(() => props.targetInbox?.medium || '');
+
+const effectiveChannelType = computed(() =>
+ getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
+);
+
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
@@ -202,7 +209,11 @@ const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
if (props.messageSignature) {
- state.message = removeSignature(state.message, props.messageSignature);
+ state.message = removeSignature(
+ state.message,
+ props.messageSignature,
+ effectiveChannelType.value
+ );
}
};
@@ -214,9 +225,9 @@ const removeTargetInbox = value => {
};
const clearSelectedContact = () => {
+ removeSignatureFromMessage();
emit('clearSelectedContact');
state.attachedFiles = [];
- removeSignatureFromMessage();
};
const onClickInsertEmoji = emoji => {
@@ -227,12 +238,16 @@ const handleAddSignature = signature => {
state.message = appendSignature(
state.message,
signature,
- inboxChannelType.value
+ effectiveChannelType.value
);
};
const handleRemoveSignature = signature => {
- state.message = removeSignature(state.message, signature);
+ state.message = removeSignature(
+ state.message,
+ signature,
+ effectiveChannelType.value
+ );
};
const handleAttachFile = files => {
@@ -356,10 +371,10 @@ const shouldShowMessageEditor = computed(() => {
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
- :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
+ :medium="targetInbox?.medium || ''"
/>
-import { ref, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
-import {
- appendSignature,
- removeSignature,
-} from 'dashboard/helper/editorHelper';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
-import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
-import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
-const props = defineProps({
- isEmailOrWebWidgetInbox: { type: Boolean, required: true },
+defineProps({
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
+ medium: { type: String, default: '' },
});
const { t } = useI18n();
@@ -25,98 +18,28 @@ const modelValue = defineModel({
type: String,
default: '',
});
-
-const state = ref({
- hasSlashCommand: false,
- showMentions: false,
- mentionSearchKey: '',
-});
-
-watch(
- modelValue,
- newValue => {
- if (props.isEmailOrWebWidgetInbox) return;
-
- const bodyWithoutSignature = newValue
- ? removeSignature(newValue, props.messageSignature)
- : '';
-
- // Check if message starts with slash
- const startsWithSlash = bodyWithoutSignature.startsWith('/');
-
- // Update slash command and mentions state
- state.value = {
- ...state.value,
- hasSlashCommand: startsWithSlash,
- showMentions: startsWithSlash,
- mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
- };
- },
- { immediate: true }
-);
-
-const hideMention = () => {
- state.value.showMentions = false;
-};
-
-const replaceText = async message => {
- // Only append signature on replace if sendWithSignature is true
- const finalMessage = props.sendWithSignature
- ? appendSignature(message, props.messageSignature, props.channelType)
- : message;
-
- await nextTick();
- modelValue.value = finalMessage;
-};
-
-
-
-
-
-
+
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 318727acc..7a2a489ac 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -54,6 +54,7 @@ import {
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
+ getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -81,6 +82,7 @@ const props = defineProps({
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
+ medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
});
@@ -105,10 +107,16 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
+const effectiveChannelType = computed(() =>
+ getEffectiveChannelType(props.channelType, props.medium)
+);
+
const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
- const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
+ const formatType = props.isPrivate
+ ? DEFAULT_FORMATTING
+ : effectiveChannelType.value;
const formatting = getFormattingForEditor(formatType);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
@@ -116,7 +124,7 @@ const editorSchema = computed(() => {
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
- : props.channelType || DEFAULT_FORMATTING;
+ : effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
return formatting.menu;
});
@@ -301,8 +309,13 @@ function isBodyEmpty(content) {
// if the signature is present, we need to remove it before checking
// note that we don't update the editorView, so this is safe
+ // Use effective channel type to match how signature was appended
const bodyWithoutSignature = props.signature
- ? removeSignatureHelper(content, props.signature)
+ ? removeSignatureHelper(
+ content,
+ props.signature,
+ effectiveChannelType.value
+ )
: content;
// trimming should remove all the whitespaces, so we can check the length
@@ -370,7 +383,11 @@ function addSignature() {
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
const contentWasEmpty = isBodyEmpty(content);
- content = appendSignature(content, props.signature, props.channelType);
+ content = appendSignature(
+ content,
+ props.signature,
+ effectiveChannelType.value
+ );
// need to reload first, ensuring that the editorView is updated
reloadState(content);
@@ -382,7 +399,11 @@ function addSignature() {
function removeSignature() {
if (!props.signature) return;
let content = props.modelValue;
- content = removeSignatureHelper(content, props.signature);
+ content = removeSignatureHelper(
+ content,
+ props.signature,
+ effectiveChannelType.value
+ );
// reload the state, ensuring that the editorView is updated
reloadState(content);
}
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index 0e698e0c6..6f955ffd5 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -43,6 +43,7 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
+ getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -564,9 +565,13 @@ export default {
return message;
}
+ const effectiveChannelType = getEffectiveChannelType(
+ this.channelType,
+ this.inbox?.medium || ''
+ );
return this.sendWithSignature
- ? appendSignature(message, this.messageSignature, this.channelType)
- : removeSignature(message, this.messageSignature);
+ ? appendSignature(message, this.messageSignature, effectiveChannelType)
+ : removeSignature(message, this.messageSignature, effectiveChannelType);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -757,10 +762,14 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
+ const effectiveChannelType = getEffectiveChannelType(
+ this.channelType,
+ this.inbox?.medium || ''
+ );
message = appendSignature(
message,
this.messageSignature,
- this.channelType
+ effectiveChannelType
);
}
@@ -800,10 +809,14 @@ export default {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
+ const effectiveChannelType = getEffectiveChannelType(
+ this.channelType,
+ this.inbox?.medium || ''
+ );
this.message = appendSignature(
this.message,
this.messageSignature,
- this.channelType
+ effectiveChannelType
);
}
this.attachedFiles = [];
@@ -1121,6 +1134,7 @@ export default {
:signature="messageSignature"
allow-signature
:channel-type="channelType"
+ :medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js
index eaec36371..087caa99f 100644
--- a/app/javascript/dashboard/constants/editor.js
+++ b/app/javascript/dashboard/constants/editor.js
@@ -210,7 +210,12 @@ export const MARKDOWN_PATTERNS = [
type: 'em', // PM: em, eg: *italic* or _italic_
patterns: [
{ pattern: /(? arr.includes(key);
+
+ // Define stripping rules: [condition, pattern, replacement]
+ const rules = [
+ [!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
+ [!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
+ [!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
+ [!has(marks, 'code'), /`([^`]+)`/g, '$1'],
+ [!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
+ [!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
+ [!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
+ // Match _text_ only at word boundaries (whitespace/string start/end)
+ // Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
+ [
+ !has(marks, 'em'),
+ /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
+ '$1',
+ ],
+ [!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
+ [!has(nodes, 'blockquote'), /^>\s?/gm, ''],
+ [!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
+ [!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
+ ];
+
+ const result = rules.reduce(
+ (text, [shouldStrip, pattern, replacement]) =>
+ shouldStrip ? text.replace(pattern, replacement) : text,
+ markdown
+ );
+
+ return result
+ .split('\n')
+ .map(line => line.trim())
+ .filter(Boolean)
+ .join('\n')
+ .replace(/\n{2,}/g, '\n')
+ .trim();
+}
+
/**
* The delimiter used to separate the signature from the rest of the body.
* @type {string}
@@ -97,29 +144,36 @@ export function findSignatureInBody(body, signature) {
}
/**
- * Checks if the channel supports image signatures.
+ * Gets the effective channel type for formatting purposes.
+ * For Twilio channels, returns WhatsApp or Twilio based on medium.
*
- * @param {string} channelType - The channel type.
- * @returns {boolean} - True if the channel supports image signatures.
+ * @param {string} channelType - The channel type
+ * @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
+ * @returns {string} - The effective channel type for formatting
*/
-export function supportsImageSignature(channelType) {
- return CHANNEL_WITH_RICH_SIGNATURE.includes(channelType);
+export function getEffectiveChannelType(channelType, medium) {
+ if (channelType === INBOX_TYPES.TWILIO) {
+ return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
+ ? INBOX_TYPES.WHATSAPP
+ : INBOX_TYPES.TWILIO;
+ }
+ return channelType;
}
/**
* Appends the signature to the body, separated by the signature delimiter.
- * Automatically strips images for channels that don't support image signatures.
+ * Automatically strips unsupported formatting based on channel capabilities.
*
* @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append.
- * @param {string} channelType - Optional. The channel type to determine if images should be stripped.
+ * @param {string} channelType - Optional. The effective channel type to determine supported formatting.
+ * For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature appended.
*/
export function appendSignature(body, signature, channelType) {
- // For channels that don't support images, strip markdown formatting
- const shouldStripImages = channelType && !supportsImageSignature(channelType);
- const preparedSignature = shouldStripImages
- ? extractTextFromMarkdown(signature)
+ // Strip only unsupported formatting based on channel capabilities
+ const preparedSignature = channelType
+ ? stripUnsupportedSignatureMarkdown(signature, channelType)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body
@@ -132,21 +186,28 @@ export function appendSignature(body, signature, channelType) {
/**
* Removes the signature from the body, along with the signature delimiter.
- * Tries to find both the original signature and the stripped version (for non-image channels).
+ * Tries to find both the original signature and the stripped version.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
+ * @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
+ * For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature removed.
*/
-export function removeSignature(body, signature) {
- // Build list of signatures to try: original first, then stripped version
- // Always try both to handle cases where channelType is unknown or inbox is being removed
+export function removeSignature(body, signature, channelType) {
+ // Build list of signatures to try: original, channel-stripped, and fully stripped
const cleanedSignature = cleanSignature(signature);
- const strippedSignature = cleanSignature(extractTextFromMarkdown(signature));
- const signaturesToTry =
- cleanedSignature === strippedSignature
- ? [cleanedSignature]
- : [cleanedSignature, strippedSignature];
+ const channelStripped = channelType
+ ? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
+ : null;
+ const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
+
+ // Try signatures in order: original → channel-specific → fully stripped
+ const signaturesToTry = [
+ cleanedSignature,
+ channelStripped,
+ fullyStripped,
+ ].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature
const signatureIndex = signaturesToTry.reduce(
diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js
index 501375ab7..05823e8a7 100644
--- a/app/javascript/dashboard/helper/inbox.js
+++ b/app/javascript/dashboard/helper/inbox.js
@@ -13,6 +13,11 @@ export const INBOX_TYPES = {
VOICE: 'Channel::Voice',
};
+export const TWILIO_CHANNEL_MEDIUM = {
+ WHATSAPP: 'whatsapp',
+ SMS: 'sms',
+};
+
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
index 07dfbf1e5..1ae765ca7 100644
--- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
@@ -5,7 +5,7 @@ import {
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
- supportsImageSignature,
+ stripUnsupportedSignatureMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
@@ -145,10 +145,63 @@ describe('appendSignature', () => {
});
});
+describe('stripUnsupportedSignatureMarkdown', () => {
+ const richSignature =
+ '**Bold** _italic_ [link](http://example.com) ';
+
+ it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
+ const result = stripUnsupportedSignatureMarkdown(
+ richSignature,
+ 'Channel::Email'
+ );
+ expect(result).toContain('**Bold**');
+ expect(result).toContain('_italic_');
+ expect(result).toContain('[link](http://example.com)');
+ expect(result).toContain('');
+ });
+ it('strips images but keeps bold/italic for Api channel', () => {
+ const result = stripUnsupportedSignatureMarkdown(
+ richSignature,
+ 'Channel::Api'
+ );
+ expect(result).toContain('**Bold**');
+ expect(result).toContain('_italic_');
+ expect(result).toContain('link'); // link text kept
+ expect(result).not.toContain('[link]('); // link syntax removed
+ expect(result).not.toContain('; // image removed
+ });
+ it('strips images but keeps bold/italic/link for Telegram channel', () => {
+ const result = stripUnsupportedSignatureMarkdown(
+ richSignature,
+ 'Channel::Telegram'
+ );
+ expect(result).toContain('**Bold**');
+ expect(result).toContain('_italic_');
+ expect(result).toContain('[link](http://example.com)');
+ expect(result).not.toContain(';
+ });
+ it('strips all formatting for SMS channel', () => {
+ const result = stripUnsupportedSignatureMarkdown(
+ richSignature,
+ 'Channel::Sms'
+ );
+ expect(result).toContain('Bold');
+ expect(result).toContain('italic');
+ expect(result).toContain('link');
+ expect(result).not.toContain('**');
+ expect(result).not.toContain('_');
+ expect(result).not.toContain('[');
+ expect(result).not.toContain(';
+ });
+ it('returns empty string for empty input', () => {
+ expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
+ expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
+ });
+});
+
describe('appendSignature with channelType', () => {
const signatureWithImage =
'Thanks\n';
- const strippedSignature = 'Thanks';
it('keeps images for Email channel', () => {
const result = appendSignature(
@@ -166,24 +219,31 @@ describe('appendSignature with channelType', () => {
);
expect(result).toContain(';
});
- it('strips images for Api channel', () => {
+ it('strips images but keeps text for Api channel', () => {
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
expect(result).not.toContain(';
- expect(result).toContain(strippedSignature);
+ expect(result).toContain('Thanks');
});
- it('strips images for WhatsApp channel', () => {
+ it('strips images but keeps text for WhatsApp channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Whatsapp'
);
expect(result).not.toContain(';
- expect(result).toContain(strippedSignature);
+ expect(result).toContain('Thanks');
});
it('keeps images when channelType is not provided', () => {
const result = appendSignature('Hello', signatureWithImage);
expect(result).toContain(';
});
+ it('keeps bold/italic for channels that support them', () => {
+ const boldSignature = '**Bold** *italic* Thanks';
+ const result = appendSignature('Hello', boldSignature, 'Channel::Api');
+ // Api supports strong and em
+ expect(result).toContain('**Bold**');
+ expect(result).toContain('*italic*');
+ });
});
describe('cleanSignature', () => {
@@ -331,24 +391,6 @@ describe('extractTextFromMarkdown', () => {
});
});
-describe('supportsImageSignature', () => {
- it('returns true for Email channel', () => {
- expect(supportsImageSignature('Channel::Email')).toBe(true);
- });
- it('returns true for WebWidget channel', () => {
- expect(supportsImageSignature('Channel::WebWidget')).toBe(true);
- });
- it('returns false for Api channel', () => {
- expect(supportsImageSignature('Channel::Api')).toBe(false);
- });
- it('returns false for WhatsApp channel', () => {
- expect(supportsImageSignature('Channel::Whatsapp')).toBe(false);
- });
- it('returns false for Telegram channel', () => {
- expect(supportsImageSignature('Channel::Telegram')).toBe(false);
- });
-});
-
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
@@ -884,6 +926,26 @@ describe('stripUnsupportedFormatting', () => {
);
});
+ it('preserves underscores in URLs and mid-word positions', () => {
+ // Underscores in URLs should not be stripped as italic formatting
+ expect(
+ stripUnsupportedFormatting(
+ 'https://www.chatwoot.com/new_first_second-third/ssd',
+ emptySchema
+ )
+ ).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
+
+ // Underscores in variable names should not be stripped
+ expect(
+ stripUnsupportedFormatting('some_variable_name', emptySchema)
+ ).toBe('some_variable_name');
+
+ // But actual italic formatting with spaces should still be stripped
+ expect(
+ stripUnsupportedFormatting('hello _world_ there', emptySchema)
+ ).toBe('hello world there');
+ });
+
it('strips inline code formatting', () => {
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
'inline code'
From 696564863cecc1e74fb5eec9966a104ce760522f Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 12 Dec 2025 10:14:22 +0530
Subject: [PATCH 3/4] feat: Add plain-text editor for non-rich content channels
(#13058)
# Pull Request Template
## Description
This PR restores the plain text editor for all channels except Website,
Email, and API.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../components/ComposeNewConversationForm.vue | 3 +
.../components/MessageEditor.vue | 6 +-
.../widgets/conversation/ReplyBox.vue | 213 ++++++++++++++----
.../shared/components/ResizableTextArea.vue | 7 +-
4 files changed, 186 insertions(+), 43 deletions(-)
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
index 77267707b..74836219b 100644
--- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
@@ -200,6 +200,7 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset();
+ state.message = '';
emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false;
state.attachedFiles = [];
@@ -220,6 +221,7 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
+ state.message = '';
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -227,6 +229,7 @@ const removeTargetInbox = value => {
const clearSelectedContact = () => {
removeSignatureFromMessage();
emit('clearSelectedContact');
+ state.message = '';
state.attachedFiles = [];
};
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue b/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue
index 2cda75e53..ef523a288 100644
--- a/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue
+++ b/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue
@@ -1,9 +1,10 @@