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:
giquieu 2022-07-12 05:03:16 -03:00 committed by GitHub
parent 68fcd28751
commit 827f977a37
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 283 additions and 28 deletions

View file

@ -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

View file

@ -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();

View file

@ -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'
);
});
}); });
}); });

View file

@ -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: '',

View file

@ -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() {

View file

@ -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"
}, },

View file

@ -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>

View file

@ -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 {

View file

@ -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);
});
});
}); });

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
json.payload do
json.partial! 'api/v1/models/contact.json.jbuilder', resource: @contact, with_contact_inboxes: false
end

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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"