Adds a company selector to the contact details form so agents can associate a contact with an existing company directly from the contact page. Closes - None Why Contacts already expose company information through the CRM fields, but the form only accepted free-text company names. As we split company CRM work into smaller PRs, this keeps the contact page aligned with the structured company model while preserving the existing company-name behavior used by automations. What changed - Shows a company dropdown in the contact details form when the Companies feature is enabled. - Keeps legacy free-text company names editable when a contact has no structured `company_id`. - Allows Enterprise contact create/update APIs to accept account-scoped `company_id`. - Syncs `additional_attributes.company_name` when a contact is associated with a company, including the existing email-domain auto-association path. - Serializes `company_id` in the contact model payload so the form can show the current association. How to test 1. Enable Companies for an account and open a contact details page. 2. In Edit contact details, use the Company field to select an existing company. 3. Save the contact and refresh the page. 4. Confirm the selected company remains visible and the contact is associated with that company. 5. Confirm contacts with only a legacy free-text company name still show the text input instead of an empty selector. --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
219 lines
7.7 KiB
Ruby
219 lines
7.7 KiB
Ruby
class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|
include Sift
|
|
sort_on :email, type: :string
|
|
sort_on :name, internal_name: :order_on_name, type: :scope, scope_params: [:direction]
|
|
sort_on :phone_number, type: :string
|
|
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
|
|
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
|
|
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
|
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
|
|
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
|
|
|
|
RESULTS_PER_PAGE = 15
|
|
|
|
before_action :check_authorization
|
|
before_action :set_current_page, only: [:index, :active, :search, :filter]
|
|
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
|
|
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
|
|
|
|
def index
|
|
@contacts = fetch_contacts(resolved_contacts)
|
|
@contacts_count = @contacts.total_count
|
|
end
|
|
|
|
def search
|
|
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
|
|
|
contacts = Current.account.contacts.where(
|
|
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
|
search: "%#{params[:q].strip}%"
|
|
)
|
|
@contacts = fetch_contacts_with_has_more(contacts)
|
|
end
|
|
|
|
def import
|
|
render json: { error: I18n.t('errors.contacts.import.failed') }, status: :unprocessable_entity and return if params[:import_file].blank?
|
|
|
|
ActiveRecord::Base.transaction do
|
|
import = Current.account.data_imports.create!(data_type: 'contacts')
|
|
import.import_file.attach(params[:import_file])
|
|
end
|
|
|
|
head :ok
|
|
end
|
|
|
|
def export
|
|
column_names = params['column_names']
|
|
filter_params = { :payload => params.permit!['payload'], :label => params.permit!['label'] }
|
|
Account::ContactsExportJob.perform_later(Current.account.id, Current.user.id, column_names, filter_params)
|
|
head :ok, message: I18n.t('errors.contacts.export.success')
|
|
end
|
|
|
|
# returns online contacts
|
|
def active
|
|
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
|
|
.get_available_contact_ids(Current.account.id))
|
|
@contacts = fetch_contacts(contacts)
|
|
@contacts_count = @contacts.total_count
|
|
end
|
|
|
|
def show; end
|
|
|
|
def filter
|
|
result = ::Contacts::FilterService.new(Current.account, Current.user, params.permit!).perform
|
|
contacts = result[:contacts]
|
|
@contacts_count = result[:count]
|
|
@contacts = fetch_contacts(contacts)
|
|
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
|
CustomExceptions::CustomFilter::InvalidOperator,
|
|
CustomExceptions::CustomFilter::InvalidQueryOperator,
|
|
CustomExceptions::CustomFilter::InvalidValue => e
|
|
render_could_not_create_error(e.message)
|
|
end
|
|
|
|
def contactable_inboxes
|
|
@all_contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
|
|
@contactable_inboxes = @all_contactable_inboxes.select { |contactable_inbox| policy(contactable_inbox[:inbox]).show? }
|
|
end
|
|
|
|
# TODO : refactor this method into dedicated contacts/custom_attributes controller class and routes
|
|
def destroy_custom_attributes
|
|
@contact.custom_attributes = @contact.custom_attributes.excluding(params[:custom_attributes])
|
|
@contact.save!
|
|
end
|
|
|
|
def create
|
|
ActiveRecord::Base.transaction do
|
|
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
|
|
@contact.save!
|
|
@contact_inbox = build_contact_inbox
|
|
process_avatar_from_url
|
|
end
|
|
end
|
|
|
|
def update
|
|
@contact.assign_attributes(contact_update_params)
|
|
@contact.save!
|
|
process_avatar_from_url
|
|
end
|
|
|
|
def destroy
|
|
if ::OnlineStatusTracker.get_presence(
|
|
@contact.account.id, 'Contact', @contact.id
|
|
)
|
|
return render_error({ message: I18n.t('contacts.online.delete', contact_name: @contact.name.capitalize) },
|
|
:unprocessable_entity)
|
|
end
|
|
|
|
@contact.destroy!
|
|
head :ok
|
|
end
|
|
|
|
def avatar
|
|
@contact.avatar.purge if @contact.avatar.attached?
|
|
@contact
|
|
end
|
|
|
|
private
|
|
|
|
# TODO: Move this to a finder class
|
|
def resolved_contacts
|
|
return @resolved_contacts if @resolved_contacts
|
|
|
|
@resolved_contacts = Current.account.contacts.resolved_contacts(use_crm_v2: Current.account.feature_enabled?('crm_v2'))
|
|
|
|
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
|
|
@resolved_contacts
|
|
end
|
|
|
|
def set_current_page
|
|
@current_page = params[:page] || 1
|
|
end
|
|
|
|
def fetch_contacts(contacts)
|
|
# Build includes hash to avoid separate query when contact_inboxes are needed
|
|
includes_hash = { avatar_attachment: [:blob] }
|
|
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
|
|
|
|
filtrate(contacts)
|
|
.includes(includes_hash)
|
|
.page(@current_page)
|
|
.per(RESULTS_PER_PAGE)
|
|
end
|
|
|
|
def fetch_contacts_with_has_more(contacts)
|
|
includes_hash = { avatar_attachment: [:blob] }
|
|
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
|
|
|
|
# Calculate offset manually to fetch one extra record for has_more check
|
|
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
|
|
results = filtrate(contacts)
|
|
.includes(includes_hash)
|
|
.offset(offset)
|
|
.limit(RESULTS_PER_PAGE + 1)
|
|
.to_a
|
|
|
|
@has_more = results.size > RESULTS_PER_PAGE
|
|
results = results.first(RESULTS_PER_PAGE) if @has_more
|
|
@contacts_count = results.size
|
|
results
|
|
end
|
|
|
|
def build_contact_inbox
|
|
return if params[:inbox_id].blank?
|
|
|
|
inbox = Current.account.inboxes.find(params[:inbox_id])
|
|
ContactInboxBuilder.new(
|
|
contact: @contact,
|
|
inbox: inbox,
|
|
source_id: params[:source_id]
|
|
).perform
|
|
end
|
|
|
|
def permitted_params
|
|
params.permit(:name, :identifier, :email, :phone_number, :avatar, :blocked, :avatar_url, additional_attributes: {}, custom_attributes: {})
|
|
end
|
|
|
|
def contact_custom_attributes
|
|
return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes]
|
|
|
|
@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
|
|
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
|
|
@include_contact_inboxes = if params[:include_contact_inboxes].present?
|
|
params[:include_contact_inboxes] == 'true'
|
|
else
|
|
true
|
|
end
|
|
end
|
|
|
|
def fetch_contact
|
|
contact_scope = Current.account.contacts
|
|
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
|
|
@contact = contact_scope.find(params[:id])
|
|
end
|
|
|
|
def process_avatar_from_url
|
|
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
|
end
|
|
|
|
def render_error(error, error_status)
|
|
render json: error, status: error_status
|
|
end
|
|
end
|
|
|
|
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
|