Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fee31912e | ||
|
|
10fe518b39 | ||
|
|
70c8496af8 | ||
|
|
ffb432dfa0 | ||
|
|
d3feffa199 | ||
|
|
742c1aad47 | ||
|
|
2c9208c6a2 | ||
|
|
44ff579c5a |
@@ -23,7 +23,7 @@ Metrics/MethodLength:
|
||||
- 'enterprise/lib/captain/agent.rb'
|
||||
|
||||
RSpec/ExampleLength:
|
||||
Max: 50
|
||||
Max: 25
|
||||
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
@@ -336,4 +336,4 @@ FactoryBot/RedundantFactoryOption:
|
||||
Enabled: false
|
||||
|
||||
FactoryBot/FactoryAssociationWithStrategy:
|
||||
Enabled: false
|
||||
Enabled: false
|
||||
@@ -21,7 +21,6 @@ gem 'telephone_number'
|
||||
gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'octokit'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
|
||||
@@ -591,9 +591,6 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -820,9 +817,6 @@ GEM
|
||||
sprockets (> 3.0)
|
||||
sprockets-rails
|
||||
tilt
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
scout_apm (5.3.3)
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
@@ -1063,7 +1057,6 @@ DEPENDENCIES
|
||||
net-smtp (~> 0.3.4)
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
octokit
|
||||
omniauth (>= 2.1.2)
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
|
||||
@@ -45,6 +45,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def update
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
@@ -87,8 +88,95 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def csat_template_status
|
||||
return render json: { error: 'CSAT template status only available for WhatsApp channels' }, status: :bad_request unless @inbox.whatsapp?
|
||||
|
||||
template_config = @inbox.csat_config&.dig('template')
|
||||
return render json: { template_exists: false } unless template_config
|
||||
|
||||
template_name = template_config['name'] || 'customer_satisfaction_survey'
|
||||
status_result = @inbox.channel.provider_service.get_template_status(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: status_result[:error] }
|
||||
end
|
||||
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_csat_template
|
||||
unless @inbox.whatsapp?
|
||||
return render json: { error: 'CSAT template creation only available for WhatsApp channels' },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
template_params = params.require(:template).permit(:message, :button_text)
|
||||
return render json: { error: 'Message is required' }, status: :unprocessable_content if template_params[:message].blank?
|
||||
|
||||
template_config = {
|
||||
message: template_params[:message],
|
||||
button_text: template_params[:button_text] || 'Please rate us',
|
||||
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
|
||||
language: 'en'
|
||||
}
|
||||
|
||||
result = @inbox.channel.provider_service.create_csat_template(template_config)
|
||||
|
||||
if result[:success]
|
||||
render json: {
|
||||
template: {
|
||||
name: 'customer_satisfaction_survey',
|
||||
template_id: result[:template_id],
|
||||
status: 'PENDING',
|
||||
language: 'en'
|
||||
}
|
||||
}, status: :created
|
||||
else
|
||||
# Parse WhatsApp error details for user-friendly message
|
||||
whatsapp_error = parse_whatsapp_error(result[:response_body])
|
||||
render json: {
|
||||
error: whatsapp_error[:user_message] || result[:error],
|
||||
details: whatsapp_error[:technical_details]
|
||||
}, status: :unprocessable_content
|
||||
end
|
||||
rescue ActionController::ParameterMissing
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_content
|
||||
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 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
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:id])
|
||||
authorize @inbox, :show?
|
||||
@@ -152,21 +240,30 @@ 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'] || '',
|
||||
'button_text' => config['button_text'] || 'Please rate us',
|
||||
'language' => config['language'] || 'en',
|
||||
'survey_rules' => {
|
||||
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
||||
'values' => config.dig('survey_rules', 'values') || []
|
||||
}
|
||||
}
|
||||
|
||||
# Preserve existing template config if present
|
||||
formatted['template'] = config['template'] if config['template'].present?
|
||||
|
||||
formatted
|
||||
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 = [])
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook
|
||||
before_action :ensure_hook_exists, except: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def repositories
|
||||
repositories = github_service.repositories
|
||||
filtered_repos = repositories.map do |repo|
|
||||
{
|
||||
full_name: repo[:full_name] || repo.full_name,
|
||||
private: repo[:private] || repo.private
|
||||
}
|
||||
end
|
||||
render json: filtered_repos
|
||||
end
|
||||
|
||||
def search_repositories
|
||||
repositories = github_service.search_repositories(params[:q])
|
||||
filtered_repos = repositories.map do |repo|
|
||||
{
|
||||
full_name: repo[:full_name] || repo.full_name,
|
||||
private: repo[:private] || repo.private
|
||||
}
|
||||
end
|
||||
render json: filtered_repos
|
||||
end
|
||||
|
||||
def assignees
|
||||
assignees = github_service.assignees(repo_full_name)
|
||||
filtered_assignees = assignees.map do |assignee|
|
||||
{
|
||||
login: assignee.login,
|
||||
avatar_url: assignee.avatar_url
|
||||
}
|
||||
end
|
||||
render json: filtered_assignees
|
||||
end
|
||||
|
||||
def labels
|
||||
labels = github_service.labels(repo_full_name)
|
||||
filtered_labels = labels.map do |label|
|
||||
{
|
||||
name: label.name,
|
||||
color: label.color
|
||||
}
|
||||
end
|
||||
render json: filtered_labels
|
||||
end
|
||||
|
||||
def search_issues
|
||||
issues = github_service.search_issues(repo_full_name, params[:q])
|
||||
filtered_issues = issues.map do |issue|
|
||||
{
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
html_url: issue.html_url
|
||||
}
|
||||
end
|
||||
render json: filtered_issues
|
||||
end
|
||||
|
||||
def create_issue
|
||||
issue_data = github_service.create_issue(
|
||||
repo_full_name,
|
||||
params[:title],
|
||||
params[:body],
|
||||
issue_options
|
||||
)
|
||||
|
||||
github_issue = create_linked_issue(issue_data)
|
||||
render json: issue_response(github_issue), status: :created
|
||||
end
|
||||
|
||||
def link_issue
|
||||
issue_data = github_service.issue(repo_full_name, params[:issue_number])
|
||||
github_issue = create_linked_issue(issue_data)
|
||||
render json: issue_response(github_issue), status: :created
|
||||
end
|
||||
|
||||
def linked_issues
|
||||
issues = GithubIssue.where(conversation_id: params[:conversation_id])
|
||||
|
||||
render json: issues.map do |issue|
|
||||
{
|
||||
id: issue.id,
|
||||
issue_number: issue.issue_number,
|
||||
title: issue.issue_title,
|
||||
html_url: issue.html_url,
|
||||
repo_full_name: issue.repo_full_name,
|
||||
linked_by: {
|
||||
id: issue.linked_by.id,
|
||||
name: issue.linked_by.name
|
||||
},
|
||||
created_at: issue.created_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def unlink_issue
|
||||
github_issue = GithubIssue.find(params[:id])
|
||||
github_issue.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'github')
|
||||
end
|
||||
|
||||
def ensure_hook_exists
|
||||
render json: { error: 'GitHub integration not configured' }, status: :unauthorized unless @hook
|
||||
end
|
||||
|
||||
def github_service
|
||||
@github_service ||= Github::GithubService.new(hook: @hook)
|
||||
end
|
||||
|
||||
def repo_full_name
|
||||
params[:repo_full_name] || "#{params[:owner]}/#{params[:repo]}"
|
||||
end
|
||||
|
||||
def issue_options
|
||||
options = {}
|
||||
options[:assignees] = params[:assignees] if params[:assignees].present?
|
||||
options[:labels] = params[:labels] if params[:labels].present?
|
||||
options
|
||||
end
|
||||
|
||||
def create_linked_issue(issue_data)
|
||||
GithubIssue.create!(
|
||||
conversation_id: params[:conversation_id],
|
||||
account: Current.account,
|
||||
repo_full_name: repo_full_name,
|
||||
issue_number: issue_data.number,
|
||||
issue_title: issue_data.title,
|
||||
html_url: issue_data.html_url,
|
||||
linked_by: Current.user
|
||||
)
|
||||
end
|
||||
|
||||
def issue_response(github_issue)
|
||||
{
|
||||
id: github_issue.id,
|
||||
issue_number: github_issue.issue_number,
|
||||
title: github_issue.issue_title,
|
||||
html_url: github_issue.html_url,
|
||||
repo_full_name: github_issue.repo_full_name
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,160 +0,0 @@
|
||||
class Github::CallbacksController < ApplicationController
|
||||
include Github::IntegrationHelper
|
||||
|
||||
def show
|
||||
# Validate account context early for all flows that require it
|
||||
account if params[:code].present?
|
||||
|
||||
if params[:installation_id].present? && params[:code].present?
|
||||
# Both installation and OAuth code present - handle both
|
||||
handle_installation_with_oauth
|
||||
elsif params[:installation_id].present?
|
||||
# Only installation_id present - redirect to OAuth
|
||||
handle_installation
|
||||
else
|
||||
# Only OAuth code present - handle authorization
|
||||
handle_authorization
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Github callback error: #{e.message}")
|
||||
redirect_to fallback_redirect_uri
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_installation_with_oauth
|
||||
# Handle both installation and OAuth in one go
|
||||
installation_id = params[:installation_id]
|
||||
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/github/callback"
|
||||
)
|
||||
|
||||
handle_response(installation_id)
|
||||
end
|
||||
|
||||
def handle_installation
|
||||
if params[:setup_action] == 'install'
|
||||
installation_id = params[:installation_id]
|
||||
|
||||
redirect_to build_oauth_url(installation_id)
|
||||
else
|
||||
Rails.logger.error("Unknown setup_action: #{params[:setup_action]}")
|
||||
redirect_to github_integration_settings_url
|
||||
end
|
||||
end
|
||||
|
||||
def handle_authorization
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/github/callback"
|
||||
)
|
||||
|
||||
handle_response
|
||||
end
|
||||
|
||||
def build_oauth_url(installation_id)
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
|
||||
# Store installation_id in session for later use
|
||||
session[:github_installation_id] = installation_id
|
||||
|
||||
# For now, redirect to a page that will initiate OAuth with proper account context
|
||||
# This is a temporary solution until we have a proper account-agnostic setup
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github?setup_action=install&installation_id=#{installation_id}"
|
||||
end
|
||||
|
||||
def oauth_client
|
||||
app_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
app_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
||||
|
||||
OAuth2::Client.new(
|
||||
app_id,
|
||||
app_secret,
|
||||
{
|
||||
site: 'https://github.com',
|
||||
token_url: '/login/oauth/access_token',
|
||||
authorize_url: '/login/oauth/authorize'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def handle_response(installation_id = nil)
|
||||
settings = build_hook_settings(installation_id)
|
||||
hook = create_integration_hook(settings)
|
||||
hook.save!
|
||||
|
||||
cleanup_session_data
|
||||
redirect_to github_redirect_uri
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Github callback error: #{e.message}")
|
||||
redirect_to fallback_redirect_uri
|
||||
end
|
||||
|
||||
def build_hook_settings(installation_id)
|
||||
settings = {
|
||||
token_type: parsed_body['token_type'],
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
|
||||
settings[:installation_id] = installation_id || session[:github_installation_id]
|
||||
settings.compact
|
||||
end
|
||||
|
||||
def create_integration_hook(settings)
|
||||
account.hooks.new(
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
app_id: 'github',
|
||||
settings: settings
|
||||
)
|
||||
end
|
||||
|
||||
def cleanup_session_data
|
||||
session.delete(:github_installation_id)
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= account_from_state
|
||||
end
|
||||
|
||||
def account_from_state
|
||||
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
||||
|
||||
# Try signed GlobalID first (installation flow)
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
return account if account
|
||||
|
||||
# Fallback to JWT token (direct OAuth flow)
|
||||
account_id = verify_github_token(params[:state])
|
||||
return Account.find(account_id) if account_id
|
||||
|
||||
raise 'Invalid or expired state'
|
||||
rescue StandardError
|
||||
raise ActionController::BadRequest, 'Invalid account context'
|
||||
end
|
||||
|
||||
def github_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/github"
|
||||
end
|
||||
|
||||
def github_integration_settings_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github"
|
||||
end
|
||||
|
||||
def fallback_redirect_uri
|
||||
github_redirect_uri
|
||||
rescue StandardError
|
||||
# Fallback if no account context available
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/settings/integrations"
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
end
|
||||
@@ -42,8 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI],
|
||||
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
|
||||
}
|
||||
|
||||
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
module Github::IntegrationHelper
|
||||
# Generates a signed JWT token for Github integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_github_token(account_id)
|
||||
return if github_client_secret.blank?
|
||||
|
||||
JWT.encode(github_token_payload(account_id), github_client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate Github token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def github_token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
# Verifies and decodes a Github JWT token
|
||||
#
|
||||
# @param token [String] The JWT token to verify
|
||||
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||
def verify_github_token(token)
|
||||
return if token.blank? || github_client_secret.blank?
|
||||
|
||||
github_decode_token(token, github_client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def github_client_secret
|
||||
@github_client_secret ||= GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
||||
end
|
||||
|
||||
def github_decode_token(token, secret)
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Github token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
syncTemplates(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||
}
|
||||
|
||||
createCSATTemplate(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/create_csat_template`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template_status`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -792,6 +792,28 @@
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "Language",
|
||||
"PLACEHOLDER": "Select template language"
|
||||
},
|
||||
"MESSAGE_PREVIEW": {
|
||||
"LABEL": "Message preview",
|
||||
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||
},
|
||||
"TEMPLATE_STATUS": {
|
||||
"APPROVED": "Approved by WhatsApp",
|
||||
"PENDING": "Pending WhatsApp approval",
|
||||
"REJECTED": "Meta rejected the template",
|
||||
"DEFAULT": "Needs WhatsApp approval"
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -803,6 +825,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -15,20 +15,6 @@
|
||||
},
|
||||
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
|
||||
},
|
||||
"GITHUB": {
|
||||
"TITLE": "Connect Github",
|
||||
"LABEL": "Github Client ID",
|
||||
"PLACEHOLDER": "Enter your Github Client ID",
|
||||
"HELP": "Enter your Github Client ID",
|
||||
"CANCEL": "Cancel",
|
||||
"SUBMIT": "Connect",
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the integration?",
|
||||
"MESSAGE": "Are you sure you want to delete the integration?",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
},
|
||||
"HEADER": "Integrations",
|
||||
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
|
||||
"LEARN_MORE": "Learn more about integrations",
|
||||
@@ -352,7 +338,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"CAPTAIN": {
|
||||
"NAME": "Captain",
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
|
||||
@@ -5,13 +5,18 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
|
||||
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
|
||||
import CSATTemplate from './components/CSATTemplate.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: { type: Object, required: true },
|
||||
@@ -29,9 +34,14 @@ const state = reactive({
|
||||
csatSurveyEnabled: false,
|
||||
displayType: 'emoji',
|
||||
message: '',
|
||||
buttonText: 'Please rate us',
|
||||
surveyRuleOperator: 'contains',
|
||||
language: '',
|
||||
});
|
||||
|
||||
const templateStatus = ref(null);
|
||||
const templateLoading = ref(false);
|
||||
|
||||
const filterTypes = [
|
||||
{
|
||||
label: t('INBOX_MGMT.CSAT.SURVEY_RULE.OPERATOR.CONTAINS'),
|
||||
@@ -51,6 +61,55 @@ const labelOptions = computed(() =>
|
||||
: []
|
||||
);
|
||||
|
||||
const languageOptions = computed(() =>
|
||||
languages.map(({ name, id }) => ({ label: `${name} (${id})`, value: id }))
|
||||
);
|
||||
|
||||
const isWhatsAppChannel = computed(
|
||||
() => props.inbox?.channel_type === 'Channel::Whatsapp'
|
||||
);
|
||||
|
||||
const messagePreviewData = computed(() => ({
|
||||
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
|
||||
}));
|
||||
|
||||
const templateApprovalStatus = computed(() => {
|
||||
if (!templateStatus.value || !templateStatus.value.template_exists) {
|
||||
return {
|
||||
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.DEFAULT'),
|
||||
icon: 'i-lucide-stamp',
|
||||
color: 'text-gray-600',
|
||||
};
|
||||
}
|
||||
|
||||
switch (templateStatus.value.status) {
|
||||
case 'APPROVED':
|
||||
return {
|
||||
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.APPROVED'),
|
||||
icon: 'i-lucide-circle-check',
|
||||
color: 'text-green-600',
|
||||
};
|
||||
case 'PENDING':
|
||||
return {
|
||||
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.PENDING'),
|
||||
icon: 'i-lucide-clock',
|
||||
color: 'text-yellow-600',
|
||||
};
|
||||
case 'REJECTED':
|
||||
return {
|
||||
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.REJECTED'),
|
||||
icon: 'i-lucide-circle-x',
|
||||
color: 'text-red-600',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.DEFAULT'),
|
||||
icon: 'i-lucide-stamp',
|
||||
color: 'text-gray-600',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const initializeState = () => {
|
||||
if (!props.inbox) return;
|
||||
|
||||
@@ -63,11 +122,15 @@ const initializeState = () => {
|
||||
const {
|
||||
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
|
||||
message = '',
|
||||
button_text: buttonText = 'Please rate us',
|
||||
language = 'en',
|
||||
survey_rules: surveyRules = {},
|
||||
} = csat_config;
|
||||
|
||||
state.displayType = displayType;
|
||||
state.message = message;
|
||||
state.buttonText = buttonText;
|
||||
state.language = language;
|
||||
state.surveyRuleOperator = surveyRules.operator || 'contains';
|
||||
|
||||
selectedLabelValues.value = Array.isArray(surveyRules.values)
|
||||
@@ -75,9 +138,26 @@ const initializeState = () => {
|
||||
: [];
|
||||
};
|
||||
|
||||
const checkTemplateStatus = async () => {
|
||||
if (!isWhatsAppChannel.value) return;
|
||||
|
||||
try {
|
||||
templateLoading.value = true;
|
||||
const response = await store.dispatch('inboxes/getCSATTemplateStatus', {
|
||||
inboxId: props.inbox.id,
|
||||
});
|
||||
templateStatus.value = response;
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (error) {
|
||||
} finally {
|
||||
templateLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeState();
|
||||
if (!labels.value?.length) store.dispatch('labels/get');
|
||||
if (isWhatsAppChannel.value) checkTemplateStatus();
|
||||
});
|
||||
|
||||
watch(() => props.inbox, initializeState, { immediate: true });
|
||||
@@ -115,13 +195,47 @@ const updateInbox = async attributes => {
|
||||
return store.dispatch('inboxes/updateInbox', payload);
|
||||
};
|
||||
|
||||
const createTemplate = async () => {
|
||||
if (!isWhatsAppChannel.value) return;
|
||||
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
await store.dispatch('inboxes/createCSATTemplate', {
|
||||
inboxId: props.inbox.id,
|
||||
template: {
|
||||
message: state.message,
|
||||
button_text: state.buttonText,
|
||||
language: state.language,
|
||||
},
|
||||
});
|
||||
|
||||
// Check status after creation
|
||||
await checkTemplateStatus();
|
||||
useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
|
||||
// For WhatsApp channels, create template first if needed
|
||||
if (isWhatsAppChannel.value && state.csatSurveyEnabled) {
|
||||
await createTemplate();
|
||||
}
|
||||
|
||||
const csatConfig = {
|
||||
display_type: state.displayType,
|
||||
message: state.message,
|
||||
button_text: state.buttonText,
|
||||
language: state.language,
|
||||
survey_rules: {
|
||||
operator: state.surveyRuleOperator,
|
||||
values: selectedLabelValues.value,
|
||||
@@ -155,7 +269,9 @@ const saveSettings = async () => {
|
||||
</template>
|
||||
|
||||
<div class="grid gap-5">
|
||||
<!-- Show display type only for non-WhatsApp channels -->
|
||||
<WithLabel
|
||||
v-if="!isWhatsAppChannel"
|
||||
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
||||
name="display_type"
|
||||
>
|
||||
@@ -165,14 +281,96 @@ const saveSettings = async () => {
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel :label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')" name="message">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<template v-if="isWhatsAppChannel">
|
||||
<div
|
||||
class="flex flex-col gap-4 justify-between w-full md:flex-row md:gap-6"
|
||||
>
|
||||
<div class="flex flex-col flex-1 gap-3">
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
||||
name="message"
|
||||
>
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<Input
|
||||
v-model="state.buttonText"
|
||||
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
|
||||
name="language"
|
||||
>
|
||||
<ComboBox
|
||||
v-model="state.language"
|
||||
:options="languageOptions"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<div
|
||||
v-if="templateApprovalStatus"
|
||||
class="flex gap-2 items-center mt-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="templateApprovalStatus.icon"
|
||||
:class="templateApprovalStatus.color"
|
||||
class="size-4"
|
||||
/>
|
||||
<span
|
||||
:class="templateApprovalStatus.color"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
{{ templateApprovalStatus.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col justify-start items-center p-6 mt-1 rounded-xl bg-n-slate-2 outline outline-1 outline-n-weak"
|
||||
>
|
||||
<p
|
||||
class="inline-flex items-center text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
|
||||
<Icon
|
||||
v-tooltip.top-end="
|
||||
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
|
||||
"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 mx-1 size-4"
|
||||
/>
|
||||
</p>
|
||||
<CSATTemplate
|
||||
:message="messagePreviewData"
|
||||
:button-text="state.buttonText"
|
||||
class="pt-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Non-WhatsApp channels layout -->
|
||||
<template v-else>
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
||||
name="message"
|
||||
>
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
</template>
|
||||
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
|
||||
@@ -180,7 +378,7 @@ const saveSettings = async () => {
|
||||
>
|
||||
<div class="mb-4">
|
||||
<span
|
||||
class="inline-flex flex-wrap items-center gap-1.5 text-sm text-n-slate-12"
|
||||
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
|
||||
<FilterSelect
|
||||
@@ -217,7 +415,11 @@ const saveSettings = async () => {
|
||||
</div>
|
||||
</WithLabel>
|
||||
<p class="text-sm italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
|
||||
{{
|
||||
isWhatsAppChannel
|
||||
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
|
||||
: $t('INBOX_MGMT.CSAT.NOTE')
|
||||
}}
|
||||
</p>
|
||||
<div>
|
||||
<NextButton
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
|
||||
import Integration from './Integration.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const integrationLoaded = ref(false);
|
||||
|
||||
const integration = useFunctionGetter('integrations/getIntegration', 'github');
|
||||
|
||||
const uiFlags = useMapGetter('integrations/getUIFlags');
|
||||
|
||||
const integrationAction = computed(() => {
|
||||
if (integration.value.enabled) {
|
||||
return 'disconnect';
|
||||
}
|
||||
return integration.value.action;
|
||||
});
|
||||
|
||||
const initializeGithubIntegration = async () => {
|
||||
await store.dispatch('integrations/get', 'github');
|
||||
integrationLoaded.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeGithubIntegration();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-grow flex-shrink max-w-6xl p-4 mx-auto overflow-auto">
|
||||
<div v-if="integrationLoaded && !uiFlags.isCreatingGithub">
|
||||
<Integration
|
||||
:integration-id="integration.id"
|
||||
:integration-logo="integration.logo"
|
||||
:integration-name="integration.name"
|
||||
:integration-description="integration.description"
|
||||
:integration-enabled="integration.enabled"
|
||||
:integration-action="integrationAction"
|
||||
:delete-confirmation-text="{
|
||||
title: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.TITLE'),
|
||||
message: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.MESSAGE'),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center flex-1">
|
||||
<Spinner size="" color-scheme="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,7 +10,7 @@ import SettingsContent from '../Wrapper.vue';
|
||||
import Linear from './Linear.vue';
|
||||
import Notion from './Notion.vue';
|
||||
import Shopify from './Shopify.vue';
|
||||
import Github from './Github.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
@@ -100,16 +100,6 @@ export default {
|
||||
},
|
||||
props: route => ({ code: route.query.code }),
|
||||
},
|
||||
{
|
||||
path: 'github',
|
||||
name: 'settings_integrations_github',
|
||||
component: Github,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
props: route => ({ error: route.query.error }),
|
||||
},
|
||||
{
|
||||
path: 'shopify',
|
||||
name: 'settings_integrations_shopify',
|
||||
|
||||
@@ -332,6 +332,14 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
createCSATTemplate: async (_, { inboxId, template }) => {
|
||||
const response = await InboxesAPI.createCSATTemplate(inboxId, template);
|
||||
return response.data;
|
||||
},
|
||||
getCSATTemplateStatus: async (_, { inboxId }) => {
|
||||
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class Integrations::App
|
||||
include Linear::IntegrationHelper
|
||||
include Github::IntegrationHelper
|
||||
attr_accessor :params
|
||||
|
||||
def initialize(params)
|
||||
@@ -31,15 +30,10 @@ class Integrations::App
|
||||
params[:fields]
|
||||
end
|
||||
|
||||
# There is no way to get the account_id from the linear/github callback
|
||||
# so we are using the generate token method to generate a token and encode it in the state parameter
|
||||
# There is no way to get the account_id from the linear callback
|
||||
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
|
||||
def encode_state
|
||||
case params[:id]
|
||||
when 'linear'
|
||||
generate_linear_token(Current.account.id)
|
||||
when 'github'
|
||||
generate_github_token(Current.account.id)
|
||||
end
|
||||
generate_linear_token(Current.account.id)
|
||||
end
|
||||
|
||||
def action
|
||||
@@ -49,8 +43,6 @@ class Integrations::App
|
||||
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
|
||||
when 'linear'
|
||||
build_linear_action
|
||||
when 'github'
|
||||
build_github_action
|
||||
else
|
||||
params[:action]
|
||||
end
|
||||
@@ -62,8 +54,6 @@ class Integrations::App
|
||||
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
|
||||
when 'linear'
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
when 'github'
|
||||
github_enabled?
|
||||
when 'shopify'
|
||||
shopify_enabled?(account)
|
||||
when 'leadsquared'
|
||||
@@ -88,17 +78,6 @@ class Integrations::App
|
||||
].join('&')
|
||||
end
|
||||
|
||||
def build_github_action
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
|
||||
# For GitHub Apps, redirect to installation page
|
||||
# The standard /installations/new URL should show org/user selection if the app is properly configured
|
||||
# Include state parameter with signed account ID for account context
|
||||
github_app_name = GlobalConfigService.load('GITHUB_APP_NAME', 'chatwoot-qa')
|
||||
state = Current.account.to_signed_global_id(expires_in: 1.hour)
|
||||
"https://github.com/apps/#{github_app_name}/installations/new?state=#{state}"
|
||||
end
|
||||
|
||||
def enabled?(account)
|
||||
case params[:id]
|
||||
when 'webhook'
|
||||
@@ -122,10 +101,6 @@ class Integrations::App
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
|
||||
end
|
||||
|
||||
def self.github_integration_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback"
|
||||
end
|
||||
|
||||
class << self
|
||||
def apps
|
||||
Hashie::Mash.new(APPS_CONFIG)
|
||||
@@ -144,10 +119,6 @@ class Integrations::App
|
||||
|
||||
private
|
||||
|
||||
def github_enabled?
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present? && GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil).present?
|
||||
end
|
||||
|
||||
def shopify_enabled?(account)
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
end
|
||||
|
||||
@@ -65,4 +65,12 @@ class InboxPolicy < ApplicationPolicy
|
||||
def health?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def csat_template_status?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def create_csat_template?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,9 @@ class CsatSurveyService
|
||||
def perform
|
||||
return unless should_send_csat_survey?
|
||||
|
||||
if within_messaging_window?
|
||||
if whatsapp_channel? && template_available_and_approved?
|
||||
send_whatsapp_template_survey
|
||||
elsif within_messaging_window?
|
||||
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
||||
else
|
||||
create_csat_not_sent_activity_message
|
||||
@@ -35,6 +37,72 @@ class CsatSurveyService
|
||||
conversation.can_reply?
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
inbox.channel_type == 'Channel::Whatsapp'
|
||||
end
|
||||
|
||||
def template_available_and_approved?
|
||||
template_config = inbox.csat_config&.dig('template')
|
||||
return false unless template_config
|
||||
|
||||
template_name = template_config['name'] || 'customer_satisfaction_survey'
|
||||
status_result = inbox.channel.provider_service.get_template_status(template_name)
|
||||
|
||||
status_result[:success] && status_result[:template][:status] == 'APPROVED'
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error checking CSAT template status: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def send_whatsapp_template_survey
|
||||
template_config = inbox.csat_config&.dig('template')
|
||||
template_name = template_config['name'] || 'customer_satisfaction_survey'
|
||||
|
||||
phone_number = conversation.contact_inbox.source_id
|
||||
template_info = build_template_info(template_name, template_config)
|
||||
message = create_csat_message
|
||||
|
||||
inbox.channel.provider_service.send_template(phone_number, template_info, message)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error sending WhatsApp CSAT template: #{e.message}"
|
||||
handle_template_send_failure
|
||||
end
|
||||
|
||||
def build_template_info(template_name, template_config)
|
||||
{
|
||||
name: template_name,
|
||||
lang_code: template_config['language'] || 'en',
|
||||
parameters: [
|
||||
{
|
||||
type: 'button',
|
||||
sub_type: 'url',
|
||||
index: '0',
|
||||
parameters: [{ type: 'text', text: conversation.uuid }]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def create_csat_message
|
||||
message = conversation.messages.build(
|
||||
account: conversation.account,
|
||||
inbox: inbox,
|
||||
message_type: :outgoing,
|
||||
content: inbox.csat_config&.dig('message') || 'Please rate this conversation',
|
||||
content_type: :input_csat
|
||||
)
|
||||
message.save!
|
||||
message
|
||||
end
|
||||
|
||||
def handle_template_send_failure
|
||||
if within_messaging_window?
|
||||
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
||||
else
|
||||
create_csat_not_sent_activity_message
|
||||
end
|
||||
end
|
||||
|
||||
def create_csat_not_sent_activity_message
|
||||
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
||||
activity_message_params = {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
class Github::GithubService
|
||||
attr_reader :hook
|
||||
|
||||
def initialize(hook:)
|
||||
@hook = hook
|
||||
end
|
||||
|
||||
def repositories
|
||||
client.repositories(nil, type: 'all')
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error fetching repositories: #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
def assignees(repo_full_name)
|
||||
client.repository_assignees(repo_full_name)
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error fetching assignees for #{repo_full_name}: #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
def search_repositories(query)
|
||||
return repositories if query.blank?
|
||||
|
||||
# Get all user's repositories first
|
||||
user_repos = repositories
|
||||
|
||||
# Filter repositories by query string (case-insensitive)
|
||||
user_repos.select do |repo|
|
||||
repo.full_name.downcase.include?(query.downcase) ||
|
||||
(repo.description&.downcase&.include?(query.downcase))
|
||||
end
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error searching repositories with query '#{query}': #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client
|
||||
@client ||= Octokit::Client.new(
|
||||
access_token: hook.access_token,
|
||||
auto_paginate: true,
|
||||
per_page: 100
|
||||
)
|
||||
end
|
||||
|
||||
def raise_api_error(error)
|
||||
case error.response_status
|
||||
when 401
|
||||
raise CustomExceptions::Base.new('GitHub authentication failed', 401)
|
||||
when 403
|
||||
raise CustomExceptions::Base.new('GitHub API rate limit exceeded or insufficient permissions', 403)
|
||||
when 404
|
||||
raise CustomExceptions::Base.new('GitHub resource not found', 404)
|
||||
else
|
||||
raise CustomExceptions::Base.new("GitHub API error: #{error.message}", error.response_status || 500)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -44,12 +44,6 @@ class Twilio::IncomingMessageService
|
||||
twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
|
||||
end
|
||||
|
||||
def normalized_phone_number
|
||||
return phone_number unless twilio_channel.whatsapp?
|
||||
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
|
||||
end
|
||||
|
||||
def formatted_phone_number
|
||||
TelephoneNumber.parse(phone_number).international_number
|
||||
end
|
||||
@@ -59,10 +53,8 @@ class Twilio::IncomingMessageService
|
||||
end
|
||||
|
||||
def set_contact
|
||||
source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From]
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: source_id,
|
||||
source_id: params[:From],
|
||||
inbox: inbox,
|
||||
contact_attributes: contact_attributes
|
||||
).perform
|
||||
|
||||
@@ -47,8 +47,17 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
|
||||
end
|
||||
|
||||
def argentina_phone_number?(phone_number)
|
||||
phone_number.match(/^54/)
|
||||
end
|
||||
|
||||
def normalised_argentina_mobil_number(phone_number)
|
||||
# Remove 9 before country code
|
||||
phone_number.sub(/^549/, '54')
|
||||
end
|
||||
|
||||
def processed_waid(waid)
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud)
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
|
||||
end
|
||||
|
||||
def error_webhook_event?(message)
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
# Service to handle phone number normalization for WhatsApp messages
|
||||
# Currently supports Brazil and Argentina phone number format variations
|
||||
# Supports both WhatsApp Cloud API and Twilio WhatsApp providers
|
||||
# Designed to be extensible for additional countries in future PRs
|
||||
#
|
||||
# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
|
||||
class Whatsapp::PhoneNumberNormalizationService
|
||||
def initialize(inbox)
|
||||
@inbox = inbox
|
||||
end
|
||||
|
||||
# @param raw_number [String] The phone number in provider-specific format
|
||||
# - Cloud: "5541988887777" (clean number)
|
||||
# - Twilio: "whatsapp:+5541988887777" (prefixed format)
|
||||
# @param provider [Symbol] :cloud or :twilio
|
||||
# @return [String] Normalized source_id in provider format or original if not found
|
||||
def normalize_and_find_contact_by_provider(raw_number, provider)
|
||||
# Extract clean number based on provider format
|
||||
clean_number = extract_clean_number(raw_number, provider)
|
||||
# Main entry point for phone number normalization
|
||||
# Returns the source_id of an existing contact if found, otherwise returns original waid
|
||||
def normalize_and_find_contact(waid)
|
||||
normalizer = find_normalizer_for_country(waid)
|
||||
return waid unless normalizer
|
||||
|
||||
# Find appropriate normalizer for the country
|
||||
normalizer = find_normalizer_for_country(clean_number)
|
||||
return raw_number unless normalizer
|
||||
normalized_waid = normalizer.normalize(waid)
|
||||
existing_contact_inbox = find_existing_contact_inbox(normalized_waid)
|
||||
|
||||
# Normalize the clean number
|
||||
normalized_clean_number = normalizer.normalize(clean_number)
|
||||
|
||||
# Format for provider and check for existing contact
|
||||
provider_format = format_for_provider(normalized_clean_number, provider)
|
||||
existing_contact_inbox = find_existing_contact_inbox(provider_format)
|
||||
|
||||
existing_contact_inbox&.source_id || raw_number
|
||||
existing_contact_inbox&.source_id || waid
|
||||
end
|
||||
|
||||
private
|
||||
@@ -42,26 +33,6 @@ class Whatsapp::PhoneNumberNormalizationService
|
||||
inbox.contact_inboxes.find_by(source_id: normalized_waid)
|
||||
end
|
||||
|
||||
# Extract clean number from provider-specific format
|
||||
def extract_clean_number(raw_number, provider)
|
||||
case provider
|
||||
when :twilio
|
||||
raw_number.gsub(/^whatsapp:\+/, '') # Remove prefix: "whatsapp:+5541988887777" → "5541988887777"
|
||||
else
|
||||
raw_number # Default fallback for unknown providers
|
||||
end
|
||||
end
|
||||
|
||||
# Format normalized number for provider-specific storage
|
||||
def format_for_provider(clean_number, provider)
|
||||
case provider
|
||||
when :twilio
|
||||
"whatsapp:+#{clean_number}" # Add prefix: "5541988887777" → "whatsapp:+5541988887777"
|
||||
else
|
||||
clean_number # Default for :cloud and unknown providers: "5541988887777"
|
||||
end
|
||||
end
|
||||
|
||||
NORMALIZERS = [
|
||||
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer,
|
||||
Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
|
||||
|
||||
@@ -58,6 +58,77 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
response.success?
|
||||
end
|
||||
|
||||
def create_csat_template(template_config)
|
||||
request_body = {
|
||||
name: 'customer_satisfaction_survey',
|
||||
language: template_config[:language] || 'en',
|
||||
category: 'UTILITY',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
text: template_config[:message]
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
type: 'URL',
|
||||
text: template_config[:button_text] || 'Please rate us',
|
||||
url: "#{template_config[:base_url]}/survey/responses/{{1}}",
|
||||
example: ["#{template_config[:base_url]}/survey/responses/12345"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = HTTParty.post(
|
||||
"#{business_account_path}/message_templates",
|
||||
headers: api_headers,
|
||||
body: request_body.to_json
|
||||
)
|
||||
|
||||
if response.success?
|
||||
{
|
||||
success: true,
|
||||
template_id: response['id'],
|
||||
template_name: 'customer_satisfaction_survey',
|
||||
status: 'PENDING'
|
||||
}
|
||||
else
|
||||
Rails.logger.error "WhatsApp template creation failed: #{response.code} - #{response.body}"
|
||||
{
|
||||
success: false,
|
||||
error: error_message(response) || "Failed to create template: #{response.code}",
|
||||
response_body: response.body
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def get_template_status(template_name)
|
||||
url = "#{business_account_path}/message_templates?name=#{template_name}&access_token=#{whatsapp_channel.provider_config['api_key']}"
|
||||
response = HTTParty.get(url)
|
||||
|
||||
return { success: false, error: 'API request failed' } unless response.success?
|
||||
|
||||
templates = response['data'] || []
|
||||
template = templates.find { |t| t['name'] == template_name }
|
||||
|
||||
if template
|
||||
{
|
||||
success: true,
|
||||
template: {
|
||||
id: template['id'],
|
||||
name: template['name'],
|
||||
status: template['status'],
|
||||
language: template['language']
|
||||
}
|
||||
}
|
||||
else
|
||||
{ success: false, error: 'Template not found' }
|
||||
end
|
||||
end
|
||||
|
||||
def api_headers
|
||||
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
|
||||
end
|
||||
|
||||
@@ -169,11 +169,7 @@
|
||||
<symbol id="icon-shopify" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-github" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</symbol>
|
||||
|
||||
|
||||
<symbol id="icon-slack" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
@@ -176,9 +176,6 @@
|
||||
- name: notion_integration
|
||||
display_name: Notion Integration
|
||||
enabled: false
|
||||
- name: github_integration
|
||||
display_name: Github Integration
|
||||
enabled: false
|
||||
- name: captain_integration_v2
|
||||
display_name: Captain V2
|
||||
enabled: false
|
||||
|
||||
@@ -451,22 +451,3 @@
|
||||
locked: false
|
||||
description: 'Token expiry in days'
|
||||
## ------ End of Customizations for Customers ------ ##
|
||||
|
||||
# ------- Github Integration Config ------- #
|
||||
- name: GITHUB_CLIENT_ID
|
||||
display_title: 'Github Client ID'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github client ID'
|
||||
- name: GITHUB_CLIENT_SECRET
|
||||
display_title: 'Github Client Secret'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github client secret'
|
||||
type: secret
|
||||
- name: GITHUB_APP_NAME
|
||||
display_title: 'Github App Name'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github App name for installation URLs (e.g., "chatwoot-qa")'
|
||||
## ------ End of Github Integration Config ------- #
|
||||
|
||||
@@ -284,11 +284,3 @@ leadsquared:
|
||||
'conversation_activity_score',
|
||||
'transcript_activity_score',
|
||||
]
|
||||
|
||||
github:
|
||||
id: github
|
||||
logo: github.png
|
||||
i18n_key: github
|
||||
hook_type: account
|
||||
action: https://github.com/login/oauth/authorize
|
||||
allow_multiple_hooks: false
|
||||
|
||||
@@ -312,10 +312,6 @@ en:
|
||||
name: 'LeadSquared'
|
||||
short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
|
||||
description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
|
||||
github:
|
||||
name: 'GitHub'
|
||||
short_description: 'Create and manage GitHub issues directly from conversations.'
|
||||
description: 'Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.'
|
||||
captain:
|
||||
copilot_message_required: Message is required
|
||||
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
|
||||
|
||||
@@ -196,6 +196,8 @@ Rails.application.routes.draw do
|
||||
delete :avatar, on: :member
|
||||
post :sync_templates, on: :member
|
||||
get :health, on: :member
|
||||
get :csat_template_status, on: :member
|
||||
post :create_csat_template, on: :member
|
||||
end
|
||||
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
||||
collection do
|
||||
@@ -302,14 +304,6 @@ Rails.application.routes.draw do
|
||||
delete :destroy
|
||||
end
|
||||
end
|
||||
resource :github, controller: 'github', only: [] do
|
||||
collection do
|
||||
delete :destroy
|
||||
get :repositories
|
||||
get :search_repositories
|
||||
get 'repositories/:owner/:repo/assignees', to: 'github#assignees', as: :repository_assignees
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :working_hours, only: [:update]
|
||||
|
||||
@@ -557,10 +551,6 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
namespace :github do
|
||||
get :callback, to: 'callbacks#show'
|
||||
end
|
||||
|
||||
get 'microsoft/callback', to: 'microsoft/callbacks#show'
|
||||
get 'google/callback', to: 'google/callbacks#show'
|
||||
get 'instagram/callback', to: 'instagram/callbacks#show'
|
||||
|
||||
|
After Width: | Height: | Size: 689 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 197 KiB |
@@ -0,0 +1,210 @@
|
||||
# CSAT Surveys via WhatsApp Message Templates
|
||||
|
||||
## Overview
|
||||
|
||||
To ensure successful delivery of CSAT (Customer Satisfaction) surveys via WhatsApp, particularly after the 24-hour customer interaction window, messages must be sent using approved WhatsApp message templates. This spec outlines support for using templates, including automation and service integration.
|
||||
|
||||
## Problem
|
||||
|
||||
Messages sent after 24 hours without an approved template result in delivery failures. Currently, CSAT surveys are completely disabled for WhatsApp channels after the 24-hour messaging window expires.
|
||||
|
||||
## Solution
|
||||
|
||||
Support free-form content input and create WhatsApp message templates automatically in the background. Users configure their CSAT message through the existing UI, and the system handles template creation and approval workflow via WhatsApp Business API.
|
||||
|
||||
**Key Design Decisions:**
|
||||
- Templates cannot be edited once submitted to WhatsApp - editing requires deleting the old template and creating a new one
|
||||
- Template lifecycle is managed automatically - new templates overwrite previous ones
|
||||
- **Template Priority**: Approved templates are always preferred over regular CSAT messages, regardless of messaging window
|
||||
- Fallback to regular CSAT only when templates are unavailable and within messaging window
|
||||
|
||||
## Data Storage
|
||||
|
||||
Template information is stored in the channel's `csat_config` JSONB field:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Hello! Can you please take this quick survey and provide us with your feedback.",
|
||||
"display_type": "emoji",
|
||||
"survey_rules": { ... },
|
||||
"template": {
|
||||
"name": "customer_satisfaction_survey",
|
||||
"template_id": "123456789",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"language": "en"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Template Naming:**
|
||||
- **Hardcoded template name**: `customer_satisfaction_survey`
|
||||
- **Single template per channel**: No multiple templates support
|
||||
- **Language handling**: Deferred to future implementation
|
||||
|
||||
**Note:** Template status is checked in real-time via API calls, not stored in the database.
|
||||
|
||||
## Template Structure
|
||||
|
||||
Template uses conversation UUID as parameter:
|
||||
- **Message**: User-configured message content
|
||||
- **Button URL**: `{{base_url}}/survey/responses/{{1}}` where `{{1}}` is the conversation UUID
|
||||
- **Button Text**: User-configured button text (default: "Please rate us")
|
||||
|
||||
#### Template Creation API
|
||||
|
||||
Send a POST request to the WhatsApp Business Account > Message Templates endpoint to create a template.
|
||||
|
||||
Request Syntax
|
||||
|
||||
POST /<WHATSAPP_BUSINESS_ACCOUNT_ID>/message_templates
|
||||
|
||||
Post Body
|
||||
|
||||
{
|
||||
"name": "customer_satisfaction_survey",
|
||||
"category": "MARKETING",
|
||||
"language": "<LANGUAGE>",
|
||||
"components": [<COMPONENTS>]
|
||||
}
|
||||
|
||||
```
|
||||
curl --location 'https://graph.facebook.com/v22.0/{{business_account_id}}/message_templates' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"name": "customer_satisfaction_survey",
|
||||
"language": "en",
|
||||
"category": "MARKETING",
|
||||
"components": [
|
||||
{
|
||||
"type": "BODY",
|
||||
"text": "Hello! Can you please take this quick survey and provide us with your feedback."
|
||||
},
|
||||
{
|
||||
"type": "BUTTONS",
|
||||
"buttons": [
|
||||
{
|
||||
"type": "URL",
|
||||
"text": "Please rate us",
|
||||
"url": "{{base_url}}/survey/responses/{{1}}",
|
||||
"example": [
|
||||
"12345"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## Service Integration
|
||||
|
||||
### CsatSurveyService Modifications
|
||||
|
||||
Extend `app/services/csat_survey_service.rb` to handle WhatsApp templates:
|
||||
|
||||
1. **Template Check**: Check if template exists in `csat_config` and verify status via real-time API call
|
||||
2. **Template Priority Logic**:
|
||||
- If template exists and approved: Always send template (regardless of messaging window)
|
||||
- If no template or not approved: Fall back to regular CSAT within messaging window
|
||||
- If outside window and no approved template: Create activity message
|
||||
3. **Survey Rules**: Apply existing label-based survey rules before sending
|
||||
|
||||
### WhatsApp Provider Integration
|
||||
|
||||
Modify `app/services/whatsapp/send_on_whatsapp_service.rb` to support CSAT templates:
|
||||
- Add CSAT template sending capability
|
||||
- Use conversation UUID as template parameter
|
||||
- Handle template-specific error cases
|
||||
|
||||
### Template Management
|
||||
|
||||
**Creation Workflow:**
|
||||
1. User updates CSAT configuration in settings
|
||||
2. System creates template via WhatsApp Business API
|
||||
3. Template info stored in `csat_config` (without status)
|
||||
4. Old templates are automatically replaced
|
||||
|
||||
**Send Survey Logic:**
|
||||
1. Conversation is resolved
|
||||
2. Check existing survey rules (labels, etc.)
|
||||
3. Check if template exists and get status via API call
|
||||
4. **Template Priority:**
|
||||
- If template approved: Send template (regardless of messaging window)
|
||||
- If no template or not approved:
|
||||
- Within messaging window: Send regular CSAT message
|
||||
- Outside messaging window: Create activity message
|
||||
|
||||
## Error Handling & Fallback
|
||||
|
||||
**Template Creation Failures:**
|
||||
- Display error message in frontend
|
||||
- Log error details for debugging
|
||||
- Continue using regular CSAT within messaging window
|
||||
|
||||
**Template Sending Failures:**
|
||||
- Log failure reason
|
||||
- Create activity message indicating survey couldn't be sent
|
||||
- Track failure metrics for monitoring
|
||||
|
||||
**Fallback Strategy:**
|
||||
- **Template Priority**: Always prefer approved templates over regular CSAT messages
|
||||
- **No Template Available**:
|
||||
- Within messaging window: Send regular CSAT message
|
||||
- Outside messaging window: Create activity message
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
**Scope:**
|
||||
- WhatsApp Cloud API channels only (primary focus)
|
||||
- 360Dialog provider is deprecated and not supported
|
||||
- Future extension to other WhatsApp providers like twilio can be considered
|
||||
|
||||
**Provider Support:**
|
||||
- Implement in `app/services/whatsapp/providers/whatsapp_cloud_service.rb`
|
||||
- Use existing template management methods
|
||||
|
||||
## Template Status Checking
|
||||
|
||||
**Real-time Status API:**
|
||||
|
||||
Check template status before sending surveys:
|
||||
|
||||
```bash
|
||||
curl --location 'https://graph.facebook.com/v20.0/{{business_account_id}}/message_templates?name={{template_name}}&access_token={{access_token}}'
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
curl --location 'https://graph.facebook.com/v20.0/1189403312549467/message_templates?name=customer_satisfaction_survey&access_token={{access_token}}'
|
||||
```
|
||||
|
||||
**Response Format:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"name": "customer_satisfaction_survey",
|
||||
"status": "APPROVED|PENDING|REJECTED|DISABLED",
|
||||
"id": "123456789",
|
||||
"language": "en",
|
||||
"category": "MARKETING"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation Points:**
|
||||
|
||||
1. **Frontend Configuration Page**: Check template status when user visits CSAT settings to show approval status
|
||||
2. **Before Survey Sending**: Real-time API call to verify template is approved - if approved, always use template
|
||||
3. **Caching Strategy**: Consider short-term caching (5-10 minutes) to avoid excessive API calls during high-volume periods
|
||||
4. **Template Priority**: Approved templates bypass messaging window restrictions and are always sent
|
||||
|
||||
**Analytics:**
|
||||
- Template usage tracking not included in initial implementation
|
||||
- Regular CSAT analytics remain unchanged
|
||||
- Can be added in future iterations
|
||||
|
||||
**Enterprise Compatibility:**
|
||||
- No specific enterprise overrides required
|
||||
- Standard CSAT enterprise policies apply
|
||||
@@ -1,6 +0,0 @@
|
||||
class AddIndexToConversationsIdentifier < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
def change
|
||||
add_index :conversations, [:identifier, :account_id], name: 'index_conversations_on_identifier_and_account_id', algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_10_22_152158) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -676,7 +676,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_22_152158) do
|
||||
t.index ["contact_id"], name: "index_conversations_on_contact_id"
|
||||
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"
|
||||
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
|
||||
t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
|
||||
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
|
||||
t.index ["priority"], name: "index_conversations_on_priority"
|
||||
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
|
||||
|
||||
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
@@ -10,7 +10,7 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
|
||||
context 'when authenticated and authorized' do
|
||||
before do
|
||||
# Create conversations across 24 hours at different times
|
||||
base_time = Time.utc(2024, 1, 14, 23, 0) # Start at 23:00 to span 2 days
|
||||
base_time = Time.utc(2024, 1, 15, 0, 0) # Start at midnight UTC
|
||||
|
||||
# Create conversations every 4 hours across 24 hours
|
||||
6.times do |i|
|
||||
@@ -23,38 +23,36 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
|
||||
end
|
||||
|
||||
it 'timezone_offset affects data grouping and timestamps correctly' do
|
||||
travel_to Time.utc(2024, 1, 15, 12, 0) do
|
||||
Time.use_zone('UTC') do
|
||||
base_time = Time.utc(2024, 1, 14, 23, 0) # Start at 23:00 to span 2 days
|
||||
base_params = {
|
||||
metric: 'conversations_count',
|
||||
type: 'account',
|
||||
since: (base_time - 1.day).to_i.to_s,
|
||||
until: (base_time + 2.days).to_i.to_s,
|
||||
group_by: 'day'
|
||||
}
|
||||
Time.use_zone('UTC') do
|
||||
base_time = Time.utc(2024, 1, 15, 0, 0)
|
||||
base_params = {
|
||||
metric: 'conversations_count',
|
||||
type: 'account',
|
||||
since: (base_time - 1.day).to_i.to_s,
|
||||
until: (base_time + 2.days).to_i.to_s,
|
||||
group_by: 'day'
|
||||
}
|
||||
|
||||
responses = [0, -8, 9].map do |offset|
|
||||
get "/api/v2/accounts/#{account.id}/reports",
|
||||
params: base_params.merge(timezone_offset: offset),
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
response.parsed_body
|
||||
end
|
||||
|
||||
data_entries = responses.map { |r| r.select { |e| e['value'] > 0 } }
|
||||
totals = responses.map { |r| r.sum { |e| e['value'] } }
|
||||
timestamps = responses.map { |r| r.map { |e| e['timestamp'] } }
|
||||
|
||||
# Data conservation and redistribution
|
||||
expect(totals.uniq).to eq([6])
|
||||
expect(data_entries[0].map { |e| e['value'] }).to eq([1, 5])
|
||||
expect(data_entries[1].map { |e| e['value'] }).to eq([3, 3])
|
||||
expect(data_entries[2].map { |e| e['value'] }).to eq([4, 2])
|
||||
|
||||
# Timestamp differences
|
||||
expect(timestamps.uniq.size).to eq(3)
|
||||
timestamps[0].zip(timestamps[1]).each { |utc, pst| expect(utc - pst).to eq(-28_800) }
|
||||
responses = [0, -8, 9].map do |offset|
|
||||
get "/api/v2/accounts/#{account.id}/reports",
|
||||
params: base_params.merge(timezone_offset: offset),
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
response.parsed_body
|
||||
end
|
||||
|
||||
data_entries = responses.map { |r| r.select { |e| e['value'] > 0 } }
|
||||
totals = responses.map { |r| r.sum { |e| e['value'] } }
|
||||
timestamps = responses.map { |r| r.map { |e| e['timestamp'] } }
|
||||
|
||||
# Data conservation and redistribution
|
||||
expect(totals.uniq).to eq([6])
|
||||
expect(data_entries[0].map { |e| e['value'] }).to eq([1, 5])
|
||||
expect(data_entries[1].map { |e| e['value'] }).to eq([3, 3])
|
||||
expect(data_entries[2].map { |e| e['value'] }).to eq([4, 2])
|
||||
|
||||
# Timestamp differences
|
||||
expect(timestamps.uniq.size).to eq(3)
|
||||
timestamps[0].zip(timestamps[1]).each { |utc, pst| expect(utc - pst).to eq(-28_800) }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Github::CallbacksController, type: :controller do
|
||||
let(:account) { create(:account) }
|
||||
let(:signed_account_id) { account.to_signed_global_id(expires_in: 1.hour) }
|
||||
|
||||
before do
|
||||
# Clear any existing GitHub hooks for the account
|
||||
account.hooks.where(app_id: 'github').destroy_all
|
||||
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('test_client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('test_client_secret')
|
||||
end
|
||||
|
||||
describe 'route accessibility' do
|
||||
it 'is accessible via the github callback route' do
|
||||
expect(get: '/github/callback').to route_to(
|
||||
controller: 'github/callbacks',
|
||||
action: 'show'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'helper methods' do
|
||||
describe '#account' do
|
||||
it 'resolves account from valid signed global id' do
|
||||
controller.params = { state: signed_account_id }
|
||||
expect(controller.send(:account)).to eq(account)
|
||||
end
|
||||
|
||||
it 'raises error for missing state' do
|
||||
controller.params = {}
|
||||
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
|
||||
end
|
||||
|
||||
it 'raises error for invalid state' do
|
||||
controller.params = { state: 'invalid_state' }
|
||||
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#oauth_client' do
|
||||
it 'creates OAuth2 client with correct configuration' do
|
||||
oauth_client = controller.send(:oauth_client)
|
||||
expect(oauth_client).to be_a(OAuth2::Client)
|
||||
expect(oauth_client.id).to eq('test_client_id')
|
||||
expect(oauth_client.secret).to eq('test_client_secret')
|
||||
expect(oauth_client.site).to eq('https://github.com')
|
||||
expect(oauth_client.options[:token_url]).to eq('/login/oauth/access_token')
|
||||
expect(oauth_client.options[:authorize_url]).to eq('/login/oauth/authorize')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#github_redirect_uri' do
|
||||
before do
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
end
|
||||
|
||||
it 'generates correct GitHub redirect URI' do
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
uri = controller.send(:github_redirect_uri)
|
||||
expect(uri).to eq("http://localhost:3000/app/accounts/#{account.id}/settings/integrations/github")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fallback_redirect_uri' do
|
||||
it 'generates fallback redirect URI when account is unavailable' do
|
||||
allow(controller).to receive(:account).and_raise(StandardError)
|
||||
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
uri = controller.send(:fallback_redirect_uri)
|
||||
expect(uri).to eq('http://localhost:3000/app/settings/integrations')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_hook_settings' do
|
||||
let(:mock_parsed_body) do
|
||||
{
|
||||
'access_token' => 'test_token',
|
||||
'token_type' => 'bearer',
|
||||
'scope' => 'repo,read:org'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:parsed_body).and_return(mock_parsed_body)
|
||||
end
|
||||
|
||||
it 'builds hook settings with installation_id' do
|
||||
settings = controller.send(:build_hook_settings, '12345')
|
||||
expect(settings).to include(
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org',
|
||||
installation_id: '12345'
|
||||
)
|
||||
end
|
||||
|
||||
it 'builds hook settings without installation_id when nil' do
|
||||
settings = controller.send(:build_hook_settings, nil)
|
||||
expect(settings).to include(
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org'
|
||||
)
|
||||
expect(settings).not_to have_key(:installation_id)
|
||||
end
|
||||
|
||||
it 'removes nil installation_id values' do
|
||||
settings = controller.send(:build_hook_settings, nil)
|
||||
expect(settings.keys).not_to include(:installation_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_integration_hook' do
|
||||
let(:settings) do
|
||||
{
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org',
|
||||
installation_id: '12345'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
allow(controller).to receive(:parsed_body).and_return({
|
||||
'access_token' => 'test_token'
|
||||
})
|
||||
end
|
||||
|
||||
it 'creates integration hook with correct attributes' do
|
||||
hook = controller.send(:create_integration_hook, settings)
|
||||
expect(hook.access_token).to eq('test_token')
|
||||
expect(hook.status).to eq('enabled')
|
||||
expect(hook.app_id).to eq('github')
|
||||
expect(hook.settings).to eq(settings.stringify_keys)
|
||||
expect(hook.account).to eq(account)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_oauth_url' do
|
||||
it 'builds OAuth URL with installation context' do
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
url = controller.send(:build_oauth_url, '12345')
|
||||
expect(url).to include('github?setup_action=install&installation_id=12345')
|
||||
expect(controller.session[:github_installation_id]).to eq('12345')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#base_url' do
|
||||
it 'returns FRONTEND_URL when set' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(controller.send(:base_url)).to eq('https://app.chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns default localhost when FRONTEND_URL not set' do
|
||||
with_modified_env FRONTEND_URL: nil do
|
||||
expect(controller.send(:base_url)).to eq('http://localhost:3000')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,9 +13,5 @@ FactoryBot.define do
|
||||
sequence(:phone_number) { |n| "+123456789#{n}1" }
|
||||
messaging_service_sid { nil }
|
||||
end
|
||||
|
||||
trait :whatsapp do
|
||||
medium { :whatsapp }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,25 +38,6 @@ RSpec.describe Integrations::App do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the app is github' do
|
||||
let(:app_name) { 'github' }
|
||||
|
||||
it 'returns the GitHub App installation URL with state parameter' do
|
||||
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id', GITHUB_APP_NAME: 'test-app' do
|
||||
expect(app.action).to include('https://github.com/apps/test-app/installations/new')
|
||||
expect(app.action).to include('state=')
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses default app name when GITHUB_APP_NAME is not set' do
|
||||
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('dummy_client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_NAME', 'chatwoot-qa').and_return('chatwoot-qa')
|
||||
expect(app.action).to include('https://github.com/apps/chatwoot-qa/installations/new')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
@@ -106,34 +87,6 @@ RSpec.describe Integrations::App do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the app is github' do
|
||||
let(:app_name) { 'github' }
|
||||
|
||||
it 'returns false if github credentials are not present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns false if only client_id is present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns false if only client_secret is present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns true if both client_id and client_secret are present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
|
||||
expect(app.active?(account)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when other apps are queried' do
|
||||
let(:app_name) { 'webhook' }
|
||||
|
||||
|
||||
@@ -402,230 +402,6 @@ describe Twilio::IncomingMessageService do
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('Alice Johnson')
|
||||
end
|
||||
|
||||
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts if contact does not exist' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5541988887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('João Silva')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
|
||||
end
|
||||
|
||||
it 'appends to existing contact if contact inbox exists' do
|
||||
# Create existing contact with same format
|
||||
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
|
||||
inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5541988887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Another message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# No new conversation should be created
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Another message from Brazil')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When incoming number is a Brazilian number in old format without the 9 included' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'appends to existing contact when contact inbox exists in old format' do
|
||||
# Create existing contact with old format (12 digits)
|
||||
old_contact = create(:contact, account: account, phone_number: '+554188887777')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+554188887777', contact: old_contact, inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil old format',
|
||||
ProfileName: 'Maria Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# No new conversation should be created
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Brazil old format')
|
||||
end
|
||||
|
||||
it 'appends to existing contact when contact inbox exists in new format' do
|
||||
# Create existing contact with new format (13 digits)
|
||||
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
|
||||
inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
# Incoming message with old format (12 digits)
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# Should find and use existing contact, not create duplicate
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the existing conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Brazil')
|
||||
# Should use the existing contact's source_id (normalized format)
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
|
||||
end
|
||||
|
||||
it 'creates contact inbox with incoming number when no existing contact' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'Carlos Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Silva')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+554188887777')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When the incoming number is an Argentine number with 9 after country code' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts if contact does not exist' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5491123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Carlos Mendoza'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Mendoza')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5491123456789')
|
||||
end
|
||||
|
||||
it 'appends to existing contact if contact inbox exists with normalized format' do
|
||||
# Create existing contact with normalized format (without 9 after country code)
|
||||
normalized_contact = create(:contact, account: account, phone_number: '+541123456789')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: normalized_contact,
|
||||
inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
# Incoming message with 9 after country code
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5491123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Carlos Mendoza'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# Should find and use existing contact, not create duplicate
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the existing conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
|
||||
# Should use the normalized source_id from existing contact
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When incoming number is an Argentine number without 9 after country code' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'appends to existing contact when contact inbox exists with same format' do
|
||||
# Create existing contact with same format (without 9)
|
||||
contact = create(:contact, account: account, phone_number: '+541123456789')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: contact, inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+541123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Ana García'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# No new conversation should be created
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
|
||||
end
|
||||
|
||||
it 'creates contact inbox with incoming number when no existing contact' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+541123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Diego López'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Diego López')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||