feat: integrate LeadSquared CRM (#11284)

This commit is contained in:
Shivam Mishra
2025-04-29 09:14:00 +05:30
committed by GitHub
parent c63b583f90
commit 1a2e6dc4ee
36 changed files with 2577 additions and 7 deletions

View File

@@ -163,9 +163,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact.custom_attributes
end
def contact_additional_attributes
return @contact.additional_attributes.merge(permitted_params[:additional_attributes]) if permitted_params[:additional_attributes]
@contact.additional_attributes
end
def contact_update_params
# we want the merged custom attributes not the original one
permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
permitted_params.except(:custom_attributes, :avatar_url)
.merge({ custom_attributes: contact_custom_attributes })
.merge({ additional_attributes: contact_additional_attributes })
end
def set_include_contact_inboxes

View File

@@ -80,7 +80,7 @@ export default {
}, {});
this.formItems.forEach(item => {
if (item.validation.includes('JSON')) {
if (item.validation?.includes('JSON')) {
hookPayload.settings[item.name] = JSON.parse(
hookPayload.settings[item.name]
);
@@ -117,7 +117,7 @@ export default {
<div class="flex flex-col h-auto overflow-auto integration-hooks">
<woot-modal-header
:header-title="integration.name"
:header-content="integration.description"
:header-content="integration.short_description || integration.description"
/>
<FormKit
v-model="values"
@@ -169,6 +169,10 @@ export default {
@apply hidden;
}
.formkit-form .formkit-help {
@apply text-n-slate-10 text-sm font-normal mt-2 w-full;
}
/* equivalent of .reset-base */
.formkit-input {
margin-bottom: 0px !important;

36
app/jobs/crm/setup_job.rb Normal file
View File

@@ -0,0 +1,36 @@
class Crm::SetupJob < ApplicationJob
queue_as :default
def perform(hook_id)
hook = Integrations::Hook.find_by(id: hook_id)
return if hook.blank? || hook.disabled?
begin
setup_service = create_setup_service(hook)
return if setup_service.nil?
setup_service.setup
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
Rails.logger.error "Error in CRM setup for hook ##{hook_id} (#{hook.app_id}): #{e.message}"
end
end
private
def create_setup_service(hook)
case hook.app_id
when 'leadsquared'
Crm::Leadsquared::SetupService.new(hook)
# Add cases for future CRMs here
# when 'hubspot'
# Crm::Hubspot::SetupService.new(hook)
# when 'zoho'
# Crm::Zoho::SetupService.new(hook)
else
Rails.logger.error "Unsupported CRM app_id: #{hook.app_id}"
nil
end
end
end

View File

@@ -1,4 +1,6 @@
class HookJob < ApplicationJob
class HookJob < MutexApplicationJob
retry_on LockAcquisitionError, wait: 3.seconds, attempts: 3
queue_as :medium
def perform(hook, event_name, event_data = {})
@@ -11,6 +13,8 @@ class HookJob < ApplicationJob
process_dialogflow_integration(hook, event_name, event_data)
when 'google_translate'
google_translate_integration(hook, event_name, event_data)
when 'leadsquared'
process_leadsquared_integration_with_lock(hook, event_name, event_data)
end
rescue StandardError => e
Rails.logger.error e
@@ -41,4 +45,39 @@ class HookJob < ApplicationJob
message = event_data[:message]
Integrations::GoogleTranslate::DetectLanguageService.new(hook: hook, message: message).perform
end
def process_leadsquared_integration_with_lock(hook, event_name, event_data)
# Why do we need a mutex here? glad you asked
# When a new conversation is created. We get a contact created event, immediately followed by
# a contact updated event, and then a conversation created event.
# This all happens within milliseconds of each other.
# Now each of these subsequent event handlers need to have a leadsquared lead created and the contact to have the ID.
# If the lead data is not present, we try to search the API and create a new lead if it doesn't exist.
# This gives us a bad race condition that allows the API to create multiple leads for the same contact.
#
# This would have not been a problem if the email and phone number were unique identifiers for contacts at LeadSquared
# But then this is configurable in the LeadSquared settings, and may or may not be unique.
valid_event_names = ['contact.updated', 'conversation.created', 'conversation.resolved']
return unless valid_event_names.include?(event_name)
return unless hook.feature_allowed?
key = format(::Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: hook.id)
with_lock(key) do
process_leadsquared_integration(hook, event_name, event_data)
end
end
def process_leadsquared_integration(hook, event_name, event_data)
# Process the event with the processor service
processor = Crm::Leadsquared::ProcessorService.new(hook)
case event_name
when 'contact.updated'
processor.handle_contact(event_data[:contact])
when 'conversation.created'
processor.handle_conversation_created(event_data[:conversation])
when 'conversation.resolved'
processor.handle_conversation_resolved(event_data[:conversation])
end
end
end

View File

@@ -11,6 +11,29 @@ class HookListener < BaseListener
execute_hooks(event, message)
end
def contact_created(event)
contact = extract_contact_and_account(event)[0]
execute_account_hooks(event, contact.account, contact: contact)
end
def contact_updated(event)
contact = extract_contact_and_account(event)[0]
execute_account_hooks(event, contact.account, contact: contact)
end
def conversation_created(event)
conversation = extract_conversation_and_account(event)[0]
execute_account_hooks(event, conversation.account, conversation: conversation)
end
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
# Only trigger for status changes is resolved
return unless conversation.status == 'resolved'
execute_account_hooks(event, conversation.account, conversation: conversation)
end
private
def execute_hooks(event, message)
@@ -22,4 +45,10 @@ class HookListener < BaseListener
HookJob.perform_later(hook, event.name, message: message)
end
end
def execute_account_hooks(event, account, event_data = {})
account.hooks.account_hooks.find_each do |hook|
HookJob.perform_later(hook, event.name, event_data)
end
end
end

View File

@@ -18,6 +18,10 @@ class Integrations::App
I18n.t("integration_apps.#{params[:i18n_key]}.description")
end
def short_description
I18n.t("integration_apps.#{params[:i18n_key]}.short_description")
end
def logo
params[:logo]
end
@@ -51,6 +55,8 @@ class Integrations::App
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
when 'leadsquared'
account.feature_enabled?('crm_integration')
else
true
end

View File

@@ -19,11 +19,13 @@ class Integrations::Hook < ApplicationRecord
attr_readonly :app_id, :account_id, :inbox_id, :hook_type
before_validation :ensure_hook_type
after_create :trigger_setup_if_crm
validates :account_id, presence: true
validates :app_id, presence: true
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
validate :validate_settings_json_schema
validate :ensure_feature_enabled
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
# TODO: This seems to be only used for slack at the moment
@@ -36,6 +38,9 @@ class Integrations::Hook < ApplicationRecord
enum hook_type: { account: 0, inbox: 1 }
scope :account_hooks, -> { where(hook_type: 'account') }
scope :inbox_hooks, -> { where(hook_type: 'inbox') }
def app
@app ||= Integrations::App.find(id: app_id)
end
@@ -61,8 +66,21 @@ class Integrations::Hook < ApplicationRecord
end
end
def feature_allowed?
return true if app.blank?
flag = app.params[:feature_flag]
return true unless flag
account.feature_enabled?(flag)
end
private
def ensure_feature_enabled
errors.add(:feature_flag, 'Feature not enabled') unless feature_allowed?
end
def ensure_hook_type
self.hook_type = app.params[:hook_type] if app.present?
end
@@ -72,4 +90,17 @@ class Integrations::Hook < ApplicationRecord
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
end
def trigger_setup_if_crm
# we need setup services to create data prerequisite to functioning of the integration
# in case of Leadsquared, we need to create a custom activity type for capturing conversations and transcripts
# https://apidocs.leadsquared.com/create-new-activity-type-api/
return unless crm_integration?
::Crm::SetupJob.perform_later(id)
end
def crm_integration?
%w[leadsquared].include?(app_id)
end
end

View File

@@ -0,0 +1,92 @@
class Crm::BaseProcessorService
def initialize(hook)
@hook = hook
@account = hook.account
end
# Class method to be overridden by subclasses
def self.crm_name
raise NotImplementedError, 'Subclasses must define self.crm_name'
end
# Instance method that calls the class method
def crm_name
self.class.crm_name
end
def process_event(event_name, event_data)
case event_name
when 'contact.created'
handle_contact_created(event_data)
when 'contact.updated'
handle_contact_updated(event_data)
when 'conversation.created'
handle_conversation_created(event_data)
when 'conversation.updated'
handle_conversation_updated(event_data)
else
{ success: false, error: "Unsupported event: #{event_name}" }
end
rescue StandardError => e
Rails.logger.error "#{crm_name} Processor Error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
{ success: false, error: e.message }
end
# Abstract methods that subclasses must implement
def handle_contact_created(contact)
raise NotImplementedError, 'Subclasses must implement #handle_contact_created'
end
def handle_contact_updated(contact)
raise NotImplementedError, 'Subclasses must implement #handle_contact_updated'
end
def handle_conversation_created(conversation)
raise NotImplementedError, 'Subclasses must implement #handle_conversation_created'
end
def handle_conversation_resolved(conversation)
raise NotImplementedError, 'Subclasses must implement #handle_conversation_resolved'
end
# Common helper methods for all CRM processors
protected
def identifiable_contact?(contact)
has_social_profile = contact.additional_attributes['social_profiles'].present?
contact.present? && (contact.email.present? || contact.phone_number.present? || has_social_profile)
end
def get_external_id(contact)
return nil if contact.additional_attributes.blank?
return nil if contact.additional_attributes['external'].blank?
contact.additional_attributes.dig('external', "#{crm_name}_id")
end
def store_external_id(contact, external_id)
# Initialize additional_attributes if it's nil
contact.additional_attributes = {} if contact.additional_attributes.nil?
# Initialize external hash if it doesn't exist
contact.additional_attributes['external'] = {} if contact.additional_attributes['external'].blank?
# Store the external ID
contact.additional_attributes['external']["#{crm_name}_id"] = external_id
contact.save!
end
def store_conversation_metadata(conversation, metadata)
# Initialize additional_attributes if it's nil
conversation.additional_attributes = {} if conversation.additional_attributes.nil?
# Initialize CRM-specific hash in additional_attributes
conversation.additional_attributes[crm_name] = {} if conversation.additional_attributes[crm_name].blank?
# Store the metadata
conversation.additional_attributes[crm_name].merge!(metadata)
conversation.save!
end
end

View File

@@ -0,0 +1,36 @@
class Crm::Leadsquared::Api::ActivityClient < Crm::Leadsquared::Api::BaseClient
# https://apidocs.leadsquared.com/post-an-activity-to-lead/#api
def post_activity(prospect_id, activity_event, activity_note)
raise ArgumentError, 'Prospect ID is required' if prospect_id.blank?
raise ArgumentError, 'Activity event code is required' if activity_event.blank?
path = 'ProspectActivity.svc/Create'
body = {
'RelatedProspectId' => prospect_id,
'ActivityEvent' => activity_event,
'ActivityNote' => activity_note
}
response = post(path, {}, body)
response['Message']['Id']
end
def create_activity_type(name:, score:, direction: 0)
raise ArgumentError, 'Activity name is required' if name.blank?
path = 'ProspectActivity.svc/CreateType'
body = {
'ActivityEventName' => name,
'Score' => score.to_i,
'Direction' => direction.to_i
}
response = post(path, {}, body)
response['Message']['Id']
end
def fetch_activity_types
get('ProspectActivity.svc/ActivityTypes.Get')
end
end

View File

@@ -0,0 +1,84 @@
class Crm::Leadsquared::Api::BaseClient
include HTTParty
class ApiError < StandardError
attr_reader :code, :response
def initialize(message = nil, code = nil, response = nil)
@code = code
@response = response
super(message)
end
end
def initialize(access_key, secret_key, endpoint_url)
@access_key = access_key
@secret_key = secret_key
@base_uri = endpoint_url
end
def get(path, params = {})
full_url = URI.join(@base_uri, path).to_s
options = {
query: params,
headers: headers
}
response = self.class.get(full_url, options)
handle_response(response)
end
def post(path, params = {}, body = {})
full_url = URI.join(@base_uri, path).to_s
options = {
query: params,
headers: headers
}
options[:body] = body.to_json if body.present?
response = self.class.post(full_url, options)
handle_response(response)
end
private
def headers
{
'Content-Type': 'application/json',
'x-LSQ-AccessKey': @access_key,
'x-LSQ-SecretKey': @secret_key
}
end
def handle_response(response)
case response.code
when 200..299
handle_success(response)
else
error_message = "LeadSquared API error: #{response.code} - #{response.body}"
Rails.logger.error error_message
raise ApiError.new(error_message, response.code, response)
end
end
def handle_success(response)
parse_response(response)
rescue JSON::ParserError, TypeError => e
error_message = "Failed to parse LeadSquared API response: #{e.message}"
raise ApiError.new(error_message, response.code, response)
end
def parse_response(response)
body = response.parsed_response
if body.is_a?(Hash) && body['Status'] == 'Error'
error_message = body['ExceptionMessage'] || 'Unknown API error'
raise ApiError.new(error_message, response.code, response)
else
body
end
end
end

View File

@@ -0,0 +1,50 @@
class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
# https://apidocs.leadsquared.com/quick-search/#api
def search_lead(key)
raise ArgumentError, 'Search key is required' if key.blank?
path = 'LeadManagement.svc/Leads.GetByQuickSearch'
params = { key: key }
get(path, params)
end
# https://apidocs.leadsquared.com/create-or-update/#api
# The email address and phone fields are used as the default search criteria.
# If none of these match with an existing lead, a new lead will be created.
# We can pass the "SearchBy" attribute in the JSON body to search by a particular parameter, however
# we don't need this capability at the moment
def create_or_update_lead(lead_data)
raise ArgumentError, 'Lead data is required' if lead_data.blank?
path = 'LeadManagement.svc/Lead.CreateOrUpdate'
formatted_data = format_lead_data(lead_data)
response = post(path, {}, formatted_data)
response['Message']['Id']
end
def update_lead(lead_data, lead_id)
raise ArgumentError, 'Lead ID is required' if lead_id.blank?
raise ArgumentError, 'Lead data is required' if lead_data.blank?
path = "LeadManagement.svc/Lead.Update?leadId=#{lead_id}"
formatted_data = format_lead_data(lead_data)
response = post(path, {}, formatted_data)
response['Message']['AffectedRows']
end
private
def format_lead_data(lead_data)
lead_data.map do |key, value|
{
'Attribute' => key,
'Value' => value
}
end
end
end

View File

@@ -0,0 +1,59 @@
class Crm::Leadsquared::LeadFinderService
def initialize(lead_client)
@lead_client = lead_client
end
def find_or_create(contact)
lead_id = get_stored_id(contact)
return lead_id if lead_id.present?
lead_id = find_by_contact(contact)
return lead_id if lead_id.present?
create_lead(contact)
end
private
def find_by_contact(contact)
lead_id = find_by_email(contact)
lead_id = find_by_phone_number(contact) if lead_id.blank?
lead_id
end
def find_by_email(contact)
return if contact.email.blank?
search_by_field(contact.email)
end
def find_by_phone_number(contact)
return if contact.phone_number.blank?
search_by_field(contact.phone_number)
end
def search_by_field(value)
leads = @lead_client.search_lead(value)
return nil unless leads.is_a?(Array)
leads.first['ProspectID'] if leads.any?
end
def create_lead(contact)
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
lead_id = @lead_client.create_or_update_lead(lead_data)
raise StandardError, 'Failed to create lead - no ID returned' if lead_id.blank?
lead_id
end
def get_stored_id(contact)
return nil if contact.additional_attributes.blank?
return nil if contact.additional_attributes['external'].blank?
contact.additional_attributes.dig('external', 'leadsquared_id')
end
end

View File

@@ -0,0 +1,35 @@
class Crm::Leadsquared::Mappers::ContactMapper
def self.map(contact)
new(contact).map
end
def initialize(contact)
@contact = contact
end
def map
base_attributes
end
private
attr_reader :contact
def base_attributes
{
'FirstName' => contact.name.presence,
'LastName' => contact.last_name.presence,
'EmailAddress' => contact.email.presence,
'Mobile' => contact.phone_number.presence,
'Source' => brand_name
}.compact
end
def brand_name
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
end
def brand_name_without_spaces
brand_name.gsub(/\s+/, '')
end
end

View File

@@ -0,0 +1,108 @@
class Crm::Leadsquared::Mappers::ConversationMapper
include ::Rails.application.routes.url_helpers
# https://help.leadsquared.com/what-is-the-maximum-character-length-supported-for-lead-and-activity-fields/
# the rest of the body of the note is around 200 chars
# so this limits it
ACTIVITY_NOTE_MAX_SIZE = 1800
def self.map_conversation_activity(conversation)
new(conversation).conversation_activity
end
def self.map_transcript_activity(conversation, messages = nil)
new(conversation, messages).transcript_activity
end
def initialize(conversation, messages = nil)
@conversation = conversation
@messages = messages
end
def conversation_activity
I18n.t('crm.created_activity',
brand_name: brand_name,
channel_info: conversation.inbox.name,
formatted_creation_time: formatted_creation_time,
display_id: conversation.display_id,
url: conversation_url)
end
def transcript_activity
return I18n.t('crm.no_message') if transcript_messages.empty?
I18n.t('crm.transcript_activity',
brand_name: brand_name,
channel_info: conversation.inbox.name,
display_id: conversation.display_id,
url: conversation_url,
format_messages: format_messages)
end
private
attr_reader :conversation, :messages
def formatted_creation_time
conversation.created_at.strftime('%Y-%m-%d %H:%M:%S')
end
def transcript_messages
@transcript_messages ||= messages || conversation.messages.chat.select(&:conversation_transcriptable?)
end
def format_messages
selected_messages = []
separator = "\n\n"
current_length = 0
# Reverse the messages to have latest on top
transcript_messages.reverse_each do |message|
formatted_message = format_message(message)
required_length = formatted_message.length + separator.length # the last one does not need to account for separator, but we add it anyway
break unless (current_length + required_length) <= ACTIVITY_NOTE_MAX_SIZE
selected_messages << formatted_message
current_length += required_length
end
selected_messages.join(separator)
end
def format_message(message)
<<~MESSAGE.strip
[#{message_time(message)}] #{sender_name(message)}: #{message_content(message)}#{attachment_info(message)}
MESSAGE
end
def message_time(message)
# TODO: Figure out what timezone to send the time in
message.created_at.strftime('%Y-%m-%d %H:%M')
end
def sender_name(message)
return 'System' if message.sender.nil?
message.sender.name.presence || "#{message.sender_type} #{message.sender_id}"
end
def message_content(message)
message.content.presence || I18n.t('crm.no_content')
end
def attachment_info(message)
return '' unless message.attachments.any?
attachments = message.attachments.map { |a| I18n.t('crm.attachment', type: a.file_type) }.join(', ')
"\n#{attachments}"
end
def conversation_url
app_account_conversation_url(account_id: conversation.account.id, id: conversation.display_id)
end
def brand_name
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
end
end

View File

@@ -0,0 +1,121 @@
class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
def self.crm_name
'leadsquared'
end
def initialize(hook)
super(hook)
@access_key = hook.settings['access_key']
@secret_key = hook.settings['secret_key']
@endpoint_url = hook.settings['endpoint_url']
@allow_transcript = hook.settings['enable_transcript_activity']
@allow_conversation = hook.settings['enable_conversation_activity']
# Initialize API clients
@lead_client = Crm::Leadsquared::Api::LeadClient.new(@access_key, @secret_key, @endpoint_url)
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, @endpoint_url)
@lead_finder = Crm::Leadsquared::LeadFinderService.new(@lead_client)
end
def handle_contact(contact)
contact.reload
unless identifiable_contact?(contact)
Rails.logger.info("Contact not identifiable. Skipping handle_contact for ##{contact.id}")
return
end
stored_lead_id = get_external_id(contact)
create_or_update_lead(contact, stored_lead_id)
end
def handle_conversation_created(conversation)
return unless @allow_conversation
create_conversation_activity(
conversation: conversation,
activity_type: 'conversation',
activity_code_key: 'conversation_activity_code',
metadata_key: 'created_activity_id',
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(conversation)
)
end
def handle_conversation_resolved(conversation)
return unless @allow_transcript
return unless conversation.status == 'resolved'
create_conversation_activity(
conversation: conversation,
activity_type: 'transcript',
activity_code_key: 'transcript_activity_code',
metadata_key: 'transcript_activity_id',
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(conversation)
)
end
private
def create_or_update_lead(contact, lead_id)
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
# Why can't we use create_or_update_lead here?
# In LeadSquared, it's possible that the email field
# may not be marked as unique, same with the phone number field
# So we just use the update API if we already have a lead ID
if lead_id.present?
@lead_client.update_lead(lead_data, lead_id)
else
new_lead_id = @lead_client.create_or_update_lead(lead_data)
store_external_id(contact, new_lead_id)
end
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "LeadSquared API error processing contact: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "Error processing contact in LeadSquared: #{e.message}"
end
def create_conversation_activity(conversation:, activity_type:, activity_code_key:, metadata_key:, activity_note:)
lead_id = get_lead_id(conversation.contact)
return if lead_id.blank?
activity_code = get_activity_code(activity_code_key)
activity_id = @activity_client.post_activity(lead_id, activity_code, activity_note)
return if activity_id.blank?
metadata = {}
metadata[metadata_key] = activity_id
store_conversation_metadata(conversation, metadata)
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "LeadSquared API error in #{activity_type} activity: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "Error creating #{activity_type} activity in LeadSquared: #{e.message}"
end
def get_activity_code(key)
activity_code = @hook.settings[key]
raise StandardError, "LeadSquared #{key} activity code not found for hook ##{@hook.id}." if activity_code.blank?
activity_code
end
def get_lead_id(contact)
contact.reload # reload to ensure all the attributes are up-to-date
unless identifiable_contact?(contact)
Rails.logger.info("Contact not identifiable. Skipping activity for ##{contact.id}")
nil
end
lead_id = @lead_finder.find_or_create(contact)
return nil if lead_id.blank?
store_external_id(contact, lead_id) unless get_external_id(contact)
lead_id
end
end

View File

@@ -0,0 +1,108 @@
class Crm::Leadsquared::SetupService
def initialize(hook)
@hook = hook
credentials = @hook.settings
@access_key = credentials['access_key']
@secret_key = credentials['secret_key']
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, 'https://api.leadsquared.com/v2/')
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, 'https://api.leadsquared.com/v2/')
end
def setup
setup_endpoint
setup_activity
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
ChatwootExceptionTracker.new(e, account: @hook.account).capture_exception
Rails.logger.error "LeadSquared API error in setup: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @hook.account).capture_exception
Rails.logger.error "Error during LeadSquared setup: #{e.message}"
end
def setup_endpoint
response = @client.get('Authentication.svc/UserByAccessKey.Get')
endpoint_host = response['LSQCommonServiceURLs']['api']
app_host = response['LSQCommonServiceURLs']['app']
endpoint_url = "https://#{endpoint_host}/v2/"
app_url = "https://#{app_host}/"
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url })
# replace the clients
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url)
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, endpoint_url)
end
private
def setup_activity
existing_types = @activity_client.fetch_activity_types
return if existing_types.blank?
activity_codes = setup_activity_types(existing_types)
return if activity_codes.blank?
update_hook_settings(activity_codes)
activity_codes
end
def setup_activity_types(existing_types)
activity_codes = {}
activity_types.each do |activity_type|
activity_id = find_or_create_activity_type(activity_type, existing_types)
if activity_id.present?
activity_codes[activity_type[:setting_key]] = activity_id
else
Rails.logger.error "Failed to find or create activity type: #{activity_type[:name]}"
end
end
activity_codes
end
def find_or_create_activity_type(activity_type, existing_types)
existing = existing_types.find { |t| t['ActivityEventName'] == activity_type[:name] }
if existing
existing['ActivityEvent'].to_i
else
@activity_client.create_activity_type(
name: activity_type[:name],
score: activity_type[:score],
direction: activity_type[:direction]
)
end
end
def update_hook_settings(params)
@hook.settings = @hook.settings.merge(params)
@hook.save!
end
def activity_types
[
{
name: "#{brand_name} Conversation Started",
score: @hook.settings['conversation_activity_score'].to_i || 0,
direction: 0,
setting_key: 'conversation_activity_code'
},
{
name: "#{brand_name} Conversation Transcript",
score: @hook.settings['transcript_activity_score'].to_i || 0,
direction: 0,
setting_key: 'transcript_activity_code'
}
].freeze
end
def brand_name
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'].presence || 'Chatwoot'
end
end

