Merge branch 'develop' into feat/voice-channel

This commit is contained in:
Sojan Jose
2025-07-24 14:05:15 +04:00
committed by GitHub
66 changed files with 1240 additions and 219 deletions

View File

@@ -121,6 +121,8 @@ gem 'sentry-sidekiq', '>= 5.19.0', require: false
gem 'sidekiq', '>= 7.3.1' gem 'sidekiq', '>= 7.3.1'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0' gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'

View File

@@ -361,6 +361,7 @@ GEM
grpc (1.72.0-x86_64-linux) grpc (1.72.0-x86_64-linux)
google-protobuf (>= 3.25, < 5.0) google-protobuf (>= 3.25, < 5.0)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
gserver (0.0.1)
haikunator (1.1.1) haikunator (1.1.1)
hairtrigger (1.0.0) hairtrigger (1.0.0)
activerecord (>= 6.0, < 8) activerecord (>= 6.0, < 8)
@@ -479,7 +480,7 @@ GEM
mime-types-data (3.2023.0218.1) mime-types-data (3.2023.0218.1)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.8) mini_portile2 (2.8.9)
minitest (5.25.5) minitest (5.25.5)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
@@ -510,14 +511,14 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.18.8) nokogiri (1.18.9)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin) nokogiri (1.18.9-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin) nokogiri (1.18.9-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu) nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@@ -787,6 +788,9 @@ GEM
fugit (~> 1.8) fugit (~> 1.8)
globalid (>= 1.0.1) globalid (>= 1.0.1)
sidekiq (>= 6) sidekiq (>= 6)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
signet (0.17.0) signet (0.17.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
@@ -825,7 +829,7 @@ GEM
stripe (8.5.0) stripe (8.5.0)
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.3.1) thor (1.4.0)
tilt (2.3.0) tilt (2.3.0)
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
@@ -1012,6 +1016,7 @@ DEPENDENCIES
shoulda-matchers shoulda-matchers
sidekiq (>= 7.3.1) sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0) sidekiq-cron (>= 1.12.0)
sidekiq_alive
simplecov (= 0.17.1) simplecov (= 0.17.1)
slack-ruby-client (~> 2.5.2) slack-ruby-client (~> 2.5.2)
spring spring

View File

@@ -6,6 +6,7 @@
# We don't want to update the name of the identified original contact. # We don't want to update the name of the identified original contact.
class ContactIdentifyAction class ContactIdentifyAction
include UrlHelper
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }] pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
def perform def perform
@@ -104,7 +105,14 @@ class ContactIdentifyAction
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs @contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! @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 end
def merge_contact(base_contact, merge_contact) def merge_contact(base_contact, merge_contact)

View File

@@ -122,7 +122,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def resolved_contacts def resolved_contacts
return @resolved_contacts if @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 = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
@resolved_contacts @resolved_contacts

View File

@@ -69,6 +69,17 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end 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 private
def fetch_inbox def fetch_inbox

View File

@@ -2,7 +2,7 @@ class MicrosoftController < ApplicationController
after_action :set_version_header after_action :set_version_header
def identity_association def identity_association
microsoft_indentity microsoft_identity
end end
private private
@@ -11,7 +11,7 @@ class MicrosoftController < ApplicationController
response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length
end end
def microsoft_indentity def microsoft_identity
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil) @identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
end end
end end

View File

@@ -28,6 +28,10 @@ class Inboxes extends CacheEnabledApiClient {
agent_bot: botId, agent_bot: botId,
}); });
} }
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
} }
export default new Inboxes(); export default new Inboxes();

View File

@@ -12,6 +12,7 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('getCampaigns'); expect(inboxesAPI).toHaveProperty('getCampaigns');
expect(inboxesAPI).toHaveProperty('getAgentBot'); expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot'); expect(inboxesAPI).toHaveProperty('setAgentBot');
expect(inboxesAPI).toHaveProperty('syncTemplates');
}); });
describe('API calls', () => { describe('API calls', () => {
@@ -40,5 +41,12 @@ describe('#InboxesAPI', () => {
inboxesAPI.deleteInboxAvatar(2); inboxesAPI.deleteInboxAvatar(2);
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar'); expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
}); });
it('#syncTemplates', () => {
inboxesAPI.syncTemplates(2);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/inboxes/2/sync_templates'
);
});
}); });
}); });

