Merge branch 'develop' into feat/reporting-reopen

This commit is contained in:
Shivam Mishra
2025-08-08 09:18:13 +05:30
committed by GitHub
87 changed files with 2208 additions and 976 deletions
@@ -1,8 +1,10 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :validate_feature_enabled!
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
# Handles the embedded signup callback data from the Facebook SDK
# Handles both initial authorization and reauthorization
# If inbox_id is present in params, it performs reauthorization
def create
validate_embedded_signup_params!
channel = process_embedded_signup
@@ -16,21 +18,42 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id]
params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys,
inbox_id: params[:inbox_id]
)
service.perform
end
def render_success_response(inbox)
def fetch_and_validate_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
validate_reauthorization_required
end
def validate_reauthorization_required
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
render json: {
success: false,
message: I18n.t('inbox.reauthorization.not_required')
}, status: :unprocessable_entity
end
def can_upgrade_to_embedded_signup?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
true
end
def render_success_response(inbox)
response = {
success: true,
id: inbox.id,
name: inbox.name,
channel_type: 'whatsapp'
}
response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present?
render json: response
end
def render_error_response(error)
@@ -30,7 +30,8 @@ class Twilio::CallbackController < ApplicationController
:NumMedia,
:Latitude,
:Longitude,
:MessageType
:MessageType,
:ProfileName
)
end
end
+9 -3
View File
@@ -15,7 +15,13 @@ class NotificationFinder
end
def unread_count
@notifications.where(read_at: nil).count
if type_included?('read')
# If we're including read notifications, filter to unread
@notifications.where(read_at: nil).count
else
# Already filtered to unread notifications, just count
@notifications.count
end
end
def count
@@ -27,7 +33,7 @@ class NotificationFinder
def set_up
find_all_notifications
filter_snoozed_notifications
fitler_read_notifications
filter_read_notifications
end
def find_all_notifications
@@ -38,7 +44,7 @@ class NotificationFinder
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
end
def fitler_read_notifications
def filter_read_notifications
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
end
@@ -9,6 +9,13 @@ class WhatsappChannel extends ApiClient {
createEmbeddedSignup(params) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
}
reauthorizeWhatsApp({ inboxId, ...params }) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, {
...params,
inbox_id: inboxId,
});
}
}
export default new WhatsappChannel();
@@ -38,11 +38,13 @@ const handleClose = () => emit('close');
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
<div class="p-6 flex flex-col gap-6">
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</div>
</template>
@@ -98,6 +98,7 @@ const onClickViewDetails = () => emit('showContact', props.id);
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1">
@@ -63,11 +63,12 @@ const lastActivityAt = computed(() => {
});
const menuItems = computed(() => [
{ key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
{
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
icon: isUnread.value ? 'mail' : 'mail-unread',
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
},
{ key: 'delete', icon: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
]);
const messageClasses = computed(() => ({
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { removeEmoji } from 'shared/helpers/emoji';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
@@ -33,10 +34,18 @@ const props = defineProps({
validator: value =>
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
},
inbox: {
type: Object,
default: null,
},
iconName: {
type: String,
default: null,
},
hideOfflineStatus: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['upload', 'delete']);
@@ -66,11 +75,11 @@ const AVATAR_COLORS = {
default: { bg: '#E8E8E8', text: '#60646C' },
};
const STATUS_CLASSES = {
const STATUS_CLASSES = computed(() => ({
online: 'bg-n-teal-10',
busy: 'bg-n-amber-10',
offline: 'bg-n-slate-10',
};
...(props.hideOfflineStatus ? {} : { offline: 'bg-n-slate-10' }),
}));
const showDefaultAvatar = computed(() => !props.src && !props.name);
@@ -178,11 +187,18 @@ watch(
<!-- Status Badge -->
<slot name="badge" :size="size">
<div
v-if="status"
v-if="status && STATUS_CLASSES[status]"
class="absolute z-20 border rounded-full border-n-slate-3"
:style="badgeStyles"
:class="STATUS_CLASSES[status]"
/>
<div
v-if="inbox && !(status && STATUS_CLASSES[status])"
:style="badgeStyles"
class="absolute z-20 flex items-center justify-center rounded-full bg-n-solid-1 border border-transparent flex-shrink-0"
>
<ChannelIcon :inbox="inbox" class="w-full h-full text-n-slate-11" />
</div>
</slot>
<!-- Delete Avatar Button -->
@@ -239,24 +255,33 @@ watch(
</template>
<!-- Upload Overlay and Input -->
<div
v-if="allowUpload"
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
<slot
v-if="allowUpload || $slots.overlay"
name="overlay"
:size="size"
:handle-upload="handleUploadAvatar"
:file-input-ref="fileInput"
:handle-image-upload="handleImageUpload"
>
<Icon
icon="i-lucide-upload"
class="text-white"
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
/>
<input
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
<div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<Icon
icon="i-lucide-upload"
class="text-white"
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
/>
<input
v-if="allowUpload"
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
</slot>
</span>
</span>
</template>
@@ -1,4 +1,5 @@
<script setup>
import { toRef } from 'vue';
import { useChannelIcon } from './provider';
import Icon from 'next/icon/Icon.vue';
@@ -9,7 +10,7 @@ const props = defineProps({
},
});
const channelIcon = useChannelIcon(props.inbox);
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
</script>
<template>
@@ -22,15 +22,21 @@ export function useChannelIcon(inbox) {
};
const channelIcon = computed(() => {
const type = inbox.channel_type;
const inboxDetails = inbox.value || inbox;
const type = inboxDetails.channel_type;
let icon = channelTypeIconMap[type];
if (type === 'Channel::Email' && inbox.provider) {
if (Object.keys(providerIconMap).includes(inbox.provider)) {
icon = providerIconMap[inbox.provider];
if (type === 'Channel::Email' && inboxDetails.provider) {
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
icon = providerIconMap[inboxDetails.provider];
}
}
// Special case for Twilio whatsapp
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
icon = 'i-ri-whatsapp-fill';
}
return icon ?? 'i-ri-global-fill';
});
@@ -25,6 +25,77 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-ri-phone-fill');
});
it('returns correct icon for Line channel', () => {
const inbox = { channel_type: 'Channel::Line' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-line-fill');
});
it('returns correct icon for SMS channel', () => {
const inbox = { channel_type: 'Channel::Sms' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
});
it('returns correct icon for Telegram channel', () => {
const inbox = { channel_type: 'Channel::Telegram' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-telegram-fill');
});
it('returns correct icon for Twitter channel', () => {
const inbox = { channel_type: 'Channel::TwitterProfile' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-twitter-x-fill');
});
it('returns correct icon for WebWidget channel', () => {
const inbox = { channel_type: 'Channel::WebWidget' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-global-fill');
});
it('returns correct icon for Instagram channel', () => {
const inbox = { channel_type: 'Channel::Instagram' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-instagram-fill');
});
describe('TwilioSms channel', () => {
it('returns chat icon for regular Twilio SMS channel', () => {
const inbox = { channel_type: 'Channel::TwilioSms' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
});
it('returns WhatsApp icon for Twilio SMS with WhatsApp medium', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-whatsapp-fill');
});
it('returns chat icon for Twilio SMS with non-WhatsApp medium', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: 'sms',
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
});
it('returns chat icon for Twilio SMS with undefined medium', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: undefined,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
});
});
describe('Email channel', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };
@@ -98,7 +98,7 @@ const toggleConversationLayout = () => {
/>
<div
id="saveFilterTeleportTarget"
class="absolute z-40 mt-2"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
@@ -124,7 +124,7 @@ const toggleConversationLayout = () => {
/>
<div
id="conversationFilterTeleportTarget"
class="absolute z-40 mt-2"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
@@ -150,7 +150,7 @@ const toggleConversationLayout = () => {
/>
<div
id="conversationFilterTeleportTarget"
class="absolute z-40 mt-2"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
@@ -1,94 +0,0 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import { useAccount } from 'dashboard/composables/useAccount';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
setup() {
const { accountId } = useAccount();
return {
accountId,
};
},
data() {
return { conversationMeta: {} };
},
computed: {
...mapGetters({
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
getAccount: 'accounts/getAccount',
}),
bannerMessage() {
return this.$t('GENERAL_SETTINGS.LIMITS_UPGRADE');
},
actionButtonMessage() {
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
},
shouldShowBanner() {
if (!this.isOnChatwootCloud) {
return false;
}
if (this.isTrialAccount()) {
return false;
}
return this.isLimitExceeded();
},
},
mounted() {
if (this.isOnChatwootCloud) {
this.fetchLimits();
}
},
methods: {
fetchLimits() {
this.$store.dispatch('accounts/limits');
},
routeToBilling() {
this.$router.push({
name: 'billing_settings_index',
params: { accountId: this.accountId },
});
},
isTrialAccount() {
// check if account is less than 15 days old
const account = this.getAccount(this.accountId);
if (!account) return false;
const createdAt = new Date(account.created_at);
const diffDays = differenceInDays(new Date(), createdAt);
return diffDays <= 15;
},
isLimitExceeded() {
const account = this.getAccount(this.accountId);
if (!account) return false;
const { limits } = account;
if (!limits) return false;
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
return this.testLimit(conversation) || this.testLimit(nonWebInboxes);
},
testLimit({ allowed, consumed }) {
return consumed > allowed;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@primary-action="routeToBilling"
/>
</template>
@@ -4,6 +4,7 @@ import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -18,6 +19,7 @@ defineProps({
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { isEnterprise } = useConfig();
const { width: windowWidth } = useWindowSize();
const currentUser = useMapGetter('getCurrentUser');
@@ -82,6 +84,9 @@ const setAssistant = async assistant => {
};
const shouldShowCopilotPanel = computed(() => {
if (!isEnterprise) {
return false;
}
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
@@ -113,7 +118,9 @@ const sendMessage = async message => {
};
onMounted(() => {
store.dispatch('captainAssistants/get');
if (isEnterprise) {
store.dispatch('captainAssistants/get');
}
});
</script>
@@ -1,9 +1,5 @@
<script>
/* eslint no-console: 0 */
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
props: {
items: {
type: Array,
@@ -1,32 +1,22 @@
<script>
import { getInboxClassByType } from 'dashboard/helper/inbox';
<script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
defineProps({
inbox: {
type: Object,
default: () => {},
},
computed: {
computedInboxClass() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
},
};
});
</script>
<template>
<div
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
>
<fluent-icon
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
:icon="computedInboxClass"
size="12"
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
<ChannelIcon
:inbox="inbox"
class="size-3 ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
/>
{{ inbox.name }}
<span class="truncate">
{{ inbox.name }}
</span>
</div>
</template>
@@ -1,12 +1,12 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Thumbnail from '../Thumbnail.vue';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
@@ -14,247 +14,227 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
export default {
components: {
CardLabels,
InboxName,
Thumbnail,
ConversationContextMenu,
TimeAgo,
MessagePreview,
PriorityMark,
SLACardLabel,
ContextMenu,
},
mixins: [inboxMixin],
props: {
activeLabel: {
type: String,
default: '',
},
chat: {
type: Object,
default: () => {},
},
hideInboxName: {
type: Boolean,
default: false,
},
hideThumbnail: {
type: Boolean,
default: false,
},
teamId: {
type: [String, Number],
default: 0,
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
conversationType: {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
enableContextMenu: {
type: Boolean,
default: false,
},
},
emits: [
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
],
data() {
return {
hovered: false,
showContextMenu: false,
contextMenu: {
x: null,
y: null,
},
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
inboxesList: 'inboxes/getInboxes',
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
}),
chatMetadata() {
return this.chat.meta || {};
},
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
assignee() {
return this.chatMetadata.assignee || {};
},
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chatMetadata.sender.id
);
},
const router = useRouter();
const store = useStore();
isActiveChat() {
return this.currentChat.id === this.chat.id;
},
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
y: null,
});
unreadCount() {
return this.chat.unread_count;
},
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
hasUnread() {
return this.unreadCount > 0;
},
const chatMetadata = computed(() => props.chat.meta || {});
isInboxNameVisible() {
return !this.activeInbox;
},
const assignee = computed(() => chatMetadata.value.assignee || {});
lastMessageInChat() {
return getLastMessage(this.chat);
},
const senderId = computed(() => chatMetadata.value.sender?.id);
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
return stateInbox;
},
const currentContact = computed(() => {
return senderId.value
? store.getters['contacts/getContact'](senderId.value)
: {};
});
showInboxName() {
return (
!this.hideInboxName &&
this.isInboxNameVisible &&
this.inboxesList.length > 1
);
},
inboxName() {
const stateInbox = this.inbox;
return stateInbox.name || '';
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
},
methods: {
onCardClick(e) {
const { activeInbox, chat } = this;
const path = frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
})
);
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
if (e.metaKey || e.ctrlKey) {
window.open(
window.chatwootConfig.hostURL + path,
'_blank',
'noopener noreferrer nofollow'
);
return;
}
if (this.isActiveChat) {
return;
}
const unreadCount = computed(() => props.chat.unread_count);
router.push({ path });
},
onThumbnailHover() {
this.hovered = !this.hideThumbnail;
},
onThumbnailLeave() {
this.hovered = false;
},
onSelectConversation(checked) {
const action = checked ? 'selectConversation' : 'deSelectConversation';
this.$emit(action, this.chat.id, this.inbox.id);
},
openContextMenu(e) {
if (!this.enableContextMenu) return;
e.preventDefault();
this.$emit('contextMenuToggle', true);
this.contextMenu.x = e.pageX || e.clientX;
this.contextMenu.y = e.pageY || e.clientY;
this.showContextMenu = true;
},
closeContextMenu() {
this.$emit('contextMenuToggle', false);
this.showContextMenu = false;
this.contextMenu.x = null;
this.contextMenu.y = null;
},
onUpdateConversation(status, snoozedUntil) {
this.closeContextMenu();
this.$emit(
'updateConversationStatus',
this.chat.id,
status,
snoozedUntil
);
},
async onAssignAgent(agent) {
this.$emit('assignAgent', agent, [this.chat.id]);
this.closeContextMenu();
},
async onAssignLabel(label) {
this.$emit('assignLabel', [label.title], [this.chat.id]);
this.closeContextMenu();
},
async onAssignTeam(team) {
this.$emit('assignTeam', team, this.chat.id);
this.closeContextMenu();
},
async markAsUnread() {
this.$emit('markAsUnread', this.chat.id);
this.closeContextMenu();
},
async markAsRead() {
this.$emit('markAsRead', this.chat.id);
this.closeContextMenu();
},
async assignPriority(priority) {
this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu();
},
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
},
const hasUnread = computed(() => unreadCount.value > 0);
const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const inboxId = computed(() => props.chat.inbox_id);
const inbox = computed(() => {
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.chat.priority
);
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
});
const messagePreviewClass = computed(() => {
return [
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
];
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
closeContextMenu();
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
'unread-chat': hasUnread,
'has-inbox-name': showInboxName,
'conversation-selected': selected,
'bg-n-slate-2 dark:bg-n-slate-3': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@@ -264,46 +244,65 @@ export default {
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<label
v-if="hovered || selected"
class="checkbox-wrapper absolute inset-0 z-20 backdrop-blur-[2px]"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="checkbox"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
</label>
<Thumbnail
<Avatar
v-if="!hideThumbnail"
:name="currentContact.name"
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:size="32"
:status="currentContact.availability_status"
size="32px"
/>
:inbox="inbox"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
rounded-full
>
<template #overlay="{ size }">
<label
v-if="hovered || selected"
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="!m-0 cursor-pointer"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div class="flex justify-between conversation-card--meta">
<InboxName v-if="showInboxName" :inbox="inbox" />
<div class="flex gap-2 ml-2 rtl:mr-2 rtl:ml-0">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div
class="flex items-center gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex text-ellipsis overflow-hidden whitespace-nowrap"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<PriorityMark :priority="chat.priority" />
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
</div>
</div>
<h4
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-70px)] text-n-slate-12"
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
:class="hasUnread ? 'font-semibold' : 'font-medium'"
>
{{ currentContact.name }}
@@ -311,24 +310,27 @@ export default {
<MessagePreview
v-if="lastMessageInChat"
:message="lastMessageInChat"
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
/>
<p
v-else
class="conversation--message text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-10"
icon="info"
/>
<span>
<span class="mx-0.5">
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div class="absolute flex flex-col mt-4 ltr:right-4 rtl:left-4 top-4">
<div
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<span class="ml-auto font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
@@ -336,12 +338,17 @@ export default {
/>
</span>
<span
class="unread shadow-lg rounded-full hidden text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels :conversation-labels="chat.labels" class="mt-0.5 mx-2 mb-0">
<CardLabels
v-if="showLabelsSection"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
@@ -359,6 +366,8 @@ export default {
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@@ -367,59 +376,8 @@ export default {
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
</template>
<style lang="scss" scoped>
.conversation {
&.unread-chat {
.unread {
@apply block;
}
}
&.compact {
@apply pl-0;
.conversation-card--meta {
@apply ltr:pr-4 rtl:pl-4;
}
.conversation--details {
@apply rounded-sm ml-0 pl-5 pr-2;
}
}
&::v-deep .user-thumbnail-box {
@apply mt-4;
}
&.conversation-selected {
@apply bg-n-slate-2 dark:bg-n-slate-3;
}
&.has-inbox-name {
&::v-deep .user-thumbnail-box {
@apply mt-8;
}
.checkbox-wrapper {
@apply mt-8;
}
.conversation--meta {
@apply mt-4;
}
}
.checkbox-wrapper {
@apply flex items-center justify-center rounded-full cursor-pointer mt-4;
input[type='checkbox'] {
@apply m-0 cursor-pointer;
}
}
}
</style>
@@ -1,5 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
@@ -8,7 +11,20 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
const MENU = {
MARK_AS_READ: 'mark-as-read',
MARK_AS_UNREAD: 'mark-as-unread',
PRIORITY: 'priority',
STATUS: 'status',
SNOOZE: 'snooze',
AGENT: 'agent',
TEAM: 'team',
LABEL: 'label',
DELETE: 'delete',
OPEN_NEW_TAB: 'open-new-tab',
COPY_LINK: 'copy-link',
};
export default {
components: {
@@ -37,6 +53,14 @@ export default {
type: String,
default: null,
},
conversationUrl: {
type: String,
default: '',
},
allowedOptions: {
type: Array,
default: () => [],
},
},
emits: [
'updateConversation',
@@ -47,6 +71,7 @@ export default {
'assignTeam',
'assignLabel',
'deleteConversation',
'close',
],
setup() {
const { isAdmin } = useAdmin();
@@ -56,6 +81,7 @@ export default {
},
data() {
return {
MENU,
STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
@@ -63,7 +89,7 @@ export default {
},
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail',
icon: 'mail-unread',
},
statusMenuConfig: [
{
@@ -88,7 +114,7 @@ export default {
icon: 'snooze',
},
priorityConfig: {
key: 'priority',
key: MENU.PRIORITY,
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
icon: 'warning',
options: [
@@ -115,25 +141,35 @@ export default {
].filter(item => item.key !== this.priority),
},
labelMenuConfig: {
key: 'label',
key: MENU.LABEL,
icon: 'tag',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
},
agentMenuConfig: {
key: 'agent',
key: MENU.AGENT,
icon: 'person-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
},
teamMenuConfig: {
key: 'team',
key: MENU.TEAM,
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
deleteOption: {
key: 'delete',
key: MENU.DELETE,
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
openInNewTabOption: {
key: MENU.OPEN_NEW_TAB,
icon: 'open',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.OPEN_IN_NEW_TAB'),
},
copyLinkOption: {
key: MENU.COPY_LINK,
icon: 'copy',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK'),
},
};
},
computed: {
@@ -180,6 +216,10 @@ export default {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
},
methods: {
isAllowed(keys) {
if (!this.allowedOptions.length) return true;
return keys.some(key => this.allowedOptions.includes(key));
},
toggleStatus(status, snoozedUntil) {
this.$emit('updateConversation', status, snoozedUntil);
},
@@ -194,6 +234,24 @@ export default {
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
openInNewTab() {
if (!this.conversationUrl) return;
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
window.open(url, '_blank', 'noopener,noreferrer');
this.$emit('close');
},
async copyConversationLink() {
if (!this.conversationUrl) return;
try {
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
await copyTextToClipboard(url);
useAlert(this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK_SUCCESS'));
this.$emit('close');
} catch (error) {
// error
}
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -217,83 +275,114 @@ export default {
</script>
<template>
<div class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px]">
<MenuItem
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click.stop="$emit('markAsUnread')"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<template v-for="option in statusMenuConfig">
<div
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
>
<template v-if="isAllowed([MENU.MARK_AS_READ, MENU.MARK_AS_UNREAD])">
<MenuItem
v-if="show(option.key)"
:key="option.key"
:option="option"
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click.stop="toggleStatus(option.key, null)"
@click.stop="$emit('markAsUnread')"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<MenuItem
v-if="showSnooze"
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItemWithSubmenu :option="priorityConfig">
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
<template v-for="option in statusMenuConfig">
<MenuItem
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
v-if="show(option.key) && isAllowed([MENU.STATUS])"
:key="option.key"
:option="option"
variant="icon"
@click.stop="toggleStatus(option.key, null)"
/>
</template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
v-if="showSnooze && isAllowed([MENU.SNOOZE])"
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
</MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template
v-if="isAllowed([MENU.PRIORITY, MENU.LABEL, MENU.AGENT, MENU.TEAM])"
>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.PRIORITY])"
:option="priorityConfig"
>
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.LABEL])"
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<MenuItem
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
/>
</template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.TEAM])"
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
/>
</MenuItemWithSubmenu>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template v-if="isAllowed([MENU.OPEN_NEW_TAB, MENU.COPY_LINK])">
<MenuItem
v-if="isAllowed([MENU.OPEN_NEW_TAB])"
:option="openInNewTabOption"
variant="icon"
@click.stop="openInNewTab"
/>
<MenuItem
v-if="isAllowed([MENU.COPY_LINK])"
:option="copyLinkOption"
variant="icon"
@click.stop="copyConversationLink"
/>
</template>
<template v-if="isAdmin && isAllowed([MENU.DELETE])">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
@@ -1,20 +1,16 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
defineProps({
option: {
type: Object,
default: () => {},
},
props: {
option: {
type: Object,
default: () => {},
},
variant: {
type: String,
default: 'default',
},
variant: {
type: String,
default: 'default',
},
};
});
</script>
<template>
@@ -30,12 +26,12 @@ export default {
class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }"
/>
<Thumbnail
<Avatar
v-if="variant === 'agent'"
:username="option.label"
:name="option.label"
:src="option.thumbnail"
:status="option.status"
size="20px"
:status="option.status === 'online' ? option.status : null"
:size="20"
class="flex-shrink-0"
/>
<p class="menu-label truncate min-w-0 flex-1">
@@ -263,7 +263,7 @@ export default {
<style scoped lang="scss">
.bulk-action__container {
@apply p-4 relative border-b border-solid border-n-strong dark:border-n-weak;
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@@ -1,12 +1,14 @@
import { computed } from 'vue';
import { useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
@@ -33,7 +35,9 @@ export function useCaptain() {
});
const fetchLimits = () => {
store.dispatch('accounts/limits');
if (isEnterprise) {
store.dispatch('accounts/limits');
}
};
return {
@@ -144,6 +144,9 @@
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
"OPEN_IN_NEW_TAB": "Open in new tab",
"COPY_LINK": "Copy conversation link",
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -72,6 +72,24 @@
"MARK_ALL_READ": "All notifications marked as read",
"DELETE_ALL": "All notifications deleted",
"DELETE_ALL_READ": "All read notifications deleted"
},
"REAUTHORIZE": {
"TITLE": "Reauthorization Required",
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
"BUTTON_TEXT": "Reconnect WhatsApp",
"LOADING_FACEBOOK": "Loading Facebook SDK...",
"SUCCESS": "WhatsApp reconnected successfully",
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
"TROUBLESHOOTING": {
"TITLE": "Troubleshooting",
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
"COOKIES": "Third-party cookies must be enabled",
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
}
}
}
}
@@ -598,6 +598,21 @@
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
"WHATSAPP_CONNECT_BUTTON": "Connect",
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
"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",
@@ -102,7 +102,7 @@ const visibleInfoItems = computed(() =>
>
<InboxName
:inbox="inbox"
class="mr-2 rtl:mr-0 rtl:ml-2 bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-11 dark:text-n-slate-11"
class="mx-2 bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-11 dark:text-n-slate-11"
/>
</div>
</div>
@@ -3,7 +3,7 @@ import WidgetHead from './WidgetHead.vue';
import WidgetBody from './WidgetBody.vue';
import WidgetFooter from './WidgetFooter.vue';
import InputRadioGroup from 'dashboard/routes/dashboard/settings/inbox/components/InputRadioGroup.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import { mapGetters } from 'vuex';
export default {
@@ -14,7 +14,6 @@ export default {
WidgetFooter,
InputRadioGroup,
},
mixins: [globalConfigMixin],
props: {
welcomeHeading: {
type: String,
@@ -57,6 +56,12 @@ export default {
default: '',
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
widgetScreens: [
@@ -159,9 +164,8 @@ export default {
/>
<span>
{{
useInstallationName(
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT'),
globalConfig.installationName
replaceInstallationName(
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT')
)
}}
</span>
@@ -60,7 +60,9 @@ export default {
:chat="conversation"
:hide-inbox-name="false"
hide-thumbnail
class="compact"
enable-context-menu
compact
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
/>
</div>
</div>
@@ -73,12 +75,4 @@ export default {
.no-label-message {
@apply text-n-slate-11 mb-4;
}
::v-deep .conversation {
@apply pr-0;
.conversation--details {
@apply pl-2;
}
}
</style>
@@ -246,7 +246,7 @@ onMounted(() => {
:key="notificationItem.id"
:inbox-item="notificationItem"
:state-inbox="stateInbox(notificationItem.primaryActor?.inboxId)"
class="inbox-card rounded-lg hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
class="inbox-card rounded-none hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
:class="
currentConversationId === notificationItem.primaryActor?.id
? 'bg-n-alpha-1 dark:bg-n-alpha-3 rounded-lg active'
@@ -1,32 +1,27 @@
<script>
<script setup>
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import MenuItem from './MenuItem.vue';
import MenuItem from 'dashboard/components/widgets/conversation/contextMenu/menuItem.vue';
export default {
components: {
MenuItem,
ContextMenu,
defineProps({
contextMenuPosition: {
type: Object,
default: () => ({}),
},
props: {
contextMenuPosition: {
type: Object,
default: () => ({}),
},
menuItems: {
type: Array,
default: () => [],
},
},
emits: ['close', 'selectAction'],
methods: {
handleClose() {
this.$emit('close');
},
onMenuItemClick(key) {
this.$emit('selectAction', key);
this.handleClose();
},
menuItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close', 'selectAction']);
const handleClose = () => {
emit('close');
};
const onMenuItemClick = key => {
emit('selectAction', key);
handleClose();
};
</script>
@@ -37,12 +32,14 @@ export default {
@close="handleClose"
>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] w-40 py-2 px-2 outline outline-1 outline-n-container shadow-lg rounded-xl"
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
>
<MenuItem
v-for="item in menuItems"
:key="item.key"
:label="item.label"
:option="item"
variant="icon"
class="!w-48"
@click.stop="onMenuItemClick(item.key)"
/>
</div>
@@ -3,14 +3,19 @@ import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
export default {
components: {
ChannelItem,
PageHeader,
},
mixins: [globalConfigMixin],
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
enabledFeatures: {},
@@ -69,12 +74,7 @@ export default {
<PageHeader
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.AUTH.TITLE')"
:header-content="
useInstallationName(
$t('INBOX_MGMT.ADD.AUTH.DESC'),
globalConfig.installationName
)
"
:header-content="replaceInstallationName($t('INBOX_MGMT.ADD.AUTH.DESC'))"
/>
<div
class="grid max-w-3xl grid-cols-2 mx-0 mt-6 sm:grid-cols-3 lg:grid-cols-4"
@@ -1,9 +1,14 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
export default {
mixins: [globalConfigMixin],
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
@@ -29,10 +34,7 @@ export default {
items() {
return this.createFlowSteps.map(item => ({
...item,
body: this.useInstallationName(
item.body,
this.globalConfig.installationName
),
body: this.replaceInstallationName(item.body),
}));
},
},
@@ -11,6 +11,7 @@ import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
@@ -44,6 +45,7 @@ export default {
GoogleReauthorize,
NextButton,
InstagramReauthorize,
WhatsappReauthorize,
DuplicateInboxBanner,
Editor,
},
@@ -87,10 +89,7 @@ export default {
return this.tabs[this.selectedTabIndex]?.key;
},
shouldShowWhatsAppConfiguration() {
return !!(
this.isAWhatsAppCloudChannel &&
this.inbox.provider_config?.source !== 'embedded_signup'
);
return this.isAWhatsAppCloudChannel;
},
whatsAppAPIProviderName() {
if (this.isAWhatsAppCloudChannel) {
@@ -247,6 +246,14 @@ export default {
this.inbox.reauthorization_required
);
},
whatsappUnauthorized() {
return (
this.isAWhatsAppChannel &&
this.inbox.provider === 'whatsapp_cloud' &&
this.inbox.provider_config?.source === 'embedded_signup' &&
this.inbox.reauthorization_required
);
},
},
watch: {
$route(to) {
@@ -416,6 +423,7 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<WhatsappReauthorize v-if="whatsappUnauthorized" :inbox="inbox" />
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
@@ -6,11 +6,11 @@ import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { required } from '@vuelidate/validators';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { mapGetters } from 'vuex';
import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
@@ -22,11 +22,12 @@ export default {
PageHeader,
NextButton,
},
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
const { replaceInstallationName } = useBranding();
return {
accountId,
replaceInstallationName,
v$: useVuelidate(),
};
},
@@ -66,9 +67,6 @@ export default {
getSelectablePages() {
return this.pageList.filter(item => !item.exists);
},
...mapGetters({
globalConfig: 'globalConfig/get',
}),
},
mounted() {
@@ -223,12 +221,7 @@ export default {
/>
</a>
<p class="py-6">
{{
useInstallationName(
$t('INBOX_MGMT.ADD.FB.HELP'),
globalConfig.installationName
)
}}
{{ replaceInstallationName($t('INBOX_MGMT.ADD.FB.HELP')) }}
</p>
</div>
<div v-else>
@@ -249,10 +242,7 @@ export default {
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.DETAILS.TITLE')"
:header-content="
useInstallationName(
$t('INBOX_MGMT.ADD.DETAILS.DESC'),
globalConfig.installationName
)
replaceInstallationName($t('INBOX_MGMT.ADD.DETAILS.DESC'))
"
/>
</div>
@@ -1,11 +1,9 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { useAccount } from 'dashboard/composables/useAccount';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import instagramClient from 'dashboard/api/channel/instagramClient';
export default {
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
return {
@@ -7,8 +7,13 @@ import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from './whatsapp/utils';
const store = useStore();
const router = useRouter();
@@ -120,14 +125,6 @@ const completeSignupFlow = async businessDataParam => {
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
businessDataLocal.business_id &&
businessDataLocal.waba_id
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
if (data.event === 'FINISH') {
@@ -162,9 +159,26 @@ const handleEmbeddedSignupData = async data => {
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
authCode.value = response.authResponse.code;
const handleSignupMessage = createMessageHandler(handleEmbeddedSignupData);
const launchEmbeddedSignup = async () => {
try {
isAuthenticating.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
fbSdkLoaded.value = true;
const code = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
authCode.value = code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
@@ -173,79 +187,18 @@ const fbLoginCallback = response => {
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} else if (response.error) {
handleSignupError({ error: response.error });
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Validate origin for security - following Facebook documentation
// https://developers.facebook.com/docs/whatsapp/embedded-signup/implementation#step-3--add-embedded-signup-to-your-website
if (!event.origin.endsWith('facebook.com')) return;
// Parse and handle WhatsApp embedded signup events
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch {
// Ignore non-JSON or irrelevant messages
}
};
const runFBInit = () => {
window.FB.init({
appId: window.chatwootConfig?.whatsappAppId,
autoLogAppEvents: true,
xfbml: true,
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
const loadFacebookSdk = async () => {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
async: true,
defer: true,
crossOrigin: 'anonymous',
});
};
const tryWhatsAppLogin = () => {
isAuthenticating.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '',
sessionInfoVersion: '3',
},
});
};
const launchEmbeddedSignup = async () => {
try {
// Load SDK first if not loaded, following Facebook.vue pattern exactly
await loadFacebookSdk();
runFBInit(); // Initialize FB after loading
// Now proceed with login
tryWhatsAppLogin();
} catch (error) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
});
if (error.message === 'Login cancelled') {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else {
handleSignupError({
error:
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
});
}
}
};
@@ -259,7 +212,6 @@ const cleanupMessageListener = () => {
};
const initialize = () => {
window.fbAsyncInit = runFBInit;
setupMessageListener();
};
@@ -0,0 +1,190 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import whatsappChannel from 'dashboard/api/channel/whatsappChannel';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from './utils';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const isRequestingAuthorization = ref(false);
const isLoadingFacebook = ref(true);
const whatsappAppId = computed(() => window.chatwootConfig.whatsappAppId);
const whatsappConfigurationId = computed(
() => window.chatwootConfig.whatsappConfigurationId
);
const reauthorizeWhatsApp = async params => {
isRequestingAuthorization.value = true;
try {
const response = await whatsappChannel.reauthorizeWhatsApp({
inboxId: props.inbox.id,
...params,
});
if (response.data.success) {
useAlert(t('INBOX.REAUTHORIZE.SUCCESS'));
} else {
useAlert(response.data.message || t('INBOX.REAUTHORIZE.ERROR'));
}
} catch (error) {
useAlert(error.message || t('INBOX.REAUTHORIZE.ERROR'));
} finally {
isRequestingAuthorization.value = false;
}
};
const handleEmbeddedSignupEvents = async (data, authCode) => {
if (!data || typeof data !== 'object') {
return;
}
// Handle different event types
if (data.event === 'FINISH') {
const businessData = data.data;
if (isValidBusinessData(businessData) && businessData.phone_number_id) {
await reauthorizeWhatsApp({
code: authCode,
business_id: businessData.business_id,
waba_id: businessData.waba_id,
phone_number_id: businessData.phone_number_id,
});
} else {
useAlert(
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA')
);
}
} else if (data.event === 'CANCEL') {
isRequestingAuthorization.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else if (data.event === 'error') {
isRequestingAuthorization.value = false;
useAlert(
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR')
);
}
};
const startEmbeddedSignup = authCode => {
const messageHandler = createMessageHandler(data =>
handleEmbeddedSignupEvents(data, authCode)
);
window.addEventListener('message', messageHandler);
};
const handleLoginAndReauthorize = async () => {
// Validate required configuration
if (!whatsappAppId.value) {
throw new Error('WhatsApp App ID is required');
}
if (!whatsappConfigurationId.value) {
throw new Error('WhatsApp Configuration ID is required');
}
try {
const authCode = await initWhatsAppEmbeddedSignup(
whatsappConfigurationId.value
);
// Check if this is a reauthorization scenario where we already have the business data
const existingConfig = props.inbox.provider_config;
if (
existingConfig &&
existingConfig.business_account_id &&
existingConfig.phone_number_id
) {
await reauthorizeWhatsApp({
code: authCode,
business_id: existingConfig.business_account_id,
waba_id: existingConfig.business_account_id,
phone_number_id: existingConfig.phone_number_id,
});
} else {
startEmbeddedSignup(authCode);
}
} catch (error) {
if (error.message === 'Login cancelled') {
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else {
useAlert(
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED')
);
}
throw error;
}
};
const requestAuthorization = async () => {
if (isLoadingFacebook.value) {
useAlert(t('INBOX.REAUTHORIZE.LOADING_FACEBOOK'));
return;
}
isRequestingAuthorization.value = true;
try {
await handleLoginAndReauthorize();
} catch (error) {
useAlert(error.message || t('INBOX.REAUTHORIZE.CONFIGURATION_ERROR'));
} finally {
// Reset only if not already processing through embedded signup
if (!window.FB || !window.FB.getLoginStatus) {
isRequestingAuthorization.value = false;
}
}
};
onMounted(async () => {
try {
// Validate required configuration
if (!whatsappAppId.value) {
useAlert(t('INBOX.REAUTHORIZE.WHATSAPP_APP_ID_MISSING'));
return;
}
if (!whatsappConfigurationId.value) {
useAlert(t('INBOX.REAUTHORIZE.WHATSAPP_CONFIG_ID_MISSING'));
return;
}
// Load Facebook SDK and initialize
await setupFacebookSdk(
whatsappAppId.value,
window.chatwootConfig?.whatsappApiVersion
);
} catch (error) {
useAlert(t('INBOX.REAUTHORIZE.FACEBOOK_LOAD_ERROR'));
} finally {
isLoadingFacebook.value = false;
}
});
// Expose requestAuthorization function for parent components
defineExpose({
requestAuthorization,
});
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
:is-loading="isRequestingAuthorization"
@reauthorize="requestAuthorization"
/>
</template>
@@ -0,0 +1,89 @@
import { loadScript } from 'dashboard/helper/DOMHelpers';
export const loadFacebookSdk = async () => {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
async: true,
defer: true,
crossOrigin: 'anonymous',
});
};
export const initializeFacebook = (appId, apiVersion) => {
const version = apiVersion || 'v22.0';
return new Promise(resolve => {
const init = () => {
window.FB.init({
appId,
autoLogAppEvents: true,
xfbml: true,
version,
});
resolve();
};
if (window.FB) {
init();
} else {
window.fbAsyncInit = init;
}
});
};
export const isValidBusinessData = businessData => {
return businessData && businessData.business_id && businessData.waba_id;
};
export const createMessageHandler = onEmbeddedSignupData => {
return event => {
if (!event.origin.endsWith('facebook.com')) return;
try {
let data;
if (typeof event.data === 'string') {
data = JSON.parse(event.data);
} else if (typeof event.data === 'object' && event.data !== null) {
data = event.data;
} else {
return;
}
if (data.type === 'WA_EMBEDDED_SIGNUP') {
onEmbeddedSignupData(data);
}
} catch {
// Ignore non-JSON or irrelevant messages
}
};
};
export const initWhatsAppEmbeddedSignup = configId => {
return new Promise((resolve, reject) => {
window.FB.login(
response => {
if (response.authResponse && response.authResponse.code) {
resolve(response.authResponse.code);
} else if (response.error) {
reject(new Error(response.error));
} else {
reject(new Error('Login cancelled'));
}
},
{
config_id: configId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '',
sessionInfoVersion: '3',
},
}
);
});
};
export const setupFacebookSdk = async (appId, apiVersion) => {
const version = apiVersion || 'v22.0';
await loadFacebookSdk();
await initializeFacebook(appId, version);
};
@@ -7,6 +7,7 @@ import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
export default {
components: {
@@ -14,6 +15,7 @@ export default {
ImapSettings,
SmtpSettings,
NextButton,
WhatsappReauthorize,
},
mixins: [inboxMixin],
props: {
@@ -29,12 +31,21 @@ export default {
return {
hmacMandatory: false,
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
};
},
validations: {
whatsAppInboxAPIKey: { required },
},
computed: {
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappAppId() {
return window.chatwootConfig?.whatsappAppId;
},
},
watch: {
inbox() {
this.setDefaults();
@@ -84,6 +95,11 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async handleReconfigure() {
if (this.$refs.whatsappReauth) {
await this.$refs.whatsappReauth.requestAuthorization();
}
},
async syncTemplates() {
this.isSyncingTemplates = true;
try {
@@ -210,45 +226,80 @@ export default {
</div>
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
<div v-if="inbox.provider_config" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')"
>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')"
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
"
>
<div
class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
<SettingsSection
v-if="whatsappAppId"
:title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')
"
>
<woot-input
v-model="whatsAppInboxAPIKey"
type="text"
class="flex-1 mr-2 [&>input]:!mb-0"
:placeholder="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
)
"
/>
<NextButton
:disabled="v$.whatsAppInboxAPIKey.$invalid"
@click="updateWhatsAppInboxAPIKey"
<div class="flex gap-4 items-center">
<p class="text-sm text-slate-600">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION'
)
}}
</p>
<NextButton @click="handleReconfigure">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</template>
<!-- Manual Setup Section -->
<template v-else>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
"
>
<div
class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
<woot-input
v-model="whatsAppInboxAPIKey"
type="text"
class="flex-1 mr-2 [&>input]:!mb-0"
:placeholder="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
)
"
/>
<NextButton
:disabled="v$.whatsAppInboxAPIKey.$invalid"
@click="updateWhatsAppInboxAPIKey"
>
{{
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON')
}}
</NextButton>
</div>
</SettingsSection>
</template>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:sub-title="
@@ -262,6 +313,12 @@ export default {
</div>
</SettingsSection>
</div>
<WhatsappReauthorize
v-if="isEmbeddedSignupWhatsApp"
ref="whatsappReauth"
:inbox="inbox"
class="hidden"
/>
</div>
</template>
@@ -3,7 +3,6 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import DashboardAppModal from './DashboardAppModal.vue';
import DashboardAppsRow from './DashboardAppsRow.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -14,7 +13,6 @@ export default {
DashboardAppsRow,
NextButton,
},
mixins: [globalConfigMixin],
data() {
return {
loading: {},
@@ -1,12 +1,14 @@
<script setup>
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, onMounted } from 'vue';
import { useBranding } from 'shared/composables/useBranding';
import IntegrationItem from './IntegrationItem.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const store = useStore();
const getters = useStoreGetters();
const { replaceInstallationName } = useBranding();
const uiFlags = getters['integrations/getUIFlags'];
@@ -27,7 +29,9 @@ onMounted(() => {
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.HEADER')"
:description="$t('INTEGRATION_SETTINGS.DESCRIPTION')"
:description="
replaceInstallationName($t('INTEGRATION_SETTINGS.DESCRIPTION'))
"
:link-text="$t('INTEGRATION_SETTINGS.LEARN_MORE')"
feature-name="integrations"
/>
@@ -5,7 +5,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { frontendURL } from '../../../../helper/URLHelper';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -26,11 +26,11 @@ const props = defineProps({
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const { replaceInstallationName } = useBranding();
const dialogRef = ref(null);
const accountId = computed(() => store.getters.getCurrentAccountId);
const globalConfig = computed(() => store.getters['globalConfig/get']);
const openDeletePopup = () => {
if (dialogRef.value) {
@@ -82,12 +82,7 @@ const confirmDeletion = () => {
{{ integrationName }}
</h3>
<p class="text-n-slate-11 text-sm leading-6">
{{
useInstallationName(
integrationDescription,
globalConfig.installationName
)
}}
{{ replaceInstallationName(integrationDescription) }}
</p>
</div>
</div>
@@ -3,7 +3,7 @@ import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -28,9 +28,9 @@ const props = defineProps({
const getters = useStoreGetters();
const accountId = getters.getCurrentAccountId;
const globalConfig = getters['globalConfig/get'];
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const integrationStatus = computed(() =>
props.enabled
@@ -80,7 +80,7 @@ const actionURL = computed(() =>
</router-link>
</div>
<p class="text-n-slate-11">
{{ useInstallationName(description, globalConfig.installationName) }}
{{ replaceInstallationName(description) }}
</p>
</div>
</div>
@@ -1,5 +1,4 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration.vue';
import IntegrationHelpText from './IntegrationHelpText.vue';
@@ -8,8 +7,6 @@ export default {
Integration,
IntegrationHelpText,
},
mixins: [globalConfigMixin],
props: {
integrationId: {
type: [String, Number],
@@ -3,7 +3,7 @@ import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -18,6 +18,7 @@ const store = useStore();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const { replaceInstallationName } = useBranding();
const selectedChannelId = ref('');
const availableChannels = ref([]);
@@ -29,16 +30,9 @@ const errorDescription = computed(() => {
? t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.DESCRIPTION')
: t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.EXPIRED');
});
const globalConfig = computed(() => store.getters['globalConfig/get']);
const formattedErrorMessage = computed(() => {
return formatMessage(
useInstallationName(
errorDescription.value,
globalConfig.value.installationName
),
false
);
return formatMessage(replaceInstallationName(errorDescription.value), false);
});
const fetchChannels = async () => {
@@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
@@ -17,6 +18,10 @@ export default {
EditWebhook,
WebhookRow,
},
setup() {
const { replaceInstallationName } = useBranding();
return { replaceInstallationName };
},
data() {
return {
loading: {},
@@ -99,7 +104,7 @@ export default {
<BaseSettingsHeader
v-if="integration.name"
:title="integration.name"
:description="integration.description"
:description="replaceInstallationName(integration.description)"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
@@ -1,21 +1,25 @@
<script>
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import { mapGetters } from 'vuex';
import WebhookForm from './WebhookForm.vue';
export default {
components: { WebhookForm },
mixins: [globalConfigMixin],
props: {
onClose: {
type: Function,
required: true,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
uiFlags: 'webhooks/getUIFlags',
}),
},
@@ -43,10 +47,7 @@ export default {
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
useInstallationName(
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'),
globalConfig.installationName
)
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
"
/>
<WebhookForm
@@ -3,10 +3,10 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFontSize } from 'dashboard/composables/useFontSize';
import { useBranding } from 'shared/composables/useBranding';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
@@ -37,16 +37,17 @@ export default {
AudioNotifications,
AccessToken,
},
mixins: [globalConfigMixin],
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
const { currentFontSize, updateFontSize } = useFontSize();
const { replaceInstallationName } = useBranding();
return {
currentFontSize,
updateFontSize,
isEditorHotKeyEnabled,
updateUISettings,
replaceInstallationName,
};
},
data() {
@@ -215,7 +216,11 @@ export default {
</div>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.NOTE')"
:description="
replaceInstallationName(
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.NOTE')
)
"
>
<FontSize
:value="currentFontSize"
@@ -288,10 +293,7 @@ export default {
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
useInstallationName(
$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'),
globalConfig.installationName
)
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
"
>
<AccessToken
@@ -4,6 +4,7 @@ import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useRouter } from 'vue-router';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { differenceInDays } from 'date-fns';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useI18n } from 'vue-i18n';
@@ -22,6 +23,7 @@ const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { accountId, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const { isAdmin } = useAdmin();
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
@@ -100,7 +102,11 @@ const routeToBilling = () => {
});
};
onMounted(() => fetchLimits());
onMounted(() => {
if (isEnterprise) {
fetchLimits();
}
});
defineExpose({ shouldShowUpgradePage });
</script>
@@ -1,5 +1,5 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
const {
LOGO_THUMBNAIL: logoThumbnail,
@@ -8,13 +8,18 @@ const {
} = window.globalConfig || {};
export default {
mixins: [globalConfigMixin],
props: {
disableBranding: {
type: Boolean,
default: false,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
globalConfig: {
@@ -61,7 +66,7 @@ export default {
:src="globalConfig.logoThumbnail"
/>
<span>
{{ useInstallationName($t('POWERED_BY'), globalConfig.brandName) }}
{{ replaceInstallationName($t('POWERED_BY')) }}
</span>
</a>
</div>
@@ -0,0 +1,96 @@
import { useBranding } from '../useBranding';
import { useMapGetter } from 'dashboard/composables/store.js';
// Mock the store composable
vi.mock('dashboard/composables/store.js', () => ({
useMapGetter: vi.fn(),
}));
describe('useBranding', () => {
let mockGlobalConfig;
beforeEach(() => {
mockGlobalConfig = {
value: {
installationName: 'MyCompany',
},
};
useMapGetter.mockReturnValue(mockGlobalConfig);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('replaceInstallationName', () => {
it('should replace "Chatwoot" with installation name when both text and installation name are provided', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to MyCompany');
});
it('should replace multiple occurrences of "Chatwoot"', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
'Chatwoot is great! Use Chatwoot today.'
);
expect(result).toBe('MyCompany is great! Use MyCompany today.');
});
it('should return original text when installation name is not provided', () => {
mockGlobalConfig.value = {};
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to Chatwoot');
});
it('should return original text when globalConfig is not available', () => {
mockGlobalConfig.value = undefined;
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to Chatwoot');
});
it('should return original text when text is empty or null', () => {
const { replaceInstallationName } = useBranding();
expect(replaceInstallationName('')).toBe('');
expect(replaceInstallationName(null)).toBe(null);
expect(replaceInstallationName(undefined)).toBe(undefined);
});
it('should handle text without "Chatwoot" gracefully', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to our platform');
expect(result).toBe('Welcome to our platform');
});
it('should be case-sensitive for "Chatwoot"', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
'Welcome to chatwoot and CHATWOOT'
);
expect(result).toBe('Welcome to chatwoot and CHATWOOT');
});
it('should handle special characters in installation name', () => {
mockGlobalConfig.value = {
installationName: 'My-Company & Co.',
};
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to My-Company & Co.');
});
});
});
@@ -0,0 +1,26 @@
/**
* Composable for branding-related utilities
* Provides methods to customize text with installation-specific branding
*/
import { useMapGetter } from 'dashboard/composables/store.js';
export function useBranding() {
const globalConfig = useMapGetter('globalConfig/get');
/**
* Replaces "Chatwoot" in text with the installation name from global config
* @param {string} text - The text to process
* @returns {string} - Text with "Chatwoot" replaced by installation name
*/
const replaceInstallationName = text => {
if (!text) return text;
const installationName = globalConfig.value?.installationName;
if (!installationName) return text;
return text.replace(/Chatwoot/g, installationName);
};
return {
replaceInstallationName,
};
}
@@ -1,12 +0,0 @@
export const useInstallationName = (str, installationName) => {
if (str && installationName) {
return str.replace(/Chatwoot/g, installationName);
}
return str;
};
export default {
methods: {
useInstallationName,
},
};
@@ -1,18 +1,17 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { required, minLength, email } from '@vuelidate/validators';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import FormInput from '../../../../components/Form/Input.vue';
import { resetPassword } from '../../../../api/auth';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { FormInput, NextButton },
mixins: [globalConfigMixin],
setup() {
return { v$: useVuelidate() };
const { replaceInstallationName } = useBranding();
return { v$: useVuelidate(), replaceInstallationName };
},
data() {
return {
@@ -24,9 +23,6 @@ export default {
error: '',
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
validations() {
return {
credentials: {
@@ -82,12 +78,7 @@ export default {
<p
class="mb-4 text-sm font-normal leading-6 tracking-normal text-n-slate-11"
>
{{
useInstallationName(
$t('RESET_PASSWORD.DESCRIPTION'),
globalConfig.installationName
)
}}
{{ replaceInstallationName($t('RESET_PASSWORD.DESCRIPTION')) }}
</p>
<div class="space-y-5">
<FormInput
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import SignupForm from './components/Signup/Form.vue';
import Testimonials from './components/Testimonials/Index.vue';
import Spinner from 'shared/components/Spinner.vue';
@@ -11,7 +11,10 @@ export default {
Spinner,
Testimonials,
},
mixins: [globalConfigMixin],
setup() {
const { replaceInstallationName } = useBranding();
return { replaceInstallationName };
},
data() {
return { isLoading: false };
},
@@ -61,12 +64,7 @@ export default {
<div class="px-1 text-sm text-n-slate-12">
<span>{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}</span>
<router-link class="text-link text-n-brand" to="/app/login">
{{
useInstallationName(
$t('LOGIN.TITLE'),
globalConfig.installationName
)
}}
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
</router-link>
</div>
</div>
@@ -3,7 +3,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import FormInput from '../../../../../components/Form/Input.vue';
@@ -20,7 +19,6 @@ export default {
NextButton,
VueHcaptcha,
},
mixins: [globalConfigMixin],
setup() {
return { v$: useVuelidate() };
},
+7 -7
View File
@@ -8,8 +8,7 @@ import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
// mixins
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
// components
import FormInput from '../../components/Form/Input.vue';
@@ -31,7 +30,6 @@ export default {
Spinner,
NextButton,
},
mixins: [globalConfigMixin],
props: {
ssoAuthToken: { type: String, default: '' },
ssoAccountId: { type: String, default: '' },
@@ -40,7 +38,11 @@ export default {
authError: { type: String, default: '' },
},
setup() {
return { v$: useVuelidate() };
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
v$: useVuelidate(),
};
},
data() {
return {
@@ -182,9 +184,7 @@ export default {
class="hidden w-auto h-8 mx-auto dark:block"
/>
<h2 class="mt-6 text-3xl font-medium text-center text-n-slate-12">
{{
useInstallationName($t('LOGIN.TITLE'), globalConfig.installationName)
}}
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
</h2>
<p v-if="showSignupLink" class="mt-3 text-sm text-center text-n-slate-11">
{{ $t('COMMON.OR') }}
+8 -3
View File
@@ -17,7 +17,7 @@ class AutomationRuleListener < BaseListener
end
def conversation_created(event)
return if performed_by_automation?(event)
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
account = conversation.account
@@ -34,7 +34,7 @@ class AutomationRuleListener < BaseListener
end
def conversation_opened(event)
return if performed_by_automation?(event)
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
account = conversation.account
@@ -87,8 +87,13 @@ class AutomationRuleListener < BaseListener
event.data[:performed_by].present? && event.data[:performed_by].instance_of?(AutomationRule)
end
def ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
conversation.additional_attributes['auto_reply'].present?
end
def ignore_message_created_event?(event)
message = event.data[:message]
performed_by_automation?(event) || message.activity?
performed_by_automation?(event) || message.activity? || message.auto_reply_email?
end
end
+19 -1
View File
@@ -37,10 +37,11 @@ class HookListener < BaseListener
private
def execute_hooks(event, message)
message.account.hooks.each do |hook|
message.account.hooks.find_each do |hook|
# In case of dialogflow, we would have a hook for each inbox.
# Which means we will execute the same hook multiple times if the below filter isn't there
next if hook.inbox.present? && hook.inbox != message.inbox
next unless supported_hook_event?(hook, event.name)
HookJob.perform_later(hook, event.name, message: message)
end
@@ -48,7 +49,24 @@ class HookListener < BaseListener
def execute_account_hooks(event, account, event_data = {})
account.hooks.account_hooks.find_each do |hook|
next unless supported_hook_event?(hook, event.name)
HookJob.perform_later(hook, event.name, event_data)
end
end
def supported_hook_event?(hook, event_name)
return false if hook.disabled?
supported_events_map = {
'slack' => ['message.created'],
'dialogflow' => ['message.created', 'message.updated'],
'google_translate' => ['message.created'],
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
}
return false unless supported_events_map.key?(hook.app_id)
supported_events_map[hook.app_id].include?(event_name)
end
end
+1
View File
@@ -84,6 +84,7 @@ class Imap::ImapMailbox
additional_attributes: {
source: 'email',
in_reply_to: in_reply_to,
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
@@ -8,13 +8,6 @@ module IncomingEmailValidityHelper
# This can happen in cases like bounce emails for invalid contact email address
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
# Process bounced emails, as regular emails
return true if @processed_mail.bounced?
# we skip processing auto reply emails like delivery status notifications
# out of office replies, etc.
return false if auto_reply_email?
true
end
@@ -24,13 +17,4 @@ module IncomingEmailValidityHelper
true
end
def auto_reply_email?
if @processed_mail.auto_reply?
Rails.logger.info "is_auto_reply? : #{processed_mail.auto_reply?}"
true
else
false
end
end
end
+1
View File
@@ -70,6 +70,7 @@ class SupportMailbox < ApplicationMailbox
additional_attributes: {
in_reply_to: in_reply_to,
source: 'email',
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
+6 -2
View File
@@ -32,8 +32,8 @@ class Channel::Whatsapp < ApplicationRecord
validates :phone_number, presence: true, uniqueness: true
validate :validate_provider_config
before_save :setup_webhooks
after_create :sync_templates
after_create_commit :setup_webhooks
before_destroy :teardown_webhooks
def name
@@ -78,8 +78,12 @@ class Channel::Whatsapp < ApplicationRecord
handle_webhook_setup_error(e)
end
def provider_config_changed?
will_save_change_to_provider_config?
end
def should_setup_webhooks?
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present? && provider_config_changed?
end
def whatsapp_cloud_provider?
+6
View File
@@ -195,6 +195,12 @@ class Message < ApplicationRecord
true
end
def auto_reply_email?
return false unless incoming_email? || inbox.email?
content_attributes.dig(:email, :auto_reply) == true
end
def valid_first_reply?
return false unless human_response? && !private?
return false if conversation.first_reply_created_at.present?
+2 -1
View File
@@ -103,7 +103,8 @@ class MailPresenter < SimpleDelegator
references: references,
subject: subject,
text_content: text_content,
to: to
to: to,
auto_reply: auto_reply?
}
end
@@ -4,6 +4,7 @@ class MessageTemplates::HookExecutionService
def perform
return if conversation.campaign.present?
return if conversation.last_incoming_message.blank?
return if message.auto_reply_email?
trigger_templates
end
@@ -61,6 +61,9 @@ class Twilio::IncomingMessageService
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
# Update existing contact name if ProfileName is available and current name is just phone number
update_contact_name_if_needed
end
def conversation_params
@@ -88,12 +91,16 @@ class Twilio::IncomingMessageService
def contact_attributes
{
name: formatted_phone_number,
name: contact_name,
phone_number: phone_number,
additional_attributes: additional_attributes
}
end
def contact_name
params[:ProfileName].presence || formatted_phone_number
end
def additional_attributes
if twilio_channel.sms?
{
@@ -169,4 +176,18 @@ class Twilio::IncomingMessageService
coordinates_long: params[:Longitude].to_f
)
end
def update_contact_name_if_needed
return if params[:ProfileName].blank?
return if @contact.name == params[:ProfileName]
# Only update if current name exactly matches the phone number or formatted phone number
return unless contact_name_matches_phone_number?
@contact.update!(name: params[:ProfileName])
end
def contact_name_matches_phone_number?
@contact.name == phone_number || @contact.name == formatted_phone_number
end
end
@@ -1,10 +1,11 @@
class Whatsapp::EmbeddedSignupService
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
def initialize(account:, params:, inbox_id: nil)
@account = account
@code = code
@business_id = business_id
@waba_id = waba_id
@phone_number_id = phone_number_id
@code = params[:code]
@business_id = params[:business_id]
@waba_id = params[:waba_id]
@phone_number_id = params[:phone_number_id]
@inbox_id = inbox_id
end
def perform
@@ -19,11 +20,19 @@ class Whatsapp::EmbeddedSignupService
# Validate token has access to the WABA
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
# Create channel
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
# Webhook setup is now handled in the channel after_create_commit callback
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
# Reauthorization flow if inbox_id is present
if @inbox_id.present?
Whatsapp::ReauthorizationService.new(
account: @account,
inbox_id: @inbox_id,
phone_number_id: @phone_number_id,
business_id: @business_id
).perform(access_token, phone_info)
else
# Create channel for new authorization
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
end
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
raise e
@@ -156,11 +156,18 @@ class Whatsapp::IncomingMessageBaseService
phones = contact[:phones]
phones = [{ phone: 'Phone number is not available' }] if phones.blank?
name_info = contact['name'] || {}
contact_meta = {
firstName: name_info['first_name'],
lastName: name_info['last_name']
}.compact
phones.each do |phone|
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type(message_type),
fallback_title: phone[:phone].to_s
fallback_title: phone[:phone].to_s,
meta: contact_meta
)
end
end
@@ -0,0 +1,42 @@
class Whatsapp::ReauthorizationService
def initialize(account:, inbox_id:, phone_number_id:, business_id:)
@account = account
@inbox_id = inbox_id
@phone_number_id = phone_number_id
@business_id = business_id
end
def perform(access_token, phone_info)
inbox = @account.inboxes.find(@inbox_id)
channel = inbox.channel
# Validate phone number matches for reauthorization
if phone_info[:phone_number] != channel.phone_number
raise StandardError, "Phone number mismatch. Expected #{channel.phone_number}, got #{phone_info[:phone_number]}"
end
# Update channel configuration
update_channel_config(channel, access_token, phone_info)
# Mark as reauthorized
channel.reauthorized! if channel.respond_to?(:reauthorized!)
channel
end
private
def update_channel_config(channel, access_token, phone_info)
current_config = channel.provider_config || {}
channel.provider_config = current_config.merge(
'api_key' => access_token,
'phone_number_id' => @phone_number_id,
'business_account_id' => @business_id,
'source' => 'embedded_signup'
)
channel.save!
# Update inbox name if business name changed
business_name = phone_info[:business_name] || phone_info[:verified_name]
channel.inbox.update!(name: business_name) if business_name.present?
end
end
@@ -116,4 +116,5 @@ json.provider resource.channel.try(:provider)
if resource.whatsapp?
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
+12
View File
@@ -31,6 +31,11 @@
en:
hello: 'Hello world'
inbox:
reauthorization:
success: 'Channel reauthorized successfully'
not_required: 'Reauthorization is not required for this inbox'
invalid_channel: 'Invalid channel type for reauthorization'
messages:
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
reset_password_failure: Uh ho! We could not find any user with the specified email.
@@ -67,6 +72,13 @@ en:
invalid_message_type: 'Invalid message type. Action not permitted'
slack:
invalid_channel_id: 'Invalid slack channel. Please try again'
whatsapp:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
inboxes:
imap:
socket_error: Please check the network connection, IMAP address and try again.
@@ -0,0 +1,11 @@
class AddNotificationsPerformanceIndex < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
# Add composite index to optimize notification count queries
# This covers the common query pattern: WHERE user_id = ? AND account_id = ? AND snoozed_until IS NULL AND read_at IS NULL
add_index :notifications, [:user_id, :account_id, :snoozed_until, :read_at],
name: 'idx_notifications_performance',
algorithm: :concurrently
end
end
+2 -1
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_22_152516) do
ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -909,6 +909,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
t.index ["last_activity_at"], name: "index_notifications_on_last_activity_at"
t.index ["primary_actor_type", "primary_actor_id"], name: "uniq_primary_actor_per_account_notifications"
t.index ["secondary_actor_type", "secondary_actor_id"], name: "uniq_secondary_actor_per_account_notifications"
t.index ["user_id", "account_id", "snoozed_until", "read_at"], name: "idx_notifications_performance"
t.index ["user_id"], name: "index_notifications_on_user_id"
end
@@ -119,19 +119,18 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
code: 'test_code',
business_id: 'test_business_id',
waba_id: 'test_waba_id',
phone_number_id: 'test_phone_id'
params: {
code: 'test_code',
business_id: 'test_business_id',
waba_id: 'test_waba_id',
phone_number_id: 'test_phone_id'
},
inbox_id: nil
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
# Stub webhook setup service
webhook_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
allow(webhook_service).to receive(:perform)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(instance_double(Whatsapp::WebhookSetupService, perform: true))
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: {
@@ -151,19 +150,17 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
code: 'test_code',
business_id: 'test_business_id',
waba_id: 'test_waba_id',
phone_number_id: nil
params: {
code: 'test_code',
business_id: 'test_business_id',
waba_id: 'test_waba_id'
},
inbox_id: nil
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
# Stub webhook setup service
webhook_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
allow(webhook_service).to receive(:perform)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(instance_double(Whatsapp::WebhookSetupService, perform: true))
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: {
@@ -300,4 +297,236 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
end
end
end
describe 'POST /api/v1/accounts/{account.id}/whatsapp/authorization with inbox_id (reauthorization)' do
let(:whatsapp_channel) do
channel = build(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
provider_config: {
'api_key' => 'test_token',
'phone_number_id' => '123456',
'business_account_id' => '654321',
'source' => 'embedded_signup'
})
allow(channel).to receive(:validate_provider_config).and_return(true)
allow(channel).to receive(:sync_templates).and_return(true)
allow(channel).to receive(:setup_webhooks).and_return(true)
channel.save!
# Call authorization_error! twice to reach the threshold
channel.authorization_error!
channel.authorization_error!
channel
end
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
context 'when user is an administrator' do
let(:administrator) { create(:user, account: account, role: :administrator) }
before do
account.enable_features!(:whatsapp_embedded_signup)
end
context 'with valid parameters' do
let(:valid_params) do
{
code: 'auth_code_123',
business_id: 'business_123',
waba_id: 'waba_123',
phone_number_id: 'phone_123'
}
end
it 'reauthorizes the WhatsApp channel successfully' do
allow(whatsapp_channel).to receive(:reauthorization_required?).and_return(true)
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
params: {
code: 'auth_code_123',
business_id: 'business_123',
waba_id: 'waba_123',
phone_number_id: 'phone_123'
},
inbox_id: whatsapp_inbox.id
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
allow(whatsapp_channel).to receive(:inbox).and_return(whatsapp_inbox)
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: valid_params.merge(inbox_id: whatsapp_inbox.id),
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['success']).to be true
expect(json_response['id']).to eq(whatsapp_inbox.id)
end
it 'handles reauthorization failure' do
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
params: {
code: 'auth_code_123',
business_id: 'business_123',
waba_id: 'waba_123',
phone_number_id: 'phone_123'
},
inbox_id: whatsapp_inbox.id
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform)
.and_raise(StandardError, 'Token exchange failed')
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: valid_params.merge(inbox_id: whatsapp_inbox.id),
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['success']).to be false
expect(json_response['error']).to eq('Token exchange failed')
end
it 'handles phone number mismatch error' do
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
params: {
code: 'auth_code_123',
business_id: 'business_123',
waba_id: 'waba_123',
phone_number_id: 'phone_123'
},
inbox_id: whatsapp_inbox.id
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform)
.and_raise(StandardError, 'Phone number mismatch. The new phone number (+1234567890) does not match ' \
'the existing phone number (+15551234567). Please use the same WhatsApp ' \
'Business Account that was originally connected.')
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: valid_params.merge(inbox_id: whatsapp_inbox.id),
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['success']).to be false
expect(json_response['error']).to include('Phone number mismatch')
end
end
context 'when inbox does not exist' do
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: { inbox_id: 0, code: 'test', business_id: 'test', waba_id: 'test' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when reauthorization is not required' do
let(:fresh_channel) do
channel = build(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
provider_config: {
'api_key' => 'test_token',
'phone_number_id' => '123456',
'business_account_id' => '654321',
'source' => 'embedded_signup'
})
allow(channel).to receive(:validate_provider_config).and_return(true)
allow(channel).to receive(:sync_templates).and_return(true)
allow(channel).to receive(:setup_webhooks).and_return(true)
channel.save!
# Do NOT call authorization_error! - channel is working fine
channel
end
let(:fresh_inbox) { create(:inbox, channel: fresh_channel, account: account) }
it 'returns unprocessable entity error' do
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: { inbox_id: fresh_inbox.id },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['success']).to be false
end
end
context 'when channel is not WhatsApp' do
let(:facebook_channel) do
stub_request(:post, 'https://graph.facebook.com/v3.2/me/subscribed_apps')
.to_return(status: 200, body: '{}', headers: {})
channel = create(:channel_facebook_page, account: account)
# Call authorization_error! twice to reach the threshold
channel.authorization_error!
channel.authorization_error!
channel
end
let(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) }
it 'returns unprocessable entity error' do
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: { inbox_id: facebook_inbox.id },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['success']).to be false
end
end
end
context 'when user is an agent' do
let(:agent) { create(:user, account: account, role: :agent) }
before do
account.enable_features!(:whatsapp_embedded_signup)
create(:inbox_member, inbox: whatsapp_inbox, user: agent)
end
it 'returns unprocessable_entity error' do
allow(whatsapp_channel).to receive(:reauthorization_required?).and_return(true)
# Stub the embedded signup service to prevent HTTP calls
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
account: account,
params: {
code: 'test',
business_id: 'test',
waba_id: 'test'
},
inbox_id: whatsapp_inbox.id
).and_return(embedded_signup_service)
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: { inbox_id: whatsapp_inbox.id, code: 'test', business_id: 'test', waba_id: 'test' },
headers: agent.create_new_auth_token,
as: :json
# Agents should get unprocessable_entity since they can find the inbox but channel doesn't need reauth
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'when user is not authenticated' do
it 'returns unauthorized error' do
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
params: { inbox_id: whatsapp_inbox.id },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end
+19 -2
View File
@@ -66,14 +66,31 @@ RSpec.describe NotificationFinder do
expect(subject.unread_count).to eq(3)
expect(subject.count).to eq(3)
end
it 'avoids duplicate filtering in unread_count method' do
# Test the logical fix: when no 'read' filter is included,
# @notifications is already filtered to unread, so unread_count
# should just count without adding another read_at filter
allow(subject.instance_variable_get(:@notifications)).to receive(:where).and_call_original
allow(subject.instance_variable_get(:@notifications)).to receive(:count).and_call_original
result = subject.unread_count
# Should return correct count without additional where clause
expect(result).to eq(3)
# The fix ensures that when params[:includes] doesn't contain 'read',
# unread_count uses @notifications.count instead of @notifications.where(read_at: nil).count
end
end
context 'with filters applied' do
let(:params) { { includes: %w[read snoozed] } }
it 'adjusts counts based on included statuses' do
expect(subject.unread_count).to eq(4)
expect(subject.count).to eq(6)
expect(subject.unread_count).to eq(4) # 3 unread + 1 snoozed (which is unread)
expect(subject.count).to eq(6) # all notifications including read and snoozed
end
end
end
@@ -48,6 +48,13 @@ describe AutomationRuleListener do
listener.conversation_created(event)
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
it 'does not call AutomationRules::ActionService if conversation has auto_reply in additional_attributes' do
conversation.additional_attributes = { 'auto_reply' => true }
allow(condition_match).to receive(:present?).and_return(true)
listener.conversation_created(event)
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
end
end
@@ -165,6 +172,18 @@ describe AutomationRuleListener do
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
it 'does not call AutomationRules::ActionService if message is auto reply email' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
email_conversation = create(:conversation, inbox: email_inbox, account: account)
email_message = create(:message, conversation: email_conversation, account: account, content_attributes: { email: { auto_reply: true } })
email_event = Events::Base.new('message_created', Time.zone.now, { message: email_message })
allow(condition_match).to receive(:present?).and_return(true)
listener.message_created(email_event)
expect(AutomationRules::ActionService).not_to have_received(:new)
end
it 'does not call AutomationRules::ActionService if conditions do not match based on content' do
message.update!(processed_message_content: 'hi', content: "hi\n\nhello")
allow(condition_match).to receive(:present?).and_return(false)
+81 -1
View File
@@ -10,6 +10,8 @@ describe HookListener do
account: account, inbox: inbox, conversation: conversation)
end
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
let(:contact_event) { Events::Base.new('contact.updated', Time.zone.now, contact: conversation.contact) }
let(:conversation_event) { Events::Base.new('conversation.created', Time.zone.now, conversation: conversation) }
describe '#message_created' do
let(:event_name) { 'message.created' }
@@ -42,10 +44,88 @@ describe HookListener do
context 'when hook is configured' do
it 'triggers hook job' do
hook = create(:integrations_hook, account: account)
hook = create(:integrations_hook, :dialogflow, account: account, inbox: inbox)
expect(HookJob).to receive(:perform_later).with(hook, 'message.updated', message: message).once
listener.message_updated(event)
end
end
end
describe 'hook job enqueuing behavior' do
let(:event_name) { 'message.created' }
context 'when app_id is not in the allowed list' do
it 'does not enqueue the job' do
create(:integrations_hook, account: account, app_id: 'unsupported_app')
expect(HookJob).not_to receive(:perform_later)
listener.message_created(event)
end
end
context 'when hook is enabled and app_id is supported' do
it 'enqueues the job for slack' do
hook = create(:integrations_hook, account: account)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
listener.message_created(event)
end
it 'enqueues the job for dialogflow' do
hook = create(:integrations_hook, :dialogflow, account: account, inbox: inbox)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
listener.message_created(event)
end
it 'enqueues the job for google_translate' do
hook = create(:integrations_hook, :google_translate, account: account)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
listener.message_created(event)
end
end
context 'with disabled hook' do
it 'does not enqueue job for disabled hooks' do
create(:integrations_hook, account: account, status: 'disabled', app_id: 'slack')
expect(HookJob).not_to receive(:perform_later)
listener.message_created(event)
end
end
context 'with unsupported app_id and event combination' do
it 'does not enqueue job for unsupported app_id' do
create(:integrations_hook, account: account, app_id: 'unsupported_app')
expect(HookJob).not_to receive(:perform_later)
listener.message_created(event)
end
end
context 'with leadsquared hook' do
let(:hook) { create(:integrations_hook, :leadsquared, account: account) }
before do
account.enable_features(:crm_integration)
end
it 'enqueues the job for conversation.created' do
expect(HookJob)
.to receive(:perform_later)
.with(hook, 'conversation.created', { conversation: conversation })
listener.conversation_created(conversation_event)
end
it 'enqueues the job for contact.updated' do
expect(HookJob)
.to receive(:perform_later)
.with(hook, 'contact.updated', { contact: conversation.contact })
listener.contact_updated(contact_event)
end
end
end
end
+4 -1
View File
@@ -111,7 +111,8 @@ RSpec.describe Imap::ImapMailbox do
let(:auto_reply_mail) { create_inbound_email_from_fixture('auto_reply.eml') }
it 'does not create a new conversation' do
expect { class_instance.process(auto_reply_mail.mail, channel) }.not_to change(Conversation, :count)
expect { class_instance.process(auto_reply_mail.mail, channel) }.to change(Conversation, :count)
expect(Conversation.last.additional_attributes['auto_reply']).to be true
end
end
@@ -120,6 +121,8 @@ RSpec.describe Imap::ImapMailbox do
it 'processes the bounced email' do
expect { class_instance.process(bounced_mail.mail, channel) }.to change(Message, :count)
expect(Message.last.content_attributes['email']['auto_reply']).to be true
expect(Conversation.last.additional_attributes['auto_reply']).to be true
end
end
+2 -1
View File
@@ -12,7 +12,8 @@ RSpec.describe ReplyMailbox do
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
let(:described_subject) { described_class.receive reply_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to]
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to
auto_reply]
end
context 'with reply uuid present' do
+1 -1
View File
@@ -56,7 +56,7 @@ RSpec.describe SupportMailbox do
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to]
text_content to auto_reply]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
+65
View File
@@ -548,4 +548,69 @@ RSpec.describe Message do
expect(presenter).to have_received(:outgoing_content)
end
end
describe '#auto_reply_email?' do
context 'when message is not an incoming email and inbox is not email' do
let(:conversation) { create(:conversation) }
let(:message) { create(:message, conversation: conversation, message_type: :outgoing) }
it 'returns false' do
expect(message.auto_reply_email?).to be false
end
end
context 'when message is an incoming email' do
let(:email_channel) { create(:channel_email) }
let(:email_inbox) { create(:inbox, channel: email_channel) }
let(:conversation) { create(:conversation, inbox: email_inbox) }
it 'returns false when auto_reply is not set to true' do
message = create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content_attributes: {}
)
expect(message.auto_reply_email?).to be false
end
it 'returns true when auto_reply is set to true' do
message = create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content_attributes: { email: { auto_reply: true } }
)
expect(message.auto_reply_email?).to be true
end
end
context 'when inbox is email' do
let(:email_channel) { create(:channel_email) }
let(:email_inbox) { create(:inbox, channel: email_channel) }
let(:conversation) { create(:conversation, inbox: email_inbox) }
it 'returns false when auto_reply is not set to true' do
message = create(
:message,
conversation: conversation,
message_type: :outgoing,
content_attributes: {}
)
expect(message.auto_reply_email?).to be false
end
it 'returns true when auto_reply is set to true' do
message = create(
:message,
conversation: conversation,
message_type: :outgoing,
content_attributes: { email: { auto_reply: true } }
)
expect(message.auto_reply_email?).to be true
end
end
end
end
+8 -1
View File
@@ -49,13 +49,15 @@ RSpec.describe MailPresenter do
:references,
:subject,
:text_content,
:to
:to,
:auto_reply
])
expect(data[:content_type]).to include('multipart/alternative')
expect(data[:date].to_s).to eq('2020-04-20T04:20:20-04:00')
expect(data[:message_id]).to eq(mail.message_id)
expect(data[:multipart]).to be(true)
expect(data[:subject]).to eq(decorated_mail.subject)
expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?)
end
it 'give email from in downcased format' do
@@ -136,6 +138,11 @@ RSpec.describe MailPresenter do
expect(decorated_auto_reply_mail.auto_reply?).to be true
expect(decorated_auto_reply_with_auto_submitted_mail.auto_reply?).to be true
end
it 'includes auto_reply status in serialized_data' do
expect(decorated_auto_reply_mail.serialized_data[:auto_reply]).to be true
expect(decorated_mail.serialized_data[:auto_reply]).to be_falsey
end
end
end
end
@@ -111,6 +111,35 @@ describe MessageTemplates::HookExecutionService do
end
end
context 'when message is an auto reply email' do
it 'does not call any template hooks' do
contact = create(:contact)
conversation = create(:conversation, contact: contact)
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true, greeting_message: 'Hi, this is a greeting message')
message = create(:message, conversation: conversation, content_type: :incoming_email)
message.content_attributes = { email: { auto_reply: true } }
message.save!
greeting_service = double
email_collect_service = double
out_of_office_service = double
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
allow(greeting_service).to receive(:perform).and_return(true)
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
allow(email_collect_service).to receive(:perform).and_return(true)
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
described_class.new(message: message).perform
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
end
context 'when it is after working hours' do
it 'calls ::MessageTemplates::Template::OutOfOffice' do
contact = create(:contact)
@@ -282,5 +282,126 @@ describe Twilio::IncomingMessageService do
expect(message.attachments.first.file_type).to eq('location')
end
end
context 'when ProfileName is provided for WhatsApp' do
it 'uses ProfileName as contact name' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello with profile name',
ProfileName: 'John Doe'
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('John Doe')
end
it 'falls back to formatted phone number when ProfileName is blank' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello without profile name',
ProfileName: ''
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('1234567890')
end
it 'uses formatted phone number when ProfileName is not provided' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Regular SMS message'
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('1234567890')
end
it 'updates existing contact name when current name matches phone number' do
# Create contact with phone number as name
existing_contact = create(:contact,
account: twilio_channel.inbox.account,
name: '+1234567890',
phone_number: '+1234567890')
create(:contact_inbox,
contact: existing_contact,
inbox: twilio_channel.inbox,
source_id: '+1234567890')
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello',
ProfileName: 'Jane Smith'
}
described_class.new(params: params).perform
existing_contact.reload
expect(existing_contact.name).to eq('Jane Smith')
end
it 'does not update contact name when current name is different from phone number' do
# Create contact with human name
existing_contact = create(:contact,
account: twilio_channel.inbox.account,
name: 'John Doe',
phone_number: '+1234567890')
create(:contact_inbox,
contact: existing_contact,
inbox: twilio_channel.inbox,
source_id: '+1234567890')
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello',
ProfileName: 'Jane Smith'
}
described_class.new(params: params).perform
existing_contact.reload
expect(existing_contact.name).to eq('John Doe') # Should not change
end
it 'updates contact name when current name matches formatted phone number' do
# Create contact with formatted phone number as name
existing_contact = create(:contact,
account: twilio_channel.inbox.account,
name: '1234567890',
phone_number: '+1234567890')
create(:contact_inbox,
contact: existing_contact,
inbox: twilio_channel.inbox,
source_id: '+1234567890')
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello',
ProfileName: 'Alice Johnson'
}
described_class.new(params: params).perform
existing_contact.reload
expect(existing_contact.name).to eq('Alice Johnson')
end
end
end
end
@@ -2,17 +2,18 @@ require 'rails_helper'
describe Whatsapp::EmbeddedSignupService do
let(:account) { create(:account) }
let(:code) { 'test_authorization_code' }
let(:business_id) { 'test_business_id' }
let(:waba_id) { 'test_waba_id' }
let(:phone_number_id) { 'test_phone_number_id' }
let(:params) do
{
code: 'test_authorization_code',
business_id: 'test_business_id',
waba_id: 'test_waba_id',
phone_number_id: 'test_phone_number_id'
}
end
let(:service) do
described_class.new(
account: account,
code: code,
business_id: business_id,
waba_id: waba_id,
phone_number_id: phone_number_id
params: params
)
end
@@ -20,37 +21,40 @@ describe Whatsapp::EmbeddedSignupService do
let(:access_token) { 'test_access_token' }
let(:phone_info) do
{
phone_number_id: phone_number_id,
phone_number_id: params[:phone_number_id],
phone_number: '+1234567890',
verified: true,
business_name: 'Test Business'
}
end
let(:channel) { instance_double(Channel::Whatsapp) }
let(:token_exchange_service) { instance_double(Whatsapp::TokenExchangeService) }
let(:phone_info_service) { instance_double(Whatsapp::PhoneInfoService) }
let(:token_validation_service) { instance_double(Whatsapp::TokenValidationService) }
let(:channel_creation_service) { instance_double(Whatsapp::ChannelCreationService) }
let(:service_doubles) do
{
token_exchange: instance_double(Whatsapp::TokenExchangeService),
phone_info: instance_double(Whatsapp::PhoneInfoService),
token_validation: instance_double(Whatsapp::TokenValidationService),
channel_creation: instance_double(Whatsapp::ChannelCreationService)
}
end
before do
allow(GlobalConfig).to receive(:clear_cache)
allow(Whatsapp::TokenExchangeService).to receive(:new).with(code).and_return(token_exchange_service)
allow(token_exchange_service).to receive(:perform).and_return(access_token)
allow(Whatsapp::TokenExchangeService).to receive(:new).with(params[:code]).and_return(service_doubles[:token_exchange])
allow(service_doubles[:token_exchange]).to receive(:perform).and_return(access_token)
allow(Whatsapp::PhoneInfoService).to receive(:new)
.with(waba_id, phone_number_id, access_token).and_return(phone_info_service)
allow(phone_info_service).to receive(:perform).and_return(phone_info)
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(service_doubles[:phone_info])
allow(service_doubles[:phone_info]).to receive(:perform).and_return(phone_info)
allow(Whatsapp::TokenValidationService).to receive(:new)
.with(access_token, waba_id).and_return(token_validation_service)
allow(token_validation_service).to receive(:perform)
.with(access_token, params[:waba_id]).and_return(service_doubles[:token_validation])
allow(service_doubles[:token_validation]).to receive(:perform)
allow(Whatsapp::ChannelCreationService).to receive(:new)
.with(account, { waba_id: waba_id, business_name: 'Test Business' }, phone_info, access_token)
.and_return(channel_creation_service)
allow(channel_creation_service).to receive(:perform).and_return(channel)
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
.and_return(service_doubles[:channel_creation])
allow(service_doubles[:channel_creation]).to receive(:perform).and_return(channel)
# Webhook setup is now handled in the channel after_create callback
# So we stub it at the model level
@@ -60,10 +64,10 @@ describe Whatsapp::EmbeddedSignupService do
end
it 'orchestrates all services in the correct order' do
expect(token_exchange_service).to receive(:perform).ordered
expect(phone_info_service).to receive(:perform).ordered
expect(token_validation_service).to receive(:perform).ordered
expect(channel_creation_service).to receive(:perform).ordered
expect(service_doubles[:token_exchange]).to receive(:perform).ordered
expect(service_doubles[:phone_info]).to receive(:perform).ordered
expect(service_doubles[:token_validation]).to receive(:perform).ordered
expect(service_doubles[:channel_creation]).to receive(:perform).ordered
result = service.perform
expect(result).to eq(channel)
@@ -73,10 +77,7 @@ describe Whatsapp::EmbeddedSignupService do
it 'raises error when code is blank' do
service = described_class.new(
account: account,
code: '',
business_id: business_id,
waba_id: waba_id,
phone_number_id: phone_number_id
params: params.merge(code: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code/)
end
@@ -84,10 +85,7 @@ describe Whatsapp::EmbeddedSignupService do
it 'raises error when business_id is blank' do
service = described_class.new(
account: account,
code: code,
business_id: '',
waba_id: waba_id,
phone_number_id: phone_number_id
params: params.merge(business_id: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: business_id/)
end
@@ -95,10 +93,7 @@ describe Whatsapp::EmbeddedSignupService do
it 'raises error when waba_id is blank' do
service = described_class.new(
account: account,
code: code,
business_id: business_id,
waba_id: '',
phone_number_id: phone_number_id
params: params.merge(waba_id: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: waba_id/)
end
@@ -106,10 +101,7 @@ describe Whatsapp::EmbeddedSignupService do
it 'raises error when multiple parameters are blank' do
service = described_class.new(
account: account,
code: '',
business_id: '',
waba_id: waba_id,
phone_number_id: phone_number_id
params: params.merge(code: '', business_id: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
end
@@ -117,11 +109,44 @@ describe Whatsapp::EmbeddedSignupService do
context 'when any service fails' do
it 'logs and re-raises the error' do
allow(token_exchange_service).to receive(:perform).and_raise('Token error')
allow(service_doubles[:token_exchange]).to receive(:perform).and_raise('Token error')
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
expect { service.perform }.to raise_error('Token error')
end
end
context 'when inbox_id is provided (reauthorization flow)' do
let(:inbox_id) { 123 }
let(:reauth_service) { instance_double(Whatsapp::ReauthorizationService) }
let(:service_with_inbox) do
described_class.new(
account: account,
params: params,
inbox_id: inbox_id
)
end
before do
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
account: account,
inbox_id: inbox_id,
phone_number_id: params[:phone_number_id],
business_id: params[:business_id]
).and_return(reauth_service)
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
end
it 'uses ReauthorizationService instead of ChannelCreationService' do
expect(service_doubles[:token_exchange]).to receive(:perform).ordered
expect(service_doubles[:phone_info]).to receive(:perform).ordered
expect(service_doubles[:token_validation]).to receive(:perform).ordered
expect(reauth_service).to receive(:perform).with(access_token, phone_info).ordered
expect(service_doubles[:channel_creation]).not_to receive(:perform)
result = service_with_inbox.perform
expect(result).to eq(channel)
end
end
end
end
@@ -267,19 +267,16 @@ describe Whatsapp::IncomingMessageService do
] }] }.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(Contact.all.first.name).to eq('Kedar')
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
# Two messages are tested deliberately to ensure multiple contact attachments work.
m1 = whatsapp_channel.inbox.messages.first
contact_attachments = m1.attachments.first
expect(m1.content).to eq('Apple Inc.')
expect(contact_attachments.fallback_title).to eq('+911800')
expect(m1.attachments.first.fallback_title).to eq('+911800')
expect(m1.attachments.first.meta).to eq({})
m2 = whatsapp_channel.inbox.messages.last
contact_attachments = m2.attachments.first
expect(m2.content).to eq('Chatwoot')
expect(contact_attachments.fallback_title).to eq('+1 (415) 341-8386')
expect(m2.attachments.first.meta).to eq({ 'firstName' => 'Chatwoot' })
end
end
@@ -7,6 +7,9 @@ RSpec.describe Whatsapp::WebhookTeardownService do
context 'when channel is whatsapp_cloud with embedded_signup' do
before do
# Stub webhook setup to prevent HTTP calls during channel update
allow(channel).to receive(:setup_webhooks).and_return(true)
channel.update!(
provider: 'whatsapp_cloud',
provider_config: {