View File

@@ -1,6 +1,7 @@
json.id resource.id
json.name resource.name
json.description resource.description
json.short_description resource.short_description.presence
json.enabled resource.enabled?(@current_account)
if Current.account_user&.administrator?

View File

@@ -166,3 +166,6 @@
- name: channel_instagram
display_name: Instagram Channel
enabled: true
- name: crm_integration
display_name: CRM Integration
enabled: false

View File

@@ -4,6 +4,7 @@
# i18n_key: the key under which translations for the integration is placed in en.yml
# action: if integration requires external redirect url
# hook_type: ( account / inbox )
# feature_flag: (string) feature flag to enable/disable the integration
# allow_multiple_hooks: whether multiple hooks can be created for the integration
# settings_json_schema: the json schema used to validate the settings hash (https://json-schema.org/)
# settings_form_schema: the formulate schema used in frontend to render settings form (https://vueformulate.com/)
@@ -186,3 +187,79 @@ shopify:
i18n_key: shopify
hook_type: account
allow_multiple_hooks: false
leadsquared:
id: leadsquared
feature_flag: crm_integration
logo: leadsquared.png
i18n_key: leadsquared
action: /leadsquared
hook_type: account
allow_multiple_hooks: false
settings_json_schema:
{
'type': 'object',
'properties':
{
'access_key': { 'type': 'string' },
'secret_key': { 'type': 'string' },
'endpoint_url': { 'type': 'string' },
'app_url': { 'type': 'string' },
'enable_conversation_activity': { 'type': 'boolean' },
'enable_transcript_activity': { 'type': 'boolean' },
'conversation_activity_score': { 'type': 'string' },
'transcript_activity_score': { 'type': 'string' },
'conversation_activity_code': { 'type': 'integer' },
'transcript_activity_code': { 'type': 'integer' },
},
'required': ['access_key', 'secret_key'],
'additionalProperties': false,
}
settings_form_schema:
[
{
'label': 'Access Key',
'type': 'text',
'name': 'access_key',
'validation': 'required',
},
{
'label': 'Secret Key',
'type': 'text',
'name': 'secret_key',
'validation': 'required',
},
{
'label': 'Push Conversation Activity',
'type': 'checkbox',
'name': 'enable_conversation_activity',
'help': 'Enable this option to push an activity when a conversation is created',
},
{
'label': 'Conversation Activity Score',
'type': 'number',
'name': 'conversation_activity_score',
'help': 'Score to assign to the conversation created activity, default is 0',
},
{
'label': 'Push Transcript Activity',
'type': 'checkbox',
'name': 'enable_transcript_activity',
'help': 'Enable this option to push an activity when a transcript is created',
},
{
'label': 'Transcript Activity Score',
'type': 'number',
'name': 'transcript_activity_score',
'help': 'Score to assign to the conversation transcript activity, default is 0',
},
]
visible_properties:
[
'access_key',
'endpoint_url',
'enable_conversation_activity',
'enable_transcript_activity',
'conversation_activity_score',
'transcript_activity_score',
]

