Compare commits

..
Author SHA1 Message Date
Pranav e4dba113ce Agentbot as assignee 2025-07-14 16:49:12 -07:00
142 changed files with 342 additions and 5547 deletions
+1 -1
View File
@@ -283,7 +283,7 @@ Rails/RedundantActiveRecordAllMethod:
Enabled: false
Layout/TrailingEmptyLines:
Enabled: true
Enabled: false
Style/SafeNavigationChainLength:
Enabled: false
@@ -30,14 +30,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end
def facebook_pages
pages = []
fb_pages = fb_object.get_connections('me', 'accounts')
pages.concat(fb_pages)
while fb_pages.respond_to?(:next_page) && (next_page = fb_pages.next_page)
fb_pages = next_page
pages.concat(fb_pages)
end
@page_details = mark_already_existing_facebook_pages(pages)
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
end
def set_instagram_id(page_access_token, facebook_channel)
@@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {})
:scheduled_at, audience: [:type, :id], trigger_rules: {})
end
end
@@ -1,8 +1,8 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation
# assigns agent/team/bot to a conversation
def create
if params.key?(:assignee_id)
set_agent
set_assignee
elsif params.key?(:team_id)
set_team
else
@@ -12,18 +12,26 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
private
def set_agent
@agent = Current.account.users.find_by(id: params[:assignee_id])
@conversation.assignee = @agent
def set_assignee
@assignee = case params[:assignee_type]
when 'AgentBot'
Current.account.agent_bots.find_by(id: params[:assignee_id])
else
Current.account.users.find_by(id: params[:assignee_id])
end
@conversation.assignee = @assignee
@conversation.save!
render_agent
render_assignee
end
def render_agent
if @agent.nil?
def render_assignee
if @assignee.nil?
render json: nil
elsif @assignee.is_a?(AgentBot)
render json: @assignee.webhook_data
else
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @assignee }
end
end
@@ -11,4 +11,4 @@ class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Bas
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
end
@@ -18,4 +18,4 @@ class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::O
render json: { success: false }, status: :unprocessable_entity
end
end
end
end
@@ -1,64 +0,0 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :validate_feature_enabled!
# POST /api/v1/accounts/:account_id/whatsapp/authorization
# Handles the embedded signup callback data from the Facebook SDK
def create
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue StandardError => e
render_error_response(e)
end
private
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id]
)
service.perform
end
def render_success_response(inbox)
render json: {
success: true,
id: inbox.id,
name: inbox.name,
channel_type: 'whatsapp'
}
end
def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
success: false,
error: error.message
}, status: :unprocessable_entity
end
def validate_feature_enabled!
return if Current.account.feature_whatsapp_embedded_signup?
render json: {
success: false,
error: 'WhatsApp embedded signup is not enabled for this account'
}, status: :forbidden
end
def validate_embedded_signup_params!
missing_params = []
missing_params << 'code' if params[:code].blank?
missing_params << 'business_id' if params[:business_id].blank?
missing_params << 'waba_id' if params[:waba_id].blank?
return if missing_params.empty?
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
end
end
@@ -47,7 +47,6 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.sso_config.merge!(sso_config_params) if sso_config_params.present? && sso_feature_enabled?
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -96,14 +95,6 @@ class Api::V1::AccountsController < Api::BaseController
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end
def sso_config_params
params.permit(sso_config: [:enabled, :provider_name, :login_url, :logout_url, :secret_key, :token_expiry])[:sso_config]
end
def sso_feature_enabled?
@account.feature_enabled?('sso')
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end
-2
View File
@@ -67,8 +67,6 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
@@ -33,4 +33,4 @@ class Notion::CallbacksController < OauthCallbackController
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
end
@@ -39,10 +39,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
@@ -7,9 +7,8 @@
class SuperAdmin::ApplicationController < Administrate::ApplicationController
include ActionView::Helpers::TagHelper
include ActionView::Context
include SuperAdmin::NavigationHelper
helper_method :render_vue_component, :settings_open?, :settings_pages
helper_method :render_vue_component
# authenticiation done via devise : SuperAdmin Model
before_action :authenticate_super_admin!
@@ -1,16 +0,0 @@
module SuperAdmin::NavigationHelper
def settings_open?
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
end
def settings_pages
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
attrs['config_key'].present? && attrs['enabled']
end
# Add general at the beginning
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
general_feature + features.to_a
end
end
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class WhatsappChannel extends ApiClient {
constructor() {
super('whatsapp', { accountScoped: true });
}
createEmbeddedSignup(params) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
}
}
export default new WhatsappChannel();
@@ -1,37 +0,0 @@
<script setup>
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -1,48 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONE_OFF,
});
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
};
const handleClose = () => emit('close');
</script>
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</template>
@@ -1,357 +0,0 @@
<script setup>
import { reactive, computed, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
};
const initialState = {
title: '',
inboxId: null,
templateId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const processedParams = ref({});
const rules = {
title: { required, minLength: minLength(1) },
inboxId: { required },
templateId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
return {
value: template.id,
label: `${friendlyName} (${template.language || 'en'})`,
template: template,
};
});
});
const selectedTemplate = computed(() => {
if (!state.templateId) return null;
return templateOptions.value.find(option => option.value === state.templateId)
?.template;
});
const templateString = computed(() => {
if (!selectedTemplate.value) return '';
try {
return (
selectedTemplate.value.components?.find(
component => component.type === 'BODY'
)?.text || ''
);
} catch (error) {
return '';
}
});
const processedString = computed(() => {
if (!templateString.value) return '';
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
return processedParams.value[variable] || `{{${variable}}}`;
});
});
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
template: getErrorMessage('templateId', 'TEMPLATE'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const hasRequiredTemplateParams = computed(() => {
const params = Object.values(processedParams.value);
return params.length === 0 || params.every(param => param.trim() !== '');
});
const isSubmitDisabled = computed(
() => v$.value.$invalid || !hasRequiredTemplateParams.value
);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
processedParams.value = {};
v$.value.$reset();
};
const handleCancel = () => emit('cancel');
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) {
processedParams.value = {};
return;
}
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = processedParams.value[variable] || '';
return acc;
}, {});
};
const prepareCampaignDetails = () => {
// Find the selected template to get its content
const currentTemplate = selectedTemplate.value;
// Extract template content - this should be the template message body
const templateContent = templateString.value;
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
name: currentTemplate?.name || '',
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: processedParams.value,
};
return {
title: state.title,
message: templateContent,
template_params: templateParams,
inbox_id: state.inboxId,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
};
};
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
// Reset template selection when inbox changes
watch(
() => state.inboxId,
() => {
state.templateId = null;
processedParams.value = {};
}
);
// Generate variables when template changes
watch(
() => state.templateId,
() => {
generateVariables();
}
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
</label>
<ComboBox
id="template"
v-model="state.templateId"
:options="templateOptions"
:has-error="!!formErrors.template"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
:message="formErrors.template"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
</p>
</div>
<!-- Template Preview -->
<div
v-if="selectedTemplate"
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
>
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ selectedTemplate.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
{{ selectedTemplate.language || 'en' }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md bg-n-alpha-black3">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ processedString }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
{{ selectedTemplate.category || 'UTILITY' }}
</div>
</div>
<!-- Template Variables -->
<div
v-if="Object.keys(processedParams).length > 0"
class="flex flex-col gap-3"
>
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<div
v-for="(value, key) in processedParams"
:key="key"
class="flex gap-2 items-center"
>
<Input
v-model="processedParams[key]"
type="text"
class="flex-1"
:placeholder="
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex gap-3 justify-between items-center w-full">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -52,9 +52,9 @@ const handleBreadcrumbClick = item => {
<template>
<section
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
class="my-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
>
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full">
<header class="mb-7 sticky top-0 z-10 bg-n-background">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
@@ -1,21 +0,0 @@
<script setup>
import AddNewRulesDialog from './AddNewRulesDialog.vue';
</script>
<template>
<Story
title="Captain/Assistant/AddNewRulesDialog"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-4 py-4 bg-n-background h-[200px]">
<AddNewRulesDialog
button-label="Add a guardrail"
placeholder="Type in another guardrail..."
confirm-label="Create"
cancel-label="Cancel"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,77 +0,0 @@
<script setup>
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
defineProps({
placeholder: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
confirmLabel: {
type: String,
default: '',
},
cancelLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['add']);
const modelValue = defineModel({
type: String,
default: '',
});
const [showPopover, togglePopover] = useToggle();
const onClickAdd = () => {
if (!modelValue.value?.trim()) return;
emit('add', modelValue.value.trim());
modelValue.value = '';
togglePopover(false);
};
const onClickCancel = () => {
togglePopover(false);
};
</script>
<template>
<div class="inline-flex relative">
<Button
:label="buttonLabel"
sm
slate
class="flex-shrink-0"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
class="absolute w-[26.5rem] top-9 z-50 ltr:left-0 rtl:right-0 flex flex-col gap-5 bg-n-alpha-3 backdrop-blur-[100px] p-4 rounded-xl border border-n-weak shadow-md"
>
<InlineInput
v-model="modelValue"
:placeholder="placeholder"
@keyup.enter="onClickAdd"
/>
<div class="flex gap-2 justify-between">
<Button
:label="cancelLabel"
sm
link
slate
class="h-10 hover:!no-underline"
@click="onClickCancel"
/>
<Button :label="confirmLabel" sm @click="onClickAdd" />
</div>
</div>
</div>
</template>
@@ -1,19 +0,0 @@
<script setup>
import AddNewRulesInput from './AddNewRulesInput.vue';
</script>
<template>
<Story
title="Captain/Assistant/AddNewRulesInput"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-6 py-4 bg-n-background">
<AddNewRulesInput
placeholder="Type in another response guideline..."
label="Add and save (↵)"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,51 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
defineProps({
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
});
const emit = defineEmits(['add']);
const modelValue = defineModel({
type: String,
default: '',
});
const onClickAdd = () => {
if (!modelValue.value?.trim()) return;
emit('add', modelValue.value.trim());
modelValue.value = '';
};
</script>
<template>
<div
class="flex py-3 ltr:pl-3 h-16 rtl:pr-3 ltr:pr-4 rtl:pl-4 items-center gap-3 rounded-xl bg-n-solid-2 outline-1 outline outline-n-container"
>
<Icon icon="i-lucide-plus" class="text-n-slate-10 size-5 flex-shrink-0" />
<InlineInput
v-model="modelValue"
:placeholder="placeholder"
@keyup.enter="onClickAdd"
/>
<Button
:label="label"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="onClickAdd"
/>
</div>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { computed } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
allItems: {
type: Array,
required: true,
},
selectAllLabel: {
type: String,
default: '',
},
selectedCountLabel: {
type: String,
default: '',
},
deleteLabel: {
type: String,
default: 'Delete',
},
});
const emit = defineEmits(['bulkDelete']);
const modelValue = defineModel({
type: Set,
default: () => new Set(),
});
const selectedCount = computed(() => modelValue.value.size);
const totalCount = computed(() => props.allItems.length);
const hasSelected = computed(() => selectedCount.value > 0);
const isIndeterminate = computed(
() => hasSelected.value && selectedCount.value < totalCount.value
);
const allSelected = computed(
() => totalCount.value > 0 && selectedCount.value === totalCount.value
);
const bulkCheckboxState = computed({
get: () => allSelected.value,
set: shouldSelectAll => {
const newSelectedIds = shouldSelectAll
? new Set(props.allItems.map(item => item.id))
: new Set();
modelValue.value = newSelectedIds;
},
});
</script>
<template>
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
enter-to-class="opacity-100 transform translate-x-0"
leave-active-class="hidden opacity-0"
>
<div
v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckboxState"
:indeterminate="isIndeterminate"
/>
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
{{ selectAllLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
{{ selectedCountLabel }}
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
:label="deleteLabel"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="emit('bulkDelete')"
/>
</slot>
</div>
</div>
<div v-else class="flex items-center gap-3">
<slot name="default-actions" />
</div>
</transition>
</template>
@@ -1,37 +0,0 @@
<script setup>
import RuleCard from './RuleCard.vue';
const sampleRules = [
{ id: 1, content: 'Block sensitive personal information', selectable: true },
{ id: 2, content: 'Reject offensive language', selectable: true },
{ id: 3, content: 'Deflect legal or medical advice', selectable: true },
];
</script>
<template>
<Story
title="Captain/Assistant/RuleCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Selectable List">
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
<RuleCard
v-for="rule in sampleRules"
:id="rule.id"
:key="rule.id"
:content="rule.content"
:selectable="rule.selectable"
@select="id => console.log('Selected rule', id)"
@edit="id => console.log('Edit', id)"
@delete="id => console.log('Delete', id)"
/>
</div>
</Variant>
<Variant title="Non-Selectable">
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
<RuleCard id="4" content="Replies should be friendly and clear." />
</div>
</Variant>
</Story>
</template>
@@ -1,94 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
content: {
type: String,
required: true,
},
selectable: {
type: Boolean,
default: false,
},
isSelected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'hover', 'edit', 'delete']);
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const isEditing = ref(false);
const editedContent = ref(props.content);
// Local content to display to avoid flicker until parent prop updates on inline edit
const localContent = ref(props.content);
// Keeps localContent in sync when parent updates content prop
watch(
() => props.content,
newVal => {
localContent.value = newVal;
}
);
const startEdit = () => {
isEditing.value = true;
editedContent.value = props.content;
};
const saveEdit = () => {
isEditing.value = false;
// Update local content
localContent.value = editedContent.value;
emit('edit', { id: props.id, content: editedContent.value });
};
</script>
<template>
<CardLayout
selectable
class="relative [&>div]:!py-5 [&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4"
layout="row"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-6 ltr:left-3 rtl:right-3">
<Checkbox v-model="modelValue" />
</div>
<InlineInput
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">
{{ localContent }}
</span>
<div class="flex items-center gap-2">
<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>
</CardLayout>
</template>
@@ -1,46 +0,0 @@
<script setup>
import SuggestedRules from './SuggestedRules.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const guidelinesExample = [
{
content:
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
},
{
content:
'Reject queries that include offensive, discriminatory, or threatening language.',
},
{
content:
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
},
];
</script>
<template>
<Story
title="Captain/Assistant/SuggestedRules"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Suggested Rules List">
<div class="px-20 py-4 bg-n-background">
<SuggestedRules
title="Example response guidelines"
:items="guidelinesExample"
>
<template #default="{ item }">
<span class="text-sm text-n-slate-12">{{ item.content }}</span>
<Button
label="Add this"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
/>
</template>
</SuggestedRules>
</div>
</Variant>
</Story>
</template>
@@ -1,63 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
title: {
type: String,
default: '',
},
items: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['add', 'close']);
const { t } = useI18n();
const onAddClick = () => {
emit('add');
};
const onClickClose = () => {
emit('close');
};
</script>
<template>
<div
class="flex flex-col items-start self-stretch rounded-xl w-full overflow-hidden border border-dashed border-n-strong"
>
<div class="flex items-center justify-between w-full gap-3 px-4 pb-1 pt-4">
<div class="flex items-center gap-3">
<h5 class="text-sm font-medium text-n-slate-11">{{ title }}</h5>
<span class="h-3 w-px bg-n-weak" />
<Button
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.ADD')"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="onAddClick"
/>
</div>
<Button
ghost
xs
slate
icon="i-lucide-x"
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="onClickClose"
/>
</div>
<div
class="flex flex-col items-start divide-y divide-n-strong divide-dashed w-full"
>
<div v-for="item in items" :key="item.content" class="w-full px-4 py-4">
<slot :item="item" />
</div>
</div>
</div>
</template>
@@ -331,11 +331,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.SMS'),
to: accountScopedRoute('campaigns_sms_index'),
},
{
name: 'WhatsApp',
label: t('SIDEBAR.WHATSAPP'),
to: accountScopedRoute('campaigns_whatsapp_index'),
},
],
},
{
-4
View File
@@ -4,7 +4,6 @@ export const FEATURE_FLAGS = {
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
@@ -37,9 +36,7 @@ export const FEATURE_FLAGS = {
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
CAPTAIN_V2: 'captain_integration_v2',
SSO: 'sso',
};
export const PREMIUM_FEATURES = [
@@ -49,5 +46,4 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2,
FEATURE_FLAGS.SSO,
];
@@ -146,7 +146,6 @@
"SEND_WEBHOOK_EVENT": "Send Webhook Event",
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
@@ -137,70 +137,6 @@
}
}
},
"WHATSAPP": {
"HEADER_TITLE": "WhatsApp campaigns",
"NEW_CAMPAIGN": "Create campaign",
"EMPTY_STATE": {
"TITLE": "No WhatsApp campaigns are available",
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
},
"CARD": {
"STATUS": {
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
"CAMPAIGN_DETAILS": {
"SENT_FROM": "Sent from",
"ON": "on"
}
},
"CREATE": {
"TITLE": "Create WhatsApp campaign",
"CANCEL_BUTTON_TEXT": "Cancel",
"CREATE_BUTTON_TEXT": "Create",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
},
"INBOX": {
"LABEL": "Select Inbox",
"PLACEHOLDER": "Select Inbox",
"ERROR": "Inbox is required"
},
"TEMPLATE": {
"LABEL": "WhatsApp Template",
"PLACEHOLDER": "Select a template",
"INFO": "Select a template to use for this campaign.",
"ERROR": "Template is required",
"PREVIEW_TITLE": "Process {templateName}",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLES_LABEL": "Variables",
"VARIABLE_PLACEHOLDER": "Enter value for {variable}"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Please select the time",
"ERROR": "Scheduled time is required"
},
"BUTTONS": {
"CREATE": "Create",
"CANCEL": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "WhatsApp campaign created successfully",
"ERROR_MESSAGE": "There was an error. Please try again."
}
}
}
},
"CONFIRM_DELETE": {
"TITLE": "Are you sure to delete?",
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
@@ -123,50 +123,6 @@
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
}
},
"SSO": {
"TITLE": "Single Sign-On",
"DESCRIPTION": "Configure SSO authentication for your organization",
"FORM": {
"ENABLE_SSO": {
"LABEL": "Enable SSO",
"HELP": "Allow users to log in using Single Sign-On"
},
"PROVIDER_NAME": {
"LABEL": "Provider Name",
"PLACEHOLDER": "e.g., 'Okta', 'Azure AD', 'Google Workspace'",
"ERROR": "Please enter a provider name"
},
"LOGIN_URL": {
"LABEL": "SSO Login URL",
"PLACEHOLDER": "https://your-sso-provider.com/login",
"ERROR": "Please enter a valid SSO login URL",
"HELP": "Users will be redirected to this URL for authentication"
},
"LOGOUT_URL": {
"LABEL": "SSO Logout URL (Optional)",
"PLACEHOLDER": "https://your-sso-provider.com/logout",
"HELP": "Users will be redirected to this URL after logout"
},
"SECRET_KEY": {
"LABEL": "Secret Key",
"PLACEHOLDER": "Enter your SSO secret key",
"ERROR": "Please enter a secret key",
"HELP": "Secret key used to verify SSO tokens"
},
"TOKEN_EXPIRY": {
"LABEL": "Token Expiry (minutes)",
"PLACEHOLDER": "5",
"ERROR": "Please enter a valid expiry time",
"HELP": "How long SSO tokens remain valid"
},
"ERROR": "Please fix the form errors"
},
"SUBMIT": "Update SSO Settings",
"UPDATE": {
"SUCCESS": "SSO settings updated successfully",
"ERROR": "Failed to update SSO settings"
}
},
"UPDATE_CHATWOOT": "An update {latestChatwootVersion} for Chatwoot is available. Please update your instance.",
"LEARN_MORE": "Learn more",
"PAYMENT_PENDING": "Your payment is pending. Please update your payment information to continue using Chatwoot",
@@ -222,17 +222,10 @@
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
"LABEL": "API Provider",
"WHATSAPP_EMBEDDED": "WhatsApp Business",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud",
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"360_DIALOG": "360Dialog"
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
},
"INBOX_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
@@ -271,28 +264,6 @@
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
@@ -521,100 +521,6 @@
"TITLE": "Captain Assistant",
"NOTE": "Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
}
},
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
"BREADCRUMB": {
"TITLE": "Guardrails"
},
"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 guardrails",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"SAVE": "Add and save (↵)",
"PLACEHOLDER": "Type in another guardrail..."
},
"NEW": {
"TITLE": "Add a guardrail",
"CREATE": "Create",
"CANCEL": "Cancel",
"PLACEHOLDER": "Type in another guardrail...",
"TEST_ALL": "Test all"
}
},
"LIST": {
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
"API": {
"ADD": {
"SUCCESS": "Guardrails added successfully",
"ERROR": "There was an error adding guardrails, please try again."
},
"UPDATE": {
"SUCCESS": "Guardrails updated successfully",
"ERROR": "There was an error updating guardrails, please try again."
},
"DELETE": {
"SUCCESS": "Guardrails deleted successfully",
"ERROR": "There was an error deleting guardrails, please try again."
}
}
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response Guidelines",
"DESCRIPTION": "The vibe and structure of your assistants replies—clear and friendly? Short and snappy? Detailed and formal?",
"BREADCRUMB": {
"TITLE": "Response Guidelines"
},
"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 response guidelines",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"SAVE": "Add and save (↵)",
"PLACEHOLDER": "Type in another response guideline..."
},
"NEW": {
"TITLE": "Add a response guideline",
"CREATE": "Create",
"CANCEL": "Cancel",
"PLACEHOLDER": "Type in another response guideline...",
"TEST_ALL": "Test all"
}
},
"LIST": {
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
"API": {
"ADD": {
"SUCCESS": "Response Guidelines added successfully",
"ERROR": "There was an error adding response guidelines, please try again."
},
"UPDATE": {
"SUCCESS": "Response Guidelines updated successfully",
"ERROR": "There was an error updating response guidelines, please try again."
},
"DELETE": {
"SUCCESS": "Response Guidelines deleted successfully",
"ERROR": "There was an error deleting response guidelines, please try again."
}
}
}
},
"DOCUMENTS": {
@@ -20,9 +20,6 @@
"BUSINESS_ACCOUNTS_ONLY": "Please use your company email address to login",
"NO_ACCOUNT_FOUND": "We couldn't find an account for your email address."
},
"SSO": {
"BUTTON_TEXT": "Login with {provider}"
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login"
@@ -319,7 +319,6 @@
"CSAT": "CSAT",
"LIVE_CHAT": "Live Chat",
"SMS": "SMS",
"WHATSAPP": "WhatsApp",
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
"ONE_OFF": "One off",
@@ -3,7 +3,6 @@ import { frontendURL } from 'dashboard/helper/URLHelper.js';
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const meta = {
@@ -51,15 +50,6 @@ const campaignsRoutes = {
meta,
component: SMSCampaignsPage,
},
{
path: 'whatsapp',
name: 'campaigns_whatsapp_index',
meta: {
...meta,
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
},
component: WhatsAppCampaignsPage,
},
],
},
],
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
@@ -24,8 +25,8 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const [showLiveChatCampaignDialog, toggleLiveChatCampaignDialog] = useToggle();
const liveChatCampaigns = computed(
() => getters['campaigns/getLiveChatCampaigns'].value
const liveChatCampaigns = computed(() =>
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONGOING)
);
const hasNoLiveChatCampaigns = computed(
@@ -58,7 +59,7 @@ const handleDelete = campaign => {
<div
v-if="isFetchingCampaigns"
class="flex justify-center items-center py-10 text-n-slate-11"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
@@ -22,7 +23,9 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const SMSCampaigns = computed(() => getters['campaigns/getSMSCampaigns'].value);
const SMSCampaigns = computed(() =>
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONE_OFF)
);
const hasNoSMSCampaigns = computed(
() => SMSCampaigns.value?.length === 0 && !isFetchingCampaigns.value
@@ -1,74 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const selectedCampaign = ref(null);
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const WhatsAppCampaigns = computed(
() => getters['campaigns/getWhatsAppCampaigns'].value
);
const hasNoWhatsAppCampaigns = computed(
() => WhatsAppCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.WHATSAPP.HEADER_TITLE')"
:button-label="t('CAMPAIGN.WHATSAPP.NEW_CAMPAIGN')"
@click="toggleWhatsAppCampaignDialog()"
@close="toggleWhatsAppCampaignDialog(false)"
>
<template #action>
<WhatsAppCampaignDialog
v-if="showWhatsAppCampaignDialog"
@close="toggleWhatsAppCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoWhatsAppCampaigns"
:campaigns="WhatsAppCampaigns"
@delete="handleDelete"
/>
<WhatsAppCampaignEmptyState
v-else
:title="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>
@@ -1,296 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import SuggestedRules from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
import AddNewRulesInput from 'dashboard/components-next/captain/assistant/AddNewRulesInput.vue';
import AddNewRulesDialog from 'dashboard/components-next/captain/assistant/AddNewRulesDialog.vue';
import RuleCard from 'dashboard/components-next/captain/assistant/RuleCard.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
);
const searchQuery = ref('');
const newInlineRule = ref('');
const newDialogRule = 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.GUARDRAILS.BREADCRUMB.TITLE') },
];
});
const guardrailsContent = computed(() => assistant.value?.guardrails || []);
const displayGuardrails = computed(() =>
guardrailsContent.value.map((c, idx) => ({ id: idx, content: c }))
);
const guardrailsExample = [
{
id: 1,
content:
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
},
{
id: 2,
content:
'Reject queries that include offensive, discriminatory, or threatening language.',
},
{
id: 3,
content:
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
},
];
const filteredGuardrails = computed(() => {
const query = searchQuery.value.trim();
if (!query) return displayGuardrails.value;
return picoSearch(displayGuardrails.value, query, ['content']);
});
const shouldShowSuggestedRules = computed(() => {
return uiSettings.value?.show_guardrails_suggestions !== false;
});
const closeSuggestedRules = () => {
updateUISettings({ show_guardrails_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 handleRuleHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const buildSelectedCountLabel = computed(() => {
const count = displayGuardrails.value.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.UNSELECT_ALL', { count })
: t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const saveGuardrails = async list => {
await store.dispatch('captainAssistants/update', {
id: assistantId,
assistant: { guardrails: list },
});
};
const addGuardrail = async content => {
try {
const newGuardrails = [...guardrailsContent.value, content];
await saveGuardrails(newGuardrails);
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.SUCCESS'));
} catch (error) {
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.ERROR'));
}
};
const editGuardrail = async ({ id, content }) => {
try {
const updated = [...guardrailsContent.value];
updated[id] = content;
await saveGuardrails(updated);
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.UPDATE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.UPDATE.ERROR'));
}
};
const deleteGuardrail = async id => {
try {
const updated = guardrailsContent.value.filter((_, idx) => idx !== id);
await saveGuardrails(updated);
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.ERROR'));
}
};
const bulkDeleteGuardrails = async () => {
try {
if (bulkSelectedIds.value.size === 0) return;
const updated = guardrailsContent.value.filter(
(_, idx) => !bulkSelectedIds.value.has(idx)
);
await saveGuardrails(updated);
bulkSelectedIds.value.clear();
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.ERROR'));
}
};
const addAllExample = () => {
updateUISettings({ show_guardrails_suggestions: false });
try {
const exampleContents = guardrailsExample.map(example => example.content);
const newGuardrails = [...guardrailsContent.value, ...exampleContents];
saveGuardrails(newGuardrails);
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.ERROR'));
}
};
</script>
<template>
<SettingsPageLayout
:breadcrumb-items="breadcrumbItems"
:is-fetching="isFetching"
>
<template #body>
<SettingsHeader
:heading="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.TITLE')"
:description="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.DESCRIPTION')"
/>
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
<SuggestedRules
:title="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.TITLE')"
:items="guardrailsExample"
@add="addAllExample"
@close="closeSuggestedRules"
>
<template #default="{ item }">
<div class="flex items-center justify-between w-full">
<span class="text-sm text-n-slate-12">
{{ item.content }}
</span>
<Button
:label="
$t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.ADD_SINGLE')
"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="addGuardrail(item.content)"
/>
</div>
</template>
</SuggestedRules>
</div>
<div class="flex mt-7 flex-col gap-4">
<div class="flex justify-between items-center">
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="displayGuardrails"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="
$t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.BULK_DELETE_BUTTON')
"
@bulk-delete="bulkDeleteGuardrails"
>
<template #default-actions>
<AddNewRulesDialog
v-model="newDialogRule"
:placeholder="
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.PLACEHOLDER')
"
:button-label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.TITLE')"
:confirm-label="
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.CREATE')
"
:cancel-label="
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.CANCEL')
"
@add="addGuardrail"
/>
<!-- Will enable this feature in future -->
<!-- <div class="h-4 w-px bg-n-strong" />
<Button
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.TEST_ALL')"
xs
ghost
slate
class="!text-sm"
/> -->
</template>
</BulkSelectBar>
<div
v-if="displayGuardrails.length && bulkSelectedIds.size === 0"
class="max-w-[22.5rem] w-full min-w-0"
>
<Input
v-model="searchQuery"
:placeholder="
t('CAPTAIN.ASSISTANTS.GUARDRAILS.LIST.SEARCH_PLACEHOLDER')
"
/>
</div>
</div>
<div v-if="displayGuardrails.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<RuleCard
v-for="guardrail in filteredGuardrails"
:id="guardrail.id"
:key="guardrail.id"
:content="guardrail.content"
:is-selected="bulkSelectedIds.has(guardrail.id)"
:selectable="
hoveredCard === guardrail.id || bulkSelectedIds.size > 0
"
@select="handleRuleSelect"
@edit="editGuardrail"
@delete="deleteGuardrail"
@hover="isHovered => handleRuleHover(isHovered, guardrail.id)"
/>
</div>
<AddNewRulesInput
v-model="newInlineRule"
:placeholder="
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.PLACEHOLDER')
"
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.SAVE')"
@add="addGuardrail"
/>
</div>
</template>
</SettingsPageLayout>
</template>
@@ -1,318 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import SuggestedRules from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
import AddNewRulesInput from 'dashboard/components-next/captain/assistant/AddNewRulesInput.vue';
import AddNewRulesDialog from 'dashboard/components-next/captain/assistant/AddNewRulesDialog.vue';
import RuleCard from 'dashboard/components-next/captain/assistant/RuleCard.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
);
const searchQuery = ref('');
const newInlineRule = ref('');
const newDialogRule = 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.RESPONSE_GUIDELINES.TITLE') },
];
});
const guidelinesContent = computed(
() => assistant.value?.response_guidelines || []
);
const displayGuidelines = computed(() =>
guidelinesContent.value.map((c, idx) => ({ id: idx, content: c }))
);
const guidelinesExample = [
{
id: 1,
content:
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
},
{
id: 2,
content:
'Reject queries that include offensive, discriminatory, or threatening language.',
},
{
id: 3,
content:
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
},
];
const filteredGuidelines = computed(() => {
const query = searchQuery.value.trim();
if (!query) return displayGuidelines.value;
return picoSearch(displayGuidelines.value, query, ['content']);
});
const shouldShowSuggestedRules = computed(() => {
return uiSettings.value?.show_response_guidelines_suggestions !== false;
});
const closeSuggestedRules = () => {
updateUISettings({ show_response_guidelines_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 handleRuleHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const buildSelectedCountLabel = computed(() => {
const count = displayGuidelines.value.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.UNSELECT_ALL', {
count,
})
: t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.SELECT_ALL', {
count,
});
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const saveGuidelines = async list => {
await store.dispatch('captainAssistants/update', {
id: assistantId,
assistant: { response_guidelines: list },
});
};
const addGuideline = async content => {
try {
const updated = [...guidelinesContent.value, content];
await saveGuidelines(updated);
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.ERROR'));
}
};
const editGuideline = async ({ id, content }) => {
try {
const updated = [...guidelinesContent.value];
updated[id] = content;
await saveGuidelines(updated);
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.UPDATE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.UPDATE.ERROR'));
}
};
const deleteGuideline = async id => {
try {
const updated = guidelinesContent.value.filter((_, idx) => idx !== id);
await saveGuidelines(updated);
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.ERROR'));
}
};
const bulkDeleteGuidelines = async () => {
try {
if (bulkSelectedIds.value.size === 0) return;
const updated = guidelinesContent.value.filter(
(_, idx) => !bulkSelectedIds.value.has(idx)
);
await saveGuidelines(updated);
bulkSelectedIds.value.clear();
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.ERROR'));
}
};
const addAllExample = async () => {
updateUISettings({ show_response_guidelines_suggestions: false });
try {
const exampleContents = guidelinesExample.map(example => example.content);
const newGuidelines = [...guidelinesContent.value, ...exampleContents];
await saveGuidelines(newGuidelines);
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.ERROR'));
}
};
</script>
<template>
<SettingsPageLayout
:breadcrumb-items="breadcrumbItems"
:is-fetching="isFetching"
>
<template #body>
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
:description="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.DESCRIPTION')"
/>
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
<SuggestedRules
:title="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
:items="guidelinesExample"
@add="addAllExample"
@close="closeSuggestedRules"
>
<template #default="{ item }">
<div class="flex items-center justify-between w-full">
<span class="text-sm text-n-slate-12">
{{ item.content }}
</span>
<Button
:label="
t(
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.ADD_SINGLE'
)
"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="addGuideline(item.content)"
/>
</div>
</template>
</SuggestedRules>
</div>
<div class="flex mt-7 flex-col gap-4">
<div class="flex justify-between items-center">
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="displayGuidelines"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="
$t(
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.BULK_DELETE_BUTTON'
)
"
@bulk-delete="bulkDeleteGuidelines"
>
<template #default-actions>
<AddNewRulesDialog
v-model="newDialogRule"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.PLACEHOLDER'
)
"
:button-label="
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.TITLE')
"
:confirm-label="
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.CREATE')
"
:cancel-label="
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.CANCEL')
"
@add="addGuideline"
/>
<!-- Will enable this feature in future -->
<!-- <div class="h-4 w-px bg-n-strong" />
<Button
:label="
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.TEST_ALL')
"
sm
ghost
slate
/> -->
</template>
</BulkSelectBar>
<div
v-if="displayGuidelines.length && bulkSelectedIds.size === 0"
class="max-w-[22.5rem] w-full min-w-0"
>
<Input
v-model="searchQuery"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.LIST.SEARCH_PLACEHOLDER'
)
"
/>
</div>
</div>
<div v-if="displayGuidelines.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<RuleCard
v-for="guideline in filteredGuidelines"
:id="guideline.id"
:key="guideline.id"
:content="guideline.content"
:is-selected="bulkSelectedIds.has(guideline.id)"
:selectable="
hoveredCard === guideline.id || bulkSelectedIds.size > 0
"
@select="handleRuleSelect"
@hover="isHovered => handleRuleHover(isHovered, guideline.id)"
@edit="editGuideline"
@delete="deleteGuideline"
/>
</div>
<AddNewRulesInput
v-model="newInlineRule"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.PLACEHOLDER'
)
"
:label="
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.SAVE')
"
@add="addGuideline"
/>
</div>
</template>
</SettingsPageLayout>
</template>
@@ -32,7 +32,7 @@ const controlItems = computed(() => {
description: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.DESCRIPTION'
),
routeName: 'captain_assistants_guardrails_index',
// routeName: 'captain_assistants_guardrails_index',
},
{
name: t(
@@ -50,7 +50,7 @@ const controlItems = computed(() => {
description: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.DESCRIPTION'
),
routeName: 'captain_assistants_guidelines_index',
// routeName: 'captain_assistants_guidelines_index',
},
];
});
@@ -5,8 +5,6 @@ import AssistantIndex from './assistants/Index.vue';
import AssistantEdit from './assistants/Edit.vue';
// import AssistantSettings from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
@@ -52,36 +50,6 @@ export const routes = [
],
},
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/guardrails'
),
component: AssistantGuardrailsIndex,
name: 'captain_assistants_guardrails_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/guidelines'
),
component: AssistantGuidelinesIndex,
name: 'captain_assistants_guidelines_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/documents'),
component: DocumentsIndex,
@@ -17,7 +17,6 @@ import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue';
import AudioTranscription from './components/AudioTranscription.vue';
import SSOConfiguration from './components/SSOConfiguration.vue';
import SectionLayout from './components/SectionLayout.vue';
export default {
@@ -29,7 +28,6 @@ export default {
AccountDelete,
AutoResolve,
AudioTranscription,
SSOConfiguration,
SectionLayout,
WithLabel,
NextInput,
@@ -79,9 +77,6 @@ export default {
FEATURE_FLAGS.CAPTAIN
);
},
showSSOConfig() {
return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SSO);
},
languagesSortedByCode() {
const enabledLanguages = [...this.enabledLanguages];
return enabledLanguages.sort((l1, l2) =>
@@ -249,7 +244,6 @@ export default {
</div>
<AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="showAudioTranscriptionConfig" />
<SSOConfiguration v-if="showSSOConfig" />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete />
@@ -1,267 +0,0 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, url } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import NextInput from 'next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SectionLayout from './SectionLayout.vue';
export default {
components: {
WithLabel,
NextInput,
NextButton,
SectionLayout,
},
setup() {
const { accountId } = useAccount();
const v$ = useVuelidate();
return { accountId, v$ };
},
data() {
return {
ssoConfig: {
enabled: false,
provider_name: 'SSO',
login_url: '',
logout_url: '',
secret_key: '',
token_expiry: 5,
},
showSecretKey: false,
isUpdating: false,
};
},
validations() {
return {
ssoConfig: {
provider_name: {
required,
},
login_url: {
required: this.ssoConfig.enabled ? required : true,
url: this.ssoConfig.enabled ? url : true,
},
secret_key: {
required: this.ssoConfig.enabled ? required : true,
},
token_expiry: {
required,
},
},
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
}),
currentAccount() {
return this.getAccount(this.accountId) || {};
},
},
mounted() {
this.initializeSSO();
},
methods: {
initializeSSO() {
try {
const { sso_config } = this.currentAccount;
if (sso_config) {
this.ssoConfig = {
enabled: sso_config.enabled || false,
provider_name: sso_config.provider_name || 'SSO',
login_url: sso_config.login_url || '',
logout_url: sso_config.logout_url || '',
secret_key: sso_config.secret_key || '',
token_expiry: sso_config.token_expiry || 5,
};
}
} catch (error) {
// Ignore error
}
},
async updateSSO() {
this.v$.$touch();
if (this.v$.$invalid) {
useAlert(this.$t('GENERAL_SETTINGS.SSO.FORM.ERROR'));
return;
}
this.isUpdating = true;
try {
await this.$store.dispatch('accounts/update', {
sso_config: this.ssoConfig,
});
useAlert(this.$t('GENERAL_SETTINGS.SSO.UPDATE.SUCCESS'));
} catch (error) {
useAlert(this.$t('GENERAL_SETTINGS.SSO.UPDATE.ERROR'));
} finally {
this.isUpdating = false;
}
},
toggleSecretKey() {
this.showSecretKey = !this.showSecretKey;
},
},
};
</script>
<template>
<SectionLayout
:title="$t('GENERAL_SETTINGS.SSO.TITLE')"
:description="$t('GENERAL_SETTINGS.SSO.DESCRIPTION')"
>
<form class="grid gap-4" @submit.prevent="updateSSO">
<WithLabel :label="$t('GENERAL_SETTINGS.SSO.FORM.ENABLE_SSO.LABEL')">
<div class="flex items-center">
<input v-model="ssoConfig.enabled" type="checkbox" class="mr-2" />
<span>{{ $t('GENERAL_SETTINGS.SSO.FORM.ENABLE_SSO.HELP') }}</span>
</div>
</WithLabel>
<template v-if="ssoConfig.enabled">
<WithLabel
:has-error="v$.ssoConfig.provider_name.$error"
:label="$t('GENERAL_SETTINGS.SSO.FORM.PROVIDER_NAME.LABEL')"
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.PROVIDER_NAME.ERROR')"
>
<NextInput
v-model="ssoConfig.provider_name"
type="text"
class="w-full"
:placeholder="
$t('GENERAL_SETTINGS.SSO.FORM.PROVIDER_NAME.PLACEHOLDER')
"
@blur="v$.ssoConfig.provider_name.$touch"
/>
</WithLabel>
<WithLabel
:has-error="v$.ssoConfig.login_url.$error"
:label="$t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.LABEL')"
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.ERROR')"
>
<NextInput
v-model="ssoConfig.login_url"
type="url"
class="w-full"
:placeholder="$t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.PLACEHOLDER')"
@blur="v$.ssoConfig.login_url.$touch"
/>
<template #help>
{{ $t('GENERAL_SETTINGS.SSO.FORM.LOGIN_URL.HELP') }}
</template>
</WithLabel>
<WithLabel :label="$t('GENERAL_SETTINGS.SSO.FORM.LOGOUT_URL.LABEL')">
<NextInput
v-model="ssoConfig.logout_url"
type="url"
class="w-full"
:placeholder="
$t('GENERAL_SETTINGS.SSO.FORM.LOGOUT_URL.PLACEHOLDER')
"
/>
<template #help>
{{ $t('GENERAL_SETTINGS.SSO.FORM.LOGOUT_URL.HELP') }}
</template>
</WithLabel>
<WithLabel
:has-error="v$.ssoConfig.secret_key.$error"
:label="$t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.LABEL')"
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.ERROR')"
>
<div class="relative">
<NextInput
v-model="ssoConfig.secret_key"
:type="showSecretKey ? 'text' : 'password'"
class="w-full pr-10"
:placeholder="
$t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.PLACEHOLDER')
"
@blur="v$.ssoConfig.secret_key.$touch"
/>
<button
type="button"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
@click="toggleSecretKey"
>
<svg
v-if="showSecretKey"
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"
/>
</svg>
<svg
v-else
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</button>
</div>
<template #help>
{{ $t('GENERAL_SETTINGS.SSO.FORM.SECRET_KEY.HELP') }}
</template>
</WithLabel>
<WithLabel
:has-error="v$.ssoConfig.token_expiry.$error"
:label="$t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.LABEL')"
:error-message="$t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.ERROR')"
>
<NextInput
v-model="ssoConfig.token_expiry"
type="number"
class="w-full"
min="1"
max="60"
:placeholder="
$t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.PLACEHOLDER')
"
@blur="v$.ssoConfig.token_expiry.$touch"
/>
<template #help>
{{ $t('GENERAL_SETTINGS.SSO.FORM.TOKEN_EXPIRY.HELP') }}
</template>
</WithLabel>
</template>
<div>
<NextButton blue :is-loading="isUpdating" type="submit">
{{ $t('GENERAL_SETTINGS.SSO.SUBMIT') }}
</NextButton>
</div>
</form>
</SectionLayout>
</template>
@@ -555,11 +555,6 @@ export const AUTOMATION_ACTION_TYPES = [
label: 'SEND_MESSAGE',
inputType: 'textarea',
},
{
key: 'add_private_note',
label: 'ADD_PRIVATE_NOTE',
inputType: 'textarea',
},
{
key: 'change_priority',
label: 'CHANGE_PRIORITY',
@@ -47,13 +47,6 @@ export default {
this.currentInbox.provider === 'whatsapp_cloud'
);
},
// If the inbox is a whatsapp cloud inbox and the source is not embedded signup, then show the webhook details
shouldShowWhatsAppWebhookDetails() {
return (
this.isWhatsAppCloudInbox &&
this.currentInbox.provider_config?.source !== 'embedded_signup'
);
},
message() {
if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
@@ -73,7 +66,7 @@ export default {
)}`;
}
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
if (this.isWhatsAppCloudInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
@@ -120,11 +113,8 @@ export default {
:script="currentInbox.callback_webhook_url"
/>
</div>
<div
v-if="shouldShowWhatsAppWebhookDetails"
class="w-[50%] max-w-[50%] ml-[25%]"
>
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
<div v-if="isWhatsAppCloudInbox" class="w-[50%] max-w-[50%] ml-[25%]">
<p class="mt-8 font-medium text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}
</p>
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />
@@ -86,12 +86,6 @@ export default {
selectedTabKey() {
return this.tabs[this.selectedTabIndex]?.key;
},
shouldShowWhatsAppConfiguration() {
return !!(
this.isAWhatsAppCloudChannel &&
this.inbox.provider_config?.source !== 'embedded_signup'
);
},
whatsAppAPIProviderName() {
if (this.isAWhatsAppCloudChannel) {
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD');
@@ -143,7 +137,7 @@ export default {
this.isALineChannel ||
this.isAPIInbox ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWhatsAppChannel ||
this.isAWebWidgetInbox
) {
visibleToAllChannelTabs = [
@@ -389,7 +383,7 @@ export default {
<template>
<div
class="overflow-auto flex-grow flex-shrink pr-0 pl-0 w-full min-w-0 settings"
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto settings"
>
<SettingIntroBanner
:header-image="inbox.avatarUrl"
@@ -411,7 +405,7 @@ export default {
/>
</woot-tabs>
</SettingIntroBanner>
<section class="mx-auto w-full max-w-6xl">
<section class="w-full max-w-6xl mx-auto">
<MicrosoftReauthorize v-if="microsoftUnauthorized" :inbox="inbox" />
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
@@ -741,7 +735,7 @@ export default {
:business-name="businessName"
@update="toggleSenderNameType"
/>
<div class="flex flex-col gap-2 items-start mt-2">
<div class="flex flex-col items-start gap-2 mt-2">
<NextButton
ghost
blue
@@ -1,156 +1,48 @@
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
<script>
import PageHeader from '../../SettingsSubPageHeader.vue';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const store = useStore();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
WHATSAPP_CLOUD: 'whatsapp_cloud',
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
THREE_SIXTY_DIALOG: '360dialog',
};
const hasWhatsappAppId = computed(() => {
return (
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
});
const isWhatsappEmbeddedSignupEnabled = computed(() => {
const accountId = route.params.accountId;
return store.getters['accounts/isFeatureEnabledonAccount'](
accountId,
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP
);
});
const selectedProvider = computed(() => route.query.provider);
const showProviderSelection = computed(() => !selectedProvider.value);
const showConfiguration = computed(() => Boolean(selectedProvider.value));
const availableProviders = computed(() => [
{
value: PROVIDER_TYPES.WHATSAPP,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
icon: '/assets/images/dashboard/channels/whatsapp.png',
export default {
components: {
PageHeader,
Twilio,
ThreeSixtyDialogWhatsapp,
CloudWhatsapp,
},
{
value: PROVIDER_TYPES.TWILIO,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: '/assets/images/dashboard/channels/twilio.png',
data() {
return {
provider: 'whatsapp_cloud',
};
},
]);
const selectProvider = providerValue => {
router.push({
name: route.name,
params: route.params,
query: { provider: providerValue },
});
};
const shouldShowEmbeddedSignup = provider => {
// Check if the feature is enabled for the account
if (!isWhatsappEmbeddedSignupEnabled.value) {
return false;
}
return (
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
);
};
const shouldShowCloudWhatsapp = provider => {
// If embedded signup feature is enabled and app ID is configured, don't show cloud whatsapp
if (isWhatsappEmbeddedSignupEnabled.value && hasWhatsappAppId.value) {
return false;
}
// Show cloud whatsapp when:
// 1. Provider is whatsapp AND
// 2. Either no app ID is configured OR embedded signup feature is disabled
return provider === PROVIDER_TYPES.WHATSAPP;
};
</script>
<template>
<div
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div v-if="showProviderSelection">
<div class="mb-10 text-left">
<h1 class="mb-2 text-lg font-medium text-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
</h1>
<p class="text-sm leading-relaxed text-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
</p>
</div>
<div class="flex gap-6 justify-start">
<div
v-for="provider in availableProviders"
:key="provider.value"
class="gap-6 px-5 py-6 w-96 rounded-2xl border transition-all duration-200 cursor-pointer border-n-weak hover:bg-n-slate-3"
@click="selectProvider(provider.value)"
>
<div class="flex justify-start mb-5">
<div
class="flex justify-center items-center rounded-full size-10 bg-n-alpha-2"
>
<img
:src="provider.icon"
:alt="provider.label"
class="object-contain size-[26px]"
/>
</div>
</div>
<div class="text-start">
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
{{ provider.label }}
</h3>
<p class="text-sm text-slate-11">
{{ provider.description }}
</p>
</div>
</div>
</div>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.WHATSAPP.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.WHATSAPP.DESC')"
/>
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
<select v-model="provider">
<option value="whatsapp_cloud">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
</option>
<option value="twilio">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
</option>
</select>
</label>
</div>
<div v-else-if="showConfiguration">
<div class="px-6 py-5 rounded-2xl border bg-n-solid-2 border-n-weak">
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp v-else />
</div>
</div>
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
<CloudWhatsapp v-else />
</div>
</template>
@@ -1,327 +0,0 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
const store = useStore();
const router = useRouter();
const { t } = useI18n();
// State
const fbSdkLoaded = ref(false);
const isProcessing = ref(false);
const processingMessage = ref('');
const authCodeReceived = ref(false);
const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
// Computed
const whatsappIconPath = '/assets/images/dashboard/channels/whatsapp.png';
const benefits = computed(() => [
{
key: 'EASY_SETUP',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'),
},
{
key: 'SECURE_AUTH',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'),
},
{
key: 'AUTO_CONFIG',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'),
},
]);
const showLoader = computed(() => isAuthenticating.value || isProcessing.value);
// Error handling
const handleSignupError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
useAlert(errorMessage);
};
const handleSignupCancellation = () => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
};
const handleSignupSuccess = inboxData => {
isProcessing.value = false;
isAuthenticating.value = false;
if (inboxData && inboxData.id) {
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: inboxData.id,
},
});
} else {
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
});
}
};
// Signup flow
const completeSignupFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
});
return;
}
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
);
try {
const params = {
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
};
const responseData = await store.dispatch(
'inboxes/createWhatsAppEmbeddedSignup',
params
);
authCode.value = null;
handleSignupSuccess(responseData);
} catch (error) {
const errorMessage =
parseAPIErrorResponse(error) ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
handleSignupError({ error: errorMessage });
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
businessDataLocal.business_id &&
businessDataLocal.waba_id
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
if (data.event === 'FINISH') {
const businessDataLocal = data.data;
if (isValidBusinessData(businessDataLocal)) {
businessData.value = businessDataLocal;
if (authCodeReceived.value && authCode.value) {
await completeSignupFlow(businessDataLocal);
} else {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
);
}
} else {
handleSignupError({
error: t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleSignupCancellation();
} else if (data.event === 'error') {
handleSignupError({
error:
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
error_id: data.error_id,
session_id: data.session_id,
});
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
authCode.value = response.authResponse.code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
);
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} else if (response.error) {
handleSignupError({ error: response.error });
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Validate origin for security - following Facebook documentation
// https://developers.facebook.com/docs/whatsapp/embedded-signup/implementation#step-3--add-embedded-signup-to-your-website
if (!event.origin.endsWith('facebook.com')) return;
// Parse and handle WhatsApp embedded signup events
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch {
// Ignore non-JSON or irrelevant messages
}
};
const runFBInit = () => {
window.FB.init({
appId: window.chatwootConfig?.whatsappAppId,
autoLogAppEvents: true,
xfbml: true,
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
const loadFacebookSdk = async () => {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
async: true,
defer: true,
crossOrigin: 'anonymous',
});
};
const tryWhatsAppLogin = () => {
isAuthenticating.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '',
sessionInfoVersion: '3',
},
});
};
const launchEmbeddedSignup = async () => {
try {
// Load SDK first if not loaded, following Facebook.vue pattern exactly
await loadFacebookSdk();
runFBInit(); // Initialize FB after loading
// Now proceed with login
tryWhatsAppLogin();
} catch (error) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
});
}
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
const initialize = () => {
window.fbAsyncInit = runFBInit;
setupMessageListener();
};
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
<div class="h-full">
<LoadingState v-if="showLoader" :message="processingMessage" />
<div v-else>
<div class="flex flex-col items-start mb-6 text-start">
<div class="flex justify-start mb-6">
<div
class="flex justify-center items-center w-12 h-12 rounded-full bg-n-alpha-2"
>
<img
:src="whatsappIconPath"
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
class="object-contain w-8 h-8"
draggable="false"
/>
</div>
</div>
<h3 class="mb-2 text-base font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
</h3>
<p class="text-sm leading-[24px] text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
</p>
</div>
<div class="flex flex-col gap-2 mb-6">
<div
v-for="benefit in benefits"
:key="benefit.key"
class="flex gap-2 items-center text-sm text-n-slate-11"
>
<Icon icon="i-lucide-check" class="text-n-slate-11 size-4" />
{{ benefit.text }}
</div>
</div>
<div class="flex mt-4">
<NextButton
:disabled="isAuthenticating"
:is-loading="isAuthenticating"
faded
slate
class="w-full"
@click="launchEmbeddedSignup"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
</NextButton>
</div>
</div>
</div>
</template>
@@ -3,8 +3,6 @@ import types from '../mutation-types';
import CampaignsAPI from '../../api/campaigns';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { CAMPAIGNS_EVENTS } from '../../helper/AnalyticsHelper/events';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const state = {
records: [],
@@ -18,35 +16,10 @@ export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getCampaigns:
_state =>
(campaignType, inboxChannelTypes = null) => {
let filteredRecords = _state.records.filter(
record => record.campaign_type === campaignType
);
if (inboxChannelTypes && Array.isArray(inboxChannelTypes)) {
filteredRecords = filteredRecords.filter(record => {
return (
record.inbox &&
inboxChannelTypes.includes(record.inbox.channel_type)
);
});
}
return filteredRecords.sort((a1, a2) => a1.id - a2.id);
},
getSMSCampaigns: (_state, _getters) => {
const smsChannelTypes = [INBOX_TYPES.SMS, INBOX_TYPES.TWILIO];
return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, smsChannelTypes);
},
getWhatsAppCampaigns: (_state, _getters) => {
const whatsappChannelTypes = [INBOX_TYPES.WHATSAPP];
return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, whatsappChannelTypes);
},
getLiveChatCampaigns: (_state, _getters) => {
const liveChatChannelTypes = [INBOX_TYPES.WEB];
return _getters.getCampaigns(CAMPAIGN_TYPES.ONGOING, liveChatChannelTypes);
getCampaigns: _state => campaignType => {
return _state.records
.filter(record => record.campaign_type === campaignType)
.sort((a1, a2) => a1.id - a2.id);
},
getAllCampaigns: _state => {
return _state.records;
@@ -5,7 +5,6 @@ import InboxesAPI from '../../api/inboxes';
import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import WhatsappChannel from '../../api/channel/whatsappChannel';
import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
@@ -96,11 +95,6 @@ export const getters = {
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
);
},
getWhatsAppInboxes($state) {
return $state.records.filter(
item => item.channel_type === INBOX_TYPES.WHATSAPP
);
},
dialogFlowEnabledInboxes($state) {
return $state.records.filter(
item => item.channel_type !== INBOX_TYPES.EMAIL
@@ -204,19 +198,6 @@ export const actions = {
throw new Error(error);
}
},
createWhatsAppEmbeddedSignup: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await WhatsappChannel.createEmbeddedSignup(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('whatsapp');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw error;
}
},
...channelActions,
// TODO: Extract other create channel methods to separate files to reduce file size
// - createChannel
@@ -11,11 +11,6 @@ export default [
url: 'https://github.com',
time_on_page: 10,
},
inbox: {
id: 1,
channel_type: 'Channel::WebWidget',
name: 'Web Widget',
},
created_at: '2021-05-03T04:53:36.354Z',
updated_at: '2021-05-03T04:53:36.354Z',
},
@@ -29,11 +24,6 @@ export default [
url: 'https://chatwoot.com',
time_on_page: '20',
},
inbox: {
id: 2,
channel_type: 'Channel::TwilioSms',
name: 'Twilio SMS',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
@@ -49,52 +39,7 @@ export default [
url: 'https://noshow.com',
time_on_page: 10,
},
inbox: {
id: 3,
channel_type: 'Channel::WebWidget',
name: 'Web Widget 2',
},
created_at: '2021-05-03T10:22:51.025Z',
updated_at: '2021-05-03T10:22:51.025Z',
},
{
id: 4,
title: 'WhatsApp Campaign',
description: null,
account_id: 1,
campaign_type: 'one_off',
message: 'Hello {{name}}, your order is ready!',
enabled: true,
trigger_rules: {},
inbox: {
id: 4,
channel_type: 'Channel::Whatsapp',
name: 'WhatsApp Business',
},
template_params: {
name: 'order_ready',
namespace: 'business_namespace',
language: 'en_US',
processed_params: { name: 'John' },
},
created_at: '2021-05-03T12:15:35.828Z',
updated_at: '2021-05-03T12:15:35.828Z',
},
{
id: 5,
title: 'SMS Promotion',
description: null,
account_id: 1,
campaign_type: 'one_off',
message: 'Get 20% off your next order!',
enabled: true,
trigger_rules: {},
inbox: {
id: 5,
channel_type: 'Channel::Sms',
name: 'SMS Channel',
},
created_at: '2021-05-03T14:15:35.828Z',
updated_at: '2021-05-03T14:15:35.828Z',
},
];
@@ -13,58 +13,20 @@ describe('#getters', () => {
it('get one_off campaigns', () => {
const state = { records: campaigns };
expect(getters.getCampaigns(state)('one_off')).toEqual([
campaigns[1],
campaigns[3],
campaigns[4],
]);
});
{
id: 2,
title: 'Onboarding Campaign',
description: null,
account_id: 1,
campaign_type: 'one_off',
it('get campaigns by channel type', () => {
const state = { records: campaigns };
expect(
getters.getCampaigns(state)('one_off', ['Channel::Whatsapp'])
).toEqual([campaigns[3]]);
});
it('get campaigns by multiple channel types', () => {
const state = { records: campaigns };
expect(
getters.getCampaigns(state)('one_off', [
'Channel::TwilioSms',
'Channel::Sms',
])
).toEqual([campaigns[1], campaigns[4]]);
});
it('get SMS campaigns', () => {
const state = { records: campaigns };
const mockGetters = {
getCampaigns: getters.getCampaigns(state),
};
expect(getters.getSMSCampaigns(state, mockGetters)).toEqual([
campaigns[1],
campaigns[4],
]);
});
it('get WhatsApp campaigns', () => {
const state = { records: campaigns };
const mockGetters = {
getCampaigns: getters.getCampaigns(state),
};
expect(getters.getWhatsAppCampaigns(state, mockGetters)).toEqual([
campaigns[3],
]);
});
it('get Live Chat campaigns', () => {
const state = { records: campaigns };
const mockGetters = {
getCampaigns: getters.getCampaigns(state),
};
expect(getters.getLiveChatCampaigns(state, mockGetters)).toEqual([
campaigns[0],
campaigns[2],
trigger_rules: {
url: 'https://chatwoot.com',
time_on_page: '20',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
]);
});
@@ -1,5 +1,5 @@
class Conversations::UpdateMessageStatusJob < ApplicationJob
queue_as :deferred
queue_as :low
# This job only support marking messages as read or delivered, update this array if we want to support more statuses
VALID_STATUSES = %w[read delivered].freeze
+12
View File
@@ -50,6 +50,18 @@ class AgentBotListener < BaseListener
process_webhook_bot_event(inbox.agent_bot, payload)
end
def assignee_changed(event)
conversation = extract_conversation_and_account(event)[0]
return unless conversation.assigned_to_bot?
agent_bot = conversation.assigned_bot
return if agent_bot.blank? || agent_bot.outgoing_url.blank?
event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name)
process_webhook_bot_event(agent_bot, payload)
end
private
def connected_agent_bot_exist?(inbox)
+1 -41
View File
@@ -12,7 +12,6 @@
# locale :integer default("en")
# name :string not null
# settings :jsonb
# sso_config :jsonb not null
# status :integer default("active")
# support_email :string(100)
# created_at :datetime not null
@@ -20,8 +19,7 @@
#
# Indexes
#
# index_accounts_on_sso_config (sso_config) USING gin
# index_accounts_on_status (status)
# index_accounts_on_status (status)
#
class Account < ApplicationRecord
@@ -57,7 +55,6 @@ class Account < ApplicationRecord
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :sso_config, :enabled, :provider_name, :login_url, :logout_url, :secret_key, :token_expiry
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -161,43 +158,6 @@ class Account < ApplicationRecord
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
end
# SSO Configuration Methods
def sso_enabled?
ActiveModel::Type::Boolean.new.cast(sso_config['enabled'])
end
def sso_provider_name
sso_config['provider_name'].presence || 'SSO'
end
def sso_login_url
sso_config['login_url']
end
def sso_logout_url
sso_config['logout_url']
end
def sso_secret_key
sso_config['secret_key']
end
def sso_token_expiry
(sso_config['token_expiry'].presence || 5).to_i
end
def update_sso_config(config)
# Validate required fields if SSO is enabled
return false if ActiveModel::Type::Boolean.new.cast(config['enabled']) && (config['login_url'].blank? || config['secret_key'].blank?)
# Set default values
config['token_expiry'] = 5 if config['token_expiry'].blank?
config['provider_name'] = 'SSO' if config['provider_name'].blank?
self.sso_config = config
save
end
private
def notify_creation
+1
View File
@@ -24,6 +24,7 @@ class AgentBot < ApplicationRecord
has_many :agent_bot_inboxes, dependent: :destroy_async
has_many :inboxes, through: :agent_bot_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :assigned_conversations, as: :assignee, class_name: 'Conversation', dependent: :nullify
belongs_to :account, optional: true
enum bot_type: { webhook: 0 }
+1 -2
View File
@@ -41,8 +41,7 @@ class AutomationRule < ApplicationRecord
def actions_attributes
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript
add_private_note].freeze
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript].freeze
end
def file_base_data
+4 -15
View File
@@ -10,7 +10,6 @@
# enabled :boolean default(TRUE)
# message :text not null
# scheduled_at :datetime
# template_params :jsonb not null
# title :string not null
# trigger_only_during_business_hours :boolean default(FALSE)
# trigger_rules :jsonb
@@ -58,22 +57,12 @@ class Campaign < ApplicationRecord
return unless one_off?
return if completed?
execute_campaign
Twilio::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Twilio SMS'
Sms::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Sms'
end
private
def execute_campaign
case inbox.inbox_type
when 'Twilio SMS'
Twilio::OneoffSmsCampaignService.new(campaign: self).perform
when 'Sms'
Sms::OneoffSmsCampaignService.new(campaign: self).perform
when 'Whatsapp'
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
end
end
def set_display_id
reload
end
@@ -81,14 +70,14 @@ class Campaign < ApplicationRecord
def validate_campaign_inbox
return unless inbox
errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms', 'Whatsapp'].include? inbox.inbox_type
errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms'].include? inbox.inbox_type
end
# TO-DO we clean up with better validations when campaigns evolve into more inboxes
def ensure_correct_campaign_attributes
return if inbox.blank?
if ['Twilio SMS', 'Sms', 'Whatsapp'].include?(inbox.inbox_type)
if ['Twilio SMS', 'Sms'].include?(inbox.inbox_type)
self.campaign_type = 'one_off'
self.scheduled_at ||= Time.now.utc
else
-38
View File
@@ -33,7 +33,6 @@ class Channel::Whatsapp < ApplicationRecord
validate :validate_provider_config
after_create :sync_templates
after_create_commit :setup_webhooks
def name
'Whatsapp'
@@ -68,41 +67,4 @@ class Channel::Whatsapp < ApplicationRecord
def validate_provider_config
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
def setup_webhooks
return unless should_setup_webhooks?
perform_webhook_setup
rescue StandardError => e
handle_webhook_setup_error(e)
end
def should_setup_webhooks?
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
end
def whatsapp_cloud_provider?
provider == 'whatsapp_cloud'
end
def embedded_signup_source?
provider_config['source'] == 'embedded_signup'
end
def webhook_config_present?
provider_config['business_account_id'].present? && provider_config['api_key'].present?
end
def perform_webhook_setup
business_account_id = provider_config['business_account_id']
api_key = provider_config['api_key']
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
end
def handle_webhook_setup_error(error)
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{error.message}"
# Don't raise the error to prevent channel creation from failing
# Webhooks can be retried later
end
end
+1 -20
View File
@@ -2,11 +2,8 @@ module SsoAuthenticatable
extend ActiveSupport::Concern
def generate_sso_auth_token
return nil unless account&.sso_enabled?
token = SecureRandom.hex(32)
expiry_minutes = account.sso_token_expiry.minutes
::Redis::Alfred.setex(sso_token_key(token), true, expiry_minutes)
::Redis::Alfred.setex(sso_token_key(token), true, 5.minutes)
token
end
@@ -19,30 +16,14 @@ module SsoAuthenticatable
end
def generate_sso_link
return nil unless account&.sso_enabled?
encoded_email = ERB::Util.url_encode(email)
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
end
def generate_sso_link_with_impersonation
return nil unless account&.sso_enabled?
"#{generate_sso_link}&impersonation=true"
end
def sso_external_login_url
return nil unless account&.sso_enabled?
account.sso_login_url
end
def sso_external_logout_url
return nil unless account&.sso_enabled?
account.sso_logout_url
end
private
def sso_token_key(token)
+42 -18
View File
@@ -6,6 +6,7 @@
# additional_attributes :jsonb
# agent_last_seen_at :datetime
# assignee_last_seen_at :datetime
# assignee_type :string
# cached_label_list :text
# contact_last_seen_at :datetime
# custom_attributes :jsonb
@@ -31,22 +32,23 @@
#
# Indexes
#
# conv_acid_inbid_stat_asgnid_idx (account_id,inbox_id,status,assignee_id)
# index_conversations_on_account_id (account_id)
# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE
# index_conversations_on_assignee_id_and_account_id (assignee_id,account_id)
# index_conversations_on_campaign_id (campaign_id)
# index_conversations_on_contact_id (contact_id)
# index_conversations_on_contact_inbox_id (contact_inbox_id)
# index_conversations_on_first_reply_created_at (first_reply_created_at)
# index_conversations_on_id_and_account_id (account_id,id)
# index_conversations_on_inbox_id (inbox_id)
# index_conversations_on_priority (priority)
# index_conversations_on_status_and_account_id (status,account_id)
# index_conversations_on_status_and_priority (status,priority)
# index_conversations_on_team_id (team_id)
# index_conversations_on_uuid (uuid) UNIQUE
# index_conversations_on_waiting_since (waiting_since)
# conv_acid_inbid_stat_asgnid_idx (account_id,inbox_id,status,assignee_id)
# index_conversations_on_account_id (account_id)
# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE
# index_conversations_on_assignee_id_and_account_id (assignee_id,account_id)
# index_conversations_on_assignee_type_and_assignee_id (assignee_type,assignee_id)
# index_conversations_on_campaign_id (campaign_id)
# index_conversations_on_contact_id (contact_id)
# index_conversations_on_contact_inbox_id (contact_inbox_id)
# index_conversations_on_first_reply_created_at (first_reply_created_at)
# index_conversations_on_id_and_account_id (account_id,id)
# index_conversations_on_inbox_id (inbox_id)
# index_conversations_on_priority (priority)
# index_conversations_on_status_and_account_id (status,account_id)
# index_conversations_on_status_and_priority (status,priority)
# index_conversations_on_team_id (team_id)
# index_conversations_on_uuid (uuid) UNIQUE
# index_conversations_on_waiting_since (waiting_since)
#
class Conversation < ApplicationRecord
@@ -59,6 +61,7 @@ class Conversation < ApplicationRecord
include SortHandler
include PushDataHelper
include ConversationMuteHelpers
include Events::Types
validates :account_id, presence: true
validates :inbox_id, presence: true
@@ -68,6 +71,7 @@ class Conversation < ApplicationRecord
validates :custom_attributes, jsonb_attributes_length: true
validates :uuid, uniqueness: true
validate :validate_referer_url
validate :validate_assignee_belongs_to_account
enum status: { open: 0, resolved: 1, pending: 2, snoozed: 3 }
enum priority: { low: 0, medium: 1, high: 2, urgent: 3 }
@@ -96,7 +100,7 @@ class Conversation < ApplicationRecord
belongs_to :account
belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true, inverse_of: :assigned_conversations
belongs_to :assignee, polymorphic: true, optional: true
belongs_to :contact
belongs_to :contact_inbox
belongs_to :team, optional: true
@@ -195,6 +199,14 @@ class Conversation < ApplicationRecord
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
end
def assigned_to_bot?
assignee_type == 'AgentBot'
end
def assigned_bot
assignee if assigned_to_bot?
end
private
def execute_after_update_commit_callbacks
@@ -273,7 +285,8 @@ class Conversation < ApplicationRecord
CONVERSATION_RESOLVED => -> { saved_change_to_status? && resolved? },
CONVERSATION_STATUS_CHANGED => -> { saved_change_to_status? },
CONVERSATION_READ => -> { saved_change_to_contact_last_seen_at? },
CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? }
CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? },
ASSIGNEE_CHANGED => -> { saved_change_to_assignee_id? }
}.each do |event, condition|
condition.call && dispatcher_dispatch(event, status_change)
end
@@ -309,6 +322,17 @@ class Conversation < ApplicationRecord
self['additional_attributes']['referer'] = nil unless url_valid?(additional_attributes['referer'])
end
def validate_assignee_belongs_to_account
return unless assignee
case assignee
when User
errors.add(:assignee, 'must belong to the same account') unless assignee.accounts.include?(account)
when AgentBot
errors.add(:assignee, 'must belong to the same account') unless assignee.account_id == account_id || assignee.system_bot?
end
end
# creating db triggers
trigger.before(:insert).for_each(:row) do
"NEW.display_id := nextval('conv_dpid_seq_' || NEW.account_id);"
+1 -1
View File
@@ -74,7 +74,7 @@ class User < ApplicationRecord
has_many :accounts, through: :account_users
accepts_nested_attributes_for :account_users
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
has_many :assigned_conversations, as: :assignee, class_name: 'Conversation', dependent: :nullify
alias_attribute :conversations, :assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
has_many :conversation_participants, dependent: :destroy_async
+1 -1
View File
@@ -17,4 +17,4 @@ class MessageContentPresenter < SimpleDelegator
def survey_url(conversation_uuid)
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation_uuid}"
end
end
end
@@ -47,13 +47,6 @@ class AutomationRules::ActionService < ActionService
Messages::MessageBuilder.new(nil, @conversation, params).perform
end
def add_private_note(message)
return if conversation_a_tweet?
params = { content: message[0], private: true, content_attributes: { automation_rule_id: @rule.id } }
Messages::MessageBuilder.new(nil, @conversation.reload, params).perform
end
def send_email_to_team(params)
teams = Team.where(id: params[0][:team_ids])
@@ -1,75 +0,0 @@
class Whatsapp::ChannelCreationService
def initialize(account, waba_info, phone_info, access_token)
@account = account
@waba_info = waba_info
@phone_info = phone_info
@access_token = access_token
end
def perform
validate_parameters!
existing_channel = find_existing_channel
raise "Channel already exists: #{existing_channel.phone_number}" if existing_channel
create_channel_with_inbox
end
private
def validate_parameters!
raise ArgumentError, 'Account is required' if @account.blank?
raise ArgumentError, 'WABA info is required' if @waba_info.blank?
raise ArgumentError, 'Phone info is required' if @phone_info.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
end
def find_existing_channel
Channel::Whatsapp.find_by(
account: @account,
phone_number: @phone_info[:phone_number]
)
end
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel = create_channel
create_inbox(channel)
channel.reload
channel
end
end
def create_channel
Channel::Whatsapp.create!(
account: @account,
phone_number: @phone_info[:phone_number],
provider: 'whatsapp_cloud',
provider_config: build_provider_config
)
end
def build_provider_config
{
api_key: @access_token,
phone_number_id: @phone_info[:phone_number_id],
business_account_id: @waba_info[:waba_id],
source: 'embedded_signup'
}
end
def create_inbox(channel)
inbox_name = build_inbox_name
Inbox.create!(
account: @account,
name: inbox_name,
channel: channel
)
end
def build_inbox_name
business_name = @phone_info[:business_name] || @waba_info[:business_name]
"#{business_name} WhatsApp"
end
end
@@ -1,44 +0,0 @@
class Whatsapp::EmbeddedSignupService
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
@account = account
@code = code
@business_id = business_id
@waba_id = waba_id
@phone_number_id = phone_number_id
end
def perform
validate_parameters!
# Exchange code for user access token
access_token = Whatsapp::TokenExchangeService.new(@code).perform
# Fetch phone information
phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
# Validate token has access to the WABA
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
# Create channel
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
# Webhook setup is now handled in the channel after_create_commit callback
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
raise e
end
private
def validate_parameters!
missing_params = []
missing_params << 'code' if @code.blank?
missing_params << 'business_id' if @business_id.blank?
missing_params << 'waba_id' if @waba_id.blank?
return if missing_params.empty?
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
end
end
@@ -1,86 +0,0 @@
class Whatsapp::FacebookApiClient
BASE_URI = 'https://graph.facebook.com'.freeze
def initialize(access_token = nil)
@access_token = access_token
@api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
end
def exchange_code_for_token(code)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/oauth/access_token",
query: {
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
code: code
}
)
handle_response(response, 'Token exchange failed')
end
def fetch_phone_numbers(waba_id)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
query: { access_token: @access_token }
)
handle_response(response, 'WABA phone numbers fetch failed')
end
def debug_token(input_token)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/debug_token",
query: {
input_token: input_token,
access_token: build_app_access_token
}
)
handle_response(response, 'Token validation failed')
end
def register_phone_number(phone_number_id, pin)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{phone_number_id}/register",
headers: request_headers,
body: { messaging_product: 'whatsapp', pin: pin.to_s }.to_json
)
handle_response(response, 'Phone registration failed')
end
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers,
body: {
override_callback_uri: callback_url,
verify_token: verify_token
}.to_json
)
handle_response(response, 'Webhook subscription failed')
end
private
def request_headers
{
'Authorization' => "Bearer #{@access_token}",
'Content-Type' => 'application/json'
}
end
def build_app_access_token
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
"#{app_id}|#{app_secret}"
end
def handle_response(response, error_message)
raise "#{error_message}: #{response.body}" unless response.success?
response.parsed_response
end
end
@@ -1,94 +0,0 @@
class Whatsapp::OneoffCampaignService
pattr_initialize [:campaign!]
def perform
validate_campaign!
process_audience(extract_audience_labels)
campaign.completed!
end
private
delegate :inbox, to: :campaign
delegate :channel, to: :inbox
def validate_campaign_type!
raise "Invalid campaign #{campaign.id}" unless whatsapp_campaign? && campaign.one_off?
end
def whatsapp_campaign?
campaign.inbox.inbox_type == 'Whatsapp'
end
def validate_campaign_status!
raise 'Completed Campaign' if campaign.completed?
end
def validate_provider!
raise 'WhatsApp Cloud provider required' if channel.provider != 'whatsapp_cloud'
end
def validate_feature_flag!
raise 'WhatsApp campaigns feature not enabled' unless campaign.account.feature_enabled?(:whatsapp_campaign)
end
def validate_campaign!
validate_campaign_type!
validate_campaign_status!
validate_provider!
validate_feature_flag!
end
def extract_audience_labels
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
campaign.account.labels.where(id: audience_label_ids).pluck(:title)
end
def process_contact(contact)
Rails.logger.info "Processing contact: #{contact.name} (#{contact.phone_number})"
if contact.phone_number.blank?
Rails.logger.info "Skipping contact #{contact.name} - no phone number"
return
end
if campaign.template_params.blank?
Rails.logger.error "Skipping contact #{contact.name} - no template_params found for WhatsApp campaign"
return
end
send_whatsapp_template_message(to: contact.phone_number)
end
def process_audience(audience_labels)
contacts = campaign.account.contacts.tagged_with(audience_labels, any: true)
Rails.logger.info "Processing #{contacts.count} contacts for campaign #{campaign.id}"
contacts.each { |contact| process_contact(contact) }
Rails.logger.info "Campaign #{campaign.id} processing completed"
end
def send_whatsapp_template_message(to:)
processor = Whatsapp::TemplateProcessorService.new(
channel: channel,
template_params: campaign.template_params
)
name, namespace, lang_code, processed_parameters = processor.call
return if name.blank?
channel.send_template(to, {
name: name,
namespace: namespace,
lang_code: lang_code,
parameters: processed_parameters
})
rescue StandardError => e
Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}"
Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}"
raise e
end
end
@@ -1,57 +0,0 @@
class Whatsapp::PhoneInfoService
def initialize(waba_id, phone_number_id, access_token)
@waba_id = waba_id
@phone_number_id = phone_number_id
@access_token = access_token
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
fetch_and_process_phone_info
end
private
def validate_parameters!
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
end
def fetch_and_process_phone_info
response = @api_client.fetch_phone_numbers(@waba_id)
phone_numbers = response['data']
phone_data = find_phone_data(phone_numbers)
raise "No phone numbers found for WABA #{@waba_id}" if phone_data.nil?
build_phone_info(phone_data)
end
def find_phone_data(phone_numbers)
return nil if phone_numbers.blank?
if @phone_number_id.present?
phone_numbers.find { |phone| phone['id'] == @phone_number_id } || phone_numbers.first
else
phone_numbers.first
end
end
def build_phone_info(phone_data)
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
{
phone_number_id: phone_data['id'],
phone_number: "+#{display_phone_number}",
verified: phone_data['code_verification_status'] == 'VERIFIED',
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
}
end
def sanitize_phone_number(phone_number)
return phone_number if phone_number.blank?
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
end
end
@@ -15,13 +15,7 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end
def send_template_message
processor = Whatsapp::TemplateProcessorService.new(
channel: channel,
template_params: template_params,
message: message
)
name, namespace, lang_code, processed_parameters = processor.call
name, namespace, lang_code, processed_parameters = processable_channel_message_template
return if name.blank?
@@ -34,6 +28,86 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
message.update!(source_id: message_id) if message_id.present?
end
def processable_channel_message_template
if template_params.present?
return [
template_params['name'],
template_params['namespace'],
template_params['language'],
processed_templates_params(template_params)
]
end
# Delete the following logic once the update for template_params is stable
# see if we can match the message content to a template
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
# Then we use regex to parse the template varibles and convert them into the proper payload
channel.message_templates&.each do |template|
match_obj = template_match_object(template)
next if match_obj.blank?
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
# no need to look up further end the search
return [template['name'], template['namespace'], template['language'], processed_parameters]
end
[nil, nil, nil, nil]
end
def template_match_object(template)
body_object = validated_body_object(template)
return if body_object.blank?
template_match_regex = build_template_match_regex(body_object['text'])
message.outgoing_content.match(template_match_regex)
end
def build_template_match_regex(template_text)
# Converts the whatsapp template to a comparable regex string to check against the message content
# the variables are of the format {{num}} ex:{{1}}
# transform the template text into a regex string
# we need to replace the {{num}} with matchers that can be used to capture the variables
template_text = template_text.gsub(/{{\d}}/, '(.*)')
# escape if there are regex characters in the template text
template_text = Regexp.escape(template_text)
# ensuring only the variables remain as capture groups
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
template_match_string = "^#{template_text}$"
Regexp.new template_match_string
end
def template(template_params)
channel.message_templates.find do |t|
t['name'] == template_params['name'] && t['language'] == template_params['language']
end
end
def processed_templates_params(template_params)
template = template(template_params)
return if template.blank?
parameter_format = template['parameter_format']
if parameter_format == 'NAMED'
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
else
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
end
end
def validated_body_object(template)
# we don't care if its not approved template
return if template['status'] != 'approved'
# we only care about text body object in template. if not present we discard the template
# we don't support other forms of templates
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
end
def send_session_message
message_id = channel.send_message(message.conversation.contact_inbox.source_id, message)
message.update!(source_id: message_id) if message_id.present?
@@ -1,95 +0,0 @@
class Whatsapp::TemplateProcessorService
pattr_initialize [:channel!, :template_params, :message]
def call
if template_params.present?
process_template_with_params
else
process_template_from_message
end
end
private
def process_template_with_params
[
template_params['name'],
template_params['namespace'],
template_params['language'],
processed_templates_params
]
end
def process_template_from_message
return [nil, nil, nil, nil] if message.blank?
# Delete the following logic once the update for template_params is stable
# see if we can match the message content to a template
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
# Then we use regex to parse the template varibles and convert them into the proper payload
channel.message_templates&.each do |template|
match_obj = template_match_object(template)
next if match_obj.blank?
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
# no need to look up further end the search
return [template['name'], template['namespace'], template['language'], processed_parameters]
end
[nil, nil, nil, nil]
end
def template_match_object(template)
body_object = validated_body_object(template)
return if body_object.blank?
template_match_regex = build_template_match_regex(body_object['text'])
message.outgoing_content.match(template_match_regex)
end
def build_template_match_regex(template_text)
# Converts the whatsapp template to a comparable regex string to check against the message content
# the variables are of the format {{num}} ex:{{1}}
# transform the template text into a regex string
# we need to replace the {{num}} with matchers that can be used to capture the variables
template_text = template_text.gsub(/{{\d}}/, '(.*)')
# escape if there are regex characters in the template text
template_text = Regexp.escape(template_text)
# ensuring only the variables remain as capture groups
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
template_match_string = "^#{template_text}$"
Regexp.new template_match_string
end
def find_template
channel.message_templates.find do |t|
t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved'
end
end
def processed_templates_params
template = find_template
return if template.blank?
parameter_format = template['parameter_format']
if parameter_format == 'NAMED'
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
else
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
end
end
def validated_body_object(template)
# we don't care if its not approved template
return if template['status'] != 'approved'
# we only care about text body object in template. if not present we discard the template
# we don't support other forms of templates
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
end
end
@@ -1,26 +0,0 @@
class Whatsapp::TokenExchangeService
def initialize(code)
@code = code
@api_client = Whatsapp::FacebookApiClient.new
end
def perform
validate_code!
exchange_token
end
private
def validate_code!
raise ArgumentError, 'Authorization code is required' if @code.blank?
end
def exchange_token
response = @api_client.exchange_code_for_token(@code)
access_token = response['access_token']
raise "No access token in response: #{response}" if access_token.blank?
access_token
end
end
@@ -1,42 +0,0 @@
class Whatsapp::TokenValidationService
def initialize(access_token, waba_id)
@access_token = access_token
@waba_id = waba_id
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
validate_token_waba_access
end
private
def validate_parameters!
raise ArgumentError, 'Access token is required' if @access_token.blank?
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
end
def validate_token_waba_access
token_debug_data = @api_client.debug_token(@access_token)
waba_scope = extract_waba_scope(token_debug_data)
verify_waba_authorization(waba_scope)
end
def extract_waba_scope(token_data)
granular_scopes = token_data.dig('data', 'granular_scopes')
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
raise 'No WABA scope found in token' unless waba_scope
waba_scope
end
def verify_waba_authorization(waba_scope)
authorized_waba_ids = waba_scope['target_ids'] || []
return if authorized_waba_ids.include?(@waba_id)
raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
end
end
@@ -1,67 +0,0 @@
class Whatsapp::WebhookSetupService
def initialize(channel, waba_id, access_token)
@channel = channel
@waba_id = waba_id
@access_token = access_token
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
register_phone_number
setup_webhook
end
private
def validate_parameters!
raise ArgumentError, 'Channel is required' if @channel.blank?
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
end
def register_phone_number
phone_number_id = @channel.provider_config['phone_number_id']
pin = fetch_or_create_pin
@api_client.register_phone_number(phone_number_id, pin)
store_pin(pin)
rescue StandardError => e
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
# Continue with webhook setup even if registration fails
# This is just a warning, not a blocking error
end
def fetch_or_create_pin
# Check if we have a stored PIN for this phone number
existing_pin = @channel.provider_config['verification_pin']
return existing_pin.to_i if existing_pin.present?
# Generate a new 6-digit PIN if none exists
SecureRandom.random_number(900_000) + 100_000
end
def store_pin(pin)
# Store the PIN in provider_config for future use
@channel.provider_config['verification_pin'] = pin
@channel.save!
end
def setup_webhook
callback_url = build_callback_url
verify_token = @channel.provider_config['webhook_verify_token']
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
raise "Webhook setup failed: #{e.message}"
end
def build_callback_url
frontend_url = ENV.fetch('FRONTEND_URL', nil)
phone_number = @channel.phone_number
"#{frontend_url}/webhooks/whatsapp/#{phone_number}"
end
end
@@ -4,4 +4,4 @@ json.payload do
json.partial! 'article', formats: [:json], article: article
end
end
end
end
@@ -1 +0,0 @@
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
@@ -1,5 +1,4 @@
json.settings resource.settings
json.sso_config resource.sso_config
json.created_at resource.created_at
if resource.custom_attributes.present?
json.custom_attributes do
@@ -9,7 +9,6 @@ json.sender do
json.partial! 'api/v1/models/agent', formats: [:json], resource: resource.sender if resource.sender.present?
end
json.message resource.message
json.template_params resource.template_params
json.campaign_status resource.campaign_status
json.enabled resource.enabled
json.campaign_type resource.campaign_type
-3
View File
@@ -39,9 +39,6 @@
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
whatsappAppId: '<%= @global_config['WHATSAPP_APP_ID'] %>',
whatsappConfigurationId: '<%= @global_config['WHATSAPP_CONFIGURATION_ID'] %>',
whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
<% if @global_config['IS_ENTERPRISE'] %>
@@ -2,12 +2,6 @@
<symbol id="icon-microsoft" viewBox="0 0 24 24">
<path fill="currentColor" d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z"/>
</symbol>
<symbol id="icon-google" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</symbol>
<symbol id="icon-cancel" viewBox="0 0 48 48">
<path fill-rule="evenodd" d="M24 19.757l-8.485-8.485c-.784-.783-2.047-.782-2.827 0l-1.417 1.416c-.777.777-.78 2.046.002 2.827L19.757 24l-8.485 8.485c-.783.784-.782 2.047 0 2.827l1.416 1.417c.777.777 2.046.78 2.827-.002L24 28.243l8.485 8.485c.784.783 2.047.782 2.827 0l1.417-1.416c.777-.777.78-2.046-.002-2.827L28.243 24l8.485-8.485c.783-.784.782-2.047 0-2.827l-1.416-1.417c-.777-.777-2.046-.78-2.827.002L24 19.757zM24 47c12.703 0 23-10.297 23-23S36.703 1 24 1 1 11.297 1 24s10.297 23 23 23z" />
</symbol>

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

@@ -1,4 +1,6 @@
<li class="px-4 mb-1">
<li
class="px-4 border-l-4 mb-1 <%= current_page?(url) ? 'border-woot-500' : 'border-transparent' %>"
>
<% text_class_name = current_page?(url) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %>
<%= link_to(url, class: text_class_name + " -ml-1 focus:outline-none cursor-pointer flex items-center px-2 py-1.5 text-slate-800 cursor-pointer hover:text-woot-500 hover:bg-slate-25 rounded-lg") do %>
<svg width="16" height="16"><use xlink:href="#<%= icon %>" /></svg>
@@ -39,13 +39,14 @@ as defined by the routes in the `admin/` namespace
label: display_resource_name(resource),
}
%>
<% end %>
<%= render 'settings_menu', open: settings_open? %>
</ul>
</div>
<div>
<ul class="my-4">
<% if ChatwootApp.enterprise? %>
<%= render partial: "nav_item", locals: { icon: 'icon-settings-2-line', url: super_admin_settings_url, label: 'Settings' } %>
<% end %>
<%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %>
@@ -1,24 +0,0 @@
<li class="px-4 mb-1">
<details class="group" <%= 'open' if open %>>
<summary class="-ml-1 flex items-center px-2 py-1.5 cursor-pointer rounded-lg <%= open ? 'text-woot-500 bg-slate-25' : 'text-slate-800 hover:text-woot-500 hover:bg-slate-25' %> list-none">
<%= link_to super_admin_settings_url, class: 'flex items-center flex-1' do %>
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
<span class="ml-2 text-sm">Settings</span>
<% end %>
<svg class="ml-auto w-4 h-4 transition-transform duration-200 group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</summary>
<ul class="ml-4 mt-1">
<% settings_pages.each do |_feature_key, attrs| %>
<% url = super_admin_app_config_url(config: attrs['config_key']) %>
<li class="px-4 mb-1">
<% text_class = current_page?(url) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %>
<%= link_to url, class: text_class + ' -ml-1 flex items-center px-2 py-1.5 hover:text-woot-500 hover:bg-slate-25 rounded-lg' do %>
<span class="ml-2 text-sm"><%= attrs['name'] %></span>
<% end %>
</li>
<% end %>
</ul>
</details>
</li>
@@ -1,4 +0,0 @@
<span class="flex gap-1 items-center bg-slate-100 h-9 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l-2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Switch to Enterprise edition</span>
</span>
@@ -1,4 +0,0 @@
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l-2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Upgrade now</span>
</a>
+20 -23
View File
@@ -39,26 +39,24 @@
</button>
</div>
</div>
<% if ChatwootApp.enterprise? %>
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg items-start md:items-center shadow-sm flex-col md:flex-row">
<div class="flex flex-col flex-grow gap-1">
<div class="flex items-center gap-2">
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
<span>Refresh</span>
</a>
</div>
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg items-start md:items-center shadow-sm flex-col md:flex-row">
<div class="flex flex-col flex-grow gap-1">
<div class="flex items-center gap-2">
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
<span>Refresh</span>
</a>
</div>
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
<span class="px-1">Manage</span>
</button>
</a>
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
</div>
<% end %>
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
<span class="px-1">Manage</span>
</button>
</a>
</div>
<% if ChatwootHub.pricing_plan != 'community' && User.count > ChatwootHub.pricing_plan_quantity %>
<div role="alert">
@@ -101,11 +99,10 @@
</div>
<% if !attrs[:enabled] %>
<div class="flex h-9 absolute top-5 items-center invisible group-hover:visible">
<% if ChatwootApp.enterprise? %>
<%= render 'upgrade_button_enterprise' %>
<% else %>
<%= render 'upgrade_button_community' %>
<% end %>
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Upgrade now</span>
</a>
</div>
<% end %>
<div class="flex items-center justify-between mb-1.5 mt-4">
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.4.0'
version: '4.3.0'
development:
<<: *shared
-7
View File
@@ -180,10 +180,3 @@
display_name: Captain V2
enabled: false
premium: true
chatwoot_internal: true
- name: whatsapp_embedded_signup
display_name: WhatsApp Embedded Signup
enabled: false
- name: whatsapp_campaign
display_name: WhatsApp Campaign
enabled: false
-42
View File
@@ -87,14 +87,11 @@
# ------- Email Related Config ------- #
- name: MAILER_INBOUND_EMAIL_DOMAIN
display_title: 'Inbound Email Domain'
value:
description: 'The domain name to be used for generating conversation continuity emails (reply+id@domain.com)'
locked: false
- name: MAILER_SUPPORT_EMAIL
display_title: 'Support Email'
value:
description: 'The support email address for your installation'
locked: false
# ------- End of Email Related Config ------- #
@@ -129,26 +126,6 @@
type: boolean
# ------- End of Facebook Channel Related Config ------- #
# ------- WhatsApp Channel Related Config ------- #
- name: WHATSAPP_APP_ID
display_title: 'WhatsApp App ID'
description: 'The Facebook App ID for WhatsApp Business API integration'
locked: false
- name: WHATSAPP_CONFIGURATION_ID
display_title: 'WhatsApp Configuration ID'
description: 'The Configuration ID for WhatsApp Embedded Signup flow (required for embedded signup)'
locked: false
- name: WHATSAPP_APP_SECRET
display_title: 'WhatsApp App Secret'
description: 'The App Secret for WhatsApp Embedded Signup flow (required for embedded signup)'
locked: false
- name: WHATSAPP_API_VERSION
display_title: 'WhatsApp API Version'
description: 'Configure this if you want to use a different WhatsApp API version. Make sure its prefixed with `v`'
value: 'v22.0'
locked: false
# ------- End of WhatsApp Channel Related Config ------- #
# MARK: Microsoft Email Channel Config
- name: AZURE_APP_ID
display_title: 'Azure App ID'
@@ -397,22 +374,3 @@
locked: false
type: secret
# ------- End of OG Image Related Config ------- #
## ------ Configs added for Google OAuth ------ ##
- name: GOOGLE_OAUTH_CLIENT_ID
display_title: 'Google OAuth Client ID'
value:
locked: false
description: 'Google OAuth Client ID for email authentication'
- name: GOOGLE_OAUTH_CLIENT_SECRET
display_title: 'Google OAuth Client Secret'
value:
locked: false
description: 'Google OAuth Client Secret for email authentication'
type: secret
- name: GOOGLE_OAUTH_REDIRECT_URI
display_title: 'Google OAuth Redirect URI'
value:
locked: false
description: 'The redirect URI configured in your Google OAuth app'
## ------ End of Configs added for Google OAuth ------ ##
-4
View File
@@ -233,10 +233,6 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
namespace :whatsapp do
resource :authorization, only: [:create]
end
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
-1
View File
@@ -23,7 +23,6 @@
- action_mailbox_routing
- low
- scheduled_jobs
- deferred
- housekeeping
- async_database_migration
- active_storage_analysis
@@ -13,4 +13,4 @@ class CreateChannelVoice < ActiveRecord::Migration[7.0]
add_index :channel_voice, :phone_number, unique: true
add_index :channel_voice, :account_id
end
end
end
@@ -1,5 +0,0 @@
class AddTemplateParamsToCampaigns < ActiveRecord::Migration[7.1]
def change
add_column :campaigns, :template_params, :jsonb, default: {}, null: false
end
end
@@ -1,6 +0,0 @@
class AddResponseGuidelinesAndGuardrailsToCaptainAssistants < ActiveRecord::Migration[7.1]
def change
add_column :captain_assistants, :response_guidelines, :jsonb, default: []
add_column :captain_assistants, :guardrails, :jsonb, default: []
end
end
@@ -0,0 +1,13 @@
class MakeConversationAssigneePolymorphic < ActiveRecord::Migration[7.1]
def change
add_column :conversations, :assignee_type, :string
add_index :conversations, [:assignee_type, :assignee_id]
# Update existing records to use User type
reversible do |dir|
dir.up do
execute "UPDATE conversations SET assignee_type = 'User' WHERE assignee_id IS NOT NULL"
end
end
end
end
@@ -1,6 +0,0 @@
class AddSsoConfigToAccounts < ActiveRecord::Migration[7.1]
def change
add_column :accounts, :sso_config, :jsonb, default: {}, null: false
add_index :accounts, :sso_config, using: :gin
end
end
+3 -6
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_07_18_083735) do
ActiveRecord::Schema[7.1].define(version: 2025_07_14_205206) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -59,8 +59,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_18_083735) do
t.integer "status", default: 0
t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
t.jsonb "sso_config", default: {}, null: false
t.index ["sso_config"], name: "index_accounts_on_sso_config", using: :gin
t.index ["status"], name: "index_accounts_on_status"
end
@@ -239,7 +237,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_18_083735) do
t.jsonb "audience", default: []
t.datetime "scheduled_at", precision: nil
t.boolean "trigger_only_during_business_hours", default: false
t.jsonb "template_params", default: {}, null: false
t.index ["account_id"], name: "index_campaigns_on_account_id"
t.index ["campaign_status"], name: "index_campaigns_on_campaign_status"
t.index ["campaign_type"], name: "index_campaigns_on_campaign_type"
@@ -280,8 +277,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_18_083735) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "config", default: {}, null: false
t.jsonb "response_guidelines", default: []
t.jsonb "guardrails", default: []
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
end
@@ -590,11 +585,13 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_18_083735) do
t.bigint "sla_policy_id"
t.datetime "waiting_since"
t.text "cached_label_list"
t.string "assignee_type"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
t.index ["account_id"], name: "index_conversations_on_account_id"
t.index ["assignee_id", "account_id"], name: "index_conversations_on_assignee_id_and_account_id"
t.index ["assignee_type", "assignee_id"], name: "index_conversations_on_assignee_type_and_assignee_id"
t.index ["campaign_id"], name: "index_conversations_on_campaign_id"
t.index ["contact_id"], name: "index_conversations_on_contact_id"
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"

Some files were not shown because too many files have changed in this diff Show More