feat: Add support for sending CSAT surveys via templates (Whatsapp Twilio) (#13143)

Fixes
https://linear.app/chatwoot/issue/CW-6189/support-for-sending-csat-surveys-via-approved-whatsapp

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-01-13 16:32:02 +04:00
committed by GitHub
parent 7b51939f07
commit c483034a07
14 changed files with 808 additions and 146 deletions

View File

@@ -9,11 +9,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
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) }
let(:mock_service) { instance_double(Whatsapp::CsatTemplateService) }
before do
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_service)
allow(Whatsapp::CsatTemplateService).to receive(:new).and_return(mock_service)
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
@@ -32,7 +32,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
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')
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp and Twilio WhatsApp channels')
end
end
@@ -161,7 +161,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
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')
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp and Twilio WhatsApp channels')
end
end
@@ -195,11 +195,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
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'
})
allow(mock_service).to receive(:create_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,
@@ -222,7 +222,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
}
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
expect(mock_service).to receive(:create_csat_template) do |config|
expect(mock_service).to receive(:create_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}")
@@ -249,11 +249,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
}
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
})
allow(mock_service).to receive(:create_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,
@@ -272,11 +272,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
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
})
allow(mock_service).to receive(:create_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,
@@ -289,7 +289,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
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)
allow(mock_service).to receive(:create_template)
.and_raise(StandardError, 'Unexpected error')
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
@@ -312,10 +312,10 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
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)
expect(mock_service).to receive(:delete_template)
.with('existing_template')
.and_return({ success: true })
expect(mock_service).to receive(:create_csat_template)
expect(mock_service).to receive(:create_template)
.and_return({
success: true,
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
@@ -336,13 +336,13 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
})
allow(mock_service).to receive(:get_template_status).and_return({ success: true })
allow(mock_service).to receive(:delete_csat_template)
allow(mock_service).to receive(:delete_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'
})
allow(mock_service).to receive(:create_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,
@@ -365,11 +365,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
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'
})
allow(mock_service).to receive(:create_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,

View File

@@ -8,11 +8,8 @@ describe Twilio::SendOnTwilioService do
let(:message_record_double) { double }
let!(:account) { create(:account) }
let!(:widget_inbox) { create(:inbox, account: account) }
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
let!(:twilio_whatsapp_inbox) { create(:inbox, channel: twilio_whatsapp, account: account) }
let!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: twilio_inbox, contact_inbox: contact_inbox) }
@@ -23,6 +20,10 @@ describe Twilio::SendOnTwilioService do
end
describe '#perform' do
let!(:widget_inbox) { create(:inbox, account: account) }
let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let!(:twilio_whatsapp_inbox) { create(:inbox, channel: twilio_whatsapp, account: account) }
context 'without reply' do
it 'if message is private' do
message = create(:message, message_type: 'outgoing', private: true, inbox: twilio_inbox, account: account)
@@ -107,4 +108,146 @@ describe Twilio::SendOnTwilioService do
expect(outgoing_message.reload.status).to eq('failed')
end
end
describe '#send_csat_template_message' do
let(:test_message) { create(:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation) }
let(:service) { described_class.new(message: test_message) }
let(:mock_twilio_message) { instance_double(Twilio::REST::Api::V2010::AccountContext::MessageInstance, sid: 'SM123456789') }
# Test parameters defined using let statements
let(:test_params) do
{
phone_number: '+1234567890',
content_sid: 'HX123456789',
content_variables: { '1' => 'conversation-uuid-123' }
}
end
before do
allow(twilio_sms).to receive(:send_message_from).and_return({ from: '+0987654321' })
allow(twilio_sms).to receive(:respond_to?).and_return(true)
allow(twilio_sms).to receive(:twilio_delivery_status_index_url).and_return('http://localhost:3000/twilio/delivery_status')
end
context 'when template message is sent successfully' do
before do
allow(messages_double).to receive(:create).and_return(mock_twilio_message)
end
it 'sends template message with correct parameters' do
expected_params = {
to: test_params[:phone_number],
content_sid: test_params[:content_sid],
content_variables: test_params[:content_variables].to_json,
status_callback: 'http://localhost:3000/twilio/delivery_status',
from: '+0987654321'
}
result = service.send_csat_template_message(**test_params)
expect(messages_double).to have_received(:create).with(expected_params)
expect(result).to eq({ success: true, message_id: 'SM123456789' })
end
it 'sends template message without content variables when empty' do
expected_params = {
to: test_params[:phone_number],
content_sid: test_params[:content_sid],
status_callback: 'http://localhost:3000/twilio/delivery_status',
from: '+0987654321'
}
result = service.send_csat_template_message(
phone_number: test_params[:phone_number],
content_sid: test_params[:content_sid]
)
expect(messages_double).to have_received(:create).with(expected_params)
expect(result).to eq({ success: true, message_id: 'SM123456789' })
end
it 'includes custom status callback when channel supports it' do
allow(twilio_sms).to receive(:respond_to?).and_return(true)
allow(twilio_sms).to receive(:twilio_delivery_status_index_url).and_return('https://example.com/webhook')
expected_params = {
to: test_params[:phone_number],
content_sid: test_params[:content_sid],
content_variables: test_params[:content_variables].to_json,
status_callback: 'https://example.com/webhook',
from: '+0987654321'
}
service.send_csat_template_message(**test_params)
expect(messages_double).to have_received(:create).with(expected_params)
end
end
context 'when Twilio API returns an error' do
before do
allow(Rails.logger).to receive(:error)
end
it 'handles Twilio::REST::TwilioError' do
allow(messages_double).to receive(:create).and_raise(Twilio::REST::TwilioError, 'Invalid phone number')
result = service.send_csat_template_message(**test_params)
expect(result).to eq({ success: false, error: 'Invalid phone number' })
expect(Rails.logger).to have_received(:error).with('Failed to send Twilio template message: Invalid phone number')
end
it 'handles Twilio API errors' do
allow(messages_double).to receive(:create).and_raise(Twilio::REST::TwilioError, 'Content template not found')
result = service.send_csat_template_message(**test_params)
expect(result).to eq({ success: false, error: 'Content template not found' })
expect(Rails.logger).to have_received(:error).with('Failed to send Twilio template message: Content template not found')
end
end
context 'with parameter handling' do
before do
allow(messages_double).to receive(:create).and_return(mock_twilio_message)
end
it 'handles empty content_variables hash' do
expected_params = {
to: test_params[:phone_number],
content_sid: test_params[:content_sid],
status_callback: 'http://localhost:3000/twilio/delivery_status',
from: '+0987654321'
}
service.send_csat_template_message(
phone_number: test_params[:phone_number],
content_sid: test_params[:content_sid],
content_variables: {}
)
expect(messages_double).to have_received(:create).with(expected_params)
end
it 'converts content_variables to JSON when present' do
variables = { '1' => 'test-uuid', '2' => 'another-value' }
expected_params = {
to: test_params[:phone_number],
content_sid: test_params[:content_sid],
content_variables: variables.to_json,
status_callback: 'http://localhost:3000/twilio/delivery_status',
from: '+0987654321'
}
service.send_csat_template_message(
phone_number: test_params[:phone_number],
content_sid: test_params[:content_sid],
content_variables: variables
)
expect(messages_double).to have_received(:create).with(expected_params)
end
end
end
end