View File

@@ -234,6 +234,10 @@ en:
shopify:
name: 'Shopify'
description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
leadsquared:
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.'
captain:
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
@@ -296,3 +300,23 @@ en:
other: '%{count} seconds'
automation:
system_name: 'Automation System'
crm:
no_message: 'No messages in conversation'
attachment: '[Attachment: %{type}]'
no_content: '[No content]'
created_activity: |
New conversation started on %{brand_name}
Channel: %{channel_info}
Created: %{formatted_creation_time}
Conversation ID: %{display_id}
View in %{brand_name}: %{url}
transcript_activity: |
Conversation Transcript from %{brand_name}
Channel: %{channel_info}
Conversation ID: %{display_id}
View in %{brand_name}: %{url}
Transcript:
%{format_messages}

View File

@@ -41,4 +41,5 @@ module Redis::RedisKeys
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -563,8 +563,11 @@ RSpec.describe 'Contacts API', type: :request do
describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id' do
let(:custom_attributes) { { test: 'test', test1: 'test1' } }
let!(:contact) { create(:contact, account: account, custom_attributes: custom_attributes) }
let(:valid_params) { { name: 'Test Blub', custom_attributes: { test: 'new test', test2: 'test2' } } }
let(:additional_attributes) { { attr1: 'attr1', attr2: 'attr2' } }
let!(:contact) { create(:contact, account: account, custom_attributes: custom_attributes, additional_attributes: additional_attributes) }
let(:valid_params) do
{ name: 'Test Blub', custom_attributes: { test: 'new test', test2: 'test2' }, additional_attributes: { attr2: 'new attr2', attr3: 'attr3' } }
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -588,6 +591,7 @@ RSpec.describe 'Contacts API', type: :request do
expect(contact.reload.name).to eq('Test Blub')
# custom attributes are merged properly without overwriting existing ones
expect(contact.custom_attributes).to eq({ 'test' => 'new test', 'test1' => 'test1', 'test2' => 'test2' })
expect(contact.additional_attributes).to eq({ 'attr1' => 'attr1', 'attr2' => 'new attr2', 'attr3' => 'attr3' })
end
it 'prevents the update of contact of another account' do

