mirror of
https://github.com/lingble/chatwoot.git
synced 2025-12-06 21:05:29 +00:00
Merge branch 'develop' into feat/voice-channel
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -121,6 +121,8 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
||||
gem 'sidekiq', '>= 7.3.1'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
# for sidekiq healthcheck
|
||||
gem 'sidekiq_alive'
|
||||
|
||||
##-- Push notification service --##
|
||||
gem 'fcm'
|
||||
|
||||
17
Gemfile.lock
17
Gemfile.lock
@@ -361,6 +361,7 @@ GEM
|
||||
grpc (1.72.0-x86_64-linux)
|
||||
google-protobuf (>= 3.25, < 5.0)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
gserver (0.0.1)
|
||||
haikunator (1.1.1)
|
||||
hairtrigger (1.0.0)
|
||||
activerecord (>= 6.0, < 8)
|
||||
@@ -479,7 +480,7 @@ GEM
|
||||
mime-types-data (3.2023.0218.1)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
@@ -510,14 +511,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.18.8)
|
||||
nokogiri (1.18.9)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
nokogiri (1.18.9-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-darwin)
|
||||
nokogiri (1.18.9-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
nokogiri (1.18.9-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -787,6 +788,9 @@ GEM
|
||||
fugit (~> 1.8)
|
||||
globalid (>= 1.0.1)
|
||||
sidekiq (>= 6)
|
||||
sidekiq_alive (2.5.0)
|
||||
gserver (~> 0.0.1)
|
||||
sidekiq (>= 5, < 9)
|
||||
signet (0.17.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
@@ -825,7 +829,7 @@ GEM
|
||||
stripe (8.5.0)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.3.1)
|
||||
thor (1.4.0)
|
||||
tilt (2.3.0)
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
@@ -1012,6 +1016,7 @@ DEPENDENCIES
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
sidekiq_alive
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client (~> 2.5.2)
|
||||
spring
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# We don't want to update the name of the identified original contact.
|
||||
|
||||
class ContactIdentifyAction
|
||||
include UrlHelper
|
||||
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
|
||||
|
||||
def perform
|
||||
@@ -104,7 +105,14 @@ class ContactIdentifyAction
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save!
|
||||
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? && !@contact.avatar.attached?
|
||||
enqueue_avatar_job
|
||||
end
|
||||
|
||||
def enqueue_avatar_job
|
||||
return unless params[:avatar_url].present? && !@contact.avatar.attached?
|
||||
return unless url_valid?(params[:avatar_url])
|
||||
|
||||
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url])
|
||||
end
|
||||
|
||||
def merge_contact(base_contact, merge_contact)
|
||||
|
||||
@@ -122,7 +122,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
def resolved_contacts
|
||||
return @resolved_contacts if @resolved_contacts
|
||||
|
||||
@resolved_contacts = Current.account.contacts.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
|
||||
|
||||
@@ -69,6 +69,17 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
unless @inbox.channel.is_a?(Channel::Whatsapp)
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' }
|
||||
end
|
||||
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
|
||||
@@ -2,7 +2,7 @@ class MicrosoftController < ApplicationController
|
||||
after_action :set_version_header
|
||||
|
||||
def identity_association
|
||||
microsoft_indentity
|
||||
microsoft_identity
|
||||
end
|
||||
|
||||
private
|
||||
@@ -11,7 +11,7 @@ class MicrosoftController < ApplicationController
|
||||
response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length
|
||||
end
|
||||
|
||||
def microsoft_indentity
|
||||
def microsoft_identity
|
||||
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,6 +28,10 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
agent_bot: botId,
|
||||
});
|
||||
}
|
||||
|
||||
syncTemplates(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('#InboxesAPI', () => {
|
||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('syncTemplates');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -40,5 +41,12 @@ describe('#InboxesAPI', () => {
|
||||
inboxesAPI.deleteInboxAvatar(2);
|
||||
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
|
||||
});
|
||||
|
||||
it('#syncTemplates', () => {
|
||||
inboxesAPI.syncTemplates(2);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/2/sync_templates'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import DropdownBody from './base/DropdownBody.vue';
|
||||
import DropdownSection from './base/DropdownSection.vue';
|
||||
import DropdownItem from './base/DropdownItem.vue';
|
||||
import DropdownSeparator from './base/DropdownSeparator.vue';
|
||||
import WootSwitch from 'components/ui/Switch.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const currentUserAutoOffline = ref(false);
|
||||
|
||||
@@ -61,7 +61,7 @@ const menuItems = ref([
|
||||
<DropdownItem label="Contact Support" class="justify-between">
|
||||
<span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span>
|
||||
<div class="flex-shrink-0">
|
||||
<WootSwitch v-model="currentUserAutoOffline" />
|
||||
<ToggleSwitch v-model="currentUserAutoOffline" />
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'next/dropdown-menu/base';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
@@ -48,6 +49,16 @@ const activeStatus = computed(() => {
|
||||
return availabilityStatuses.value.find(status => status.active);
|
||||
});
|
||||
|
||||
const autoOfflineToggle = computed({
|
||||
get: () => currentUserAutoOffline.value,
|
||||
set: autoOffline => {
|
||||
store.dispatch('updateAutoOffline', {
|
||||
accountId: currentAccountId.value,
|
||||
autoOffline,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function changeAvailabilityStatus(availability) {
|
||||
if (isImpersonating.value) {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
|
||||
@@ -62,13 +73,6 @@ function changeAvailabilityStatus(availability) {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR'));
|
||||
}
|
||||
}
|
||||
|
||||
function updateAutoOffline(autoOffline) {
|
||||
store.dispatch('updateAutoOffline', {
|
||||
accountId: currentAccountId.value,
|
||||
autoOffline,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -118,11 +122,7 @@ function updateAutoOffline(autoOffline) {
|
||||
class="size-4 text-n-slate-10"
|
||||
/>
|
||||
</div>
|
||||
<woot-switch
|
||||
class="flex-shrink-0"
|
||||
:model-value="currentUserAutoOffline"
|
||||
@input="updateAutoOffline"
|
||||
/>
|
||||
<ToggleSwitch v-model="autoOfflineToggle" />
|
||||
</DropdownItem>
|
||||
</div>
|
||||
</DropdownSection>
|
||||
|
||||
@@ -19,8 +19,8 @@ const updateValue = () => {
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2"
|
||||
:class="modelValue ? 'bg-n-brand' : 'bg-n-alpha-1 dark:bg-n-alpha-2'"
|
||||
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
|
||||
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
@click="updateValue"
|
||||
|
||||
@@ -61,7 +61,9 @@ const onCopy = async e => {
|
||||
|
||||
<template>
|
||||
<div class="relative text-left">
|
||||
<div class="top-1.5 absolute right-1.5 flex items-center gap-1">
|
||||
<div
|
||||
class="top-1.5 absolute ltr:right-1.5 rtl:left-1.5 flex backdrop-blur-sm rounded-lg items-center gap-1"
|
||||
>
|
||||
<form
|
||||
v-if="enableCodePen"
|
||||
class="flex items-center"
|
||||
@@ -86,6 +88,11 @@ const onCopy = async e => {
|
||||
@click="onCopy"
|
||||
/>
|
||||
</div>
|
||||
<highlightjs v-if="script" :language="lang" :code="scrubbedScript" />
|
||||
<highlightjs
|
||||
v-if="script"
|
||||
:language="lang"
|
||||
:code="scrubbedScript"
|
||||
class="[&_code]:text-start"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
size: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue', 'input'],
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$emit('update:modelValue', !this.modelValue);
|
||||
this.$emit('input', !this.modelValue);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-button p-0"
|
||||
:class="{ active: modelValue, small: size === 'small' }"
|
||||
role="switch"
|
||||
:aria-checked="modelValue.toString()"
|
||||
@click="onClick"
|
||||
>
|
||||
<span aria-hidden="true" :class="{ active: modelValue }" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toggle-button {
|
||||
@apply bg-n-slate-5;
|
||||
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
|
||||
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
|
||||
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
|
||||
border-radius: 0.5625rem;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 1.188rem;
|
||||
position: relative;
|
||||
transition-duration: 200ms;
|
||||
transition-property: background-color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 2.125rem;
|
||||
|
||||
&.active {
|
||||
@apply bg-n-brand;
|
||||
}
|
||||
|
||||
&.small {
|
||||
width: 1.375rem;
|
||||
height: 0.875rem;
|
||||
|
||||
span {
|
||||
@apply size-2.5;
|
||||
|
||||
&.active {
|
||||
@apply ltr:translate-x-[0.5rem] ltr:translate-y-0 rtl:translate-x-[-0.5rem] rtl:translate-y-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
@apply bg-n-background;
|
||||
|
||||
border-radius: 100%;
|
||||
box-shadow: var(--toggle-button-box-shadow);
|
||||
display: inline-block;
|
||||
height: 0.9375rem;
|
||||
transform: translate(0, 0);
|
||||
transition-duration: 200ms;
|
||||
transition-property: transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 0.9375rem;
|
||||
|
||||
&.active {
|
||||
@apply ltr:translate-x-[0.9375rem] ltr:translate-y-0 rtl:translate-x-[-0.9375rem] rtl:translate-y-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -94,7 +94,7 @@ export default {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasWhatsappTemplates: {
|
||||
enableWhatsAppTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -333,7 +333,7 @@ export default {
|
||||
@click="toggleMessageSignature"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="hasWhatsappTemplates"
|
||||
v-if="enableWhatsAppTemplates"
|
||||
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
|
||||
@@ -184,9 +184,8 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
hasWhatsappTemplates() {
|
||||
return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
|
||||
.length;
|
||||
showWhatsappTemplates() {
|
||||
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||
@@ -1220,7 +1219,7 @@ export default {
|
||||
<ReplyBottomPanel
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:has-whatsapp-templates="hasWhatsappTemplates"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
// TODO: Remove this when we support all formats
|
||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
inboxId: {
|
||||
type: Number,
|
||||
@@ -13,6 +18,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
isRefreshing: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -37,14 +43,26 @@ export default {
|
||||
return template.components.find(component => component.type === 'BODY')
|
||||
.text;
|
||||
},
|
||||
async refreshTemplates() {
|
||||
this.isRefreshing = true;
|
||||
try {
|
||||
await this.$store.dispatch('inboxes/syncTemplates', this.inboxId);
|
||||
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex gap-2 mb-2.5">
|
||||
<div
|
||||
class="gap-1 bg-n-alpha-black2 items-center flex mb-2.5 py-0 px-2.5 rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
|
||||
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
|
||||
>
|
||||
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
|
||||
<input
|
||||
@@ -54,21 +72,34 @@ export default {
|
||||
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
:disabled="isRefreshing"
|
||||
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||
@click="refreshTemplates"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-refresh-ccw"
|
||||
class="text-n-slate-12 size-4"
|
||||
:class="{ 'animate-spin': isRefreshing }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
|
||||
>
|
||||
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
|
||||
<button
|
||||
class="rounded-lg cursor-pointer block p-2.5 text-left w-full hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
||||
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
||||
@click="$emit('onSelect', template)"
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2.5">
|
||||
<div class="flex justify-between items-center mb-2.5">
|
||||
<p class="text-sm">
|
||||
{{ template.name }}
|
||||
</p>
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none bg-n-slate-3 rounded-lg cursor-default text-n-slate-12"
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
|
||||
{{ template.language }}
|
||||
@@ -94,12 +125,19 @@ export default {
|
||||
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!filteredTemplateMessages.length">
|
||||
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
||||
<div v-if="query && whatsAppTemplateMessages.length">
|
||||
<p>
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
<strong>{{ query }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
|
||||
<p class="text-n-slate-11">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -600,6 +600,10 @@
|
||||
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
|
||||
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
|
||||
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"PICKER": {
|
||||
"SEARCH_PLACEHOLDER": "Search Templates",
|
||||
"NO_TEMPLATES_FOUND": "No templates found for",
|
||||
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
||||
"REFRESH_BUTTON": "Refresh templates",
|
||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||
"LABELS": {
|
||||
"LANGUAGE": "Language",
|
||||
"TEMPLATE_BODY": "Template Body",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { messageStamp } from 'shared/helpers/timeHelper';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const props = defineProps({
|
||||
automation: {
|
||||
@@ -19,14 +21,17 @@ const readableDate = date => messageStamp(new Date(date), 'LLL d, yyyy');
|
||||
const readableDateWithTime = date =>
|
||||
messageStamp(new Date(date), 'LLL d, yyyy hh:mm a');
|
||||
|
||||
const toggle = () => {
|
||||
const { id, name, active } = props.automation;
|
||||
const automationActive = computed({
|
||||
get: () => props.automation.active,
|
||||
set: active => {
|
||||
const { id, name } = props.automation;
|
||||
emit('toggle', {
|
||||
id,
|
||||
name,
|
||||
status: active,
|
||||
status: !active,
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,7 +39,7 @@ const toggle = () => {
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4 min-w-[200px]">{{ automation.name }}</td>
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ automation.description }}</td>
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4">
|
||||
<woot-switch :model-value="automation.active" @input="toggle" />
|
||||
<ToggleSwitch v-model="automationActive" />
|
||||
</td>
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 min-w-[12px]"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script>
|
||||
import Draggable from 'vuedraggable';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
export default {
|
||||
components: { Draggable },
|
||||
components: { Draggable, ToggleSwitch },
|
||||
props: {
|
||||
preChatFields: {
|
||||
type: Array,
|
||||
@@ -45,9 +46,9 @@ export default {
|
||||
<tr class="border-b border-n-weak">
|
||||
<td class="pre-chat-field"><fluent-icon icon="drag" /></td>
|
||||
<td class="pre-chat-field">
|
||||
<woot-switch
|
||||
<ToggleSwitch
|
||||
:model-value="item['enabled']"
|
||||
@input="handlePreChatFieldOptions($event, 'enabled', item)"
|
||||
@change="handlePreChatFieldOptions($event, 'enabled', item)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
||||
@@ -29,6 +29,7 @@ export default {
|
||||
return {
|
||||
hmacMandatory: false,
|
||||
whatsAppInboxAPIKey: '',
|
||||
isSyncingTemplates: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -83,6 +84,19 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async syncTemplates() {
|
||||
this.isSyncingTemplates = true;
|
||||
try {
|
||||
await this.$store.dispatch('inboxes/syncTemplates', this.inbox.id);
|
||||
useAlert(
|
||||
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isSyncingTemplates = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -137,7 +151,7 @@ export default {
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="hmacMandatory"
|
||||
v-model="hmacMandatory"
|
||||
@@ -169,7 +183,7 @@ export default {
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="hmacMandatory"
|
||||
v-model="hmacMandatory"
|
||||
@@ -215,12 +229,12 @@ export default {
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
|
||||
class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
|
||||
>
|
||||
<woot-input
|
||||
v-model="whatsAppInboxAPIKey"
|
||||
type="text"
|
||||
class="flex-1 mr-2"
|
||||
class="flex-1 mr-2 [&>input]:!mb-0"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
|
||||
@@ -235,6 +249,18 @@ export default {
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div class="flex justify-start items-center mt-2">
|
||||
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
verifyServiceWorkerExistence,
|
||||
} from 'dashboard/helper/pushHelper.js';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import FormSwitch from 'v3/components/Form/Switch.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import { NOTIFICATION_TYPES } from './constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TableHeaderCell,
|
||||
FormSwitch,
|
||||
ToggleSwitch,
|
||||
CheckBox,
|
||||
},
|
||||
data() {
|
||||
@@ -284,9 +284,9 @@ export default {
|
||||
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.BROWSER_PERMISSION') }}
|
||||
</span>
|
||||
</div>
|
||||
<FormSwitch
|
||||
:model-value="hasEnabledPushPermissions"
|
||||
@update:model-value="onRequestPermissions"
|
||||
<ToggleSwitch
|
||||
v-model="hasEnabledPushPermissions"
|
||||
@change="onRequestPermissions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import ReportsFiltersRatings from './Filters/Ratings.vue';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import { DATE_RANGE_OPTIONS } from '../constants';
|
||||
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -21,6 +22,7 @@ export default {
|
||||
ReportsFiltersInboxes,
|
||||
ReportsFiltersTeams,
|
||||
ReportsFiltersRatings,
|
||||
ToggleSwitch,
|
||||
},
|
||||
props: {
|
||||
showGroupByFilter: {
|
||||
@@ -106,11 +108,6 @@ export default {
|
||||
return this.validGroupOptions[0];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
businessHoursSelected() {
|
||||
this.emitChange();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.emitChange();
|
||||
},
|
||||
@@ -224,7 +221,7 @@ export default {
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<woot-switch v-model="businessHoursSelected" />
|
||||
<ToggleSwitch v-model="businessHoursSelected" @change="emitChange" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import startOfDay from 'date-fns/startOfDay';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
import { GROUP_BY_FILTER } from '../constants';
|
||||
const CUSTOM_DATE_RANGE_ID = 5;
|
||||
@@ -13,6 +14,7 @@ export default {
|
||||
components: {
|
||||
WootDateRangePicker,
|
||||
Thumbnail,
|
||||
ToggleSwitch,
|
||||
},
|
||||
props: {
|
||||
currentFilter: {
|
||||
@@ -125,9 +127,6 @@ export default {
|
||||
groupByFilterItemsList() {
|
||||
this.currentSelectedGroupByFilter = this.selectedGroupByFilter;
|
||||
},
|
||||
businessHoursSelected() {
|
||||
this.$emit('businessHoursToggle', this.businessHoursSelected);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.onDateRangeChange();
|
||||
@@ -140,6 +139,9 @@ export default {
|
||||
groupBy: this.groupBy,
|
||||
});
|
||||
},
|
||||
onBusinessHoursToggle() {
|
||||
this.$emit('businessHoursToggle', this.businessHoursSelected);
|
||||
},
|
||||
fromCustomDate(date) {
|
||||
return getUnixTime(startOfDay(date));
|
||||
},
|
||||
@@ -303,7 +305,10 @@ export default {
|
||||
{{ $t('REPORT.BUSINESS_HOURS') }}
|
||||
</span>
|
||||
<span>
|
||||
<woot-switch v-model="businessHoursSelected" />
|
||||
<ToggleSwitch
|
||||
v-model="businessHoursSelected"
|
||||
@change="onBusinessHoursToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ import validations from './validations';
|
||||
import SlaTimeInput from './SlaTimeInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SlaTimeInput,
|
||||
NextButton,
|
||||
ToggleSwitch,
|
||||
},
|
||||
props: {
|
||||
selectedResponse: {
|
||||
@@ -203,7 +205,7 @@ export default {
|
||||
<span for="sla_bh" class="text-n-slate-11">
|
||||
{{ $t('SLA.FORM.BUSINESS_HOURS.PLACEHOLDER') }}
|
||||
</span>
|
||||
<woot-switch id="sla_bh" v-model="onlyDuringBusinessHours" />
|
||||
<ToggleSwitch id="sla_bh" v-model="onlyDuringBusinessHours" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end w-full gap-2 mt-8">
|
||||
|
||||
@@ -317,6 +317,13 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
syncTemplates: async (_, inboxId) => {
|
||||
try {
|
||||
await InboxesAPI.syncTemplates(inboxId);
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -231,4 +231,28 @@ describe('#actions', () => {
|
||||
).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#syncTemplates', () => {
|
||||
it('sends correct API call when sync is successful', async () => {
|
||||
axios.post.mockResolvedValue({
|
||||
data: { message: 'Template sync initiated successfully' },
|
||||
});
|
||||
|
||||
await actions.syncTemplates({ commit }, 123);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/123/sync_templates'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error when API call fails', async () => {
|
||||
const errorMessage =
|
||||
'Template sync is only available for WhatsApp channels';
|
||||
axios.post.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
await expect(actions.syncTemplates({ commit }, 123)).rejects.toThrow(
|
||||
errorMessage
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import hljsVuePlugin from '@highlightjs/vue-plugin';
|
||||
|
||||
import Multiselect from 'vue-multiselect';
|
||||
import { plugin, defaultConfig } from '@formkit/vue';
|
||||
import WootSwitch from 'components/ui/Switch.vue';
|
||||
import WootWizard from 'components/ui/Wizard.vue';
|
||||
import FloatingVue from 'floating-vue';
|
||||
import WootUiKit from 'dashboard/components';
|
||||
@@ -90,7 +89,6 @@ app.use(FloatingVue, {
|
||||
app.use(hljsVuePlugin);
|
||||
|
||||
app.component('multiselect', Multiselect);
|
||||
app.component('woot-switch', WootSwitch);
|
||||
app.component('woot-wizard', WootWizard);
|
||||
app.component('fluent-icon', FluentIcon);
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$emit('update:modelValue', !this.modelValue);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex-shrink-0 h-4 p-0 border-none shadow-inner w-7 rounded-3xl"
|
||||
:class="
|
||||
modelValue ? 'bg-n-brand shadow-n-brand' : 'shadow-n-slate-6 bg-n-slate-5'
|
||||
"
|
||||
role="switch"
|
||||
:aria-checked="modelValue.toString()"
|
||||
@click="onClick"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="rounded-full bg-n-background top-0.5 absolute w-3 h-3 translate-y-0 duration-200 transition-transform ease-in-out"
|
||||
:class="
|
||||
modelValue
|
||||
? 'ltr:translate-x-0 rtl:translate-x-[0.75rem]'
|
||||
: 'ltr:-translate-x-[0.75rem] rtl:translate-x-0'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -29,9 +29,9 @@ class Account::ContactsExportJob < ApplicationJob
|
||||
result = ::Contacts::FilterService.new(@account, @account_user, @params).perform
|
||||
result[:contacts]
|
||||
elsif @params[:label].present?
|
||||
@account.contacts.resolved_contacts.tagged_with(@params[:label], any: true)
|
||||
@account.contacts.resolved_contacts(use_crm_v2: @account.feature_enabled?('crm_v2')).tagged_with(@params[:label], any: true)
|
||||
else
|
||||
@account.contacts.resolved_contacts
|
||||
@account.contacts.resolved_contacts(use_crm_v2: @account.feature_enabled?('crm_v2'))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Avatar::AvatarFromGravatarJob < ApplicationJob
|
||||
queue_as :low
|
||||
queue_as :purgable
|
||||
|
||||
def perform(avatarable, email)
|
||||
return if GlobalConfigService.load('DISABLE_GRAVATAR', '').present?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||
queue_as :low
|
||||
queue_as :purgable
|
||||
|
||||
def perform(avatarable, avatar_url)
|
||||
return unless avatarable.respond_to?(:avatar)
|
||||
|
||||
@@ -34,6 +34,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
|
||||
after_create :sync_templates
|
||||
after_create_commit :setup_webhooks
|
||||
before_destroy :teardown_webhooks
|
||||
|
||||
def name
|
||||
'Whatsapp'
|
||||
@@ -105,4 +106,8 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
# Don't raise the error to prevent channel creation from failing
|
||||
# Webhooks can be retried later
|
||||
end
|
||||
|
||||
def teardown_webhooks
|
||||
Whatsapp::WebhookTeardownService.new(self).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
# Indexes
|
||||
#
|
||||
# index_contacts_on_account_id (account_id)
|
||||
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
|
||||
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
|
||||
# index_contacts_on_blocked (blocked)
|
||||
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
|
||||
@@ -175,9 +176,13 @@ class Contact < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def self.resolved_contacts
|
||||
def self.resolved_contacts(use_crm_v2: false)
|
||||
if use_crm_v2
|
||||
where(contact_type: 'lead')
|
||||
else
|
||||
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
|
||||
end
|
||||
end
|
||||
|
||||
def discard_invalid_attrs
|
||||
phone_number_format
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# name :string not null
|
||||
# page_title :string
|
||||
# slug :string not null
|
||||
# ssl_settings :jsonb not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
@@ -69,3 +70,5 @@ class Portal < ApplicationRecord
|
||||
errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
|
||||
end
|
||||
end
|
||||
|
||||
Portal.include_mod_with('Concerns::Portal')
|
||||
|
||||
@@ -57,4 +57,8 @@ class InboxPolicy < ApplicationPolicy
|
||||
def avatar?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def sync_templates?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
class SearchService
|
||||
pattr_initialize [:current_user!, :current_account!, :params!, :search_type!]
|
||||
|
||||
def account_user
|
||||
@account_user ||= current_account.account_users.find_by(user: current_user)
|
||||
end
|
||||
|
||||
def perform
|
||||
case search_type
|
||||
when 'Message'
|
||||
@@ -78,8 +82,9 @@ class SearchService
|
||||
end
|
||||
|
||||
def message_base_query
|
||||
current_account.messages.where(inbox_id: accessable_inbox_ids)
|
||||
.where('created_at >= ?', 3.months.ago)
|
||||
query = current_account.messages.where('created_at >= ?', 3.months.ago)
|
||||
query = query.where(inbox_id: accessable_inbox_ids) unless account_user.administrator?
|
||||
query
|
||||
end
|
||||
|
||||
def use_gin_search
|
||||
@@ -90,7 +95,9 @@ class SearchService
|
||||
@contacts = current_account.contacts.where(
|
||||
"name ILIKE :search OR email ILIKE :search OR phone_number
|
||||
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
|
||||
).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
|
||||
).resolved_contacts(
|
||||
use_crm_v2: current_account.feature_enabled?('crm_v2')
|
||||
).order_on_last_activity_at('desc').page(params[:page]).per(15)
|
||||
end
|
||||
|
||||
def filter_articles
|
||||
|
||||
@@ -63,6 +63,15 @@ class Whatsapp::FacebookApiClient
|
||||
handle_response(response, 'Webhook subscription failed')
|
||||
end
|
||||
|
||||
def unsubscribe_waba_webhook(waba_id)
|
||||
response = HTTParty.delete(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers
|
||||
)
|
||||
|
||||
handle_response(response, 'Webhook unsubscription failed')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_headers
|
||||
|
||||
47
app/services/whatsapp/webhook_teardown_service.rb
Normal file
47
app/services/whatsapp/webhook_teardown_service.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class Whatsapp::WebhookTeardownService
|
||||
def initialize(channel)
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def perform
|
||||
return unless should_teardown_webhook?
|
||||
|
||||
teardown_webhook
|
||||
rescue StandardError => e
|
||||
handle_webhook_teardown_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_teardown_webhook?
|
||||
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
|
||||
end
|
||||
|
||||
def whatsapp_cloud_provider?
|
||||
@channel.provider == 'whatsapp_cloud'
|
||||
end
|
||||
|
||||
def embedded_signup_source?
|
||||
@channel.provider_config['source'] == 'embedded_signup'
|
||||
end
|
||||
|
||||
def webhook_config_present?
|
||||
@channel.provider_config['business_account_id'].present? &&
|
||||
@channel.provider_config['api_key'].present?
|
||||
end
|
||||
|
||||
def teardown_webhook
|
||||
waba_id = @channel.provider_config['business_account_id']
|
||||
access_token = @channel.provider_config['api_key']
|
||||
api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
|
||||
api_client.unsubscribe_waba_webhook(waba_id)
|
||||
Rails.logger.info "[WHATSAPP] Webhook unsubscribed successfully for channel #{@channel.id}"
|
||||
end
|
||||
|
||||
def handle_webhook_teardown_error(error)
|
||||
Rails.logger.error "[WHATSAPP] Webhook teardown failed: #{error.message}"
|
||||
# Don't raise the error to prevent channel deletion from failing
|
||||
# Failed webhook teardown shouldn't block deletion
|
||||
end
|
||||
end
|
||||
@@ -187,3 +187,7 @@
|
||||
- name: whatsapp_campaign
|
||||
display_name: WhatsApp Campaign
|
||||
enabled: false
|
||||
- name: crm_v2
|
||||
display_name: CRM V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
|
||||
@@ -416,3 +416,17 @@
|
||||
locked: false
|
||||
description: 'The redirect URI configured in your Google OAuth app'
|
||||
## ------ End of Configs added for Google OAuth ------ ##
|
||||
|
||||
## ------ Configs added for Cloudflare ------ ##
|
||||
- name: CLOUDFLARE_API_KEY
|
||||
display_title: 'Cloudflare API Key'
|
||||
value:
|
||||
locked: false
|
||||
description: 'API key for Cloudflare account authentication'
|
||||
type: secret
|
||||
- name: CLOUDFLARE_ZONE_ID
|
||||
display_title: 'Cloudflare Zone ID'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Zone ID for the Cloudflare domain'
|
||||
## ------ End of Configs added for Cloudflare ------ ##
|
||||
|
||||
@@ -196,6 +196,7 @@ Rails.application.routes.draw do
|
||||
get :agent_bot, on: :member
|
||||
post :set_agent_bot, on: :member
|
||||
delete :avatar, on: :member
|
||||
post :sync_templates, on: :member
|
||||
end
|
||||
|
||||
# Voice call management - using resource to avoid plural/singular confusion
|
||||
@@ -555,6 +556,7 @@ Rails.application.routes.draw do
|
||||
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
|
||||
get '.well-known/apple-app-site-association' => 'apple_app#site_association'
|
||||
get '.well-known/microsoft-identity-association.json' => 'microsoft#identity_association'
|
||||
get '.well-known/cf-custom-hostname-challenge/:id', to: 'custom_domains#verify'
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Internal Monitoring Routes
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- low
|
||||
- scheduled_jobs
|
||||
- deferred
|
||||
- purgable
|
||||
- housekeeping
|
||||
- async_database_migration
|
||||
- active_storage_analysis
|
||||
|
||||
5
db/migrate/20250722083820_add_ssl_settings_to_portals.rb
Normal file
5
db/migrate/20250722083820_add_ssl_settings_to_portals.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddSslSettingsToPortals < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :portals, :ssl_settings, :jsonb, default: {}, null: false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddIndexOnContactTypeAndAccountIdToContacts < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :contacts, [:account_id, :contact_type], name: 'index_contacts_on_account_id_and_contact_type', algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -539,6 +539,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
|
||||
t.string "country_code", default: ""
|
||||
t.boolean "blocked", default: false, null: false
|
||||
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
|
||||
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
|
||||
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
|
||||
t.index ["account_id"], name: "index_contacts_on_account_id"
|
||||
@@ -942,6 +943,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
|
||||
t.jsonb "config", default: {"allowed_locales" => ["en"]}
|
||||
t.boolean "archived", default: false
|
||||
t.bigint "channel_web_widget_id"
|
||||
t.jsonb "ssl_settings", default: {}, null: false
|
||||
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
|
||||
t.index ["custom_domain"], name: "index_portals_on_custom_domain", unique: true
|
||||
t.index ["slug"], name: "index_portals_on_slug", unique: true
|
||||
|
||||
22
enterprise/app/controllers/custom_domains_controller.rb
Normal file
22
enterprise/app/controllers/custom_domains_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class CustomDomainsController < ApplicationController
|
||||
def verify
|
||||
challenge_id = permitted_params[:id]
|
||||
|
||||
domain = request.host
|
||||
portal = Portal.find_by(custom_domain: domain)
|
||||
|
||||
return render plain: 'Domain not found', status: :not_found unless portal
|
||||
|
||||
ssl_settings = portal.ssl_settings || {}
|
||||
|
||||
return render plain: 'Challenge ID not found', status: :not_found unless ssl_settings['cf_verification_id'] == challenge_id
|
||||
|
||||
render plain: ssl_settings['cf_verification_body'], status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id)
|
||||
end
|
||||
end
|
||||
@@ -34,6 +34,6 @@ module Enterprise::SuperAdmin::AppConfigsController
|
||||
def internal_config_options
|
||||
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
|
||||
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
|
||||
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF]
|
||||
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class Enterprise::CloudflareVerificationJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(portal_id)
|
||||
portal = Portal.find(portal_id)
|
||||
return unless portal && portal.custom_domain.present?
|
||||
|
||||
result = check_hostname_status(portal)
|
||||
|
||||
create_hostname(portal) if result[:errors].present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_hostname(portal)
|
||||
Cloudflare::CreateCustomHostnameService.new(portal: portal).perform
|
||||
end
|
||||
|
||||
def check_hostname_status(portal)
|
||||
Cloudflare::CheckCustomHostnameService.new(portal: portal).perform
|
||||
end
|
||||
end
|
||||
14
enterprise/app/models/enterprise/concerns/portal.rb
Normal file
14
enterprise/app/models/enterprise/concerns/portal.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Enterprise::Concerns::Portal
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_save :enqueue_cloudflare_verification, if: :saved_change_to_custom_domain?
|
||||
end
|
||||
|
||||
def enqueue_cloudflare_verification
|
||||
return if custom_domain.blank?
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
Enterprise::CloudflareVerificationJob.perform_later(id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
class Cloudflare::BaseCloudflareZoneService
|
||||
BASE_URI = 'https://api.cloudflare.com/client/v4'.freeze
|
||||
|
||||
private
|
||||
|
||||
def headers
|
||||
{
|
||||
'Authorization' => "Bearer #{api_token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def api_token
|
||||
InstallationConfig.find_by(name: 'CLOUDFLARE_API_KEY')&.value
|
||||
end
|
||||
|
||||
def zone_id
|
||||
InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
class Cloudflare::CheckCustomHostnameService < Cloudflare::BaseCloudflareZoneService
|
||||
pattr_initialize [:portal!]
|
||||
|
||||
def perform
|
||||
return { errors: ['Cloudflare API token or zone ID not found'] } if api_token.blank? || zone_id.blank?
|
||||
return { errors: ['No custom domain found'] } if @portal.custom_domain.blank?
|
||||
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/zones/#{zone_id}/custom_hostnames?hostname=#{@portal.custom_domain}", headers: headers
|
||||
)
|
||||
|
||||
return { errors: response.parsed_response['errors'] } unless response.success?
|
||||
|
||||
data = response.parsed_response['result']
|
||||
|
||||
if data.present?
|
||||
update_portal_ssl_settings(data.first)
|
||||
return { data: data }
|
||||
end
|
||||
|
||||
{ errors: ['Hostname is missing in Cloudflare'] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_portal_ssl_settings(data)
|
||||
verification_record = data['ownership_verification_http']
|
||||
ssl_settings = {
|
||||
'cf_verification_id': verification_record['http_url'].split('/').last,
|
||||
'cf_verification_body': verification_record['http_body']
|
||||
}
|
||||
@portal.update(ssl_settings: ssl_settings)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneService
|
||||
pattr_initialize [:portal!]
|
||||
|
||||
def perform
|
||||
return { errors: ['Cloudflare API token or zone ID not found'] } if api_token.blank? || zone_id.blank?
|
||||
return { errors: ['No hostname found'] } if @portal.custom_domain.blank?
|
||||
|
||||
response = create_hostname
|
||||
|
||||
return { errors: response.parsed_response['errors'] } unless response.success?
|
||||
|
||||
data = response.parsed_response['result']
|
||||
|
||||
if data.present?
|
||||
update_portal_ssl_settings(data)
|
||||
return { data: data }
|
||||
end
|
||||
|
||||
{ errors: ['Could not create hostname'] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_hostname
|
||||
HTTParty.post(
|
||||
"#{BASE_URI}/zones/#{zone_id}/custom_hostnames",
|
||||
headers: headers,
|
||||
body: { hostname: @portal.custom_domain }.to_json
|
||||
)
|
||||
end
|
||||
|
||||
def update_portal_ssl_settings(data)
|
||||
verification_record = data['ownership_verification_http']
|
||||
ssl_settings = {
|
||||
'cf_verification_id': verification_record['http_url'].split('/').last,
|
||||
'cf_verification_body': verification_record['http_body']
|
||||
}
|
||||
@portal.update(ssl_settings: ssl_settings)
|
||||
end
|
||||
end
|
||||
@@ -36,12 +36,18 @@ describe ContactIdentifyAction do
|
||||
expect(result.additional_attributes['social_profiles']).to eq({ 'linkedin' => 'saras', 'twitter' => 'saras' })
|
||||
end
|
||||
|
||||
it 'enques avatar job when avatar url parameter is passed' do
|
||||
it 'enqueues avatar job when valid avatar url parameter is passed' do
|
||||
params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' }
|
||||
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once
|
||||
described_class.new(contact: contact, params: params).perform
|
||||
end
|
||||
|
||||
it 'does not enqueue avatar job when invalid avatar url parameter is passed' do
|
||||
params = { name: 'test', avatar_url: 'invalid-url' }
|
||||
expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later)
|
||||
described_class.new(contact: contact, params: params).perform
|
||||
end
|
||||
|
||||
context 'when contact with same identifier exists' do
|
||||
it 'merges the current contact to identified contact' do
|
||||
existing_identified_contact = create(:contact, account: account, identifier: 'test_id')
|
||||
|
||||
@@ -904,4 +904,80 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/sync_templates' do
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
|
||||
end
|
||||
let(:whatsapp_inbox) { create(:inbox, account: account, channel: whatsapp_channel) }
|
||||
let(:non_whatsapp_inbox) { create(:inbox, account: account) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
it 'returns unauthorized for agent' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
context 'with WhatsApp inbox' do
|
||||
it 'successfully initiates template sync' do
|
||||
expect(Channels::Whatsapp::TemplatesSyncJob).to receive(:perform_later).with(whatsapp_channel)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['message']).to eq('Template sync initiated successfully')
|
||||
end
|
||||
|
||||
it 'handles job errors gracefully' do
|
||||
allow(Channels::Whatsapp::TemplatesSyncJob).to receive(:perform_later).and_raise(StandardError, 'Job failed')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:internal_server_error)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('Job failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-WhatsApp inbox' do
|
||||
it 'returns unprocessable entity error' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/sync_templates",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('Template sync is only available for WhatsApp channels')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-existent inbox' do
|
||||
it 'returns not found error' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/999999/sync_templates",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::CloudflareVerificationJob do
|
||||
let(:portal) { create(:portal, custom_domain: 'test.example.com') }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when portal is not found' do
|
||||
it 'returns early' do
|
||||
expect(Portal).to receive(:find).with(0).and_return(nil)
|
||||
expect(Cloudflare::CheckCustomHostnameService).not_to receive(:new)
|
||||
expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when portal has no custom domain' do
|
||||
it 'returns early' do
|
||||
portal_without_domain = create(:portal, custom_domain: nil)
|
||||
expect(Cloudflare::CheckCustomHostnameService).not_to receive(:new)
|
||||
expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(portal_without_domain.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when portal exists with custom domain' do
|
||||
it 'checks hostname status' do
|
||||
check_service = instance_double(Cloudflare::CheckCustomHostnameService, perform: { data: 'success' })
|
||||
expect(Cloudflare::CheckCustomHostnameService).to receive(:new).with(portal: portal).and_return(check_service)
|
||||
expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(portal.id)
|
||||
end
|
||||
|
||||
it 'creates hostname when check returns errors' do
|
||||
check_service = instance_double(Cloudflare::CheckCustomHostnameService, perform: { errors: ['Hostname is missing'] })
|
||||
create_service = instance_double(Cloudflare::CreateCustomHostnameService, perform: { data: 'success' })
|
||||
|
||||
expect(Cloudflare::CheckCustomHostnameService).to receive(:new).with(portal: portal).and_return(check_service)
|
||||
expect(Cloudflare::CreateCustomHostnameService).to receive(:new).with(portal: portal).and_return(create_service)
|
||||
|
||||
described_class.perform_now(portal.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
59
spec/enterprise/models/enterprise/concerns/portal_spec.rb
Normal file
59
spec/enterprise/models/enterprise/concerns/portal_spec.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::Concerns::Portal do
|
||||
describe '#enqueue_cloudflare_verification' do
|
||||
let(:portal) { create(:portal, custom_domain: nil) }
|
||||
|
||||
context 'when custom_domain is changed' do
|
||||
context 'when on chatwoot cloud' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
end
|
||||
|
||||
it 'enqueues cloudflare verification job' do
|
||||
expect do
|
||||
portal.update(custom_domain: 'test.example.com')
|
||||
end.to have_enqueued_job(Enterprise::CloudflareVerificationJob).with(portal.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when not on chatwoot cloud' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not enqueue cloudflare verification job' do
|
||||
expect do
|
||||
portal.update(custom_domain: 'test.example.com')
|
||||
end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when custom_domain is not changed' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
portal.update(custom_domain: 'test.example.com')
|
||||
end
|
||||
|
||||
it 'does not enqueue cloudflare verification job' do
|
||||
expect do
|
||||
portal.update(name: 'New Name')
|
||||
end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when custom_domain is set to blank' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
portal.update(custom_domain: 'test.example.com')
|
||||
end
|
||||
|
||||
it 'does not enqueue cloudflare verification job' do
|
||||
expect do
|
||||
portal.update(custom_domain: '')
|
||||
end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,111 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Cloudflare::CheckCustomHostnameService do
|
||||
let(:portal) { create(:portal, custom_domain: 'test.example.com') }
|
||||
let(:installation_config_api_key) { create(:installation_config, name: 'CLOUDFLARE_API_KEY', value: 'test-api-key') }
|
||||
let(:installation_config_zone_id) { create(:installation_config, name: 'CLOUDFLARE_ZONE_ID', value: 'test-zone-id') }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when API token or zone ID is not found' do
|
||||
it 'returns error when API token is missing' do
|
||||
installation_config_zone_id
|
||||
service = described_class.new(portal: portal)
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
|
||||
end
|
||||
|
||||
it 'returns error when zone ID is missing' do
|
||||
installation_config_api_key
|
||||
service = described_class.new(portal: portal)
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no hostname ID is found' do
|
||||
it 'returns error' do
|
||||
installation_config_api_key
|
||||
installation_config_zone_id
|
||||
portal.update(custom_domain: nil)
|
||||
service = described_class.new(portal: portal)
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq(errors: ['No custom domain found'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API request is made' do
|
||||
before do
|
||||
installation_config_api_key
|
||||
installation_config_zone_id
|
||||
end
|
||||
|
||||
context 'when API request fails' do
|
||||
it 'returns error response' do
|
||||
service = described_class.new(portal: portal)
|
||||
error_response = {
|
||||
'errors' => [{ 'message' => 'API error' }]
|
||||
}
|
||||
|
||||
stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
|
||||
.to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:errors]).to eq(error_response['errors'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API request succeeds but no data is returned' do
|
||||
it 'returns hostname missing error' do
|
||||
service = described_class.new(portal: portal)
|
||||
success_response = {
|
||||
'result' => []
|
||||
}
|
||||
|
||||
stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
|
||||
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq(errors: ['Hostname is missing in Cloudflare'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API request succeeds and data is returned' do
|
||||
it 'updates portal SSL settings and returns success' do
|
||||
service = described_class.new(portal: portal)
|
||||
success_response = {
|
||||
'result' => [
|
||||
{
|
||||
'ownership_verification_http' => {
|
||||
'http_url' => 'http://example.com/.well-known/cf-verification/verification-id',
|
||||
'http_body' => 'verification-body'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
|
||||
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect(portal).to receive(:update).with(
|
||||
ssl_settings: {
|
||||
'cf_verification_id': 'verification-id',
|
||||
'cf_verification_body': 'verification-body'
|
||||
}
|
||||
)
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq(data: success_response['result'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,111 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Cloudflare::CreateCustomHostnameService do
|
||||
let(:portal) { create(:portal, custom_domain: 'test.example.com') }
|
||||
let(:installation_config_api_key) { create(:installation_config, name: 'CLOUDFLARE_API_KEY', value: 'test-api-key') }
|
||||
let(:installation_config_zone_id) { create(:installation_config, name: 'CLOUDFLARE_ZONE_ID', value: 'test-zone-id') }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when API token or zone ID is not found' do
|
||||
it 'returns error when API token is missing' do
|
||||
installation_config_zone_id
|
||||
service = described_class.new(portal: portal)
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
|
||||
end
|
||||
|
||||
it 'returns error when zone ID is missing' do
|
||||
installation_config_api_key
|
||||
service = described_class.new(portal: portal)
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no hostname is found' do
|
||||
it 'returns error' do
|
||||
installation_config_api_key
|
||||
installation_config_zone_id
|
||||
portal.update(custom_domain: nil)
|
||||
service = described_class.new(portal: portal)
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq(errors: ['No hostname found'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API request is made' do
|
||||
before do
|
||||
installation_config_api_key
|
||||
installation_config_zone_id
|
||||
end
|
||||
|
||||
context 'when API request fails' do
|
||||
it 'returns error response' do
|
||||
service = described_class.new(portal: portal)
|
||||
error_response = {
|
||||
'errors' => [{ 'message' => 'API error' }]
|
||||
}
|
||||
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
|
||||
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
|
||||
body: { hostname: 'test.example.com' }.to_json)
|
||||
.to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:errors]).to eq(error_response['errors'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API request succeeds but no data is returned' do
|
||||
it 'returns hostname creation error' do
|
||||
service = described_class.new(portal: portal)
|
||||
success_response = {
|
||||
'result' => nil
|
||||
}
|
||||
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
|
||||
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
|
||||
body: { hostname: 'test.example.com' }.to_json)
|
||||
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq(errors: ['Could not create hostname'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API request succeeds and data is returned' do
|
||||
it 'updates portal SSL settings and returns success' do
|
||||
service = described_class.new(portal: portal)
|
||||
success_response = {
|
||||
'result' => {
|
||||
'ownership_verification_http' => {
|
||||
'http_url' => 'http://example.com/.well-known/cf-verification/verification-id',
|
||||
'http_body' => 'verification-body'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
|
||||
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
|
||||
body: { hostname: 'test.example.com' }.to_json)
|
||||
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect(portal).to receive(:update).with(ssl_settings: { 'cf_verification_id': 'verification-id',
|
||||
'cf_verification_body': 'verification-body' })
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq(data: success_response['result'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,7 @@ RSpec.describe Avatar::AvatarFromGravatarJob do
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later(avatarable, email) }.to have_enqueued_job(described_class)
|
||||
.on_queue('low')
|
||||
.on_queue('purgable')
|
||||
end
|
||||
|
||||
it 'will call AvatarFromUrlJob with gravatar url' do
|
||||
|
||||
@@ -6,7 +6,7 @@ RSpec.describe Avatar::AvatarFromUrlJob do
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later(avatarable, avatar_url) }.to have_enqueued_job(described_class)
|
||||
.on_queue('low')
|
||||
.on_queue('purgable')
|
||||
end
|
||||
|
||||
it 'will attach avatar from url' do
|
||||
|
||||
@@ -122,4 +122,60 @@ RSpec.describe Channel::Whatsapp do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#teardown_webhooks' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
context 'when channel is whatsapp_cloud with embedded_signup' do
|
||||
it 'calls WebhookTeardownService on destroy' do
|
||||
# Mock the setup service to prevent HTTP calls during creation
|
||||
setup_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
|
||||
allow(setup_service).to receive(:perform)
|
||||
|
||||
channel = create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'source' => 'embedded_signup',
|
||||
'business_account_id' => 'test_waba_id',
|
||||
'api_key' => 'test_access_token',
|
||||
'phone_number_id' => '123456789'
|
||||
},
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
|
||||
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
|
||||
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service)
|
||||
allow(teardown_service).to receive(:perform)
|
||||
|
||||
channel.destroy
|
||||
|
||||
expect(Whatsapp::WebhookTeardownService).to have_received(:new).with(channel)
|
||||
expect(teardown_service).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is not embedded_signup' do
|
||||
it 'does not call WebhookTeardownService on destroy' do
|
||||
channel = create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'source' => 'manual',
|
||||
'api_key' => 'test_access_token'
|
||||
},
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
|
||||
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
|
||||
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service)
|
||||
allow(teardown_service).to receive(:perform)
|
||||
|
||||
channel.destroy
|
||||
|
||||
expect(teardown_service).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -102,4 +102,98 @@ RSpec.describe Contact do
|
||||
expect(contact.contact_type).to eq 'lead'
|
||||
end
|
||||
end
|
||||
|
||||
describe '.resolved_contacts' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
context 'when crm_v2 feature flag is disabled' do
|
||||
it 'returns contacts with email, phone_number, or identifier using feature flag value' do
|
||||
# Create contacts with different attributes
|
||||
contact_with_email = create(:contact, account: account, email: 'test@example.com', name: 'John Doe')
|
||||
contact_with_phone = create(:contact, account: account, phone_number: '+1234567890', name: 'Jane Smith')
|
||||
contact_with_identifier = create(:contact, account: account, identifier: 'user123', name: 'Bob Wilson')
|
||||
contact_without_details = create(:contact, account: account, name: 'Alice Johnson', email: nil, phone_number: nil, identifier: nil)
|
||||
|
||||
resolved = account.contacts.resolved_contacts(use_crm_v2: false)
|
||||
|
||||
expect(resolved).to include(contact_with_email, contact_with_phone, contact_with_identifier)
|
||||
expect(resolved).not_to include(contact_without_details)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when crm_v2 feature flag is enabled' do
|
||||
it 'returns only contacts with contact_type lead' do
|
||||
# Contact with email and phone - should be marked as lead
|
||||
contact_with_details = create(:contact, account: account, email: 'customer@example.com', phone_number: '+1234567890', name: 'Customer One')
|
||||
expect(contact_with_details.contact_type).to eq('lead')
|
||||
|
||||
# Contact without email/phone - should be marked as visitor
|
||||
contact_without_details = create(:contact, account: account, name: 'Lead', email: nil, phone_number: nil)
|
||||
expect(contact_without_details.contact_type).to eq('visitor')
|
||||
|
||||
# Force set contact_type to lead for testing
|
||||
contact_without_details.update!(contact_type: 'lead')
|
||||
|
||||
resolved = account.contacts.resolved_contacts(use_crm_v2: true)
|
||||
|
||||
expect(resolved).to include(contact_with_details)
|
||||
expect(resolved).to include(contact_without_details)
|
||||
end
|
||||
|
||||
it 'includes all lead contacts regardless of email/phone presence' do
|
||||
# Create a lead contact with only name
|
||||
lead_contact = create(:contact, account: account, name: 'Test Lead')
|
||||
lead_contact.update!(contact_type: 'lead')
|
||||
|
||||
# Create a customer contact
|
||||
customer_contact = create(:contact, account: account, email: 'customer@test.com')
|
||||
customer_contact.update!(contact_type: 'customer')
|
||||
|
||||
# Create a visitor contact
|
||||
visitor_contact = create(:contact, account: account, name: 'Visitor')
|
||||
expect(visitor_contact.contact_type).to eq('visitor')
|
||||
|
||||
resolved = account.contacts.resolved_contacts(use_crm_v2: true)
|
||||
|
||||
expect(resolved).to include(lead_contact)
|
||||
expect(resolved).not_to include(customer_contact)
|
||||
expect(resolved).not_to include(visitor_contact)
|
||||
end
|
||||
|
||||
it 'returns contacts with email, phone_number, or identifier when explicitly passing use_crm_v2: false' do
|
||||
# Even though feature flag is enabled, we're explicitly passing false
|
||||
contact_with_email = create(:contact, account: account, email: 'test@example.com', name: 'John Doe')
|
||||
contact_with_phone = create(:contact, account: account, phone_number: '+1234567890', name: 'Jane Smith')
|
||||
contact_without_details = create(:contact, account: account, name: 'Alice Johnson', email: nil, phone_number: nil, identifier: nil)
|
||||
|
||||
resolved = account.contacts.resolved_contacts(use_crm_v2: false)
|
||||
|
||||
# Should use the old logic despite feature flag being enabled
|
||||
expect(resolved).to include(contact_with_email, contact_with_phone)
|
||||
expect(resolved).not_to include(contact_without_details)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed contact types' do
|
||||
it 'correctly filters based on use_crm_v2 parameter regardless of feature flag' do
|
||||
# Create different types of contacts
|
||||
visitor_contact = create(:contact, account: account, name: 'Visitor')
|
||||
lead_with_email = create(:contact, account: account, email: 'lead@example.com', name: 'Lead')
|
||||
lead_without_email = create(:contact, account: account, name: 'Lead Only')
|
||||
lead_without_email.update!(contact_type: 'lead')
|
||||
customer_contact = create(:contact, account: account, email: 'customer@example.com', name: 'Customer')
|
||||
customer_contact.update!(contact_type: 'customer')
|
||||
|
||||
# Test with use_crm_v2: false
|
||||
resolved_old = account.contacts.resolved_contacts(use_crm_v2: false)
|
||||
expect(resolved_old).to include(lead_with_email, customer_contact)
|
||||
expect(resolved_old).not_to include(visitor_contact, lead_without_email)
|
||||
|
||||
# Test with use_crm_v2: true
|
||||
resolved_new = account.contacts.resolved_contacts(use_crm_v2: true)
|
||||
expect(resolved_new).to include(lead_with_email, lead_without_email)
|
||||
expect(resolved_new).not_to include(visitor_contact, customer_contact)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -185,6 +185,46 @@ describe SearchService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#message_base_query' do
|
||||
let(:params) { { q: 'test' } }
|
||||
let(:search_type) { 'Message' }
|
||||
|
||||
context 'when user is admin' do
|
||||
let(:admin_user) { create(:user) }
|
||||
let(:admin_search) do
|
||||
create(:account_user, account: account, user: admin_user, role: 'administrator')
|
||||
described_class.new(current_user: admin_user, current_account: account, params: params, search_type: search_type)
|
||||
end
|
||||
|
||||
it 'does not filter by inbox_id' do
|
||||
# Testing the private method itself seems like the best way to ensure
|
||||
# that the inboxes are not added to the search query
|
||||
base_query = admin_search.send(:message_base_query)
|
||||
|
||||
# Should only have the time filter, not inbox filter
|
||||
expect(base_query.to_sql).to include('created_at >= ')
|
||||
expect(base_query.to_sql).not_to include('inbox_id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not admin' do
|
||||
before do
|
||||
account_user = account.account_users.find_or_create_by(user: user)
|
||||
account_user.update!(role: 'agent')
|
||||
end
|
||||
|
||||
it 'filters by accessible inbox_id' do
|
||||
# Testing the private method itself seems like the best way to ensure
|
||||
# that the inboxes are not added to the search query
|
||||
base_query = search.send(:message_base_query)
|
||||
|
||||
# Should have both time and inbox filters
|
||||
expect(base_query.to_sql).to include('created_at >= ')
|
||||
expect(base_query.to_sql).to include('inbox_id')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#use_gin_search' do
|
||||
let(:params) { { q: 'test' } }
|
||||
|
||||
|
||||
@@ -194,4 +194,41 @@ describe Whatsapp::FacebookApiClient do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#unsubscribe_waba_webhook' do
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:delete, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns success response' do
|
||||
result = api_client.unsubscribe_waba_webhook(waba_id)
|
||||
expect(result['success']).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:delete, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
|
||||
)
|
||||
.to_return(status: 400, body: { error: 'Webhook unsubscription failed' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { api_client.unsubscribe_waba_webhook(waba_id) }.to raise_error(/Webhook unsubscription failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
81
spec/services/whatsapp/webhook_teardown_service_spec.rb
Normal file
81
spec/services/whatsapp/webhook_teardown_service_spec.rb
Normal file
@@ -0,0 +1,81 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Whatsapp::WebhookTeardownService do
|
||||
describe '#perform' do
|
||||
let(:channel) { create(:channel_whatsapp, validate_provider_config: false, sync_templates: false) }
|
||||
let(:service) { described_class.new(channel) }
|
||||
|
||||
context 'when channel is whatsapp_cloud with embedded_signup' do
|
||||
before do
|
||||
channel.update!(
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'source' => 'embedded_signup',
|
||||
'business_account_id' => 'test_waba_id',
|
||||
'api_key' => 'test_api_key'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'calls unsubscribe_waba_webhook on Facebook API client' do
|
||||
api_client = instance_double(Whatsapp::FacebookApiClient)
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).with('test_api_key').and_return(api_client)
|
||||
allow(api_client).to receive(:unsubscribe_waba_webhook).with('test_waba_id')
|
||||
|
||||
service.perform
|
||||
|
||||
expect(api_client).to have_received(:unsubscribe_waba_webhook).with('test_waba_id')
|
||||
end
|
||||
|
||||
it 'handles errors gracefully without raising' do
|
||||
api_client = instance_double(Whatsapp::FacebookApiClient)
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
|
||||
allow(api_client).to receive(:unsubscribe_waba_webhook).and_raise(StandardError, 'API Error')
|
||||
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is not whatsapp_cloud' do
|
||||
before do
|
||||
channel.update!(provider: 'default')
|
||||
end
|
||||
|
||||
it 'does not attempt to unsubscribe webhook' do
|
||||
expect(Whatsapp::FacebookApiClient).not_to receive(:new)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is whatsapp_cloud but not embedded_signup' do
|
||||
before do
|
||||
channel.update!(
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: { 'source' => 'manual' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not attempt to unsubscribe webhook' do
|
||||
expect(Whatsapp::FacebookApiClient).not_to receive(:new)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when required config is missing' do
|
||||
before do
|
||||
channel.update!(
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: { 'source' => 'embedded_signup' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not attempt to unsubscribe webhook' do
|
||||
expect(Whatsapp::FacebookApiClient).not_to receive(:new)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user