View File

@@ -6,7 +6,7 @@ import DropdownBody from './base/DropdownBody.vue';
import DropdownSection from './base/DropdownSection.vue'; import DropdownSection from './base/DropdownSection.vue';
import DropdownItem from './base/DropdownItem.vue'; import DropdownItem from './base/DropdownItem.vue';
import DropdownSeparator from './base/DropdownSeparator.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); const currentUserAutoOffline = ref(false);
@@ -61,7 +61,7 @@ const menuItems = ref([
<DropdownItem label="Contact Support" class="justify-between"> <DropdownItem label="Contact Support" class="justify-between">
<span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span> <span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span>
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<WootSwitch v-model="currentUserAutoOffline" /> <ToggleSwitch v-model="currentUserAutoOffline" />
</div> </div>
</DropdownItem> </DropdownItem>
</DropdownSection> </DropdownSection>

View File

@@ -14,6 +14,7 @@ import {
} from 'next/dropdown-menu/base'; } from 'next/dropdown-menu/base';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue'; import Button from 'next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
@@ -48,6 +49,16 @@ const activeStatus = computed(() => {
return availabilityStatuses.value.find(status => status.active); 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) { function changeAvailabilityStatus(availability) {
if (isImpersonating.value) { if (isImpersonating.value) {
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')); useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
@@ -62,13 +73,6 @@ function changeAvailabilityStatus(availability) {
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR')); useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR'));
} }
} }
function updateAutoOffline(autoOffline) {
store.dispatch('updateAutoOffline', {
accountId: currentAccountId.value,
autoOffline,
});
}
</script> </script>
<template> <template>
@@ -118,11 +122,7 @@ function updateAutoOffline(autoOffline) {
class="size-4 text-n-slate-10" class="size-4 text-n-slate-10"
/> />
</div> </div>
<woot-switch <ToggleSwitch v-model="autoOfflineToggle" />
class="flex-shrink-0"
:model-value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</DropdownItem> </DropdownItem>
</div> </div>
</DropdownSection> </DropdownSection>

View File

@@ -19,8 +19,8 @@ const updateValue = () => {
<template> <template>
<button <button
type="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="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-alpha-1 dark:bg-n-alpha-2'" :class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
role="switch" role="switch"
:aria-checked="modelValue" :aria-checked="modelValue"
@click="updateValue" @click="updateValue"

View File

@@ -61,7 +61,9 @@ const onCopy = async e => {
<template> <template>
<div class="relative text-left"> <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 <form
v-if="enableCodePen" v-if="enableCodePen"
class="flex items-center" class="flex items-center"
@@ -86,6 +88,11 @@ const onCopy = async e => {
@click="onCopy" @click="onCopy"
/> />
</div> </div>
<highlightjs v-if="script" :language="lang" :code="scrubbedScript" /> <highlightjs
v-if="script"
:language="lang"
:code="scrubbedScript"
class="[&_code]:text-start"
/>
</div> </div>
</template> </template>

View File

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

View File

@@ -94,7 +94,7 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
hasWhatsappTemplates: { enableWhatsAppTemplates: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@@ -333,7 +333,7 @@ export default {
@click="toggleMessageSignature" @click="toggleMessageSignature"
/> />
<NextButton <NextButton
v-if="hasWhatsappTemplates" v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')" v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo" icon="i-ph-whatsapp-logo"
slate slate

View File

@@ -184,9 +184,8 @@ export default {
return false; return false;
}, },
hasWhatsappTemplates() { showWhatsappTemplates() {
return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId) return this.isAWhatsAppCloudChannel && !this.isPrivate;
.length;
}, },
isPrivate() { isPrivate() {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) { if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
@@ -1220,7 +1219,7 @@ export default {
<ReplyBottomPanel <ReplyBottomPanel
:conversation-id="conversationId" :conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload" :enable-multiple-file-upload="enableMultipleFileUpload"
:has-whatsapp-templates="hasWhatsappTemplates" :enable-whats-app-templates="showWhatsappTemplates"
:inbox="inbox" :inbox="inbox"
:is-on-private-note="isOnPrivateNote" :is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio" :is-recording-audio="isRecordingAudio"

View File

@@ -1,8 +1,13 @@
<script> <script>
import { useAlert } from 'dashboard/composables';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// TODO: Remove this when we support all formats // TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO']; const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
export default { export default {
components: {
Icon,
},
props: { props: {
inboxId: { inboxId: {
type: Number, type: Number,
@@ -13,6 +18,7 @@ export default {
data() { data() {
return { return {
query: '', query: '',
isRefreshing: false,
}; };
}, },
computed: { computed: {
@@ -37,38 +43,63 @@ export default {
return template.components.find(component => component.type === 'BODY') return template.components.find(component => component.type === 'BODY')
.text; .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> </script>
<template> <template>
<div class="w-full"> <div class="w-full">
<div <div class="flex gap-2 mb-2.5">
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" <div
> 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 <fluent-icon icon="search" class="text-n-slate-12" size="16" />
v-model="query" <input
type="search" v-model="query"
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')" type="search"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0" :placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
/> 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>
<div <div
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5" 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"> <div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
<button <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)" @click="$emit('onSelect', template)"
> >
<div> <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"> <p class="text-sm">
{{ template.name }} {{ template.name }}
</p> </p>
<span <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') }} : {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
{{ template.language }} {{ template.language }}
@@ -94,11 +125,18 @@ export default {
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]" class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/> />
</div> </div>
<div v-if="!filteredTemplateMessages.length"> <div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<p> <div v-if="query && whatsAppTemplateMessages.length">
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }} <p>
<strong>{{ query }}</strong> {{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
</p> <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> </div>
</div> </div>

View File

@@ -600,6 +600,10 @@
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update", "WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token", "WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.", "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" "UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
}, },
"HELP_CENTER": { "HELP_CENTER": {

View File

@@ -8,6 +8,10 @@
"PICKER": { "PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates", "SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for", "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": { "LABELS": {
"LANGUAGE": "Language", "LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body", "TEMPLATE_BODY": "Template Body",

View File

@@ -1,6 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue';
import { messageStamp } from 'shared/helpers/timeHelper'; import { messageStamp } from 'shared/helpers/timeHelper';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
const props = defineProps({ const props = defineProps({
automation: { automation: {
@@ -19,14 +21,17 @@ const readableDate = date => messageStamp(new Date(date), 'LLL d, yyyy');
const readableDateWithTime = date => const readableDateWithTime = date =>
messageStamp(new Date(date), 'LLL d, yyyy hh:mm a'); messageStamp(new Date(date), 'LLL d, yyyy hh:mm a');
const toggle = () => { const automationActive = computed({
const { id, name, active } = props.automation; get: () => props.automation.active,
emit('toggle', { set: active => {
id, const { id, name } = props.automation;
name, emit('toggle', {
status: active, id,
}); name,
}; status: !active,
});
},
});
</script> </script>
<template> <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 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">{{ automation.description }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4"> <td class="py-4 ltr:pr-4 rtl:pl-4">
<woot-switch :model-value="automation.active" @input="toggle" /> <ToggleSwitch v-model="automationActive" />
</td> </td>
<td <td
class="py-4 ltr:pr-4 rtl:pl-4 min-w-[12px]" class="py-4 ltr:pr-4 rtl:pl-4 min-w-[12px]"

View File

@@ -1,8 +1,9 @@
<script> <script>
import Draggable from 'vuedraggable'; import Draggable from 'vuedraggable';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
export default { export default {
components: { Draggable }, components: { Draggable, ToggleSwitch },
props: { props: {
preChatFields: { preChatFields: {
type: Array, type: Array,
@@ -45,9 +46,9 @@ export default {
<tr class="border-b border-n-weak"> <tr class="border-b border-n-weak">
<td class="pre-chat-field"><fluent-icon icon="drag" /></td> <td class="pre-chat-field"><fluent-icon icon="drag" /></td>
<td class="pre-chat-field"> <td class="pre-chat-field">
<woot-switch <ToggleSwitch
:model-value="item['enabled']" :model-value="item['enabled']"
@input="handlePreChatFieldOptions($event, 'enabled', item)" @change="handlePreChatFieldOptions($event, 'enabled', item)"
/> />
</td> </td>
<td <td

View File

@@ -29,6 +29,7 @@ export default {
return { return {
hmacMandatory: false, hmacMandatory: false,
whatsAppInboxAPIKey: '', whatsAppInboxAPIKey: '',
isSyncingTemplates: false,
}; };
}, },
validations: { validations: {
@@ -83,6 +84,19 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); 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> </script>
@@ -137,7 +151,7 @@ export default {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')" :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 <input
id="hmacMandatory" id="hmacMandatory"
v-model="hmacMandatory" v-model="hmacMandatory"
@@ -169,7 +183,7 @@ export default {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')" :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 <input
id="hmacMandatory" id="hmacMandatory"
v-model="hmacMandatory" v-model="hmacMandatory"
@@ -215,12 +229,12 @@ export default {
" "
> >
<div <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 <woot-input
v-model="whatsAppInboxAPIKey" v-model="whatsAppInboxAPIKey"
type="text" type="text"
class="flex-1 mr-2" class="flex-1 mr-2 [&>input]:!mb-0"
:placeholder=" :placeholder="
$t( $t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER' 'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
@@ -235,6 +249,18 @@ export default {
</NextButton> </NextButton>
</div> </div>
</SettingsSection> </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>
</div> </div>
</template> </template>

View File

@@ -9,13 +9,13 @@ import {
verifyServiceWorkerExistence, verifyServiceWorkerExistence,
} from 'dashboard/helper/pushHelper.js'; } from 'dashboard/helper/pushHelper.js';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; 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'; import { NOTIFICATION_TYPES } from './constants';
export default { export default {
components: { components: {
TableHeaderCell, TableHeaderCell,
FormSwitch, ToggleSwitch,
CheckBox, CheckBox,
}, },
data() { data() {
@@ -284,9 +284,9 @@ export default {
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.BROWSER_PERMISSION') }} {{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.BROWSER_PERMISSION') }}
</span> </span>
</div> </div>
<FormSwitch <ToggleSwitch
:model-value="hasEnabledPushPermissions" v-model="hasEnabledPushPermissions"
@update:model-value="onRequestPermissions" @change="onRequestPermissions"
/> />
</div> </div>
</div> </div>

View File

@@ -10,6 +10,7 @@ import ReportsFiltersRatings from './Filters/Ratings.vue';
import subDays from 'date-fns/subDays'; import subDays from 'date-fns/subDays';
import { DATE_RANGE_OPTIONS } from '../constants'; import { DATE_RANGE_OPTIONS } from '../constants';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper'; import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
export default { export default {
components: { components: {
@@ -21,6 +22,7 @@ export default {
ReportsFiltersInboxes, ReportsFiltersInboxes,
ReportsFiltersTeams, ReportsFiltersTeams,
ReportsFiltersRatings, ReportsFiltersRatings,
ToggleSwitch,
}, },
props: { props: {
showGroupByFilter: { showGroupByFilter: {
@@ -106,11 +108,6 @@ export default {
return this.validGroupOptions[0]; return this.validGroupOptions[0];
}, },
}, },
watch: {
businessHoursSelected() {
this.emitChange();
},
},
mounted() { mounted() {
this.emitChange(); this.emitChange();
}, },
@@ -224,7 +221,7 @@ export default {
{{ $t('REPORT.BUSINESS_HOURS') }} {{ $t('REPORT.BUSINESS_HOURS') }}
</span> </span>
<span> <span>
<woot-switch v-model="businessHoursSelected" /> <ToggleSwitch v-model="businessHoursSelected" @change="emitChange" />
</span> </span>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays'; import subDays from 'date-fns/subDays';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue'; import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { GROUP_BY_FILTER } from '../constants'; import { GROUP_BY_FILTER } from '../constants';
const CUSTOM_DATE_RANGE_ID = 5; const CUSTOM_DATE_RANGE_ID = 5;
@@ -13,6 +14,7 @@ export default {
components: { components: {
WootDateRangePicker, WootDateRangePicker,
Thumbnail, Thumbnail,
ToggleSwitch,
}, },
props: { props: {
currentFilter: { currentFilter: {
@@ -125,9 +127,6 @@ export default {
groupByFilterItemsList() { groupByFilterItemsList() {
this.currentSelectedGroupByFilter = this.selectedGroupByFilter; this.currentSelectedGroupByFilter = this.selectedGroupByFilter;
}, },
businessHoursSelected() {
this.$emit('businessHoursToggle', this.businessHoursSelected);
},
}, },
mounted() { mounted() {
this.onDateRangeChange(); this.onDateRangeChange();
@@ -140,6 +139,9 @@ export default {
groupBy: this.groupBy, groupBy: this.groupBy,
}); });
}, },
onBusinessHoursToggle() {
this.$emit('businessHoursToggle', this.businessHoursSelected);
},
fromCustomDate(date) { fromCustomDate(date) {
return getUnixTime(startOfDay(date)); return getUnixTime(startOfDay(date));
}, },
@@ -303,7 +305,10 @@ export default {
{{ $t('REPORT.BUSINESS_HOURS') }} {{ $t('REPORT.BUSINESS_HOURS') }}
</span> </span>
<span> <span>
<woot-switch v-model="businessHoursSelected" /> <ToggleSwitch
v-model="businessHoursSelected"
@change="onBusinessHoursToggle"
/>
</span> </span>
</div> </div>

View File

@@ -5,11 +5,13 @@ import validations from './validations';
import SlaTimeInput from './SlaTimeInput.vue'; import SlaTimeInput from './SlaTimeInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
export default { export default {
components: { components: {
SlaTimeInput, SlaTimeInput,
NextButton, NextButton,
ToggleSwitch,
}, },
props: { props: {
selectedResponse: { selectedResponse: {
@@ -203,7 +205,7 @@ export default {
<span for="sla_bh" class="text-n-slate-11"> <span for="sla_bh" class="text-n-slate-11">
{{ $t('SLA.FORM.BUSINESS_HOURS.PLACEHOLDER') }} {{ $t('SLA.FORM.BUSINESS_HOURS.PLACEHOLDER') }}
</span> </span>
<woot-switch id="sla_bh" v-model="onlyDuringBusinessHours" /> <ToggleSwitch id="sla_bh" v-model="onlyDuringBusinessHours" />
</div> </div>
<div class="flex items-center justify-end w-full gap-2 mt-8"> <div class="flex items-center justify-end w-full gap-2 mt-8">

View File

@@ -317,6 +317,13 @@ export const actions = {
throw new Error(error); throw new Error(error);
} }
}, },
syncTemplates: async (_, inboxId) => {
try {
await InboxesAPI.syncTemplates(inboxId);
} catch (error) {
throw new Error(error);
}
},
}; };
export const mutations = { export const mutations = {

View File

@@ -231,4 +231,28 @@ describe('#actions', () => {
).rejects.toThrow(Error); ).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
);
});
});
}); });

View File

@@ -7,7 +7,6 @@ import hljsVuePlugin from '@highlightjs/vue-plugin';
import Multiselect from 'vue-multiselect'; import Multiselect from 'vue-multiselect';
import { plugin, defaultConfig } from '@formkit/vue'; import { plugin, defaultConfig } from '@formkit/vue';
import WootSwitch from 'components/ui/Switch.vue';
import WootWizard from 'components/ui/Wizard.vue'; import WootWizard from 'components/ui/Wizard.vue';
import FloatingVue from 'floating-vue'; import FloatingVue from 'floating-vue';
import WootUiKit from 'dashboard/components'; import WootUiKit from 'dashboard/components';
@@ -90,7 +89,6 @@ app.use(FloatingVue, {
app.use(hljsVuePlugin); app.use(hljsVuePlugin);
app.component('multiselect', Multiselect); app.component('multiselect', Multiselect);
app.component('woot-switch', WootSwitch);
app.component('woot-wizard', WootWizard); app.component('woot-wizard', WootWizard);
app.component('fluent-icon', FluentIcon); app.component('fluent-icon', FluentIcon);

View File

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

View File

@@ -29,9 +29,9 @@ class Account::ContactsExportJob < ApplicationJob
result = ::Contacts::FilterService.new(@account, @account_user, @params).perform result = ::Contacts::FilterService.new(@account, @account_user, @params).perform
result[:contacts] result[:contacts]
elsif @params[:label].present? 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 else
@account.contacts.resolved_contacts @account.contacts.resolved_contacts(use_crm_v2: @account.feature_enabled?('crm_v2'))
end end
end end

View File

@@ -1,5 +1,5 @@
class Avatar::AvatarFromGravatarJob < ApplicationJob class Avatar::AvatarFromGravatarJob < ApplicationJob
queue_as :low queue_as :purgable
def perform(avatarable, email) def perform(avatarable, email)
return if GlobalConfigService.load('DISABLE_GRAVATAR', '').present? return if GlobalConfigService.load('DISABLE_GRAVATAR', '').present?

View File

@@ -1,5 +1,5 @@
class Avatar::AvatarFromUrlJob < ApplicationJob class Avatar::AvatarFromUrlJob < ApplicationJob
queue_as :low queue_as :purgable
def perform(avatarable, avatar_url) def perform(avatarable, avatar_url)
return unless avatarable.respond_to?(:avatar) return unless avatarable.respond_to?(:avatar)

View File

@@ -34,6 +34,7 @@ class Channel::Whatsapp < ApplicationRecord
after_create :sync_templates after_create :sync_templates
after_create_commit :setup_webhooks after_create_commit :setup_webhooks
before_destroy :teardown_webhooks
def name def name
'Whatsapp' 'Whatsapp'
@@ -105,4 +106,8 @@ class Channel::Whatsapp < ApplicationRecord
# Don't raise the error to prevent channel creation from failing # Don't raise the error to prevent channel creation from failing
# Webhooks can be retried later # Webhooks can be retried later
end end
def teardown_webhooks
Whatsapp::WebhookTeardownService.new(self).perform
end
end end

View File

@@ -25,6 +25,7 @@
# Indexes # Indexes
# #
# index_contacts_on_account_id (account_id) # 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_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
# index_contacts_on_blocked (blocked) # index_contacts_on_blocked (blocked)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id) # index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
@@ -175,8 +176,12 @@ class Contact < ApplicationRecord
} }
end end
def self.resolved_contacts def self.resolved_contacts(use_crm_v2: false)
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''") if use_crm_v2
where(contact_type: 'lead')
else
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
end
end end
def discard_invalid_attrs def discard_invalid_attrs

View File

@@ -12,6 +12,7 @@
# name :string not null # name :string not null
# page_title :string # page_title :string
# slug :string not null # slug :string not null
# ssl_settings :jsonb not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :integer 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? errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
end end
end end
Portal.include_mod_with('Concerns::Portal')

View File

@@ -57,4 +57,8 @@ class InboxPolicy < ApplicationPolicy
def avatar? def avatar?
@account_user.administrator? @account_user.administrator?
end end
def sync_templates?
@account_user.administrator?
end
end end

View File

@@ -1,6 +1,10 @@
class SearchService class SearchService
pattr_initialize [:current_user!, :current_account!, :params!, :search_type!] 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 def perform
case search_type case search_type
when 'Message' when 'Message'
@@ -78,8 +82,9 @@ class SearchService
end end
def message_base_query def message_base_query
current_account.messages.where(inbox_id: accessable_inbox_ids) query = current_account.messages.where('created_at >= ?', 3.months.ago)
.where('created_at >= ?', 3.months.ago) query = query.where(inbox_id: accessable_inbox_ids) unless account_user.administrator?
query
end end
def use_gin_search def use_gin_search
@@ -90,7 +95,9 @@ class SearchService
@contacts = current_account.contacts.where( @contacts = current_account.contacts.where(
"name ILIKE :search OR email ILIKE :search OR phone_number "name ILIKE :search OR email ILIKE :search OR phone_number
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%" 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 end
def filter_articles def filter_articles

View File

@@ -63,6 +63,15 @@ class Whatsapp::FacebookApiClient
handle_response(response, 'Webhook subscription failed') handle_response(response, 'Webhook subscription failed')
end 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 private
def request_headers def request_headers

View 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

View File

@@ -187,3 +187,7 @@
- name: whatsapp_campaign - name: whatsapp_campaign
display_name: WhatsApp Campaign display_name: WhatsApp Campaign
enabled: false enabled: false
- name: crm_v2
display_name: CRM V2
enabled: false
chatwoot_internal: true

View File

@@ -416,3 +416,17 @@
locked: false locked: false
description: 'The redirect URI configured in your Google OAuth app' description: 'The redirect URI configured in your Google OAuth app'
## ------ End of Configs added for Google OAuth ------ ## ## ------ 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 ------ ##

View File

@@ -196,6 +196,7 @@ Rails.application.routes.draw do
get :agent_bot, on: :member get :agent_bot, on: :member
post :set_agent_bot, on: :member post :set_agent_bot, on: :member
delete :avatar, on: :member delete :avatar, on: :member
post :sync_templates, on: :member
end end
# Voice call management - using resource to avoid plural/singular confusion # 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/assetlinks.json' => 'android_app#assetlinks'
get '.well-known/apple-app-site-association' => 'apple_app#site_association' get '.well-known/apple-app-site-association' => 'apple_app#site_association'
get '.well-known/microsoft-identity-association.json' => 'microsoft#identity_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 # Internal Monitoring Routes

View File

@@ -24,6 +24,7 @@
- low - low
- scheduled_jobs - scheduled_jobs
- deferred - deferred
- purgable
- housekeeping - housekeeping
- async_database_migration - async_database_migration
- active_storage_analysis - active_storage_analysis

View File

@@ -0,0 +1,5 @@
class AddSslSettingsToPortals < ActiveRecord::Migration[7.1]
def change
add_column :portals, :ssl_settings, :jsonb, default: {}, null: false
end
end

View File

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

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" 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.string "country_code", default: ""
t.boolean "blocked", default: false, null: false t.boolean "blocked", default: false, null: false
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id" 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", "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", "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" 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.jsonb "config", default: {"allowed_locales" => ["en"]}
t.boolean "archived", default: false t.boolean "archived", default: false
t.bigint "channel_web_widget_id" 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 ["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 ["custom_domain"], name: "index_portals_on_custom_domain", unique: true
t.index ["slug"], name: "index_portals_on_slug", unique: true t.index ["slug"], name: "index_portals_on_slug", unique: true

View 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

View File

@@ -34,6 +34,6 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options 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 %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 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
end end

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -36,12 +36,18 @@ describe ContactIdentifyAction do
expect(result.additional_attributes['social_profiles']).to eq({ 'linkedin' => 'saras', 'twitter' => 'saras' }) expect(result.additional_attributes['social_profiles']).to eq({ 'linkedin' => 'saras', 'twitter' => 'saras' })
end 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' } params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' }
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once
described_class.new(contact: contact, params: params).perform described_class.new(contact: contact, params: params).perform
end 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 context 'when contact with same identifier exists' do
it 'merges the current contact to identified contact' do it 'merges the current contact to identified contact' do
existing_identified_contact = create(:contact, account: account, identifier: 'test_id') existing_identified_contact = create(:contact, account: account, identifier: 'test_id')

View File

@@ -904,4 +904,80 @@ RSpec.describe 'Inboxes API', type: :request do
end end
end 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 end

View File

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

View 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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ RSpec.describe Avatar::AvatarFromGravatarJob do
it 'enqueues the job' do it 'enqueues the job' do
expect { described_class.perform_later(avatarable, email) }.to have_enqueued_job(described_class) expect { described_class.perform_later(avatarable, email) }.to have_enqueued_job(described_class)
.on_queue('low') .on_queue('purgable')
end end
it 'will call AvatarFromUrlJob with gravatar url' do it 'will call AvatarFromUrlJob with gravatar url' do

View File

@@ -6,7 +6,7 @@ RSpec.describe Avatar::AvatarFromUrlJob do
it 'enqueues the job' do it 'enqueues the job' do
expect { described_class.perform_later(avatarable, avatar_url) }.to have_enqueued_job(described_class) expect { described_class.perform_later(avatarable, avatar_url) }.to have_enqueued_job(described_class)
.on_queue('low') .on_queue('purgable')
end end
it 'will attach avatar from url' do it 'will attach avatar from url' do

View File

@@ -122,4 +122,60 @@ RSpec.describe Channel::Whatsapp do
end end
end 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 end

View File

@@ -102,4 +102,98 @@ RSpec.describe Contact do
expect(contact.contact_type).to eq 'lead' expect(contact.contact_type).to eq 'lead'
end end
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 end

View File

@@ -185,6 +185,46 @@ describe SearchService do
end end
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 describe '#use_gin_search' do
let(:params) { { q: 'test' } } let(:params) { { q: 'test' } }

View File

@@ -194,4 +194,41 @@ describe Whatsapp::FacebookApiClient do
end end
end 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 end

View 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