Compare commits

..
231 changed files with 776 additions and 6989 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 18
parallelism: 20
steps:
- checkout
- node/install:
+3 -8
View File
@@ -43,18 +43,13 @@
## General Guidelines
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
+1 -1
View File
@@ -195,7 +195,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.12.0'
gem 'ai-agents', '>= 0.10.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
+7 -7
View File
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.12.0)
ai-agents (0.10.0)
ruby_llm (~> 1.14)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -193,12 +193,12 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.7)
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
@@ -304,7 +304,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.3)
faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -474,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.9)
json (2.19.8)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -570,7 +570,7 @@ GEM
minitest (5.25.5)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.3)
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
@@ -1058,7 +1058,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.12.0)
ai-agents (>= 0.10.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -91,17 +91,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
fallback_title: attachment['title'],
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if [:share, :post].include?(type.to_sym)
return :fallback if type.to_sym == :share
super
end
@@ -30,8 +30,7 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
:fair_distribution_limit, :fair_distribution_window, :enabled
)
end
end
@@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save!
render json: preferences_payload
@@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models).compact_blank.presence
existing_models.merge(permitted_captain_models)
end
def merged_captain_features
@@ -47,30 +47,29 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def permitted_captain_models
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
end
def captain_feature_keys
Llm::Models.feature_keys.map(&:to_sym)
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
provider: route[:provider],
source: route[:source]
selected: account_models[feature_key] || config[:default]
)
end
end
@@ -52,9 +52,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
render json: { content: translated_content }
rescue Google::Cloud::Error => e
# `details` carries the clean human message; `message` includes gRPC debug noise
render_could_not_create_error(e.details.presence || e.message)
end
private
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
)
end
@@ -1,19 +1,17 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
STEP_ACCOUNT_DETAILS = 'account_details'.freeze
STEP_INBOX_SETUP = 'inbox_setup'.freeze
ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
def update
return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
@account = Current.account
# The client declares the step it is completing; `account_details` runs
# `complete_account_details`, and so on. The known-step guard above keeps the
# client value from `send`-ing an arbitrary method.
send("complete_#{params[:onboarding_step]}")
finalize = finalizing_account_details?
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes.delete('onboarding_step') if finalize
@account.save!
# TODO: re-enable when the help center generation UI is ready to surface progress
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
render 'api/v1/accounts/update', format: :json
end
@@ -24,48 +22,12 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
private
def complete_account_details
# Only act while the cursor still points here, so a stale replay after
# onboarding finished can't re-enter it.
return unless current_step == STEP_ACCOUNT_DETAILS
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
# inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
# environment check); self-hosted finishes onboarding here.
if ChatwootApp.chatwoot_cloud?
move_to_step(STEP_INBOX_SETUP)
create_onboarding_inboxes
else
finish_onboarding
end
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
end
def complete_inbox_setup
# Only finalize while the cursor still points here, so a stale or out-of-order
# request can't end onboarding early. Replays are no-ops.
return unless current_step == STEP_INBOX_SETUP
finish_onboarding
end
def current_step
@account.custom_attributes[ONBOARDING_STEP_KEY]
end
def move_to_step(step)
@account.custom_attributes[ONBOARDING_STEP_KEY] = step
@account.save!
end
def finish_onboarding
@account.custom_attributes.delete(ONBOARDING_STEP_KEY)
@account.save!
end
def create_onboarding_inboxes
Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
def website
custom_attributes_params[:website]
end
def account_params
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def team_params
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
params.require(:team).permit(:name, :description, :allow_auto_assign)
end
end
@@ -10,8 +10,7 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'],
@conversation.contact,
@message
@conversation.contact
)
render_response(response)
end
@@ -1,29 +0,0 @@
module PortalHomeData
extend ActiveSupport::Concern
private
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
-5
View File
@@ -1,6 +1,5 @@
class DashboardController < ActionController::Base
include SwitchLocale
include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[
LOGO
@@ -64,10 +63,6 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
if @portal.layout == 'documentation'
request.variant = :documentation
load_home_data
end
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -39,11 +39,9 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
# while UI translations fall back to an available I18n locale (e.g. th).
@locale = params[:locale]
@locale = validate_and_get_locale(params[:locale])
I18n.with_locale(validate_and_get_locale(@locale), &)
I18n.with_locale(@locale, &)
end
def switch_locale_with_article(&)
@@ -51,12 +49,13 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
@locale = if article.category.present?
article.category.locale
else
article.locale
end
I18n.with_locale(validate_and_get_locale(@locale), &)
article_locale = if article.category.present?
article.category.locale
else
article.locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
end
def allow_iframe_requests
@@ -1,6 +1,4 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
include PortalHomeData
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
@@ -33,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -35,8 +35,7 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
#
def resource_params
permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params
end
+1 -4
View File
@@ -18,7 +18,6 @@ class AccountDashboard < Administrate::BaseDashboard
# Add all_features last so it appears after manually_managed_features
attributes[:all_features] = AccountFeaturesField
attributes[:captain_models] = CaptainModelOverridesField
attributes
else
@@ -58,7 +57,6 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[custom_attributes limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -81,7 +79,6 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -120,7 +117,7 @@ class AccountDashboard < Administrate::BaseDashboard
# to prevent an error from being raised (wrong number of arguments)
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
def permitted_attributes(action)
attrs = super + [limits: {}, captain_models: {}]
attrs = super + [limits: {}]
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
@@ -20,7 +20,6 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -29,18 +28,13 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
@@ -168,7 +168,6 @@ const selectEmoji = emoji => {
<Button
v-if="showRemoveButton && value"
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
type="button"
variant="ghost"
color="slate"
size="sm"
@@ -1,7 +1,9 @@
<script setup>
import { computed } from 'vue';
import { computed, onMounted } from 'vue';
import BaseBubble from './Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
import Icon from 'next/icon/Icon.vue';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import { useMessageContext } from '../provider.js';
const { attachments } = useMessageContext();
@@ -9,11 +11,28 @@ const { attachments } = useMessageContext();
const attachment = computed(() => {
return attachments.value[0];
});
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
mediaType: 'audio',
});
onMounted(() => {
if (attachment.value?.dataUrl) {
loadWithRetry(attachment.value.dataUrl);
}
});
</script>
<template>
<BaseBubble class="bg-transparent" data-bubble-name="audio">
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
<p class="mb-0 text-n-slate-11">
{{ $t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
</p>
</div>
<AudioChip
v-else-if="isLoaded"
:attachment="attachment"
class="p-2 text-n-slate-12 skip-context-menu"
/>
@@ -34,7 +34,6 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x-bold',
};
const { t } = useI18n();
@@ -82,11 +81,7 @@ const isWhatsapp = computed(
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isFailed = computed(() =>
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(status.value)
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
const endReason = computed(() => call.value?.endReason);
@@ -90,7 +90,6 @@ export const VOICE_CALL_STATUS = {
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
REJECTED: 'rejected',
};
export const VOICE_CALL_DIRECTION = {
@@ -31,22 +31,12 @@ export default {
}
},
methods: {
createErrorMessage(error) {
const responseError = error?.response?.data?.error;
if (typeof responseError === 'string') return responseError;
return (
responseError?.error?.message ||
responseError?.message ||
this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')
);
},
async onClick() {
this.isLoading = true;
try {
await DyteAPI.createAMeeting(this.conversationId);
} catch (error) {
useAlert(this.createErrorMessage(error));
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
} finally {
this.isLoading = false;
}
@@ -21,7 +21,6 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -30,18 +29,13 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
@@ -1,8 +1,9 @@
import { ref } from 'vue';
export const useLoadWithRetry = (config = {}) => {
const maxRetry = config.max_retry || 3;
const backoff = config.backoff || 1000;
const maxRetry = (config.max_retry ?? config.maxRetry) || 3;
const backoff = (config.backoff ?? config.backOff) || 1000;
const mediaType = (config.mediaType ?? config.media_type) || 'image';
const isLoaded = ref(false);
const hasError = ref(false);
@@ -10,19 +11,25 @@ export const useLoadWithRetry = (config = {}) => {
const loadWithRetry = async url => {
const attemptLoad = () => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const onSuccess = () => {
isLoaded.value = true;
hasError.value = false;
resolve();
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
img.src = url;
let element;
if (mediaType === 'audio') {
element = new Audio();
element.preload = 'metadata';
element.onloadedmetadata = onSuccess;
} else {
element = new Image();
element.onload = onSuccess;
}
element.onerror = () => reject(new Error('Failed to load resource'));
const cacheBustedUrl = new URL(url);
cacheBustedUrl.searchParams.set('t', Date.now());
element.src = cacheBustedUrl.toString();
});
};
@@ -163,11 +163,6 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Context::NoToolbar': {
marks: ['strong', 'em', 'link'],
nodes: ['bulletList', 'orderedList'],
menu: [],
},
};
// Editor menu options for Full Editor
@@ -162,7 +162,4 @@ export const SESSION_EVENTS = Object.freeze({
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
INBOX_SETUP_VISITED: 'Onboarding: Inbox setup visited',
INBOX_SETUP_COMPLETED: 'Onboarding: Inbox setup completed',
INBOX_SETUP_SKIPPED: 'Onboarding: Inbox setup skipped',
});
-18
View File
@@ -13,24 +13,6 @@ export const INBOX_TYPES = {
TIKTOK: 'Channel::Tiktok',
};
// Short channel-type slugs used to identify a channel without leaning on its
// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps.
export const CHANNEL_TYPES = {
WEBSITE: 'website',
WHATSAPP: 'whatsapp',
FACEBOOK: 'facebook',
INSTAGRAM: 'instagram',
TIKTOK: 'tiktok',
TELEGRAM: 'telegram',
LINE: 'line',
GMAIL: 'gmail',
OUTLOOK: 'outlook',
SMS: 'sms',
API: 'api',
VOICE: 'voice',
EMAIL: 'email',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
-1
View File
@@ -7,7 +7,6 @@ export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'rejected',
'no-answer',
'canceled',
'missed',
@@ -30,59 +30,5 @@
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
},
"ONBOARDING_INBOX_SETUP": {
"GREETING": "Let's set up a few things",
"SUBTITLE": "This will give you head-start in your workspace",
"CONTINUE": "Continue",
"SKIP": "Skip",
"ERROR": "Something went wrong. Please try again.",
"WHATSAPP_CONNECTED": "WhatsApp connected successfully",
"FACEBOOK_CONNECTED": "Facebook connected successfully",
"CREATED_FOR_YOU": {
"TITLE": "We've created the following for you",
"LIVE_CHAT": "Live Chat widget",
"LIVE_CHAT_DESCRIPTION": "Instant messenger for your website",
"LIVE_CHAT_STATUS": "Almost done…",
"LIVE_CHAT_READY": "Ready",
"HELP_CENTER": "Help Center",
"HELP_CENTER_DESCRIPTION": "Your digital encyclopedia",
"HELP_CENTER_GENERATING": "Creating your help center…",
"HELP_CENTER_ANALYZING_WEBSITE": "Analyzing your website…",
"HELP_CENTER_SETTING_UP_CATEGORIES": "Setting up categories…",
"HELP_CENTER_CURATING_ARTICLES": "Curating articles…",
"HELP_CENTER_ARTICLES": "Created {count} article | Created {count} articles",
"HELP_CENTER_CATEGORIES": "{count} category | {count} categories",
"HELP_CENTER_SUMMARY": "Created {count} article across {categories} | Created {count} articles across {categories}"
},
"CHANNELS": {
"TITLE": "Connect all your conversation channels",
"HEADER": "We found a few channels you can connect",
"CONNECT": "Connect",
"CONNECTED": "Connected",
"MORE_CHANNELS_NOTE": "Set up {email} and {voice} channels later inside the app",
"MORE_CHANNELS_EMAIL": "Email",
"MORE_CHANNELS_VOICE": "Voice",
"VIEW_ALL": "View all",
"GMAIL": "Gmail",
"OUTLOOK": "Outlook",
"OTHER_EMAIL": "Other Email Providers"
},
"CHANNELS_DIALOG": {
"TITLE": "Connect all your channels instantly",
"SUBTITLE": "Manage all of them from one dashboard. You can also set up and edit inboxes later inside the app.",
"NOTE": "SMS, API, Voice, and other email providers can be set up later from your dashboard.",
"CONNECT_TITLE": "Connect your {name} account",
"CONNECT_SUBTITLE": "Fill out these quick details",
"CONNECT": "Connect",
"BACK": "Back",
"SETUP_LATER": "Setup later in app",
"FACEBOOK_SUBTITLE": "Authorize access and choose a Page to connect.",
"FACEBOOK_LAUNCH": "Continue with Facebook",
"FACEBOOK_SELECT_PAGE": "Select a Page to connect",
"FACEBOOK_LOADING": "Loading your Facebook Pages…",
"FACEBOOK_NO_PAGES": "No connectable Pages found. All your Pages are already connected.",
"FACEBOOK_ERROR": "Couldn't connect to Facebook. Please try again."
}
}
}
@@ -296,6 +296,7 @@
},
"MEDIA": {
"IMAGE_UNAVAILABLE": "This image is no longer available.",
"AUDIO_UNAVAILABLE": "This audio is no longer available.",
"LOADING_FAILED": "Loading failed"
}
},
@@ -440,9 +441,7 @@
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts.",
"MODEL_TITLE": "Audio Transcription Model",
"MODEL_DESCRIPTION": "Select the AI model to use for converting audio messages into text transcripts"
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
},
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
@@ -800,14 +799,10 @@
},
"FAIR_DISTRIBUTION": {
"LABEL": "Fair distribution policy",
"DESCRIPTION": "Cap conversations per agent within a time window to avoid overload. Defaults to 100 per hour.",
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
"INPUT_MAX": "Assign max",
"DURATION": "Conversations per agent in every"
},
"EXCLUDE_OLDER_THAN": {
"LABEL": "Skip stale conversations",
"DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable."
},
"INBOXES": {
"LABEL": "Added inboxes",
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
@@ -6,7 +6,6 @@ import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
import {
ACCOUNT_EVENTS,
@@ -120,20 +119,16 @@ export default {
handleClose(e) {
this.$emit('close', e);
},
async handleTranslate() {
handleTranslate() {
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
const agentLocale = this.getUISettings?.locale;
const targetLanguage = agentLocale || accountLocale || 'en';
try {
await this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage,
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
} catch (error) {
useAlert(parseAPIErrorResponse(error));
}
this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage,
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
this.handleClose();
},
handleReplyTo() {
@@ -252,7 +252,6 @@ export default {
<MultiselectDropdown
:options="teamsList"
:selected-item="assignedTeam"
show-emoji-icon
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
@@ -13,7 +13,6 @@ import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
import OnboardingAccountDetails from './onboarding/Index.vue';
import OnboardingInboxSetup from './onboarding/InboxSetup.vue';
export default {
routes: [
@@ -41,14 +40,6 @@ export default {
},
component: OnboardingAccountDetails,
},
{
path: frontendURL('accounts/:accountId/onboarding/inbox-setup'),
name: 'onboarding_inbox_setup',
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
component: OnboardingInboxSetup,
},
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
@@ -1,158 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import OnboardingLayout from './shared/OnboardingLayout.vue';
import OnboardingSection from './shared/OnboardingSection.vue';
import InboxChannelsDialog from './inbox-setup/InboxChannelsDialog.vue';
import InboxChannelsFooter from './inbox-setup/InboxChannelsFooter.vue';
import ChannelRow from './inbox-setup/ChannelRow.vue';
import WebWidgetCreationStatus from './inbox-setup/WebWidgetCreationStatus.vue';
import HelpCenterCreationStatus from './inbox-setup/HelpCenterCreationStatus.vue';
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
import { useChannelConnect } from './inbox-setup/useChannelConnect';
import { useDetectedChannels } from './inbox-setup/useDetectedChannels';
import { DIALOG_CHANNELS } from './inbox-setup/constants';
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const { accountId, currentAccount, finishOnboarding } = useAccount();
const { isEnterprise } = useConfig();
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
const helpCenterGenerationId = computed(
() => currentAccount.value?.custom_attributes?.help_center_generation_id
);
const isSubmitting = ref(false);
const inboxes = useMapGetter('inboxes/getInboxes');
const {
displayedChannels,
remainingChannels,
connectedInbox,
hasDetectedChannels,
} = useDetectedChannels();
const channelsDialogRef = ref(null);
// The initial inboxes fetch happens in WebWidgetCreationStatus, which polls
// `inboxes/get` from its own mount — no need to dispatch it here too.
onMounted(() => {
useTrack(ONBOARDING_EVENTS.INBOX_SETUP_VISITED);
});
const completeOnboarding = async event => {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
// Declare the step we're completing so the controller only clears it when
// the stored step still matches (idempotent). setUser then refreshes the
// auth store so the router guard sees the cleared step and lets us in.
await finishOnboarding({ onboarding_step: 'inbox_setup' });
useTrack(event);
await store.dispatch('setUser');
router.push({ name: 'home', params: { accountId: accountId.value } });
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
} finally {
isSubmitting.value = false;
}
};
const handleContinue = () =>
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_COMPLETED);
const handleSkip = () =>
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_SKIPPED);
const openChannelsDialog = () => channelsDialogRef.value?.open();
const refetchInboxes = () => store.dispatch('inboxes/get');
// WhatsApp connects via Meta's embedded-signup popup; the DIALOG_CHANNELS open
// the channels dialog preselected to their in-dialog step; the rest go through
// the redirect OAuth flow (Gmail/Outlook keyed by email provider, Instagram by
// channel type).
const connectChannel = channel => {
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
connectWhatsapp();
return;
}
if (DIALOG_CHANNELS.includes(channel.type)) {
channelsDialogRef.value?.open(channel.type);
return;
}
connectViaOAuth(channel.inbox?.provider || channel.type);
};
</script>
<template>
<div>
<OnboardingLayout
:greeting="t('ONBOARDING_INBOX_SETUP.GREETING')"
:subtitle="t('ONBOARDING_INBOX_SETUP.SUBTITLE')"
:continue-label="t('ONBOARDING_INBOX_SETUP.CONTINUE')"
:skip-label="t('ONBOARDING_INBOX_SETUP.SKIP')"
:is-loading="isSubmitting"
@continue="handleContinue"
@skip="handleSkip"
>
<template #greeting-icon>
<Icon icon="i-lucide-wrench" class="size-4 text-n-slate-7" />
</template>
<OnboardingSection
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.TITLE')"
icon="i-lucide-sparkles"
>
<div class="divide-y divide-n-weak">
<WebWidgetCreationStatus />
<HelpCenterCreationStatus
v-if="isEnterprise && helpCenterGenerationId"
/>
</div>
</OnboardingSection>
<OnboardingSection
:title="t('ONBOARDING_INBOX_SETUP.CHANNELS.TITLE')"
icon="i-lucide-inbox"
>
<div
v-if="hasDetectedChannels"
class="flex items-center gap-2 p-3 border-b border-dashed border-n-strong"
>
<Icon
icon="i-lucide-lightbulb"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
<span class="flex-1 min-w-0 text-body-main text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.HEADER') }}
</span>
</div>
<ChannelRow
v-for="channel in displayedChannels"
:key="channel.type"
:channel="channel"
:connected-inbox="connectedInbox(channel)"
@connect="connectChannel"
/>
<InboxChannelsFooter
:remaining-channels="remainingChannels"
@view-all="openChannelsDialog"
/>
</OnboardingSection>
</OnboardingLayout>
<InboxChannelsDialog
ref="channelsDialogRef"
:inboxes="inboxes"
@connected="refetchInboxes"
/>
</div>
</template>
@@ -1,22 +1,21 @@
<script setup>
import { ref, computed, nextTick, onMounted } from 'vue';
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import OnboardingLayout from './shared/OnboardingLayout.vue';
import OnboardingSection from './shared/OnboardingSection.vue';
import OnboardingFormRow from './account-details/OnboardingFormRow.vue';
import OnboardingFormSelect from './account-details/OnboardingFormSelect.vue';
import { useAccountEnrichment } from './account-details/useAccountEnrichment';
import OnboardingLayout from './OnboardingLayout.vue';
import OnboardingSection from './OnboardingSection.vue';
import OnboardingFormRow from './OnboardingFormRow.vue';
import OnboardingFormSelect from './OnboardingFormSelect.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import {
@@ -24,16 +23,12 @@ import {
INDUSTRY_OPTIONS,
REFERRAL_SOURCE_OPTIONS,
USER_ROLE_OPTIONS,
} from './shared/constants';
} from './constants';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, finishOnboarding } = useAccount();
// Where each onboarding cursor routes. The backend owns which steps run where;
// the frontend just follows the cursor it advanced us to (no deployment checks).
const STEP_ROUTES = { inbox_setup: 'onboarding_inbox_setup' };
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
@@ -51,10 +46,7 @@ const showErrorOnFields = ref(false);
const validationRules = {
userRole: {},
// Website is required: the onboarding web-widget inbox can't be created
// without a URL (Channel::WebWidget validates presence), so a blank value
// would leave the "Live Chat widget" status polling forever.
website: { required },
website: {},
locale: {},
timezone: {},
companySize: {},
@@ -75,15 +67,12 @@ const v$ = useVuelidate(validationRules, {
const userName = computed(() => currentUser.value?.name || '');
const userEmail = computed(() => currentUser.value?.email || '');
const accountName = computed(() => currentAccount.value?.name || '');
const { isEnriching, getChangedFields } = useAccountEnrichment({
locale,
website,
timezone,
companySize,
industry,
referralSource,
});
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
const companyLogo = computed(() => {
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
if (!logos?.length) return '';
@@ -109,10 +98,94 @@ const timezoneOptions = computed(() => {
}
});
// Best-effort match browser language to enabled Chatwoot locales.
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
// falls back to account locale or 'en'.
const detectBestLocale = () => {
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
const browserLang = navigator.language?.replace('-', '_');
const accountLocale = currentAccount.value?.locale || 'en';
if (!browserLang) return accountLocale;
if (codes.includes(browserLang)) return browserLang;
const base = browserLang.split('_')[0];
if (codes.includes(base)) return base;
return accountLocale;
};
// Snapshot of auto-populated values to detect user edits at submit time
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const account = currentAccount.value;
const attrs = account?.custom_attributes || {};
const brandInfo = attrs.brand_info;
if (!locale.value) locale.value = detectBestLocale();
if (!website.value) {
website.value = attrs.website || brandInfo?.domain || '';
}
if (!timezone.value) {
timezone.value =
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
}
if (!companySize.value) companySize.value = attrs.company_size || '';
if (!industry.value) {
industry.value =
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
}
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, 30000);
};
onMounted(() => {
populateFormFields();
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, newVal => {
if (newVal) {
startEnrichmentTimer();
} else {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
populateFormFields();
}
});
// Re-populate when account data arrives after mount, or when brand_info
// appears after enrichment. populateFormFields is idempotent so this is safe.
watch(
() => currentAccount.value?.custom_attributes,
() => populateFormFields()
);
const enableWebsiteEditing = () => {
isEditingWebsite.value = true;
nextTick(() => websiteInput.value?.focus());
@@ -131,10 +204,8 @@ const normalizeWebsiteUrl = raw => {
const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass
// the form with empty values — the controller would otherwise clear
// onboarding_step and persist incomplete data. Also guard against
// re-entry while a submit is in flight (double-click/Enter), which would
// fire parallel requests that can duplicate the auto-created inbox/portal.
if (isEnriching.value || isSubmitting.value) return;
// onboarding_step and persist incomplete data.
if (isEnriching.value) return;
v$.value.$touch();
if (v$.value.$invalid) {
@@ -143,15 +214,22 @@ const handleSubmit = async () => {
setTimeout(() => {
showErrorOnFields.value = false;
}, 600);
// The website field is read-only until edited; open it so a required-but-
// empty value is immediately fixable rather than just shaking a locked field.
if (v$.value.website.$error) enableWebsiteEditing();
return;
}
// Capture which enrichable fields the user edited *before* normalizing the
// website, so an untouched auto-filled domain isn't falsely flagged.
const fieldsChanged = getChangedFields();
// Detect which enrichable fields the user actually edited *before*
// normalizing — otherwise an untouched auto-filled domain
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
// and gets falsely reported as changed, skewing onboarding telemetry.
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
const fieldsChanged = Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key);
// Persist with a scheme so downstream consumers (Firecrawl, portal
// homepage_link) get a fully-qualified URL regardless of what the user typed.
@@ -168,7 +246,6 @@ const handleSubmit = async () => {
timezone: timezone.value,
referral_source: referralSource.value,
user_role: userRole.value,
onboarding_step: 'account_details',
});
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
@@ -183,19 +260,8 @@ const handleSubmit = async () => {
});
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
// Follow the cursor the backend advanced us to. A next step routes there; no
// next step means onboarding is complete, so refresh the user (so the router
// guard sees the cleared cursor) and head to the dashboard.
const nextStep = currentAccount.value?.custom_attributes?.onboarding_step;
if (STEP_ROUTES[nextStep]) {
router.push({
name: STEP_ROUTES[nextStep],
params: { accountId: accountId.value },
});
} else {
await store.dispatch('setUser');
router.push({ name: 'home', params: { accountId: accountId.value } });
}
store.commit('RESET_ONBOARDING', accountId.value);
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
} catch {
useAlert(t('ONBOARDING_NEXT.ERROR'));
} finally {
@@ -5,12 +5,11 @@ defineProps({
greeting: { type: String, required: true },
subtitle: { type: String, default: '' },
continueLabel: { type: String, default: 'Continue' },
skipLabel: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
defineEmits(['continue', 'skip']);
defineEmits(['continue']);
</script>
<template>
@@ -21,7 +20,7 @@ defineEmits(['continue', 'skip']);
<div
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
/>
<div class="relative w-full max-w-[40rem]">
<div class="relative w-full max-w-[580px]">
<div class="relative ps-12">
<!-- Timeline dotted line -->
<svg
@@ -105,30 +104,16 @@ defineEmits(['continue', 'skip']);
>
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
</svg>
<div class="flex items-center gap-3">
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
:class="
skipLabel ? 'flex-1 justify-center' : 'w-full justify-center'
"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
<NextButton
v-if="skipLabel"
type="button"
slate
outline
class="flex-1 justify-center"
@click="$emit('skip')"
>
{{ skipLabel }}
</NextButton>
</div>
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
class="w-full justify-center"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
</div>
</div>
</div>
@@ -4,7 +4,6 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
bare: { type: Boolean, default: false },
});
</script>
@@ -43,12 +42,8 @@ defineProps({
</div>
<!-- Card -->
<div
v-if="!bare"
class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1"
>
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
<slot />
</div>
<slot v-else />
</div>
</template>
@@ -1,136 +0,0 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
const ENRICHMENT_TIMEOUT = 30000;
// Manages the post-signup enrichment lifecycle for the account-details form.
// After signup the account is enriched asynchronously (onboarding_step ===
// 'enrichment'); this fills empty form fields from the enriched
// custom_attributes/brand_info as it arrives — idempotently, so it never
// clobbers a value the user has already typed — waits the step out (with a
// timeout fallback), and tracks which enrichable fields the user edited.
//
// `fields` is the set of form refs to populate, owned by the component so it can
// still wire them to validation and the template.
export function useAccountEnrichment(fields) {
const { currentAccount } = useAccount();
const { enabledLanguages } = useConfig();
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
// Best-effort match browser language to enabled Chatwoot locales: exact match
// first (e.g. 'pt_BR'), then base language (e.g. 'pt'), else the account
// locale or 'en'.
const detectBestLocale = () => {
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
const browserLang = navigator.language?.replace('-', '_');
const accountLocale = currentAccount.value?.locale || 'en';
if (!browserLang) return accountLocale;
if (codes.includes(browserLang)) return browserLang;
const base = browserLang.split('_')[0];
if (codes.includes(base)) return base;
return accountLocale;
};
// Snapshot of the auto-populated values, used to detect user edits at submit.
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: fields.website.value,
company_size: fields.companySize.value,
industry: fields.industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const {
website,
timezone,
company_size: companySize,
industry,
referral_source: referralSource,
brand_info: brandInfo,
} = currentAccount.value?.custom_attributes || {};
const fillIfEmpty = (field, value) => {
if (!field.value) field.value = value || '';
};
fillIfEmpty(fields.locale, detectBestLocale());
fillIfEmpty(fields.website, website || brandInfo?.domain);
fillIfEmpty(
fields.timezone,
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
);
fillIfEmpty(fields.companySize, companySize);
fillIfEmpty(
fields.industry,
industry || brandInfo?.industries?.[0]?.industry
);
fillIfEmpty(fields.referralSource, referralSource);
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, ENRICHMENT_TIMEOUT);
};
onMounted(() => {
populateFormFields();
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, enriching => {
if (enriching) {
startEnrichmentTimer();
} else {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
populateFormFields();
}
});
// Re-populate when account data arrives after mount, or when brand_info
// appears after enrichment. populateFormFields is idempotent so this is safe.
watch(
() => currentAccount.value?.custom_attributes,
() => populateFormFields()
);
// Enrichable fields the user actually edited since they were auto-filled.
// Compare against the snapshot *before* the caller normalizes any values —
// otherwise an untouched auto-filled domain (acme.com -> https://acme.com)
// compares unequal and gets falsely reported as changed.
const getChangedFields = () => {
const init = initialValues.value;
const current = {
website: fields.website.value,
company_size: fields.companySize.value,
industry: fields.industry.value,
};
return Object.entries(current)
.filter(([key, value]) => value !== init[key])
.map(([key]) => key);
};
return { isEnriching, getChangedFields };
}
@@ -1,60 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
const props = defineProps({
channel: { type: Object, required: true },
connectedInbox: { type: Object, default: null },
});
defineEmits(['connect']);
const { t } = useI18n();
const connected = computed(() => Boolean(props.connectedInbox));
// Prefer the real connected account's name over the detected handle — the user
// may have connected a different account than the one we detected.
const connectedName = computed(
() =>
props.connectedInbox?.name ||
props.channel.handle ||
t(props.channel.labelKey)
);
</script>
<template>
<div class="flex items-center gap-2 p-3 border-b border-n-weak">
<div class="size-4 rounded overflow-hidden flex-shrink-0">
<ChannelIcon
:inbox="channel.inbox"
use-brand-icon
class="size-4"
:class="{ grayscale: !connected }"
/>
</div>
<span class="flex-1 min-w-0 truncate text-body-main text-n-slate-12">
{{ t(channel.labelKey) }}
</span>
<div
v-if="connected"
class="flex items-center gap-2 flex-shrink-0 text-body-main text-n-slate-11"
>
<span class="whitespace-nowrap">{{ connectedName }}</span>
<span class="w-px h-3 bg-n-weak" />
<span class="whitespace-nowrap">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECTED') }}
</span>
</div>
<button
v-else
type="button"
class="flex items-center flex-shrink-0 h-7 px-2 rounded-lg outline outline-1 outline-n-container bg-n-button-color text-button text-n-blue-11"
@click="$emit('connect', channel)"
>
<span class="truncate">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECT') }}
</span>
</button>
</div>
</template>
@@ -1,33 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// Presentational shell for the "Created for you" rows. The owning component
// drives `ready` from its own lifecycle (widget polling, help-center events);
// row separation is handled by the parent's `divide-y`.
defineProps({
ready: { type: Boolean, default: false },
title: { type: String, required: true },
description: { type: String, default: '' },
status: { type: String, default: '' },
});
</script>
<template>
<div class="flex items-center justify-between gap-3 px-3 py-3">
<div class="flex items-center gap-2 min-w-0">
<Icon
v-if="ready"
icon="i-lucide-check"
class="size-4 text-n-teal-11 flex-shrink-0"
/>
<Spinner v-else :size="16" class="text-n-slate-9 flex-shrink-0" />
<span class="text-sm font-medium text-n-slate-12 flex-shrink-0">
{{ title }}
</span>
<span class="w-px h-4 bg-n-weak flex-shrink-0" />
<span class="text-sm text-n-slate-11 truncate">{{ description }}</span>
</div>
<span class="text-sm text-n-slate-11 flex-shrink-0">{{ status }}</span>
</div>
</template>
@@ -1,116 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useTimeoutPoll } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import OnboardingAPI from 'dashboard/api/onboarding';
import CreationStatusRow from './CreationStatusRow.vue';
const { t } = useI18n();
const POLL_INTERVAL = 5000;
const generation = ref({
generation_id: null,
state: null,
articles_count: 0,
categories_count: 0,
});
// Before the first article arrives, advance through phases so the spinner
// label doesn't sit on a single line through the whole curation step.
const generatingPhases = computed(() => [
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_GENERATING'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ANALYZING_WEBSITE'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SETTING_UP_CATEGORIES'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CURATING_ARTICLES'),
]);
const PHASE_DELAY_BASE = 4000;
const PHASE_DELAY_JITTER = 2000;
const phaseIndex = ref(0);
let phaseTimer = null;
// Advance one phase after a jittered delay; stop once we reach the last line
// (it then holds until the first article arrives).
const scheduleNextPhase = () => {
if (phaseIndex.value >= generatingPhases.value.length - 1) return;
const delay = PHASE_DELAY_BASE + Math.random() * PHASE_DELAY_JITTER;
phaseTimer = setTimeout(() => {
phaseIndex.value += 1;
scheduleNextPhase();
}, delay);
};
const status = computed(
() =>
generation.value.state?.status ||
(generation.value.generation_id ? 'generating' : 'not_started')
);
const isCompleted = computed(() => status.value === 'completed');
const isSkipped = computed(() => status.value === 'skipped');
const isNotStarted = computed(() => status.value === 'not_started');
const isTerminal = computed(
() => isCompleted.value || isSkipped.value || isNotStarted.value
);
const articlesCount = computed(() => generation.value.articles_count || 0);
const categoriesCount = computed(() => generation.value.categories_count || 0);
// Poll the generation status until it reaches a terminal state. useTimeoutPoll
// waits for each request to settle before scheduling the next (so requests never
// overlap), fires immediately on mount, and stops on unmount.
const { pause: stopPolling } = useTimeoutPoll(
async () => {
try {
const { data } = await OnboardingAPI.getHelpCenterGeneration();
generation.value = data;
if (isTerminal.value) stopPolling();
} catch {
// Keep polling; transient network failures should not strand onboarding.
}
},
POLL_INTERVAL,
{ immediate: true }
);
onMounted(scheduleNextPhase);
onBeforeUnmount(() => clearTimeout(phaseTimer));
const articlesText = computed(() =>
t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ARTICLES',
{ count: articlesCount.value },
articlesCount.value
)
);
const statusText = computed(() => {
if (isCompleted.value) {
const categories = t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CATEGORIES',
{ count: categoriesCount.value },
categoriesCount.value
);
return t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SUMMARY',
{ count: articlesCount.value, categories },
articlesCount.value
);
}
if (articlesCount.value > 0) return articlesText.value;
return generatingPhases.value[phaseIndex.value];
});
const isVisible = computed(() => !isSkipped.value && !isNotStarted.value);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<CreationStatusRow
v-if="isVisible"
:ready="isCompleted"
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER')"
:description="
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_DESCRIPTION')
"
:status="statusText"
/>
</template>
@@ -1,139 +0,0 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
channel: { type: Object, required: true },
});
const emit = defineEmits(['back', 'created']);
const { t } = useI18n();
const store = useStore();
// Per-channel field list + payload shape, mirroring the standalone settings
// forms (channels/Line.vue, channels/Telegram.vue). Labels/placeholders reuse
// the existing INBOX_MGMT translations so there's nothing new to localize.
const FORMS = {
line: {
fields: [
{
key: 'lineChannelId',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.PLACEHOLDER'
),
},
{
key: 'lineChannelSecret',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.PLACEHOLDER'
),
type: 'password',
},
{
key: 'lineChannelToken',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.PLACEHOLDER'
),
type: 'password',
},
],
errorMessage: t('INBOX_MGMT.ADD.LINE_CHANNEL.API.ERROR_MESSAGE'),
// Inbox name is mandatory for Line; prefill it from the channel label
// rather than asking, to keep the form to just the credentials.
buildPayload: values => ({
name: t(props.channel.labelKey),
channel: {
type: 'line',
line_channel_id: values.lineChannelId,
line_channel_secret: values.lineChannelSecret,
line_channel_token: values.lineChannelToken,
},
}),
},
telegram: {
fields: [
{
key: 'botToken',
label: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.LABEL'),
placeholder: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.PLACEHOLDER'),
type: 'password',
},
],
errorMessage: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.API.ERROR_MESSAGE'),
buildPayload: values => ({
channel: { type: 'telegram', bot_token: values.botToken },
}),
},
};
const config = FORMS[props.channel.type];
const values = reactive(
Object.fromEntries(config.fields.map(field => [field.key, '']))
);
const isCreating = ref(false);
const isValid = computed(() =>
config.fields.every(field => values[field.key].trim())
);
const submit = async () => {
if (!isValid.value || isCreating.value) return;
isCreating.value = true;
try {
await store.dispatch('inboxes/createChannel', config.buildPayload(values));
emit('created');
} catch (error) {
useAlert(error?.message || config.errorMessage);
} finally {
isCreating.value = false;
}
};
</script>
<!-- This sits inside the dialog's <form>; intercept Enter so it submits the
channel here instead of bubbling up and completing onboarding. -->
<template>
<div class="flex flex-col gap-4" @keydown.enter.prevent="submit">
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
<template v-for="field in config.fields" :key="field.key">
<label :for="field.key" class="text-sm font-medium text-n-slate-12">
{{ field.label }}
</label>
<Input
:id="field.key"
v-model="values[field.key]"
:type="field.type || 'text'"
:placeholder="field.placeholder"
/>
</template>
</div>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:is-loading="isCreating"
:disabled="!isValid"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
@click="submit"
/>
</div>
</div>
</template>
@@ -1,210 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
import { useChannelConnect } from './useChannelConnect';
import { useChannelConfig } from './useChannelConfig';
import { CHANNEL_LIST } from './constants';
import { isChannelConnected } from './channelMatchers';
import InboxChannelForm from './InboxChannelForm.vue';
import InboxFacebookForm from './InboxFacebookForm.vue';
const props = defineProps({
inboxes: { type: Array, default: () => [] },
});
const emit = defineEmits(['connected']);
const { t } = useI18n();
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
const { isConfigured } = useChannelConfig();
// Maps the dialog's display types to the OAuth client key the flow expects.
// Types without an entry (manual-setup channels) are no-ops for now.
const OAUTH_PROVIDERS = {
[CHANNEL_TYPES.GMAIL]: 'google',
[CHANNEL_TYPES.OUTLOOK]: 'microsoft',
[CHANNEL_TYPES.INSTAGRAM]: 'instagram',
[CHANNEL_TYPES.TIKTOK]: 'tiktok',
};
// A card's availability — what the user can do with it right now:
// available — usable now (configured, not deferred)
// setupLater — deferred to in-app setup (SMS/API/Voice/Email cards)
// Channels whose installation OAuth credentials are missing are hidden entirely
// (see channelCards), so they never reach this state.
// `connected` (a real inbox already backs it) is orthogonal and tracked
// separately, since a connected channel can still be in any of these states.
const channelAvailability = channel =>
channel.setupLater ? 'setupLater' : 'available';
const CARD_CLASS = {
available: 'bg-n-solid-1 hover:outline-n-slate-6 cursor-pointer',
setupLater: 'bg-n-slate-2 cursor-not-allowed',
};
// Decorate the catalog with per-render state so the template reads plain fields
// rather than calling predicates for each card. Channels needing an absent
// installation credential are dropped so they don't show at all; deferred
// (setupLater) channels stay since they aren't a configuration problem.
const channelCards = computed(() =>
CHANNEL_LIST.filter(
channel => channel.setupLater || isConfigured(channel.type)
).map(channel => {
const connected = isChannelConnected(props.inboxes, channel.inbox);
// Website inboxes are only auto-created during onboarding — there is no
// manual creation path, so an unconnected Website card defers rather than
// offering a click that can't do anything.
const availability =
channel.type === CHANNEL_TYPES.WEBSITE && !connected
? 'setupLater'
: channelAvailability(channel);
return { ...channel, availability, connected };
})
);
const dialogRef = ref(null);
// Credential-form channels (Line, Telegram) and Facebook swap the grid for an
// inline view; OAuth channels redirect; the rest are no-ops for now.
const selectedChannel = ref(null);
// An inbox was created by an in-dialog form (Line/Telegram credentials or the
// Facebook page picker); close the dialog (its @close resets the form view) and
// let the parent refetch so the connected state and real channel icons update.
const onCreated = () => {
dialogRef.value?.close();
emit('connected');
};
const onCardClick = channel => {
if (channel.availability !== 'available') return;
if (channel.form) {
selectedChannel.value = channel;
return;
}
// WhatsApp uses Meta's embedded-signup popup, not the redirect OAuth flow.
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
connectWhatsapp();
return;
}
// Facebook swaps to an in-dialog page picker (FB.login → choose a Page).
if (channel.type === CHANNEL_TYPES.FACEBOOK) {
selectedChannel.value = channel;
return;
}
connectViaOAuth(OAUTH_PROVIDERS[channel.type]);
};
const dialogTitle = computed(() =>
selectedChannel.value
? t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_TITLE', {
name: t(selectedChannel.value.labelKey),
})
: t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.TITLE')
);
const dialogDescription = computed(() => {
if (!selectedChannel.value) {
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SUBTITLE');
}
if (selectedChannel.value.type === CHANNEL_TYPES.FACEBOOK) {
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SUBTITLE');
}
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_SUBTITLE');
});
const open = preselectType => {
const entry = preselectType
? channelCards.value.find(channel => channel.type === preselectType)
: null;
// Only jump straight into a channel's view when it's actually usable;
// otherwise show the grid (with its muted card) rather than launching SDK
// auth with a missing credential.
selectedChannel.value = entry?.availability === 'available' ? entry : null;
dialogRef.value?.open();
};
const close = () => dialogRef.value?.close();
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="dialogTitle"
:description="dialogDescription"
width="lg"
:show-confirm-button="false"
:show-cancel-button="false"
@close="selectedChannel = null"
>
<InboxFacebookForm
v-if="selectedChannel?.type === CHANNEL_TYPES.FACEBOOK"
@back="selectedChannel = null"
@created="onCreated"
/>
<InboxChannelForm
v-else-if="selectedChannel"
:channel="selectedChannel"
@back="selectedChannel = null"
@created="onCreated"
/>
<template v-else>
<div class="grid grid-cols-2 gap-3">
<button
v-for="channel in channelCards"
:key="channel.type"
type="button"
:disabled="channel.availability !== 'available'"
class="flex items-center gap-3 p-3 rounded-xl outline outline-1 outline-n-weak shadow-[0px_1px_2px_0px_rgba(27,28,29,0.036)] transition-colors text-start"
:class="CARD_CLASS[channel.availability]"
@click="onCardClick(channel)"
>
<div
class="size-9 rounded-[10px] outline outline-1 outline-n-weak flex items-center justify-center flex-shrink-0"
>
<ChannelIcon
v-if="channel.inbox"
:inbox="channel.inbox"
use-brand-icon
class="size-5 text-n-slate-11"
/>
<Icon
v-else
:icon="channel.fallbackIcon"
class="size-4 text-n-slate-11"
/>
</div>
<div class="flex-1 min-w-0">
<span class="block text-sm font-medium text-n-slate-12">
{{ t(channel.labelKey) }}
</span>
<span
v-if="channel.availability === 'setupLater'"
class="block text-xs text-n-slate-11"
>
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SETUP_LATER') }}
</span>
</div>
<Icon
v-if="channel.connected"
icon="i-lucide-circle-check"
class="size-5 text-n-teal-11"
/>
<Icon
v-else-if="channel.availability === 'available'"
icon="i-lucide-chevron-right"
class="size-5 text-n-slate-9"
/>
</button>
</div>
<p class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.NOTE') }}
</p>
</template>
</Dialog>
</template>
@@ -1,70 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { FALLBACK_PREVIEW_CHANNELS } from './constants';
import { useChannelConfig } from './useChannelConfig';
const props = defineProps({
remainingChannels: { type: Array, default: () => [] },
});
defineEmits(['viewAll']);
const { t } = useI18n();
const { isConfigured } = useChannelConfig();
// Icons shown next to "View all". Defaults to the unconnected socials, but when
// everything detected is already connected we still want a hint of what's
// behind the dialog — fall back to a representative trio. Either way, drop
// channels whose installation credentials are missing so we never preview an
// icon the dialog itself hides.
const previewChannels = computed(() =>
(props.remainingChannels.length
? props.remainingChannels
: FALLBACK_PREVIEW_CHANNELS
).filter(channel => isConfigured(channel.type))
);
</script>
<template>
<div class="flex items-center gap-2 px-3 pt-3 pb-4">
<Icon icon="i-lucide-info" class="size-4 text-n-slate-9 flex-shrink-0" />
<I18nT
keypath="ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_NOTE"
tag="span"
class="flex-1 min-w-0 text-body-main text-n-slate-11"
>
<template #email>
<span class="text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_EMAIL') }}
</span>
</template>
<template #voice>
<span class="text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_VOICE') }}
</span>
</template>
</I18nT>
<div class="flex items-center gap-2 flex-shrink-0">
<ChannelIcon
v-for="channel in previewChannels"
:key="channel.type"
:inbox="channel.inbox"
use-brand-icon
class="size-5"
/>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ps-1">
<span class="w-px h-3 bg-n-weak" />
<button
type="button"
class="text-button text-n-slate-12 hover:underline"
@click="$emit('viewAll')"
>
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.VIEW_ALL') }}
</button>
</div>
</div>
</template>
@@ -1,157 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const emit = defineEmits(['back', 'created']);
const { t } = useI18n();
const store = useStore();
const { isAuthenticating, preloadSdk, loginAndFetchPages } =
useFacebookPageConnect();
// null until the login + page fetch completes.
const pages = ref(null);
const userAccessToken = ref('');
const selectedPageId = ref('');
const errored = ref(false);
const isCreating = ref(false);
const hasFetched = computed(() => pages.value !== null);
// Pages already connected as inboxes come back with `exists: true`.
const pageOptions = computed(() =>
(pages.value || [])
.filter(page => !page.exists)
.map(page => ({ value: page.id, label: page.name }))
);
// Warm the SDK on open so the login click opens its popup within the gesture's
// activation window (see useFacebookPageConnect).
onMounted(preloadSdk);
const connect = async () => {
errored.value = false;
try {
const result = await loginAndFetchPages();
if (!result) return; // cancelled — stay on the connect prompt
userAccessToken.value = result.userAccessToken;
pages.value = result.pages;
} catch {
errored.value = true;
}
};
const createInbox = async () => {
const page = (pages.value || []).find(p => p.id === selectedPageId.value);
if (!page || isCreating.value) return;
isCreating.value = true;
try {
await store.dispatch('inboxes/createFBChannel', {
user_access_token: userAccessToken.value,
page_access_token: page.access_token,
page_id: page.id,
inbox_name: page.name,
});
useAlert(t('ONBOARDING_INBOX_SETUP.FACEBOOK_CONNECTED'));
emit('created');
} catch (error) {
useAlert(parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR'));
} finally {
isCreating.value = false;
}
};
</script>
<template>
<div class="flex flex-col gap-4">
<div
v-if="isAuthenticating"
class="flex items-center justify-center gap-3 py-8"
>
<Spinner :size="16" class="text-n-blue-10" />
<span class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LOADING') }}
</span>
</div>
<template v-else-if="hasFetched && pageOptions.length">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE') }}
</label>
<ComboBox
v-model="selectedPageId"
:options="pageOptions"
:placeholder="
t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE')
"
/>
</div>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:is-loading="isCreating"
:disabled="!selectedPageId"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
@click="createInbox"
/>
</div>
</template>
<template v-else-if="hasFetched">
<p class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES') }}
</p>
<div class="flex">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
</div>
</template>
<template v-else>
<p v-if="errored" class="text-sm text-n-ruby-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR') }}
</p>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH')"
@click="connect"
/>
</div>
</template>
</div>
</template>
@@ -1,44 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useTimeoutPoll } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import CreationStatusRow from './CreationStatusRow.vue';
const POLL_INTERVAL = 5000;
const { t } = useI18n();
const store = useStore();
// The web widget inbox is created asynchronously during account setup. We poll
// the inboxes endpoint until it shows up, then stop — much simpler than the
// event-driven help center flow.
const websiteInboxes = useMapGetter('inboxes/getWebsiteInboxes');
const isReady = computed(() => websiteInboxes.value.length > 0);
// useTimeoutPoll waits for each fetch to settle before scheduling the next (so
// requests never overlap), fires immediately on mount, and stops on unmount.
const { pause } = useTimeoutPoll(
async () => {
await store.dispatch('inboxes/get');
if (isReady.value) pause();
},
POLL_INTERVAL,
{ immediate: true }
);
</script>
<template>
<CreationStatusRow
:ready="isReady"
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT')"
:description="
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_DESCRIPTION')
"
:status="
isReady
? t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_READY')
: t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_STATUS')
"
/>
</template>
@@ -1,18 +0,0 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
// A detected channel maps to a real inbox when they share a channel_type. Gmail
// and Outlook both use Channel::Email, so for email we also match on provider.
// `stub` is a channel's `{ channel_type, provider }` shape (e.g. channel.inbox).
// Returns the matching inbox (not a boolean) so callers can show the connected
// account's real name rather than the detected handle.
export const findConnectedInbox = (inboxes, stub) =>
inboxes.find(
inbox =>
inbox.channel_type === stub?.channel_type &&
(stub?.channel_type !== INBOX_TYPES.EMAIL ||
inbox.provider === stub?.provider)
);
export const isChannelConnected = (inboxes, stub) =>
Boolean(stub) && Boolean(findConnectedInbox(inboxes, stub));
@@ -1,149 +0,0 @@
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
// Channels whose connect flow opens the channels dialog preselected to their
// in-dialog step — Facebook (page picker) and the credential-form channels
// (Telegram, Line) — rather than redirecting through OAuth.
export const DIALOG_CHANNELS = [
CHANNEL_TYPES.FACEBOOK,
CHANNEL_TYPES.TELEGRAM,
CHANNEL_TYPES.LINE,
];
// Suggested channels (in priority order) to offer as rows when nothing is
// detected, so the step isn't empty. The mainstream OAuth channels show on
// configured installs, while credential-free Telegram/LINE keep the list
// non-empty on a bare self-host.
export const DEFAULT_CHANNEL_TYPES = [
CHANNEL_TYPES.WHATSAPP,
CHANNEL_TYPES.FACEBOOK,
CHANNEL_TYPES.INSTAGRAM,
CHANNEL_TYPES.TELEGRAM,
CHANNEL_TYPES.LINE,
];
// Channels offered in the onboarding "View all" dialog. `inbox` is a stub shaped
// like a real inbox so ChannelIcon can resolve the icon from the shared provider.
// With `use-brand-icon`, ChannelIcon renders the full-color brand logo when one
// exists and falls back to the monochrome glyph otherwise, so no per-channel
// style flag is needed. Entries without a channel type (Voice, Other Email
// Providers) render `fallbackIcon` instead. `form: true` swaps the grid for an
// inline credential form; `setupLater: true` defers the channel to in-app setup
// for this phase. `labelKey` is an i18n key — most reuse the shared channel
// titles from the inbox settings (INBOX_MGMT.ADD.AUTH.CHANNEL.*.TITLE) so the
// names translate without duplicating strings; resolve it with `t()` at display.
export const CHANNEL_LIST = [
{
type: 'website',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE',
inbox: { channel_type: 'Channel::WebWidget' },
},
{
type: 'whatsapp',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'instagram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
{
type: 'facebook',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
{
type: 'tiktok',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
inbox: { channel_type: 'Channel::Tiktok' },
},
{
type: 'telegram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
inbox: { channel_type: 'Channel::Telegram' },
form: true,
},
{
type: 'line',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
form: true,
},
// Email channels (including Gmail/Outlook OAuth) are set up later in-app for
// this phase; they will be enabled in a future PR.
{
type: 'gmail',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.GMAIL',
inbox: { channel_type: 'Channel::Email', provider: 'google' },
setupLater: true,
},
{
type: 'outlook',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OUTLOOK',
inbox: { channel_type: 'Channel::Email', provider: 'microsoft' },
setupLater: true,
},
{
type: 'sms',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE',
inbox: { channel_type: 'Channel::Sms' },
setupLater: true,
},
{
type: 'api',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE',
inbox: { channel_type: 'Channel::Api' },
setupLater: true,
},
{
type: 'voice',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE',
fallbackIcon: 'i-woot-voice',
setupLater: true,
},
{
type: 'email',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OTHER_EMAIL',
fallbackIcon: 'i-woot-mail',
setupLater: true,
},
];
const channelByType = type =>
CHANNEL_LIST.find(channel => channel.type === type);
// Icons shown next to "View all" when every detected channel is already
// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs
// aren't duplicated.
export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map(
channelByType
);
// Social channels that detected brand_info socials map to, keyed by social type
// in the order they're offered as rows. Derived from CHANNEL_LIST so channel
// identity (label, channel_type) has a single source. Keys mirror
// SocialLinkParser::SOCIAL_DOMAIN_MAP.
const SOCIAL_PLATFORM_TYPES = [
'whatsapp',
'facebook',
'line',
'instagram',
'telegram',
'tiktok',
];
export const SOCIAL_PLATFORMS = Object.fromEntries(
SOCIAL_PLATFORM_TYPES.map(type => {
const { labelKey, inbox } = channelByType(type);
return [type, { labelKey, channelType: inbox.channel_type }];
})
);
// Mailbox providers inferred from the signup domain's MX records, keyed by
// Channel::Email#provider. Derived from CHANNEL_LIST's email entries.
export const EMAIL_PROVIDERS = Object.fromEntries(
CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [
channel.inbox.provider,
{ labelKey: channel.labelKey },
])
);
@@ -1,29 +0,0 @@
import { useMapGetter } from 'dashboard/composables/store';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
// onboarding entirely. Channels without an entry (Website, Telegram, Line, …)
// need no installation credential and are always considered configured.
// Mirrors the availability checks in ChannelItem.vue.
export function useChannelConfig() {
const globalConfig = useMapGetter('globalConfig/get');
const installationConfig = window.chatwootConfig || {};
const CHANNEL_CONFIGURED = {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () => Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
};
const isConfigured = type => CHANNEL_CONFIGURED[type]?.() ?? true;
return { isConfigured };
}
@@ -1,67 +0,0 @@
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import googleClient from 'dashboard/api/channel/googleClient';
import microsoftClient from 'dashboard/api/channel/microsoftClient';
import instagramClient from 'dashboard/api/channel/instagramClient';
import tiktokClient from 'dashboard/api/channel/tiktokClient';
// Channels that complete via an OAuth redirect. Email channels are keyed by their
// Channel::Email provider, others by channel type. The request is tagged with a
// return hint so the callback brings the user back to onboarding instead of the
// inbox settings page.
const OAUTH_CLIENTS = {
google: googleClient,
microsoft: microsoftClient,
instagram: instagramClient,
tiktok: tiktokClient,
};
export function useChannelConnect() {
const { t } = useI18n();
const store = useStore();
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const connectViaOAuth = async provider => {
const client = OAUTH_CLIENTS[provider];
if (!client) return;
try {
const {
data: { url },
} = await client.generateAuthorization({ return_to: 'onboarding' });
window.location.href = url;
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
}
};
// WhatsApp connects via Meta's embedded-signup popup instead of the redirect
// OAuth flow above. Collect the signup credentials, exchange them for an
// inbox, and surface the result inline — then refetch so the connected state
// reflects the freshly created inbox (and renders its real channel icon).
const connectWhatsapp = async () => {
let credentials;
try {
credentials = await runEmbeddedSignup();
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
return;
}
if (!credentials) return; // user dismissed the popup
try {
await store.dispatch('inboxes/createWhatsAppEmbeddedSignup', credentials);
await store.dispatch('inboxes/get');
useAlert(t('ONBOARDING_INBOX_SETUP.WHATSAPP_CONNECTED'));
} catch (error) {
useAlert(
parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR')
);
}
};
return { connectViaOAuth, connectWhatsapp };
}
@@ -1,130 +0,0 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import {
SOCIAL_PLATFORMS,
EMAIL_PROVIDERS,
DEFAULT_CHANNEL_TYPES,
} from './constants';
import { findConnectedInbox } from './channelMatchers';
import { useChannelConfig } from './useChannelConfig';
// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES
// is config-gated like everything else, then sliced to this limit.
const DISPLAYED_CHANNEL_LIMIT = 3;
// Pull the handle/username out of a detected social URL, formatted per channel.
const extractHandle = ({ type, url }) => {
try {
const { pathname } = new URL(url);
const path = pathname.replace(/^\/+|\/+$/g, '');
if (type === 'whatsapp') {
const digits = path.replace(/\D/g, '');
return digits ? `+${digits}` : '';
}
if (type === 'line') return path;
return path.startsWith('@') ? path : `@${path}`;
} catch {
return '';
}
};
// Derives the channel rows for the inbox-setup step from the account's detected
// brand_info (socials + mailbox provider) and the real connected inboxes,
// keeping InboxSetup.vue focused on layout, connect routing, and completion.
export function useDetectedChannels() {
const { currentAccount } = useAccount();
const inboxes = useMapGetter('inboxes/getInboxes');
const { isConfigured } = useChannelConfig();
const brandSocials = computed(
() => currentAccount.value?.custom_attributes?.brand_info?.socials || []
);
const connectedChannels = computed(() =>
brandSocials.value
.filter(social => SOCIAL_PLATFORMS[social.type] && social.url)
.map(social => ({
type: social.type,
handle: extractHandle(social),
labelKey: SOCIAL_PLATFORMS[social.type].labelKey,
inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType },
}))
);
const detectedEmailChannel = computed(() => {
const brandInfo = currentAccount.value?.custom_attributes?.brand_info;
const provider = brandInfo?.email_provider;
if (!EMAIL_PROVIDERS[provider]) return null;
return {
type: 'email',
handle: brandInfo?.email || '',
labelKey: EMAIL_PROVIDERS[provider].labelKey,
inbox: { channel_type: 'Channel::Email', provider },
};
});
// The real inbox backing a channel, if one exists — returned (not just a
// boolean) so the row can show the connected account's real name.
const connectedInbox = channel =>
findConnectedInbox(inboxes.value, channel.inbox);
// A channel row built from a social type, with no detected handle — used for
// the default suggestions when nothing was detected.
const toChannelRow = type => ({
type,
handle: '',
labelKey: SOCIAL_PLATFORMS[type].labelKey,
inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType },
});
const detectedChannels = computed(() =>
[detectedEmailChannel.value, ...connectedChannels.value]
.filter(Boolean)
// Email channels (including Gmail/Outlook OAuth) are disabled for this
// phase; they will be enabled in a future PR.
.filter(channel => channel.type !== 'email')
// Hide channels whose installation OAuth credentials are missing — their
// connect flow would only error.
.filter(channel => isConfigured(channel.type))
);
const defaultChannels = computed(() =>
DEFAULT_CHANNEL_TYPES.filter(isConfigured)
.slice(0, DISPLAYED_CHANNEL_LIMIT)
.map(toChannelRow)
);
// Show the detected channels, or fall back to the default suggestions so the
// step is never an empty list.
const displayedChannels = computed(() =>
detectedChannels.value.length
? detectedChannels.value
: defaultChannels.value
);
const remainingChannels = computed(() => {
// Exclude whatever is already shown as a row (detected or defaulted) so the
// footer preview doesn't duplicate it.
const shownTypes = new Set(displayedChannels.value.map(c => c.type));
return Object.entries(SOCIAL_PLATFORMS)
.filter(([type]) => !shownTypes.has(type))
.filter(([type]) => isConfigured(type))
.slice(0, 3)
.map(([type, { labelKey, channelType }]) => ({
type,
labelKey,
inbox: { channel_type: channelType },
}));
});
const hasDetectedChannels = computed(() => detectedChannels.value.length > 0);
return {
displayedChannels,
remainingChannels,
connectedInbox,
hasDetectedChannels,
};
}
@@ -1,186 +0,0 @@
import { defineComponent, h, ref } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useRoute } from 'vue-router';
import { useAccountEnrichment } from '../../account-details/useAccountEnrichment';
vi.mock('vue-router');
const ENABLED_LANGUAGES = [
{ iso_639_1_code: 'en', name: 'English' },
{ iso_639_1_code: 'fr', name: 'French' },
];
// Mounts the composable against a real store and the real useAccount/useConfig
// (only useRoute and the underlying account getter / window config are faked),
// so a change to how those resolve their data is exercised here too. `presets`
// seeds form fields as if the user had already typed them.
const mountComposable = ({
account = {},
enabledLanguages = ENABLED_LANGUAGES,
presets = {},
} = {}) => {
window.chatwootConfig = { enabledLanguages };
const store = createStore({
modules: {
accounts: {
namespaced: true,
getters: { getAccount: () => () => account },
},
},
});
const fields = {
locale: ref(presets.locale || ''),
website: ref(presets.website || ''),
timezone: ref(presets.timezone || ''),
companySize: ref(presets.companySize || ''),
industry: ref(presets.industry || ''),
referralSource: ref(presets.referralSource || ''),
};
let api;
const Component = defineComponent({
setup() {
api = useAccountEnrichment(fields);
return () => h('div');
},
});
const wrapper = mount(Component, { global: { plugins: [store] } });
return { ...api, fields, wrapper };
};
beforeEach(() => {
useRoute.mockReturnValue({ params: { accountId: '1' } });
});
afterEach(() => {
delete window.chatwootConfig;
});
describe('useAccountEnrichment', () => {
describe('populateFormFields', () => {
it('fills empty fields from the enriched attributes on mount', () => {
const { fields } = mountComposable({
account: {
locale: 'en',
custom_attributes: {
website: 'https://acme.com',
timezone: 'America/New_York',
company_size: '11-50',
industry: 'Technology',
referral_source: 'google',
},
},
});
expect(fields.website.value).toBe('https://acme.com');
expect(fields.timezone.value).toBe('America/New_York');
expect(fields.companySize.value).toBe('11-50');
expect(fields.industry.value).toBe('Technology');
expect(fields.referralSource.value).toBe('google');
});
it('falls back to brand_info for website and industry', () => {
const { fields } = mountComposable({
account: {
custom_attributes: {
brand_info: {
domain: 'acme.com',
industries: [{ industry: 'Retail & E-commerce' }],
},
},
},
});
expect(fields.website.value).toBe('acme.com');
expect(fields.industry.value).toBe('Retail & E-commerce');
});
it('does not clobber fields the user already set', () => {
const { fields } = mountComposable({
presets: { website: 'mysite.com', industry: 'Finance' },
account: {
custom_attributes: {
website: 'https://enriched.com',
industry: 'Technology',
},
},
});
expect(fields.website.value).toBe('mysite.com');
expect(fields.industry.value).toBe('Finance');
});
it('detects the locale from the browser, else the account locale', () => {
// jsdom reports navigator.language as 'en-US' -> base 'en' is enabled.
const { fields } = mountComposable({ account: { locale: 'de' } });
expect(fields.locale.value).toBe('en');
// No enabled language matches the browser -> fall back to account locale.
const { fields: other } = mountComposable({
account: { locale: 'de' },
enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }],
});
expect(other.locale.value).toBe('de');
});
});
describe('isEnriching', () => {
it('is true while the account is on the enrichment step', () => {
const { isEnriching } = mountComposable({
account: { custom_attributes: { onboarding_step: 'enrichment' } },
});
expect(isEnriching.value).toBe(true);
});
it('is false on any other step', () => {
const { isEnriching } = mountComposable({
account: { custom_attributes: { onboarding_step: 'account_details' } },
});
expect(isEnriching.value).toBe(false);
});
it('times out after 30s, flipping to false and populating', () => {
vi.useFakeTimers();
try {
const { isEnriching, fields } = mountComposable({
account: {
custom_attributes: {
onboarding_step: 'enrichment',
company_size: '51-200',
},
},
});
expect(isEnriching.value).toBe(true);
vi.advanceTimersByTime(30000);
expect(isEnriching.value).toBe(false);
expect(fields.companySize.value).toBe('51-200');
} finally {
vi.useRealTimers();
}
});
});
describe('getChangedFields', () => {
it('lists only enrichable fields edited after auto-fill', () => {
const { fields, getChangedFields } = mountComposable({
account: {
custom_attributes: {
website: 'https://acme.com',
company_size: '11-50',
industry: 'Technology',
},
},
});
expect(getChangedFields()).toEqual([]);
fields.industry.value = 'Finance';
expect(getChangedFields()).toEqual(['industry']);
});
});
});
@@ -1,123 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils';
import HelpCenterCreationStatus from '../../inbox-setup/HelpCenterCreationStatus.vue';
import OnboardingAPI from 'dashboard/api/onboarding';
vi.mock('dashboard/api/onboarding', () => ({
default: {
getHelpCenterGeneration: vi.fn(),
},
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key.endsWith('HELP_CENTER_CATEGORIES')) {
return `${params.count} categories`;
}
if (key.endsWith('HELP_CENTER_SUMMARY')) {
return `${params.count} articles across ${params.categories}`;
}
if (key.endsWith('HELP_CENTER_ARTICLES')) {
return `${params.count} articles`;
}
return key;
},
}),
}));
const mountStatus = () =>
mount(HelpCenterCreationStatus, {
global: {
stubs: {
CreationStatusRow: {
props: ['ready', 'title', 'description', 'status'],
template:
'<div data-test="row" :data-ready="ready">{{ status }}</div>',
},
},
},
});
describe('HelpCenterCreationStatus', () => {
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('renders completed summary from the status endpoint', async () => {
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
data: {
generation_id: 'generation-123',
state: { status: 'completed' },
articles_count: 3,
categories_count: 2,
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
'true'
);
expect(wrapper.find('[data-test="row"]').text()).toBe(
'3 articles across 2 categories'
);
});
it('hides the row when generation is skipped', async () => {
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
data: {
generation_id: 'generation-123',
state: { status: 'skipped' },
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').exists()).toBe(false);
});
it('polls while generating and stops after completion', async () => {
vi.useFakeTimers();
OnboardingAPI.getHelpCenterGeneration
.mockResolvedValueOnce({
data: {
generation_id: 'generation-123',
state: { status: 'generating' },
articles_count: 1,
categories_count: 0,
},
})
.mockResolvedValueOnce({
data: {
generation_id: 'generation-123',
state: { status: 'completed' },
articles_count: 2,
categories_count: 1,
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').text()).toBe('1 articles');
vi.advanceTimersByTime(5000);
await flushPromises();
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
'true'
);
expect(wrapper.find('[data-test="row"]').text()).toBe(
'2 articles across 1 categories'
);
vi.advanceTimersByTime(5000);
await flushPromises();
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
});
});
@@ -1,59 +0,0 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
vi.mock('dashboard/composables/store', () => ({
useMapGetter: () => ({ value: {} }),
}));
vi.mock('../../inbox-setup/useChannelConnect', () => ({
useChannelConnect: () => ({
connectViaOAuth: vi.fn(),
connectWhatsapp: vi.fn(),
}),
}));
const mountDialog = () =>
mount(InboxChannelsDialog, {
props: { inboxes: [] },
global: {
stubs: {
Dialog: {
template: '<div><slot /></div>',
methods: { open() {}, close() {} },
},
InboxFacebookForm: { template: '<div data-test="fb-form" />' },
InboxChannelForm: { template: '<div data-test="channel-form" />' },
ChannelIcon: true,
Icon: true,
},
},
});
describe('InboxChannelsDialog Facebook gating', () => {
afterEach(() => {
delete window.chatwootConfig;
});
it('opens the Facebook page picker when fbAppId is configured', async () => {
window.chatwootConfig = { fbAppId: 'fb-app' };
const wrapper = mountDialog();
wrapper.vm.open('facebook');
await nextTick();
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(true);
});
it('shows the grid (not the picker) when fbAppId is missing', async () => {
window.chatwootConfig = {};
const wrapper = mountDialog();
wrapper.vm.open('facebook');
await nextTick();
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(false);
// The channel grid renders its cards instead.
expect(wrapper.find('button').exists()).toBe(true);
});
});
@@ -1,158 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils';
import { ref, nextTick } from 'vue';
import InboxFacebookForm from '../../inbox-setup/InboxFacebookForm.vue';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('dashboard/store/utils/api', () => ({
parseAPIErrorResponse: vi.fn(),
}));
vi.mock('dashboard/composables/useFacebookPageConnect', () => ({
useFacebookPageConnect: vi.fn(),
}));
const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn() }));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
}));
const NextButtonStub = {
props: ['label', 'disabled', 'isLoading'],
emits: ['click'],
template: `<button :disabled="disabled" @click="$emit('click')">{{ label }}</button>`,
};
const ComboBoxStub = {
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template: '<div data-test="combobox" />',
};
const PAGES = [
{ id: 'p1', name: 'Page One', access_token: 'pt1' },
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
];
const LAUNCH = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH';
const CONNECT = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT';
let loginAndFetchPages;
let preloadSdk;
const mountForm = () =>
mount(InboxFacebookForm, {
global: {
stubs: {
NextButton: NextButtonStub,
ComboBox: ComboBoxStub,
Spinner: true,
},
},
});
const clickButton = (wrapper, label) =>
wrapper
.findAll('button')
.find(button => button.text() === label)
.trigger('click');
beforeEach(() => {
vi.clearAllMocks();
preloadSdk = vi.fn();
loginAndFetchPages = vi.fn();
useFacebookPageConnect.mockReturnValue({
isAuthenticating: ref(false),
preloadSdk,
loginAndFetchPages,
});
dispatch.mockResolvedValue({ id: 1 });
});
describe('InboxFacebookForm', () => {
it('preloads the SDK on mount', () => {
mountForm();
expect(preloadSdk).toHaveBeenCalled();
});
it('lists only connectable pages and creates an inbox for the selected one', async () => {
loginAndFetchPages.mockResolvedValue({
userAccessToken: 'tok',
pages: PAGES,
});
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
// p2 is already connected (exists), so only p1 is offered.
const combobox = wrapper.findComponent(ComboBoxStub);
expect(combobox.props('options')).toEqual([
{ value: 'p1', label: 'Page One' },
]);
combobox.vm.$emit('update:modelValue', 'p1');
await nextTick();
await clickButton(wrapper, CONNECT);
await flushPromises();
expect(dispatch).toHaveBeenCalledWith('inboxes/createFBChannel', {
user_access_token: 'tok',
page_access_token: 'pt1',
page_id: 'p1',
inbox_name: 'Page One',
});
expect(wrapper.emitted('created')).toBeTruthy();
});
it('shows the empty state when every page is already connected', async () => {
loginAndFetchPages.mockResolvedValue({
userAccessToken: 'tok',
pages: [
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
],
});
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES'
);
expect(wrapper.find('[data-test="combobox"]').exists()).toBe(false);
});
it('shows an error when the connection fails', async () => {
loginAndFetchPages.mockRejectedValue(new Error('boom'));
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
);
expect(dispatch).not.toHaveBeenCalled();
});
it('stays on the connect prompt without an error when cancelled', async () => {
loginAndFetchPages.mockResolvedValue(null);
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).not.toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
);
// Launch button is still available to retry.
expect(
wrapper.findAll('button').some(button => button.text() === LAUNCH)
).toBe(true);
});
});
@@ -1,62 +0,0 @@
import {
findConnectedInbox,
isChannelConnected,
} from '../../inbox-setup/channelMatchers';
const WHATSAPP = { id: 1, channel_type: 'Channel::Whatsapp' };
const GMAIL = { id: 2, channel_type: 'Channel::Email', provider: 'google' };
const OUTLOOK = {
id: 3,
channel_type: 'Channel::Email',
provider: 'microsoft',
};
describe('channelMatchers', () => {
describe('findConnectedInbox', () => {
it('returns the inbox sharing the channel type', () => {
expect(
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
).toBe(WHATSAPP);
});
it('matches email inboxes on provider', () => {
expect(
findConnectedInbox([OUTLOOK, GMAIL], {
channel_type: 'Channel::Email',
provider: 'google',
})
).toBe(GMAIL);
});
it('does not match a different email provider', () => {
expect(
findConnectedInbox([OUTLOOK], {
channel_type: 'Channel::Email',
provider: 'google',
})
).toBeUndefined();
});
it('returns undefined when nothing matches', () => {
expect(
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Telegram' })
).toBeUndefined();
});
});
describe('isChannelConnected', () => {
it('is true when a matching inbox exists', () => {
expect(
isChannelConnected([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
).toBe(true);
});
it('is false when no inbox matches', () => {
expect(isChannelConnected([WHATSAPP], GMAIL)).toBe(false);
});
it('is false for a channel without an inbox stub', () => {
expect(isChannelConnected([WHATSAPP], undefined)).toBe(false);
});
});
});
@@ -1,300 +0,0 @@
import { defineComponent, h } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useRoute } from 'vue-router';
import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
// used, so assertions validate against the actual channel identity (label keys,
// channel_type, social ordering) derived from CHANNEL_LIST.
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
const store = createStore({
modules: {
accounts: {
namespaced: true,
getters: {
getAccount: () => () => ({
id: 1,
custom_attributes: { brand_info: brandInfo },
}),
},
},
inboxes: {
namespaced: true,
getters: { getInboxes: () => inboxes },
},
},
});
let result;
const Component = defineComponent({
setup() {
result = useDetectedChannels();
return () => h('div');
},
});
mount(Component, { global: { plugins: [store] } });
return result;
};
beforeEach(() => {
useRoute.mockReturnValue({ params: { accountId: '1' } });
// Configure the installation OAuth credentials so detected channels aren't
// hidden by the config gate; individual tests clear this to assert hiding.
window.chatwootConfig = {
fbAppId: 'fb',
instagramAppId: 'ig',
tiktokAppId: 'tt',
whatsappAppId: 'wa',
whatsappConfigurationId: 'wa-config',
};
});
afterEach(() => {
delete window.chatwootConfig;
});
describe('useDetectedChannels', () => {
describe('displayedChannels', () => {
it('maps detected socials with a url to channel rows', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' },
{ type: 'instagram', url: 'https://instagram.com/acme' },
],
},
});
expect(displayedChannels.value).toEqual([
{
type: 'whatsapp',
handle: '+14155552671',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'instagram',
handle: '@acme',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
]);
});
it('skips socials without a url or with an unknown type', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'telegram' }, // no url
{ type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'tiktok',
]);
});
it('uses the raw path for line and falls back to empty on a bad url', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'line', url: 'https://line.me/acme' },
{ type: 'facebook', url: 'not-a-url' },
],
},
});
expect(displayedChannels.value).toEqual([
{
type: 'line',
handle: 'acme',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
},
{
type: 'facebook',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
]);
});
it('omits the detected email channel while email is disabled for this phase', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
email_provider: 'google',
email: 'support@acme.com',
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'whatsapp',
]);
});
it('falls back to the default channel suggestions when nothing is detected', () => {
const { displayedChannels } = mountComposable({ brandInfo: undefined });
// The configured mainstream channels, with no detected handle.
expect(displayedChannels.value).toEqual([
{
type: 'whatsapp',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'facebook',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
{
type: 'instagram',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
]);
});
it('gates the default suggestions by installation config, keeping the list non-empty', () => {
window.chatwootConfig = {}; // no OAuth credentials configured
const { displayedChannels } = mountComposable({ brandInfo: undefined });
// Only the credential-free defaults survive (Telegram, LINE).
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'telegram',
'line',
]);
});
it('hides detected channels whose installation OAuth credentials are missing', () => {
window.chatwootConfig = {}; // nothing configured
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'facebook', url: 'https://facebook.com/acme' },
{ type: 'line', url: 'https://line.me/acme' },
],
},
});
// Facebook needs fbAppId (absent → hidden); LINE needs no install credential.
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'line',
]);
});
});
describe('remainingChannels', () => {
it('returns the platforms not already shown as default rows', () => {
// Nothing detected → displayed falls back to the defaults (WhatsApp,
// Facebook, Instagram), so the footer previews the remaining platforms.
const { remainingChannels } = mountComposable({ brandInfo: {} });
expect(remainingChannels.value).toEqual([
{
type: 'line',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
},
{
type: 'telegram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
inbox: { channel_type: 'Channel::Telegram' },
},
{
type: 'tiktok',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
inbox: { channel_type: 'Channel::Tiktok' },
},
]);
});
it('excludes already-detected socials, preserving order', () => {
const { remainingChannels } = mountComposable({
brandInfo: {
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
},
});
expect(remainingChannels.value.map(channel => channel.type)).toEqual([
'facebook',
'line',
'instagram',
]);
});
it('excludes channels whose installation OAuth credentials are missing', () => {
window.chatwootConfig = {}; // nothing configured
const { remainingChannels } = mountComposable({ brandInfo: {} });
// The only configured channels (Telegram, LINE) are shown as default rows,
// and every other platform is gated out — so nothing remains for the footer.
expect(remainingChannels.value).toEqual([]);
});
});
describe('connectedInbox', () => {
it('returns the real inbox sharing the channel type', () => {
const inbox = {
id: 1,
channel_type: 'Channel::Whatsapp',
name: 'WA Biz',
};
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [inbox],
});
expect(
connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } })
).toBe(inbox);
});
it('matches email inboxes on provider', () => {
const gmail = {
id: 1,
channel_type: 'Channel::Email',
provider: 'google',
};
const outlook = {
id: 2,
channel_type: 'Channel::Email',
provider: 'microsoft',
};
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [outlook, gmail],
});
expect(
connectedInbox({
inbox: { channel_type: 'Channel::Email', provider: 'google' },
})
).toBe(gmail);
});
it('returns undefined when nothing matches', () => {
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [],
});
expect(
connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } })
).toBeUndefined();
});
});
});
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Editor from 'next/Editor/Editor.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
@@ -162,13 +162,9 @@ const toggleAutoResolve = async () => {
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
<Editor
<TextArea
v-model="message"
class="w-full"
channel-type="Context::NoToolbar"
enable-variables
:enable-canned-responses="false"
:show-character-count="false"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
"
@@ -10,9 +10,6 @@ export const LONGEST_WAITING = 'longest_waiting';
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
// Default age threshold for excluding stale unassigned conversations (7 days)
export const DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168;
// Options groupings
export const OPTIONS = {
ORDER: [ROUND_ROBIN, BALANCED],
@@ -106,7 +106,6 @@ const formData = computed(() => ({
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
excludeOlderThanHours: selectedPolicy.value?.excludeOlderThanHours ?? null,
}));
const handleDeleteInbox = async inboxId => {
@@ -8,8 +8,6 @@ import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
@@ -18,7 +16,6 @@ import {
EARLIEST_CREATED,
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
const props = defineProps({
@@ -31,7 +28,6 @@ const props = defineProps({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
}),
},
mode: {
@@ -60,6 +56,7 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'submit',
'addInbox',
@@ -67,9 +64,6 @@ const emit = defineEmits([
'navigateToInbox',
'validationChange',
]);
// Duration limits for the stale-conversation threshold: 1 hour to 999 days (in minutes)
const MIN_EXCLUSION_MINUTES = 60;
const MAX_EXCLUSION_MINUTES = 1438560;
const { t } = useI18n();
const route = useRoute();
@@ -89,28 +83,12 @@ const state = reactive({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
const validationState = ref({
isValid: false,
});
const exclusionUnit = ref(DURATION_UNITS.DAYS);
// DurationInput works in minutes; the policy stores hours, so bridge the two
const excludeOlderThanMinutes = computed({
get() {
return state.excludeOlderThanHours == null
? null
: state.excludeOlderThanHours * 60;
},
set(minutes) {
state.excludeOlderThanHours =
minutes == null ? null : Math.round(minutes / 60);
},
});
const createOption = (
type,
key,
@@ -192,7 +170,6 @@ const resetForm = () => {
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
};
@@ -200,17 +177,10 @@ const handleSubmit = () => {
emit('submit', { ...state });
};
// Pick the display unit from the stored value so non-day thresholds (e.g. 25h) don't get floored
const detectExclusionUnit = hours => {
exclusionUnit.value =
hours && hours % 24 !== 0 ? DURATION_UNITS.HOURS : DURATION_UNITS.DAYS;
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
detectExclusionUnit(newData.excludeOlderThanHours);
},
{ immediate: true, deep: true }
);
@@ -277,27 +247,6 @@ defineExpose({
v-model:window-unit="state.windowUnit"
/>
</div>
<div class="pt-4 pb-2 flex-col flex gap-4">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }}
</p>
</div>
<div
class="flex items-center gap-2 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<DurationInput
v-model:unit="exclusionUnit"
v-model:model-value="excludeOlderThanMinutes"
:min="MIN_EXCLUSION_MINUTES"
:max="MAX_EXCLUSION_MINUTES"
/>
</div>
</div>
</div>
<Button
@@ -144,10 +144,8 @@ const showWebhookSection = computed(
() => props.healthData?.webhook_configuration !== undefined
);
// Phone-level override takes precedence over WABA-level (application), so prefer it.
const webhookUrl = computed(
() =>
props.healthData?.webhook_configuration?.phone_number ||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
props.healthData?.webhook_configuration?.application
);
@@ -11,7 +11,6 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const store = useStore();
const { t } = useI18n();
@@ -124,16 +123,9 @@ const confirmPlaceHolderText = computed(() =>
>
<div class="flex items-start gap-4">
<div
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1 text-lg"
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
>
<EmojiIcon
v-if="team.icon"
:value="team.icon"
:color="team.icon_color"
class="size-5"
/>
<Icon
v-else
icon="i-lucide-users-round"
class="size-4 text-n-slate-11"
/>
@@ -1,27 +1,15 @@
<script>
import validations from './helpers/validations';
import FormInput from 'v3/components/Form/Input.vue';
import { reactive, ref, defineAsyncComponent } from 'vue';
import { reactive } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { OnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
);
export default {
components: {
NextButton,
FormInput,
OnClickOutside,
EmojiIcon,
Icon,
EmojiIconPicker,
},
props: {
onSubmit: {
@@ -47,38 +35,19 @@ export default {
description = '',
name: title = '',
allow_auto_assign: allowAutoAssign = true,
icon = '',
icon_color: iconColor = '',
} = formData;
const state = reactive({
description,
title,
allowAutoAssign,
icon,
iconColor,
});
const isIconPickerOpen = ref(false);
const rules = validations;
const v$ = useVuelidate(rules, state);
return { state, v$, isIconPickerOpen };
return { state, v$ };
},
methods: {
onSelectIcon({ type, value, color }) {
this.state.icon = value;
this.state.iconColor = type === 'icon' ? color : '';
this.isIconPickerOpen = false;
},
onColorChange(color) {
this.state.iconColor = color;
},
onRemoveIcon() {
this.state.icon = '';
this.state.iconColor = '';
this.isIconPickerOpen = false;
},
handleSubmit() {
this.v$.$touch();
if (this.v$.$invalid) {
@@ -88,8 +57,6 @@ export default {
description: this.state.description,
name: this.state.title,
allow_auto_assign: this.state.allowAutoAssign,
icon: this.state.icon,
icon_color: this.state.iconColor,
});
},
},
@@ -99,49 +66,16 @@ export default {
<template>
<div class="flex-shrink-0 w-full">
<form class="mx-0 grid gap-4" @submit.prevent="handleSubmit">
<div class="relative">
<FormInput
v-model="state.title"
class="!ps-12"
name="title"
spacing="compact"
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
:has-error="v$.title.$error"
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
@blur="v$.title.$touch"
/>
<OnClickOutside
class="absolute top-[1.75rem] start-0"
@trigger="isIconPickerOpen = false"
>
<NextButton
type="button"
variant="ghost"
color="slate"
class="text-lg !size-[2.5rem] !p-0 ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isIconPickerOpen = !isIconPickerOpen"
>
<EmojiIcon
v-if="state.icon"
:value="state.icon"
:color="state.iconColor"
class="size-5 text-xl !leading-5"
/>
<Icon v-else icon="i-lucide-smile-plus " class="size-4" />
</NextButton>
<EmojiIconPicker
v-if="isIconPickerOpen"
class="start-0 top-10"
:value="state.icon"
:color="state.iconColor"
show-remove-button
@select="onSelectIcon"
@color-change="onColorChange"
@remove="onRemoveIcon"
/>
</OnClickOutside>
</div>
<FormInput
v-model="state.title"
name="title"
spacing="compact"
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
:has-error="v$.title.$error"
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
@blur="v$.title.$touch"
/>
<FormInput
v-model="state.description"
name="description"
+3 -12
View File
@@ -7,12 +7,9 @@ import { validateLoggedInRoutes } from '../helper/routeHelpers';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import AnalyticsHelper from '../helper/AnalyticsHelper';
const ONBOARDING_STEPS = ['account_details', 'enrichment', 'inbox_setup'];
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
const routes = [...dashboard.routes];
const onboardingPath = step =>
step === 'inbox_setup' ? 'onboarding/inbox-setup' : 'onboarding';
export const router = createRouter({ history: createWebHistory(), routes });
export const validateAuthenticateRoutePermission = async (to, next) => {
@@ -42,18 +39,12 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
isActive;
if (to.name === 'no_accounts' || !to.name) {
const target = needsOnboarding
? onboardingPath(userAccount?.onboarding_step)
: 'dashboard';
const target = needsOnboarding ? 'onboarding' : 'dashboard';
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}
if (needsOnboarding && !isOnOnboardingView(to)) {
return next(
frontendURL(
`accounts/${routeAccountId}/${onboardingPath(userAccount?.onboarding_step)}`
)
);
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
}
if (!needsOnboarding && isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
@@ -2,10 +2,14 @@ import MessageApi from '../../../../api/inbox/message';
export default {
async translateMessage(_, { conversationId, messageId, targetLanguage }) {
await MessageApi.translateMessage(
conversationId,
messageId,
targetLanguage
);
try {
await MessageApi.translateMessage(
conversationId,
messageId,
targetLanguage
);
} catch (error) {
// ignore error
}
},
};
@@ -6,7 +6,6 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
const props = defineProps({
@@ -38,10 +37,6 @@ const props = defineProps({
type: String,
default: 'Search',
},
showEmojiIcon: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
@@ -101,18 +96,8 @@ const hasIcon = computed(() => {
hide-offline-status
rounded-full
/>
<div
v-if="hasValue && hasIcon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon
:value="selectedItem.icon"
:color="selectedItem.icon_color"
class="size-3.5 !text-sm"
/>
</div>
<Icon
v-else-if="hasValue && hasIcon"
v-if="hasValue && hasIcon"
:icon="selectedItem.icon"
class="size-5 text-n-slate-11"
/>
@@ -139,7 +124,6 @@ const hasIcon = computed(() => {
:has-thumbnail="hasThumbnail"
:input-placeholder="inputPlaceholder"
:no-search-result="noSearchResult"
:show-emoji-icon="showEmojiIcon"
@select="onClickSelectItem"
/>
</div>
@@ -3,7 +3,6 @@ import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
@@ -12,7 +11,6 @@ export default {
WootDropdownMenu,
Avatar,
Icon,
EmojiIcon,
NextButton,
},
@@ -37,10 +35,6 @@ export default {
type: String,
default: 'No results found',
},
showEmojiIcon: {
type: Boolean,
default: false,
},
},
emits: ['select'],
@@ -122,18 +116,8 @@ export default {
hide-offline-status
rounded-full
/>
<div
v-if="option.icon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon
:value="option.icon"
:color="option.icon_color"
class="size-3.5 !text-sm"
/>
</div>
<Icon
v-else-if="option.icon"
v-if="option.icon"
:icon="option.icon"
class="size-5 text-n-slate-11"
/>
@@ -1,11 +1,5 @@
const DYTE_MEETING_LINK = 'https://examples.realtime.cloudflare.com/meeting/';
const DYTE_MEETING_LINK = 'https://app.dyte.io/v2/meeting';
export const buildDyteURL = dyteAuthToken => {
const params = new URLSearchParams({
authToken: dyteAuthToken,
showSetupScreen: true,
disableVideoBackground: true,
});
return `${DYTE_MEETING_LINK}?${params.toString()}`;
return `${DYTE_MEETING_LINK}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`;
};
+1 -1
View File
@@ -54,7 +54,7 @@ class Account < ApplicationRecord
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :reporting_timezone
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :captain_auto_resolve_mode, :captain_false_promise_harness_enabled
store_accessor :settings, :captain_auto_resolve_mode
include AccountCaptainAutoResolve
has_many :account_users, dependent: :destroy_async
-2
View File
@@ -7,7 +7,6 @@
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
# exclude_older_than_hours :integer default(168)
# fair_distribution_limit :integer default(100), not null
# fair_distribution_window :integer default(3600), not null
# name :string(255) not null
@@ -29,7 +28,6 @@ class AssignmentPolicy < ApplicationRecord
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :fair_distribution_limit, numericality: { greater_than: 0 }
validates :fair_distribution_window, numericality: { greater_than: 0 }
validates :exclude_older_than_hours, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
+16 -6
View File
@@ -1,9 +1,6 @@
module AccountSettingsSchema
extend ActiveSupport::Concern
CAPTAIN_MODEL_PROPERTIES = Llm::Models.feature_keys.index_with { { 'type': %w[string null] } }.freeze
CAPTAIN_FEATURE_PROPERTIES = Llm::Models.feature_keys.index_with { { 'type': %w[boolean null] } }.freeze
SETTINGS_PARAMS_SCHEMA = {
'type': 'object',
'properties':
@@ -15,19 +12,32 @@ module AccountSettingsSchema
'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] },
'captain_false_promise_harness_enabled': { 'type': %w[boolean null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
},
'captain_models': {
'type': %w[object null],
'properties': CAPTAIN_MODEL_PROPERTIES,
'properties': {
'editor': { 'type': %w[string null] },
'assistant': { 'type': %w[string null] },
'copilot': { 'type': %w[string null] },
'label_suggestion': { 'type': %w[string null] },
'audio_transcription': { 'type': %w[string null] },
'help_center_search': { 'type': %w[string null] }
},
'additionalProperties': false
},
'captain_features': {
'type': %w[object null],
'properties': CAPTAIN_FEATURE_PROPERTIES,
'properties': {
'editor': { 'type': %w[boolean null] },
'assistant': { 'type': %w[boolean null] },
'copilot': { 'type': %w[boolean null] },
'label_suggestion': { 'type': %w[boolean null] },
'audio_transcription': { 'type': %w[boolean null] },
'help_center_search': { 'type': %w[boolean null] }
},
'additionalProperties': false
}
},
+9 -20
View File
@@ -4,7 +4,6 @@ module CaptainFeaturable
extend ActiveSupport::Concern
included do
before_validation :normalize_captain_models
validate :validate_captain_models
# Dynamically define accessor methods for each captain feature
@@ -31,8 +30,14 @@ module CaptainFeaturable
private
def captain_models_with_defaults
Llm::Models.feature_keys.index_with do |feature_key|
Llm::FeatureRouter.resolve(feature: feature_key, account: self)[:model]
stored_models = captain_models || {}
Llm::Models.feature_keys.each_with_object({}) do |feature_key, result|
stored_value = stored_models[feature_key]
result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value)
stored_value
else
Llm::Models.default_model_for(feature_key)
end
end
end
@@ -47,27 +52,11 @@ module CaptainFeaturable
return if captain_models.blank?
captain_models.each do |feature_key, model_name|
unless Llm::Models.feature?(feature_key)
errors.add(:captain_models, "'#{feature_key}' is not a known feature")
next
end
next if model_name.blank?
next if Llm::Models.valid_model_for?(feature_key, model_name)
allowed_models = Llm::Models.models_for(feature_key)
errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}")
end
end
def normalize_captain_models
return unless captain_models.is_a?(Hash)
normalized_models = captain_models.each_with_object({}) do |(feature_key, model_name), result|
next if model_name.blank?
result[feature_key.to_s] = model_name.to_s
end
self.captain_models = normalized_models.presence
end
end
+1 -46
View File
@@ -30,7 +30,6 @@ class Integrations::Hook < ApplicationRecord
validate :validate_settings_json_schema
validate :ensure_feature_enabled
validate :validate_openai_api_key, if: :validate_openai_api_key?
validate :validate_cloudflare_realtimekit_credentials, if: :validate_cloudflare_realtimekit_credentials?
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
# TODO: This seems to be only used for slack at the moment
@@ -62,10 +61,6 @@ class Integrations::Hook < ApplicationRecord
app_id == 'openai'
end
def dyte?
app_id == 'dyte'
end
def notion?
app_id == 'notion'
end
@@ -101,7 +96,6 @@ class Integrations::Hook < ApplicationRecord
def validate_settings_json_schema
return if app.blank? || app.params[:settings_json_schema].blank?
return if legacy_dyte_settings_unchanged?
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
end
@@ -112,57 +106,18 @@ class Integrations::Hook < ApplicationRecord
openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?)
end
def validate_cloudflare_realtimekit_credentials?
dyte? && enabled? && !legacy_dyte_settings_unchanged? &&
(new_record? || cloudflare_realtimekit_credentials_changed? || will_save_change_to_status?)
end
def openai_api_key_changed?
settings_api_key(settings) != settings_api_key(settings_in_database)
end
def cloudflare_realtimekit_credentials_changed?
settings_cloudflare_realtimekit_credentials(settings) != settings_cloudflare_realtimekit_credentials(settings_in_database)
end
def legacy_dyte_settings_unchanged?
dyte? && persisted? && !will_save_change_to_settings? && legacy_dyte_settings?(settings_in_database)
end
def legacy_dyte_settings?(value)
return false if value.blank?
%w[organization_id api_key].any? { |key| settings_value(value, key).present? } &&
%w[account_id app_id api_token].none? { |key| settings_value(value, key).present? }
end
def validate_openai_api_key
return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings))
errors.add(:base, I18n.t('errors.openai.invalid_api_key'))
end
def validate_cloudflare_realtimekit_credentials
result = Integrations::Cloudflare::RealtimeKitCredentialsValidator.validate(*settings_cloudflare_realtimekit_credentials(settings))
return if result.success?
errors.add(:base, I18n.t("errors.cloudflare.realtimekit.#{result.error}"))
end
def settings_api_key(value)
settings_value(value, 'api_key')
end
def settings_cloudflare_realtimekit_credentials(value)
[
settings_value(value, 'account_id'),
settings_value(value, 'app_id'),
settings_value(value, 'api_token')
]
end
def settings_value(value, key)
value&.dig(key) || value&.dig(key.to_sym)
value&.dig('api_key') || value&.dig(:api_key)
end
def trigger_setup_if_crm
+1 -5
View File
@@ -5,8 +5,6 @@
# id :bigint not null, primary key
# allow_auto_assign :boolean default(TRUE)
# description :text
# icon :string default("")
# icon_color :string default("")
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -64,9 +62,7 @@ class Team < ApplicationRecord
def push_event_data
{
id: id,
name: name,
icon: icon,
icon_color: icon_color
name: name
}
end
end
@@ -35,11 +35,8 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Skip stale backlog with no activity beyond the policy's age threshold (defaults to 7 days)
policy = inbox.assignment_policy
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
# Apply conversation priority using assignment policy if available
policy = inbox.assignment_policy
scope = if policy&.longest_waiting?
scope.reorder(last_activity_at: :asc, created_at: :asc)
else
@@ -49,16 +46,6 @@ class AutoAssignment::AssignmentService
scope.limit(limit)
end
def apply_age_exclusions(scope, hours_threshold)
return scope if hours_threshold.blank?
hours = hours_threshold.to_i
return scope unless hours.positive?
# Use last_activity_at so reopened/active conversations aren't excluded by their original created_at
scope.where('conversations.last_activity_at >= ?', hours.hours.ago)
end
def find_available_agent(conversation = nil)
agents = filter_agents_by_team(inbox.available_agents, conversation)
return nil if agents.nil?
@@ -78,14 +78,6 @@ class Crm::BaseProcessorService
contact.save!
end
def clear_external_id(contact)
return if contact.additional_attributes.blank?
return if contact.additional_attributes['external'].blank?
contact.additional_attributes['external'].delete("#{crm_name}_id")
contact.save!
end
def store_conversation_metadata(conversation, metadata)
# Initialize additional_attributes if it's nil
conversation.additional_attributes = {} if conversation.additional_attributes.nil?
@@ -64,7 +64,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
# may not be marked as unique, same with the phone number field
# So we just use the update API if we already have a lead ID
if lead_id.present?
with_stale_lead_recovery(contact, lead_id) { |id| @lead_client.update_lead(lead_data, id) }
@lead_client.update_lead(lead_data, lead_id)
else
new_lead_id = @lead_client.create_or_update_lead(lead_data)
store_external_id(contact, new_lead_id)
@@ -82,9 +82,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
return if lead_id.blank?
activity_code = get_activity_code(activity_code_key)
activity_id = with_stale_lead_recovery(conversation.contact, lead_id) do |id|
@activity_client.post_activity(id, activity_code, activity_note)
end
activity_id = @activity_client.post_activity(lead_id, activity_code, activity_note)
return if activity_id.blank?
metadata = {}
@@ -96,31 +94,6 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
log_activity_error(e, activity_type, conversation)
end
# The cached lead id can become stale when the lead is deleted/merged in LeadSquared,
# making LeadSquared reject the call with "Lead not found". When that happens, clear the
# stored id, re-resolve the contact to a fresh lead, and run the operation again once.
def with_stale_lead_recovery(contact, lead_id)
yield(lead_id)
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
raise unless lead_not_found_error?(e)
Rails.logger.warn("LeadSquared stale lead #{lead_id} for contact ##{contact.id}, clearing and retrying")
clear_external_id(contact)
fresh_lead_id = get_lead_id(contact)
raise if fresh_lead_id.blank? || fresh_lead_id == lead_id
yield(fresh_lead_id)
end
def lead_not_found_error?(error)
return false if error.response.blank?
parsed = error.response.parsed_response
parsed.is_a?(Hash) && parsed['ExceptionType'] == 'MXInvalidEntityReferenceException'
rescue StandardError
false
end
def log_activity_error(error, activity_type, conversation, payload: nil)
ChatwootExceptionTracker.new(error, account: @account).capture_exception
context = "account_id=#{conversation.account_id}, conversation_display_id=#{conversation.display_id}"
@@ -143,7 +116,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
unless identifiable_contact?(contact)
Rails.logger.info("Contact not identifiable. Skipping activity for ##{contact.id}")
return nil
nil
end
lead_id = @lead_finder.find_or_create(contact)
@@ -31,14 +31,6 @@ class UserSessionTrackingService
private
def session_attributes
client_headers = mobile_client_headers
if client_headers
return client_headers.merge(
ip_address: @request.remote_ip,
user_agent: @request.user_agent
)
end
browser = Browser.new(@request.user_agent)
attrs = {
@@ -54,30 +46,6 @@ class UserSessionTrackingService
patch_for_legacy_mobile(attrs)
end
def mobile_client_headers
name = @request.headers['X-Chatwoot-Client-Name']
return nil if name.blank?
platform = @request.headers['X-Chatwoot-Platform']
model = @request.headers['X-Chatwoot-Device-Model']
{
browser_name: name,
browser_version: @request.headers['X-Chatwoot-Client-Version'],
device_name: device_name_for_icon(platform, model),
platform_name: model,
platform_version: @request.headers['X-Chatwoot-Platform-Version']
}
end
def device_name_for_icon(platform, model)
normalized_platform = platform.to_s.downcase
return 'iPad' if normalized_platform == 'ios' && model.to_s.include?('iPad')
return 'iPhone' if normalized_platform == 'ios'
'Android'
end
def patch_for_legacy_mobile(attrs)
return attrs unless attrs[:browser_name] == 'Unknown Browser'
+19 -35
View File
@@ -1,7 +1,5 @@
class Whatsapp::FacebookApiClient
BASE_URI = 'https://graph.facebook.com'.freeze
# Base webhook fields resent on every subscribe so Meta won't reset to defaults. `calls` is added by callers only when voice is enabled.
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze
def initialize(access_token = nil)
@access_token = access_token
@@ -62,62 +60,48 @@ class Whatsapp::FacebookApiClient
data['code_verification_status'] == 'VERIFIED'
end
def subscribe_phone_number_webhook(waba_id, phone_number_id, callback_url, verify_token, subscribed_fields: nil)
# Subscribe app to WABA first — Meta requires it before any callback override (issue #13097).
# subscribed_fields (incl. `calls` when voice is enabled) is declared here; the phone-level POST has no such field.
subscribe_app_to_waba(waba_id, subscribed_fields: subscribed_fields || WEBHOOK_DEFAULT_FIELDS)
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze
# Phone-level override takes precedence over WABA-level, so numbers on one WABA can route to different URLs.
override_phone_number_callback(phone_number_id, callback_url, verify_token)
def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
# Step 1: Subscribe app to WABA first (required before override)
# Meta requires the app to be subscribed before using override_callback_uri
# See: https://github.com/chatwoot/chatwoot/issues/13097
subscribe_app_to_waba(waba_id)
# Step 2: Override callback URL for this specific WABA
override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
end
def subscribe_app_to_waba(waba_id, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
def subscribe_app_to_waba(waba_id)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers,
body: { subscribed_fields: subscribed_fields }.to_json
headers: request_headers
)
handle_response(response, 'App subscription to WABA failed')
end
def override_phone_number_callback(phone_number_id, callback_url, verify_token)
def override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers,
body: {
webhook_configuration: {
override_callback_uri: callback_url,
verify_token: verify_token
}
override_callback_uri: callback_url,
verify_token: verify_token,
subscribed_fields: subscribed_fields
}.to_json
)
handle_response(response, 'Phone number webhook callback override failed')
handle_response(response, 'Webhook callback override failed')
end
def clear_phone_number_callback_override(phone_number_id)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
headers: request_headers,
body: {
webhook_configuration: {
override_callback_uri: ''
}
}.to_json
)
handle_response(response, 'Phone number webhook callback clear failed')
end
# Fully removes this app's WABA subscription (last inbox deleted) so Meta stops delivering webhooks.
def unsubscribe_app_from_waba(waba_id)
def unsubscribe_waba_webhook(waba_id)
response = HTTParty.delete(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers
)
handle_response(response, 'WABA app unsubscription failed')
handle_response(response, 'Webhook unsubscription failed')
end
private
@@ -27,12 +27,9 @@ class Whatsapp::ReauthorizationService
def update_channel_config(channel, access_token, phone_info)
current_config = channel.provider_config || {}
# Legacy clients may omit phone_number_id; fall back to the value just fetched from Meta.
resolved_phone_number_id = @phone_number_id.presence || phone_info[:phone_number_id]
channel.provider_config = current_config.merge(
'api_key' => access_token,
'phone_number_id' => resolved_phone_number_id,
'phone_number_id' => @phone_number_id,
'business_account_id' => @business_id,
'source' => 'embedded_signup'
)
+2 -18
View File
@@ -28,7 +28,6 @@ class Whatsapp::WebhookSetupService
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?
raise ArgumentError, 'Phone number ID is required' if @channel.provider_config['phone_number_id'].blank?
end
def register_phone_number
@@ -59,9 +58,8 @@ class Whatsapp::WebhookSetupService
def setup_webhook
callback_url = build_callback_url
verify_token = @channel.provider_config['webhook_verify_token']
phone_number_id = @channel.provider_config['phone_number_id']
@api_client.subscribe_phone_number_webhook(@waba_id, phone_number_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
raise "Webhook setup failed: #{e.message}"
@@ -70,24 +68,10 @@ class Whatsapp::WebhookSetupService
# Subscribe to `calls` only when voice calling is enabled on the inbox
def subscribed_fields
fields = %w[messages smb_message_echoes]
fields << 'calls' if calls_enabled_on_waba?
fields << 'calls' if @channel.provider_config['calling_enabled']
fields
end
# `subscribed_fields` is a WABA-wide app subscription, so keep `calls` whenever this inbox or
# any sibling on the same WABA has voice on — otherwise a non-calling sibling's setup would
# rewrite the shared subscription and drop calls for a calling-enabled sibling.
def calls_enabled_on_waba?
return true if @channel.provider_config['calling_enabled']
Channel::Whatsapp
.where(provider: 'whatsapp_cloud')
.where.not(id: @channel.id)
.where("provider_config->>'business_account_id' = ?", @waba_id)
.where("provider_config->>'calling_enabled' = 'true'")
.exists?
end
def build_callback_url
frontend_url = ENV.fetch('FRONTEND_URL', nil)
phone_number = @channel.phone_number
@@ -6,53 +6,42 @@ class Whatsapp::WebhookTeardownService
def perform
return unless should_teardown_webhook?
api_client = Whatsapp::FacebookApiClient.new(provider_config['api_key'])
clear_phone_number_override(api_client)
unsubscribe_app_if_last_inbox(api_client)
teardown_webhook
rescue StandardError => e
# before_destroy must never block a channel delete — log and move on.
Rails.logger.error "[WHATSAPP] Webhook teardown failed for channel #{@channel&.id}: #{e.message}"
handle_webhook_teardown_error(e)
end
private
def provider_config
@channel.provider_config || {}
end
def should_teardown_webhook?
@channel.provider == 'whatsapp_cloud' &&
provider_config['source'] == 'embedded_signup' &&
provider_config['api_key'].present? &&
(provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?)
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
end
def clear_phone_number_override(api_client)
phone_number_id = provider_config['phone_number_id']
return if phone_number_id.blank?
api_client.clear_phone_number_callback_override(phone_number_id)
Rails.logger.info "[WHATSAPP] Phone-level webhook override cleared for channel #{@channel.id}"
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}"
def whatsapp_cloud_provider?
@channel.provider == 'whatsapp_cloud'
end
# The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one.
def unsubscribe_app_if_last_inbox(api_client)
waba_id = provider_config['business_account_id']
return if waba_id.blank?
return if waba_sibling_exists?(waba_id)
api_client.unsubscribe_app_from_waba(waba_id)
Rails.logger.info "[WHATSAPP] WABA app subscription removed for channel #{@channel.id}"
rescue StandardError => e
Rails.logger.error "[WHATSAPP] WABA app unsubscribe failed for channel #{@channel.id}: #{e.message}"
def embedded_signup_source?
@channel.provider_config['source'] == 'embedded_signup'
end
def waba_sibling_exists?(waba_id)
Channel::Whatsapp
.where.not(id: @channel.id)
.exists?(["provider_config ->> 'business_account_id' = ?", waba_id])
def webhook_config_present?
@channel.provider_config['business_account_id'].present? &&
@channel.provider_config['api_key'].present?
end
def teardown_webhook
waba_id = @channel.provider_config['business_account_id']
access_token = @channel.provider_config['api_key']
api_client = Whatsapp::FacebookApiClient.new(access_token)
api_client.unsubscribe_waba_webhook(waba_id)
Rails.logger.info "[WHATSAPP] Webhook unsubscribed successfully for channel #{@channel.id}"
end
def handle_webhook_teardown_error(error)
Rails.logger.error "[WHATSAPP] Webhook teardown failed: #{error.message}"
# Don't raise the error to prevent channel deletion from failing
# Failed webhook teardown shouldn't block deletion
end
end
@@ -5,7 +5,6 @@ json.assignment_order assignment_policy.assignment_order
json.conversation_priority assignment_policy.conversation_priority
json.fair_distribution_limit assignment_policy.fair_distribution_limit
json.fair_distribution_window assignment_policy.fair_distribution_window
json.exclude_older_than_hours assignment_policy.exclude_older_than_hours
json.enabled assignment_policy.enabled
json.assigned_inbox_count assignment_policy.inboxes.count
json.created_at assignment_policy.created_at.to_i
@@ -2,7 +2,5 @@ json.id resource.id
json.name resource.name
json.description resource.description
json.allow_auto_assign resource.allow_auto_assign
json.icon resource.icon
json.icon_color resource.icon_color
json.account_id resource.account_id
json.is_member Current.user.teams.include?(resource)
-6
View File
@@ -253,12 +253,6 @@
display_title: 'Cloud Plans'
value:
description: 'Config to store stripe plans for cloud'
- name: MARKETING_CONVERSION_TRACKING_CONFIG
value:
display_title: 'Marketing Conversion Tracking Config'
description: 'JSON config for Chatwoot Cloud signup and plan activation conversion tracking'
locked: true
type: code
- name: CHATWOOT_CLOUD_PLAN_FEATURES
display_title: 'Planwise Features List'
value:
+8 -15
View File
@@ -215,35 +215,28 @@ dyte:
'type': 'object',
'properties':
{
'account_id': { 'type': 'string' },
'app_id': { 'type': 'string' },
'api_token': { 'type': 'string' },
'api_key': { 'type': 'string' },
'organization_id': { 'type': 'string' },
},
'required': ['account_id', 'app_id', 'api_token'],
'required': ['api_key', 'organization_id'],
'additionalProperties': false,
}
settings_form_schema:
[
{
'label': 'Cloudflare Account ID',
'label': 'Organization ID',
'type': 'text',
'name': 'account_id',
'name': 'organization_id',
'validation': 'required',
},
{
'label': 'RealtimeKit App ID',
'label': 'API Key',
'type': 'text',
'name': 'app_id',
'validation': 'required',
},
{
'label': 'Cloudflare API Token',
'type': 'text',
'name': 'api_token',
'name': 'api_key',
'validation': 'required',
},
]
visible_properties: ['account_id', 'app_id']
visible_properties: ['organization_id']
shopify:
id: shopify
+5 -49
View File
@@ -1,4 +1,4 @@
providers:
aproviders:
openai:
display_name: 'OpenAI'
anthropic:
@@ -59,10 +59,6 @@ models:
provider: openai
display_name: 'Whisper'
credit_multiplier: 1
gpt-4o-mini-transcribe:
provider: openai
display_name: 'GPT-4o Mini Transcribe'
credit_multiplier: 1
text-embedding-3-small:
provider: openai
display_name: 'Text Embedding 3 Small'
@@ -86,7 +82,6 @@ features:
assistant:
models:
[
gpt-4.1-mini,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
@@ -96,11 +91,10 @@ features:
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1
default: gpt-5.1
copilot:
models:
[
gpt-4.1-mini,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
@@ -110,52 +104,14 @@ features:
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1
default: gpt-5.1
label_suggestion:
models:
[gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5]
default: gpt-4.1-mini
document_faq_generation:
models:
[
gpt-4.1-mini,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
gpt-5.2,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1-mini
pdf_faq_generation:
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
default: gpt-4.1-mini
help_center_article_generation:
models:
[
gpt-4.1-mini,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
gpt-5.2,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-5.2
onboarding_content_generation:
models:
[gpt-4.1, gpt-4.1-mini, gpt-5-mini, gpt-5.1, gpt-5.2]
default: gpt-4.1
help_center_query_translation:
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini]
default: gpt-4.1-nano
audio_transcription:
models: [gpt-4o-mini-transcribe, whisper-1]
default: gpt-4o-mini-transcribe
models: [whisper-1]
default: whisper-1
help_center_search:
models: [text-embedding-3-small]
default: text-embedding-3-small
+2 -33
View File
@@ -113,15 +113,6 @@ en:
unique: should be unique in the category and portal
dyte:
invalid_message_type: 'Invalid message type. Action not permitted'
realtimekit_credentials_required: 'Cloudflare RealtimeKit credentials are required. Delete the existing Dyte integration and create it again with Cloudflare RealtimeKit credentials.'
cloudflare:
realtimekit:
invalid_credentials: 'Cloudflare RealtimeKit credentials are invalid. Please check the Account ID, RealtimeKit App ID, and API token permissions.'
missing_credentials: 'Cloudflare Account ID, RealtimeKit App ID, and API token are required.'
invalid_api_token: 'Cloudflare API token is invalid or inactive. Please create a new token with Realtime Admin permissions.'
invalid_account_or_permissions: 'Cloudflare Account ID is invalid, or the API token does not have Realtime Admin access to this account.'
app_not_found: 'RealtimeKit App ID was not found in this Cloudflare account. Please check the app you selected.'
verification_failed: 'Could not verify Cloudflare RealtimeKit credentials right now. Please try again.'
slack:
invalid_channel_id: 'Invalid slack channel. Please try again'
whatsapp:
@@ -359,9 +350,9 @@ en:
name: 'Dashboard Apps'
description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
dyte:
name: 'Cloudflare RealtimeKit'
name: 'Dyte'
short_description: 'Start video/voice calls with customers directly from Chatwoot.'
description: 'Cloudflare RealtimeKit lets your agents start video/voice calls with your customers directly from Chatwoot.'
description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
meeting_name: '%{agent_name} has started a meeting'
slack:
name: 'Slack'
@@ -574,28 +565,6 @@ en:
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
super_admin:
captain_model_overrides:
form:
helper_text: 'Leave a model blank to use the YAML default for that AI feature.'
use_default: 'Use default: %{model} (%{model_id})'
show:
summary: 'View model routing'
provider: 'Provider'
model: 'Model'
sources:
account_override: 'Account override'
default: 'Default'
features:
editor: 'Editor'
assistant: 'Assistant'
copilot: 'Copilot'
label_suggestion: 'Label suggestion'
document_faq_generation: 'Document FAQ generation'
help_center_article_generation: 'Help center article generation'
onboarding_content_generation: 'Onboarding content generation'
help_center_query_translation: 'Help center query translation'
audio_transcription: 'Audio transcription'
help_center_search: 'Help center search'
push_diagnostics:
user_not_found: 'User not found.'
no_subscriptions_to_test: 'Select at least one subscription to test.'
@@ -1,6 +0,0 @@
class AddIconToTeams < ActiveRecord::Migration[7.1]
def change
add_column :teams, :icon, :string, default: '' unless column_exists?(:teams, :icon)
add_column :teams, :icon_color, :string, default: '' unless column_exists?(:teams, :icon_color)
end
end
@@ -1,6 +0,0 @@
class AddExcludeOlderThanHoursToAssignmentPolicies < ActiveRecord::Migration[7.1]
def change
# Default 168 hours (7 days); nil disables the age exclusion for the policy
add_column :assignment_policies, :exclude_older_than_hours, :integer, default: 168
end
end
@@ -1,9 +0,0 @@
class BackfillRejectedCallStatus < ActiveRecord::Migration[7.1]
def up
execute("UPDATE calls SET status = 'rejected' WHERE status = 'failed' AND end_reason = 'agent_rejected'")
end
def down
execute("UPDATE calls SET status = 'failed' WHERE status = 'rejected' AND end_reason = 'agent_rejected'")
end
end
-3
View File
@@ -205,7 +205,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.boolean "enabled", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "exclude_older_than_hours", default: 168
t.index ["account_id", "name"], name: "index_assignment_policies_on_account_id_and_name", unique: true
t.index ["account_id"], name: "index_assignment_policies_on_account_id"
t.index ["enabled"], name: "index_assignment_policies_on_enabled"
@@ -1266,8 +1265,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "icon", default: ""
t.string "icon_color", default: ""
t.index ["account_id"], name: "index_teams_on_account_id"
t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true
end
@@ -49,7 +49,7 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
end
def destroy
Companies::DeleteJob.perform_later(company_id: @company.id)
@company.destroy!
head :ok
end
@@ -74,9 +74,9 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
rejected = call.with_lock do
next false unless agent_rejecting_before_pickup?(call)
call.update!(status: 'rejected', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id)
call.update!(status: 'failed', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id)
true
end
Voice::CallMessageBuilder.new(call).update_status!(status: 'rejected', agent: Current.user) if rejected
Voice::CallMessageBuilder.new(call).update_status!(status: 'failed', agent: Current.user) if rejected
end
end

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