mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +00:00
feat: integrate LeadSquared CRM (#11284)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
36
app/jobs/crm/setup_job.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
92
app/services/crm/base_processor_service.rb
Normal file
92
app/services/crm/base_processor_service.rb
Normal 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
|
||||
36
app/services/crm/leadsquared/api/activity_client.rb
Normal file
36
app/services/crm/leadsquared/api/activity_client.rb
Normal 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
|
||||
84
app/services/crm/leadsquared/api/base_client.rb
Normal file
84
app/services/crm/leadsquared/api/base_client.rb
Normal 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
|
||||
50
app/services/crm/leadsquared/api/lead_client.rb
Normal file
50
app/services/crm/leadsquared/api/lead_client.rb
Normal 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
|
||||
59
app/services/crm/leadsquared/lead_finder_service.rb
Normal file
59
app/services/crm/leadsquared/lead_finder_service.rb
Normal 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
|
||||
35
app/services/crm/leadsquared/mappers/contact_mapper.rb
Normal file
35
app/services/crm/leadsquared/mappers/contact_mapper.rb
Normal 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
|
||||
108
app/services/crm/leadsquared/mappers/conversation_mapper.rb
Normal file
108
app/services/crm/leadsquared/mappers/conversation_mapper.rb
Normal 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
|
||||
121
app/services/crm/leadsquared/processor_service.rb
Normal file
121
app/services/crm/leadsquared/processor_service.rb
Normal 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
|
||||
108
app/services/crm/leadsquared/setup_service.rb
Normal file
108
app/services/crm/leadsquared/setup_service.rb
Normal 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
|
||||
@@ -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?
|
||||
|
||||
@@ -166,3 +166,6 @@
|
||||
- name: channel_instagram
|
||||
display_name: Instagram Channel
|
||||
enabled: true
|
||||
- name: crm_integration
|
||||
display_name: CRM Integration
|
||||
enabled: false
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
public/dashboard/images/integrations/leadsquared-dark.png
Normal file
BIN
public/dashboard/images/integrations/leadsquared-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/dashboard/images/integrations/leadsquared.png
Normal file
BIN
public/dashboard/images/integrations/leadsquared.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
85
spec/jobs/crm/setup_job_spec.rb
Normal file
85
spec/jobs/crm/setup_job_spec.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
217
spec/services/crm/leadsquared/api/activity_client_spec.rb
Normal file
217
spec/services/crm/leadsquared/api/activity_client_spec.rb
Normal 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
|
||||
187
spec/services/crm/leadsquared/api/base_client_spec.rb
Normal file
187
spec/services/crm/leadsquared/api/base_client_spec.rb
Normal 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
|
||||
231
spec/services/crm/leadsquared/api/lead_client_spec.rb
Normal file
231
spec/services/crm/leadsquared/api/lead_client_spec.rb
Normal 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
|
||||
98
spec/services/crm/leadsquared/lead_finder_service_spec.rb
Normal file
98
spec/services/crm/leadsquared/lead_finder_service_spec.rb
Normal 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
|
||||
34
spec/services/crm/leadsquared/mappers/contact_mapper_spec.rb
Normal file
34
spec/services/crm/leadsquared/mappers/contact_mapper_spec.rb
Normal 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
|
||||
@@ -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
|
||||
233
spec/services/crm/leadsquared/processor_service_spec.rb
Normal file
233
spec/services/crm/leadsquared/processor_service_spec.rb
Normal 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
|
||||
122
spec/services/crm/leadsquared/setup_service_spec.rb
Normal file
122
spec/services/crm/leadsquared/setup_service_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user