mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	feat: Custom Attributes for contacts (#1158)
Co-authored-by: Pranav Raj Sreepuram <pranavrajs@gmail.com>
This commit is contained in:
		| @@ -33,7 +33,8 @@ class ContactIdentifyAction | ||||
|   end | ||||
|  | ||||
|   def update_contact | ||||
|     @contact.update!(params.slice(:name, :email, :identifier)) | ||||
|     custom_attributes = params[:custom_attributes] ? @contact.custom_attributes.merge(params[:custom_attributes]) : @contact.custom_attributes | ||||
|     @contact.update!(params.slice(:name, :email, :identifier).merge({ custom_attributes: custom_attributes })) | ||||
|     ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -12,14 +12,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController | ||||
|  | ||||
|   def create | ||||
|     ActiveRecord::Base.transaction do | ||||
|       @contact = Current.account.contacts.new(contact_create_params) | ||||
|       @contact = Current.account.contacts.new(contact_params) | ||||
|       @contact.save! | ||||
|       @contact_inbox = build_contact_inbox | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def update | ||||
|     @contact.update!(contact_params) | ||||
|     @contact.update!(contact_update_params) | ||||
|   end | ||||
|  | ||||
|   def search | ||||
| @@ -43,14 +43,21 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController | ||||
|   end | ||||
|  | ||||
|   def contact_params | ||||
|     params.require(:contact).permit(:name, :email, :phone_number) | ||||
|     params.require(:contact).permit(:name, :email, :phone_number, custom_attributes: {}) | ||||
|   end | ||||
|  | ||||
|   def contact_custom_attributes | ||||
|     return @contact.custom_attributes.merge(contact_params[:custom_attributes]) if contact_params[:custom_attributes] | ||||
|  | ||||
|     @contact.custom_attributes | ||||
|   end | ||||
|  | ||||
|   def contact_update_params | ||||
|     # we want the merged custom attributes not the original one | ||||
|     contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes }) | ||||
|   end | ||||
|  | ||||
|   def fetch_contact | ||||
|     @contact = Current.account.contacts.find(params[:id]) | ||||
|   end | ||||
|  | ||||
|   def contact_create_params | ||||
|     params.require(:contact).permit(:name, :email, :phone_number) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -10,6 +10,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController | ||||
|   private | ||||
|  | ||||
|   def permitted_params | ||||
|     params.permit(:website_token, :identifier, :email, :name, :avatar_url) | ||||
|     params.permit(:website_token, :identifier, :email, :name, :avatar_url, custom_attributes: {}) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -9,6 +9,9 @@ | ||||
|       "NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.", | ||||
|       "TITLE": "Previous Conversations" | ||||
|     }, | ||||
|     "CUSTOM_ATTRIBUTES": { | ||||
|       "TITLE": "Custom Attributes" | ||||
|     }, | ||||
|     "LABELS": { | ||||
|       "TITLE": "Conversation Labels", | ||||
|       "MODAL": { | ||||
|   | ||||
| @@ -0,0 +1,59 @@ | ||||
| <template> | ||||
|   <div class="custom-attributes--panel"> | ||||
|     <contact-details-item | ||||
|       :title="$t('CONTACT_PANEL.CUSTOM_ATTRIBUTES.TITLE')" | ||||
|       icon="ion-code" | ||||
|     /> | ||||
|     <div | ||||
|       v-for="attribute in listOfAttributes" | ||||
|       :key="attribute" | ||||
|       class="custom-attribute--row" | ||||
|     > | ||||
|       <div class="custom-attribute--row__attribute"> | ||||
|         {{ attribute }} | ||||
|       </div> | ||||
|       <div> | ||||
|         {{ customAttributes[attribute] }} | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import ContactDetailsItem from './ContactDetailsItem.vue'; | ||||
|  | ||||
| export default { | ||||
|   components: { | ||||
|     ContactDetailsItem, | ||||
|   }, | ||||
|   props: { | ||||
|     customAttributes: { | ||||
|       type: Object, | ||||
|       default: () => ({}), | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     listOfAttributes() { | ||||
|       return Object.keys(this.customAttributes).filter(key => { | ||||
|         const value = this.customAttributes[key]; | ||||
|         return value !== null && value !== undefined && value !== ''; | ||||
|       }); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .custom-attributes--panel { | ||||
|   border-top: 1px solid var(--b-100); | ||||
|   padding: var(--space-normal); | ||||
| } | ||||
|  | ||||
| .custom-attribute--row { | ||||
|   margin-bottom: var(--space-small); | ||||
| } | ||||
|  | ||||
| .custom-attribute--row__attribute { | ||||
|   font-weight: 500; | ||||
| } | ||||
| </style> | ||||
| @@ -36,7 +36,7 @@ export default { | ||||
| @import '~dashboard/assets/scss/mixins'; | ||||
|  | ||||
| .conv-details--item { | ||||
|   padding-bottom: $space-normal; | ||||
|   padding-bottom: var(--space-slab); | ||||
|  | ||||
|   &:last-child { | ||||
|     padding-bottom: 0; | ||||
|   | ||||
| @@ -84,6 +84,10 @@ | ||||
|         icon="ion-clock" | ||||
|       /> | ||||
|     </div> | ||||
|     <contact-custom-attributes | ||||
|       v-if="hasContactAttributes" | ||||
|       :custom-attributes="contact.custom_attributes" | ||||
|     /> | ||||
|     <conversation-labels :conversation-id="conversationId" /> | ||||
|     <contact-conversations | ||||
|       v-if="contact.id" | ||||
| @@ -99,9 +103,11 @@ import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; | ||||
| import ContactConversations from './ContactConversations.vue'; | ||||
| import ContactDetailsItem from './ContactDetailsItem.vue'; | ||||
| import ConversationLabels from './labels/LabelBox.vue'; | ||||
| import ContactCustomAttributes from './ContactCustomAttributes'; | ||||
|  | ||||
| export default { | ||||
|   components: { | ||||
|     ContactCustomAttributes, | ||||
|     ContactConversations, | ||||
|     ContactDetailsItem, | ||||
|     ConversationLabels, | ||||
| @@ -129,6 +135,10 @@ export default { | ||||
|     additionalAttributes() { | ||||
|       return this.currentConversationMetaData.additional_attributes || {}; | ||||
|     }, | ||||
|     hasContactAttributes() { | ||||
|       const { custom_attributes: customAttributes } = this.contact; | ||||
|       return customAttributes && Object.keys(customAttributes).length; | ||||
|     }, | ||||
|     browser() { | ||||
|       return this.additionalAttributes.browser || {}; | ||||
|     }, | ||||
|   | ||||
| @@ -32,6 +32,24 @@ const runSDK = ({ baseUrl, websiteToken }) => { | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     setCustomAttributes(customAttributes = {}) { | ||||
|       if (!customAttributes || !Object.keys(customAttributes).length) { | ||||
|         throw new Error('Custom attributes should have atleast one key'); | ||||
|       } else { | ||||
|         IFrameHelper.sendMessage('set-custom-attributes', { customAttributes }); | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     deleteCustomAttribute(customAttribute = '') { | ||||
|       if (!customAttribute) { | ||||
|         throw new Error('Custom attribute is required'); | ||||
|       } else { | ||||
|         IFrameHelper.sendMessage('delete-custom-attribute', { | ||||
|           customAttribute, | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     setLabel(label = '') { | ||||
|       IFrameHelper.sendMessage('set-label', { label }); | ||||
|     }, | ||||
|   | ||||
| @@ -15,8 +15,6 @@ | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| /* global bus */ | ||||
|  | ||||
| import Vue from 'vue'; | ||||
| import { mapGetters, mapActions } from 'vuex'; | ||||
| import { setHeader } from 'widget/helpers/axios'; | ||||
| @@ -99,6 +97,15 @@ export default { | ||||
|         this.$store.dispatch('conversationLabels/destroy', message.label); | ||||
|       } else if (message.event === 'set-user') { | ||||
|         this.$store.dispatch('contacts/update', message); | ||||
|       } else if (message.event === 'set-custom-attributes') { | ||||
|         this.$store.dispatch( | ||||
|           'contacts/setCustomAttributes', | ||||
|           message.customAttributes | ||||
|         ); | ||||
|       } else if (message.event === 'delete-custom-attribute') { | ||||
|         this.$store.dispatch('contacts/setCustomAttributes', { | ||||
|           [message.customAttribute]: null, | ||||
|         }); | ||||
|       } else if (message.event === 'set-locale') { | ||||
|         this.setLocale(message.locale); | ||||
|         this.setBubbleLabel(); | ||||
|   | ||||
| @@ -9,4 +9,9 @@ export default { | ||||
|       ...userObject, | ||||
|     }); | ||||
|   }, | ||||
|   setCustomAttibutes(customAttributes = {}) { | ||||
|     return API.patch(buildUrl('widget/contact'), { | ||||
|       custom_attributes: customAttributes, | ||||
|     }); | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -17,6 +17,13 @@ export const actions = { | ||||
|       // Ingore error | ||||
|     } | ||||
|   }, | ||||
|   setCustomAttributes: async (_, customAttributes = {}) => { | ||||
|     try { | ||||
|       await ContactsAPI.setCustomAttibutes(customAttributes); | ||||
|     } catch (error) { | ||||
|       // Ingore error | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
| # | ||||
| #  id                    :integer          not null, primary key | ||||
| #  additional_attributes :jsonb | ||||
| #  custom_attributes     :jsonb | ||||
| #  email                 :string | ||||
| #  identifier            :string | ||||
| #  name                  :string | ||||
| @@ -68,12 +69,12 @@ class Contact < ApplicationRecord | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def downcase_email | ||||
|     email.downcase! if email.present? | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def dispatch_create_event | ||||
|     Rails.configuration.dispatcher.dispatch(CONTACT_CREATED, Time.zone.now, contact: self) | ||||
|   end | ||||
|   | ||||
| @@ -5,6 +5,7 @@ json.id resource.id | ||||
| json.name resource.name | ||||
| json.phone_number resource.phone_number | ||||
| json.thumbnail resource.avatar_url | ||||
| json.custom_attributes resource.custom_attributes | ||||
|  | ||||
| # we only want to output contact inbox when its /contacts endpoints | ||||
| if defined?(with_contact_inboxes) && with_contact_inboxes.present? | ||||
|   | ||||
| @@ -0,0 +1,5 @@ | ||||
| class AddCustomAttributesToContacts < ActiveRecord::Migration[6.0] | ||||
|   def change | ||||
|     add_column :contacts, :custom_attributes, :jsonb, default: {} | ||||
|   end | ||||
| end | ||||
| @@ -10,7 +10,7 @@ | ||||
| # | ||||
| # It's strongly recommended that you check this file into your version control system. | ||||
|  | ||||
| ActiveRecord::Schema.define(version: 2020_08_02_170002) do | ||||
| ActiveRecord::Schema.define(version: 2020_08_19_190629) do | ||||
|  | ||||
|   # These are extensions that must be enabled in order to support this database | ||||
|   enable_extension "pg_stat_statements" | ||||
| @@ -204,6 +204,7 @@ ActiveRecord::Schema.define(version: 2020_08_02_170002) do | ||||
|     t.string "pubsub_token" | ||||
|     t.jsonb "additional_attributes" | ||||
|     t.string "identifier" | ||||
|     t.jsonb "custom_attributes", default: {} | ||||
|     t.index ["account_id"], name: "index_contacts_on_account_id" | ||||
|     t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true | ||||
|     t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true | ||||
|   | ||||
| @@ -66,6 +66,31 @@ window.$chatwoot.setUser('identifier_key', { | ||||
|  | ||||
| Make sure that you reset the session when the user logs out of your app. | ||||
|  | ||||
| ### Set custom attributes | ||||
|  | ||||
| Inorder to set additional information about the customer you can use customer attributes field. | ||||
|  | ||||
| To set a custom attributes call `setCustomAttributes` as follows | ||||
|  | ||||
| ```js | ||||
| window.$chatwoot.setCustomAttributes({ | ||||
|   accountId: 1, | ||||
|   pricingPlan: 'paid', | ||||
|  | ||||
|   // You can pass any key value pair here. | ||||
|   // Value should either be a string or a number. | ||||
|   // You need to flatten nested JSON structure while using this function | ||||
| }); | ||||
| ``` | ||||
|  | ||||
| You can view these information in the sidepanel of a conversation. | ||||
|  | ||||
| To delete a custom attribute, use `deleteCustomAttribute` as follows | ||||
|  | ||||
| ```js | ||||
| window.$chatwoot.deleteCustomAttribute('attribute-name'); | ||||
| ``` | ||||
|  | ||||
| ### To set language manually | ||||
|  | ||||
| ```js | ||||
|   | ||||
| @@ -4,14 +4,17 @@ describe ::ContactIdentifyAction do | ||||
|   subject(:contact_identify) { described_class.new(contact: contact, params: params).perform } | ||||
|  | ||||
|   let!(:account) { create(:account) } | ||||
|   let!(:contact) { create(:contact, account: account) } | ||||
|   let(:params) { { name: 'test', identifier: 'test_id' } } | ||||
|   let(:custom_attributes) { { test: 'test', test1: 'test1' } } | ||||
|   let!(:contact) { create(:contact, account: account, custom_attributes: custom_attributes) } | ||||
|   let(:params) { { name: 'test', identifier: 'test_id', custom_attributes: { test: 'new test', test2: 'test2' } } } | ||||
|  | ||||
|   describe '#perform' do | ||||
|     it 'updates the contact' do | ||||
|       expect(ContactAvatarJob).not_to receive(:perform_later).with(contact, params[:avatar_url]) | ||||
|       contact_identify | ||||
|       expect(contact.reload.name).to eq 'test' | ||||
|       # custom attributes are merged properly without overwritting existing ones | ||||
|       expect(contact.custom_attributes).to eq({ 'test' => 'new test', 'test1' => 'test1', 'test2' => 'test2' }) | ||||
|       expect(contact.reload.identifier).to eq 'test_id' | ||||
|     end | ||||
|  | ||||
|   | ||||
| @@ -83,7 +83,8 @@ RSpec.describe 'Contacts API', type: :request do | ||||
|   end | ||||
|  | ||||
|   describe 'POST /api/v1/accounts/{account.id}/contacts' do | ||||
|     let(:valid_params) { { contact: { name: 'test' } } } | ||||
|     let(:custom_attributes) { { test: 'test', test1: 'test1' } } | ||||
|     let(:valid_params) { { contact: { name: 'test', custom_attributes: custom_attributes } } } | ||||
|  | ||||
|     context 'when it is an unauthenticated user' do | ||||
|       it 'returns unauthorized' do | ||||
| @@ -104,6 +105,10 @@ RSpec.describe 'Contacts API', type: :request do | ||||
|         end.to change(Contact, :count).by(1) | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|  | ||||
|         # custom attributes are updated | ||||
|         json_response = JSON.parse(response.body) | ||||
|         expect(json_response['payload']['contact']['custom_attributes']).to eq({ 'test' => 'test', 'test1' => 'test1' }) | ||||
|       end | ||||
|  | ||||
|       it 'creates the contact identifier when inbox id is passed' do | ||||
| @@ -118,8 +123,9 @@ RSpec.describe 'Contacts API', type: :request do | ||||
|   end | ||||
|  | ||||
|   describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id' do | ||||
|     let!(:contact) { create(:contact, account: account) } | ||||
|     let(:valid_params) { { contact: { name: 'Test Blub' } } } | ||||
|     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' } } } | ||||
|  | ||||
|     context 'when it is an unauthenticated user' do | ||||
|       it 'returns unauthorized' do | ||||
| @@ -140,7 +146,9 @@ RSpec.describe 'Contacts API', type: :request do | ||||
|               as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expect(Contact.last.name).to eq('Test Blub') | ||||
|         expect(contact.reload.name).to eq('Test Blub') | ||||
|         # custom attributes are merged properly without overwritting existing ones | ||||
|         expect(contact.custom_attributes).to eq({ 'test' => 'new test', 'test1' => 'test1', 'test2' => 'test2' }) | ||||
|       end | ||||
|  | ||||
|       it 'prevents the update of contact of another account' do | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Sojan Jose
					Sojan Jose