View File

@@ -37,5 +37,16 @@ FactoryBot.define do
access_token { SecureRandom.hex }
reference_id { 'test-store.myshopify.com' }
end
trait :leadsquared do
app_id { 'leadsquared' }
settings do
{
'access_key' => SecureRandom.hex,
'secret_key' => SecureRandom.hex,
'endpoint_url' => 'https://api.leadsquared.com/'
}
end
end
end
end

View File

@@ -0,0 +1,85 @@
require 'rails_helper'
RSpec.describe Crm::SetupJob do
subject(:job) { described_class.perform_later(hook.id) }
let(:account) { create(:account) }
let(:hook) do
create(:integrations_hook,
account: account,
app_id: 'leadsquared',
settings: {
access_key: 'test_key',
secret_key: 'test_token',
endpoint_url: 'https://api.leadsquared.com'
})
end
before do
account.enable_features('crm_integration')
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(hook.id)
.on_queue('default')
end
describe '#perform' do
context 'when hook is not found' do
it 'returns without processing' do
allow(Integrations::Hook).to receive(:find_by).and_return(nil)
expect(described_class.new.perform(0)).to be_nil
end
end
context 'when hook is disabled' do
it 'returns without processing' do
disabled_hook = create(:integrations_hook,
account: account,
app_id: 'leadsquared',
status: 'disabled',
settings: {
access_key: 'test_key',
secret_key: 'test_token',
endpoint_url: 'https://api.leadsquared.com'
})
expect(described_class.new.perform(disabled_hook.id)).to be_nil
end
end
context 'when hook is not a CRM integration' do
it 'returns without processing' do
non_crm_hook = create(:integrations_hook,
account: account,
app_id: 'slack',
settings: { webhook_url: 'https://slack.com/webhook' })
expect(described_class.new.perform(non_crm_hook.id)).to be_nil
end
end
context 'when hook is valid' do
let(:setup_service) { instance_double(Crm::Leadsquared::SetupService) }
before do
allow(Crm::Leadsquared::SetupService).to receive(:new).with(hook).and_return(setup_service)
end
context 'when setup raises an error' do
it 'captures exception and logs error' do
error = StandardError.new('Test error')
allow(setup_service).to receive(:setup).and_raise(error)
allow(Rails.logger).to receive(:error)
allow(ChatwootExceptionTracker).to receive(:new)
.with(error, account: hook.account)
.and_return(instance_double(ChatwootExceptionTracker, capture_exception: true))
described_class.new.perform(hook.id)
expect(Rails.logger).to have_received(:error)
.with("Error in CRM setup for hook ##{hook.id} (#{hook.app_id}): Test error")
end
end
end
end
end

View File

@@ -66,4 +66,110 @@ RSpec.describe HookJob do
described_class.perform_now(hook, event_name, event_data)
end
end
context 'when processing leadsquared integration' do
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, contact: contact) }
let(:processor_service) { instance_double(Crm::Leadsquared::ProcessorService) }
let(:leadsquared_hook) { instance_double(Integrations::Hook, id: 123, app_id: 'leadsquared', account: account) }
before do
allow(Crm::Leadsquared::ProcessorService).to receive(:new).with(leadsquared_hook).and_return(processor_service)
end
context 'when processing contact.updated event' do
let(:event_name) { 'contact.updated' }
let(:event_data) { { contact: contact } }
it 'uses a lock when processing' do
allow(leadsquared_hook).to receive(:disabled?).and_return(false)
allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true)
allow(processor_service).to receive(:handle_contact).with(contact)
# Mock the with_lock method directly on the job instance
job_instance = described_class.new
allow(job_instance).to receive(:with_lock).and_yield
allow(described_class).to receive(:new).and_return(job_instance)
expect(job_instance).to receive(:with_lock).with(
format(Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: leadsquared_hook.id)
)
job_instance.perform(leadsquared_hook, event_name, event_data)
end
it 'does not process when feature is not allowed' do
allow(leadsquared_hook).to receive(:disabled?).and_return(false)
allow(leadsquared_hook).to receive(:feature_allowed?).and_return(false)
job_instance = described_class.new
allow(job_instance).to receive(:with_lock)
expect(job_instance).not_to receive(:with_lock)
expect(processor_service).not_to receive(:handle_contact)
job_instance.perform(leadsquared_hook, event_name, event_data)
end
end
context 'when processing conversation.created event' do
let(:event_name) { 'conversation.created' }
let(:event_data) { { conversation: conversation } }
it 'uses a lock when processing' do
allow(leadsquared_hook).to receive(:disabled?).and_return(false)
allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true)
allow(processor_service).to receive(:handle_conversation_created).with(conversation)
job_instance = described_class.new
allow(job_instance).to receive(:with_lock).and_yield
allow(described_class).to receive(:new).and_return(job_instance)
expect(job_instance).to receive(:with_lock).with(
format(Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: leadsquared_hook.id)
)
job_instance.perform(leadsquared_hook, event_name, event_data)
end
end
context 'when processing conversation.resolved event' do
let(:event_name) { 'conversation.resolved' }
let(:event_data) { { conversation: conversation } }
it 'uses a lock when processing' do
allow(leadsquared_hook).to receive(:disabled?).and_return(false)
allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true)
allow(processor_service).to receive(:handle_conversation_resolved).with(conversation)
job_instance = described_class.new
allow(job_instance).to receive(:with_lock).and_yield
allow(described_class).to receive(:new).and_return(job_instance)
expect(job_instance).to receive(:with_lock).with(
format(Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: leadsquared_hook.id)
)
job_instance.perform(leadsquared_hook, event_name, event_data)
end
end
context 'when processing invalid event' do
let(:event_name) { 'invalid.event' }
let(:event_data) { { contact: contact } }
it 'does not process for invalid event names' do
allow(leadsquared_hook).to receive(:disabled?).and_return(false)
allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true)
job_instance = described_class.new
allow(job_instance).to receive(:with_lock)
expect(job_instance).not_to receive(:with_lock)
expect(processor_service).not_to receive(:handle_contact)
job_instance.perform(leadsquared_hook, event_name, event_data)
end
end
end
end

