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'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
##-- Push notification service --##
gem 'fcm'

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
display_name: WhatsApp Campaign
enabled: false
- name: crm_v2
display_name: CRM V2
enabled: false
chatwoot_internal: true

View File

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

View File

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

View File

@@ -24,6 +24,7 @@
- low
- scheduled_jobs
- deferred
- purgable
- housekeeping
- async_database_migration
- 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.
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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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