mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	feat: Update API for contact avatar (#4719)
Added the ability to update the contact's avatar via API and Dashboard. - Contact create and update APIs can now accept avatar attachment parameters [form data]. - Contact create and update endpoints can now accept the avatar_url parameter.[json] - API endpoint to remove a contact avatar. - Updated Contact create/edit UI components with avatar support Fixes: #3428
This commit is contained in:
		| @@ -12,7 +12,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController | |||||||
|  |  | ||||||
|   before_action :check_authorization |   before_action :check_authorization | ||||||
|   before_action :set_current_page, only: [:index, :active, :search, :filter] |   before_action :set_current_page, only: [:index, :active, :search, :filter] | ||||||
|   before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes] |   before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] | ||||||
|   before_action :set_include_contact_inboxes, only: [:index, :search, :filter] |   before_action :set_include_contact_inboxes, only: [:index, :search, :filter] | ||||||
|  |  | ||||||
|   def index |   def index | ||||||
| @@ -72,15 +72,17 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController | |||||||
|  |  | ||||||
|   def create |   def create | ||||||
|     ActiveRecord::Base.transaction do |     ActiveRecord::Base.transaction do | ||||||
|       @contact = Current.account.contacts.new(contact_params) |       @contact = Current.account.contacts.new(permitted_params.except(:avatar_url)) | ||||||
|       @contact.save! |       @contact.save! | ||||||
|       @contact_inbox = build_contact_inbox |       @contact_inbox = build_contact_inbox | ||||||
|  |       process_avatar | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def update |   def update | ||||||
|     @contact.assign_attributes(contact_update_params) |     @contact.assign_attributes(contact_update_params) | ||||||
|     @contact.save! |     @contact.save! | ||||||
|  |     process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present? | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def destroy |   def destroy | ||||||
| @@ -95,6 +97,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController | |||||||
|     head :ok |     head :ok | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def avatar | ||||||
|  |     @contact.avatar.purge if @contact.avatar.attached? | ||||||
|  |     @contact | ||||||
|  |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|   # TODO: Move this to a finder class |   # TODO: Move this to a finder class | ||||||
| @@ -131,19 +138,19 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController | |||||||
|     ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id) |     ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def contact_params |   def permitted_params | ||||||
|     params.require(:contact).permit(:name, :identifier, :email, :phone_number, additional_attributes: {}, custom_attributes: {}) |     params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, additional_attributes: {}, custom_attributes: {}) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def contact_custom_attributes |   def contact_custom_attributes | ||||||
|     return @contact.custom_attributes.merge(contact_params[:custom_attributes]) if contact_params[:custom_attributes] |     return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes] | ||||||
|  |  | ||||||
|     @contact.custom_attributes |     @contact.custom_attributes | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def contact_update_params |   def contact_update_params | ||||||
|     # we want the merged custom attributes not the original one |     # we want the merged custom attributes not the original one | ||||||
|     contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes }) |     permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes }) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def set_include_contact_inboxes |   def set_include_contact_inboxes | ||||||
| @@ -158,6 +165,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController | |||||||
|     @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) |     @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def process_avatar | ||||||
|  |     if permitted_params[:avatar].blank? && permitted_params[:avatar_url].present? | ||||||
|  |       ::ContactAvatarJob.perform_later(@contact, params[:avatar_url]) | ||||||
|  |     elsif permitted_params[:avatar].blank? && permitted_params[:email].present? | ||||||
|  |       hash = Digest::MD5.hexdigest(params[:email]) | ||||||
|  |       gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404" | ||||||
|  |       ::ContactAvatarJob.perform_later(@contact, gravatar_url) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def render_error(error, error_status) |   def render_error(error, error_status) | ||||||
|     render json: error, status: error_status |     render json: error, status: error_status | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -71,6 +71,10 @@ class ContactAPI extends ApiClient { | |||||||
|       custom_attributes: customAttributes, |       custom_attributes: customAttributes, | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   destroyAvatar(contactId) { | ||||||
|  |     return axios.delete(`${this.url}/${contactId}/avatar`); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| export default new ContactAPI(); | export default new ContactAPI(); | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ describe('#ContactsAPI', () => { | |||||||
|     expect(contactAPI).toHaveProperty('delete'); |     expect(contactAPI).toHaveProperty('delete'); | ||||||
|     expect(contactAPI).toHaveProperty('getConversations'); |     expect(contactAPI).toHaveProperty('getConversations'); | ||||||
|     expect(contactAPI).toHaveProperty('filter'); |     expect(contactAPI).toHaveProperty('filter'); | ||||||
|  |     expect(contactAPI).toHaveProperty('destroyAvatar'); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   describeWithAPIMock('API calls', context => { |   describeWithAPIMock('API calls', context => { | ||||||
| @@ -100,6 +101,13 @@ describe('#ContactsAPI', () => { | |||||||
|         queryPayload |         queryPayload | ||||||
|       ); |       ); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     it('#destroyAvatar', () => { | ||||||
|  |       contactAPI.destroyAvatar(1); | ||||||
|  |       expect(context.axiosMock.delete).toHaveBeenCalledWith( | ||||||
|  |         '/api/v1/contacts/1/avatar' | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| <template> | <template> | ||||||
|   <button |   <button | ||||||
|     class="button" |     class="button" | ||||||
|  |     :type="type" | ||||||
|     :class="buttonClasses" |     :class="buttonClasses" | ||||||
|     :disabled="isDisabled || isLoading" |     :disabled="isDisabled || isLoading" | ||||||
|     @click="handleClick" |     @click="handleClick" | ||||||
| @@ -24,6 +25,10 @@ export default { | |||||||
|   name: 'WootButton', |   name: 'WootButton', | ||||||
|   components: { EmojiOrIcon, Spinner }, |   components: { EmojiOrIcon, Spinner }, | ||||||
|   props: { |   props: { | ||||||
|  |     type: { | ||||||
|  |       type: String, | ||||||
|  |       default: 'submit', | ||||||
|  |     }, | ||||||
|     variant: { |     variant: { | ||||||
|       type: String, |       type: String, | ||||||
|       default: '', |       default: '', | ||||||
|   | |||||||
| @@ -3,12 +3,18 @@ | |||||||
|     <label> |     <label> | ||||||
|       <span v-if="label">{{ label }}</span> |       <span v-if="label">{{ label }}</span> | ||||||
|     </label> |     </label> | ||||||
|     <woot-thumbnail v-if="src" size="80px" :src="src" /> |     <woot-thumbnail | ||||||
|  |       v-if="src" | ||||||
|  |       size="80px" | ||||||
|  |       :src="src" | ||||||
|  |       :username="usernameAvatar" | ||||||
|  |     /> | ||||||
|     <div v-if="src && deleteAvatar" class="avatar-delete-btn"> |     <div v-if="src && deleteAvatar" class="avatar-delete-btn"> | ||||||
|       <woot-button |       <woot-button | ||||||
|         color-scheme="alert" |         color-scheme="alert" | ||||||
|         variant="hollow" |         variant="hollow" | ||||||
|         size="tiny" |         size="tiny" | ||||||
|  |         type="button" | ||||||
|         @click="onAvatarDelete" |         @click="onAvatarDelete" | ||||||
|       > |       > | ||||||
|         {{ this.$t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT') }} |         {{ this.$t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT') }} | ||||||
| @@ -38,6 +44,10 @@ export default { | |||||||
|       type: String, |       type: String, | ||||||
|       default: '', |       default: '', | ||||||
|     }, |     }, | ||||||
|  |     usernameAvatar: { | ||||||
|  |       type: String, | ||||||
|  |       default: '', | ||||||
|  |     }, | ||||||
|     deleteAvatar: { |     deleteAvatar: { | ||||||
|       type: Boolean, |       type: Boolean, | ||||||
|       default: false, |       default: false, | ||||||
| @@ -50,7 +60,7 @@ export default { | |||||||
|  |  | ||||||
|       this.$emit('change', { |       this.$emit('change', { | ||||||
|         file, |         file, | ||||||
|         url: URL.createObjectURL(file), |         url: file ? URL.createObjectURL(file) : null, | ||||||
|       }); |       }); | ||||||
|     }, |     }, | ||||||
|     onAvatarDelete() { |     onAvatarDelete() { | ||||||
|   | |||||||
| @@ -148,6 +148,12 @@ | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "DELETE_AVATAR": { | ||||||
|  |       "API": { | ||||||
|  |         "SUCCESS_MESSAGE": "Contact avatar deleted successfully", | ||||||
|  |         "ERROR_MESSAGE": "Could not delete the contact avatar. Please try again later." | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "SUCCESS_MESSAGE": "Contact saved successfully", |     "SUCCESS_MESSAGE": "Contact saved successfully", | ||||||
|     "ERROR_MESSAGE": "There was an error, please try again" |     "ERROR_MESSAGE": "There was an error, please try again" | ||||||
|   }, |   }, | ||||||
|   | |||||||
| @@ -1,5 +1,18 @@ | |||||||
| <template> | <template> | ||||||
|   <form class="contact--form" @submit.prevent="handleSubmit"> |   <form class="contact--form" @submit.prevent="handleSubmit"> | ||||||
|  |     <div class="row"> | ||||||
|  |       <div class="columns"> | ||||||
|  |         <woot-avatar-uploader | ||||||
|  |           :label="$t('CONTACT_FORM.FORM.AVATAR.LABEL')" | ||||||
|  |           :src="avatarUrl" | ||||||
|  |           :username-avatar="name" | ||||||
|  |           :delete-avatar="!!avatarUrl" | ||||||
|  |           class="settings-item" | ||||||
|  |           @change="handleImageUpload" | ||||||
|  |           @onAvatarDelete="handleAvatarDelete" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|     <div class="row"> |     <div class="row"> | ||||||
|       <div class="columns"> |       <div class="columns"> | ||||||
|         <label :class="{ error: $v.name.$error }"> |         <label :class="{ error: $v.name.$error }"> | ||||||
| @@ -129,6 +142,8 @@ export default { | |||||||
|       email: '', |       email: '', | ||||||
|       name: '', |       name: '', | ||||||
|       phoneNumber: '', |       phoneNumber: '', | ||||||
|  |       avatarFile: null, | ||||||
|  |       avatarUrl: '', | ||||||
|       socialProfileUserNames: { |       socialProfileUserNames: { | ||||||
|         facebook: '', |         facebook: '', | ||||||
|         twitter: '', |         twitter: '', | ||||||
| @@ -186,6 +201,7 @@ export default { | |||||||
|       this.phoneNumber = phoneNumber || ''; |       this.phoneNumber = phoneNumber || ''; | ||||||
|       this.companyName = additionalAttributes.company_name || ''; |       this.companyName = additionalAttributes.company_name || ''; | ||||||
|       this.description = additionalAttributes.description || ''; |       this.description = additionalAttributes.description || ''; | ||||||
|  |       this.avatarUrl = this.contact.thumbnail || ''; | ||||||
|       const { |       const { | ||||||
|         social_profiles: socialProfiles = {}, |         social_profiles: socialProfiles = {}, | ||||||
|         screen_name: twitterScreenName, |         screen_name: twitterScreenName, | ||||||
| @@ -198,7 +214,7 @@ export default { | |||||||
|       }; |       }; | ||||||
|     }, |     }, | ||||||
|     getContactObject() { |     getContactObject() { | ||||||
|       return { |       const contactObject = { | ||||||
|         id: this.contact.id, |         id: this.contact.id, | ||||||
|         name: this.name, |         name: this.name, | ||||||
|         email: this.email, |         email: this.email, | ||||||
| @@ -210,6 +226,11 @@ export default { | |||||||
|           social_profiles: this.socialProfileUserNames, |           social_profiles: this.socialProfileUserNames, | ||||||
|         }, |         }, | ||||||
|       }; |       }; | ||||||
|  |       if (this.avatarFile) { | ||||||
|  |         contactObject.avatar = this.avatarFile; | ||||||
|  |         contactObject.isFormData = true; | ||||||
|  |       } | ||||||
|  |       return contactObject; | ||||||
|     }, |     }, | ||||||
|     async handleSubmit() { |     async handleSubmit() { | ||||||
|       this.$v.$touch(); |       this.$v.$touch(); | ||||||
| @@ -237,6 +258,28 @@ export default { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     handleImageUpload({ file, url }) { | ||||||
|  |       this.avatarFile = file; | ||||||
|  |       this.avatarUrl = url; | ||||||
|  |     }, | ||||||
|  |     async handleAvatarDelete() { | ||||||
|  |       try { | ||||||
|  |         if (this.contact && this.contact.id) { | ||||||
|  |           await this.$store.dispatch('contacts/deleteAvatar', this.contact.id); | ||||||
|  |           this.showAlert( | ||||||
|  |             this.$t('CONTACT_FORM.DELETE_AVATAR.API.SUCCESS_MESSAGE') | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |         this.avatarFile = null; | ||||||
|  |         this.avatarUrl = ''; | ||||||
|  |       } catch (error) { | ||||||
|  |         this.showAlert( | ||||||
|  |           error.message | ||||||
|  |             ? error.message | ||||||
|  |             : this.$t('CONTACT_FORM.DELETE_AVATAR.API.ERROR_MESSAGE') | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -6,6 +6,33 @@ import types from '../../mutation-types'; | |||||||
| import ContactAPI from '../../../api/contacts'; | import ContactAPI from '../../../api/contacts'; | ||||||
| import AccountActionsAPI from '../../../api/accountActions'; | import AccountActionsAPI from '../../../api/accountActions'; | ||||||
|  |  | ||||||
|  | const buildContactFormData = contactParams => { | ||||||
|  |   const formData = new FormData(); | ||||||
|  |   const { additional_attributes = {}, ...contactProperties } = contactParams; | ||||||
|  |   Object.keys(contactProperties).forEach(key => { | ||||||
|  |     if (contactProperties[key]) { | ||||||
|  |       formData.append(key, contactProperties[key]); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |   const { | ||||||
|  |     social_profiles, | ||||||
|  |     ...additionalAttributesProperties | ||||||
|  |   } = additional_attributes; | ||||||
|  |   Object.keys(additionalAttributesProperties).forEach(key => { | ||||||
|  |     formData.append( | ||||||
|  |       `additional_attributes[${key}]`, | ||||||
|  |       additionalAttributesProperties[key] | ||||||
|  |     ); | ||||||
|  |   }); | ||||||
|  |   Object.keys(social_profiles).forEach(key => { | ||||||
|  |     formData.append( | ||||||
|  |       `additional_attributes[social_profiles][${key}]`, | ||||||
|  |       social_profiles[key] | ||||||
|  |     ); | ||||||
|  |   }); | ||||||
|  |   return formData; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export const actions = { | export const actions = { | ||||||
|   search: async ({ commit }, { search, page, sortAttr, label }) => { |   search: async ({ commit }, { search, page, sortAttr, label }) => { | ||||||
|     commit(types.SET_CONTACT_UI_FLAG, { isFetching: true }); |     commit(types.SET_CONTACT_UI_FLAG, { isFetching: true }); | ||||||
| @@ -52,10 +79,13 @@ export const actions = { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|   update: async ({ commit }, { id, ...updateObj }) => { |   update: async ({ commit }, { id, isFormData = false, ...contactParams }) => { | ||||||
|     commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true }); |     commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true }); | ||||||
|     try { |     try { | ||||||
|       const response = await ContactAPI.update(id, updateObj); |       const response = await ContactAPI.update( | ||||||
|  |         id, | ||||||
|  |         isFormData ? buildContactFormData(contactParams) : contactParams | ||||||
|  |       ); | ||||||
|       commit(types.EDIT_CONTACT, response.data.payload); |       commit(types.EDIT_CONTACT, response.data.payload); | ||||||
|       commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false }); |       commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false }); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
| @@ -68,10 +98,12 @@ export const actions = { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|   create: async ({ commit }, userObject) => { |   create: async ({ commit }, { isFormData = false, ...contactParams }) => { | ||||||
|     commit(types.SET_CONTACT_UI_FLAG, { isCreating: true }); |     commit(types.SET_CONTACT_UI_FLAG, { isCreating: true }); | ||||||
|     try { |     try { | ||||||
|       const response = await ContactAPI.create(userObject); |       const response = await ContactAPI.create( | ||||||
|  |         isFormData ? buildContactFormData(contactParams) : contactParams | ||||||
|  |       ); | ||||||
|       commit(types.SET_CONTACT_ITEM, response.data.payload.contact); |       commit(types.SET_CONTACT_ITEM, response.data.payload.contact); | ||||||
|       commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); |       commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
| @@ -83,6 +115,7 @@ export const actions = { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|   import: async ({ commit }, file) => { |   import: async ({ commit }, file) => { | ||||||
|     commit(types.SET_CONTACT_UI_FLAG, { isCreating: true }); |     commit(types.SET_CONTACT_UI_FLAG, { isCreating: true }); | ||||||
|     try { |     try { | ||||||
| @@ -95,6 +128,7 @@ export const actions = { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|   delete: async ({ commit }, id) => { |   delete: async ({ commit }, id) => { | ||||||
|     commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true }); |     commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true }); | ||||||
|     try { |     try { | ||||||
| @@ -122,6 +156,15 @@ export const actions = { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|  |   deleteAvatar: async ({ commit }, id) => { | ||||||
|  |     try { | ||||||
|  |       const response = await ContactAPI.destroyAvatar(id); | ||||||
|  |       commit(types.EDIT_CONTACT, response.data.payload); | ||||||
|  |     } catch (error) { | ||||||
|  |       throw new Error(error); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |  | ||||||
|   fetchContactableInbox: async ({ commit }, id) => { |   fetchContactableInbox: async ({ commit }, id) => { | ||||||
|     commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true }); |     commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true }); | ||||||
|     try { |     try { | ||||||
|   | |||||||
| @@ -73,7 +73,13 @@ describe('#actions', () => { | |||||||
|   describe('#update', () => { |   describe('#update', () => { | ||||||
|     it('sends correct mutations if API is success', async () => { |     it('sends correct mutations if API is success', async () => { | ||||||
|       axios.patch.mockResolvedValue({ data: { payload: contactList[0] } }); |       axios.patch.mockResolvedValue({ data: { payload: contactList[0] } }); | ||||||
|       await actions.update({ commit }, contactList[0]); |       await actions.update( | ||||||
|  |         { commit }, | ||||||
|  |         { | ||||||
|  |           id: contactList[0].id, | ||||||
|  |           contactParams: contactList[0], | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|       expect(commit.mock.calls).toEqual([ |       expect(commit.mock.calls).toEqual([ | ||||||
|         [types.SET_CONTACT_UI_FLAG, { isUpdating: true }], |         [types.SET_CONTACT_UI_FLAG, { isUpdating: true }], | ||||||
|         [types.EDIT_CONTACT, contactList[0]], |         [types.EDIT_CONTACT, contactList[0]], | ||||||
| @@ -101,9 +107,15 @@ describe('#actions', () => { | |||||||
|           }, |           }, | ||||||
|         }, |         }, | ||||||
|       }); |       }); | ||||||
|       await expect(actions.update({ commit }, contactList[0])).rejects.toThrow( |       await expect( | ||||||
|         DuplicateContactException |         actions.update( | ||||||
|       ); |           { commit }, | ||||||
|  |           { | ||||||
|  |             id: contactList[0].id, | ||||||
|  |             contactParams: contactList[0], | ||||||
|  |           } | ||||||
|  |         ) | ||||||
|  |       ).rejects.toThrow(DuplicateContactException); | ||||||
|       expect(commit.mock.calls).toEqual([ |       expect(commit.mock.calls).toEqual([ | ||||||
|         [types.SET_CONTACT_UI_FLAG, { isUpdating: true }], |         [types.SET_CONTACT_UI_FLAG, { isUpdating: true }], | ||||||
|         [types.SET_CONTACT_UI_FLAG, { isUpdating: false }], |         [types.SET_CONTACT_UI_FLAG, { isUpdating: false }], | ||||||
| @@ -116,7 +128,12 @@ describe('#actions', () => { | |||||||
|       axios.post.mockResolvedValue({ |       axios.post.mockResolvedValue({ | ||||||
|         data: { payload: { contact: contactList[0] } }, |         data: { payload: { contact: contactList[0] } }, | ||||||
|       }); |       }); | ||||||
|       await actions.create({ commit }, contactList[0]); |       await actions.create( | ||||||
|  |         { commit }, | ||||||
|  |         { | ||||||
|  |           contactParams: contactList[0], | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|       expect(commit.mock.calls).toEqual([ |       expect(commit.mock.calls).toEqual([ | ||||||
|         [types.SET_CONTACT_UI_FLAG, { isCreating: true }], |         [types.SET_CONTACT_UI_FLAG, { isCreating: true }], | ||||||
|         [types.SET_CONTACT_ITEM, contactList[0]], |         [types.SET_CONTACT_ITEM, contactList[0]], | ||||||
| @@ -142,9 +159,14 @@ describe('#actions', () => { | |||||||
|           }, |           }, | ||||||
|         }, |         }, | ||||||
|       }); |       }); | ||||||
|       await expect(actions.create({ commit }, contactList[0])).rejects.toThrow( |       await expect( | ||||||
|         ExceptionWithMessage |         actions.create( | ||||||
|       ); |           { commit }, | ||||||
|  |           { | ||||||
|  |             contactParams: contactList[0], | ||||||
|  |           } | ||||||
|  |         ) | ||||||
|  |       ).rejects.toThrow(ExceptionWithMessage); | ||||||
|       expect(commit.mock.calls).toEqual([ |       expect(commit.mock.calls).toEqual([ | ||||||
|         [types.SET_CONTACT_UI_FLAG, { isCreating: true }], |         [types.SET_CONTACT_UI_FLAG, { isCreating: true }], | ||||||
|         [types.SET_CONTACT_UI_FLAG, { isCreating: false }], |         [types.SET_CONTACT_UI_FLAG, { isCreating: false }], | ||||||
| @@ -299,4 +321,18 @@ describe('#actions', () => { | |||||||
|       expect(commit.mock.calls).toEqual([[types.CLEAR_CONTACT_FILTERS]]); |       expect(commit.mock.calls).toEqual([[types.CLEAR_CONTACT_FILTERS]]); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   describe('#deleteAvatar', () => { | ||||||
|  |     it('sends correct mutations if API is success', async () => { | ||||||
|  |       axios.delete.mockResolvedValue({ data: { payload: contactList[0] } }); | ||||||
|  |       await actions.deleteAvatar({ commit }, contactList[0].id); | ||||||
|  |       expect(commit.mock.calls).toEqual([[types.EDIT_CONTACT, contactList[0]]]); | ||||||
|  |     }); | ||||||
|  |     it('sends correct actions if API is error', async () => { | ||||||
|  |       axios.delete.mockRejectedValue({ message: 'Incorrect header' }); | ||||||
|  |       await expect( | ||||||
|  |         actions.deleteAvatar({ commit }, contactList[0].id) | ||||||
|  |       ).rejects.toThrow(Error); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -7,6 +7,8 @@ class ContactAvatarJob < ApplicationJob | |||||||
|       max_size: 15 * 1024 * 1024 |       max_size: 15 * 1024 * 1024 | ||||||
|     ) |     ) | ||||||
|     contact.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type) |     contact.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type) | ||||||
|  |   rescue Down::NotFound | ||||||
|  |     contact.avatar.attachment.destroy! if contact.avatar.attached? | ||||||
|   rescue Down::Error => e |   rescue Down::Error => e | ||||||
|     Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}" |     Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}" | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -39,6 +39,10 @@ class ContactPolicy < ApplicationPolicy | |||||||
|     true |     true | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def avatar? | ||||||
|  |     true | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def destroy? |   def destroy? | ||||||
|     @account_user.administrator? |     @account_user.administrator? | ||||||
|   end |   end | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								app/views/api/v1/accounts/contacts/avatar.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								app/views/api/v1/accounts/contacts/avatar.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | json.payload do | ||||||
|  |   json.partial! 'api/v1/models/contact.json.jbuilder', resource: @contact, with_contact_inboxes: false | ||||||
|  | end | ||||||
| @@ -95,6 +95,7 @@ Rails.application.routes.draw do | |||||||
|             member do |             member do | ||||||
|               get :contactable_inboxes |               get :contactable_inboxes | ||||||
|               post :destroy_custom_attributes |               post :destroy_custom_attributes | ||||||
|  |               delete :avatar | ||||||
|             end |             end | ||||||
|             scope module: :contacts do |             scope module: :contacts do | ||||||
|               resources :conversations, only: [:index] |               resources :conversations, only: [:index] | ||||||
|   | |||||||
| @@ -360,7 +360,7 @@ RSpec.describe 'Contacts API', type: :request do | |||||||
|  |  | ||||||
|   describe 'POST /api/v1/accounts/{account.id}/contacts' do |   describe 'POST /api/v1/accounts/{account.id}/contacts' do | ||||||
|     let(:custom_attributes) { { test: 'test', test1: 'test1' } } |     let(:custom_attributes) { { test: 'test', test1: 'test1' } } | ||||||
|     let(:valid_params) { { contact: { name: 'test', custom_attributes: custom_attributes } } } |     let(:valid_params) { { name: 'test', custom_attributes: custom_attributes } } | ||||||
|  |  | ||||||
|     context 'when it is an unauthenticated user' do |     context 'when it is an unauthenticated user' do | ||||||
|       it 'returns unauthorized' do |       it 'returns unauthorized' do | ||||||
| @@ -388,7 +388,7 @@ RSpec.describe 'Contacts API', type: :request do | |||||||
|       end |       end | ||||||
|  |  | ||||||
|       it 'does not create the contact' do |       it 'does not create the contact' do | ||||||
|         valid_params[:contact][:name] = 'test' * 999 |         valid_params[:name] = 'test' * 999 | ||||||
|  |  | ||||||
|         post "/api/v1/accounts/#{account.id}/contacts", headers: admin.create_new_auth_token, |         post "/api/v1/accounts/#{account.id}/contacts", headers: admin.create_new_auth_token, | ||||||
|                                                         params: valid_params |                                                         params: valid_params | ||||||
| @@ -413,7 +413,7 @@ RSpec.describe 'Contacts API', type: :request do | |||||||
|   describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id' do |   describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id' do | ||||||
|     let(:custom_attributes) { { test: 'test', test1: 'test1' } } |     let(:custom_attributes) { { test: 'test', test1: 'test1' } } | ||||||
|     let!(:contact) { create(:contact, account: account, custom_attributes: custom_attributes) } |     let!(:contact) { create(:contact, account: account, custom_attributes: custom_attributes) } | ||||||
|     let(:valid_params) { { contact: { name: 'Test Blub', custom_attributes: { test: 'new test', test2: 'test2' } } } } |     let(:valid_params) { { name: 'Test Blub', custom_attributes: { test: 'new test', test2: 'test2' } } } | ||||||
|  |  | ||||||
|     context 'when it is an unauthenticated user' do |     context 'when it is an unauthenticated user' do | ||||||
|       it 'returns unauthorized' do |       it 'returns unauthorized' do | ||||||
| @@ -456,7 +456,7 @@ RSpec.describe 'Contacts API', type: :request do | |||||||
|  |  | ||||||
|         patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", |         patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", | ||||||
|               headers: admin.create_new_auth_token, |               headers: admin.create_new_auth_token, | ||||||
|               params: valid_params[:contact].merge({ email: other_contact.email }), |               params: valid_params.merge({ email: other_contact.email }), | ||||||
|               as: :json |               as: :json | ||||||
|  |  | ||||||
|         expect(response).to have_http_status(:unprocessable_entity) |         expect(response).to have_http_status(:unprocessable_entity) | ||||||
| @@ -468,12 +468,25 @@ RSpec.describe 'Contacts API', type: :request do | |||||||
|  |  | ||||||
|         patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", |         patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", | ||||||
|               headers: admin.create_new_auth_token, |               headers: admin.create_new_auth_token, | ||||||
|               params: valid_params[:contact].merge({ phone_number: other_contact.phone_number }), |               params: valid_params.merge({ phone_number: other_contact.phone_number }), | ||||||
|               as: :json |               as: :json | ||||||
|  |  | ||||||
|         expect(response).to have_http_status(:unprocessable_entity) |         expect(response).to have_http_status(:unprocessable_entity) | ||||||
|         expect(JSON.parse(response.body)['attributes']).to include('phone_number') |         expect(JSON.parse(response.body)['attributes']).to include('phone_number') | ||||||
|       end |       end | ||||||
|  |  | ||||||
|  |       it 'updates avatar' do | ||||||
|  |         # no avatar before upload | ||||||
|  |         expect(contact.avatar.attached?).to eq(false) | ||||||
|  |         file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') | ||||||
|  |         patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", | ||||||
|  |               params: valid_params.merge(avatar: file), | ||||||
|  |               headers: admin.create_new_auth_token | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |         contact.reload | ||||||
|  |         expect(contact.avatar.attached?).to eq(true) | ||||||
|  |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -554,4 +567,33 @@ RSpec.describe 'Contacts API', type: :request do | |||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   describe 'DELETE /api/v1/accounts/{account.id}/contacts/:id/avatar' do | ||||||
|  |     let(:contact) { create(:contact, account: account) } | ||||||
|  |     let(:agent) { create(:user, account: account, role: :agent) } | ||||||
|  |  | ||||||
|  |     context 'when it is an unauthenticated user' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/avatar" | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when it is an authenticated user' do | ||||||
|  |       before do | ||||||
|  |         create(:contact, account: account) | ||||||
|  |         contact.avatar.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png') | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'delete contact avatar' do | ||||||
|  |         delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/avatar", | ||||||
|  |                headers: agent.create_new_auth_token, | ||||||
|  |                as: :json | ||||||
|  |  | ||||||
|  |         expect { contact.avatar.attachment.reload }.to raise_error(ActiveRecord::RecordNotFound) | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -3,9 +3,12 @@ | |||||||
| FactoryBot.define do | FactoryBot.define do | ||||||
|   factory :contact do |   factory :contact do | ||||||
|     sequence(:name) { |n| "Contact #{n}" } |     sequence(:name) { |n| "Contact #{n}" } | ||||||
|     avatar { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') } |  | ||||||
|     account |     account | ||||||
|  |  | ||||||
|  |     trait :with_avatar do | ||||||
|  |       avatar { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') } | ||||||
|  |     end | ||||||
|  |  | ||||||
|     trait :with_email do |     trait :with_email do | ||||||
|       sequence(:email) { |n| "contact-#{n}@example.com" } |       sequence(:email) { |n| "contact-#{n}@example.com" } | ||||||
|     end |     end | ||||||
|   | |||||||
| @@ -13,6 +13,12 @@ properties: | |||||||
|   phone_number: |   phone_number: | ||||||
|     type: string |     type: string | ||||||
|     description: phone number of the contact |     description: phone number of the contact | ||||||
|  |   avatar: | ||||||
|  |     type: string <binary> | ||||||
|  |     description: Send the form data with the avatar image binary or use the avatar_url | ||||||
|  |   avatar_url: | ||||||
|  |     type: string | ||||||
|  |     description: The url to a jpeg, png file for the contact avatar | ||||||
|   identifier: |   identifier: | ||||||
|     type: string |     type: string | ||||||
|     description: A unique identifier for the contact in external system |     description: A unique identifier for the contact in external system | ||||||
|   | |||||||
| @@ -9,7 +9,13 @@ properties: | |||||||
|   phone_number: |   phone_number: | ||||||
|     type: string |     type: string | ||||||
|     description: phone number of the contact |     description: phone number of the contact | ||||||
|   identifier:  |   avatar: | ||||||
|  |     type: string <binary> | ||||||
|  |     description: Send the form data with the avatar image binary or use the avatar_url | ||||||
|  |   avatar_url: | ||||||
|  |     type: string | ||||||
|  |     description: The url to a jpeg, png file for the contact avatar | ||||||
|  |   identifier: | ||||||
|     type: string |     type: string | ||||||
|     description: A unique identifier for the contact in external system |     description: A unique identifier for the contact in external system | ||||||
|   custom_attributes: |   custom_attributes: | ||||||
|   | |||||||
| @@ -5583,6 +5583,14 @@ | |||||||
|           "type": "string", |           "type": "string", | ||||||
|           "description": "phone number of the contact" |           "description": "phone number of the contact" | ||||||
|         }, |         }, | ||||||
|  |         "avatar": { | ||||||
|  |           "type": "string <binary>", | ||||||
|  |           "description": "Send the form data with the avatar image binary or use the avatar_url" | ||||||
|  |         }, | ||||||
|  |         "avatar_url": { | ||||||
|  |           "type": "string", | ||||||
|  |           "description": "The url to a jpeg, png file for the contact avatar" | ||||||
|  |         }, | ||||||
|         "identifier": { |         "identifier": { | ||||||
|           "type": "string", |           "type": "string", | ||||||
|           "description": "A unique identifier for the contact in external system" |           "description": "A unique identifier for the contact in external system" | ||||||
| @@ -5608,6 +5616,14 @@ | |||||||
|           "type": "string", |           "type": "string", | ||||||
|           "description": "phone number of the contact" |           "description": "phone number of the contact" | ||||||
|         }, |         }, | ||||||
|  |         "avatar": { | ||||||
|  |           "type": "string <binary>", | ||||||
|  |           "description": "Send the form data with the avatar image binary or use the avatar_url" | ||||||
|  |         }, | ||||||
|  |         "avatar_url": { | ||||||
|  |           "type": "string", | ||||||
|  |           "description": "The url to a jpeg, png file for the contact avatar" | ||||||
|  |         }, | ||||||
|         "identifier": { |         "identifier": { | ||||||
|           "type": "string", |           "type": "string", | ||||||
|           "description": "A unique identifier for the contact in external system" |           "description": "A unique identifier for the contact in external system" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 giquieu
					giquieu