View File

@@ -51,4 +51,85 @@ RSpec.describe Integrations::Hook do
expect(openai_double).to have_received(:perform)
end
end
describe 'scopes' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let!(:account_hook) { create(:integrations_hook, account: account, app_id: 'webhook') }
let!(:inbox_hook) do
create(:integrations_hook,
account: account,
app_id: 'dialogflow',
inbox: inbox,
settings: {
project_id: 'test-project',
credentials: { type: 'service_account' }
})
end
it 'returns account hooks' do
expect(described_class.account_hooks).to include(account_hook)
expect(described_class.account_hooks).not_to include(inbox_hook)
end
it 'returns inbox hooks' do
expect(described_class.inbox_hooks).to include(inbox_hook)
expect(described_class.inbox_hooks).not_to include(account_hook)
end
end
describe '#crm_integration?' do
let(:account) { create(:account) }
before do
account.enable_features('crm_integration')
end
it 'returns true for leadsquared integration' do
hook = create(:integrations_hook,
account: account,
app_id: 'leadsquared',
settings: {
access_key: 'test',
secret_key: 'test',
endpoint_url: 'https://api.leadsquared.com'
})
expect(hook.send(:crm_integration?)).to be true
end
it 'returns false for non-crm integrations' do
hook = create(:integrations_hook, account: account, app_id: 'slack')
expect(hook.send(:crm_integration?)).to be false
end
end
describe '#trigger_setup_if_crm' do
let(:account) { create(:account) }
before do
account.enable_features('crm_integration')
allow(Crm::SetupJob).to receive(:perform_later)
end
context 'when integration is a CRM' do
it 'enqueues setup job' do
create(:integrations_hook,
account: account,
app_id: 'leadsquared',
settings: {
access_key: 'test',
secret_key: 'test',
endpoint_url: 'https://api.leadsquared.com'
})
expect(Crm::SetupJob).to have_received(:perform_later)
end
end
context 'when integration is not a CRM' do
it 'does not enqueue setup job' do
create(:integrations_hook, account: account, app_id: 'slack')
expect(Crm::SetupJob).not_to have_received(:perform_later)
end
end
end
end

View File

