Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f3967148 | ||
|
|
9c10fe4eb1 | ||
|
|
6b3800e25b | ||
|
|
b560720e08 | ||
|
|
cc87429903 | ||
|
|
98154bbeab | ||
|
|
03a1b1dbc1 | ||
|
|
848e94bcf2 | ||
|
|
56dc580f50 | ||
|
|
35fcd56ba9 | ||
|
|
d57354c8b5 | ||
|
|
8d2ef4ec5e |
@@ -77,7 +77,8 @@ jobs:
|
||||
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '11.10.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
@@ -118,7 +119,8 @@ jobs:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '11.10.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
@@ -149,7 +151,8 @@ jobs:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '11.10.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
|
||||
@@ -124,8 +124,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def reauthorize_and_update_channel(channel_attributes)
|
||||
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
||||
end
|
||||
|
||||
def update_channel_feature_flags
|
||||
|
||||
@@ -59,6 +59,10 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||
permitted_params.dig(:contact, :phone_number)
|
||||
end
|
||||
|
||||
def contact_custom_attributes
|
||||
permitted_params.dig(:contact, :custom_attributes)&.to_h
|
||||
end
|
||||
|
||||
def browser_params
|
||||
{
|
||||
browser_name: browser.name,
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
def process_update_contact
|
||||
@contact = ContactIdentifyAction.new(
|
||||
contact: @contact,
|
||||
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
|
||||
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes },
|
||||
retain_original_contact_name: true,
|
||||
discard_invalid_attrs: true
|
||||
).perform
|
||||
@@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
|
||||
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
|
||||
message: [:content, :referer_url, :timestamp, :echo_id],
|
||||
custom_attributes: {})
|
||||
end
|
||||
|
||||
@@ -37,6 +37,20 @@ class CaptainAssistant extends ApiClient {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
});
|
||||
}
|
||||
|
||||
getDrilldown({ assistantId, metric, range, page, signal }) {
|
||||
const requestConfig = {
|
||||
params: {
|
||||
metric,
|
||||
range,
|
||||
timezone_offset: getTimezoneOffset(),
|
||||
page,
|
||||
},
|
||||
};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainAssistant();
|
||||
|
||||
@@ -61,10 +61,10 @@ const triggerAction = () => {
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex-shrink-0">
|
||||
<button
|
||||
v-if="actionLabel"
|
||||
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
|
||||
class="px-3 py-1 w-auto grid place-content-center rounded-lg whitespace-nowrap"
|
||||
:class="buttonClass"
|
||||
@click="triggerAction"
|
||||
>
|
||||
|
||||
@@ -107,6 +107,7 @@ const onClickCancel = () => {
|
||||
|
||||
<TextArea
|
||||
v-model="state.description"
|
||||
:max-length="500"
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
||||
"
|
||||
|
||||
@@ -213,6 +213,7 @@ const renderInstruction = instruction => () =>
|
||||
|
||||
<TextArea
|
||||
v-model="state.description"
|
||||
:max-length="500"
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
||||
"
|
||||
|
||||
+1
@@ -122,6 +122,7 @@ watch(
|
||||
|
||||
<Editor
|
||||
v-model="state.description"
|
||||
:max-length="500"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
:message="formErrors.description"
|
||||
|
||||
+1
@@ -117,6 +117,7 @@ watch(
|
||||
|
||||
<Editor
|
||||
v-model="state.description"
|
||||
:max-length="500"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
:message="formErrors.description"
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
|
||||
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
assistantId: { type: [String, Number], default: null },
|
||||
metric: { type: String, default: '' },
|
||||
metricName: { type: String, default: '' },
|
||||
metricValue: { type: String, default: '' },
|
||||
range: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const drawerRef = ref(null);
|
||||
const {
|
||||
records,
|
||||
meta,
|
||||
isFetching,
|
||||
isFetchingMore,
|
||||
hasError,
|
||||
hasRecords,
|
||||
hasMore,
|
||||
open: openDrilldown,
|
||||
close,
|
||||
loadMore,
|
||||
} = useReportDrilldown(params => CaptainAssistant.getDrilldown(params));
|
||||
|
||||
let previousActiveElement = null;
|
||||
|
||||
const isOpen = computed(() => props.open);
|
||||
const title = computed(() => props.metricName || '');
|
||||
|
||||
const subtitle = computed(() => {
|
||||
if (!meta.value.conversation_count) return '';
|
||||
|
||||
return t('CAPTAIN.OVERVIEW.DRILLDOWN.RESULT_COUNT_CONVERSATION', {
|
||||
count: meta.value.conversation_count,
|
||||
});
|
||||
});
|
||||
|
||||
const recordKey = record => `${record.conversation?.id}-${record.occurred_at}`;
|
||||
|
||||
const restoreFocus = () => {
|
||||
if (previousActiveElement?.isConnected) {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
previousActiveElement = null;
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
close();
|
||||
emit('close');
|
||||
restoreFocus();
|
||||
};
|
||||
|
||||
const rememberActiveElement = () => {
|
||||
if (previousActiveElement) return;
|
||||
|
||||
previousActiveElement =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
};
|
||||
|
||||
const focusDrawer = () => {
|
||||
nextTick(() => drawerRef.value?.focus());
|
||||
};
|
||||
|
||||
const fetchDrilldown = () => {
|
||||
if (!props.metric || !props.assistantId) return;
|
||||
|
||||
openDrilldown({
|
||||
assistantId: props.assistantId,
|
||||
metric: props.metric,
|
||||
range: props.range,
|
||||
});
|
||||
};
|
||||
|
||||
const onKeydown = event => {
|
||||
if (!isOpen.value) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeDrawer();
|
||||
}
|
||||
};
|
||||
|
||||
useEventListener(document, 'keydown', onKeydown);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
isDrawerOpen => {
|
||||
if (!isDrawerOpen) {
|
||||
close();
|
||||
restoreFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
rememberActiveElement();
|
||||
fetchDrilldown();
|
||||
focusDrawer();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.metric, props.range],
|
||||
() => {
|
||||
if (props.open) fetchDrilldown();
|
||||
}
|
||||
);
|
||||
|
||||
// The drawer's headline value is a snapshot of the previous assistant's card,
|
||||
// so switching assistants (e.g. browser Back/Forward) closes it instead of
|
||||
// refetching records that would no longer match the header.
|
||||
watch(
|
||||
() => props.assistantId,
|
||||
() => {
|
||||
if (props.open) closeDrawer();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
restoreFocus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeleportWithDirection to="body">
|
||||
<Transition name="report-drilldown-fade">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 bg-black/30"
|
||||
role="presentation"
|
||||
@click.self="closeDrawer"
|
||||
>
|
||||
<aside
|
||||
ref="drawerRef"
|
||||
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header
|
||||
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-base font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="metricValue"
|
||||
class="mt-1 text-xl font-semibold text-n-slate-12"
|
||||
>
|
||||
{{ metricValue }}
|
||||
</p>
|
||||
<div
|
||||
class="text-sm text-n-slate-11"
|
||||
:class="{
|
||||
'mt-2': metricValue,
|
||||
'mt-1': !metricValue,
|
||||
}"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
size="sm"
|
||||
icon="i-ph-x"
|
||||
:aria-label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.CLOSE')"
|
||||
@click="closeDrawer"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex h-40 items-center justify-center"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="hasError"
|
||||
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
|
||||
>
|
||||
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!hasRecords"
|
||||
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.EMPTY') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<ReportDrilldownCard
|
||||
v-for="record in records"
|
||||
:key="recordKey(record)"
|
||||
:record="record"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="hasMore"
|
||||
faded
|
||||
slate
|
||||
size="sm"
|
||||
class="mx-auto mt-2"
|
||||
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
|
||||
:is-loading="isFetchingMore"
|
||||
@click="loadMore"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Transition>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
+20
-1
@@ -8,16 +8,35 @@ const props = defineProps({
|
||||
hint: { type: String, default: '' },
|
||||
// null = neutral, true = good direction, false = bad direction
|
||||
trendGood: { type: Boolean, default: null },
|
||||
clickable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const trendClass = computed(() => {
|
||||
if (props.trendGood === null) return 'text-n-slate-11';
|
||||
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
|
||||
});
|
||||
|
||||
const onActivate = () => {
|
||||
if (props.clickable) emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 p-5 group bg-n-solid-1">
|
||||
<div
|
||||
class="flex flex-col gap-3 p-5 group bg-n-solid-1"
|
||||
:class="
|
||||
clickable
|
||||
? 'cursor-pointer transition-colors hover:bg-n-slate-2/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand'
|
||||
: ''
|
||||
"
|
||||
:role="clickable ? 'button' : undefined"
|
||||
:tabindex="clickable ? 0 : undefined"
|
||||
@click="onActivate"
|
||||
@keydown.enter.self.prevent="onActivate"
|
||||
@keydown.space.self.prevent="onActivate"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
|
||||
<span
|
||||
|
||||
@@ -44,6 +44,12 @@ const showChatSupport = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const toggleChatSupport = () => {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
@@ -51,9 +57,7 @@ const menuItems = computed(() => {
|
||||
showOnCustomBrandedInstance: false,
|
||||
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
|
||||
icon: 'i-lucide-life-buoy',
|
||||
click: () => {
|
||||
window.$chatwoot.toggle();
|
||||
},
|
||||
click: toggleChatSupport,
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ChannelSelector from '../ChannelSelector.vue';
|
||||
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
channel: {
|
||||
@@ -21,10 +20,7 @@ const hasFbConfigured = computed(() => {
|
||||
});
|
||||
|
||||
const hasInstagramConfigured = computed(() => {
|
||||
return (
|
||||
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
|
||||
window.chatwootConfig?.instagramAppId
|
||||
);
|
||||
return window.chatwootConfig?.instagramAppId;
|
||||
});
|
||||
|
||||
const hasTiktokConfigured = computed(() => {
|
||||
@@ -56,19 +52,10 @@ const isActive = computed(() => {
|
||||
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
|
||||
}
|
||||
|
||||
if (key === 'voice') {
|
||||
if (key === 'voice' || key === 'whatsapp_call') {
|
||||
return props.enabledFeatures.channel_voice;
|
||||
}
|
||||
|
||||
if (key === 'whatsapp_call') {
|
||||
return (
|
||||
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
|
||||
props.enabledFeatures.channel_voice &&
|
||||
!!window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
calculateMenuPosition,
|
||||
getEffectiveChannelType,
|
||||
stripUnsupportedFormatting,
|
||||
createVariableInputRule,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -306,6 +307,10 @@ const plugins = computed(() => {
|
||||
searchTerm: variableSearchTerm,
|
||||
isAllowed: () => !props.isPrivate,
|
||||
}),
|
||||
createVariableInputRule({
|
||||
isPrivate: () => props.isPrivate,
|
||||
getVariables: () => props.variables,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: ':',
|
||||
minChars: 2,
|
||||
|
||||
@@ -33,7 +33,9 @@ import {
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import wootConstants, {
|
||||
META_RESTRICTION_STATUS_URL,
|
||||
} from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
@@ -93,6 +95,7 @@ export default {
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
@@ -170,6 +173,12 @@ export default {
|
||||
instagramInbox
|
||||
);
|
||||
},
|
||||
isInstagramRestrictionBannerVisible() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
instagramRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
@@ -455,7 +464,15 @@ export default {
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
v-if="isInstagramRestrictionBannerVisible"
|
||||
color-scheme="warning"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
|
||||
:href-link="instagramRestrictionStatusUrl"
|
||||
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
getAgentVariables,
|
||||
getContactVariables,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
@@ -393,7 +395,13 @@ export default {
|
||||
contact: this.currentContact,
|
||||
inbox: this.inbox,
|
||||
});
|
||||
return variables;
|
||||
// Match the backend drops: names are Ruby-capitalized and
|
||||
// {{agent.*}} is the message sender, not the assignee.
|
||||
return {
|
||||
...variables,
|
||||
...getContactVariables(this.currentContact),
|
||||
...getAgentVariables(this.currentUser),
|
||||
};
|
||||
},
|
||||
connectedPortalSlug() {
|
||||
const { help_center: portal = {} } = this.inbox;
|
||||
|
||||
@@ -78,8 +78,5 @@ export default {
|
||||
},
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
|
||||
// Temporarily disables Instagram and WhatsApp inbox creation
|
||||
// (WhatsApp embedded signup popup, Instagram OAuth, WhatsApp Call).
|
||||
// Flip to false when the channels are brought back.
|
||||
export const IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED = true;
|
||||
export const META_RESTRICTION_STATUS_URL =
|
||||
'https://status.chatwoot.com/incident/948346';
|
||||
|
||||
@@ -7,6 +7,7 @@ export const FEATURE_FLAGS = {
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { InputRule, inputRules } from 'prosemirror-inputrules';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
@@ -428,6 +429,55 @@ export function stripUnsupportedFormatting(content, schema) {
|
||||
* - emoji
|
||||
*/
|
||||
|
||||
// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send.
|
||||
const LIQUID_SYNTAX = /\{\{|\{%/;
|
||||
|
||||
// Value when set (and not itself Liquid), else the {{placeholder}} for the backend.
|
||||
export const resolveVariableText = (key, variables) => {
|
||||
const value = String(variables?.[key] ?? '');
|
||||
return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`;
|
||||
};
|
||||
|
||||
// Name variables normalized like the backend drops (UserDrop/ContactDrop):
|
||||
// name split on whitespace, each word Ruby-capitalized (rest downcased).
|
||||
const getNameVariables = (prefix, name) => {
|
||||
const names = (name || '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
||||
return {
|
||||
[`${prefix}.name`]: names.join(' '),
|
||||
[`${prefix}.first_name`]: names[0] || '',
|
||||
[`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '',
|
||||
};
|
||||
};
|
||||
|
||||
// {{agent.*}} values for the message sender.
|
||||
export const getAgentVariables = user => ({
|
||||
...getNameVariables('agent', user.name),
|
||||
'agent.email': user.email,
|
||||
});
|
||||
|
||||
// {{contact.*}} name values.
|
||||
export const getContactVariables = contact =>
|
||||
getNameVariables('contact', contact?.name);
|
||||
|
||||
// Resolves a manually typed {{variable}} to its value on the closing braces.
|
||||
// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note.
|
||||
export const createVariableInputRule = ({ isPrivate, getVariables }) => {
|
||||
const rule = new InputRule(
|
||||
/\{\{([^{}]+)\}\}$/,
|
||||
(editorState, match, from, to) => {
|
||||
if (isPrivate()) return null;
|
||||
const [, key] = match;
|
||||
const text = resolveVariableText(key, getVariables());
|
||||
if (text === `{{${key}}}`) return null;
|
||||
return editorState.tr.insertText(text, from, to);
|
||||
}
|
||||
);
|
||||
return inputRules({ rules: [rule] });
|
||||
};
|
||||
|
||||
/**
|
||||
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
|
||||
* @param {Object} editorView - The editor view instance.
|
||||
@@ -462,7 +512,7 @@ const createNode = (editorView, nodeType, content) => {
|
||||
);
|
||||
}
|
||||
case 'variable':
|
||||
return state.schema.text(`{{${content}}}`);
|
||||
return state.schema.text(content);
|
||||
case 'emoji':
|
||||
return state.schema.text(content);
|
||||
case 'tool': {
|
||||
@@ -497,8 +547,12 @@ const nodeCreators = {
|
||||
to,
|
||||
};
|
||||
},
|
||||
variable: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'variable', content),
|
||||
variable: (editorView, content, from, to, variables) => ({
|
||||
node: createNode(
|
||||
editorView,
|
||||
'variable',
|
||||
resolveVariableText(content, variables)
|
||||
),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
|
||||
@@ -94,16 +94,56 @@ describe('getContentNode', () => {
|
||||
});
|
||||
|
||||
describe('getVariableNode', () => {
|
||||
it('should create a variable node', () => {
|
||||
const content = 'name';
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
getContentNode(editorView, 'variable', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
it('should render the resolved value directly when the variable has a value', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.name',
|
||||
{ from: 0, to: 10 },
|
||||
{ 'contact.name': 'John' }
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('John');
|
||||
});
|
||||
|
||||
it('should resolve camelCase custom attributes and non-string values', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.custom_attribute.cloudCustomer',
|
||||
{ from: 0, to: 10 },
|
||||
{ 'contact.custom_attribute.cloudCustomer': true }
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('true');
|
||||
});
|
||||
|
||||
it('should keep the placeholder when the variable has no value', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.email',
|
||||
{ from: 0, to: 10 },
|
||||
{}
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith(
|
||||
'{{contact.email}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the placeholder when the value contains Liquid syntax', () => {
|
||||
getContentNode(
|
||||
editorView,
|
||||
'variable',
|
||||
'contact.name',
|
||||
{ from: 0, to: 10 },
|
||||
{ 'contact.name': '{{agent.email}}' }
|
||||
);
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith(
|
||||
'{{contact.name}}'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
calculateMenuPosition,
|
||||
cleanSignature,
|
||||
collapseSelection,
|
||||
createVariableInputRule,
|
||||
extractTextFromMarkdown,
|
||||
findNodeToInsertImage,
|
||||
findSignatureInBody,
|
||||
getAgentVariables,
|
||||
getContactVariables,
|
||||
getContentNode,
|
||||
getFormattingForEditor,
|
||||
getMenuAnchor,
|
||||
@@ -1228,3 +1231,149 @@ describe('Menu positioning helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentVariables', () => {
|
||||
it('builds agent variables from the user', () => {
|
||||
expect(
|
||||
getAgentVariables({ name: 'John Doe', email: 'john@example.com' })
|
||||
).toEqual({
|
||||
'agent.name': 'John Doe',
|
||||
'agent.first_name': 'John',
|
||||
'agent.last_name': 'Doe',
|
||||
'agent.email': 'john@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => {
|
||||
const variables = getAgentVariables({ name: 'JANE doE' });
|
||||
|
||||
expect(variables['agent.name']).toBe('Jane Doe');
|
||||
expect(variables['agent.first_name']).toBe('Jane');
|
||||
expect(variables['agent.last_name']).toBe('Doe');
|
||||
});
|
||||
|
||||
it('ignores extra whitespace between words', () => {
|
||||
expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe(
|
||||
'John Doe'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves last_name empty for single-word names', () => {
|
||||
const variables = getAgentVariables({ name: 'john' });
|
||||
|
||||
expect(variables['agent.first_name']).toBe('John');
|
||||
expect(variables['agent.last_name']).toBe('');
|
||||
});
|
||||
|
||||
it('handles a missing name', () => {
|
||||
const variables = getAgentVariables({ email: 'john@example.com' });
|
||||
|
||||
expect(variables['agent.name']).toBe('');
|
||||
expect(variables['agent.first_name']).toBe('');
|
||||
expect(variables['agent.last_name']).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContactVariables', () => {
|
||||
it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => {
|
||||
expect(getContactVariables({ name: 'JANE doE' })).toEqual({
|
||||
'contact.name': 'Jane Doe',
|
||||
'contact.first_name': 'Jane',
|
||||
'contact.last_name': 'Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves last_name empty for single-word names', () => {
|
||||
const variables = getContactVariables({ name: 'john' });
|
||||
|
||||
expect(variables['contact.first_name']).toBe('John');
|
||||
expect(variables['contact.last_name']).toBe('');
|
||||
});
|
||||
|
||||
it('handles a missing contact', () => {
|
||||
expect(getContactVariables(undefined)['contact.name']).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createVariableInputRule', () => {
|
||||
// Editor holding `{{key}` so we can simulate typing the final `}`.
|
||||
const buildView = (typed, { isPrivate = false, variables = {} } = {}) => {
|
||||
const plugin = createVariableInputRule({
|
||||
isPrivate: () => isPrivate,
|
||||
getVariables: () => variables,
|
||||
});
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc: schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [schema.text(typed)]),
|
||||
]),
|
||||
plugins: [plugin],
|
||||
});
|
||||
return new EditorView(document.body, { state });
|
||||
};
|
||||
|
||||
// Types the closing `}`; when the rule declines, insert it like the browser would.
|
||||
const typeClosingBrace = view => {
|
||||
const end = view.state.doc.content.size - 1;
|
||||
const handled = view.someProp('handleTextInput', fn =>
|
||||
fn(view, end, end, '}')
|
||||
);
|
||||
if (!handled) {
|
||||
view.dispatch(view.state.tr.insertText('}', end, end));
|
||||
}
|
||||
};
|
||||
|
||||
it('resolves a manually typed {{variable}} to its value on the closing brace', () => {
|
||||
const view = buildView('{{contact.name}', {
|
||||
variables: { 'contact.name': 'John' },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('John');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('resolves boolean/non-string values', () => {
|
||||
const view = buildView('{{contact.custom_attribute.cloudCustomer}', {
|
||||
variables: { 'contact.custom_attribute.cloudCustomer': true },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('true');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('keeps the placeholder when the variable has no value', () => {
|
||||
const view = buildView('{{contact.email}', { variables: {} });
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('{{contact.email}}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('keeps the placeholder when the value itself contains Liquid syntax', () => {
|
||||
const view = buildView('{{contact.name}', {
|
||||
variables: { 'contact.name': '{{agent.email}}' },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('{{contact.name}}');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it('does not resolve inside a private note', () => {
|
||||
const view = buildView('{{contact.name}', {
|
||||
isPrivate: true,
|
||||
variables: { 'contact.name': 'John' },
|
||||
});
|
||||
|
||||
typeClosingBrace(view);
|
||||
|
||||
expect(view.state.doc.textContent).toBe('{{contact.name}}');
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
|
||||
@@ -58,7 +58,10 @@
|
||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||
"RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. We’ll restore support as soon as possible.",
|
||||
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"STATUS_LINK": "View status update"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
||||
@@ -320,6 +323,8 @@
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
|
||||
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
|
||||
"MANUAL_LINK_TEXT": "manual setup flow",
|
||||
"RESTRICTED_WARNING": "WhatsApp embedded signup is temporarily unavailable due to current Meta platform restrictions. We’ll restore support as soon as possible.",
|
||||
"STATUS_LINK": "View status update",
|
||||
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
|
||||
},
|
||||
"API": {
|
||||
@@ -815,6 +820,15 @@
|
||||
"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_MANUAL_TRANSFER_TITLE": "Switch to Manual Setup",
|
||||
"WHATSAPP_MANUAL_TRANSFER_SUBHEADER": "This inbox was connected through WhatsApp embedded signup, which is no longer supported. Enter the WhatsApp Cloud API credentials from your own Meta app to switch this inbox to a manual setup. Configure the webhook in your Meta app using the verification token above before switching.",
|
||||
"WHATSAPP_MANUAL_TRANSFER_PHONE_NUMBER_ID_LABEL": "Phone Number ID",
|
||||
"WHATSAPP_MANUAL_TRANSFER_BUSINESS_ACCOUNT_ID_LABEL": "Business Account ID",
|
||||
"WHATSAPP_MANUAL_TRANSFER_API_KEY_LABEL": "API Key",
|
||||
"WHATSAPP_MANUAL_TRANSFER_API_KEY_PLACEHOLDER": "Enter a permanent access token from your Meta app",
|
||||
"WHATSAPP_MANUAL_TRANSFER_BUTTON": "Switch to Manual Setup",
|
||||
"WHATSAPP_MANUAL_TRANSFER_SUCCESS": "Inbox switched to manual setup successfully.",
|
||||
"WHATSAPP_MANUAL_TRANSFER_ERROR": "Could not switch to manual setup. Please verify the credentials and try again.",
|
||||
"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.",
|
||||
@@ -832,6 +846,70 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"WHATSAPP_MANUAL_MIGRATION": {
|
||||
"BANNER": {
|
||||
"TITLE": "WhatsApp setup action required",
|
||||
"DESCRIPTION": "Meta restrictions are affecting WhatsApp setup and management features. Reconnect this inbox manually to keep your WhatsApp configuration up to date.",
|
||||
"START": "Start manual migration",
|
||||
"GUIDE": "View guide"
|
||||
},
|
||||
"DIALOG": {
|
||||
"EYEBROW": "WhatsApp manual migration",
|
||||
"TITLE": "Reconnect WhatsApp inbox",
|
||||
"CLOSE": "Close",
|
||||
"ACTION_REQUIRED_TITLE": "Action required for this WhatsApp inbox",
|
||||
"ACTION_REQUIRED_DESCRIPTION": "Meta restrictions are affecting setup and management features. This guided flow updates the WhatsApp API connection without creating a new inbox.",
|
||||
"GUIDE_LINK": "Open the manual setup guide",
|
||||
"PRESERVED_TITLE": "Preserved",
|
||||
"PRESERVED_DESCRIPTION": "Conversations, contacts, collaborators, routing, business hours, and inbox settings.",
|
||||
"UPDATED_TITLE": "Updated",
|
||||
"UPDATED_DESCRIPTION": "WABA ID, phone number ID, access token, and webhook configuration.",
|
||||
"WABA_ID": "WABA ID",
|
||||
"WABA_PLACEHOLDER": "Enter WABA ID",
|
||||
"WABA_HELP": "The WhatsApp Business Account that owns this phone number.",
|
||||
"PHONE_NUMBER_ID": "Phone Number ID",
|
||||
"PHONE_NUMBER_PLACEHOLDER": "Enter Phone Number ID",
|
||||
"PHONE_NUMBER_HELP": "Meta's unique ID for the WhatsApp number connected to this inbox.",
|
||||
"DISPLAY_PHONE_NUMBER": "Display phone number",
|
||||
"DISPLAY_PHONE_NUMBER_PLACEHOLDER": "Enter display phone number",
|
||||
"DISPLAY_PHONE_NUMBER_HELP": "The customer-facing WhatsApp number. This cannot be changed during migration.",
|
||||
"ACCESS_TOKEN": "Permanent access token or system user token",
|
||||
"ACCESS_TOKEN_PLACEHOLDER": "Paste access token",
|
||||
"TOKEN_HELP_PREFIX": "The token must include",
|
||||
"TOKEN_HELP_MIDDLE": "Add",
|
||||
"TOKEN_HELP_SUFFIX": "for template sync and template management.",
|
||||
"MESSAGING_PERMISSION": "whatsapp_business_messaging.",
|
||||
"MANAGEMENT_PERMISSION": "whatsapp_business_management",
|
||||
"REVIEW_TITLE": "Review before reconnecting",
|
||||
"INBOX": "Inbox",
|
||||
"PHONE_NUMBER": "Phone number",
|
||||
"NOT_ENTERED": "Not entered",
|
||||
"VERIFY_NOTICE": "Chatwoot will verify these credentials with Meta before applying any changes. If verification fails, the current configuration is left untouched.",
|
||||
"BACK": "Back",
|
||||
"CANCEL": "Cancel",
|
||||
"CONTINUE": "Continue",
|
||||
"REVIEW_MIGRATION": "Review migration",
|
||||
"RECONNECT": "Reconnect WhatsApp inbox"
|
||||
},
|
||||
"STEPS": {
|
||||
"BEFORE_YOU_START": {
|
||||
"TITLE": "Before you start",
|
||||
"DESCRIPTION": "This reconnects the WhatsApp API details for this inbox. Conversations, collaborators, routing, business hours, CSAT, and bot settings will be preserved."
|
||||
},
|
||||
"BUSINESS_DETAILS": {
|
||||
"TITLE": "Business details",
|
||||
"DESCRIPTION": "Enter the WhatsApp assets from the customer Meta Business account."
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access token",
|
||||
"DESCRIPTION": "Paste a permanent access token or system user token with WhatsApp permissions."
|
||||
},
|
||||
"REVIEW_MIGRATION": {
|
||||
"TITLE": "Review migration",
|
||||
"DESCRIPTION": "Confirm the connection details before reconnecting this inbox. Chatwoot will verify the token, WABA, and phone number with Meta before applying changes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING_ENABLED": {
|
||||
"LABEL": "Enable voice calling",
|
||||
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
|
||||
|
||||
@@ -439,6 +439,13 @@
|
||||
"HINT": "Average replies the assistant sends per conversation."
|
||||
}
|
||||
},
|
||||
"DRILLDOWN": {
|
||||
"CLOSE": "Close details",
|
||||
"EMPTY": "No records found for this metric.",
|
||||
"ERROR": "Could not load records. Please try again.",
|
||||
"LOAD_MORE": "Load more",
|
||||
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations"
|
||||
},
|
||||
"KNOWLEDGE": {
|
||||
"TITLE": "Knowledge coverage",
|
||||
"COVERAGE": "{pct}% approved",
|
||||
@@ -446,17 +453,6 @@
|
||||
"PENDING": "Pending FAQs",
|
||||
"DOCUMENTS": "Documents"
|
||||
},
|
||||
"FLAGGED": {
|
||||
"TITLE": "Response quality",
|
||||
"TOTAL": "{count} flagged · {rate}"
|
||||
},
|
||||
"CREDITS": {
|
||||
"TITLE": "Credit usage",
|
||||
"UNIT": "credits",
|
||||
"LEGEND": "Daily credits used",
|
||||
"AXIS_START": "{count}d ago",
|
||||
"AXIS_END": "Today"
|
||||
},
|
||||
"LINKS": {
|
||||
"DOCS": {
|
||||
"TITLE": "Captain docs",
|
||||
@@ -470,13 +466,6 @@
|
||||
"TITLE": "Billing",
|
||||
"DESCRIPTION": "Manage credits and plan"
|
||||
}
|
||||
},
|
||||
"FLAG_REASONS": {
|
||||
"INCORRECT": "Incorrect info",
|
||||
"INCOMPLETE": "Incomplete",
|
||||
"OUTDATED": "Outdated",
|
||||
"INAPPROPRIATE": "Inappropriate",
|
||||
"OTHER": "Other"
|
||||
}
|
||||
},
|
||||
"ASSISTANT_SWITCHER": {
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
|
||||
"ACCOUNT_SUSPENDED": {
|
||||
"TITLE": "Account Suspended",
|
||||
"MESSAGE": "Your account has been suspended after we detected activity that may violate our policies or put other users at risk. If you believe this is a mistake, please contact our support team."
|
||||
"MESSAGE": "Your account has been suspended due to activity that may violate our policies. If you believe this is a mistake, please contact our support team."
|
||||
},
|
||||
"NO_ACCOUNTS": {
|
||||
"TITLE": "No account found",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
@@ -10,6 +11,7 @@ import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Pay
|
||||
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
|
||||
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
|
||||
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
|
||||
import AssistantDrilldownDrawer from 'dashboard/components-next/captain/pageComponents/overview/AssistantDrilldownDrawer.vue';
|
||||
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
|
||||
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
|
||||
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
|
||||
@@ -17,6 +19,9 @@ import CoverageBanner from 'dashboard/components-next/captain/pageComponents/ove
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
// Drilldown is admin-only; the backend policy enforces the same restriction.
|
||||
const { checkPermissions } = usePolicy();
|
||||
const canDrilldown = computed(() => checkPermissions(['administrator']));
|
||||
|
||||
const selectedRange = ref('this_month');
|
||||
|
||||
@@ -69,34 +74,38 @@ const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
key: 'handled',
|
||||
metric: 'conversations_handled',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
|
||||
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
|
||||
},
|
||||
{
|
||||
key: 'autoResolution',
|
||||
metric: 'auto_resolution_rate',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
|
||||
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'handoff',
|
||||
metric: 'handoff_rate',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
|
||||
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'reopen',
|
||||
metric: 'reopen_rate',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
|
||||
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'hoursSaved',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
|
||||
...metricFor('hours_saved', formatDuration, 'up'),
|
||||
},
|
||||
{
|
||||
key: 'reopen',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
|
||||
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'depth',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
|
||||
@@ -109,6 +118,22 @@ const metrics = computed(() => [
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const drilldown = ref({ metric: '', label: '', value: '' });
|
||||
const isDrilldownOpen = ref(false);
|
||||
|
||||
const openDrilldown = metric => {
|
||||
drilldown.value = {
|
||||
metric: metric.metric,
|
||||
label: metric.label,
|
||||
value: metric.value,
|
||||
};
|
||||
isDrilldownOpen.value = true;
|
||||
};
|
||||
|
||||
const closeDrilldown = () => {
|
||||
isDrilldownOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -144,6 +169,8 @@ const metrics = computed(() => [
|
||||
:trend="metric.trend"
|
||||
:hint="metric.hint"
|
||||
:trend-good="metric.trendGood"
|
||||
:clickable="canDrilldown && Boolean(metric.metric)"
|
||||
@click="openDrilldown(metric)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -151,6 +178,17 @@ const metrics = computed(() => [
|
||||
|
||||
<QuickLinks />
|
||||
</div>
|
||||
|
||||
<AssistantDrilldownDrawer
|
||||
v-if="canDrilldown"
|
||||
:open="isDrilldownOpen"
|
||||
:assistant-id="assistantId"
|
||||
:metric="drilldown.metric"
|
||||
:metric-name="drilldown.label"
|
||||
:metric-value="drilldown.value"
|
||||
:range="selectedRange"
|
||||
@close="closeDrilldown"
|
||||
/>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
+3
-4
@@ -1,5 +1,4 @@
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
|
||||
|
||||
// OAuth/SDK channels need installation-level app credentials to be usable. When
|
||||
// the credential is missing the channel is "not configured" and is hidden from
|
||||
@@ -8,20 +7,20 @@ import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constan
|
||||
// Mirrors the availability checks in ChannelItem.vue.
|
||||
export function useChannelConfig() {
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const installationConfig = window.chatwootConfig || {};
|
||||
|
||||
const CHANNEL_CONFIGURED = {
|
||||
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
|
||||
// app id (not the 'none' sentinel) and the signup configuration id.
|
||||
whatsapp: () =>
|
||||
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
|
||||
!isOnChatwootCloud.value &&
|
||||
Boolean(installationConfig.whatsappAppId) &&
|
||||
installationConfig.whatsappAppId !== 'none' &&
|
||||
Boolean(installationConfig.whatsappConfigurationId),
|
||||
facebook: () => Boolean(installationConfig.fbAppId),
|
||||
instagram: () =>
|
||||
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
|
||||
Boolean(installationConfig.instagramAppId),
|
||||
!isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId),
|
||||
tiktok: () => Boolean(installationConfig.tiktokAppId),
|
||||
gmail: () => Boolean(installationConfig.googleOAuthClientId),
|
||||
outlook: () => Boolean(globalConfig.value.azureAppId),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import googleClient from 'dashboard/api/channel/googleClient';
|
||||
@@ -23,11 +24,17 @@ export function useChannelConnect() {
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const connectViaOAuth = async provider => {
|
||||
const client = OAUTH_CLIENTS[provider];
|
||||
if (!client) return;
|
||||
|
||||
if (provider === 'instagram' && isOnChatwootCloud.value) {
|
||||
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { url },
|
||||
|
||||
+28
-8
@@ -6,21 +6,25 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
|
||||
|
||||
vi.mock('vue-router');
|
||||
|
||||
// Neutralize the temporary Instagram/WhatsApp kill switch so these specs keep
|
||||
// covering the credential-based gating it currently short-circuits.
|
||||
vi.mock('dashboard/constants/globals', async importOriginal => ({
|
||||
...(await importOriginal()),
|
||||
IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED: false,
|
||||
}));
|
||||
|
||||
// Mounts the composable against a real store and the real useAccount (only
|
||||
// useRoute and the underlying getters are faked), so a change to how useAccount
|
||||
// resolves the current account is exercised here too. The real ./constants are
|
||||
// used, so assertions validate against the actual channel identity (label keys,
|
||||
// channel_type, social ordering) derived from CHANNEL_LIST.
|
||||
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
|
||||
const mountComposable = ({
|
||||
brandInfo,
|
||||
inboxes = [],
|
||||
isOnChatwootCloud = false,
|
||||
} = {}) => {
|
||||
const store = createStore({
|
||||
modules: {
|
||||
globalConfig: {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
get: () => ({}),
|
||||
isOnChatwootCloud: () => isOnChatwootCloud,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
namespaced: true,
|
||||
getters: {
|
||||
@@ -202,6 +206,22 @@ describe('useDetectedChannels', () => {
|
||||
'line',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Instagram from onboarding on Chatwoot Cloud', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
isOnChatwootCloud: true,
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme' },
|
||||
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'tiktok',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remainingChannels', () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
@@ -27,6 +29,8 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu
|
||||
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
||||
import BotConfiguration from './components/BotConfiguration.vue';
|
||||
import AccountHealth from './components/AccountHealth.vue';
|
||||
import WhatsappManualMigrationDialog from './components/WhatsappManualMigrationDialog.vue';
|
||||
import WhatsappManualMigrationBanner from './components/WhatsappManualMigrationBanner.vue';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
|
||||
@@ -42,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
|
||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
@@ -74,8 +80,11 @@ export default {
|
||||
ColorPicker,
|
||||
SelectInput,
|
||||
AccountHealth,
|
||||
WhatsappManualMigrationDialog,
|
||||
WhatsappManualMigrationBanner,
|
||||
Widget,
|
||||
AccessToken,
|
||||
Icon,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -107,6 +116,7 @@ export default {
|
||||
isLoadingHealth: false,
|
||||
healthError: null,
|
||||
isRegisteringWebhook: false,
|
||||
isTransferringWhatsAppToManual: false,
|
||||
widgetBubblePosition: 'right',
|
||||
widgetBubbleType: 'standard',
|
||||
widgetBubbleLauncherTitle: '',
|
||||
@@ -338,6 +348,12 @@ export default {
|
||||
instagramUnauthorized() {
|
||||
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
showInstagramRestrictionSettingsBanner() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
metaRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
tiktokUnauthorized() {
|
||||
return this.isATiktokChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
@@ -371,10 +387,12 @@ export default {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
},
|
||||
whatsappUnauthorized() {
|
||||
// The manual migration banner supersedes the embedded-signup reauthorize flow when the feature is enabled.
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.inbox.reauthorization_required
|
||||
this.inbox.reauthorization_required &&
|
||||
!this.showWhatsAppManualMigration
|
||||
);
|
||||
},
|
||||
whatsappRegistrationIncomplete() {
|
||||
@@ -391,6 +409,16 @@ export default {
|
||||
this.healthData.throughput?.level === 'NOT_APPLICABLE'
|
||||
);
|
||||
},
|
||||
showWhatsAppManualMigration() {
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
|
||||
)
|
||||
);
|
||||
},
|
||||
widgetBuilderStorageKey() {
|
||||
return `${LOCAL_STORAGE_KEYS.WIDGET_BUILDER}${this.inbox.id}`;
|
||||
},
|
||||
@@ -402,6 +430,7 @@ export default {
|
||||
if (inboxChanged) {
|
||||
this.syncInboxData();
|
||||
this.setTabFromRouteParam();
|
||||
this.openWhatsAppManualMigrationIfRequested();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -412,6 +441,7 @@ export default {
|
||||
this.fetchHealthData();
|
||||
this.$nextTick(() => {
|
||||
this.setTabFromRouteParam();
|
||||
this.openWhatsAppManualMigrationIfRequested();
|
||||
});
|
||||
} else {
|
||||
this.selectedFeatureFlags = newInbox?.selected_feature_flags || [];
|
||||
@@ -422,8 +452,52 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.fetchSharedData();
|
||||
this.openWhatsAppManualMigrationIfRequested();
|
||||
},
|
||||
methods: {
|
||||
openWhatsAppManualMigrationDialog() {
|
||||
this.$refs.whatsappManualMigrationDialog?.open();
|
||||
},
|
||||
openWhatsAppManualMigrationIfRequested() {
|
||||
if (
|
||||
this.showWhatsAppManualMigration &&
|
||||
this.$route.query.migration === 'whatsapp_manual'
|
||||
) {
|
||||
this.$nextTick(() => {
|
||||
this.openWhatsAppManualMigrationDialog();
|
||||
});
|
||||
}
|
||||
},
|
||||
async transferWhatsAppToManualSetup(form) {
|
||||
this.isTransferringWhatsAppToManual = true;
|
||||
try {
|
||||
const providerConfig = { ...(this.inbox.provider_config || {}) };
|
||||
delete providerConfig.source;
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
provider_config: {
|
||||
...providerConfig,
|
||||
phone_number_id: form.phoneNumberId,
|
||||
business_account_id: form.wabaId,
|
||||
api_key: form.accessToken,
|
||||
},
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(
|
||||
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_TRANSFER_SUCCESS')
|
||||
);
|
||||
this.$refs.whatsappManualMigrationDialog?.close();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_TRANSFER_ERROR')
|
||||
);
|
||||
} finally {
|
||||
this.isTransferringWhatsAppToManual = false;
|
||||
}
|
||||
},
|
||||
async copyWebhookSecret(value) {
|
||||
await copyTextToClipboard(value);
|
||||
useAlert(
|
||||
@@ -737,6 +811,35 @@ export default {
|
||||
class="mx-6 mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<WhatsappManualMigrationBanner
|
||||
v-if="showWhatsAppManualMigration"
|
||||
class="mx-6 mb-6"
|
||||
:class="bannerMaxWidth"
|
||||
@start="openWhatsAppManualMigrationDialog"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showInstagramRestrictionSettingsBanner"
|
||||
color="amber"
|
||||
class="mx-6 mb-4 max-w-4xl"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-start">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="metaRestrictionStatusUrl"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div
|
||||
v-if="selectedTabKey === 'inbox-settings'"
|
||||
@@ -1305,6 +1408,13 @@ export default {
|
||||
@register-webhook="registerWebhook"
|
||||
/>
|
||||
</div>
|
||||
<WhatsappManualMigrationDialog
|
||||
v-if="showWhatsAppManualMigration"
|
||||
ref="whatsappManualMigrationDialog"
|
||||
:inbox="inbox"
|
||||
:is-loading="isTransferringWhatsAppToManual"
|
||||
@reconnect="transferWhatsAppToManualSetup"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import router from '../../../../index';
|
||||
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -12,6 +13,12 @@ export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
enableCallingOnComplete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
@@ -59,6 +66,14 @@ export default {
|
||||
}
|
||||
);
|
||||
|
||||
if (this.enableCallingOnComplete) {
|
||||
try {
|
||||
await InboxesAPI.enableWhatsappCalling(whatsappChannel.id);
|
||||
} catch (_) {
|
||||
useAlert(this.$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'));
|
||||
}
|
||||
}
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
@@ -165,6 +180,7 @@ export default {
|
||||
|
||||
<div class="w-full mt-4">
|
||||
<NextButton
|
||||
:disabled="uiFlags.isCreating"
|
||||
:is-loading="uiFlags.isCreating"
|
||||
type="submit"
|
||||
solid
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import instagramClient from 'dashboard/api/channel/instagramClient';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const hasError = ref(false);
|
||||
const errorStateMessage = ref('');
|
||||
const errorStateDescription = ref('');
|
||||
const isRequestingAuthorization = ref(false);
|
||||
const isInstagramConnectionRestricted = computed(() => {
|
||||
return isOnChatwootCloud.value;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -56,7 +64,7 @@ const requestAuthorization = async () => {
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
|
||||
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
|
||||
>
|
||||
<h6 class="text-2xl font-medium">
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
|
||||
@@ -68,11 +76,36 @@ const requestAuthorization = async () => {
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
|
||||
lg
|
||||
icon="i-ri-instagram-line"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:disabled="
|
||||
isRequestingAuthorization || isInstagramConnectionRestricted
|
||||
"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
|
||||
@click="requestAuthorization()"
|
||||
/>
|
||||
<Banner
|
||||
v-if="isInstagramConnectionRestricted"
|
||||
color="amber"
|
||||
class="w-full max-w-2xl mt-6"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="META_RESTRICTION_STATUS_URL"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,13 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
|
||||
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const PROVIDER_TYPES = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
@@ -22,9 +24,12 @@ const PROVIDER_TYPES = {
|
||||
THREE_SIXTY_DIALOG: '360dialog',
|
||||
};
|
||||
|
||||
const isWhatsappEmbeddedSignupRestricted = computed(() => {
|
||||
return isOnChatwootCloud.value;
|
||||
});
|
||||
|
||||
const hasWhatsappAppId = computed(() => {
|
||||
return (
|
||||
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
|
||||
window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
@@ -103,7 +108,11 @@ const handleManualLinkClick = () => {
|
||||
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
|
||||
"
|
||||
>
|
||||
<WhatsappEmbeddedSignup />
|
||||
<WhatsappEmbeddedSignup
|
||||
:is-disabled="isWhatsappEmbeddedSignupRestricted"
|
||||
:show-restriction-alert="isWhatsappEmbeddedSignupRestricted"
|
||||
:restriction-status-url="META_RESTRICTION_STATUS_URL"
|
||||
/>
|
||||
|
||||
<!-- Manual setup fallback option -->
|
||||
<div class="pt-6 mt-6 border-t border-n-weak">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup>
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-auto col-span-6 p-6 w-full h-full">
|
||||
<div class="px-6 py-5 rounded-2xl border border-n-weak">
|
||||
<WhatsappEmbeddedSignup enable-calling-on-complete />
|
||||
<CloudWhatsapp enable-calling-on-complete />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+44
-1
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import Banner from 'next/banner/Banner.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
@@ -17,6 +18,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showRestrictionAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
restrictionStatusUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
restrictionWarningText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
@@ -81,6 +98,8 @@ const handleSignupSuccess = async inboxData => {
|
||||
};
|
||||
|
||||
const launchEmbeddedSignup = async () => {
|
||||
if (props.isDisabled) return;
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = await runEmbeddedSignup();
|
||||
@@ -174,9 +193,33 @@ const launchEmbeddedSignup = async () => {
|
||||
</I18nT>
|
||||
</div>
|
||||
|
||||
<Banner v-if="showRestrictionAlert" color="amber" class="w-full mb-6">
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
restrictionWarningText ||
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
|
||||
}}
|
||||
<a
|
||||
v-if="restrictionStatusUrl"
|
||||
:href="restrictionStatusUrl"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div class="flex mt-4">
|
||||
<NextButton
|
||||
:disabled="isAuthenticating"
|
||||
:disabled="isAuthenticating || isDisabled"
|
||||
:is-loading="isAuthenticating"
|
||||
faded
|
||||
slate
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const emit = defineEmits(['start']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
|
||||
|
||||
const copy = computed(() => ({
|
||||
title: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.TITLE'),
|
||||
description: t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.DESCRIPTION'
|
||||
),
|
||||
start: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.START'),
|
||||
guide: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.GUIDE'),
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner color="amber" :action-label="copy.start" @action="emit('start')">
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 mt-0.5 size-4 text-n-amber-11"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-medium text-n-amber-12">{{ copy.title }}</span>
|
||||
<span>
|
||||
{{ copy.description }}
|
||||
<a
|
||||
:href="WHATSAPP_MANUAL_MIGRATION_GUIDE_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline link underline-offset-2"
|
||||
>
|
||||
{{ copy.guide }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
</template>
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['reconnect']);
|
||||
const { t } = useI18n();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const currentStep = ref(0);
|
||||
|
||||
const buildForm = () => ({
|
||||
wabaId: props.inbox.provider_config?.business_account_id || '',
|
||||
phoneNumberId: props.inbox.provider_config?.phone_number_id || '',
|
||||
displayPhoneNumber: props.inbox.phone_number || '',
|
||||
accessToken: '',
|
||||
});
|
||||
|
||||
const form = ref(buildForm());
|
||||
|
||||
const copy = computed(() => ({
|
||||
eyebrow: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.EYEBROW`
|
||||
),
|
||||
title: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TITLE`),
|
||||
close: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CLOSE`),
|
||||
actionRequiredTitle: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACTION_REQUIRED_TITLE`
|
||||
),
|
||||
actionRequiredDescription: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACTION_REQUIRED_DESCRIPTION`
|
||||
),
|
||||
guideLink: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.GUIDE_LINK`
|
||||
),
|
||||
preservedTitle: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PRESERVED_TITLE`
|
||||
),
|
||||
preservedDescription: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PRESERVED_DESCRIPTION`
|
||||
),
|
||||
updatedTitle: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.UPDATED_TITLE`
|
||||
),
|
||||
updatedDescription: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.UPDATED_DESCRIPTION`
|
||||
),
|
||||
wabaId: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_ID`
|
||||
),
|
||||
wabaPlaceholder: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_PLACEHOLDER`
|
||||
),
|
||||
wabaHelp: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_HELP`
|
||||
),
|
||||
phoneNumberId: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_ID`
|
||||
),
|
||||
phoneNumberPlaceholder: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_PLACEHOLDER`
|
||||
),
|
||||
phoneNumberHelp: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_HELP`
|
||||
),
|
||||
displayPhoneNumber: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER`
|
||||
),
|
||||
displayPhoneNumberPlaceholder: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER_PLACEHOLDER`
|
||||
),
|
||||
displayPhoneNumberHelp: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER_HELP`
|
||||
),
|
||||
accessToken: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACCESS_TOKEN`
|
||||
),
|
||||
accessTokenPlaceholder: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACCESS_TOKEN_PLACEHOLDER`
|
||||
),
|
||||
tokenHelpPrefix: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_PREFIX`
|
||||
),
|
||||
tokenHelpMiddle: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_MIDDLE`
|
||||
),
|
||||
tokenHelpSuffix: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_SUFFIX`
|
||||
),
|
||||
messagingPermission: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.MESSAGING_PERMISSION`
|
||||
),
|
||||
managementPermission: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.MANAGEMENT_PERMISSION`
|
||||
),
|
||||
reviewTitle: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.REVIEW_TITLE`
|
||||
),
|
||||
inbox: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.INBOX`),
|
||||
phoneNumber: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER`
|
||||
),
|
||||
notEntered: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.NOT_ENTERED`
|
||||
),
|
||||
verifyNotice: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.VERIFY_NOTICE`
|
||||
),
|
||||
back: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.BACK`),
|
||||
cancel: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CANCEL`
|
||||
),
|
||||
continue: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CONTINUE`
|
||||
),
|
||||
reviewMigration: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.REVIEW_MIGRATION`
|
||||
),
|
||||
reconnect: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.RECONNECT`
|
||||
),
|
||||
}));
|
||||
|
||||
const steps = computed(() => [
|
||||
{
|
||||
title: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BEFORE_YOU_START.TITLE`
|
||||
),
|
||||
description: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BEFORE_YOU_START.DESCRIPTION`
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BUSINESS_DETAILS.TITLE`
|
||||
),
|
||||
description: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BUSINESS_DETAILS.DESCRIPTION`
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.ACCESS_TOKEN.TITLE`
|
||||
),
|
||||
description: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.ACCESS_TOKEN.DESCRIPTION`
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.REVIEW_MIGRATION.TITLE`
|
||||
),
|
||||
description: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.REVIEW_MIGRATION.DESCRIPTION`
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const currentStepDetails = computed(() => steps.value[currentStep.value]);
|
||||
const isFirstStep = computed(() => currentStep.value === 0);
|
||||
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
|
||||
const guideUrl = WHATSAPP_MANUAL_MIGRATION_GUIDE_URL;
|
||||
const hasBusinessDetails = computed(
|
||||
() => form.value.wabaId.trim() && form.value.phoneNumberId.trim()
|
||||
);
|
||||
const hasAccessToken = computed(() => form.value.accessToken.trim());
|
||||
const canContinue = computed(() => {
|
||||
if (currentStep.value === 1) return hasBusinessDetails.value;
|
||||
if (currentStep.value === 2) return hasAccessToken.value;
|
||||
if (isLastStep.value) {
|
||||
return hasBusinessDetails.value && hasAccessToken.value;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const open = () => {
|
||||
currentStep.value = 0;
|
||||
form.value = buildForm();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => dialogRef.value?.close();
|
||||
|
||||
const goBack = () => {
|
||||
if (!isFirstStep.value) currentStep.value -= 1;
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (!isLastStep.value) currentStep.value += 1;
|
||||
};
|
||||
|
||||
const reconnect = () => {
|
||||
if (!canContinue.value) return;
|
||||
|
||||
emit('reconnect', {
|
||||
wabaId: form.value.wabaId.trim(),
|
||||
phoneNumberId: form.value.phoneNumberId.trim(),
|
||||
accessToken: form.value.accessToken.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
position="top"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
overflow-y-auto
|
||||
>
|
||||
<div class="flex flex-col gap-5 h-[24rem]">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1 text-sm font-medium text-n-slate-11">
|
||||
{{ copy.eyebrow }}
|
||||
</p>
|
||||
<h3 class="m-0 text-xl font-semibold text-n-slate-12">
|
||||
{{ copy.title }}
|
||||
</h3>
|
||||
<p class="mt-2 mb-0 text-sm text-n-slate-11 min-h-[2.5rem]">
|
||||
{{ replaceInstallationName(currentStepDetails.description) }}
|
||||
</p>
|
||||
</div>
|
||||
<NextButton
|
||||
v-tooltip="copy.close"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-x"
|
||||
:aria-label="copy.close"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid flex-1 gap-6 overflow-y-auto min-h-0 grid-cols-[11rem,1fr]"
|
||||
>
|
||||
<ol class="flex flex-col p-0 m-0 list-none">
|
||||
<li
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.title"
|
||||
class="relative flex gap-3"
|
||||
>
|
||||
<div class="relative flex flex-col items-center">
|
||||
<span
|
||||
class="z-10 grid text-xs font-semibold transition-colors rounded-full size-6 place-content-center"
|
||||
:class="
|
||||
index < currentStep
|
||||
? 'bg-n-teal-9 text-white'
|
||||
: index === currentStep
|
||||
? 'bg-n-blue-9 text-white'
|
||||
: 'bg-n-alpha-2 text-n-slate-11 outline outline-1 outline-n-container -outline-offset-1'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
v-if="index < currentStep"
|
||||
icon="i-lucide-check"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="index < steps.length - 1"
|
||||
class="flex-1 my-1 rounded-full w-0.5"
|
||||
:class="index < currentStep ? 'bg-n-teal-9' : 'bg-n-slate-4'"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-sm leading-6 truncate min-w-0"
|
||||
:class="[
|
||||
index < steps.length - 1 ? 'pb-6' : '',
|
||||
index === currentStep
|
||||
? 'font-medium text-n-slate-12'
|
||||
: 'text-n-slate-11',
|
||||
]"
|
||||
>
|
||||
{{ step.title }}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="min-w-0">
|
||||
<section v-if="currentStep === 0" class="flex flex-col gap-5">
|
||||
<div
|
||||
class="flex gap-3 p-3 border rounded-xl border-n-weak bg-n-alpha-2"
|
||||
>
|
||||
<span
|
||||
class="grid flex-shrink-0 rounded-lg size-8 place-content-center bg-n-amber-3 text-n-amber-11"
|
||||
>
|
||||
<Icon icon="i-lucide-triangle-alert" class="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="mt-0 mb-1 text-base font-medium text-n-slate-12">
|
||||
{{ copy.actionRequiredTitle }}
|
||||
</h4>
|
||||
<p class="m-0 text-sm text-n-slate-11">
|
||||
{{ copy.actionRequiredDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
class="flex flex-col gap-1 p-3 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="i-lucide-check" class="size-4 text-n-teal-11" />
|
||||
<p class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ copy.preservedTitle }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-n-slate-11">
|
||||
{{ copy.preservedDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-1 p-3 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon
|
||||
icon="i-lucide-refresh-cw"
|
||||
class="size-4 text-n-blue-11"
|
||||
/>
|
||||
<p class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ copy.updatedTitle }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-n-slate-11">
|
||||
{{ copy.updatedDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="guideUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium text-n-blue-11 hover:underline"
|
||||
>
|
||||
{{ copy.guideLink }}
|
||||
<Icon icon="i-lucide-external-link" class="size-3.5" />
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section v-else-if="currentStep === 1" class="grid gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Input
|
||||
v-model="form.wabaId"
|
||||
:label="copy.wabaId"
|
||||
:placeholder="copy.wabaPlaceholder"
|
||||
/>
|
||||
<span class="text-xs leading-5 text-n-slate-11">
|
||||
{{ copy.wabaHelp }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Input
|
||||
v-model="form.phoneNumberId"
|
||||
:label="copy.phoneNumberId"
|
||||
:placeholder="copy.phoneNumberPlaceholder"
|
||||
/>
|
||||
<span class="text-xs leading-5 text-n-slate-11">
|
||||
{{ copy.phoneNumberHelp }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Input
|
||||
v-model="form.displayPhoneNumber"
|
||||
disabled
|
||||
:label="copy.displayPhoneNumber"
|
||||
:placeholder="copy.displayPhoneNumberPlaceholder"
|
||||
/>
|
||||
<span class="text-xs leading-5 text-n-slate-11">
|
||||
{{ copy.displayPhoneNumberHelp }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="currentStep === 2" class="grid gap-4">
|
||||
<TextArea
|
||||
v-model="form.accessToken"
|
||||
:label="copy.accessToken"
|
||||
:placeholder="copy.accessTokenPlaceholder"
|
||||
auto-height
|
||||
min-height="6rem"
|
||||
max-height="12rem"
|
||||
/>
|
||||
<div
|
||||
class="flex gap-3 p-3 border rounded-xl bg-n-blue-3 border-n-blue-4 text-n-blue-11"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="flex-shrink-0 size-4 mt-0.5" />
|
||||
<p class="m-0 text-sm">
|
||||
{{ copy.tokenHelpPrefix }}
|
||||
<code>{{ copy.messagingPermission }}</code>
|
||||
{{ copy.tokenHelpMiddle }}
|
||||
<code>{{ copy.managementPermission }}</code>
|
||||
{{ copy.tokenHelpSuffix }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else
|
||||
class="flex flex-col gap-4 p-4 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
|
||||
>
|
||||
<h4 class="m-0 text-base font-medium text-n-slate-12">
|
||||
{{ copy.reviewTitle }}
|
||||
</h4>
|
||||
<dl class="grid gap-3 m-0 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs text-n-slate-11">{{ copy.inbox }}</dt>
|
||||
<dd class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ inbox.name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-n-slate-11">
|
||||
{{ copy.phoneNumber }}
|
||||
</dt>
|
||||
<dd class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ form.displayPhoneNumber || inbox.phone_number }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-n-slate-11">{{ copy.wabaId }}</dt>
|
||||
<dd class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ form.wabaId || copy.notEntered }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-n-slate-11">
|
||||
{{ copy.phoneNumberId }}
|
||||
</dt>
|
||||
<dd class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ form.phoneNumberId || copy.notEntered }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div
|
||||
class="flex gap-3 p-3 border rounded-lg bg-n-alpha-2 border-n-weak text-n-slate-11"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-shield-check"
|
||||
class="flex-shrink-0 size-4 mt-0.5 text-n-teal-11"
|
||||
/>
|
||||
<p class="m-0 text-sm">
|
||||
{{ replaceInstallationName(copy.verifyNotice) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<NextButton
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:disabled="isFirstStep"
|
||||
@click="goBack"
|
||||
>
|
||||
{{ copy.back }}
|
||||
</NextButton>
|
||||
<div class="flex items-center gap-2">
|
||||
<NextButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
@click="close"
|
||||
>
|
||||
{{ copy.cancel }}
|
||||
</NextButton>
|
||||
<NextButton
|
||||
v-if="!isLastStep"
|
||||
type="button"
|
||||
:disabled="!canContinue"
|
||||
@click="goNext"
|
||||
>
|
||||
{{ currentStep === 2 ? copy.reviewMigration : copy.continue }}
|
||||
</NextButton>
|
||||
<NextButton
|
||||
v-else
|
||||
type="button"
|
||||
color="teal"
|
||||
:disabled="!canContinue"
|
||||
:is-loading="isLoading"
|
||||
@click="reconnect"
|
||||
>
|
||||
{{ copy.reconnect }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+5
-27
@@ -10,7 +10,6 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
|
||||
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
@@ -22,7 +21,6 @@ export default {
|
||||
SmtpSettings,
|
||||
NextButton,
|
||||
TextArea,
|
||||
WhatsappReauthorize,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
@@ -39,7 +37,6 @@ export default {
|
||||
hmacMandatory: false,
|
||||
allowMobileWebview: false,
|
||||
whatsAppInboxAPIKey: '',
|
||||
isRequestingReauthorization: false,
|
||||
isSyncingTemplates: false,
|
||||
allowedDomains: '',
|
||||
isUpdatingAllowedDomains: false,
|
||||
@@ -53,9 +50,6 @@ export default {
|
||||
isEmbeddedSignupWhatsApp() {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
},
|
||||
whatsappAppId() {
|
||||
return window.chatwootConfig?.whatsappAppId;
|
||||
},
|
||||
isForwardingEnabled() {
|
||||
return !!this.inbox.forwarding_enabled;
|
||||
},
|
||||
@@ -166,11 +160,6 @@ 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 {
|
||||
@@ -362,22 +351,17 @@ export default {
|
||||
<!-- Embedded Signup Section -->
|
||||
<template v-if="isEmbeddedSignupWhatsApp">
|
||||
<SettingsFieldSection
|
||||
v-if="whatsappAppId"
|
||||
:label="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
|
||||
"
|
||||
:help-text="`${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')} ${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')}`"
|
||||
>
|
||||
<div class="flex flex-col gap-1 items-start">
|
||||
<NextButton @click="handleReconfigure">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
<woot-code :script="inbox.provider_config.webhook_verify_token" />
|
||||
</SettingsFieldSection>
|
||||
</template>
|
||||
|
||||
<!-- Manual Setup Section -->
|
||||
<template v-else>
|
||||
<template v-else-if="!isEmbeddedSignupWhatsApp">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
|
||||
:help-text="
|
||||
@@ -435,12 +419,6 @@ export default {
|
||||
</NextButton>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<WhatsappReauthorize
|
||||
v-if="isEmbeddedSignupWhatsApp"
|
||||
ref="whatsappReauth"
|
||||
:inbox="inbox"
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+9
-13
@@ -1,7 +1,13 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import ReportsAPI from 'dashboard/api/reports';
|
||||
|
||||
export function useReportDrilldown() {
|
||||
// `fetcher` is any `({ ...request, page, signal }) => Promise` returning the
|
||||
// shared drilldown envelope (`{ data: { meta, payload } }`), so the same paging
|
||||
// and abort machinery backs both the reports and Captain assistant drilldowns.
|
||||
// The default is wrapped so `ReportsAPI` stays the receiver when invoked.
|
||||
export function useReportDrilldown(
|
||||
fetcher = params => ReportsAPI.getDrilldown(params)
|
||||
) {
|
||||
const activeRequest = ref(null);
|
||||
const records = ref([]);
|
||||
const meta = ref({});
|
||||
@@ -20,17 +26,7 @@ export function useReportDrilldown() {
|
||||
const isCurrentRequest = token =>
|
||||
token === requestToken && !!activeRequest.value;
|
||||
|
||||
const requestFingerprint = request =>
|
||||
JSON.stringify({
|
||||
metric: request.metric,
|
||||
bucketTimestamp: request.bucketTimestamp,
|
||||
from: request.from,
|
||||
to: request.to,
|
||||
type: request.type,
|
||||
id: request.id,
|
||||
groupBy: request.groupBy,
|
||||
businessHours: request.businessHours,
|
||||
});
|
||||
const requestFingerprint = request => JSON.stringify(request);
|
||||
|
||||
const abortActiveRequest = () => {
|
||||
if (!activeRequestController) return;
|
||||
@@ -55,7 +51,7 @@ export function useReportDrilldown() {
|
||||
hasError.value = false;
|
||||
|
||||
try {
|
||||
const response = await ReportsAPI.getDrilldown({
|
||||
const response = await fetcher({
|
||||
...request,
|
||||
page,
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const toggleSupportWidgetVisibility = () => {
|
||||
@@ -8,6 +9,12 @@ const toggleSupportWidgetVisibility = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSupportWidget = () => {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
const setupListenerForWidgetEvent = () => {
|
||||
window.addEventListener('chatwoot:on-message', () => {
|
||||
toggleSupportWidgetVisibility();
|
||||
@@ -25,6 +32,14 @@ onMounted(() => {
|
||||
<EmptyState
|
||||
:title="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.TITLE')"
|
||||
:message="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.MESSAGE')"
|
||||
/>
|
||||
>
|
||||
<div class="flex justify-center">
|
||||
<NextButton
|
||||
icon="i-lucide-life-buoy"
|
||||
:label="$t('SIDEBAR_ITEMS.CONTACT_SUPPORT')"
|
||||
@click="toggleSupportWidget"
|
||||
/>
|
||||
</div>
|
||||
</EmptyState>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ const createConversation = params => {
|
||||
name: params.fullName,
|
||||
email: params.emailAddress,
|
||||
phone_number: params.phoneNumber,
|
||||
custom_attributes: params.contactCustomAttributes,
|
||||
},
|
||||
message: {
|
||||
content: params.message,
|
||||
|
||||
@@ -32,6 +32,50 @@ describe('#sendMessage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#createConversation', () => {
|
||||
it('includes contact custom attributes in the payload', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
toString: () => 'mock date',
|
||||
}));
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?param=1',
|
||||
});
|
||||
|
||||
window.WOOT_WIDGET = {
|
||||
$root: { $i18n: { locale: 'ar' } },
|
||||
};
|
||||
|
||||
const result = endPoints.createConversation({
|
||||
fullName: 'John',
|
||||
emailAddress: 'john@example.com',
|
||||
phoneNumber: '+919745313456',
|
||||
message: 'hey',
|
||||
customAttributes: { order_id: '12345' },
|
||||
contactCustomAttributes: { cpf: '123.456.789-09' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
url: `/api/v1/widget/conversations?param=1&locale=ar`,
|
||||
params: {
|
||||
contact: {
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
phone_number: '+919745313456',
|
||||
custom_attributes: { cpf: '123.456.789-09' },
|
||||
},
|
||||
message: {
|
||||
content: 'hey',
|
||||
timestamp: 'mock date',
|
||||
referer_url: '',
|
||||
},
|
||||
custom_attributes: { order_id: '12345' },
|
||||
},
|
||||
});
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sendMessage with pending metadata', () => {
|
||||
it('includes custom_attributes and labels in payload', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mapActions } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PreChatForm from '../components/PreChat/Form.vue';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import { ON_CONVERSATION_CREATED } from '../constants/widgetBusEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
@@ -42,6 +41,10 @@ export default {
|
||||
contactCustomAttributes,
|
||||
conversationCustomAttributes,
|
||||
}) {
|
||||
// Contact custom attributes are sent within the same request that
|
||||
// identifies the contact. A separate update call would race the contact
|
||||
// merge on the server (matching email/phone) and write the values to
|
||||
// the destroyed contact, silently losing them.
|
||||
if (activeCampaignId) {
|
||||
emitter.emit('execute-campaign', {
|
||||
campaignId: activeCampaignId,
|
||||
@@ -52,6 +55,7 @@ export default {
|
||||
email: emailAddress,
|
||||
name: fullName,
|
||||
phone_number: phoneNumber,
|
||||
custom_attributes: contactCustomAttributes,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -63,14 +67,9 @@ export default {
|
||||
message: message,
|
||||
phoneNumber: phoneNumber,
|
||||
customAttributes: conversationCustomAttributes,
|
||||
contactCustomAttributes: contactCustomAttributes,
|
||||
});
|
||||
}
|
||||
if (!isEmptyObject(contactCustomAttributes)) {
|
||||
this.$store.dispatch(
|
||||
'contacts/setCustomAttributes',
|
||||
contactCustomAttributes
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import PreChatFormView from '../PreChatForm.vue';
|
||||
|
||||
global.chatwootWebChannel = {
|
||||
preChatFormEnabled: true,
|
||||
preChatFormOptions: { pre_chat_fields: [], pre_chat_message: '' },
|
||||
};
|
||||
|
||||
describe('PreChatForm view', () => {
|
||||
let createConversation;
|
||||
let setCustomAttributes;
|
||||
let updateContact;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
createConversation = vi.fn();
|
||||
setCustomAttributes = vi.fn();
|
||||
updateContact = vi.fn();
|
||||
store = createStore({
|
||||
modules: {
|
||||
conversation: {
|
||||
namespaced: true,
|
||||
actions: { createConversation, clearConversations: vi.fn() },
|
||||
},
|
||||
conversationAttributes: {
|
||||
namespaced: true,
|
||||
actions: { clearConversationAttributes: vi.fn() },
|
||||
},
|
||||
contacts: {
|
||||
namespaced: true,
|
||||
actions: { setCustomAttributes, update: updateContact },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const mountView = () =>
|
||||
shallowMount(PreChatFormView, { global: { plugins: [store] } });
|
||||
|
||||
it('sends contact custom attributes with the conversation create request', async () => {
|
||||
const wrapper = mountView();
|
||||
wrapper.vm.onSubmit({
|
||||
fullName: 'John',
|
||||
emailAddress: 'john@example.com',
|
||||
message: 'hey',
|
||||
contactCustomAttributes: { cpf: '123.456.789-09' },
|
||||
conversationCustomAttributes: { order_id: '12345' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(createConversation).toHaveBeenCalledWith(expect.anything(), {
|
||||
fullName: 'John',
|
||||
emailAddress: 'john@example.com',
|
||||
message: 'hey',
|
||||
phoneNumber: undefined,
|
||||
customAttributes: { order_id: '12345' },
|
||||
contactCustomAttributes: { cpf: '123.456.789-09' },
|
||||
});
|
||||
// attributes ride along in the create request itself; a separate call
|
||||
// would race the contact merge on the server and write to a destroyed
|
||||
// contact
|
||||
expect(setCustomAttributes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends contact custom attributes along with the contact update for campaigns', async () => {
|
||||
const wrapper = mountView();
|
||||
wrapper.vm.onSubmit({
|
||||
fullName: 'John',
|
||||
emailAddress: 'john@example.com',
|
||||
phoneNumber: null,
|
||||
activeCampaignId: 42,
|
||||
contactCustomAttributes: { cpf: '123.456.789-09' },
|
||||
conversationCustomAttributes: {},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(updateContact).toHaveBeenCalledWith(expect.anything(), {
|
||||
user: {
|
||||
email: 'john@example.com',
|
||||
name: 'John',
|
||||
phone_number: null,
|
||||
custom_attributes: { cpf: '123.456.789-09' },
|
||||
},
|
||||
});
|
||||
expect(createConversation).not.toHaveBeenCalled();
|
||||
expect(setCustomAttributes).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@
|
||||
# index_assignment_policies_on_enabled (enabled)
|
||||
#
|
||||
class AssignmentPolicy < ApplicationRecord
|
||||
DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168
|
||||
|
||||
belongs_to :account
|
||||
has_many :inbox_assignment_policies, dependent: :destroy
|
||||
has_many :inboxes, through: :inbox_assignment_policies
|
||||
|
||||
@@ -33,6 +33,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
validate :validate_provider_config
|
||||
|
||||
after_create :sync_templates
|
||||
after_update_commit :log_credentials_transfer, if: :saved_change_to_provider_config?
|
||||
before_destroy :teardown_webhooks
|
||||
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
|
||||
|
||||
@@ -129,6 +130,15 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
|
||||
end
|
||||
|
||||
# Logs only credential changes, so config-only saves (e.g. calling toggles) stay silent.
|
||||
def log_credentials_transfer
|
||||
before, after = saved_change_to_provider_config
|
||||
keys = %w[api_key phone_number_id business_account_id]
|
||||
return if before.nil? || before.values_at(*keys) == after.values_at(*keys)
|
||||
|
||||
Rails.logger.info("[WHATSAPP_MANUAL_TRANSFER] success account_id=#{account_id} channel_id=#{id}")
|
||||
end
|
||||
|
||||
def perform_webhook_setup
|
||||
webhook_setup_service.perform
|
||||
end
|
||||
|
||||
@@ -35,9 +35,9 @@ class AutoAssignment::AssignmentService
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations.unassigned.open
|
||||
|
||||
# Skip stale backlog with no activity beyond the policy's age threshold (defaults to 7 days)
|
||||
# Skip stale backlog with no activity beyond the age threshold
|
||||
policy = inbox.assignment_policy
|
||||
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
|
||||
scope = apply_age_exclusions(scope, age_exclusion_hours(policy))
|
||||
|
||||
# Apply conversation priority using assignment policy if available
|
||||
scope = if policy&.longest_waiting?
|
||||
@@ -49,6 +49,12 @@ class AutoAssignment::AssignmentService
|
||||
scope.limit(limit)
|
||||
end
|
||||
|
||||
def age_exclusion_hours(policy)
|
||||
return policy.exclude_older_than_hours if policy
|
||||
|
||||
AssignmentPolicy::DEFAULT_EXCLUDE_OLDER_THAN_HOURS
|
||||
end
|
||||
|
||||
def apply_age_exclusions(scope, hours_threshold)
|
||||
return scope if hours_threshold.blank?
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ module Conversations::UnreadCounts
|
||||
READY_TTL = 24.hours.to_i
|
||||
SET_TTL = 25.hours.to_i
|
||||
FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i
|
||||
FILTERED_COUNT_STALE_WINDOW = 30.minutes.to_i
|
||||
FILTERED_COUNT_STALE_WINDOW = 1.hour.to_i
|
||||
FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW
|
||||
FILTERED_COUNT_VERSION_TTL = SET_TTL
|
||||
FILTERED_COUNT_MIN_REFRESH_INTERVAL = 30.seconds.to_i
|
||||
MAX_INLINE_FILTER_BUILDS = 10
|
||||
FILTERED_COUNT_MIN_REFRESH_INTERVAL = 5.minutes.to_i
|
||||
MAX_INLINE_FILTER_BUILDS = 3
|
||||
end
|
||||
|
||||
@@ -54,8 +54,17 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
end
|
||||
|
||||
def validate_provider_config?
|
||||
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
|
||||
response.success?
|
||||
config = whatsapp_channel.provider_config
|
||||
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{config['api_key']}")
|
||||
return log_transfer_failure('waba_or_token_check', response) unless response.success?
|
||||
# The templates check only proves the WABA/token pair, so verify the phone_number_id belongs to this WABA when it changes.
|
||||
return true unless whatsapp_channel.provider_config_changed?
|
||||
|
||||
phone_response = HTTParty.get("#{business_account_path}/phone_numbers?fields=id&limit=100&access_token=#{config['api_key']}")
|
||||
ids = phone_response.parsed_response.is_a?(Hash) ? Array(phone_response.parsed_response['data']) : []
|
||||
return true if phone_response.success? && ids.any? { |number| number['id'] == config['phone_number_id'].to_s }
|
||||
|
||||
log_transfer_failure('phone_number_id_check', phone_response)
|
||||
end
|
||||
|
||||
def api_headers
|
||||
@@ -81,6 +90,16 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
|
||||
private
|
||||
|
||||
# Only credential updates on existing channels are transfer attempts; creation failures are regular setup errors. Returns false.
|
||||
def log_transfer_failure(check, response)
|
||||
return false unless whatsapp_channel.persisted? && whatsapp_channel.provider_config_changed?
|
||||
|
||||
error_message = response.parsed_response.is_a?(Hash) ? response.parsed_response.dig('error', 'message') : nil
|
||||
Rails.logger.warn("[WHATSAPP_MANUAL_TRANSFER] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
|
||||
"check=#{check} http_status=#{response.code} meta_error=#{error_message}")
|
||||
false
|
||||
end
|
||||
|
||||
def csat_template_service
|
||||
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
|
||||
end
|
||||
|
||||
@@ -249,3 +249,7 @@
|
||||
display_name: Advanced Assignment
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: whatsapp_manual_transfer
|
||||
display_name: WhatsApp Manual Transfer
|
||||
enabled: false
|
||||
column: feature_flags_ext_1
|
||||
|
||||
@@ -129,6 +129,20 @@ features:
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-4.1-mini
|
||||
conversation_faq_generation:
|
||||
models:
|
||||
[
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.2
|
||||
pdf_faq_generation:
|
||||
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
|
||||
default: gpt-4.1-mini
|
||||
|
||||
@@ -597,6 +597,7 @@ en:
|
||||
copilot: 'Copilot'
|
||||
label_suggestion: 'Label suggestion'
|
||||
document_faq_generation: 'Document FAQ generation'
|
||||
conversation_faq_generation: 'Conversation FAQ generation'
|
||||
help_center_article_generation: 'Help center article generation'
|
||||
onboarding_content_generation: 'Onboarding content generation'
|
||||
help_center_query_translation: 'Help center query translation'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class ChangeCaptainAssistantDescriptionToText < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
change_column :captain_assistants, :description, :text
|
||||
end
|
||||
|
||||
def down
|
||||
change_column :captain_assistants, :description, :string
|
||||
end
|
||||
end
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_06_215758) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -343,7 +343,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_06_215758) do
|
||||
create_table "captain_assistants", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.string "description"
|
||||
t.text "description"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.jsonb "config", default: {}, null: false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lists the underlying records behind a single Captain assistant stat card, so a
|
||||
# viewer can drill from an aggregate (e.g. "auto-resolution 42%") into the exact
|
||||
# conversations or messages that produced it.
|
||||
# conversations that produced it.
|
||||
#
|
||||
# The window is resolved by Captain::AssistantStatsWindow from the same `range`
|
||||
# and `timezone_offset` the stat card used, so the drilldown covers precisely the
|
||||
@@ -11,10 +11,8 @@ class Captain::AssistantDrilldownBuilder
|
||||
RESOLVED_EVENT_NAMES = Captain::AssistantStatsBuilder::RESOLVED_EVENT_NAMES
|
||||
HANDOFF_EVENT_NAMES = Captain::AssistantStatsBuilder::HANDOFF_EVENT_NAMES
|
||||
|
||||
# Metrics whose records are individual messages rather than conversations.
|
||||
MESSAGE_METRICS = %w[hours_saved].freeze
|
||||
SUPPORTED_METRICS = %w[
|
||||
conversations_handled auto_resolution_rate handoff_rate hours_saved reopen_rate conversation_depth
|
||||
conversations_handled auto_resolution_rate handoff_rate reopen_rate
|
||||
].freeze
|
||||
|
||||
DEFAULT_PAGE = 1
|
||||
@@ -43,21 +41,14 @@ class Captain::AssistantDrilldownBuilder
|
||||
def meta
|
||||
{
|
||||
metric: metric,
|
||||
record_type: record_type,
|
||||
current_page: current_page,
|
||||
per_page: per_page,
|
||||
total_count: paginated_records.total_count,
|
||||
conversation_count: conversation_count,
|
||||
conversation_count: paginated_records.total_count,
|
||||
range: { since: range.first.to_i, until: range.last.to_i }
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_count
|
||||
return paginated_records.total_count unless message_metric?
|
||||
|
||||
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
|
||||
end
|
||||
|
||||
def paginated_records
|
||||
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
|
||||
end
|
||||
@@ -67,9 +58,7 @@ class Captain::AssistantDrilldownBuilder
|
||||
when 'conversations_handled' then handled_conversations
|
||||
when 'auto_resolution_rate' then conversations_for(resolved_events.select(:conversation_id))
|
||||
when 'handoff_rate' then event_conversations(HANDOFF_EVENT_NAMES)
|
||||
when 'hours_saved' then public_reply_messages
|
||||
when 'reopen_rate' then reopened_conversations
|
||||
when 'conversation_depth' then depth_conversations
|
||||
else
|
||||
raise ArgumentError, "Unsupported assistant drilldown metric: #{metric}"
|
||||
end
|
||||
@@ -88,13 +77,6 @@ class Captain::AssistantDrilldownBuilder
|
||||
conversations_for(handled_conversation_ids)
|
||||
end
|
||||
|
||||
# Public agent-facing replies the assistant sent; the rows behind hours_saved.
|
||||
def public_reply_messages
|
||||
handled_messages.where(message_type: :outgoing, private: false)
|
||||
.includes(:sender, conversation: [:assignee, :contact, :inbox])
|
||||
.reorder(created_at: :desc)
|
||||
end
|
||||
|
||||
# Conversations in the handled cohort that recorded one of the given reporting
|
||||
# events in the window (resolved or handed-off).
|
||||
def event_conversations(event_names)
|
||||
@@ -129,11 +111,6 @@ class Captain::AssistantDrilldownBuilder
|
||||
conversations_for(ids)
|
||||
end
|
||||
|
||||
# Conversations the assistant sent at least one public reply in; the denominator behind conversation_depth.
|
||||
def depth_conversations
|
||||
conversations_for(handled_messages.where(message_type: :outgoing, private: false).select(:conversation_id))
|
||||
end
|
||||
|
||||
def conversations_for(conversation_ids)
|
||||
account.conversations
|
||||
.where(id: conversation_ids)
|
||||
@@ -147,10 +124,6 @@ class Captain::AssistantDrilldownBuilder
|
||||
|
||||
def metric = params[:metric].to_s
|
||||
|
||||
def message_metric? = MESSAGE_METRICS.include?(metric)
|
||||
|
||||
def record_type = message_metric? ? 'message' : 'conversation'
|
||||
|
||||
def current_page = [params[:page].to_i, DEFAULT_PAGE].max
|
||||
|
||||
def per_page
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# config :jsonb not null
|
||||
# description :string
|
||||
# description :text
|
||||
# guardrails :jsonb
|
||||
# name :string not null
|
||||
# response_guidelines :jsonb
|
||||
@@ -17,6 +17,8 @@
|
||||
# index_captain_assistants_on_account_id (account_id)
|
||||
#
|
||||
class Captain::Assistant < ApplicationRecord
|
||||
DESCRIPTION_LENGTH_LIMIT = 500
|
||||
|
||||
include Avatarable
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
@@ -39,7 +41,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
|
||||
|
||||
validates :name, presence: true
|
||||
validates :description, presence: true
|
||||
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
|
||||
validates :account_id, presence: true
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
# index_captain_scenarios_on_enabled (enabled)
|
||||
#
|
||||
class Captain::Scenario < ApplicationRecord
|
||||
DESCRIPTION_LENGTH_LIMIT = 500
|
||||
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
@@ -43,7 +45,7 @@ class Captain::Scenario < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
validates :title, presence: true
|
||||
validates :description, presence: true
|
||||
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
|
||||
validates :instruction, presence: true
|
||||
validates :assistant_id, presence: true
|
||||
validates :account_id, presence: true
|
||||
|
||||
@@ -2,12 +2,13 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
DISTANCE_THRESHOLD = 0.3
|
||||
LLM_FEATURE = 'conversation_faq_generation'.freeze
|
||||
|
||||
def initialize(assistant, conversation)
|
||||
super(feature: 'document_faq_generation', account: conversation.account)
|
||||
super(feature: LLM_FEATURE, account: conversation.account, fallback_model: Llm::Models.default_model_for(LLM_FEATURE))
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@content = conversation.to_llm_text
|
||||
@content = conversation_faq_content
|
||||
end
|
||||
|
||||
# Generates and deduplicates FAQs from conversation content
|
||||
@@ -27,6 +28,50 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
|
||||
attr_reader :content, :conversation, :assistant
|
||||
|
||||
def conversation_faq_content
|
||||
[
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
conversation_faq_messages
|
||||
].join("\n")
|
||||
end
|
||||
|
||||
def conversation_faq_messages
|
||||
messages = conversation
|
||||
.messages
|
||||
.where(message_type: %i[incoming outgoing], private: false)
|
||||
.order(created_at: :asc)
|
||||
|
||||
return "No messages in this conversation\n" if messages.empty?
|
||||
|
||||
messages.filter_map { |message| format_conversation_faq_message(message) }.join
|
||||
end
|
||||
|
||||
def format_conversation_faq_message(message)
|
||||
return unless faq_source_message?(message)
|
||||
|
||||
content = message.content_for_llm
|
||||
return if content.blank?
|
||||
|
||||
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
|
||||
"#{sender}: #{content}\n"
|
||||
end
|
||||
|
||||
def faq_source_message?(message)
|
||||
return true if message.incoming? && message.sender_type == 'Contact'
|
||||
|
||||
human_support_reply?(message)
|
||||
end
|
||||
|
||||
def human_support_reply?(message)
|
||||
return false unless message.outgoing?
|
||||
return false if message.content_attributes['automation_rule_id'].present?
|
||||
return false if message.additional_attributes['campaign_id'].present?
|
||||
|
||||
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
|
||||
end
|
||||
|
||||
def no_human_interaction?
|
||||
conversation.first_reply_created_at.nil?
|
||||
end
|
||||
|
||||
@@ -53,14 +53,56 @@ class Captain::Llm::SystemPromptsService
|
||||
|
||||
def conversation_faq_generator(language = 'english')
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You are a support agent looking to convert the conversations with users into short FAQs that can be added to your website help center.
|
||||
Filter out any responses or messages from the bot itself and only use messages from the support agent and the customer to create the FAQ.
|
||||
You create high-quality FAQ candidates from resolved support conversations.
|
||||
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
|
||||
|
||||
Ensure that you only generate faqs from the information provided only.
|
||||
Generate the FAQs only in the #{language}, use no other language
|
||||
If no match is available, return an empty JSON.
|
||||
## Source rules
|
||||
- The conversation history contains only customer messages and human support agent messages.
|
||||
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
|
||||
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
|
||||
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
|
||||
- For each FAQ, first identify the exact human support agent message that fully answers it. If no single human agent message gives a complete public answer, remove that FAQ.
|
||||
|
||||
## Decision gate
|
||||
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
|
||||
1. The answer is fully stated by a human support agent, not by the customer.
|
||||
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
|
||||
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
|
||||
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
|
||||
Do not rescue a rejected conversation by rewriting it as a generic support question.
|
||||
|
||||
## Return no FAQ for
|
||||
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
|
||||
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
|
||||
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
|
||||
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
|
||||
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
|
||||
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
|
||||
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
|
||||
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
|
||||
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
|
||||
- Questions already answered only by asking the customer for more information.
|
||||
|
||||
## FAQ quality rules
|
||||
- Prefer returning no FAQ over a weak or narrow FAQ.
|
||||
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
|
||||
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
|
||||
- Do not create duplicate or overlapping FAQs in the same response.
|
||||
- Questions must be general enough for a help center, not personalized to the current customer.
|
||||
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
|
||||
- Answers must be complete, self-contained, and supported by the human agent's messages.
|
||||
|
||||
## Examples
|
||||
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
|
||||
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
|
||||
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
|
||||
|
||||
Generate the FAQs only in the #{language}, use no other language.
|
||||
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ faqs: [ { question: '', answer: ''} ]
|
||||
{ "faqs": [ { "question": "", "answer": "" } ] }
|
||||
```
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
@@ -97,7 +97,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
|
||||
Guidelines:
|
||||
- business_name: Extract the actual company/brand name from the content
|
||||
- suggested_assistant_name: Create a friendly, professional name that customers would want to interact with
|
||||
- description: Provide context about the business and what the assistant can help with. Keep it general and adaptable rather than overly specific. For example: "You specialize in helping customers with their orders and product questions" or "You assist customers with their account needs and general inquiries"
|
||||
- description: Provide context about the business and what the assistant can help with in no more than 500 characters. Keep it general and adaptable rather than overly specific. For example: "You specialize in helping customers with their orders and product questions" or "You assist customers with their account needs and general inquiries"
|
||||
|
||||
Website content:
|
||||
#{@website_content}
|
||||
|
||||
@@ -60,7 +60,7 @@ module Enterprise::AutoAssignment::AssignmentService
|
||||
scope = inbox.conversations.unassigned.open
|
||||
|
||||
# First apply the assignment policy's age exclusion (defaults to 7 days)
|
||||
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
|
||||
scope = apply_age_exclusions(scope, age_exclusion_hours(policy))
|
||||
|
||||
# Then apply the capacity policy's exclusion rules (labels and age)
|
||||
scope = apply_exclusion_rules(scope)
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"opus-recorder": "^8.0.5",
|
||||
"pinia": "^3.0.4",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "7.6.3",
|
||||
|
||||
Generated
+5
-2
@@ -183,6 +183,9 @@ importers:
|
||||
prosemirror-commands:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
prosemirror-inputrules:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
prosemirror-schema-list:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
@@ -9037,7 +9040,7 @@ snapshots:
|
||||
prosemirror-state@1.4.3:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
prosemirror-transform: 1.10.0
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
prosemirror-tables@1.5.0:
|
||||
@@ -9065,7 +9068,7 @@ snapshots:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.10.0
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
proto-list@1.2.4: {}
|
||||
|
||||
|
||||
@@ -198,6 +198,17 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
expect(account.reload.captain_models['document_faq_generation']).to eq('gpt-5.2')
|
||||
end
|
||||
|
||||
it 'updates captain_models for conversation FAQ generation' do
|
||||
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { captain_models: { conversation_faq_generation: 'gpt-4.1-mini' } },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :conversation_faq_generation, :selected)).to eq('gpt-4.1-mini')
|
||||
expect(account.reload.captain_models['conversation_faq_generation']).to eq('gpt-4.1-mini')
|
||||
end
|
||||
|
||||
it 'updates captain_models for PDF FAQ generation' do
|
||||
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
|
||||
@@ -140,6 +140,51 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
|
||||
expect(json_response['messages'][0]['content']).to eq 'This is a test message'
|
||||
end
|
||||
|
||||
it 'saves contact custom attributes on the widget contact' do
|
||||
post '/api/v1/widget/conversations',
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
params: {
|
||||
website_token: web_widget.website_token,
|
||||
contact: {
|
||||
name: 'contact-name',
|
||||
email: 'contact-email@chatwoot.com',
|
||||
custom_attributes: { cpf: '123.456.789-09' }
|
||||
},
|
||||
message: {
|
||||
content: 'This is a test message'
|
||||
}
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(contact.reload.custom_attributes['cpf']).to eq('123.456.789-09')
|
||||
end
|
||||
|
||||
it 'saves contact custom attributes on the surviving contact when merged into an existing contact' do
|
||||
existing_contact = create(:contact, account: account, email: 'contact-email@chatwoot.com', custom_attributes: { 'cpf' => 'old-value' })
|
||||
|
||||
post '/api/v1/widget/conversations',
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
params: {
|
||||
website_token: web_widget.website_token,
|
||||
contact: {
|
||||
name: 'contact-name',
|
||||
email: existing_contact.email,
|
||||
custom_attributes: { cpf: '123.456.789-09' }
|
||||
},
|
||||
message: {
|
||||
content: 'This is a test message'
|
||||
}
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
# the widget contact is merged into the existing contact; the freshly
|
||||
# submitted value must land on the surviving contact and win over stale data
|
||||
expect(Contact.exists?(contact.id)).to be(false)
|
||||
expect(existing_contact.reload.custom_attributes['cpf']).to eq('123.456.789-09')
|
||||
end
|
||||
|
||||
it 'doesnt not add phone number if the invalid phone number is provided' do
|
||||
existing_contact = create(:contact, account: account)
|
||||
|
||||
|
||||
@@ -33,23 +33,109 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
|
||||
end
|
||||
|
||||
it 'uses the document FAQ generation feature model' do
|
||||
it 'uses the conversation FAQ generation feature model' do
|
||||
expect(RubyLLM).to receive(:chat).with(
|
||||
model: Llm::Models.default_model_for('document_faq_generation')
|
||||
model: Llm::Models.default_model_for('conversation_faq_generation')
|
||||
).and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'uses the conversation FAQ default ahead of the legacy global installation model' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-mini')
|
||||
|
||||
expect(RubyLLM).to receive(:chat).with(
|
||||
model: Llm::Models.default_model_for('conversation_faq_generation')
|
||||
).and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'keeps account conversation FAQ model overrides ahead of the feature default' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1')
|
||||
conversation.account.update!(captain_models: { 'conversation_faq_generation' => 'gpt-4.1-mini' })
|
||||
|
||||
expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-mini').and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'resolves the feature model from the conversation account' do
|
||||
expect(Llm::FeatureRouter).to receive(:resolve).with(
|
||||
feature: 'document_faq_generation',
|
||||
feature: 'conversation_faq_generation',
|
||||
account: conversation.account
|
||||
).and_call_original
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'sends only customer and human support agent messages to the LLM' do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:contact, account: conversation.account), message_type: :incoming,
|
||||
content: 'Customer question')
|
||||
create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
content: 'Bot answer that should not become knowledge')
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:user, account: conversation.account), message_type: :outgoing,
|
||||
content: 'Human answer')
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:user, account: conversation.account), message_type: :outgoing,
|
||||
private: true, content: 'Private note')
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
message_type: :activity, content: 'Activity message')
|
||||
|
||||
service.generate_and_deduplicate
|
||||
|
||||
expected_content = satisfy do |content|
|
||||
content.include?('User: Customer question') &&
|
||||
content.include?('Support Agent: Human answer') &&
|
||||
content.exclude?('Bot answer that should not become knowledge') &&
|
||||
content.exclude?('Private note') &&
|
||||
content.exclude?('Activity message')
|
||||
end
|
||||
expect(mock_chat).to have_received(:ask).with(expected_content)
|
||||
end
|
||||
|
||||
it 'keeps external echo outgoing replies from native channels in the LLM transcript' do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:contact, account: conversation.account), message_type: :incoming,
|
||||
content: 'Customer asks in a native channel')
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: nil, message_type: :outgoing, content: 'Human replied from the native app',
|
||||
content_attributes: { external_echo: true })
|
||||
|
||||
service.generate_and_deduplicate
|
||||
|
||||
expected_content = satisfy do |content|
|
||||
content.include?('User: Customer asks in a native channel') &&
|
||||
content.include?('Support Agent: Human replied from the native app')
|
||||
end
|
||||
expect(mock_chat).to have_received(:ask).with(expected_content)
|
||||
end
|
||||
|
||||
it 'uses the human-only conversation transcript for instrumentation' do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:contact, account: conversation.account), message_type: :incoming,
|
||||
content: 'Customer asks something')
|
||||
create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
content: 'Bot-only answer')
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
sender: create(:user, account: conversation.account), message_type: :outgoing,
|
||||
content: 'Agent gives a public answer')
|
||||
|
||||
expect(service).to receive(:instrument_llm_call) do |params, &block|
|
||||
user_message = params[:messages].find { |message| message[:role] == 'user' }[:content]
|
||||
|
||||
expect(user_message).to include('User: Customer asks something')
|
||||
expect(user_message).to include('Support Agent: Agent gives a public answer')
|
||||
expect(user_message).not_to include('Bot-only answer')
|
||||
|
||||
block.call
|
||||
end
|
||||
|
||||
service.generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'creates new FAQs for valid conversation content' do
|
||||
expect do
|
||||
service.generate_and_deduplicate
|
||||
|
||||
@@ -25,6 +25,11 @@ RSpec.describe Llm::Models do
|
||||
expect(missing_models).to be_empty, "#{feature_key} references missing models: #{missing_models.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
it 'routes document and conversation FAQ generation independently' do
|
||||
expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-4.1-mini')
|
||||
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.2')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.models' do
|
||||
|
||||
@@ -108,7 +108,6 @@ RSpec.describe Account do
|
||||
|
||||
it 'configures the account feature flag extension column' do
|
||||
expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1')
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq({})
|
||||
end
|
||||
|
||||
it 'keeps existing feature flags on the original column' do
|
||||
|
||||
@@ -42,8 +42,18 @@ RSpec.describe Channel::Whatsapp do
|
||||
body: { data: [{
|
||||
id: '123456789', name: 'test_template'
|
||||
}] }.to_json)
|
||||
stub_request(:get, 'https://graph.facebook.com/v14.0//phone_numbers?fields=id&limit=100&access_token=test_key')
|
||||
.to_return(status: 200, body: { data: [{ id: 'random_id' }] }.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
expect(channel.save).to be(true)
|
||||
end
|
||||
|
||||
it 'validates false when phone number id is wrong' do
|
||||
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key')
|
||||
.to_return(status: 200, body: { data: [] }.to_json)
|
||||
stub_request(:get, 'https://graph.facebook.com/v14.0//phone_numbers?fields=id&limit=100&access_token=test_key')
|
||||
.to_return(status: 200, body: { data: [{ id: 'another_phone_id' }] }.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
expect(channel.save).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'webhook_verify_token' do
|
||||
|
||||
@@ -239,6 +239,24 @@ RSpec.describe AutoAssignment::AssignmentService do
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
context 'when the inbox has no assignment policy' do
|
||||
before do
|
||||
inbox.inbox_assignment_policy.destroy!
|
||||
inbox.reload
|
||||
end
|
||||
|
||||
it 'falls back to the default threshold and skips stale conversations' do
|
||||
stale_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 8.days.ago)
|
||||
recent_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 6.days.ago)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(stale_conversation.reload.assignee).to be_nil
|
||||
expect(recent_conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with fair distribution' do
|
||||
|
||||
@@ -96,7 +96,14 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
|
||||
|
||||
described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
|
||||
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale
|
||||
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 36.minutes)).to be_expired
|
||||
expect(
|
||||
described_class.built_in_filter_counts_state(
|
||||
account_id: account_id,
|
||||
user_id: user_id,
|
||||
now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL +
|
||||
Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW + 1.second
|
||||
)
|
||||
).to be_expired
|
||||
|
||||
Redis::Alfred.delete(described_class.built_in_filter_counts_key(account_id, user_id))
|
||||
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id)).to be_missing
|
||||
@@ -202,8 +209,18 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
|
||||
)
|
||||
|
||||
snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id)
|
||||
expect(described_class.refresh_due?(snapshot, now: built_at + 10.seconds)).to be(false)
|
||||
expect(described_class.refresh_due?(snapshot, now: built_at + 31.seconds)).to be(true)
|
||||
expect(
|
||||
described_class.refresh_due?(
|
||||
snapshot,
|
||||
now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
|
||||
)
|
||||
).to be(false)
|
||||
expect(
|
||||
described_class.refresh_due?(
|
||||
snapshot,
|
||||
now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
|
||||
)
|
||||
).to be(true)
|
||||
|
||||
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(true)
|
||||
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(false)
|
||||
|
||||
@@ -48,10 +48,22 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do
|
||||
create(:mention, account: account, conversation: second_mention, user: agent)
|
||||
store.bump_conversation_version!(account.id)
|
||||
|
||||
expect(described_class.new(account: account, user: agent, now: now + 10.seconds).perform[:mentions_count]).to eq(1)
|
||||
expect(
|
||||
described_class.new(
|
||||
account: account,
|
||||
user: agent,
|
||||
now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
|
||||
).perform[:mentions_count]
|
||||
).to eq(1)
|
||||
|
||||
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
|
||||
expect(described_class.new(account: account, user: agent, now: now + 31.seconds).perform[:mentions_count]).to eq(2)
|
||||
expect(
|
||||
described_class.new(
|
||||
account: account,
|
||||
user: agent,
|
||||
now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
|
||||
).perform[:mentions_count]
|
||||
).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns stale built-in counts when a refresh build hits a database error' do
|
||||
@@ -62,7 +74,11 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do
|
||||
|
||||
store.bump_conversation_version!(account.id)
|
||||
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
|
||||
failing_counter = described_class.new(account: account, user: agent, now: now + 31.seconds)
|
||||
failing_counter = described_class.new(
|
||||
account: account,
|
||||
user: agent,
|
||||
now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
|
||||
)
|
||||
allow(failing_counter).to receive(:built_in_counts_from_database).and_raise(ActiveRecord::StatementInvalid.new('statement timeout'))
|
||||
|
||||
expect(failing_counter.perform[:mentions_count]).to eq(1)
|
||||
|
||||
Reference in New Issue
Block a user