Merge branch 'develop' into feat/reporting-reopen
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
+7
-5
@@ -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">
|
||||
|
||||
+1
-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 {
|
||||
|
||||
+37
-85
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
+190
@@ -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);
|
||||
};
|
||||
+94
-37
@@ -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>
|
||||
|
||||
|
||||
-2
@@ -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
-9
@@ -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')"
|
||||
|
||||
+8
-7
@@ -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() };
|
||||
},
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user