@@ -0,0 +1,217 @@
require 'rails_helper'
RSpec.describe Crm::Leadsquared::Api::ActivityClient do
let(:credentials) do
{
access_key: SecureRandom.hex,
secret_key: SecureRandom.hex,
endpoint_url: 'https://api.leadsquared.com/'
}
end
let(:headers) do
{
'Content-Type': 'application/json',
'x-LSQ-AccessKey': credentials[:access_key],
'x-LSQ-SecretKey': credentials[:secret_key]
}
end
let(:client) { described_class.new(credentials[:access_key], credentials[:secret_key], credentials[:endpoint_url]) }
let(:prospect_id) { SecureRandom.uuid }
let(:activity_event) { 1001 } # Example activity event code
let(:activity_note) { 'Test activity note' }
let(:activity_date_time) { '2025-04-11 14:15:00' }
describe '#post_activity' do
let(:path) { '/ProspectActivity.svc/Create' }
let(:full_url) { URI.join(credentials[:endpoint_url], path).to_s }
context 'with missing required parameters' do
it 'raises ArgumentError when prospect_id is missing' do
expect { client.post_activity(nil, activity_event, activity_note) }
.to raise_error(ArgumentError, 'Prospect ID is required')
end
it 'raises ArgumentError when activity_event is missing' do
expect { client.post_activity(prospect_id, nil, activity_note) }
.to raise_error(ArgumentError, 'Activity event code is required')
end
end
context 'when request is successful' do
let(:activity_id) { SecureRandom.uuid }
let(:success_response) do
{
'Status' => 'Success',
'Message' => {
'Id' => activity_id,
'Message' => 'Activity created successfully'
}
}
end
before do
stub_request(:post, full_url)
.with(
body: {
'RelatedProspectId' => prospect_id,
'ActivityEvent' => activity_event,
'ActivityNote' => activity_note
}.to_json,
headers: headers
)
.to_return(
status: 200,
body: success_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns activity ID directly' do
response = client.post_activity(prospect_id, activity_event, activity_note)
expect(response).to eq(activity_id)
end
end
context 'when response indicates failure' do
let(:error_response) do
{
'Status' => 'Error',
'ExceptionType' => 'NullReferenceException',
'ExceptionMessage' => 'There was an error processing the request.'
}
end
before do
stub_request(:post, full_url)
.with(
body: {
'RelatedProspectId' => prospect_id,
'ActivityEvent' => activity_event,
'ActivityNote' => activity_note
}.to_json,
headers: headers
)
.to_return(
status: 200,
body: error_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError when activity creation fails' do
expect { client.post_activity(prospect_id, activity_event, activity_note) }
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
end
end
end
describe '#create_activity_type' do
let(:path) { 'ProspectActivity.svc/CreateType' }
let(:full_url) { URI.join(credentials[:endpoint_url], path).to_s }
let(:activity_params) do
{
name: 'Test Activity Type',
score: 10,
direction: 0
}
end
context 'with missing required parameters' do
it 'raises ArgumentError when name is missing' do
expect { client.create_activity_type(name: nil, score: 10) }
.to raise_error(ArgumentError, 'Activity name is required')
end
end
context 'when request is successful' do
let(:activity_event_id) { 1001 }
let(:success_response) do
{
'Status' => 'Success',
'Message' => {
'Id' => activity_event_id
}
}
end
before do
stub_request(:post, full_url)
.with(
body: {
'ActivityEventName' => activity_params[:name],
'Score' => activity_params[:score],
'Direction' => activity_params[:direction]
}.to_json,
headers: headers
)
.to_return(
status: 200,
body: success_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns activity ID directly' do
response = client.create_activity_type(**activity_params)
expect(response).to eq(activity_event_id)
end
end
context 'when response indicates failure' do
let(:error_response) do
{
'Status' => 'Error',
'ExceptionType' => 'MXInvalidInputException',
'ExceptionMessage' => 'Invalid Input! Parameter Name: activity'
}
end
before do
stub_request(:post, full_url)
.with(
body: {
'ActivityEventName' => activity_params[:name],
'Score' => activity_params[:score],
'Direction' => activity_params[:direction]
}.to_json,
headers: headers
)
.to_return(
status: 200,
body: error_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError when activity type creation fails' do
expect { client.create_activity_type(**activity_params) }
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
end
end
context 'when API request fails' do
before do
stub_request(:post, full_url)
.with(
body: {
'ActivityEventName' => activity_params[:name],
'Score' => activity_params[:score],
'Direction' => activity_params[:direction]
}.to_json,
headers: headers
)
.to_return(
status: 500,
body: 'Internal Server Error',
headers: { 'Content-Type' => 'text/plain' }
)
end
it 'raises ApiError when the request fails' do
expect { client.create_activity_type(**activity_params) }
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
end
end
end
end

View File

@@ -0,0 +1,187 @@
require 'rails_helper'
RSpec.describe Crm::Leadsquared::Api::BaseClient do
let(:access_key) { SecureRandom.hex }
let(:secret_key) { SecureRandom.hex }
let(:headers) do
{
'Content-Type': 'application/json',
'x-LSQ-AccessKey': access_key,
'x-LSQ-SecretKey': secret_key
}
end
let(:endpoint_url) { 'https://api.leadsquared.com/v2' }
let(:client) { described_class.new(access_key, secret_key, endpoint_url) }
describe '#initialize' do
it 'creates a client with valid credentials' do
expect(client.instance_variable_get(:@access_key)).to eq(access_key)
expect(client.instance_variable_get(:@secret_key)).to eq(secret_key)
expect(client.instance_variable_get(:@base_uri)).to eq(endpoint_url)
end
end
describe '#get' do
let(:path) { 'LeadManagement.svc/Leads.Get' }
let(:params) { { leadId: '123' } }
let(:full_url) { URI.join(endpoint_url, path).to_s }
context 'when request is successful' do
before do
stub_request(:get, full_url)
.with(
query: params,
headers: headers
)
.to_return(
status: 200,
body: { Message: 'Success', Status: 'Success' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns parsed response data directly' do
response = client.get(path, params)
expect(response).to include('Message' => 'Success')
expect(response).to include('Status' => 'Success')
end
end
context 'when request returns error status' do
before do
stub_request(:get, full_url)
.with(
query: params,
headers: headers
)
.to_return(
status: 200,
body: { Status: 'Error', ExceptionMessage: 'Invalid lead ID' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError with error message' do
expect { client.get(path, params) }.to raise_error(
Crm::Leadsquared::Api::BaseClient::ApiError,
'Invalid lead ID'
)
end
end
context 'when request fails with non-200 status' do
before do
stub_request(:get, full_url)
.with(
query: params,
headers: headers
)
.to_return(status: 404, body: 'Not Found')
end
it 'raises ApiError with status code' do
expect { client.get(path, params) }.to raise_error do |error|
expect(error).to be_a(Crm::Leadsquared::Api::BaseClient::ApiError)
expect(error.message).to include('Not Found')
expect(error.code).to eq(404)
end
end
end
end
describe '#post' do
let(:path) { 'LeadManagement.svc/Lead.Create' }
let(:params) { {} }
let(:body) { { FirstName: 'John', LastName: 'Doe' } }
let(:full_url) { URI.join(endpoint_url, path).to_s }
context 'when request is successful' do
before do
stub_request(:post, full_url)
.with(
query: params,
body: body.to_json,
headers: headers
)
.to_return(
status: 200,
body: { Message: 'Lead created', Status: 'Success' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns parsed response data directly' do
response = client.post(path, params, body)
expect(response).to include('Message' => 'Lead created')
expect(response).to include('Status' => 'Success')
end
end
context 'when request returns error status' do
before do
stub_request(:post, full_url)
.with(
query: params,
body: body.to_json,
headers: headers
)
.to_return(
status: 200,
body: { Status: 'Error', ExceptionMessage: 'Invalid data' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError with error message' do
expect { client.post(path, params, body) }.to raise_error(
Crm::Leadsquared::Api::BaseClient::ApiError,
'Invalid data'
)
end
end
context 'when response cannot be parsed' do
before do
stub_request(:post, full_url)
.with(
query: params,
body: body.to_json,
headers: headers
)
.to_return(
status: 200,
body: 'Invalid JSON',
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError for invalid JSON' do
expect { client.post(path, params, body) }.to raise_error do |error|
expect(error).to be_a(Crm::Leadsquared::Api::BaseClient::ApiError)
expect(error.message).to include('Failed to parse')
end
end
end
context 'when request fails with server error' do
before do
stub_request(:post, full_url)
.with(
query: params,
body: body.to_json,
headers: headers
)
.to_return(status: 500, body: 'Internal Server Error')
end
it 'raises ApiError with status code' do
expect { client.post(path, params, body) }.to raise_error do |error|
expect(error).to be_a(Crm::Leadsquared::Api::BaseClient::ApiError)
expect(error.message).to include('Internal Server Error')
expect(error.code).to eq(500)
end
end
end
end
end

View File

@@ -0,0 +1,231 @@
require 'rails_helper'
RSpec.describe Crm::Leadsquared::Api::LeadClient do
let(:access_key) { SecureRandom.hex }
let(:secret_key) { SecureRandom.hex }
let(:headers) do
{
'Content-Type': 'application/json',
'x-LSQ-AccessKey': access_key,
'x-LSQ-SecretKey': secret_key
}
end
let(:endpoint_url) { 'https://api.leadsquared.com/v2' }
let(:client) { described_class.new(access_key, secret_key, endpoint_url) }
describe '#search_lead' do
let(:path) { 'LeadManagement.svc/Leads.GetByQuickSearch' }
let(:search_key) { 'test@example.com' }
let(:full_url) { URI.join(endpoint_url, path).to_s }
context 'when search key is missing' do
it 'raises ArgumentError' do
expect { client.search_lead(nil) }
.to raise_error(ArgumentError, 'Search key is required')
end
end
context 'when no leads are found' do
before do
stub_request(:get, full_url)
.with(query: { key: search_key }, headers: headers)
.to_return(
status: 200,
body: [].to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns empty array directly' do
response = client.search_lead(search_key)
expect(response).to eq([])
end
end
context 'when leads are found' do
let(:lead_data) do
[{
'ProspectID' => SecureRandom.uuid,
'FirstName' => 'John',
'LastName' => 'Doe',
'EmailAddress' => search_key
}]
end
before do
stub_request(:get, full_url)
.with(query: { key: search_key }, headers: headers, body: anything)
.to_return(
status: 200,
body: lead_data.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns lead data array directly' do
response = client.search_lead(search_key)
expect(response).to eq(lead_data)
end
end
end
describe '#create_or_update_lead' do
let(:path) { 'LeadManagement.svc/Lead.CreateOrUpdate' }
let(:full_url) { URI.join(endpoint_url, path).to_s }
let(:lead_data) do
{
'FirstName' => 'John',
'LastName' => 'Doe',
'EmailAddress' => 'john.doe@example.com'
}
end
let(:formatted_lead_data) do
lead_data.map do |key, value|
{
'Attribute' => key,
'Value' => value
}
end
end
context 'when lead data is missing' do
it 'raises ArgumentError' do
expect { client.create_or_update_lead(nil) }
.to raise_error(ArgumentError, 'Lead data is required')
end
end
context 'when lead is successfully created' do
let(:lead_id) { '8e0f69ae-e2ac-40fc-a0cf-827326181c8a' }
let(:success_response) do
{
'Status' => 'Success',
'Message' => {
'Id' => lead_id
}
}
end
before do
stub_request(:post, full_url)
.with(
body: formatted_lead_data.to_json,
headers: headers
)
.to_return(
status: 200,
body: success_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns lead ID directly' do
response = client.create_or_update_lead(lead_data)
expect(response).to eq(lead_id)
end
end
context 'when request fails' do
let(:error_response) do
{
'Status' => 'Error',
'ExceptionMessage' => 'Error message'
}
end
before do
stub_request(:post, full_url)
.with(
body: formatted_lead_data.to_json,
headers: headers
)
.to_return(
status: 200,
body: error_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError' do
expect { client.create_or_update_lead(lead_data) }
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
end
end
# Add test for update_lead method
describe '#update_lead' do
let(:path) { 'LeadManagement.svc/Lead.Update' }
let(:lead_id) { '8e0f69ae-e2ac-40fc-a0cf-827326181c8a' }
let(:full_url) { URI.join(endpoint_url, "#{path}?leadId=#{lead_id}").to_s }
context 'with missing parameters' do
it 'raises ArgumentError when lead_id is missing' do
expect { client.update_lead(lead_data, nil) }
.to raise_error(ArgumentError, 'Lead ID is required')
end
it 'raises ArgumentError when lead_data is missing' do
expect { client.update_lead(nil, lead_id) }
.to raise_error(ArgumentError, 'Lead data is required')
end
end
context 'when update is successful' do
let(:success_response) do
{
'Status' => 'Success',
'Message' => {
'AffectedRows' => 1
}
}
end
before do
stub_request(:post, full_url)
.with(
body: formatted_lead_data.to_json,
headers: headers
)
.to_return(
status: 200,
body: success_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns affected rows directly' do
response = client.update_lead(lead_data, lead_id)
expect(response).to eq(1)
end
end
context 'when update fails' do
let(:error_response) do
{
'Status' => 'Error',
'ExceptionMessage' => 'Invalid lead ID'
}
end
before do
stub_request(:post, full_url)
.with(
body: formatted_lead_data.to_json,
headers: headers
)
.to_return(
status: 200,
body: error_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError' do
expect { client.update_lead(lead_data, lead_id) }
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
end
end
end
end
end

View File

@@ -0,0 +1,98 @@
require 'rails_helper'
RSpec.describe Crm::Leadsquared::LeadFinderService do
let(:lead_client) { instance_double(Crm::Leadsquared::Api::LeadClient) }
let(:service) { described_class.new(lead_client) }
let(:contact) { create(:contact, email: 'test@example.com', phone_number: '+1234567890') }
describe '#find_or_create' do
context 'when contact has stored lead ID' do
before do
contact.additional_attributes = { 'external' => { 'leadsquared_id' => '123' } }
contact.save!
end
it 'returns the stored lead ID' do
result = service.find_or_create(contact)
expect(result).to eq('123')
end
end
context 'when contact has no stored lead ID' do
context 'when lead is found by email' do
before do
allow(lead_client).to receive(:search_lead)
.with(contact.email)
.and_return([{ 'ProspectID' => '456' }])
end
it 'returns the found lead ID' do
result = service.find_or_create(contact)
expect(result).to eq('456')
end
end
context 'when lead is found by phone' do
before do
allow(lead_client).to receive(:search_lead)
.with(contact.email)
.and_return([])
allow(lead_client).to receive(:search_lead)
.with(contact.phone_number)
.and_return([{ 'ProspectID' => '789' }])
end
it 'returns the found lead ID' do
result = service.find_or_create(contact)
expect(result).to eq('789')
end
end
context 'when lead is not found and needs to be created' do
before do
allow(lead_client).to receive(:search_lead)
.with(contact.email)
.and_return([])
allow(lead_client).to receive(:search_lead)
.with(contact.phone_number)
.and_return([])
allow(lead_client).to receive(:create_or_update_lead)
.with(Crm::Leadsquared::Mappers::ContactMapper.map(contact))
.and_return('999')
end
it 'creates a new lead and returns its ID' do
result = service.find_or_create(contact)
expect(result).to eq('999')
end
end
context 'when lead creation fails' do
before do
allow(lead_client).to receive(:search_lead)
.with(contact.email)
.and_return([])
allow(lead_client).to receive(:search_lead)
.with(contact.phone_number)
.and_return([])
allow(Crm::Leadsquared::Mappers::ContactMapper).to receive(:map)
.with(contact)
.and_return({})
allow(lead_client).to receive(:create_or_update_lead)
.with({})
.and_raise(StandardError, 'Failed to create lead')
end
it 'raises an error' do
expect { service.find_or_create(contact) }.to raise_error(StandardError, 'Failed to create lead')
end
end
end
end
end

View File

@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe Crm::Leadsquared::Mappers::ContactMapper do
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account, name: '', last_name: '', country_code: '') }
let(:brand_name) { 'Test Brand' }
before do
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => brand_name })
end
describe '.map' do
context 'with basic attributes' do
it 'maps basic contact attributes correctly' do
contact.update!(
name: 'John',
last_name: 'Doe',
email: 'john@example.com',
phone_number: '+1234567890'
)
mapped_data = described_class.map(contact)
expect(mapped_data).to include(
'FirstName' => 'John',
'LastName' => 'Doe',
'EmailAddress' => 'john@example.com',
'Mobile' => '+1234567890',
'Source' => 'Test Brand'
)
end
end
end
end

View File

@@ -0,0 +1,210 @@
require 'rails_helper'
RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, name: 'Test Inbox', channel_type: 'Channel') }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:user) { create(:user, name: 'John Doe') }
let(:contact) { create(:contact, name: 'Jane Smith') }
before do
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
end
describe '.map_conversation_activity' do
it 'generates conversation activity note' do
travel_to(Time.zone.parse('2024-01-01 10:00:00')) do
result = described_class.map_conversation_activity(conversation)
expect(result).to include('New conversation started on TestBrand')
expect(result).to include('Channel: Test Inbox')
expect(result).to include('Created: 2024-01-01 10:00:00')
expect(result).to include("Conversation ID: #{conversation.display_id}")
expect(result).to include('View in TestBrand: http://')
end
end
end
describe '.map_transcript_activity' do
context 'when conversation has no messages' do
it 'returns no messages message' do
result = described_class.map_transcript_activity(conversation)
expect(result).to eq('No messages in conversation')
end
end
context 'when conversation has messages' do
let(:message1) do
create(:message,
conversation: conversation,
sender: user,
content: 'Hello',
message_type: :outgoing,
created_at: Time.zone.parse('2024-01-01 10:00:00'))
end
let(:message2) do
create(:message,
conversation: conversation,
sender: contact,
content: 'Hi there',
message_type: :incoming,
created_at: Time.zone.parse('2024-01-01 10:01:00'))
end
let(:system_message) do
create(:message,
conversation: conversation,
sender: nil,
content: 'System Message',
message_type: :activity,
created_at: Time.zone.parse('2024-01-01 10:02:00'))
end
before do
message1
message2
system_message
end
it 'generates transcript with messages in reverse chronological order' do
result = described_class.map_transcript_activity(conversation)
expect(result).to include('Conversation Transcript from TestBrand')
expect(result).to include('Channel: Test Inbox')
# Check that messages appear in reverse order (newest first)
message_positions = {
'[2024-01-01 10:00] John Doe: Hello' => result.index('[2024-01-01 10:00] John Doe: Hello'),
'[2024-01-01 10:01] Jane Smith: Hi there' => result.index('[2024-01-01 10:01] Jane Smith: Hi there')
}
# Latest message (10:01) should come before older message (10:00)
expect(message_positions['[2024-01-01 10:01] Jane Smith: Hi there']).to be < message_positions['[2024-01-01 10:00] John Doe: Hello']
end
context 'when message has attachments' do
let(:message_with_attachment) do
create(:message, :with_attachment,
conversation: conversation,
sender: user,
content: 'See attachment',
message_type: :outgoing,
created_at: Time.zone.parse('2024-01-01 10:03:00'))
end
before { message_with_attachment }
it 'includes attachment information' do
result = described_class.map_transcript_activity(conversation)
expect(result).to include('See attachment')
expect(result).to include('[Attachment: image]')
end
end
context 'when message has empty content' do
let(:empty_message) do
create(:message,
conversation: conversation,
sender: user,
content: '',
message_type: :outgoing,
created_at: Time.zone.parse('2024-01-01 10:04'))
end
before { empty_message }
it 'shows no content placeholder' do
result = described_class.map_transcript_activity(conversation)
expect(result).to include('[No content]')
end
end
context 'when sender has no name' do
let(:unnamed_sender_message) do
create(:message,
conversation: conversation,
sender: create(:user, name: ''),
content: 'Message',
message_type: :outgoing,
created_at: Time.zone.parse('2024-01-01 10:05'))
end
before { unnamed_sender_message }
it 'uses sender type and id' do
result = described_class.map_transcript_activity(conversation)
expect(result).to include("User #{unnamed_sender_message.sender_id}")
end
end
end
context 'when specific messages are provided' do
let(:message1) { create(:message, conversation: conversation, content: 'Message 1', message_type: :outgoing) }
let(:message2) { create(:message, conversation: conversation, content: 'Message 2', message_type: :outgoing) }
let(:specific_messages) { [message1] }
it 'only includes provided messages' do
result = described_class.map_transcript_activity(conversation, specific_messages)
expect(result).to include('Message 1')
expect(result).not_to include('Message 2')
end
end
context 'when messages exceed the ACTIVITY_NOTE_MAX_SIZE' do
it 'truncates messages to stay within the character limit' do
# Create a large number of messages with reasonably sized content
long_message_content = 'A' * 200
messages = []
# Create 15 messages (which should exceed the 1800 character limit)
15.times do |i|
messages << create(:message,
conversation: conversation,
sender: user,
content: "#{long_message_content} #{i}",
message_type: :outgoing,
created_at: Time.zone.parse("2024-01-01 #{10 + i}:00:00"))
end
result = described_class.map_transcript_activity(conversation, messages)
# Verify latest message is included (message 14)
expect(result).to include("[2024-01-02 00:00] John Doe: #{long_message_content} 14")
# Calculate the expected character count of the formatted messages
messages.map do |msg|
"[#{msg.created_at.strftime('%Y-%m-%d %H:%M')}] John Doe: #{msg.content}"
end
# Verify the result is within the character limit
expect(result.length).to be <= described_class::ACTIVITY_NOTE_MAX_SIZE + 100
# Verify that not all messages are included (some were truncated)
expect(messages.count).to be > result.scan(/John Doe:/).count
end
it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do
# Create a single message that would exceed the limit by itself
giant_content = 'A' * 2000
message = create(:message,
conversation: conversation,
sender: user,
content: giant_content,
message_type: :outgoing)
result = described_class.map_transcript_activity(conversation, [message])
# Extract just the formatted messages part
id = conversation.display_id
prefix = "Conversation Transcript from TestBrand\nChannel: Test Inbox\nConversation ID: #{id}\nView in TestBrand: "
formatted_messages = result.sub(prefix, '').sub(%r{http://.*}, '')
# Check that it's under the limit (with some tolerance for the message format)
expect(formatted_messages.length).to be <= described_class::ACTIVITY_NOTE_MAX_SIZE + 100
end
end
end
end

View File

@@ -0,0 +1,233 @@
require 'rails_helper'
RSpec.describe Crm::Leadsquared::ProcessorService do
let(:account) { create(:account) }
let(:hook) do
create(:integrations_hook, :leadsquared, account: account, settings: {
'access_key' => 'test_access_key',
'secret_key' => 'test_secret_key',
'endpoint_url' => 'https://api.leadsquared.com/v2',
'enable_transcript_activity' => true,
'enable_conversation_activity' => true,
'conversation_activity_code' => 1001,
'transcript_activity_code' => 1002
})
end
let(:contact) { create(:contact, account: account, email: 'test@example.com', phone_number: '+1234567890') }
let(:contact_with_social_profile) do
create(:contact, account: account, additional_attributes: { 'social_profiles' => { 'facebook' => 'chatwootapp' } })
end
let(:blank_contact) { create(:contact, account: account, email: '', phone_number: '') }
let(:conversation) { create(:conversation, account: account, contact: contact) }
let(:service) { described_class.new(hook) }
let(:lead_client) { instance_double(Crm::Leadsquared::Api::LeadClient) }
let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) }
let(:lead_finder) { instance_double(Crm::Leadsquared::LeadFinderService) }
before do
account.enable_features('crm_integration')
allow(Crm::Leadsquared::Api::LeadClient).to receive(:new)
.with('test_access_key', 'test_secret_key', 'https://api.leadsquared.com/v2')
.and_return(lead_client)
allow(Crm::Leadsquared::Api::ActivityClient).to receive(:new)
.with('test_access_key', 'test_secret_key', 'https://api.leadsquared.com/v2')
.and_return(activity_client)
allow(Crm::Leadsquared::LeadFinderService).to receive(:new)
.with(lead_client)
.and_return(lead_finder)
end
describe '.crm_name' do
it 'returns leadsquared' do
expect(described_class.crm_name).to eq('leadsquared')
end
end
describe '#handle_contact' do
context 'when contact is valid' do
before do
allow(service).to receive(:identifiable_contact?).and_return(true)
end
context 'when contact has no stored lead ID' do
before do
contact.update(additional_attributes: { 'external' => nil })
contact.reload
allow(lead_client).to receive(:create_or_update_lead)
.with(any_args)
.and_return('new_lead_id')
end
it 'creates a new lead and stores the ID' do
service.handle_contact(contact)
expect(lead_client).to have_received(:create_or_update_lead).with(any_args)
expect(contact.reload.additional_attributes['external']['leadsquared_id']).to eq('new_lead_id')
end
end
context 'when contact has existing lead ID' do
before do
contact.additional_attributes = { 'external' => { 'leadsquared_id' => 'existing_lead_id' } }
contact.save!
allow(lead_client).to receive(:update_lead)
.with(any_args)
.and_return(nil) # The update method doesn't need to return anything
end
it 'updates the lead using existing ID' do
service.handle_contact(contact)
expect(lead_client).to have_received(:update_lead).with(any_args)
end
end
context 'when API call raises an error' do
before do
allow(lead_client).to receive(:create_or_update_lead)
.with(any_args)
.and_raise(Crm::Leadsquared::Api::BaseClient::ApiError.new('API Error'))
allow(Rails.logger).to receive(:error)
end
it 'catches and logs the error' do
service.handle_contact(contact)
expect(Rails.logger).to have_received(:error).with(/LeadSquared API error/)
end
end
end
context 'when contact is invalid' do
before do
allow(service).to receive(:identifiable_contact?).and_return(false)
allow(lead_client).to receive(:create_or_update_lead)
end
it 'returns without making API calls' do
service.handle_contact(blank_contact)
expect(lead_client).not_to have_received(:create_or_update_lead)
end
end
end
describe '#handle_conversation_created' do
let(:activity_note) { 'New conversation started' }
before do
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_conversation_activity)
.with(conversation)
.and_return(activity_note)
end
context 'when conversation activities are enabled' do
before do
service.instance_variable_set(:@allow_conversation, true)
end
context 'when lead_id is found' do
before do
allow(lead_finder).to receive(:find_or_create)
.with(contact)
.and_return('test_lead_id')
allow(activity_client).to receive(:post_activity)
.with('test_lead_id', 1001, activity_note)
.and_return('test_activity_id')
end
it 'creates the activity and stores metadata' do
service.handle_conversation_created(conversation)
expect(conversation.reload.additional_attributes['leadsquared']['created_activity_id']).to eq('test_activity_id')
end
end
context 'when post_activity raises an error' do
before do
allow(lead_finder).to receive(:find_or_create)
.with(contact)
.and_return('test_lead_id')
allow(activity_client).to receive(:post_activity)
.with('test_lead_id', 1001, activity_note)
.and_raise(StandardError.new('Activity error'))
allow(Rails.logger).to receive(:error)
end
it 'logs the error' do
service.handle_conversation_created(conversation)
expect(Rails.logger).to have_received(:error).with(/Error creating conversation activity/)
end
end
end
context 'when conversation activities are disabled' do
before do
service.instance_variable_set(:@allow_conversation, false)
allow(activity_client).to receive(:post_activity)
end
it 'does not create an activity' do
service.handle_conversation_created(conversation)
expect(activity_client).not_to have_received(:post_activity)
end
end
end
describe '#handle_conversation_resolved' do
let(:activity_note) { 'Conversation transcript' }
before do
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity)
.with(conversation)
.and_return(activity_note)
end
context 'when transcript activities are enabled and conversation is resolved' do
before do
service.instance_variable_set(:@allow_transcript, true)
conversation.update!(status: 'resolved')
allow(lead_finder).to receive(:find_or_create)
.with(contact)
.and_return('test_lead_id')
allow(activity_client).to receive(:post_activity)
.with('test_lead_id', 1002, activity_note)
.and_return('test_activity_id')
end
it 'creates the transcript activity and stores metadata' do
service.handle_conversation_resolved(conversation)
expect(conversation.reload.additional_attributes['leadsquared']['transcript_activity_id']).to eq('test_activity_id')
end
end
context 'when conversation is not resolved' do
before do
service.instance_variable_set(:@allow_transcript, true)
conversation.update!(status: 'open')
allow(activity_client).to receive(:post_activity)
end
it 'does not create an activity' do
service.handle_conversation_resolved(conversation)
expect(activity_client).not_to have_received(:post_activity)
end
end
context 'when transcript activities are disabled' do
before do
service.instance_variable_set(:@allow_transcript, false)
conversation.update!(status: 'resolved')
allow(activity_client).to receive(:post_activity)
end
it 'does not create an activity' do
service.handle_conversation_resolved(conversation)
expect(activity_client).not_to have_received(:post_activity)
end
end
end
end

View File

@@ -0,0 +1,122 @@
require 'rails_helper'
RSpec.describe Crm::Leadsquared::SetupService do
let(:account) { create(:account) }
let(:hook) { create(:integrations_hook, :leadsquared, account: account) }
let(:service) { described_class.new(hook) }
let(:base_client) { instance_double(Crm::Leadsquared::Api::BaseClient) }
let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) }
let(:endpoint_response) do
{
'LSQCommonServiceURLs' => {
'api' => 'api-in.leadsquared.com',
'app' => 'app.leadsquared.com'
}
}
end
before do
account.enable_features('crm_integration')
allow(Crm::Leadsquared::Api::BaseClient).to receive(:new).and_return(base_client)
allow(Crm::Leadsquared::Api::ActivityClient).to receive(:new).and_return(activity_client)
allow(base_client).to receive(:get).with('Authentication.svc/UserByAccessKey.Get').and_return(endpoint_response)
end
describe '#setup' do
context 'when fetching activity types succeeds' do
let(:started_type) do
{ 'ActivityEventName' => 'Chatwoot Conversation Started', 'ActivityEvent' => 1001 }
end
let(:transcript_type) do
{ 'ActivityEventName' => 'Chatwoot Conversation Transcript', 'ActivityEvent' => 1002 }
end
context 'when all required types exist' do
before do
allow(activity_client).to receive(:fetch_activity_types)
.and_return([started_type, transcript_type])
end
it 'uses existing activity types and updates hook settings' do
service.setup
# Verify hook settings were merged with existing settings
updated_settings = hook.reload.settings
expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/')
expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/')
expect(updated_settings['conversation_activity_code']).to eq(1001)
expect(updated_settings['transcript_activity_code']).to eq(1002)
end
end
context 'when some activity types need to be created' do
before do
allow(activity_client).to receive(:fetch_activity_types)
.and_return([started_type])
allow(activity_client).to receive(:create_activity_type)
.with(
name: 'Chatwoot Conversation Transcript',
score: 0,
direction: 0
)
.and_return(1002)
end
it 'creates missing types and updates hook settings' do
service.setup
# Verify hook settings were merged with existing settings
updated_settings = hook.reload.settings
expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/')
expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/')
expect(updated_settings['conversation_activity_code']).to eq(1001)
expect(updated_settings['transcript_activity_code']).to eq(1002)
end
end
context 'when activity type creation fails' do
before do
allow(activity_client).to receive(:fetch_activity_types)
.and_return([started_type])
allow(activity_client).to receive(:create_activity_type)
.with(anything)
.and_raise(StandardError.new('Failed to create activity type'))
allow(Rails.logger).to receive(:error)
end
it 'logs the error and returns nil' do
expect(service.setup).to be_nil
expect(Rails.logger).to have_received(:error).with(/Error during LeadSquared setup/)
end
end
end
end
describe '#activity_types' do
it 'defines conversation started activity type' do
required_types = service.send(:activity_types)
conversation_type = required_types.find { |t| t[:setting_key] == 'conversation_activity_code' }
expect(conversation_type).to include(
name: 'Chatwoot Conversation Started',
score: 0,
direction: 0,
setting_key: 'conversation_activity_code'
)
end
it 'defines transcript activity type' do
required_types = service.send(:activity_types)
transcript_type = required_types.find { |t| t[:setting_key] == 'transcript_activity_code' }
expect(transcript_type).to include(
name: 'Chatwoot Conversation Transcript',
score: 0,
direction: 0,
setting_key: 'transcript_activity_code'
)
end
end
end