Merge branch 'develop' into feat/reporting-reopen

This commit is contained in:
Shivam Mishra
2025-08-04 12:14:37 +05:30
committed by GitHub
99 changed files with 3036 additions and 824 deletions
+1 -1
View File
@@ -1 +1 @@
3.4.0
3.4.2
@@ -47,6 +47,20 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
head :ok
end
def send_instructions
email = permitted_params[:email]
return render_could_not_create_error(I18n.t('portals.send_instructions.email_required')) if email.blank?
return render_could_not_create_error(I18n.t('portals.send_instructions.invalid_email_format')) unless valid_email?(email)
return render_could_not_create_error(I18n.t('portals.send_instructions.custom_domain_not_configured')) if @portal.custom_domain.blank?
PortalInstructionsMailer.send_cname_instructions(
portal: @portal,
recipient_email: email
).deliver_later
render json: { message: I18n.t('portals.send_instructions.instructions_sent_successfully') }, status: :ok
end
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id)
@@ -60,12 +74,12 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def permitted_params
params.permit(:id)
params.permit(:id, :email)
end
def portal_params
params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link,
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
@@ -88,4 +102,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
def valid_email?(email)
ValidEmail2::Address.new(email).valid?
end
end
Api::V1::Accounts::PortalsController.prepend_mod_with('Api::V1::Accounts::PortalsController')
+1 -1
View File
@@ -136,7 +136,7 @@ export default {
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
class="flex flex-col w-full h-screen min-h-0"
:class="{ 'app-rtl--wrapper': isRTL }"
:dir="isRTL ? 'rtl' : 'ltr'"
>
@@ -0,0 +1,36 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainScenarios extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ assistantId, page = 1, searchKey } = {}) {
return axios.get(`${this.url}/${assistantId}/scenarios`, {
params: { page, searchKey },
});
}
show({ assistantId, id }) {
return axios.get(`${this.url}/${assistantId}/scenarios/${id}`);
}
create({ assistantId, ...data } = {}) {
return axios.post(`${this.url}/${assistantId}/scenarios`, {
scenario: data,
});
}
update({ assistantId, id }, data = {}) {
return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, {
scenario: data,
});
}
delete({ assistantId, id }) {
return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`);
}
}
export default new CaptainScenarios();
@@ -0,0 +1,16 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainTools extends ApiClient {
constructor() {
super('captain/assistants/tools', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, {
params,
});
}
}
export default new CaptainTools();
@@ -21,6 +21,14 @@ class PortalsAPI extends ApiClient {
deleteLogo(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}/logo`);
}
sendCnameInstructions(portalSlug, email) {
return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email });
}
sslStatus(portalSlug) {
return axios.get(`${this.url}/${portalSlug}/ssl_status`);
}
}
export default PortalsAPI;
@@ -37,30 +37,6 @@ body {
width: 100%;
}
.app-wrapper {
@apply h-screen flex-grow-0 min-h-0 w-full;
.button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
}
}
.banner + .app-wrapper {
// Reduce the height of the dashboard to make room for the banner.
// And causing the top right green-action button to be pushed down when scrolling.
@apply h-[calc(100%-48px)];
.button--fixed-top {
@apply top-14;
}
.off-canvas-content {
.button--fixed-top {
@apply top-2;
}
}
}
.tooltip {
@apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96;
}
@@ -20,6 +20,7 @@ const props = defineProps({
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
});
const emit = defineEmits(['update:modelValue']);
@@ -98,6 +99,7 @@ watch(
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -1,6 +1,9 @@
<script setup>
import { ref, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { helpers } from '@vuelidate/validators';
import { isValidDomain } from '@chatwoot/utils';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -26,6 +29,20 @@ const formState = reactive({
customDomain: props.customDomain,
});
const rules = {
customDomain: {
isValidDomain: helpers.withMessage(
() =>
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.FORMAT_ERROR'
),
isValidDomain
),
},
};
const v$ = useVuelidate(rules, formState);
watch(
() => props.customDomain,
newVal => {
@@ -33,7 +50,10 @@ watch(
}
);
const handleDialogConfirm = () => {
const handleDialogConfirm = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('addCustomDomain', formState.customDomain);
};
@@ -67,6 +87,11 @@ defineExpose({ dialogRef });
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
:message="
v$.customDomain.$error ? v$.customDomain.$errors[0].$message : ''
"
:message-type="v$.customDomain.$error ? 'error' : 'info'"
@blur="v$.customDomain.$touch()"
/>
</Dialog>
</template>
@@ -1,9 +1,15 @@
<script setup>
import { ref, computed } from 'vue';
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
import { email, required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
customDomain: {
@@ -12,10 +18,20 @@ const props = defineProps({
},
});
const emit = defineEmits(['confirm']);
const emit = defineEmits(['send', 'close']);
const { t } = useI18n();
const state = reactive({
email: '',
});
const validationRules = {
email: { email, required },
};
const v$ = useVuelidate(validationRules, state);
const domain = computed(() => {
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
@@ -25,10 +41,34 @@ const subdomainCNAME = computed(
() => `${props.customDomain} CNAME ${domain.value}`
);
const handleCopy = async e => {
e.stopPropagation();
await copyTextToClipboard(subdomainCNAME.value);
useAlert(
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.COPY'
)
);
};
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
const resetForm = () => {
v$.value.$reset();
state.email = '';
};
const onClose = () => {
resetForm();
emit('close');
};
const handleSend = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('send', state.email);
onClose();
};
defineExpose({ dialogRef });
@@ -37,42 +77,103 @@ defineExpose({ dialogRef });
<template>
<Dialog
ref="dialogRef"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:show-cancel-button="false"
@confirm="handleDialogConfirm"
:show-confirm-button="false"
@close="resetForm"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</template>
<NextButton
icon="i-lucide-x"
sm
ghost
slate
class="flex-shrink-0 absolute top-2 ltr:right-2 rtl:left-2"
@click="onClose"
/>
<div class="flex flex-col gap-6 divide-y divide-n-strong">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</div>
<div class="flex items-center gap-3 w-full">
<span
class="min-h-10 px-3 py-2.5 inline-flex items-center w-full text-sm bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<NextButton
faded
slate
type="button"
icon="i-lucide-copy"
class="flex-shrink-0"
@click="handleCopy"
/>
</div>
</div>
<div class="flex flex-col gap-6">
<span
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<p class="text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
)
}}
</p>
<div class="flex flex-col gap-6 pt-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.DESCRIPTION'
)
}}
</p>
</div>
<form
class="flex items-start gap-3 w-full"
@submit.prevent="handleSend"
>
<Input
v-model="state.email"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.PLACEHOLDER'
)
"
:message="
v$.email.$error
? t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.ERROR'
)
: ''
"
:message-type="v$.email.$error ? 'error' : 'info'"
class="w-full"
@blur="v$.email.$touch()"
/>
<NextButton
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.SEND_BUTTON'
)
"
type="submit"
class="flex-shrink-0"
/>
</form>
</div>
</div>
</Dialog>
</template>
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
@@ -11,11 +12,52 @@ const props = defineProps({
type: Object,
required: true,
},
isFetchingStatus: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['updatePortalConfiguration']);
const emit = defineEmits([
'updatePortalConfiguration',
'refreshStatus',
'sendCnameInstructions',
]);
const SSL_STATUS = {
LIVE: ['active', 'staging_active'],
PENDING: [
'provisioned',
'pending',
'initializing',
'pending_validation',
'pending_deployment',
'pending_issuance',
'holding_deployment',
'holding_validation',
'pending_expiration',
'pending_cleanup',
'pending_deletion',
'staging_deployment',
'backup_issued',
],
ERROR: [
'blocked',
'inactive',
'moved',
'expired',
'deleted',
'timed_out_initializing',
'timed_out_validation',
'timed_out_issuance',
'timed_out_deployment',
'timed_out_deletion',
'deactivating',
],
};
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
@@ -25,6 +67,45 @@ const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
);
const sslSettings = computed(() => props.activePortal?.ssl_settings || {});
const verificationErrors = computed(
() => sslSettings.value.verification_errors || ''
);
const isLive = computed(() =>
SSL_STATUS.LIVE.includes(sslSettings.value.status)
);
const isPending = computed(() =>
SSL_STATUS.PENDING.includes(sslSettings.value.status)
);
const isError = computed(() =>
SSL_STATUS.ERROR.includes(sslSettings.value.status)
);
const statusText = computed(() => {
if (isLive.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.LIVE'
);
if (isPending.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.PENDING'
);
if (isError.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.ERROR'
);
return '';
});
const statusColors = computed(() => {
if (isLive.value)
return { text: 'text-n-teal-11', bubble: 'outline-n-teal-6 bg-n-teal-9' };
if (isError.value)
return { text: 'text-n-ruby-11', bubble: 'outline-n-ruby-6 bg-n-ruby-9' };
return { text: 'text-n-amber-11', bubble: 'outline-n-amber-6 bg-n-amber-9' };
});
const updatePortalConfiguration = customDomain => {
const portal = {
id: props.activePortal?.id,
@@ -42,6 +123,17 @@ const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
};
const onClickRefreshSSLStatus = () => {
emit('refreshStatus');
};
const onClickSend = email => {
emit('sendCnameInstructions', {
portalSlug: props.activePortal?.slug,
email,
});
};
</script>
<template>
@@ -63,33 +155,76 @@ const closeDNSConfigurationDialog = () => {
</span>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex justify-between w-full gap-2">
<div
v-if="customDomainAddress"
class="flex items-center w-full h-8 gap-4"
>
<label class="text-sm font-medium text-n-slate-12">
<div class="flex items-center justify-between w-full gap-2">
<div v-if="customDomainAddress" class="flex flex-col gap-1">
<div class="flex items-center w-full h-8 gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<span
v-if="!isLive && isOnChatwootCloud"
class="text-sm text-n-slate-11"
>
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<div class="flex items-center justify-end w-full">
<Button
v-if="customDomainAddress"
color="slate"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<div class="flex items-center">
<div v-if="customDomainAddress" class="flex items-center gap-3">
<div
v-if="statusText && isOnChatwootCloud"
v-tooltip="verificationErrors"
class="flex items-center gap-3 flex-shrink-0"
>
<span
class="size-1.5 rounded-full outline outline-2 block flex-shrink-0"
:class="statusColors.bubble"
/>
<span
:class="statusColors.text"
class="text-sm leading-[16px] font-medium"
>
{{ statusText }}
</span>
</div>
<div
v-if="statusText && isOnChatwootCloud"
class="w-px h-3 bg-n-weak"
/>
<Button
slate
sm
link
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
class="hover:!no-underline flex-shrink-0"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<div v-if="isOnChatwootCloud" class="w-px h-3 bg-n-weak" />
<Button
v-if="isOnChatwootCloud"
slate
sm
link
icon="i-lucide-refresh-ccw"
:class="isFetchingStatus && 'animate-spin'"
@click="onClickRefreshSSLStatus"
/>
</div>
<Button
v-else
:label="
@@ -112,7 +247,8 @@ const closeDNSConfigurationDialog = () => {
<DNSConfigurationDialog
ref="dnsConfigurationDialogRef"
:custom-domain="updatedDomainAddress || customDomainAddress"
@confirm="closeDNSConfigurationDialog"
@close="closeDNSConfigurationDialog"
@send="onClickSend"
/>
</div>
</template>
@@ -26,6 +26,8 @@ const emit = defineEmits([
'updatePortal',
'updatePortalConfiguration',
'deletePortal',
'refreshStatus',
'sendCnameInstructions',
]);
const { t } = useI18n();
@@ -36,6 +38,7 @@ const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus');
const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
@@ -53,6 +56,14 @@ const handleUpdatePortalConfiguration = portal => {
emit('updatePortalConfiguration', portal);
};
const fetchSSLStatus = () => {
emit('refreshStatus');
};
const handleSendCnameInstructions = payload => {
emit('sendCnameInstructions', payload);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
@@ -85,7 +96,10 @@ const handleDeletePortal = () => {
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
@@ -153,7 +153,7 @@ onBeforeMount(contextMenuActions.close);
<template>
<div
role="button"
class="flex flex-col w-full gap-2 p-3 transition-all duration-300 ease-in-out cursor-pointer"
class="flex flex-col w-full gap-1 p-3 transition-all duration-300 ease-in-out cursor-pointer"
@contextmenu="contextMenuActions.open($event)"
@click="emit('click')"
>
@@ -232,7 +232,7 @@ onBeforeMount(contextMenuActions.close);
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm text-n-slate-10">
<span class="text-xs text-n-slate-10">
{{ lastActivityAt }}
</span>
</div>
@@ -1,5 +1,6 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
@@ -44,7 +45,10 @@ const onClickCancel = () => {
</script>
<template>
<div class="inline-flex relative">
<div
v-on-click-outside="() => togglePopover(false)"
class="inline-flex relative"
>
<Button
:label="buttonLabel"
sm
@@ -0,0 +1,155 @@
<script setup>
import { computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { vOnClickOutside } from '@vueuse/components';
import { required, minLength } from '@vuelidate/validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const emit = defineEmits(['add']);
const { t } = useI18n();
const [showPopover, togglePopover] = useToggle();
const state = reactive({
id: '',
title: '',
description: '',
instruction: '',
});
const rules = {
title: { required, minLength: minLength(1) },
description: { required },
instruction: { required },
};
const v$ = useVuelidate(rules, state);
const titleError = computed(() =>
v$.value.title.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.ERROR')
: ''
);
const descriptionError = computed(() =>
v$.value.description.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.ERROR')
: ''
);
const instructionError = computed(() =>
v$.value.instruction.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR')
: ''
);
const resetState = () => {
Object.assign(state, {
id: '',
title: '',
description: '',
instruction: '',
});
};
const onClickAdd = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
await emit('add', state);
resetState();
togglePopover(false);
};
const onClickCancel = () => {
togglePopover(false);
};
</script>
<template>
<div
v-on-click-outside="() => togglePopover(false)"
class="inline-flex relative"
>
<Button
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.CREATE')"
sm
slate
class="flex-shrink-0"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
class="w-[31.25rem] absolute top-10 ltr:left-0 rtl:right-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6 z-50"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.TITLE`) }}
</h3>
<div class="flex flex-col gap-4">
<Input
v-model="state.title"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.PLACEHOLDER')
"
:message="titleError"
:message-type="titleError ? 'error' : 'info'"
/>
<TextArea
v-model="state.description"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.PLACEHOLDER'
)
"
:message="descriptionError"
:message-type="descriptionError ? 'error' : 'info'"
show-character-count
/>
<Editor
v-model="state.instruction"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.LABEL')
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.PLACEHOLDER'
)
"
:message="instructionError"
:message-type="instructionError ? 'error' : 'info'"
:show-character-count="false"
enable-captain-tools
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
@click="onClickCancel"
/>
<Button
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CREATE')"
class="w-full"
@click="onClickAdd"
/>
</div>
</div>
</div>
</template>
@@ -73,7 +73,6 @@ const saveEdit = () => {
v-if="isEditing"
v-model="editedContent"
focus-on-mount
custom-input-class="flex items-center gap-2 text-sm text-n-slate-12"
@keyup.enter="saveEdit"
/>
<span v-else class="flex items-center gap-2 text-sm text-n-slate-12">
@@ -0,0 +1,45 @@
<script setup>
import ScenariosCard from './ScenariosCard.vue';
const sampleScenarios = [
{
id: 1,
title: 'Refund Order',
description: 'User requests a refund for a recent purchase.',
instruction:
'Gather order details and reason for refund. Use [Order Search](tool://order_search) then submit with [Refund Payment](tool://refund_payment).',
tools: ['order_search', 'refund_payment'],
},
{
id: 2,
title: 'Bug Report',
description: 'Customer reports a bug in the mobile app.',
instruction:
'Ask for reproduction steps and environment. Check [Known Issues](tool://known_issues) then create ticket with [Create Bug Report](tool://bug_report_create).',
tools: ['known_issues', 'bug_report_create'],
},
];
</script>
<template>
<Story
title="Captain/Assistant/ScenariosCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div
v-for="scenario in sampleScenarios"
:key="scenario.id"
class="px-4 py-4 bg-n-background"
>
<ScenariosCard
:id="scenario.id"
:title="scenario.title"
:description="scenario.description"
:instruction="scenario.instruction"
:tools="scenario.tools"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,218 @@
<script setup>
import { computed, h, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
instruction: {
type: String,
required: true,
},
tools: {
type: Array,
required: true,
},
selectable: {
type: Boolean,
default: false,
},
isSelected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'hover', 'delete', 'update']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const state = reactive({
id: '',
title: '',
description: '',
instruction: '',
});
const [isEditing, toggleEditing] = useToggle();
const startEdit = () => {
Object.assign(state, {
id: props.id,
title: props.title,
description: props.description,
instruction: props.instruction,
tools: props.tools,
});
toggleEditing(true);
};
const rules = {
title: { required, minLength: minLength(1) },
description: { required },
instruction: { required },
};
const v$ = useVuelidate(rules, state);
const titleError = computed(() =>
v$.value.title.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.ERROR')
: ''
);
const descriptionError = computed(() =>
v$.value.description.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.ERROR')
: ''
);
const onClickUpdate = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
emit('update', { ...state });
toggleEditing(false);
};
const instructionError = computed(() =>
v$.value.instruction.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR')
: ''
);
const LINK_INSTRUCTION_CLASS =
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
const renderInstruction = instruction => () =>
h('p', {
class: `text-sm text-n-slate-12 py-4 mb-0 [&_ol]:list-decimal ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
</script>
<template>
<CardLayout
selectable
class="relative [&>div]:!py-4"
:class="{
'[&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4': !isEditing,
'[&>div]:ltr:!pr-10 [&>div]:rtl:!pl-10': isEditing,
}"
layout="row"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="selectable && !isEditing"
class="absolute top-[1.125rem] ltr:left-3 rtl:right-3"
>
<Checkbox v-model="modelValue" />
</div>
<div v-if="!isEditing" class="flex flex-col w-full">
<div class="flex items-start justify-between w-full gap-2">
<div class="flex flex-col items-start">
<span class="text-sm text-n-slate-12 font-medium">{{ title }}</span>
<span class="text-sm text-n-slate-11 mt-2">
{{ description }}
</span>
</div>
<div class="flex items-center gap-2">
<!-- <Button label="Test" slate xs ghost class="!text-sm" />
<span class="w-px h-4 bg-n-weak" /> -->
<Button icon="i-lucide-pen" slate xs ghost @click="startEdit" />
<span class="w-px h-4 bg-n-weak" />
<Button
icon="i-lucide-trash"
slate
xs
ghost
@click="emit('delete', id)"
/>
</div>
</div>
<component :is="renderInstruction(formatMessage(instruction, false))" />
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ tools?.map(tool => `@${tool}`).join(', ') }}
</span>
</div>
<div v-else class="overflow-hidden flex flex-col gap-4 w-full">
<Input
v-model="state.title"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.PLACEHOLDER')
"
:message="titleError"
:message-type="titleError ? 'error' : 'info'"
/>
<TextArea
v-model="state.description"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.PLACEHOLDER')
"
:message="descriptionError"
:message-type="descriptionError ? 'error' : 'info'"
show-character-count
/>
<Editor
v-model="state.instruction"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.LABEL')
"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.PLACEHOLDER')
"
:message="instructionError"
:message-type="instructionError ? 'error' : 'info'"
:show-character-count="false"
enable-captain-tools
/>
<div class="flex items-center gap-3">
<Button
faded
slate
sm
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.UPDATE.CANCEL')"
@click="toggleEditing(false)"
/>
<Button
sm
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.UPDATE.UPDATE')"
@click="onClickUpdate"
/>
</div>
</div>
</CardLayout>
</template>
@@ -0,0 +1,37 @@
<script setup>
import { ref } from 'vue';
import ToolsDropdown from './ToolsDropdown.vue';
const items = [
{
id: 'order_search',
title: 'Order Search',
description: 'Lookup orders by customer ID, email, or order number',
},
{
id: 'refund_payment',
title: 'Refund Payment',
description: 'Initiates a refund on a specific payment',
},
{
id: 'fetch_customer',
title: 'Fetch Customer',
description: 'Pulls customer details (email, tags, last seen, etc.)',
},
];
const selectedIndex = ref(0);
</script>
<template>
<Story
title="Captain/Assistant/ToolsDropdown"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default">
<div class="relative h-80 bg-n-background p-4">
<ToolsDropdown :items="items" :selected-index="selectedIndex" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,54 @@
<script setup>
import { ref, watch, nextTick } from 'vue';
const props = defineProps({
items: {
type: Array,
required: true,
},
selectedIndex: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['select']);
const toolsDropdownRef = ref(null);
const onItemClick = idx => emit('select', idx);
watch(
() => props.selectedIndex,
() => {
nextTick(() => {
const el = toolsDropdownRef.value?.querySelector(
`#tool-item-${props.selectedIndex}`
);
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
});
},
{ immediate: true }
);
</script>
<template>
<div
ref="toolsDropdownRef"
class="w-[22.5rem] p-2 flex flex-col gap-1 z-50 absolute rounded-xl bg-n-alpha-3 shadow outline outline-1 outline-n-weak backdrop-blur-[50px] max-h-[20rem] overflow-y-auto"
>
<div
v-for="(tool, idx) in items"
:id="`tool-item-${idx}`"
:key="tool.id || idx"
:class="{ 'bg-n-alpha-black2': idx === selectedIndex }"
class="flex flex-col gap-1 rounded-md py-2 px-2 cursor-pointer hover:bg-n-alpha-black2"
@click="onItemClick(idx)"
>
<span class="text-n-slate-12 font-medium text-sm">{{ tool.title }}</span>
<span class="text-n-slate-11 text-sm">{{ tool.description }}</span>
</div>
</div>
</template>
@@ -2,6 +2,7 @@
import { computed, defineModel, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import FilterSelect from './inputs/FilterSelect.vue';
import MultiSelect from './inputs/MultiSelect.vue';
import SingleSelect from './inputs/SingleSelect.vue';
@@ -178,11 +179,11 @@ defineExpose({ validate });
disable-search
:options="booleanOptions"
/>
<input
<Input
v-else
v-model="values"
:type="inputType === 'date' ? 'date' : 'text'"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
class="[&>input]:h-8 [&>input]:py-1.5 [&>input]:outline-offset-0"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</template>
@@ -9,6 +9,7 @@ import { useContactFilterContext } from './contactProvider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ConditionRow from './ConditionRow.vue';
const props = defineProps({
@@ -109,16 +110,13 @@ const outsideClickHandler = [
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isSegmentView">
<label class="pb-6 border-b border-n-weak">
<div class="mb-2 text-sm text-n-slate-11">
{{ $t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL') }}
</div>
<input
<div class="pb-6 border-b border-n-weak">
<Input
v-model="segmentNameLocal"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:label="$t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL')"
:placeholder="t('CONTACTS_LAYOUT.FILTER.SEGMENT.INPUT_PLACEHOLDER')"
/>
</label>
</div>
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
@@ -9,6 +9,7 @@ import { useConversationFilterContext } from './provider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ConditionRow from './ConditionRow.vue';
const props = defineProps({
@@ -110,16 +111,13 @@ const outsideClickHandler = [
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView">
<label class="border-b border-n-weak pb-6">
<div class="text-n-slate-11 text-sm mb-2">
{{ t('FILTER.FOLDER_LABEL') }}
</div>
<input
<div class="border-b border-n-weak pb-6">
<Input
v-model="folderNameLocal"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:label="t('FILTER.FOLDER_LABEL')"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</label>
</div>
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
@@ -6,10 +6,12 @@ import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { vOnClickOutside } from '@vueuse/components';
import { useTrack } from 'dashboard/composables';
import NextButton from 'next/button/Button.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
export default {
components: {
NextButton,
NextInput,
},
directives: {
onClickOutside: vOnClickOutside,
@@ -103,20 +105,13 @@ export default {
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
</h3>
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
<label :class="{ error: v$.name.$error }">
<input
v-model="name"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span
v-if="v$.name.$error"
class="text-xs text-n-ruby-11 ml-1 rtl:mr-1"
>
{{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }}
</span>
</label>
<NextInput
v-model="name"
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
:message-type="v$.name.$error && 'error'"
@blur="v$.name.$touch"
/>
<div class="flex flex-row justify-end w-full gap-2">
<NextButton faded slate sm @click.prevent="onClose">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
@@ -0,0 +1,58 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
isMobileSidebarOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['toggle']);
const route = useRoute();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
'inbox_view_conversation',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const toggleSidebar = () => {
emit('toggle');
};
</script>
<template>
<div
v-if="!isConversationRoute"
id="mobile-sidebar-launcher"
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-out block md:hidden"
:class="[
{
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
},
]"
>
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-lucide-menu"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -17,10 +18,18 @@ import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
isMobileSidebarOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'closeKeyShortcutModal',
'openKeyShortcutModal',
'showCreateAccountModal',
'closeMobileSidebar',
]);
const { accountScopedRoute } = useAccount();
@@ -77,6 +86,11 @@ const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const closeMobileSidebar = () => {
if (!props.isMobileSidebarOpen) return;
emit('closeMobileSidebar');
};
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -488,7 +502,19 @@ const menuItems = computed(() => {
<template>
<aside
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
]"
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
:class="[
{
'ltr:translate-x-0 rtl:-translate-x-0 shadow-lg md:shadow-none':
isMobileSidebarOpen,
'ltr:-translate-x-full rtl:translate-x-full md:translate-x-0':
!isMobileSidebarOpen,
},
]"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
@@ -4,7 +4,11 @@ import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import wootConstants from 'dashboard/constants/globals';
defineProps({
conversationInboxType: {
type: String,
@@ -13,12 +17,19 @@ defineProps({
});
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { width: windowWidth } = useWindowSize();
const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const inboxAssistant = useMapGetter('getCopilotAssistant');
const currentChat = useMapGetter('getSelectedChat');
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const selectedCopilotThreadId = ref(null);
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](
@@ -32,7 +43,6 @@ const isFeatureEnabledonAccount = useMapGetter(
);
const selectedAssistantId = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -55,6 +65,15 @@ const activeAssistant = computed(() => {
return assistants.value[0];
});
const closeCopilotPanel = () => {
if (isSmallScreen.value && uiSettings.value?.is_copilot_panel_open) {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
}
};
const setAssistant = async assistant => {
selectedAssistantId.value = assistant.id;
await updateUISettings({
@@ -101,7 +120,14 @@ onMounted(() => {
<template>
<div
v-if="shouldShowCopilotPanel"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
v-on-click-outside="() => closeCopilotPanel()"
class="bg-n-background h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': shouldShowCopilotPanel,
'md:hidden': !shouldShowCopilotPanel,
},
]"
>
<Copilot
:messages="messages"
@@ -15,6 +15,7 @@ import CannedResponse from '../conversation/CannedResponse.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
@@ -72,6 +73,7 @@ const props = defineProps({
updateSelectionWith: { type: String, default: '' },
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
@@ -89,6 +91,7 @@ const emit = defineEmits([
'toggleUserMention',
'toggleCannedMenu',
'toggleVariablesMenu',
'toggleToolsMenu',
'clearSelection',
'blur',
'focus',
@@ -140,7 +143,9 @@ const showUserMentions = ref(false);
const showCannedMenu = ref(false);
const showVariables = ref(false);
const showEmojiMenu = ref(false);
const showToolsMenu = ref(false);
const mentionSearchKey = ref('');
const toolSearchKey = ref('');
const cannedSearchTerm = ref('');
const variableSearchTerm = ref('');
const emojiSearchTerm = ref('');
@@ -216,11 +221,17 @@ const plugins = computed(() => {
}
return [
createSuggestionPlugin({
trigger: '@',
showMenu: showToolsMenu,
searchTerm: toolSearchKey,
isAllowed: () => props.enableCaptainTools,
}),
createSuggestionPlugin({
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate,
isAllowed: () => props.isPrivate || !props.enableCaptainTools,
}),
createSuggestionPlugin({
trigger: '/',
@@ -262,6 +273,9 @@ watch(showCannedMenu, updatedValue => {
watch(showVariables, updatedValue => {
emit('toggleVariablesMenu', !props.isPrivate && updatedValue);
});
watch(showToolsMenu, updatedValue => {
emit('toggleToolsMenu', props.enableCaptainTools && updatedValue);
});
function focusEditorInputField(pos = 'end') {
const { tr } = editorView.state;
@@ -538,6 +552,7 @@ function insertSpecialContent(type, content) {
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
tool: CONVERSATION_EVENTS.INSERTED_A_TOOL,
};
useTrack(event_map[type]);
@@ -699,6 +714,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="emojiSearchTerm"
@select-emoji="emoji => insertSpecialContent('emoji', emoji)"
/>
<TagTools
v-if="showToolsMenu"
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
<input
ref="imageUpload"
type="file"
@@ -812,6 +832,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
}
.prosemirror-tools-node {
@apply font-medium text-n-slate-12 py-0;
}
.editor-wrap {
@apply mb-4;
}
@@ -2,6 +2,9 @@
import { computed } from 'vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import wootConstants from 'dashboard/constants/globals';
defineProps({
currentChat: {
@@ -10,7 +13,8 @@ defineProps({
},
});
const { uiSettings } = useUISettings();
const { uiSettings, updateUISettings } = useUISettings();
const { width: windowWidth } = useWindowSize();
const activeTab = computed(() => {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
@@ -20,11 +24,31 @@ const activeTab = computed(() => {
}
return null;
});
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const closeContactPanel = () => {
if (isSmallScreen.value && uiSettings.value?.is_contact_sidebar_open) {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
}
};
</script>
<template>
<div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
v-on-click-outside="() => closeContactPanel()"
class="bg-n-background h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': activeTab === 0,
'md:hidden': activeTab !== 0,
},
]"
>
<div class="flex flex-1 overflow-auto">
<ContactPanel
@@ -372,6 +372,7 @@ export default {
const variables = getMessageVariables({
conversation: this.currentChat,
contact: this.currentContact,
inbox: this.inbox,
});
return variables;
},
@@ -0,0 +1,56 @@
<script setup>
import { ref, computed, watch } from 'vue';
import ToolsDropdown from 'dashboard/components-next/captain/assistant/ToolsDropdown.vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMapGetter } from 'dashboard/composables/store.js';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectTool']);
const tools = useMapGetter('captainTools/getRecords');
const selectedIndex = ref(0);
const filteredTools = computed(() => {
const search = props.searchKey?.trim().toLowerCase() || '';
return tools.value.filter(tool => tool.title.toLowerCase().includes(search));
});
const adjustScroll = () => {};
const onSelect = idx => {
if (idx) selectedIndex.value = idx;
emit('selectTool', filteredTools.value[selectedIndex.value]);
};
useKeyboardNavigableList({
items: filteredTools,
onSelect,
adjustScroll,
selectedIndex,
});
watch(filteredTools, newListOfTools => {
if (newListOfTools.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
});
</script>
<template>
<ToolsDropdown
v-if="filteredTools.length"
:items="filteredTools"
:selected-index="selectedIndex"
class="bottom-20"
@select="onSelect"
/>
<template v-else />
</template>
@@ -35,7 +35,7 @@ export default {
HELP_CENTER_DOCS_URL:
'https://www.chatwoot.com/docs/product/others/help-center',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
SMALL_SCREEN_BREAKPOINT: 1200,
SMALL_SCREEN_BREAKPOINT: 768,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
UNTIL_NEXT_REPLY: 'until_next_reply',
@@ -6,6 +6,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
TRANSLATE_A_MESSAGE: 'Translated a message',
INSERTED_A_VARIABLE: 'Inserted a variable',
INSERTED_AN_EMOJI: 'Inserted an emoji',
INSERTED_A_TOOL: 'Inserted a tool',
USED_MENTIONS: 'Used mentions',
SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list',
@@ -319,6 +319,12 @@ const createNode = (editorView, nodeType, content) => {
return state.schema.text(`{{${content}}}`);
case 'emoji':
return state.schema.text(content);
case 'tool': {
return state.schema.nodes.tools.create({
id: content.id,
name: content.title,
});
}
default:
return null;
}
@@ -355,6 +361,11 @@ const nodeCreators = {
from,
to,
}),
tool: (editorView, content, from, to) => ({
node: createNode(editorView, 'tool', content),
from,
to,
}),
};
/**
@@ -157,6 +157,12 @@
"DELETE_SUCCESS": "Portal deleted successfully",
"DELETE_ERROR": "Error while deleting portal"
}
},
"SEND_CNAME_INSTRUCTIONS": {
"API": {
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
"ERROR_MESSAGE": "Error while sending CNAME instructions"
}
}
},
"EDIT": {
@@ -747,9 +753,15 @@
"HEADER": "Custom domain",
"LABEL": "Custom domain:",
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
"PLACEHOLDER": "Portal custom domain",
"EDIT_BUTTON": "Edit custom domain",
"EDIT_BUTTON": "Edit",
"ADD_BUTTON": "Add custom domain",
"STATUS": {
"LIVE": "Live",
"PENDING": "Awaiting verification",
"ERROR": "Verification failed"
},
"DIALOG": {
"ADD_HEADER": "Add custom domain",
"EDIT_HEADER": "Edit custom domain",
@@ -757,13 +769,20 @@
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
"LABEL": "Custom domain",
"PLACEHOLDER": "Portal custom domain",
"ERROR": "Custom domain is required"
"ERROR": "Custom domain is required",
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
},
"DNS_CONFIGURATION_DIALOG": {
"HEADER": "DNS configuration",
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
"HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.",
"CONFIRM_BUTTON_LABEL": "Got it!"
"COPY": "Successfully copied CNAME",
"SEND_INSTRUCTIONS": {
"HEADER": "Send instructions",
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
"PLACEHOLDER": "Enter their email",
"ERROR": "Enter a valid email address",
"SEND_BUTTON": "Send"
}
}
},
"DELETE_PORTAL": {
@@ -554,6 +554,7 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
"API": {
"ADD": {
"SUCCESS": "Guardrails added successfully",
@@ -601,6 +602,7 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
"API": {
"ADD": {
"SUCCESS": "Response Guidelines added successfully",
@@ -615,6 +617,73 @@
"ERROR": "There was an error deleting response guidelines, please try again."
}
}
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
"BREADCRUMB": {
"TITLE": "Scenarios"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Delete"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example scenarios",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"TOOLS_USED": "Tools used :"
},
"NEW": {
"CREATE": "Add a scenario",
"TITLE": "Create a scenario",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Enter a name for the scenario",
"ERROR": "Scenario name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Describe how and where this scenario will be used",
"ERROR": "Scenario description is required"
},
"INSTRUCTION": {
"LABEL": "How to handle",
"PLACEHOLDER": "Describe how and where this scenario will be handled",
"ERROR": "Scenario content is required"
},
"CREATE": "Create",
"CANCEL": "Cancel"
}
}
},
"UPDATE": {
"CANCEL": "Cancel",
"UPDATE": "Update changes"
},
"LIST": {
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
"API": {
"ADD": {
"SUCCESS": "Scenarios added successfully",
"ERROR": "There was an error adding scenarios, please try again."
},
"UPDATE": {
"SUCCESS": "Scenarios updated successfully",
"ERROR": "There was an error updating scenarios, please try again."
},
"DELETE": {
"SUCCESS": "Scenarios deleted successfully",
"ERROR": "There was an error deleting scenarios, please try again."
}
}
}
},
"DOCUMENTS": {
@@ -2,7 +2,7 @@
"AGENT_BOTS": {
"HEADER": "Boty",
"LOADING_EDITOR": "Ładowanie edytora...",
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
"DESCRIPTION": "Boty agentów są jak najbardziej fantastyczni członkowie Twojego zespołu. Mogą zajmować się drobnymi sprawami, dzięki czemu Ty możesz skupić się na tym, co naprawdę ważne. Wypróbuj je! Możesz zarządzać swoimi botami z tej strony lub tworzyć nowe za pomocą przycisku 'Dodaj bota'.",
"LEARN_MORE": "Learn about agent bots",
"GLOBAL_BOT": "System bot",
"GLOBAL_BOT_BADGE": "System",
@@ -30,10 +30,10 @@
}
},
"LIST": {
"404": "No bots found. You can create a bot by clicking the 'Add Bot' button.",
"404": "Nie znaleziono botów. Możesz utworzyć bota klikając przycisk 'Dodaj bota'.",
"LOADING": "Pobieranie botów...",
"TABLE_HEADER": {
"DETAILS": "Bot Details",
"DETAILS": "Szczegóły bota",
"URL": "Adres URL webhooka"
}
},
@@ -8,6 +8,7 @@ import UpgradePage from 'dashboard/routes/dashboard/upgrade/UpgradePage.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import { useWindowSize } from '@vueuse/core';
import wootConstants from 'dashboard/constants/globals';
@@ -18,6 +19,8 @@ const CommandBar = defineAsyncComponent(
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
export default {
components: {
NextSidebar,
@@ -27,17 +30,20 @@ export default {
UpgradePage,
CopilotLauncher,
CopilotContainer,
MobileSidebarLauncher,
},
setup() {
const upgradePageRef = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
return {
uiSettings,
updateUISettings,
accountId,
upgradePageRef,
windowWidth,
};
},
data() {
@@ -45,10 +51,13 @@ export default {
showAccountModal: false,
showCreateAccountModal: false,
showShortcutModal: false,
displayLayoutType: '',
isMobileSidebarOpen: false,
};
},
computed: {
isSmallScreen() {
return this.windowWidth < wootConstants.SMALL_SCREEN_BREAKPOINT;
},
showUpgradePage() {
return this.upgradePageRef?.shouldShowUpgradePage;
},
@@ -66,54 +75,30 @@ export default {
} = this.uiSettings;
return conversationDisplayType;
},
previouslyUsedSidebarView() {
const { previously_used_sidebar_view: showSecondarySidebar } =
this.uiSettings;
return showSecondarySidebar;
},
},
watch: {
displayLayoutType() {
const { LAYOUT_TYPES } = wootConstants;
this.updateUISettings({
conversation_display_type:
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
? LAYOUT_TYPES.EXPANDED
: this.previouslyUsedDisplayType,
show_secondary_sidebar:
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
? false
: this.previouslyUsedSidebarView,
});
isSmallScreen: {
handler() {
const { LAYOUT_TYPES } = wootConstants;
if (window.innerWidth <= wootConstants.SMALL_SCREEN_BREAKPOINT) {
this.updateUISettings({
conversation_display_type: LAYOUT_TYPES.EXPANDED,
});
} else {
this.updateUISettings({
conversation_display_type: this.previouslyUsedDisplayType,
});
}
},
immediate: true,
},
},
mounted() {
this.handleResize();
window.addEventListener('resize', this.handleResize);
},
unmounted() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
handleResize() {
const { SMALL_SCREEN_BREAKPOINT, LAYOUT_TYPES } = wootConstants;
let throttled = false;
const delay = 150;
if (throttled) {
return;
}
throttled = true;
setTimeout(() => {
throttled = false;
if (window.innerWidth <= SMALL_SCREEN_BREAKPOINT) {
this.displayLayoutType = LAYOUT_TYPES.EXPANDED;
} else {
this.displayLayoutType = LAYOUT_TYPES.CONDENSED;
}
}, delay);
toggleMobileSidebar() {
this.isMobileSidebarOpen = !this.isMobileSidebarOpen;
},
closeMobileSidebar() {
this.isMobileSidebarOpen = false;
},
openCreateAccountModal() {
this.showAccountModal = false;
@@ -136,23 +121,35 @@ export default {
</script>
<template>
<div class="flex flex-wrap app-wrapper text-n-slate-12">
<div class="flex flex-grow overflow-hidden text-n-slate-12">
<NextSidebar
:is-mobile-sidebar-open="isMobileSidebarOpen"
@toggle-account-modal="toggleAccountModal"
@open-key-shortcut-modal="toggleKeyShortcutModal"
@close-key-shortcut-modal="closeKeyShortcutModal"
@show-create-account-modal="openCreateAccountModal"
@close-mobile-sidebar="closeMobileSidebar"
/>
<main class="flex flex-1 h-full min-h-0 px-0 overflow-hidden">
<main class="flex flex-1 h-full w-full min-h-0 px-0 overflow-hidden">
<UpgradePage
v-show="showUpgradePage"
ref="upgradePageRef"
:bypass-upgrade-page="bypassUpgradePage"
/>
>
<MobileSidebarLauncher
:is-mobile-sidebar-open="isMobileSidebarOpen"
@toggle="toggleMobileSidebar"
/>
</UpgradePage>
<template v-if="!showUpgradePage">
<router-view />
<CommandBar />
<CopilotLauncher />
<MobileSidebarLauncher
:is-mobile-sidebar-open="isMobileSidebarOpen"
@toggle="toggleMobileSidebar"
/>
<CopilotContainer />
</template>
<AddAccountModal
@@ -266,6 +266,11 @@ const addAllExample = () => {
{{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else-if="filteredGuardrails.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.SEARCH_EMPTY_MESSAGE') }}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<RuleCard
v-for="guardrail in filteredGuardrails"
@@ -284,6 +284,13 @@ const addAllExample = async () => {
{{ t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else-if="filteredGuidelines.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.SEARCH_EMPTY_MESSAGE')
}}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<RuleCard
v-for="guideline in filteredGuidelines"
@@ -0,0 +1,320 @@
<script setup>
import { computed, h, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { picoSearch } from '@scmmishra/pico-search';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import SuggestedScenarios from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
import ScenariosCard from 'dashboard/components-next/captain/assistant/ScenariosCard.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import AddNewScenariosDialog from 'dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue';
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainScenarios/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingList);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
);
const scenarios = useMapGetter('captainScenarios/getRecords');
const searchQuery = ref('');
const breadcrumbItems = computed(() => {
return [
{
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
routeName: 'captain_assistants_index',
},
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
{ label: t('CAPTAIN.ASSISTANTS.SCENARIOS.BREADCRUMB.TITLE') },
];
});
const TOOL_LINK_REGEX = /\[([^\]]+)]\(tool:\/\/.+?\)/g;
const renderInstruction = instruction => () =>
h('span', {
class: 'text-sm text-n-slate-12 py-4',
innerHTML: instruction.replace(
TOOL_LINK_REGEX,
(_, title) =>
`<span class="text-n-iris-11 font-medium">@${title.replace(/^@/, '')}</span>`
),
});
// Suggested example scenarios for quick add
const scenariosExample = [
{
id: 1,
title: 'Refund Order',
description: 'User encountered a technical issue or error message.',
instruction:
'Ask for steps to reproduce + browser/app version. Use [Known Issues](tool://known_issues) to check if its a known bug. File with [Create Bug Report](tool://bug_report_create) if new.',
tools: ['create_bug_report', 'known_issues'],
},
{
id: 2,
title: 'Product Recommendation',
description: 'User is unsure which product or service to choose.',
instruction:
'Ask 23 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 23 options with pros/cons. Link to compare page if available.',
tools: ['product_match[user_needs]'],
},
];
const filteredScenarios = computed(() => {
const query = searchQuery.value.trim();
const source = scenarios.value;
if (!query) return source;
return picoSearch(source, query, ['title', 'description', 'instruction']);
});
const shouldShowSuggestedRules = computed(() => {
return uiSettings.value?.show_scenarios_suggestions !== false;
});
const closeSuggestedRules = () => {
updateUISettings({ show_scenarios_suggestions: false });
};
// Bulk selection & hover state
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const handleRuleSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const buildSelectedCountLabel = computed(() => {
const count = scenarios.value.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.UNSELECT_ALL', { count })
: t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const handleRuleHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const getToolsFromInstruction = instruction => [
...new Set(
[...(instruction?.matchAll(/\(tool:\/\/([^)]+)\)/g) ?? [])].map(m => m[1])
),
];
const updateScenario = async scenario => {
try {
await store.dispatch('captainScenarios/update', {
id: scenario.id,
assistantId: route.params.assistantId,
...scenario,
tools: getToolsFromInstruction(scenario.instruction),
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.UPDATE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.UPDATE.ERROR');
useAlert(errorMessage);
}
};
const deleteScenario = async id => {
try {
await store.dispatch('captainScenarios/delete', {
id,
assistantId: route.params.assistantId,
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.ERROR');
useAlert(errorMessage);
}
};
// TODO: Add bulk delete endpoint
const bulkDeleteScenarios = async ids => {
const idsArray = ids || Array.from(bulkSelectedIds.value);
await Promise.all(
idsArray.map(id =>
store.dispatch('captainScenarios/delete', {
id,
assistantId: route.params.assistantId,
})
)
);
bulkSelectedIds.value = new Set();
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.SUCCESS'));
};
const addScenario = async scenario => {
try {
await store.dispatch('captainScenarios/create', {
assistantId: route.params.assistantId,
...scenario,
tools: getToolsFromInstruction(scenario.instruction),
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.ERROR');
useAlert(errorMessage);
}
};
const addAllExampleScenarios = async () => {
try {
scenariosExample.forEach(async scenario => {
await store.dispatch('captainScenarios/create', {
assistantId: route.params.assistantId,
...scenario,
});
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.ERROR');
useAlert(errorMessage);
}
};
onMounted(() => {
store.dispatch('captainScenarios/get', {
assistantId: assistantId,
});
store.dispatch('captainTools/getTools');
});
</script>
<template>
<SettingsPageLayout
:breadcrumb-items="breadcrumbItems"
:is-fetching="isFetching"
>
<template #body>
<SettingsHeader
:heading="$t('CAPTAIN.ASSISTANTS.SCENARIOS.TITLE')"
:description="$t('CAPTAIN.ASSISTANTS.SCENARIOS.DESCRIPTION')"
/>
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
<SuggestedScenarios
:title="$t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TITLE')"
:items="scenariosExample"
@close="closeSuggestedRules"
@add="addAllExampleScenarios"
>
<template #default="{ item }">
<div class="flex items-center gap-3 justify-between">
<span class="text-sm text-n-slate-12">
{{ item.title }}
</span>
<Button
:label="
$t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.ADD_SINGLE')
"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="addScenario(item)"
/>
</div>
<div class="flex flex-col">
<span class="text-sm text-n-slate-11 mt-2">
{{ item.description }}
</span>
<component :is="renderInstruction(item.instruction)" />
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
</span>
</div>
</template>
</SuggestedScenarios>
</div>
<div class="flex mt-7 flex-col gap-4">
<div class="flex justify-between items-center">
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="scenarios"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="
$t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.BULK_DELETE_BUTTON')
"
@bulk-delete="bulkDeleteScenarios"
>
<template #default-actions>
<AddNewScenariosDialog @add="addScenario" />
</template>
</BulkSelectBar>
<div
v-if="scenarios.length && bulkSelectedIds.size === 0"
class="max-w-[22.5rem] w-full min-w-0"
>
<Input
v-model="searchQuery"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.LIST.SEARCH_PLACEHOLDER')
"
/>
</div>
</div>
<div v-if="scenarios.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else-if="filteredScenarios.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.SEARCH_EMPTY_MESSAGE') }}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<ScenariosCard
v-for="scenario in filteredScenarios"
:id="scenario.id"
:key="scenario.id"
:title="scenario.title"
:description="scenario.description"
:instruction="scenario.instruction"
:tools="scenario.tools"
:is-selected="bulkSelectedIds.has(scenario.id)"
:selectable="
hoveredCard === scenario.id || bulkSelectedIds.size > 0
"
@select="handleRuleSelect"
@delete="deleteScenario(scenario.id)"
@update="updateScenario"
@hover="isHovered => handleRuleHover(isHovered, scenario.id)"
/>
</div>
</div>
</template>
</SettingsPageLayout>
</template>
@@ -41,7 +41,7 @@ const controlItems = computed(() => {
description: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.SCENARIOS.DESCRIPTION'
),
// routeName: 'captain_assistants_scenarios_index',
routeName: 'captain_assistants_scenarios_index',
},
{
name: t(
@@ -7,6 +7,7 @@ import AssistantEdit from './assistants/Edit.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
@@ -67,6 +68,21 @@ export const routes = [
],
},
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/scenarios'
),
component: AssistantScenariosIndex,
name: 'captain_assistants_scenarios_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/guidelines'
@@ -31,7 +31,7 @@ export default {
slate
xs
faded
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0"
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0 md:inline-flex hidden"
@click="toggle"
/>
</template>
@@ -68,7 +68,7 @@ export default {
<template>
<div
class="flex flex-col gap-12 sm:gap-16 items-center justify-center py-0 px-4 md:px-0 w-full min-h-screen max-w-full overflow-auto bg-n-background"
class="flex flex-col gap-12 sm:gap-16 items-center justify-center py-0 px-4 w-full min-h-screen max-w-full overflow-auto bg-n-background"
>
<div class="flex flex-col justify-start sm:justify-center gap-6">
<div class="flex flex-col gap-1.5 items-start sm:items-center">
@@ -64,7 +64,7 @@ watch(
</script>
<template>
<div class="flex flex-grow-0 w-full h-full min-h-0 app-wrapper">
<div class="flex w-full h-full min-h-0">
<section
v-if="isHelpCenterEnabled"
class="flex flex-1 h-full px-0 overflow-hidden bg-n-background"
@@ -4,12 +4,16 @@ import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import PortalSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue';
const SSL_STATUS_FETCH_INTERVAL = 5000;
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const { isOnChatwootCloud } = useAccount();
const { updateUISettings } = useUISettings();
@@ -24,6 +28,15 @@ const getDefaultLocale = slug => {
return getPortalBySlug.value(slug)?.meta?.default_locale;
};
const fetchSSLStatus = () => {
if (!isOnChatwootCloud.value) return;
const { portalSlug } = route.params;
store.dispatch('portals/sslStatus', {
portalSlug,
});
};
const fetchPortalAndItsCategories = async (slug, locale) => {
const selectedPortalParam = { portalSlug: slug, locale };
await Promise.all([
@@ -106,8 +119,35 @@ const deletePortal = async selectedPortalForDelete => {
}
};
const handleSendCnameInstructions = async payload => {
try {
await store.dispatch('portals/sendCnameInstructions', payload);
useAlert(
t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.SUCCESS_MESSAGE'
)
);
} catch (error) {
useAlert(
error?.message ||
t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.ERROR_MESSAGE'
)
);
}
};
const handleUpdatePortal = updatePortalSettings;
const handleUpdatePortalConfiguration = updatePortalSettings;
const handleUpdatePortalConfiguration = portalObj => {
updatePortalSettings(portalObj);
// If custom domain is added or updated, fetch SSL status after a delay of 5 seconds (only on Chatwoot cloud)
if (portalObj?.custom_domain && isOnChatwootCloud.value) {
setTimeout(() => {
fetchSSLStatus();
}, SSL_STATUS_FETCH_INTERVAL);
}
};
const handleDeletePortal = deletePortal;
</script>
@@ -118,5 +158,7 @@ const handleDeletePortal = deletePortal;
@update-portal="handleUpdatePortal"
@update-portal-configuration="handleUpdatePortalConfiguration"
@delete-portal="handleDeletePortal"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
</template>
@@ -1,221 +1,236 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, ref, watch, onMounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import wootConstants from 'dashboard/constants/globals';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import InboxCard from 'dashboard/components-next/Inbox/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
InboxCard,
InboxListHeader,
IntersectionObserver,
CmdBarConversationSnooze,
Spinner,
},
setup() {
const { uiSettings } = useUISettings();
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const { uiSettings } = useUISettings();
return {
uiSettings,
};
},
data() {
return {
infiniteLoaderOptions: {
root: this.$refs.notificationList,
rootMargin: '100px 0px 100px 0px',
},
page: 1,
status: '',
type: '',
sortOrder: wootConstants.INBOX_SORT_BY.NEWEST,
isInboxContextMenuOpen: false,
notificationIdToSnooze: null,
};
},
computed: {
...mapGetters({
meta: 'notifications/getMeta',
uiFlags: 'notifications/getUIFlags',
notification: 'notifications/getFilteredNotifications',
notificationV4: 'notifications/getFilteredNotificationsV4',
inboxById: 'inboxes/getInboxById',
}),
currentNotificationId() {
return Number(this.$route.params.notification_id);
},
inboxFilters() {
return {
page: this.page,
status: this.status,
type: this.type,
sortOrder: this.sortOrder,
};
},
notifications() {
return this.notification(this.inboxFilters);
},
notificationsV4() {
return this.notificationV4(this.inboxFilters);
},
showEndOfList() {
return this.uiFlags.isAllNotificationsLoaded && !this.uiFlags.isFetching;
},
showEmptyState() {
return !this.uiFlags.isFetching && !this.notifications.length;
},
},
watch: {
inboxFilters(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch('notifications/updateNotificationFilters', newVal);
}
},
},
mounted() {
this.setSavedFilter();
this.fetchNotifications();
},
methods: {
stateInbox(inboxId) {
return this.inboxById(inboxId);
},
fetchNotifications() {
this.page = 1;
this.$store.dispatch('notifications/clear');
const filter = this.inboxFilters;
this.$store.dispatch('notifications/index', filter);
},
redirectToInbox() {
if (this.$route.name === 'inbox_view') return;
this.$router.replace({ name: 'inbox_view' });
},
loadMoreNotifications() {
if (this.uiFlags.isAllNotificationsLoaded) return;
this.$store.dispatch('notifications/index', {
page: this.page + 1,
status: this.status,
type: this.type,
sortOrder: this.sortOrder,
});
this.page += 1;
},
markNotificationAsRead(notification) {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notification;
this.$store
.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
})
.then(() => {
useAlert(this.$t('INBOX.ALERTS.MARK_AS_READ'));
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
});
},
markNotificationAsUnRead(notification) {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD);
this.redirectToInbox();
const { id } = notification;
this.$store
.dispatch('notifications/unread', {
id,
})
.then(() => {
useAlert(this.$t('INBOX.ALERTS.MARK_AS_UNREAD'));
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
});
},
deleteNotification(notification) {
useTrack(INBOX_EVENTS.DELETE_NOTIFICATION);
this.redirectToInbox();
this.$store
.dispatch('notifications/delete', {
notification,
unread_count: this.meta.unreadCount,
count: this.meta.count,
})
.then(() => {
useAlert(this.$t('INBOX.ALERTS.DELETE'));
});
},
onFilterChange(option) {
const { STATUS, TYPE, SORT_ORDER } = wootConstants.INBOX_FILTER_TYPE;
if (option.type === STATUS) {
this.status = option.selected ? option.key : '';
}
if (option.type === TYPE) {
this.type = option.selected ? option.key : '';
}
if (option.type === SORT_ORDER) {
this.sortOrder = option.key;
}
this.fetchNotifications();
},
setSavedFilter() {
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
const { status, type, sort_by: sortBy } = filterBy;
this.status = status;
this.type = type;
this.sortOrder = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
this.$store.dispatch(
'notifications/setNotificationFilters',
this.inboxFilters
);
},
openConversation(notification) {
const {
id,
primaryActorId,
primaryActorType,
primaryActor: { inboxId },
notificationType,
} = notification;
const notificationList = ref(null);
const page = ref(1);
const status = ref('');
const type = ref('');
const sortOrder = ref(wootConstants.INBOX_SORT_BY.NEWEST);
const isInboxContextMenuOpen = ref(false);
if (this.$route.params.notification_id !== id) {
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
const infiniteLoaderOptions = computed(() => ({
root: notificationList.value,
rootMargin: '100px 0px 100px 0px',
}));
this.$store
.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
})
.then(() => {
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
});
const meta = useMapGetter('notifications/getMeta');
const uiFlags = useMapGetter('notifications/getUIFlags');
const records = useMapGetter('notifications/getFilteredNotificationsV4');
const inboxById = useMapGetter('inboxes/getInboxById');
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, notification_id: id },
});
}
},
},
const currentConversationId = computed(() => Number(route.params.id));
const inboxFilters = computed(() => ({
page: page.value,
status: status.value,
type: type.value,
sortOrder: sortOrder.value,
}));
const notifications = computed(() => {
return records.value(inboxFilters.value);
});
const showEndOfList = computed(() => {
return uiFlags.value.isAllNotificationsLoaded && !uiFlags.value.isFetching;
});
const showEmptyState = computed(() => {
return !uiFlags.value.isFetching && !notifications.value.length;
});
const stateInbox = inboxId => {
return inboxById.value(inboxId);
};
const fetchNotifications = () => {
page.value = 1;
store.dispatch('notifications/clear');
const filter = inboxFilters.value;
store.dispatch('notifications/index', filter);
};
const scrollActiveIntoView = () => {
const activeEl = notificationList.value?.querySelector('.inbox-card.active');
activeEl?.scrollIntoView({ block: 'center', behavior: 'smooth' });
};
const redirectToInbox = () => {
if (route.name === 'inbox_view') return;
router.replace({ name: 'inbox_view' });
};
const loadMoreNotifications = () => {
if (uiFlags.value.isAllNotificationsLoaded) return;
page.value += 1;
store.dispatch('notifications/index', {
page: page.value,
status: status.value,
type: type.value,
sortOrder: sortOrder.value,
});
};
const markNotificationAsRead = async notificationItem => {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notificationItem;
try {
await store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: meta.value.unreadCount,
});
useAlert(t('INBOX.ALERTS.MARK_AS_READ'));
store.dispatch('notifications/unReadCount');
} catch {
// error
}
};
const markNotificationAsUnRead = async notificationItem => {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD);
redirectToInbox();
const { id } = notificationItem;
try {
await store.dispatch('notifications/unread', { id });
useAlert(t('INBOX.ALERTS.MARK_AS_UNREAD'));
store.dispatch('notifications/unReadCount');
} catch {
// error
}
};
const deleteNotification = async notificationItem => {
useTrack(INBOX_EVENTS.DELETE_NOTIFICATION);
redirectToInbox();
try {
await store.dispatch('notifications/delete', {
notification: notificationItem,
unread_count: meta.value.unreadCount,
count: meta.value.count,
});
useAlert(t('INBOX.ALERTS.DELETE'));
} catch {
// error
}
};
const onFilterChange = option => {
const { STATUS, TYPE, SORT_ORDER } = wootConstants.INBOX_FILTER_TYPE;
if (option.type === STATUS) {
status.value = option.selected ? option.key : '';
}
if (option.type === TYPE) {
type.value = option.selected ? option.key : '';
}
if (option.type === SORT_ORDER) {
sortOrder.value = option.key;
}
fetchNotifications();
};
const setSavedFilter = () => {
const { inbox_filter_by: filterBy = {} } = uiSettings.value;
const { status: savedStatus, type: savedType, sort_by: sortBy } = filterBy;
status.value = savedStatus;
type.value = savedType;
sortOrder.value = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
store.dispatch('notifications/setNotificationFilters', inboxFilters.value);
};
const openConversation = async notificationItem => {
const {
id,
primaryActorId,
primaryActorType,
primaryActor: { inboxId, id: conversationId },
notificationType,
} = notificationItem;
if (route.params.id === String(conversationId)) return;
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
try {
await store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: meta.value.unreadCount,
});
// to update the unread count in the store realtime
store.dispatch('notifications/unReadCount');
router.push({
name: 'inbox_view_conversation',
params: { inboxId, type: 'conversation', id: conversationId },
});
} catch {
// error
}
};
watch(
inboxFilters,
(newVal, oldVal) => {
if (newVal !== oldVal) {
store.dispatch('notifications/updateNotificationFilters', newVal);
}
},
{ deep: true }
);
watch(currentConversationId, () => {
nextTick(scrollActiveIntoView);
});
onMounted(() => {
scrollActiveIntoView();
setSavedFilter();
fetchNotifications();
});
</script>
<template>
<section class="flex w-full h-full bg-n-solid-1">
<div
class="flex flex-col h-full w-full lg:min-w-[400px] lg:max-w-[400px] ltr:border-r rtl:border-l border-n-weak"
:class="!currentNotificationId ? 'flex' : 'hidden xl:flex'"
class="flex flex-col h-full w-full lg:min-w-[340px] lg:max-w-[340px] ltr:border-r rtl:border-l border-n-weak"
:class="!currentConversationId ? 'flex' : 'hidden xl:flex'"
>
<InboxListHeader
:is-context-menu-open="isInboxContextMenuOpen"
@@ -224,17 +239,17 @@ export default {
/>
<div
ref="notificationList"
class="flex flex-col gap-px w-full h-[calc(100%-56px)] pb-3 overflow-x-hidden px-3 overflow-y-auto divide-y divide-n-weak [&>*:hover]:!border-y-transparent [&>*.active]:!border-y-transparent [&>*:hover+*]:!border-t-transparent [&>*.active+*]:!border-t-transparent"
class="flex flex-col gap-0.5 w-full h-[calc(100%-56px)] pb-4 overflow-x-hidden px-2 overflow-y-auto divide-y divide-n-weak [&>*:hover]:!border-y-transparent [&>*.active]:!border-y-transparent [&>*:hover+*]:!border-t-transparent [&>*.active+*]:!border-t-transparent"
>
<InboxCard
v-for="notificationItem in notificationsV4"
v-for="notificationItem in notifications"
:key="notificationItem.id"
:inbox-item="notificationItem"
:state-inbox="stateInbox(notificationItem.primaryActor?.inboxId)"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
class="inbox-card rounded-lg hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
:class="
currentNotificationId === notificationItem.id
? 'bg-n-alpha-1 dark:bg-n-alpha-3 click-animation rounded-xl active'
currentConversationId === notificationItem.primaryActor?.id
? 'bg-n-alpha-1 dark:bg-n-alpha-3 rounded-lg active'
: ''
"
@mark-notification-as-read="markNotificationAsRead"
@@ -264,23 +279,3 @@ export default {
<CmdBarConversationSnooze />
</section>
</template>
<style scoped>
.click-animation {
animation: click-animation 0.2s ease-in-out;
}
@keyframes click-animation {
0% {
transform: scale(1);
}
50% {
transform: scale(0.99);
}
100% {
transform: scale(1);
}
}
</style>
@@ -1,183 +1,191 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useTrack } from 'dashboard/composables';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { emitter } from 'shared/helpers/mitt';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
export default {
components: {
InboxItemHeader,
InboxEmptyState,
ConversationBox,
Spinner,
},
setup() {
const { uiSettings, updateUISettings } = useUISettings();
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
return {
uiSettings,
updateUISettings,
};
},
data() {
return {
isConversationLoading: false,
};
},
computed: {
...mapGetters({
notification: 'notifications/getFilteredNotifications',
currentChat: 'getSelectedChat',
activeNotificationById: 'notifications/getNotificationById',
conversationById: 'getConversationById',
uiFlags: 'notifications/getUIFlags',
meta: 'notifications/getMeta',
}),
notifications() {
return this.notification({
sortOrder: this.activeSortOrder,
});
},
inboxId() {
return Number(this.$route.params.inboxId);
},
notificationId() {
return Number(this.$route.params.notification_id);
},
activeNotification() {
return this.activeNotificationById(this.notificationId);
},
conversationId() {
return this.activeNotification?.primary_actor?.id;
},
totalNotificationCount() {
return this.meta.count;
},
showEmptyState() {
return (
!this.conversationId ||
(!this.notifications?.length && this.uiFlags.isFetching)
);
},
activeNotificationIndex() {
return this.notifications?.findIndex(n => n.id === this.notificationId);
},
activeSortOrder() {
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
const { sort_by: sortBy } = filterBy;
return sortBy || 'desc';
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
},
watch: {
conversationId: {
immediate: true,
handler(newVal, oldVal) {
if (newVal !== oldVal) {
this.fetchConversationById();
}
},
},
},
mounted() {
this.$store.dispatch('agents/get');
},
methods: {
async fetchConversationById() {
if (!this.notificationId || !this.conversationId) return;
this.$store.dispatch('clearSelectedState');
const existingChat = this.findConversation();
if (existingChat) {
this.setActiveChat(existingChat);
return;
}
this.isConversationLoading = true;
await this.$store.dispatch('getConversation', this.conversationId);
this.setActiveChat();
this.isConversationLoading = false;
},
setActiveChat() {
const selectedConversation = this.findConversation();
if (!selectedConversation) return;
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
return this.conversationById(this.conversationId);
},
navigateToConversation(activeIndex, direction) {
let updatedIndex;
if (direction === 'prev' && activeIndex) {
updatedIndex = activeIndex - 1;
} else if (
direction === 'next' &&
activeIndex < this.totalNotificationCount
) {
updatedIndex = activeIndex + 1;
}
const targetNotification = this.notifications[updatedIndex];
if (targetNotification) {
this.openNotification(targetNotification);
}
},
openNotification(notification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { meta: { unreadCount } = {} },
notification_type: notificationType,
} = notification;
const route = useRoute();
const router = useRouter();
const store = useStore();
const { uiSettings } = useUISettings();
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
const isConversationLoading = ref(false);
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
const notification = useMapGetter('notifications/getFilteredNotifications');
const currentChat = useMapGetter('getSelectedChat');
const conversationById = useMapGetter('getConversationById');
const uiFlags = useMapGetter('notifications/getUIFlags');
const meta = useMapGetter('notifications/getMeta');
this.$router.push({
name: 'inbox_view_conversation',
params: { notification_id: id },
});
},
onClickNext() {
this.navigateToConversation(this.activeNotificationIndex, 'next');
},
onClickPrev() {
this.navigateToConversation(this.activeNotificationIndex, 'prev');
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
},
const inboxId = computed(() => Number(route.params.inboxId));
const conversationId = computed(() => Number(route.params.id));
const activeSortOrder = computed(() => {
const { inbox_filter_by: filterBy = {} } = uiSettings.value;
const { sort_by: sortBy } = filterBy;
return sortBy || 'desc';
});
const notifications = computed(() => {
return notification.value({
sortOrder: activeSortOrder.value,
});
});
const activeNotification = computed(() => {
return notifications.value?.find(
n => n.primary_actor?.id === conversationId.value
);
});
const totalNotificationCount = computed(() => {
return meta.value.count;
});
const showEmptyState = computed(() => {
return (
!conversationId.value ||
(!notifications.value?.length && uiFlags.value.isFetching)
);
});
const activeNotificationIndex = computed(() => {
return notifications.value?.findIndex(
n => n.primary_actor?.id === conversationId.value
);
});
const isContactPanelOpen = computed(() => {
if (currentChat.value.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
return isContactSidebarOpen;
}
return false;
});
const findConversation = () => {
return conversationById.value(conversationId.value);
};
const openNotification = async notificationItem => {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: {
meta: { unreadCount } = {},
id: conversationIdFromNotification,
},
notification_type: notificationType,
} = notificationItem;
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
try {
await store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
router.push({
name: 'inbox_view_conversation',
params: { type: 'conversation', id: conversationIdFromNotification },
});
} catch {
// error
}
};
const setActiveChat = async () => {
const selectedConversation = findConversation();
if (!selectedConversation) return;
try {
await store.dispatch('setActiveChat', { data: selectedConversation });
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
} catch {
// error
}
};
const fetchConversationById = async () => {
if (!conversationId.value) return;
store.dispatch('clearSelectedState');
const existingChat = findConversation();
if (existingChat) {
await setActiveChat();
return;
}
isConversationLoading.value = true;
try {
await store.dispatch('getConversation', conversationId.value);
await setActiveChat();
} catch {
// error
} finally {
isConversationLoading.value = false;
}
};
const navigateToConversation = (activeIndex, direction) => {
const isValidPrev = direction === 'prev' && activeIndex > 0;
const isValidNext =
direction === 'next' && activeIndex < totalNotificationCount.value - 1;
if (!isValidPrev && !isValidNext) return;
const updatedIndex = direction === 'prev' ? activeIndex - 1 : activeIndex + 1;
const targetNotification = notifications.value[updatedIndex];
if (targetNotification) {
openNotification(targetNotification);
}
};
const onClickNext = () => {
navigateToConversation(activeNotificationIndex.value, 'next');
};
const onClickPrev = () => {
navigateToConversation(activeNotificationIndex.value, 'prev');
};
watch(
conversationId,
(newVal, oldVal) => {
if (newVal !== oldVal) {
fetchConversationById();
}
},
{ immediate: true }
);
onMounted(async () => {
await store.dispatch('agents/get');
});
</script>
<template>
<div class="h-full w-full xl:w-[calc(100%-400px)]">
<div class="h-full w-full flex-1">
<div v-if="showEmptyState" class="flex w-full h-full">
<InboxEmptyState
:empty-state-message="$t('INBOX.LIST.NO_MESSAGES_AVAILABLE')"
@@ -193,19 +201,24 @@ export default {
/>
<div
v-if="isConversationLoading"
class="flex items-center h-[calc(100%-56px)] my-4 justify-center bg-n-solid-1"
class="flex items-center flex-1 my-4 justify-center bg-n-solid-1"
>
<Spinner class="text-n-brand" />
</div>
<ConversationBox
v-else
class="h-[calc(100%-56px)] [&.conversation-details-wrap]:!border-0"
is-inbox-view
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="false"
@contact-panel-toggle="onToggleContactPanel"
/>
<div v-else class="flex h-[calc(100%-48px)] min-w-0">
<ConversationBox
class="flex-1 [&.conversation-details-wrap]:!border-0"
is-inbox-view
:inbox-id="inboxId"
:is-on-expanded-layout="false"
>
<SidepanelSwitch v-if="currentChat.id" />
</ConversationBox>
<ConversationSidebar
v-if="isContactPanelOpen"
:current-chat="currentChat"
/>
</div>
</div>
</div>
</template>
@@ -109,7 +109,7 @@ export default {
<template>
<div
class="flex items-center justify-between w-full gap-2 border-b ltr:pl-4 rtl:pl-2 h-12 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
class="flex items-center justify-between w-full gap-2 border-b px-3 h-12 rtl:border-r border-n-weak flex-shrink-0"
>
<div class="flex items-center gap-4">
<BackButton
@@ -79,9 +79,9 @@ export default {
</script>
<template>
<div class="flex items-center justify-between w-full gap-1 h-14 px-4 mb-2">
<div class="flex items-center justify-between w-full gap-1 h-12 px-3">
<div class="flex items-center gap-2 min-w-0 flex-1">
<h1 class="min-w-0 text-lg font-medium truncate text-n-slate-12">
<h1 class="min-w-0 text-base font-medium truncate text-n-slate-12">
{{ $t('INBOX.LIST.TITLE') }}
</h1>
<div class="relative">
@@ -21,7 +21,7 @@ export const routes = [
},
},
{
path: ':notification_id',
path: ':type/:id',
name: 'inbox_view_conversation',
component: InboxDetailView,
meta: {
@@ -1,32 +1,21 @@
<script>
import { useAdmin } from 'dashboard/composables/useAdmin';
import BackButton from '../../../components/widgets/BackButton.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BackButton,
NextButton,
},
props: {
headerTitle: {
default: '',
type: String,
},
buttonRoute: {
default: '',
type: String,
},
buttonText: {
default: '',
type: String,
},
icon: {
default: '',
type: String,
},
showBackButton: { type: Boolean, default: false },
showNewButton: { type: Boolean, default: false },
backUrl: {
type: [String, Object],
default: '',
@@ -67,14 +56,5 @@ export default {
{{ headerTitle }}
</span>
</h1>
<!-- TODO: Remove this when we are not using this -->
<router-link v-if="showNewButton && isAdmin" :to="buttonRoute">
<NextButton
teal
icon="i-lucide-circle-plus"
class="button--fixed-top"
:label="buttonText"
/>
</router-link>
</div>
</template>
@@ -2,13 +2,10 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import SettingsHeader from './SettingsHeader.vue';
const props = defineProps({
headerTitle: { type: String, default: '' },
headerButtonText: { type: String, default: '' },
icon: { type: String, default: '' },
keepAlive: { type: Boolean, default: true },
newButtonRoutes: { type: Array, default: () => [] },
showBackButton: { type: Boolean, default: false },
backUrl: { type: [String, Object], default: '' },
fullWidth: { type: Boolean, default: false },
@@ -16,16 +13,8 @@ const props = defineProps({
const { t } = useI18n();
const showNewButton = computed(
() => props.newButtonRoutes.length && !props.showBackButton
);
const showSettingsHeader = computed(
() =>
props.headerTitle ||
props.icon ||
props.showBackButton ||
showNewButton.value
() => props.headerTitle || props.icon || props.showBackButton
);
</script>
@@ -37,13 +26,10 @@ const showSettingsHeader = computed(
>
<SettingsHeader
v-if="showSettingsHeader"
button-route="new"
:icon="icon"
:header-title="t(headerTitle)"
:button-text="t(headerButtonText)"
:show-back-button="showBackButton"
:back-url="backUrl"
:show-new-button="showNewButton"
class="sticky top-0 z-20"
:class="{ 'max-w-6xl w-full mx-auto': fullWidth }"
/>
@@ -42,9 +42,7 @@ export default {
const fullWidth = params.name === 'settings_inbox_show';
return {
headerTitle: 'INBOX_MGMT.HEADER',
headerButtonText: 'SETTINGS.INBOXES.NEW_INBOX',
icon: 'mail-inbox-all',
newButtonRoutes: ['settings_inbox_list'],
showBackButton,
fullWidth,
};
@@ -41,9 +41,7 @@ export default {
props: () => {
return {
headerTitle: 'TEAMS_SETTINGS.HEADER',
headerButtonText: 'TEAMS_SETTINGS.NEW_TEAM',
icon: 'people-team',
newButtonRoutes: ['settings_teams_new'],
showBackButton: true,
};
},
@@ -106,42 +106,44 @@ defineExpose({ shouldShowUpgradePage });
</script>
<template>
<template v-if="shouldShowUpgradePage">
<div class="mx-auto h-full pt-[clamp(3rem,15vh,12rem)]">
<div
class="flex flex-col gap-4 max-w-md px-8 py-6 shadow-lg bg-n-solid-1 rounded-xl outline outline-1 outline-n-container"
>
<div class="flex flex-col gap-4">
<div class="flex items-center w-full gap-2">
<span
class="flex items-center justify-center w-6 h-6 rounded-full bg-n-solid-blue"
>
<Icon
class="flex-shrink-0 text-n-brand size-[14px]"
icon="i-lucide-lock-keyhole"
/>
</span>
<span class="text-base font-medium text-n-slate-12">
{{ $t('GENERAL_SETTINGS.UPGRADE') }}
</span>
</div>
<div>
<p class="text-sm font-normal text-n-slate-11 mb-3">
{{ limitExceededMessage }}
</p>
<p v-if="!isAdmin">
{{ t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
</p>
</div>
<div
v-if="shouldShowUpgradePage"
class="mx-auto h-full pt-[clamp(3rem,15vh,12rem)]"
>
<div
class="flex flex-col gap-4 max-w-md px-8 py-6 shadow-lg bg-n-solid-1 rounded-xl outline outline-1 outline-n-container"
>
<div class="flex flex-col gap-4">
<div class="flex items-center w-full gap-2">
<span
class="flex items-center justify-center w-6 h-6 rounded-full bg-n-solid-blue"
>
<Icon
class="flex-shrink-0 text-n-brand size-[14px]"
icon="i-lucide-lock-keyhole"
/>
</span>
<span class="text-base font-medium text-n-slate-12">
{{ $t('GENERAL_SETTINGS.UPGRADE') }}
</span>
</div>
<div>
<p class="text-sm font-normal text-n-slate-11 mb-3">
{{ limitExceededMessage }}
</p>
<p v-if="!isAdmin">
{{ t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
</p>
</div>
<NextButton
v-if="isAdmin"
:label="$t('GENERAL_SETTINGS.OPEN_BILLING')"
icon="i-lucide-credit-card"
@click="routeToBilling()"
/>
</div>
<NextButton
v-if="isAdmin"
:label="$t('GENERAL_SETTINGS.OPEN_BILLING')"
icon="i-lucide-credit-card"
@click="routeToBilling()"
/>
</div>
</template>
<template v-else />
<slot />
</div>
<div v-else />
</template>
@@ -0,0 +1,38 @@
import CaptainScenarios from 'dashboard/api/captain/scenarios';
import { createStore } from './storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainScenario',
API: CaptainScenarios,
actions: mutations => ({
update: async ({ commit }, { id, assistantId, ...updateObj }) => {
commit(mutations.SET_UI_FLAG, { updatingItem: true });
try {
const response = await CaptainScenarios.update(
{ id, assistantId },
updateObj
);
commit(mutations.EDIT, response.data);
commit(mutations.SET_UI_FLAG, { updatingItem: false });
return response.data;
} catch (error) {
commit(mutations.SET_UI_FLAG, { updatingItem: false });
return throwErrorMessage(error);
}
},
delete: async ({ commit }, { id, assistantId }) => {
commit(mutations.SET_UI_FLAG, { deletingItem: true });
try {
await CaptainScenarios.delete({ id, assistantId });
commit(mutations.DELETE, id);
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return id;
} catch (error) {
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return throwErrorMessage(error);
}
},
}),
});
@@ -0,0 +1,24 @@
import { createStore } from './storeFactory';
import CaptainToolsAPI from '../../api/captain/tools';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const toolsStore = createStore({
name: 'captainTool',
API: CaptainToolsAPI,
actions: mutations => ({
getTools: async ({ commit }) => {
commit(mutations.SET_UI_FLAG, { fetchingList: true });
try {
const response = await CaptainToolsAPI.get();
commit(mutations.SET, response.data);
commit(mutations.SET_UI_FLAG, { fetchingList: false });
return response.data;
} catch (error) {
commit(mutations.SET_UI_FLAG, { fetchingList: false });
return throwErrorMessage(error);
}
},
}),
});
export default toolsStore;
+4
View File
@@ -53,6 +53,8 @@ import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages';
import captainScenarios from './captain/scenarios';
import captainTools from './captain/tools';
const plugins = [];
@@ -111,6 +113,8 @@ export default createStore({
captainBulkActions,
copilotThreads,
copilotMessages,
captainScenarios,
captainTools,
},
plugins,
});
@@ -116,4 +116,24 @@ export const actions = {
isSwitching,
});
},
sendCnameInstructions: async (_, { portalSlug, email }) => {
try {
await portalAPIs.sendCnameInstructions(portalSlug, email);
} catch (error) {
throwErrorMessage(error);
}
},
sslStatus: async ({ commit }, { portalSlug }) => {
try {
commit(types.SET_UI_FLAG, { isFetchingSSLStatus: true });
const { data } = await portalAPIs.sslStatus(portalSlug);
commit(types.SET_SSL_SETTINGS, { portalSlug, sslSettings: data });
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { isFetchingSSLStatus: false });
}
},
};
@@ -8,6 +8,7 @@ export const getters = {
isFetchingPortals: state => state.uiFlags.isFetching,
isCreatingPortal: state => state.uiFlags.isCreating,
isSwitchingPortal: state => state.uiFlags.isSwitching,
isFetchingSSLStatus: state => state.uiFlags.isFetchingSSLStatus,
portalBySlug:
(...getterArguments) =>
portalId => {
@@ -6,6 +6,7 @@ export const defaultPortalFlags = {
isFetching: false,
isUpdating: false,
isDeleting: false,
isFetchingSSLStatus: false,
};
const state = {
@@ -13,6 +13,7 @@ export const types = {
REMOVE_PORTAL_ID: 'removePortalId',
SET_HELP_PORTAL_UI_FLAG: 'setHelpCenterUIFlag',
SET_PORTAL_SWITCHING_FLAG: 'setPortalSwitchingFlag',
SET_SSL_SETTINGS: 'setSSLSettings',
};
export const mutations = {
@@ -110,4 +111,18 @@ export const mutations = {
[types.SET_PORTAL_SWITCHING_FLAG]($state, { isSwitching }) {
$state.uiFlags.isSwitching = isSwitching;
},
[types.SET_SSL_SETTINGS]($state, { portalSlug, sslSettings }) {
const portal = $state.portals.byId[portalSlug];
$state.portals.byId = {
...$state.portals.byId,
[portalSlug]: {
...portal,
ssl_settings: {
...portal.ssl_settings,
...sslSettings,
},
},
};
},
};
@@ -135,6 +135,36 @@ describe('#actions', () => {
});
});
describe('#sslStatus', () => {
it('commits SET_SSL_SETTINGS with data from API', async () => {
axios.get.mockResolvedValue({
data: { status: 'active', verification_errors: [] },
});
await actions.sslStatus({ commit }, { portalSlug: 'domain' });
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isFetchingSSLStatus: true }],
[
types.SET_SSL_SETTINGS,
{
portalSlug: 'domain',
sslSettings: { status: 'active', verification_errors: [] },
},
],
[types.SET_UI_FLAG, { isFetchingSSLStatus: false }],
]);
});
it('throws error and does not commit when API fails', async () => {
axios.get.mockRejectedValue({ message: 'error' });
await expect(
actions.sslStatus({ commit }, { portalSlug: 'domain' })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isFetchingSSLStatus: true }],
[types.SET_UI_FLAG, { isFetchingSSLStatus: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
@@ -89,6 +89,25 @@ describe('#mutations', () => {
isFetching: true,
isUpdating: false,
isDeleting: false,
isFetchingSSLStatus: false,
});
});
});
describe('[types.SET_SSL_SETTINGS]', () => {
it('merges new ssl settings into existing portal.ssl_settings', () => {
state.portals.byId.domain = {
slug: 'domain',
ssl_settings: { cf_status: 'pending' },
};
mutations[types.SET_SSL_SETTINGS](state, {
portalSlug: 'domain',
sslSettings: { status: 'active', verification_errors: ['error'] },
});
expect(state.portals.byId.domain.ssl_settings).toEqual({
cf_status: 'pending',
status: 'active',
verification_errors: ['error'],
});
});
});
@@ -157,6 +157,14 @@ export const MESSAGE_VARIABLES = [
label: 'Agent email',
key: 'agent.email',
},
{
key: 'inbox.name',
label: 'Inbox name',
},
{
label: 'Inbox id',
key: 'inbox.id',
},
];
export const ATTACHMENT_ICONS = {
@@ -1,5 +1,6 @@
class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
queue_as :scheduled_jobs
include BillingHelper
def perform
email_inboxes = Inbox.where(channel_type: 'Channel::Email')
@@ -11,6 +12,13 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
private
def should_fetch_emails?(inbox)
inbox.channel.imap_enabled && !inbox.account.suspended?
return false if inbox.account.suspended?
return false unless inbox.channel.imap_enabled
return false if inbox.channel.reauthorization_required?
return true unless ChatwootApp.chatwoot_cloud?
return false if default_plan?(inbox.account)
true
end
end
@@ -4,16 +4,17 @@ module IncomingEmailValidityHelper
def incoming_email_from_valid_email?
return false unless valid_external_email_for_active_account?
# Return if email doesn't have a valid sender
# This can happen in cases like bounce emails for invalid contact email address
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
# Process bounced emails, as regular emails
return true if @processed_mail.bounced?
# we skip processing auto reply emails like delivery status notifications
# out of office replies, etc.
return false if auto_reply_email?
# return if email doesn't have a valid sender
# This can happen in cases like bounce emails for invalid contact email address
# TODO: Handle the bounce separately and mark the contact as invalid in case of reply bounces
# The returned value could be "\"\"" for some email clients
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
true
end
+41
View File
@@ -0,0 +1,41 @@
class PortalInstructionsMailer < ApplicationMailer
def send_cname_instructions(portal:, recipient_email:)
return unless smtp_config_set_or_development?
return if target_domain.blank?
@portal = portal
@cname_record = generate_cname_record
send_mail_with_liquid(
to: recipient_email,
subject: I18n.t('portals.send_instructions.subject', custom_domain: @portal.custom_domain)
)
end
private
def liquid_locals
super.merge({ cname_record: @cname_record })
end
def generate_cname_record
"#{@portal.custom_domain} CNAME #{target_domain}"
end
def target_domain
helpcenter_url = ENV.fetch('HELPCENTER_URL', '')
frontend_url = ENV.fetch('FRONTEND_URL', '')
return extract_hostname(helpcenter_url) if helpcenter_url.present?
return extract_hostname(frontend_url) if frontend_url.present?
''
end
def extract_hostname(url)
uri = URI.parse(url)
uri.host
rescue URI::InvalidURIError
url.gsub(%r{https?://}, '').split('/').first
end
end
+8
View File
@@ -26,6 +26,14 @@ class PortalPolicy < ApplicationPolicy
def logo?
@account_user.administrator?
end
def send_instructions?
@account_user.administrator?
end
def ssl_status?
@account.users.include?(@user)
end
end
PortalPolicy.prepend_mod_with('PortalPolicy')
+4
View File
@@ -157,6 +157,10 @@ class MailPresenter < SimpleDelegator
auto_submitted? || x_auto_reply?
end
def bounced?
@mail.bounced? || @mail['X-Failed-Recipients'].try(:value).present?
end
def notification_email_from_chatwoot?
# notification emails are send via mailer sender email address. so it should match
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
@@ -34,3 +34,10 @@ json.meta do
json.categories_count portal.categories.try(:size)
json.default_locale portal.default_locale
end
if portal.ssl_settings.present?
json.ssl_settings do
json.status portal.ssl_settings['cf_status']
json.verification_errors portal.ssl_settings['cf_verification_errors']
end
end
@@ -0,0 +1,30 @@
<tr>
<td>
<p>Hello there,</p>
<p>To complete the setup of your help center, you'll need to update the DNS settings for your custom domain: <strong>{{ cname_record | split: ' ' | first }}</strong>.</p>
<p>Please add the following CNAME record to your DNS provider's configuration:</p>
</td>
</tr>
<tr>
<td>
<p><strong>{{ cname_record }}</strong></p>
</td>
</tr>
<tr>
<td>
<p>Step-by-step Instructions:</p>
<ol>
<li>Log in to your DNS providers dashboard</li>
<li>Go to the DNS management section</li>
<li>Create a new CNAME record using the information above</li>
<li>Save the changes and allow up to 24 hours for the DNS to propagate</li>
</ol>
<p>Once the DNS record is live, your custom domain will automatically be secured with an SSL certificate.</p>
<p>If you have any questions or need help, feel free to reach out to our support team—were here to assist you.</p>
</td>
</tr>
+9
View File
@@ -358,3 +358,12 @@ en:
Transcript:
%{format_messages}
portals:
send_instructions:
email_required: 'Email is required'
invalid_email_format: 'Invalid email format'
custom_domain_not_configured: 'Custom domain is not configured'
instructions_sent_successfully: 'Instructions sent successfully'
subject: 'Finish setting up %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
+2
View File
@@ -290,6 +290,8 @@ Rails.application.routes.draw do
member do
patch :archive
delete :logo
post :send_instructions
get :ssl_status
end
resources :categories
resources :articles do
+7 -6
View File
@@ -2,7 +2,7 @@
# Description: Install and manage a Chatwoot installation.
# OS: Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS
# Script Version: 3.4.0
# Script Version: 3.4.2
# Run this script as root
set -eu -o errexit -o pipefail -o noclobber -o nounset
@@ -990,7 +990,7 @@ EOF
# Check if CW_VERSION is 4.0 or above
if [[ "$(printf '%s\n' "$CW_VERSION" "4.0" | sort -V | head -n 1)" == "4.0" ]]; then
echo "Chatwoot v4.0 and above requires pgvector support in PostgreSQL."
read -p "Does your postgres support pgvector and want to proceed with the upgrade? [Y/n]: " user_input
read -p "Does your postgres support pgvector and want to proceed with the upgrade? [y/N]: " user_input
user_input=${user_input:-Y}
if [[ "$user_input" =~ ^([yY][eE][sS]|[yY])$ ]]; then
echo "Proceeding with the upgrade..."
@@ -1005,7 +1005,8 @@ EOF
upgrade_redis
upgrade_node
get_pnpm
sudo -i -u chatwoot << "EOF"
sudo -i -u chatwoot << EOF
# Navigate to the Chatwoot directory
cd chatwoot
@@ -1016,9 +1017,9 @@ EOF
# Ensure the ruby version is upto date
# Parse the latest ruby version
latest_ruby_version="$(cat '.ruby-version')"
rvm install "ruby-$latest_ruby_version"
rvm use "$latest_ruby_version" --default
latest_ruby_version="\$(cat '.ruby-version')"
rvm install "ruby-\$latest_ruby_version"
rvm use "\$latest_ruby_version" --default
# Update dependencies
bundle
@@ -0,0 +1,15 @@
module Enterprise::Api::V1::Accounts::PortalsController
def ssl_status
return render_could_not_create_error(I18n.t('portals.ssl_status.custom_domain_not_configured')) if @portal.custom_domain.blank?
result = Cloudflare::CheckCustomHostnameService.new(portal: @portal).perform
return render_could_not_create_error(result[:errors]) if result[:errors].present?
ssl_settings = @portal.ssl_settings || {}
render json: {
status: ssl_settings['cf_status'],
verification_errors: ssl_settings['cf_verification_errors']
}
end
end
@@ -68,8 +68,16 @@ module Enterprise::Concerns::Article
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', nil)}" }
body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json
Rails.logger.info "Requesting Chat GPT with body: #{body}"
response = HTTParty.post('https://api.openai.com/v1/chat/completions', headers: headers, body: body)
response = HTTParty.post(openai_api_url, headers: headers, body: body)
Rails.logger.info "Chat GPT response: #{response.body}"
JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms']
end
private
def openai_api_url
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1/chat/completions"
end
end
@@ -17,4 +17,25 @@ class Cloudflare::BaseCloudflareZoneService
def zone_id
InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value
end
def update_portal_ssl_settings(portal, data)
verification_record = data['ownership_verification_http']
ssl_record = data['ssl']
verification_errors = data['verification_errors']&.first || ''
# Start with existing settings to preserve verification data if it exists
ssl_settings = portal.ssl_settings || {}
# Only update verification fields if they exist in the response (during initial setup)
if verification_record.present?
ssl_settings['cf_verification_id'] = verification_record['http_url'].split('/').last
ssl_settings['cf_verification_body'] = verification_record['http_body']
end
# Always update SSL status and errors from current response
ssl_settings['cf_status'] = ssl_record&.dig('status')
ssl_settings['cf_verification_errors'] = verification_errors
portal.update(ssl_settings: ssl_settings)
end
end
@@ -14,21 +14,10 @@ class Cloudflare::CheckCustomHostnameService < Cloudflare::BaseCloudflareZoneSer
data = response.parsed_response['result']
if data.present?
update_portal_ssl_settings(data.first)
update_portal_ssl_settings(@portal, data.first)
return { data: data }
end
{ errors: ['Hostname is missing in Cloudflare'] }
end
private
def update_portal_ssl_settings(data)
verification_record = data['ownership_verification_http']
ssl_settings = {
'cf_verification_id': verification_record['http_url'].split('/').last,
'cf_verification_body': verification_record['http_body']
}
@portal.update(ssl_settings: ssl_settings)
end
end
@@ -12,7 +12,7 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe
data = response.parsed_response['result']
if data.present?
update_portal_ssl_settings(data)
update_portal_ssl_settings(@portal, data)
return { data: data }
end
@@ -25,16 +25,13 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe
HTTParty.post(
"#{BASE_URI}/zones/#{zone_id}/custom_hostnames",
headers: headers,
body: { hostname: @portal.custom_domain }.to_json
body: {
hostname: @portal.custom_domain,
ssl: {
method: 'http',
type: 'dv'
}
}.to_json
)
end
def update_portal_ssl_settings(data)
verification_record = data['ownership_verification_http']
ssl_settings = {
'cf_verification_id': verification_record['http_url'].split('/').last,
'cf_verification_body': verification_record['http_body']
}
@portal.update(ssl_settings: ssl_settings)
end
end
@@ -15,7 +15,8 @@ class Llm::BaseOpenAiService
private
def uri_base
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
endpoint.presence || 'https://api.openai.com/'
end
def setup_model
@@ -1,5 +1,10 @@
json.data do
json.payload do
json.array! @scenarios do |scenario|
json.partial! 'api/v1/models/captain/scenario', scenario: scenario
end
end
json.meta do
json.total_count @scenarios.count
json.page 1
end
-62
View File
@@ -1,62 +0,0 @@
class ChatGpt
def self.base_uri
'https://api.openai.com'
end
def initialize(context_sections = '')
@model = 'gpt-4o'
@messages = [system_message(context_sections)]
end
def generate_response(input, previous_messages = [], role = 'user')
@messages += previous_messages
@messages << { 'role': role, 'content': input } if input.present?
response = request_gpt
JSON.parse(response['choices'][0]['message']['content'].strip)
end
private
def system_message(context_sections)
{
'role': 'system',
'content': system_content(context_sections)
}
end
def system_content(context_sections)
<<~SYSTEM_PROMPT_MESSAGE
You are a very enthusiastic customer support representative who loves to help people.
Your answers will always be formatted in valid JSON hash, as shown below. Never respond in non JSON format.
```
{
response: '' ,
context_ids: [ids],
}
```
response: will be the next response to the conversation
context_ids: will be an array of unique context IDs that were used to generate the answer. choose top 3.
The answers will be generated using the information provided at the end of the prompt under the context sections. You will not respond outside the context of the information provided in context sections.
If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response
----------------------------------
Context sections:
#{context_sections}
SYSTEM_PROMPT_MESSAGE
end
def request_gpt
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" }
body = { model: @model, messages: @messages, response_format: { type: 'json_object' } }.to_json
Rails.logger.info "Requesting Chat GPT with body: #{body}"
response = HTTParty.post("#{self.class.base_uri}/v1/chat/completions", headers: headers, body: body)
Rails.logger.info "Chat GPT response: #{response.body}"
JSON.parse(response.body)
end
end
+7 -2
View File
@@ -4,7 +4,6 @@ class Integrations::OpenaiBaseService
# sticking with 120000 to be safe
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000
API_URL = 'https://api.openai.com/v1/chat/completions'.freeze
GPT_MODEL = ENV.fetch('OPENAI_GPT_MODEL', 'gpt-4o-mini').freeze
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
@@ -81,6 +80,12 @@ class Integrations::OpenaiBaseService
self.class::CACHEABLE_EVENTS.include?(event_name)
end
def api_url
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1/chat/completions"
end
def make_api_call(body)
headers = {
'Content-Type' => 'application/json',
@@ -88,7 +93,7 @@ class Integrations::OpenaiBaseService
}
Rails.logger.info("OpenAI API request: #{body}")
response = HTTParty.post(API_URL, headers: headers, body: body)
response = HTTParty.post(api_url, headers: headers, body: body)
Rails.logger.info("OpenAI API response: #{response.body}")
return { error: response.parsed_response, error_code: response.code } unless response.success?
+3 -3
View File
@@ -33,8 +33,8 @@
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.6-next",
"@chatwoot/utils": "^0.0.47",
"@chatwoot/prosemirror-schema": "1.2.1",
"@chatwoot/utils": "^0.0.49",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
@@ -93,7 +93,7 @@
"vue-chartjs": "5.3.1",
"vue-datepicker-next": "^1.0.3",
"vue-dompurify-html": "^5.1.0",
"vue-i18n": "9.14.3",
"vue-i18n": "9.14.5",
"vue-letter": "^0.2.1",
"vue-multiselect": "3.1.0",
"vue-router": "~4.4.5",
+70 -39
View File
@@ -20,11 +20,11 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.1.6-next
version: 1.1.6-next
specifier: 1.2.1
version: 1.2.1
'@chatwoot/utils':
specifier: ^0.0.47
version: 0.0.47
specifier: ^0.0.49
version: 0.0.49
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
@@ -200,8 +200,8 @@ importers:
specifier: ^5.1.0
version: 5.1.0(vue@3.5.12(typescript@5.6.2))
vue-i18n:
specifier: 9.14.3
version: 9.14.3(vue@3.5.12(typescript@5.6.2))
specifier: 9.14.5
version: 9.14.5(vue@3.5.12(typescript@5.6.2))
vue-letter:
specifier: ^0.2.1
version: 0.2.1
@@ -403,11 +403,11 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.1.6-next':
resolution: {integrity: sha512-9lf7FrcED/B5oyGrMmIkbegkhlC/P0NrtXoX8k94YWRosZcx0hGVGhpTud+0Mhm7saAfGerKIwTRVDmmnxPuCA==}
'@chatwoot/prosemirror-schema@1.2.1':
resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==}
'@chatwoot/utils@0.0.47':
resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==}
'@chatwoot/utils@0.0.49':
resolution: {integrity: sha512-Co68VzaFtctTNYKY6y4izBBATvk6/8ZVtkyEP5HL72uhFDA11LrY5pqSh04HMoFyfdIU+uVPimfI45HAeso1IA==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -906,8 +906,8 @@ packages:
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
engines: {node: '>= 16'}
'@intlify/core-base@9.14.3':
resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
'@intlify/core-base@9.14.5':
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
engines: {node: '>= 16'}
'@intlify/eslint-plugin-vue-i18n@3.2.0':
@@ -920,16 +920,16 @@ packages:
resolution: {integrity: sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==}
engines: {node: '>= 16'}
'@intlify/message-compiler@9.14.3':
resolution: {integrity: sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==}
'@intlify/message-compiler@9.14.5':
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
engines: {node: '>= 16'}
'@intlify/shared@9.14.2':
resolution: {integrity: sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==}
engines: {node: '>= 16'}
'@intlify/shared@9.14.3':
resolution: {integrity: sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==}
'@intlify/shared@9.14.5':
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
engines: {node: '>= 16'}
'@isaacs/cliui@8.0.2':
@@ -940,6 +940,9 @@ packages:
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/gen-mapping@0.3.5':
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
engines: {node: '>=6.0.0'}
@@ -952,19 +955,29 @@ packages:
resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
engines: {node: '>=6.0.0'}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/set-array@1.2.1':
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
'@jridgewell/source-map@0.3.10':
resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@jridgewell/sourcemap-codec@1.5.4':
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@june-so/analytics-next@2.0.0':
resolution: {integrity: sha512-7uFP94JLD7mP4qLyOwn5HBs+CC8VlevOkiGd1CIYqPSjSRmbCOI+MVcJNlTAcpyNvMi9iUnWZ3jGVO5177Di4A==}
@@ -1993,8 +2006,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.14.1:
resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -4960,8 +4973,8 @@ packages:
peerDependencies:
eslint: '>=6.0.0'
vue-i18n@9.14.3:
resolution: {integrity: sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==}
vue-i18n@9.14.5:
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
engines: {node: '>= 16'}
peerDependencies:
vue: ^3.0.0
@@ -5237,7 +5250,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.1.6-next':
'@chatwoot/prosemirror-schema@1.2.1':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@@ -5255,7 +5268,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
'@chatwoot/utils@0.0.47':
'@chatwoot/utils@0.0.49':
dependencies:
date-fns: 2.30.0
@@ -5778,10 +5791,10 @@ snapshots:
'@intlify/message-compiler': 9.14.2
'@intlify/shared': 9.14.2
'@intlify/core-base@9.14.3':
'@intlify/core-base@9.14.5':
dependencies:
'@intlify/message-compiler': 9.14.3
'@intlify/shared': 9.14.3
'@intlify/message-compiler': 9.14.5
'@intlify/shared': 9.14.5
'@intlify/eslint-plugin-vue-i18n@3.2.0(eslint@8.57.0)':
dependencies:
@@ -5813,14 +5826,14 @@ snapshots:
'@intlify/shared': 9.14.2
source-map-js: 1.2.1
'@intlify/message-compiler@9.14.3':
'@intlify/message-compiler@9.14.5':
dependencies:
'@intlify/shared': 9.14.3
'@intlify/shared': 9.14.5
source-map-js: 1.2.1
'@intlify/shared@9.14.2': {}
'@intlify/shared@9.14.3': {}
'@intlify/shared@9.14.5': {}
'@isaacs/cliui@8.0.2':
dependencies:
@@ -5833,6 +5846,12 @@ snapshots:
'@istanbuljs/schema@0.1.3': {}
'@jridgewell/gen-mapping@0.3.12':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping': 0.3.29
optional: true
'@jridgewell/gen-mapping@0.3.5':
dependencies:
'@jridgewell/set-array': 1.2.1
@@ -5847,21 +5866,33 @@ snapshots:
'@jridgewell/resolve-uri@3.1.1': {}
'@jridgewell/resolve-uri@3.1.2':
optional: true
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/source-map@0.3.6':
'@jridgewell/source-map@0.3.10':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
optional: true
'@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/sourcemap-codec@1.5.4':
optional: true
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping@0.3.29':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
optional: true
'@june-so/analytics-next@2.0.0':
dependencies:
'@lukeed/uuid': 2.0.0
@@ -7078,7 +7109,7 @@ snapshots:
acorn@8.14.0: {}
acorn@8.14.1:
acorn@8.15.0:
optional: true
activestorage@5.2.8:
@@ -10124,8 +10155,8 @@ snapshots:
terser@5.33.0:
dependencies:
'@jridgewell/source-map': 0.3.6
acorn: 8.14.1
'@jridgewell/source-map': 0.3.10
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
optional: true
@@ -10479,10 +10510,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-i18n@9.14.3(vue@3.5.12(typescript@5.6.2)):
vue-i18n@9.14.5(vue@3.5.12(typescript@5.6.2)):
dependencies:
'@intlify/core-base': 9.14.3
'@intlify/shared': 9.14.3
'@intlify/core-base': 9.14.5
'@intlify/shared': 9.14.5
'@vue/devtools-api': 6.6.4
vue: 3.5.12(typescript@5.6.2)
@@ -210,4 +210,76 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
end
end
end
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/send_instructions' do
let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
params: { email: 'dev@example.com' }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated agent' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
headers: agent.create_new_auth_token,
params: { email: 'dev@example.com' },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin' do
it 'returns error when email is missing' do
post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
headers: admin.create_new_auth_token,
params: {},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Email is required')
end
it 'returns error when email is invalid' do
post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
headers: admin.create_new_auth_token,
params: { email: 'invalid-email' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid email format')
end
it 'returns error when custom domain is not configured' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/send_instructions",
headers: admin.create_new_auth_token,
params: { email: 'dev@example.com' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Custom domain is not configured')
end
it 'sends instructions successfully' do
mailer_double = instance_double(ActionMailer::MessageDelivery)
allow(PortalInstructionsMailer).to receive(:send_cname_instructions).and_return(mailer_double)
allow(mailer_double).to receive(:deliver_later)
post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
headers: admin.create_new_auth_token,
params: { email: 'dev@example.com' },
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['message']).to eq('Instructions sent successfully')
expect(PortalInstructionsMailer).to have_received(:send_cname_instructions)
.with(portal: portal_with_domain, recipient_email: 'dev@example.com')
end
end
end
end
@@ -26,7 +26,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:data].length).to eq(3)
expect(json_response[:payload].length).to eq(3)
end
end
@@ -38,7 +38,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:data].length).to eq(5)
expect(json_response[:payload].length).to eq(5)
end
it 'returns only enabled scenarios' do
@@ -49,8 +49,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:data].length).to eq(1)
expect(json_response[:data].first[:enabled]).to be(true)
expect(json_response[:payload].length).to eq(1)
expect(json_response[:payload].first[:enabled]).to be(true)
end
end
end
@@ -87,4 +87,73 @@ RSpec.describe 'Enterprise Portal API', type: :request do
end
end
end
describe 'GET /api/v1/accounts/{account.id}/portals/{portal.slug}/ssl_status' do
let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'returns error when custom domain is not configured' do
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/ssl_status",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Custom domain is not configured')
end
it 'returns SSL status when portal has ssl_settings' do
portal_with_domain.update(ssl_settings: {
'cf_status' => 'active',
'cf_verification_errors' => nil
})
mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
allow(mock_service).to receive(:perform).and_return({ data: [] })
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['status']).to eq('active')
expect(response.parsed_body['verification_errors']).to be_nil
end
it 'returns null values when portal has no ssl_settings' do
mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
allow(mock_service).to receive(:perform).and_return({ data: [] })
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['status']).to be_nil
expect(response.parsed_body['verification_errors']).to be_nil
end
it 'returns error when Cloudflare service returns errors' do
mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
allow(mock_service).to receive(:perform).and_return({ errors: ['API token not found'] })
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(['API token not found'])
end
end
end
end
@@ -0,0 +1,26 @@
require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
context 'when chatwoot_cloud is enabled' do
let(:account) { create(:account) }
let(:premium_account) { create(:account, custom_attributes: { plan_name: 'Startups' }) }
let(:imap_email_channel) { create(:channel_email, imap_enabled: true, account: account) }
let(:premium_imap_channel) { create(:channel_email, imap_enabled: true, account: premium_account) }
before do
premium_account.custom_attributes['plan_name'] = 'Startups'
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create!(value: 'cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create!(value: [{ 'name' => 'Hacker' }])
end
it 'skips inboxes with default plan' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel)
described_class.perform_now
end
it 'processes inboxes with premium plan' do
expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(premium_imap_channel)
described_class.perform_now
end
end
end
@@ -96,8 +96,10 @@ RSpec.describe Cloudflare::CheckCustomHostnameService do
expect(portal).to receive(:update).with(
ssl_settings: {
'cf_verification_id': 'verification-id',
'cf_verification_body': 'verification-body'
'cf_verification_id' => 'verification-id',
'cf_verification_body' => 'verification-body',
'cf_status' => nil,
'cf_verification_errors' => ''
}
)
@@ -54,7 +54,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
body: { hostname: 'test.example.com' }.to_json)
body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
@@ -72,7 +72,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
body: { hostname: 'test.example.com' }.to_json)
body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
@@ -92,17 +92,22 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
}
}
}
expect(portal.ssl_settings).to eq({})
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
body: { hostname: 'test.example.com' }.to_json)
body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
expect(portal).to receive(:update).with(ssl_settings: { 'cf_verification_id': 'verification-id',
'cf_verification_body': 'verification-body' })
result = service.perform
expect(portal.ssl_settings).to eq(
{
'cf_verification_id' => 'verification-id',
'cf_verification_body' => 'verification-body',
'cf_status' => nil,
'cf_verification_errors' => ''
}
)
expect(result).to eq(data: success_response['result'])
end
end
+120
View File
@@ -0,0 +1,120 @@
Delivered-To: robert.smith@gmail.com
Return-Path: <>
Subject: Delivery Status Notification (Failure)
From: Mail Delivery Subsystem <mailer-daemon@googlemail.com>
To: robert.smith@gmail.com
Content-Type: multipart/report; boundary="00000000000093475906390e1e9b"; report-type=delivery-status
Auto-Submitted: auto-replied
Message-ID: <686707c9.050a0220.302e7d.0cb2.GMR@mx.google.com>
Date: Thu, 03 Jul 2025 15:44:25 -0700 (PDT)
X-Failed-Recipients: alex.jones@fictionalcorp.com
--00000000000093475906390e1e9b
Content-Type: multipart/related; boundary="000000000000936d8406390e1ec7"
--000000000000936d8406390e1ec7
Content-Type: multipart/alternative; boundary="000000000000936d9006390e1ec8"
--000000000000936d9006390e1ec8
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
** Address not found **
Your message wasn't delivered to alex.jones@fictionalcorp.com because the address co=
uldn't be found or is unable to receive email.
Learn more here: https://support.google.com/mail/?p=3DNoSuchUser
The response was:
550 5.1.1 The email account that you tried to reach does not exist. Please =
try double-checking the recipient's email address for typos or unnecessary =
spaces. For more information, go to https://support.google.com/mail/?p=3DNo=
SuchUser d2e1a72fcca58-74ce2b0525csor332154b3a.0 - gsmtp
--000000000000936d9006390e1ec8
Content-Type: text/html; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
<html>
<head>
<style>
* {
font-family:Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
}
</style>
</head>
<body>
<table cellpadding=3D"0" cellspacing=3D"0" class=3D"email-wrapper" style=3D=
"padding-top:32px;background-color:#ffffff;"><tbody>
<tr><td>
<table cellpadding=3D0 cellspacing=3D0><tbody>
<tr><td style=3D"max-width:560px;padding:24px 24px 32px;background-color:#f=
afafa;border:1px solid #e0e0e0;border-radius:2px">
<img style=3D"padding:0 24px 16px 0;float:left" width=3D72 height=3D72 alt=
=3D"Error Icon" src=3D"cid:icon.png">
<table style=3D"min-width:272px;padding-top:8px"><tbody>
<tr><td><h2 style=3D"font-size:20px;color:#212121;font-weight:bold;margin:0=
">
Address not found
</h2></td></tr>
<tr><td style=3D"padding-top:20px;color:#757575;font-size:16px;font-weight:=
normal;text-align:left">
Your message wasn't delivered to <a style=3D'color:#212121;text-decoration:=
none'><b>alex.jones@fictionalcorp.com</b></a> because the address couldn't be found =
or is unable to receive email.
</td></tr>
<tr><td style=3D"padding-top:24px;color:#4285F4;font-size:14px;font-weight:=
bold;text-align:left">
<a style=3D"text-decoration:none" href=3D"https://support.google.com/mail/?=
p=3DNoSuchUser">LEARN MORE</a>
</td></tr>
</tbody></table>
</td></tr>
</tbody></table>
</td></tr>
<tr style=3D"border:none;background-color:#fff;font-size:12.8px;width:90%">
<td align=3D"left" style=3D"padding:48px 10px">
The response was:<br/>
<p style=3D"font-family:monospace">
550 5.1.1 The email account that you tried to reach does not exist. Please =
try double-checking the recipient's email address for typos or unnecessary =
spaces. For more information, go to https://support.google.com/mail/?p=3DNo=
SuchUser d2e1a72fcca58-74ce2b0525csor332154b3a.0 - gsmtp
</p>
</td>
</tr>
</tbody></table>
</body>
</html>
--000000000000936d9006390e1ec8--
--000000000000936d8406390e1ec7
Content-Type: image/png; name="icon.png"
Content-Disposition: attachment; filename="icon.png"
Content-Transfer-Encoding: base64
Content-ID: <icon.png>
--000000000000936d8406390e1ec7--
--00000000000093475906390e1e9b
Content-Type: message/delivery-status
--00000000000093475906390e1e9b
Content-Type: message/rfc822
Date: Thu, 03 Jul 2025 15:44:23 -0700
From: Robert Smith <robert.smith@gmail.com>
Reply-To: robert.smith@gmail.com
To: alex.jones@fictionalcorp.com
Message-ID: <conversation/93775311-9416-4428-964b-862001f8a8b2/messages/27855337@gmail.com>
In-Reply-To: <account/1/conversation/93775311-9416-4428-964b-862001f8a8b2@gmail.com>
Subject: Just checking in
Mime-Version: 1.0
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
<p>Hey, just checking in. Let me know if you got my earlier message.</p>
--00000000000093475906390e1e9b--
@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
let(:account) { create(:account) }
let(:suspended_account) { create(:account, status: 'suspended') }
let(:premium_account) { create(:account, custom_attributes: { plan_name: 'Startups' }) }
let(:imap_email_channel) do
create(:channel_email, imap_enabled: true, account: account)
@@ -16,6 +17,19 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
create(:channel_email, imap_enabled: false, account: account)
end
let(:reauth_required_channel) do
create(:channel_email, imap_enabled: true, account: account)
end
let(:premium_imap_channel) do
create(:channel_email, imap_enabled: true, account: premium_account)
end
before do
reauth_required_channel.prompt_reauthorization!
premium_account.custom_attributes['plan_name'] = 'Startups'
end
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
@@ -44,5 +58,11 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
described_class.perform_now
end
it 'skips channels requiring reauthorization' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(reauth_required_channel)
described_class.perform_now
end
end
end
@@ -253,5 +253,52 @@ RSpec.describe Integrations::Openai::ProcessorService do
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
context 'when testing endpoint configuration' do
let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do
it 'uses default OpenAI endpoint' do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: anything, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
context 'when CAPTAIN_OPEN_AI_ENDPOINT is configured' do
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/')
end
it 'uses custom endpoint' do
stub_request(:post, 'https://custom.azure.com/v1/chat/completions')
.with(body: anything, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
context 'when CAPTAIN_OPEN_AI_ENDPOINT has trailing slash' do
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/')
end
it 'properly handles trailing slash' do
stub_request(:post, 'https://custom.azure.com/v1/chat/completions')
.with(body: anything, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq({ :message => 'This is a reply from openai.' })
end
end
end
end
end
+8
View File
@@ -115,6 +115,14 @@ RSpec.describe Imap::ImapMailbox do
end
end
context 'when the email is bounced' do
let!(:bounced_mail) { create_inbound_email_from_fixture('bounced_gmail.eml') }
it 'processes the bounced email' do
expect { class_instance.process(bounced_mail.mail, channel) }.to change(Message, :count)
end
end
context 'when a reply for existing email conversation' do
let(:prev_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
let(:reply_mail) do
@@ -0,0 +1,50 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PortalInstructionsMailer do
describe 'send_cname_instructions' do
let!(:account) { create(:account) }
let!(:portal) { create(:portal, account: account, custom_domain: 'help.example.com') }
let(:recipient_email) { 'admin@example.com' }
let(:class_instance) { described_class.new }
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
end
context 'when target domain is available' do
it 'sends email with cname instructions' do
with_modified_env HELPCENTER_URL: 'https://help.chatwoot.com' do
mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
expect(mail.to).to eq([recipient_email])
expect(mail.subject).to eq("Finish setting up #{portal.custom_domain}")
expect(mail.body.encoded).to include('help.example.com CNAME help.chatwoot.com')
end
end
end
context 'when helpcenter url is not available but frontend url is' do
it 'uses frontend url as target domain' do
with_modified_env HELPCENTER_URL: '', FRONTEND_URL: 'https://app.chatwoot.com' do
mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
expect(mail.to).to eq([recipient_email])
expect(mail.body.encoded).to include('help.example.com CNAME app.chatwoot.com')
end
end
end
context 'when no target domain is available' do
it 'does not send email' do
with_modified_env HELPCENTER_URL: '', FRONTEND_URL: '' do
mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
expect(mail).to be_nil
end
end
end
end
end