Merge branch 'develop' into fix/CW-6859

This commit is contained in:
Sivin Varghese
2026-04-27 15:42:41 +05:30
committed by GitHub
107 changed files with 3688 additions and 586 deletions
+1
View File
@@ -84,6 +84,7 @@ gem 'barnes'
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
+4
View File
@@ -727,6 +727,9 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
@@ -1125,6 +1128,7 @@ DEPENDENCIES
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.1)
rails-i18n (~> 7.0)
redis
redis-namespace
responders (>= 3.1.1)
+1 -1
View File
@@ -41,7 +41,7 @@ class Email::BaseBuilder
end
def business_name
inbox.business_name || inbox.sanitized_name
inbox.sanitized_business_name
end
def account_support_email
@@ -0,0 +1,43 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :set_articles, only: [:update_status, :delete_articles]
def translate
head :not_implemented
end
def update_status
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(status: params[:status]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@articles.destroy_all
head :ok
end
private
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def check_authorization
authorize(Article, :create?)
end
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
@@ -0,0 +1,68 @@
class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController
def show
@query = params[:user_query].to_s.strip
@user = resolve_user(@query)
@subscriptions = @user ? @user.notification_subscriptions.order(:id) : []
@results = []
end
def create
@user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: @user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test')
end
run_test_and_render(ids)
end
def destroy_subscriptions
user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete')
end
deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size
log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}")
redirect_to super_admin_push_diagnostics_path(user_query: user.id),
notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count)
end
private
def run_test_and_render(ids)
@query = @user.id.to_s
@subscriptions = @user.notification_subscriptions.order(:id)
@results = Notification::PushTestService.new(
user: @user, subscription_ids: ids,
title: params[:push_title], body: params[:push_body]
).perform
log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}")
render :show
end
def log_super_admin_action(message)
Rails.logger.info(
"[SuperAdmin] push diagnostics #{message} " \
"(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})"
)
end
def resolve_user(query)
return if query.blank?
query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query)
end
def parsed_subscription_ids
Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i)
end
end
@@ -72,6 +72,27 @@ class ArticlesAPI extends PortalsAPI {
category_slug: categorySlug,
});
}
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
return axios.post(
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
{ ids: articleIds, locale, category_id: categoryId, force }
);
}
bulkUpdateStatus({ portalSlug, articleIds, status }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
{ ids: articleIds, status }
);
}
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
{ data: { ids: articleIds } }
);
}
}
export default new ArticlesAPI();
@@ -112,11 +112,14 @@ const selectedModel = computed({
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div v-if="!isInboxView" class="w-20 flex-shrink-0">
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div v-if="!isInboxView" class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div
v-if="!isInboxView && showInboxName"
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
/>
<div
v-tooltip.top="{
@@ -9,11 +9,15 @@ import {
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
@@ -44,14 +48,46 @@ const props = defineProps({
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showSelectionControl: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['openArticle', 'articleAction']);
const emit = defineEmits([
'openArticle',
'articleAction',
'toggleSelect',
'hover',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const articleMenuItems = computed(() => {
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
(acc, [key, item]) => {
@@ -64,7 +100,9 @@ const articleMenuItems = computed(() => {
const statusItems = (
ARTICLE_MENU_OPTIONS[props.status] ||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
).map(key => commonItems[key]);
)
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
});
@@ -123,14 +161,27 @@ const handleClick = id => {
</script>
<template>
<CardLayout>
<CardLayout
:selectable="selectable"
class="relative"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="showSelectionControl"
class="absolute top-7 ltr:left-3 rtl:right-3"
>
<Checkbox :model-value="isSelected" @change="emit('toggleSelect', id)" />
</div>
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
<div class="flex items-center gap-2 min-w-0">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
</div>
<div class="flex items-center gap-2">
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
@@ -138,7 +138,7 @@ const handleCreateArticle = event => {
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
:autofocus="isNewArticle"
@blur="handleCreateArticle"
/>
<ArticleEditorControls
@@ -155,7 +155,7 @@ const handleCreateArticle = event => {
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
:autofocus="!isNewArticle"
/>
</template>
</HelpCenterLayout>
@@ -20,8 +20,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
selectedArticleIds: {
type: Set,
default: () => new Set(),
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
const { ARTICLE_STATUS_TYPES } = wootConstants;
const router = useRouter();
@@ -30,12 +36,26 @@ const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
// Enable dragging only for category articles and when there's more than one article
return props.isCategoryArticles && localArticles.value?.length > 1;
return (
props.isCategoryArticles &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
});
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
const shouldShowSelectionControl = id => {
return hoveredArticleId.value === id || hasBulkSelection.value;
};
const handleCardHover = (isHovered, id) => {
hoveredArticleId.value = isHovered ? id : null;
};
const getCategoryById = useMapGetter('categories/categoryById');
const openArticle = id => {
@@ -152,6 +172,10 @@ const handleArticleAction = async (action, { status, id }) => {
};
const updateArticle = ({ action, value, id }) => {
if (action === 'translate') {
emit('translateArticle', id);
return;
}
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
@@ -187,9 +211,14 @@ watch(
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
:is-selected="selectedArticleIds.has(element.id)"
selectable
:show-selection-control="shouldShowSelectionControl(element.id)"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, element.id)"
/>
</li>
</template>
@@ -1,9 +1,13 @@
<script setup>
import { computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
@@ -11,6 +15,10 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
articles: {
@@ -39,7 +47,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['pageChange', 'fetchPortal']);
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
const router = useRouter();
const route = useRoute();
@@ -47,6 +55,42 @@ const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetching = useMapGetter('articles/isFetching');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
const selectAllLabel = computed(() => {
if (!visibleArticleIds.value.length) return '';
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
count: visibleArticleIds.value.length,
});
});
const selectedCountLabel = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
count: selectedArticleIds.value.size,
})
);
const bulkTranslateDialogRef = ref(null);
const hasNoArticles = computed(
() => !isFetching.value && !props.articles.length
@@ -128,6 +172,80 @@ const navigateToNewArticlePage = () => {
params: { categorySlug, locale },
});
};
const handleToggleSelect = articleId => {
const newSet = new Set(selectedArticleIds.value);
if (newSet.has(articleId)) {
newSet.delete(articleId);
} else {
newSet.add(articleId);
}
selectedArticleIds.value = newSet;
};
const clearSelection = () => {
selectedArticleIds.value = new Set();
};
const handleTranslateArticle = articleId => {
selectedArticleIds.value = new Set([articleId]);
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const openTranslateDialog = () => {
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const onBulkActionSuccess = message => {
useAlert(message);
clearSelection();
emit('refreshArticles');
};
const bulkUpdateStatus = async status => {
try {
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
);
}
};
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
const bulkDelete = async () => {
try {
await articlesAPI.bulkDelete({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
});
deleteConfirmDialogRef.value?.close();
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
);
} catch (error) {
deleteConfirmDialogRef.value?.close();
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
);
}
};
// Clear selection when articles change (page change, filter change)
watch(
() => props.articles,
() => clearSelection()
);
</script>
<template>
@@ -166,11 +284,91 @@ const navigateToNewArticlePage = () => {
>
<Spinner />
</div>
<ArticleList
v-else-if="!hasNoArticles"
:articles="articles"
:is-category-articles="isCategoryArticles"
/>
<template v-else-if="!hasNoArticles">
<div
v-if="selectedArticleIds.size > 0"
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
>
<BulkSelectBar
v-model="selectedArticleIds"
:all-items="allItems"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
>
<template #secondary-actions>
<Button
sm
ghost
slate
:label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
"
class="!px-1.5"
@click="clearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<Button
sm
faded
slate
icon="i-lucide-check"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('published')"
/>
<Button
sm
faded
slate
icon="i-lucide-pencil-line"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('draft')"
/>
<Button
sm
faded
slate
icon="i-lucide-archive-restore"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
<Button
v-if="isTranslationAvailable"
sm
faded
slate
icon="i-lucide-languages"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="openTranslateDialog"
/>
<Button
sm
faded
ruby
icon="i-lucide-trash"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
@click="confirmBulkDelete"
/>
</div>
</template>
</BulkSelectBar>
</div>
<ArticleList
:articles="articles"
:is-category-articles="isCategoryArticles"
:selected-article-ids="selectedArticleIds"
class="relative z-0"
@translate-article="handleTranslateArticle"
@toggle-select="handleToggleSelect"
/>
</template>
<ArticleEmptyState
v-else
class="pt-14"
@@ -183,5 +381,31 @@ const navigateToNewArticlePage = () => {
@click="navigateToNewArticlePage"
/>
</template>
<BulkTranslateDialog
ref="bulkTranslateDialogRef"
:selected-article-ids="[...selectedArticleIds]"
:allowed-locales="allowedLocales"
@translate-started="clearSelection"
/>
<Dialog
ref="deleteConfirmDialogRef"
type="alert"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
selectedArticleIds.size
)
"
:description="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
selectedArticleIds.size
)
"
:confirm-button-label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
"
@confirm="bulkDelete"
/>
</HelpCenterLayout>
</template>
@@ -0,0 +1,249 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
selectedArticleIds: {
type: Array,
default: () => [],
},
allowedLocales: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['translateStarted']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const dialogRef = ref(null);
const isSubmitting = ref(false);
const selectedLocale = ref('');
const selectedCategoryId = ref('');
const targetCategories = ref([]);
const isFetchingCategories = ref(false);
const duplicateArticles = ref([]);
const currentLocale = computed(() => route.params.locale);
const localeOptions = computed(() => {
return props.allowedLocales
.filter(locale => locale.code !== currentLocale.value)
.map(locale => ({
value: locale.code,
label: `${locale.name} (${locale.code})`,
}));
});
const categoryOptions = computed(() => {
return targetCategories.value.map(category => ({
value: category.id,
label: category.name,
}));
});
const articleCount = computed(() => props.selectedArticleIds.length);
const dialogTitle = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
);
const description = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
);
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
const confirmLabel = computed(() => {
if (hasDuplicates.value) {
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
}
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
});
const isConfirmDisabled = computed(() => {
return !selectedLocale.value || isSubmitting.value;
});
const articleEditUrl = articleId => {
const { portalSlug, categorySlug, tab } = route.params;
const resolved = router.resolve({
name: 'portals_articles_edit',
params: {
portalSlug,
locale: selectedLocale.value,
categorySlug,
tab,
articleSlug: articleId,
},
});
return resolved.href;
};
const fetchCategoriesForLocale = async locale => {
if (!locale) {
targetCategories.value = [];
return;
}
isFetchingCategories.value = true;
try {
const { data } = await categoriesAPI.get({
portalSlug: route.params.portalSlug,
locale,
});
targetCategories.value = data.payload;
} catch {
targetCategories.value = [];
} finally {
isFetchingCategories.value = false;
}
};
watch(selectedLocale, newLocale => {
selectedCategoryId.value = '';
duplicateArticles.value = [];
fetchCategoriesForLocale(newLocale);
});
const resetForm = () => {
selectedLocale.value = '';
selectedCategoryId.value = '';
targetCategories.value = [];
duplicateArticles.value = [];
};
const submitTranslation = async (force = false) => {
isSubmitting.value = true;
try {
await store.dispatch('articles/bulkTranslate', {
portalSlug: route.params.portalSlug,
articleIds: props.selectedArticleIds,
locale: selectedLocale.value,
categoryId: selectedCategoryId.value,
force,
});
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
resetForm();
dialogRef.value?.close();
emit('translateStarted');
} catch (error) {
if (error.response?.status === 409) {
duplicateArticles.value = error.response.data.duplicate_articles;
return;
}
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
);
} finally {
isSubmitting.value = false;
}
};
const onConfirm = () => {
if (isConfirmDisabled.value) return;
submitTranslation(hasDuplicates.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="dialogTitle"
:description="description"
:confirm-button-label="confirmLabel"
:disable-confirm-button="isConfirmDisabled"
:is-loading="isSubmitting"
@close="resetForm"
@confirm="onConfirm"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
</span>
<ComboBox
v-model="selectedLocale"
:options="localeOptions"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
<span class="text-n-slate-10 font-normal">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
</span>
</span>
<ComboBox
v-model="selectedCategoryId"
:options="categoryOptions"
:disabled="!selectedLocale || isFetchingCategories"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div
v-if="hasDuplicates"
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
>
<Icon
icon="i-lucide-triangle-alert"
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
/>
<div class="flex flex-col gap-2 min-w-0">
<p class="text-sm text-n-amber-12 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
duplicateArticles.length
)
}}
</p>
<div class="flex flex-col gap-1">
<a
v-for="article in duplicateArticles"
:key="article.id"
:href="articleEditUrl(article.id)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
>
{{ article.title }}
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
</a>
</div>
<p class="text-xs text-n-amber-11 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
)
}}
</p>
</div>
</div>
</div>
</Dialog>
</template>
@@ -233,6 +233,7 @@ onMounted(() => resetContacts());
<Popover
ref="popoverRef"
:align="align"
:show-content-border="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
@@ -20,6 +20,7 @@ const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
isTwilioWhatsAppInbox: { type: Boolean, default: false },
// eslint-disable-next-line vue/no-unused-properties
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
@@ -198,7 +199,6 @@ useEventListener(document, 'paste', onPaste);
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<ContentTemplateSelector
@@ -24,9 +24,7 @@ const handleBack = () => {
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
<div class="w-full">
<ContentTemplateParser
:template="template"
@@ -6,6 +6,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import ContentTemplateForm from './ContentTemplateForm.vue';
const props = defineProps({
@@ -22,7 +23,6 @@ const inbox = useMapGetter('inboxes/getInbox');
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const contentTemplates = computed(() => {
const inboxData = inbox.value(props.inboxId);
@@ -39,29 +39,36 @@ const filteredTemplates = computed(() => {
);
});
const handleTriggerClick = () => {
const handlePopoverShow = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
selectedTemplate.value = null;
};
const handlePopoverHide = () => {
selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
selectedTemplate.value = null;
hide();
};
</script>
<template>
<div class="relative">
<Popover
align="start"
disable-mobile-view
@show="handlePopoverShow"
@hide="handlePopoverHide"
>
<Button
icon="i-ph-whatsapp-logo"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
@@ -69,56 +76,59 @@ const handleSendMessage = template => {
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<template #content="{ hide }">
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
v-if="!selectedTemplate"
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER'
)
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<ContentTemplateForm
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
<ContentTemplateForm
v-else
:template="selectedTemplate"
@send-message="payload => handleSendMessage(payload, hide)"
@back="handleBack"
/>
</template>
</Popover>
</template>
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
const props = defineProps({
@@ -24,8 +25,6 @@ const getFilteredWhatsAppTemplates = useMapGetter(
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
@@ -40,29 +39,36 @@ const getTemplateBody = template => {
return template.components.find(component => component.type === 'BODY').text;
};
const handleTriggerClick = () => {
const handlePopoverShow = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
selectedTemplate.value = null;
};
const handlePopoverHide = () => {
selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
selectedTemplate.value = null;
hide();
};
</script>
<template>
<div class="relative">
<Popover
align="start"
disable-mobile-view
@show="handlePopoverShow"
@hide="handlePopoverHide"
>
<Button
icon="i-ri-whatsapp-line"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
@@ -70,50 +76,53 @@ const handleSendMessage = template => {
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<template #content="{ hide }">
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
v-if="!selectedTemplate"
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{
t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE')
}}
</p>
</template>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplate
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
<WhatsappTemplate
v-else
:template="selectedTemplate"
@send-message="payload => handleSendMessage(payload, hide)"
@back="handleBack"
/>
</template>
</Popover>
</template>
@@ -24,9 +24,7 @@ const handleBack = () => {
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@@ -12,6 +12,14 @@ const props = defineProps({
default: 'end',
validator: v => ['start', 'end'].includes(v),
},
disableMobileView: {
type: Boolean,
default: false,
},
showContentBorder: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['show', 'hide']);
@@ -22,7 +30,8 @@ const popoverRef = ref(null);
const mobileContentRef = ref(null);
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('md');
const belowMd = breakpoints.smaller('md');
const isMobile = computed(() => !props.disableMobileView && belowMd.value);
const showPopover = computed(() => isActive.value && !isMobile.value);
const { fixedPosition, updatePosition } = useDropdownPosition(
@@ -99,9 +108,13 @@ defineExpose({ show, hide, toggle });
{ ignore: clickOutsideIgnore },
]"
data-popover-content
class="relative w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 overflow-y-auto bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
class="relative flex flex-col w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<slot name="content" :hide="hide" />
<div
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
>
<slot name="content" :hide="hide" />
</div>
</div>
</div>
@@ -113,9 +126,14 @@ defineExpose({ show, hide, toggle });
data-popover-content
:class="fixedPosition.class"
:style="fixedPosition.style"
class="bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl overflow-y-auto max-h-[calc(100vh-2rem)]"
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<slot name="content" :hide="hide" />
<div
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
:class="{ 'border border-n-strong': showContentBorder }"
>
<slot name="content" :hide="hide" />
</div>
</div>
</TeleportWithDirection>
</template>
@@ -65,12 +65,11 @@ defineExpose({ conversationListRef });
>
<Virtualizer
ref="virtualListRef"
v-slot="{ item, index }"
v-slot="{ item }"
:data="conversationList"
class="[&>div:has(+_div_.active)>*]:!border-n-surface-1 [&>div:has(+_div_.selected)>*]:!border-n-surface-1"
>
<ConversationItem
:key="index"
:source="item"
:label="label"
:team-id="teamId"
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
action: 'archive',
icon: 'i-lucide-archive-restore',
},
translate: {
label:
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
value: 'translate',
action: 'translate',
icon: 'i-lucide-languages',
},
delete: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
value: 'delete',
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
};
export const ARTICLE_MENU_OPTIONS = {
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
};
export const ARTICLE_TABS = {
@@ -525,6 +525,7 @@
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Delete"
},
"STATUS": {
@@ -579,6 +580,41 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
},
"BULK_TRANSLATE": {
"TITLE": "Translate article | Translate {count} articles",
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
"LOCALE_LABEL": "Target language",
"LOCALE_PLACEHOLDER": "Select a language",
"CATEGORY_LABEL": "Target category",
"CATEGORY_PLACEHOLDER": "Select a category",
"OPTIONAL": "(optional)",
"CONFIRM": "Translate",
"SELECT_ALL": "Select all ({count})",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"TRANSLATE_BUTTON": "Translate",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -745,6 +745,7 @@
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
"INBOX_UPDATE_TITLE": "Inbox Settings",
@@ -60,9 +60,7 @@ const onDelete = async hide => {
<Popover @hide="$emit('close')">
<slot name="trigger" />
<template #content="{ hide }">
<div
class="w-full md:w-80 p-6 flex flex-col gap-4 border-0 md:border rounded-xl md:border-n-strong"
>
<div class="w-full md:w-80 p-6 flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('DELETE_CONTACT.CONFIRM.TITLE') }}
@@ -72,9 +72,7 @@ const onMergeContacts = async (parentContactId, hide) => {
<Popover @hide="$emit('close')">
<slot name="trigger" />
<template #content="{ hide }">
<div
class="w-full md:w-96 p-6 flex flex-col gap-4 border-0 md:border rounded-xl md:border-n-strong"
>
<div class="w-full md:w-96 p-6 flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('MERGE_CONTACTS.TITLE') }}
@@ -125,7 +125,7 @@ export default {
set(priorityItem) {
const conversationId = this.currentChat.id;
const oldValue = this.currentChat?.priority;
const priority = priorityItem ? priorityItem.id : null;
const priority = priorityItem.id;
this.$store.dispatch('setCurrentChatPriority', {
priority,
@@ -203,7 +203,9 @@ export default {
this.assignedPriority &&
this.assignedPriority.id === selectedPriorityItem.id;
this.assignedPriority = isSamePriority ? null : selectedPriorityItem;
this.assignedPriority = isSamePriority
? this.priorityOptions[0]
: selectedPriorityItem;
},
},
};
@@ -119,6 +119,7 @@ watch(
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@fetch-portal="fetchPortalAndItsCategories"
@refresh-articles="fetchArticles"
/>
</div>
</template>
@@ -112,9 +112,33 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
uiFlags: 'inboxes/getUIFlags',
portals: 'portals/allPortals',
}),
isInboundEmailEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.INBOUND_EMAILS
);
},
showContinuityToggle() {
if (this.isInboundEmailEnabled) return true;
return this.isOnChatwootCloud;
},
isContinuityDisabled() {
return this.isOnChatwootCloud && !this.isInboundEmailEnabled;
},
continuityDescription() {
if (this.isContinuityDisabled) {
return this.$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT'
);
}
return this.$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
);
},
selectedTabKey() {
return this.tabs[this.selectedTabIndex]?.key;
},
@@ -542,7 +566,8 @@ export default {
welcome_tagline: this.channelWelcomeTagline || '',
selectedFeatureFlags: this.selectedFeatureFlags,
reply_time: this.replyTime || 'in_a_few_minutes',
continuity_via_email: this.continuityViaEmail,
continuity_via_email:
this.isInboundEmailEnabled && this.continuityViaEmail,
},
};
if (this.avatarFile) {
@@ -1148,15 +1173,15 @@ export default {
/>
<SettingsToggleSection
v-if="isAWebWidgetInbox"
v-if="isAWebWidgetInbox && showContinuityToggle"
v-model="continuityViaEmail"
:header="
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL')
"
:description="
$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
)
:description="continuityDescription"
:hide-toggle="isContinuityDisabled"
:class="
isContinuityDisabled ? 'cursor-not-allowed opacity-50' : ''
"
/>
</SettingsAccordion>
@@ -166,4 +166,18 @@ export const actions = {
throw error;
}
},
bulkTranslate: async (
_,
{ portalSlug, articleIds, locale, categoryId, force = false }
) => {
const { data } = await articlesAPI.bulkTranslate({
portalSlug,
articleIds,
locale,
categoryId,
force,
});
return data;
},
};
@@ -1,3 +1,5 @@
import { isApple } from './platform';
export const isEnter = e => {
return e.key === 'Enter';
};
@@ -14,13 +16,20 @@ export const hasPressedCommand = e => {
return e.metaKey;
};
// True when the platform's "command" modifier is held: Cmd (metaKey) on
// Apple platforms (macOS, iOS/iPadOS hardware keyboards), Ctrl (ctrlKey)
// elsewhere. Mirrors the `$mod` convention used by tinykeys and
// prosemirror-keymap so the editor and the app agree on what counts as the
// send modifier.
export const hasPressedMod = e => Boolean(isApple() ? e.metaKey : e.ctrlKey);
export const hasPressedEnterAndNotCmdOrShift = e => {
return isEnter(e) && !hasPressedCommand(e) && !hasPressedShift(e);
return isEnter(e) && !hasPressedMod(e) && !hasPressedShift(e);
};
export const hasPressedCommandAndEnter = e => {
return hasPressedCommand(e) && isEnter(e);
};
// Detects the platform-aware "send" shortcut: Cmd+Enter on Apple platforms,
// Ctrl+Enter on Windows/Linux.
export const hasPressedCommandAndEnter = e => hasPressedMod(e) && isEnter(e);
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
// https://github.com/chatwoot/chatwoot/issues/9492
+50
View File
@@ -0,0 +1,50 @@
// Detects the current OS using the modern User-Agent Client Hints API,
// falling back to userAgent parsing on Safari/Firefox where it is unavailable.
// Treats iPad on iOS 13+ (which spoofs Macintosh) as iOS via maxTouchPoints.
export const OS = Object.freeze({
MAC: 'macos',
WINDOWS: 'windows',
LINUX: 'linux',
ANDROID: 'android',
IOS: 'ios',
UNKNOWN: 'unknown',
});
// navigator.userAgentData.platform → OS constant (lowercased keys)
const UAD_MAP = {
macos: OS.MAC,
windows: OS.WINDOWS,
linux: OS.LINUX,
android: OS.ANDROID,
ios: OS.IOS,
};
export function detectOS() {
if (typeof navigator === 'undefined') return OS.UNKNOWN;
// Trust userAgentData only when it maps to a known OS; otherwise fall
// through to UA parsing so unmapped values (e.g. "Chrome OS") don't leak.
const uad = navigator.userAgentData?.platform?.toLowerCase();
if (uad && UAD_MAP[uad]) return UAD_MAP[uad];
const ua = navigator.userAgent || '';
if (/android/i.test(ua)) return OS.ANDROID;
if (/iPhone|iPod/.test(ua)) return OS.IOS;
if (
/iPad/.test(ua) ||
(/Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1)
) {
return OS.IOS;
}
if (/Win/i.test(ua)) return OS.WINDOWS;
if (/Mac/i.test(ua)) return OS.MAC;
if (/Linux/i.test(ua)) return OS.LINUX;
return OS.UNKNOWN;
}
export const isApple = () => {
const os = detectOS();
return os === OS.MAC || os === OS.IOS;
};
@@ -3,9 +3,29 @@ import {
isEscape,
hasPressedShift,
hasPressedCommand,
hasPressedMod,
hasPressedCommandAndEnter,
hasPressedEnterAndNotCmdOrShift,
isActiveElementTypeable,
} from '../KeyboardHelpers';
const setNavigator = navigatorValue => {
Object.defineProperty(global, 'navigator', {
value: navigatorValue,
configurable: true,
writable: true,
});
};
const onMac = () => setNavigator({ userAgentData: { platform: 'macOS' } });
const onWindows = () =>
setNavigator({ userAgentData: { platform: 'Windows' } });
const onIOS = () =>
setNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
});
describe('#KeyboardHelpers', () => {
describe('#isEnter', () => {
it('return correct values', () => {
@@ -30,6 +50,112 @@ describe('#KeyboardHelpers', () => {
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
});
});
describe('#hasPressedMod', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('uses metaKey on macOS', () => {
onMac();
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
});
it('uses ctrlKey on Windows', () => {
onWindows();
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(true);
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(false);
});
it('uses metaKey on iOS hardware keyboards', () => {
onIOS();
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
});
it('returns false when no modifier is held', () => {
onWindows();
expect(hasPressedMod({ metaKey: false, ctrlKey: false })).toBe(false);
});
});
describe('#hasPressedCommandAndEnter', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('returns true for Cmd+Enter on macOS', () => {
onMac();
expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
true
);
});
it('returns true for Ctrl+Enter on Windows (CW-6859 fix)', () => {
onWindows();
expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
true
);
});
it('returns false for Ctrl+Enter on macOS (Mac uses Cmd, not Ctrl)', () => {
onMac();
expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
false
);
});
it('returns true for Cmd+Enter on iOS hardware keyboards', () => {
onIOS();
expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
true
);
});
it('returns false for plain Enter', () => {
onWindows();
expect(hasPressedCommandAndEnter({ key: 'Enter' })).toBe(false);
});
});
describe('#hasPressedEnterAndNotCmdOrShift', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('returns true for plain Enter on Windows', () => {
onWindows();
expect(hasPressedEnterAndNotCmdOrShift({ key: 'Enter' })).toBe(true);
});
it('returns false for Ctrl+Enter on Windows (mod is held)', () => {
onWindows();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', ctrlKey: true })
).toBe(false);
});
it('returns false for Cmd+Enter on macOS (mod is held)', () => {
onMac();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', metaKey: true })
).toBe(false);
});
it('returns false for Shift+Enter', () => {
onWindows();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', shiftKey: true })
).toBe(false);
});
});
});
describe('isActiveElementTypeable', () => {
@@ -0,0 +1,186 @@
import { detectOS, isApple, OS } from '../platform';
const setNavigator = ({ userAgentData, userAgent, maxTouchPoints } = {}) => {
Object.defineProperty(global, 'navigator', {
value: { userAgentData, userAgent, maxTouchPoints },
configurable: true,
writable: true,
});
};
describe('detectOS', () => {
const originalNavigator = global.navigator;
afterEach(() => {
Object.defineProperty(global, 'navigator', {
value: originalNavigator,
configurable: true,
writable: true,
});
});
describe('with userAgentData available', () => {
it('returns OS.MAC for macOS', () => {
setNavigator({ userAgentData: { platform: 'macOS' } });
expect(detectOS()).toBe(OS.MAC);
});
it('returns OS.WINDOWS for Windows', () => {
setNavigator({ userAgentData: { platform: 'Windows' } });
expect(detectOS()).toBe(OS.WINDOWS);
});
it('returns OS.LINUX for Linux', () => {
setNavigator({ userAgentData: { platform: 'Linux' } });
expect(detectOS()).toBe(OS.LINUX);
});
it('returns OS.ANDROID for Android', () => {
setNavigator({ userAgentData: { platform: 'Android' } });
expect(detectOS()).toBe(OS.ANDROID);
});
it('falls through to userAgent for unmapped values like "Chrome OS"', () => {
setNavigator({
userAgentData: { platform: 'Chrome OS' },
userAgent: 'Mozilla/5.0 (X11; CrOS x86_64) AppleWebKit/537.36',
});
// Not a mapped UAD value AND not a recognized UA pattern → unknown
expect(detectOS()).toBe(OS.UNKNOWN);
});
it('prefers userAgentData over userAgent when value is mapped', () => {
setNavigator({
userAgentData: { platform: 'Windows' },
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
});
expect(detectOS()).toBe(OS.WINDOWS);
});
});
describe('with userAgent fallback', () => {
it('detects macOS from Safari userAgent', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
});
expect(detectOS()).toBe(OS.MAC);
});
it('detects Windows from userAgent', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
});
expect(detectOS()).toBe(OS.WINDOWS);
});
it('detects Linux from userAgent', () => {
setNavigator({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
});
expect(detectOS()).toBe(OS.LINUX);
});
it('detects Android from userAgent (before Linux match)', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36',
});
expect(detectOS()).toBe(OS.ANDROID);
});
it('detects iOS from iPhone userAgent', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
});
expect(detectOS()).toBe(OS.IOS);
});
it('detects iPadOS spoofing Macintosh via maxTouchPoints', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
maxTouchPoints: 5,
});
expect(detectOS()).toBe(OS.IOS);
});
it('returns OS.UNKNOWN when no match', () => {
setNavigator({ userAgent: 'SomeRandomBot/1.0' });
expect(detectOS()).toBe(OS.UNKNOWN);
});
it('returns OS.UNKNOWN when userAgent is missing', () => {
setNavigator({});
expect(detectOS()).toBe(OS.UNKNOWN);
});
});
describe('without navigator', () => {
it('returns OS.UNKNOWN when navigator is undefined', () => {
Object.defineProperty(global, 'navigator', {
value: undefined,
configurable: true,
writable: true,
});
expect(detectOS()).toBe(OS.UNKNOWN);
});
});
});
describe('isApple', () => {
const originalNavigator = global.navigator;
afterEach(() => {
Object.defineProperty(global, 'navigator', {
value: originalNavigator,
configurable: true,
writable: true,
});
});
it('returns true on macOS', () => {
setNavigator({ userAgentData: { platform: 'macOS' } });
expect(isApple()).toBe(true);
});
it('returns true on iOS (iPhone)', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
});
expect(isApple()).toBe(true);
});
it('returns true on iPadOS spoofing Macintosh', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
maxTouchPoints: 5,
});
expect(isApple()).toBe(true);
});
it('returns false on Windows', () => {
setNavigator({ userAgentData: { platform: 'Windows' } });
expect(isApple()).toBe(false);
});
it('returns false on Linux', () => {
setNavigator({ userAgentData: { platform: 'Linux' } });
expect(isApple()).toBe(false);
});
it('returns false on Android', () => {
setNavigator({ userAgentData: { platform: 'Android' } });
expect(isApple()).toBe(false);
});
});
describe('OS constants', () => {
it('is frozen so callers cannot mutate it', () => {
expect(Object.isFrozen(OS)).toBe(true);
});
});
+84 -84
View File
@@ -1,153 +1,153 @@
{
"COMPONENTS": {
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploading..."
"DOWNLOAD": "Laadi alla",
"UPLOADING": "Üleslaadimine..."
},
"FORM_BUBBLE": {
"SUBMIT": "Submit"
"SUBMIT": "Saada"
},
"MESSAGE_BUBBLE": {
"RETRY": "Send message again",
"ERROR_MESSAGE": "Couldn't send, try again"
"RETRY": "Saada sõnum uuesti",
"ERROR_MESSAGE": "Saatmine ebaõnnestus, proovi uuesti"
}
},
"THUMBNAIL": {
"AUTHOR": {
"NOT_AVAILABLE": "Not available"
"NOT_AVAILABLE": "Pole saadaval"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "We are online",
"OFFLINE": "We are away at the moment",
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
"ONLINE": "Oleme võrgus",
"OFFLINE": "Oleme hetkel eemal",
"BACK_AS_SOON_AS_POSSIBLE": "Oleme tagasi esimesel võimalusel"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
"IN_A_FEW_HOURS": "Typically replies in a few hours",
"IN_A_DAY": "Typically replies in a day",
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
"BACK_AT_TIME": "We will be back online at {time}",
"BACK_ON_DAY": "We will be back online on {day}",
"BACK_TOMORROW": "We will be back online tomorrow",
"BACK_IN_SOME_TIME": "We will be back online in some time"
"IN_A_FEW_MINUTES": "Tavaliselt vastame mõne minuti jooksul",
"IN_A_FEW_HOURS": "Tavaliselt vastame mõne tunni jooksul",
"IN_A_DAY": "Tavaliselt vastame päeva jooksul",
"BACK_IN_HOURS": "Oleme tagasi {n} tunni pärast | Oleme tagasi {n} tunni pärast",
"BACK_IN_MINUTES": "Oleme tagasi {time} minuti pärast",
"BACK_AT_TIME": "Oleme tagasi kell {time}",
"BACK_ON_DAY": "Oleme tagasi {day}",
"BACK_TOMORROW": "Oleme tagasi homme",
"BACK_IN_SOME_TIME": "Oleme mõne aja pärast tagasi"
},
"DAY_NAMES": {
"SUNDAY": "Sunday",
"MONDAY": "Monday",
"TUESDAY": "Tuesday",
"WEDNESDAY": "Wednesday",
"THURSDAY": "Thursday",
"FRIDAY": "Friday",
"SATURDAY": "Saturday"
"SUNDAY": "Pühapäev",
"MONDAY": "Esmaspäev",
"TUESDAY": "Teisipäev",
"WEDNESDAY": "Kolmapäev",
"THURSDAY": "Neljapäev",
"FRIDAY": "Reede",
"SATURDAY": "Laupäev"
},
"START_CONVERSATION": "Start Conversation",
"END_CONVERSATION": "End Conversation",
"CONTINUE_CONVERSATION": "Continue conversation",
"YOU": "You",
"START_NEW_CONVERSATION": "Start a new conversation",
"VIEW_UNREAD_MESSAGES": "You have unread messages",
"START_CONVERSATION": "Alusta vestlust",
"END_CONVERSATION": "Lõpeta vestlus",
"CONTINUE_CONVERSATION": "Jätka vestlust",
"YOU": "Sina",
"START_NEW_CONVERSATION": "Alusta uut vestlust",
"VIEW_UNREAD_MESSAGES": "Sul on lugemata sõnumeid",
"UNREAD_VIEW": {
"VIEW_MESSAGES_BUTTON": "See new messages",
"CLOSE_MESSAGES_BUTTON": "Close",
"COMPANY_FROM": "from",
"VIEW_MESSAGES_BUTTON": "Vaata uusi sõnumeid",
"CLOSE_MESSAGES_BUTTON": "Sulge",
"COMPANY_FROM": "saatjalt",
"BOT": "Bot"
},
"BUBBLE": {
"LABEL": "Chat with us"
"LABEL": "Vestle meiega"
},
"POWERED_BY": "Powered by Chatwoot",
"EMAIL_PLACEHOLDER": "Please enter your email",
"CHAT_PLACEHOLDER": "Type your message",
"TODAY": "Today",
"YESTERDAY": "Yesterday",
"POWERED_BY": "Toetab Chatwoot",
"EMAIL_PLACEHOLDER": "Palun sisesta oma e-post",
"CHAT_PLACEHOLDER": "Kirjuta oma sõnum",
"TODAY": "Täna",
"YESTERDAY": "Eile",
"PRE_CHAT_FORM": {
"FIELDS": {
"FULL_NAME": {
"LABEL": "Full Name",
"PLACEHOLDER": "Please enter your full name",
"REQUIRED_ERROR": "Full Name is required"
"LABEL": "Täisnimi",
"PLACEHOLDER": "Palun sisesta oma täisnimi",
"REQUIRED_ERROR": "Täisnimi on kohustuslik"
},
"EMAIL_ADDRESS": {
"LABEL": "Email Address",
"PLACEHOLDER": "Please enter your email address",
"REQUIRED_ERROR": "Email Address is required",
"VALID_ERROR": "Please enter a valid email address"
"LABEL": "E-posti aadress",
"PLACEHOLDER": "Palun sisesta oma e-posti aadress",
"REQUIRED_ERROR": "E-posti aadress on kohustuslik",
"VALID_ERROR": "Palun sisesta kehtiv e-posti aadress"
},
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Please enter your phone number",
"REQUIRED_ERROR": "Phone Number is required",
"DIAL_CODE_VALID_ERROR": "Please select a country code",
"VALID_ERROR": "Please enter a valid phone number",
"DROPDOWN_EMPTY": "No results found",
"DROPDOWN_SEARCH": "Search country"
"LABEL": "Telefoninumber",
"PLACEHOLDER": "Palun sisesta oma telefoninumber",
"REQUIRED_ERROR": "Telefoninumber on kohustuslik",
"DIAL_CODE_VALID_ERROR": "Palun vali riigikood",
"VALID_ERROR": "Palun sisesta kehtiv telefoninumber",
"DROPDOWN_EMPTY": "Tulemusi ei leitud",
"DROPDOWN_SEARCH": "Otsi riiki"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter your message",
"ERROR": "Message too short"
"LABEL": "Sõnum",
"PLACEHOLDER": "Palun sisesta oma sõnum",
"ERROR": "Sõnum on liiga lühike"
}
},
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
"IS_REQUIRED": "is required",
"REQUIRED": "Required",
"REGEX_ERROR": "Please provide a valid input"
"CAMPAIGN_HEADER": "Palun sisesta enne vestluse alustamist oma nimi ja e-post",
"IS_REQUIRED": "on kohustuslik",
"REQUIRED": "Kohustuslik",
"REGEX_ERROR": "Palun sisesta korrektne väärtus"
},
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"FILE_SIZE_LIMIT": "Fail ületab {MAXIMUM_FILE_UPLOAD_SIZE} manuse limiidi",
"CHAT_FORM": {
"INVALID": {
"FIELD": "Invalid field"
"FIELD": "Vigane väli"
}
},
"EMOJI": {
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
"PLACEHOLDER": "Otsi emotikone",
"NOT_FOUND": "Ühtegi emotikoni ei leitud",
"ARIA_LABEL": "Emotikonide valija"
},
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
"PLACEHOLDER": "Tell us more..."
"TITLE": "Hinda oma vestlust",
"SUBMITTED_TITLE": "Täname hinnangu eest",
"PLACEHOLDER": "Räägi meile rohkem..."
},
"EMAIL_TRANSCRIPT": {
"BUTTON_TEXT": "Request a conversation transcript",
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
"SEND_EMAIL_ERROR": "There was an error, please try again"
"BUTTON_TEXT": "Taotle vestluse koopiat",
"SEND_EMAIL_SUCCESS": "Vestluse koopia saadeti edukalt",
"SEND_EMAIL_ERROR": "Tekkis viga, palun proovi uuesti"
},
"INTEGRATIONS": {
"DYTE": {
"CLICK_HERE_TO_JOIN": "Click here to join",
"LEAVE_THE_ROOM": "Leave the call"
"CLICK_HERE_TO_JOIN": "Klõpsa siia liitumiseks",
"LEAVE_THE_ROOM": "Lahku kõnest"
}
},
"PORTAL": {
"POPULAR_ARTICLES": "Popular Articles",
"VIEW_ALL_ARTICLES": "View all articles",
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
"POPULAR_ARTICLES": "Populaarsed artiklid",
"VIEW_ALL_ARTICLES": "Vaata kõiki artikleid",
"IFRAME_LOAD_ERROR": "Artikli laadimisel tekkis viga, palun värskenda lehte ja proovi uuesti."
},
"ATTACHMENTS": {
"image": {
"CONTENT": "Picture message"
"CONTENT": "Pildisõnum"
},
"audio": {
"CONTENT": "Audio message"
"CONTENT": "Helisõnum"
},
"video": {
"CONTENT": "Video message"
"CONTENT": "Videosõnum"
},
"file": {
"CONTENT": "File Attachment"
"CONTENT": "Faili manus"
},
"location": {
"CONTENT": "Location"
"CONTENT": "Asukoht"
},
"fallback": {
"CONTENT": "has shared a url"
"CONTENT": "jagas URL-i"
}
},
"FOOTER_REPLY_TO": {
"REPLY_TO": "Replying to:"
"REPLY_TO": "Vastus sõnumile:"
}
}
+6 -1
View File
@@ -42,8 +42,13 @@ class Account::ContactsExportJob < ApplicationJob
def attach_export_file(csv_data)
return if csv_data.blank?
# Prepend UTF-8 BOM so that spreadsheet applications (e.g. Excel)
# correctly recognise the file encoding for non-ASCII characters
# such as Arabic, Japanese, and Chinese.
bom = "\xEF\xBB\xBF"
@account.contacts_export.attach(
io: StringIO.new(csv_data),
io: StringIO.new("#{bom}#{csv_data}"),
filename: "#{@account.name}_#{@account.id}_contacts.csv",
content_type: 'text/csv'
)
+42 -17
View File
@@ -9,27 +9,17 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
include UrlHelper
queue_as :purgable
MAX_DOWNLOAD_SIZE = 15 * 1024 * 1024
ALLOWED_CONTENT_TYPES = Avatarable::ALLOWED_AVATAR_CONTENT_TYPES
MAX_DOWNLOAD_SIZE = 15.megabytes
RATE_LIMIT_WINDOW = 1.minute
def perform(avatarable, avatar_url)
return unless avatarable.respond_to?(:avatar)
return unless url_valid?(avatar_url)
return unless syncable_avatar?(avatarable, avatar_url)
return unless should_sync_avatar?(avatarable, avatar_url)
avatar_file = Down.download(avatar_url, max_size: MAX_DOWNLOAD_SIZE)
raise Down::Error, 'Invalid file' unless valid_file?(avatar_file)
avatarable.avatar.attach(
io: avatar_file,
filename: avatar_file.original_filename,
content_type: avatar_file.content_type
)
rescue Down::NotFound
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
rescue Down::Error => e
fetch_and_attach_avatar(avatarable, avatar_url)
rescue SafeFetch::HttpError => e
log_http_error(avatar_url, e)
rescue SafeFetch::Error => e
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
ensure
update_avatar_sync_attributes(avatarable, avatar_url)
@@ -37,6 +27,41 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
private
def syncable_avatar?(avatarable, avatar_url)
avatarable.respond_to?(:avatar) &&
url_valid?(avatar_url) &&
should_sync_avatar?(avatarable, avatar_url)
end
def fetch_and_attach_avatar(avatarable, avatar_url)
SafeFetch.fetch(
avatar_url,
max_bytes: MAX_DOWNLOAD_SIZE,
allowed_content_type_prefixes: [],
allowed_content_types: ALLOWED_CONTENT_TYPES
) do |avatar_file|
attach_avatar(avatarable, avatar_file)
end
end
def attach_avatar(avatarable, avatar_file)
raise SafeFetch::FetchError, 'Invalid file' unless valid_file?(avatar_file)
avatarable.avatar.attach(
io: avatar_file.tempfile,
filename: avatar_file.original_filename,
content_type: avatar_file.content_type
)
end
def log_http_error(avatar_url, error)
if error.message.start_with?('404')
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
else
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{error.class} - #{error.message}"
end
end
def should_sync_avatar?(avatarable, avatar_url)
# Only Contacts are rate-limited and hash-gated.
return true unless avatarable.is_a?(Contact)
+1
View File
@@ -106,6 +106,7 @@ class DataImportJob < ApplicationJob
raw_data = file.read
utf8_data = raw_data.force_encoding('UTF-8')
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
clean_data = clean_data.delete_prefix("\xEF\xBB\xBF")
CSV.new(StringIO.new(clean_data), headers: true)
end
+1 -1
View File
@@ -105,7 +105,7 @@ class ConversationReplyMailer < ApplicationMailer
end
def business_name
@inbox.business_name || @inbox.sanitized_name
@inbox.sanitized_business_name
end
def from_email
+3 -2
View File
@@ -4,6 +4,8 @@ module Avatarable
extend ActiveSupport::Concern
include Rails.application.routes.url_helpers
ALLOWED_AVATAR_CONTENT_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
included do
has_one_attached :avatar
validate :acceptable_avatar, if: -> { avatar.changed? }
@@ -30,7 +32,6 @@ module Avatarable
errors.add(:avatar, 'is too big') if avatar.byte_size > 15.megabytes
acceptable_types = ['image/jpeg', 'image/png', 'image/gif'].freeze
errors.add(:avatar, 'filetype not supported') unless acceptable_types.include?(avatar.content_type)
errors.add(:avatar, 'filetype not supported') unless ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
end
end
+13 -2
View File
@@ -102,7 +102,7 @@ class Inbox < ApplicationRecord
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
# REMOVES: Forbidden chars (\<>@"()) + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return default_name_for_blank_name if name.blank?
@@ -110,6 +110,10 @@ class Inbox < ApplicationRecord
sanitized.blank? && email? ? display_name_from_email : sanitized
end
def sanitized_business_name
sanitize_raw_name(business_name) || sanitized_name
end
def sms?
channel_type == 'Channel::Sms'
end
@@ -209,8 +213,15 @@ class Inbox < ApplicationRecord
email? ? display_name_from_email : ''
end
def sanitize_raw_name(raw)
return nil if raw.blank?
result = apply_sanitization_rules(raw)
result.presence
end
def apply_sanitization_rules(name)
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;()]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
@@ -39,7 +39,8 @@ class Messages::SearchDataPresenter < SimpleDelegator
end
def content_attributes_data
email_subject = content_attributes.dig(:email, :subject)
email_subject = content_attributes.dig(:email, :subject).presence ||
conversation.additional_attributes&.dig('mail_subject').presence
return {} if email_subject.blank?
{ email: { subject: email_subject } }
@@ -0,0 +1,160 @@
class Notification::PushTestService
pattr_initialize [:user!, :subscription_ids!, :title, :body]
DEFAULT_TITLE = '%<installation_name>s notification test'.freeze
DEFAULT_BODY = 'This is a test from our team to check notification delivery on your device. No action needed.'.freeze
def self.default_title
format(DEFAULT_TITLE, installation_name: GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot'))
end
def self.default_body
DEFAULT_BODY
end
def perform
selected_subscriptions.map { |subscription| test_send(subscription) }
end
private
def resolved_title
title.presence || self.class.default_title
end
def resolved_body
body.presence || self.class.default_body
end
def selected_subscriptions
user.notification_subscriptions.where(id: subscription_ids).order(:id)
end
def test_send(subscription)
if subscription.browser_push?
test_browser_push(subscription)
elsif subscription.fcm?
test_fcm(subscription)
else
result(subscription, subscription.subscription_type.to_s, :skipped, 'Unknown subscription type')
end
end
def test_browser_push(subscription)
return result(subscription, 'browser_push', :skipped, 'VAPID keys not configured') unless VapidService.public_key
WebPush.payload_send(**browser_push_payload(subscription))
result(subscription, 'browser_push', :success, 'Web push accepted by endpoint')
rescue StandardError => e
result(subscription, 'browser_push', :failure, "#{e.class.name}: #{e.message}")
end
def test_fcm(subscription)
if firebase_credentials_present?
test_fcm_direct(subscription)
elsif chatwoot_hub_enabled?
test_fcm_via_hub(subscription)
else
result(subscription, 'fcm', :skipped, 'No Firebase credentials and push relay disabled')
end
end
def test_fcm_direct(subscription)
fcm_service = Notification::FcmService.new(
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil),
GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
)
response = fcm_service.fcm_client.send_v1(fcm_options(subscription))
status_code = response[:status_code].to_i
status = status_code.between?(200, 299) ? :success : :failure
result(subscription, 'fcm', status, "HTTP #{status_code}#{response[:body]}")
rescue StandardError => e
result(subscription, 'fcm', :failure, "#{e.class.name}: #{e.message}")
end
def test_fcm_via_hub(subscription)
response = ChatwootHub.send_push_with_response(fcm_options(subscription))
result(subscription, 'fcm_via_hub', :success, "HTTP #{response.code}#{response.body}")
rescue RestClient::ExceptionWithResponse => e
result(subscription, 'fcm_via_hub', :failure, "HTTP #{e.response&.code}#{e.response&.body}")
rescue StandardError => e
result(subscription, 'fcm_via_hub', :failure, "#{e.class.name}: #{e.message}")
end
def firebase_credentials_present?
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
end
def chatwoot_hub_enabled?
ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
end
def browser_push_payload(subscription)
{
message: JSON.generate(
title: resolved_title,
tag: "super_admin_test_#{Time.zone.now.to_i}",
url: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com')
),
endpoint: subscription.subscription_attributes['endpoint'],
p256dh: subscription.subscription_attributes['p256dh'],
auth: subscription.subscription_attributes['auth'],
vapid: {
subject: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com'),
public_key: VapidService.public_key,
private_key: VapidService.private_key
},
ssl_timeout: 5,
open_timeout: 5,
read_timeout: 5
}
end
def fcm_options(subscription)
{
'token': subscription.subscription_attributes['push_token'],
'data': { payload: { data: { notification: { type: 'test' } } }.to_json },
'notification': { title: resolved_title, body: resolved_body },
'android': { priority: 'high' },
'apns': { payload: { aps: { sound: 'default', category: Time.zone.now.to_i.to_s } } },
'fcm_options': { analytics_label: 'SuperAdminTest' }
}
end
def result(subscription, type, status, message)
attrs = subscription.subscription_attributes || {}
{
id: subscription.id,
type: type.to_s,
device: device_label(subscription, attrs),
token_tail: token_tail(subscription, attrs),
status: status,
message: message
}
end
def device_label(subscription, attrs)
if subscription.browser_push?
endpoint_host(attrs['endpoint'].to_s)
else
attrs['device_id'].present? ? "#{attrs['device_id'].to_s.last(6)}" : '—'
end
end
def endpoint_host(endpoint)
return '—' if endpoint.blank?
URI.parse(endpoint).host.presence || endpoint
rescue URI::InvalidURIError
endpoint
end
def token_tail(subscription, attrs)
if subscription.browser_push?
endpoint = attrs['endpoint'].to_s
endpoint.present? ? "#{endpoint.last(6)}" : '—'
else
attrs['push_token'].present? ? "#{attrs['push_token'].to_s.last(6)}" : '—'
end
end
end
@@ -0,0 +1,89 @@
<tr>
<td style="padding: 0 0 16px;">
<span style="display: inline-block; padding: 6px 10px; border-radius: 999px; background-color: #EFF6FF; color: #1D4ED8; font-size: 12px; line-height: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;">
<%= eyebrow %>
</span>
</td>
</tr>
<tr>
<td style="padding: 0 0 12px;">
<h1 style="margin: 0; font-size: 32px; line-height: 38px; font-weight: 700; color: #0F172A;"><%= heading %></h1>
</td>
</tr>
<tr>
<td style="padding: 0 0 8px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #334155;">Hi <%= recipient_name %>,</p>
</td>
</tr>
<tr>
<td style="padding: 0 0 10px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= intro_text %></p>
</td>
</tr>
<tr>
<td style="padding: 0 0 <%= detail_rows.any? || info_title.present? ? '16px' : '24px' %>;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= supporting_text %></p>
</td>
</tr>
<% if detail_rows.any? %>
<tr>
<td style="padding: 0 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; border: 1px solid #DCE7F5; border-radius: 16px; background-color: #F8FBFF;">
<% detail_rows.each_with_index do |(label, value), index| %>
<tr>
<td style="padding: 16px 18px;<%= ' border-top: 1px solid #E2E8F0;' unless index.zero? %>">
<p style="margin: 0 0 4px; font-size: 12px; line-height: 16px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748B;">
<%= label %>
</p>
<p style="margin: 0; font-size: 14px; line-height: 21px; font-weight: 600; color: #0F172A;"><%= value %></p>
</td>
</tr>
<% end %>
</table>
</td>
</tr>
<% end %>
<% if action_url.present? %>
<tr>
<td style="padding: 0 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; margin: 0;">
<tr>
<td bgcolor="#2781F6" style="border-radius: 14px; background-color: #2781F6;">
<%= link_to(
action_text,
action_url,
style: 'display:block; width:100%; box-sizing:border-box; padding:12px 24px; font-size:14px; line-height:20px; font-weight:700; color:#FFFFFF; text-align:center; text-decoration:none;'
) %>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 0 0 20px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #64748B;">
If the button does not work, open
<%= link_to(
'this secure link',
action_url,
style: 'color:#2781F6; text-decoration:none; font-weight:600;'
) %>.
</p>
</td>
</tr>
<% elsif info_title.present? %>
<tr>
<td style="padding: 0 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; border: 1px solid #DBEAFE; border-radius: 16px; background-color: #EFF6FF;">
<tr>
<td style="padding: 18px;">
<p style="margin: 0 0 6px; font-size: 14px; line-height: 21px; font-weight: 700; color: #0F172A;">
<%= info_title %>
</p>
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= info_text %></p>
</td>
</tr>
</table>
</td>
</tr>
<% end %>
@@ -1,29 +1,65 @@
<p>Hi <%= @resource.name %>,</p>
<%
brand_name = global_config['BRAND_NAME'] || 'Chatwoot'
recipient_name = @resource.name.presence || @resource.email
account_user = @resource&.account_users&.first
inviter = account_user&.inviter
account_name = account_user&.account&.name
invited_user = inviter.present? && @resource.unconfirmed_email.blank?
<% account_user = @resource&.account_users&.first %>
eyebrow = 'Welcome'
heading = 'Confirm your email to get started'
intro_text =
"Welcome to #{brand_name}. We just need to verify your email address before you can start using your account."
supporting_text = 'This only takes a moment.'
action_text = 'Confirm my account'
action_url = frontend_url('auth/confirmation', confirmation_token: @token)
info_title = nil
info_text = nil
detail_rows = []
detail_rows << ['New email', @resource.unconfirmed_email] if @resource.unconfirmed_email.present?
<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.</p>
<% end %>
if @resource.unconfirmed_email.present?
eyebrow = 'Email update'
heading = 'Confirm your new email address'
intro_text = "We received a request to update the email address on your #{brand_name} account."
supporting_text = 'Confirm the new address below to finish the change.'
action_text = 'Confirm email address'
elsif @resource.confirmed?
eyebrow = 'Account ready'
heading = 'Your account is ready'
intro_text = "Your #{brand_name} account is already active."
supporting_text = 'Use the button below to sign in and continue where you left off.'
action_text = 'Open my account'
action_url = frontend_url('auth/sign_in')
detail_rows = []
elsif invited_user
eyebrow = 'Workspace invitation'
heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
intro_text = if account_name.present?
"#{inviter.name} invited you to join the #{account_name} workspace on #{brand_name}."
else
"#{inviter.name} invited you to try #{brand_name}."
end
supporting_text = 'Create your account to start collaborating with your team.'
action_text = 'Accept invitation'
action_url = frontend_url(
'auth/password/edit',
reset_password_token: @resource.send(:set_reset_password_token)
)
detail_rows = [['Invited by', inviter.name]]
detail_rows << ['Workspace', account_name] if account_name.present?
end
%>
<% if @resource.confirmed? %>
<p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
<% else %>
<% if account_user&.inviter.blank? %>
<p>
Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
</p>
<% end %>
<p>Please take a moment and click the link below and activate your account.</p>
<% end %>
<% if @resource.unconfirmed_email.present? %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% elsif @resource.confirmed? %>
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
<% elsif account_user&.inviter.present? %>
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
<% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% end %>
<%= render partial: 'devise/mailer/confirmation_body', locals: {
action_text: action_text,
action_url: action_url,
detail_rows: detail_rows,
eyebrow: eyebrow,
heading: heading,
info_text: info_text,
info_title: info_title,
intro_text: intro_text,
recipient_name: recipient_name,
supporting_text: supporting_text
} %>
+98 -55
View File
@@ -7,86 +7,129 @@
<style type="text/css">
img {
max-width: 100%;
border: none;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
height: 100%;
line-height: 1.6em;
line-height: 21px;
width: 100% !important;
margin: 0;
padding: 0;
background-color: #F4F7FB;
}
body {
background-color: #F8FAFC;
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
a {
color: #2781F6;
}
.email-container {
width: 100%;
max-width: 640px;
}
.main-card {
width: 100%;
border: 1px solid #DCE7F5;
border-radius: 24px;
background-color: #FFFFFF;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.accent-bar {
height: 6px;
background-color: #2781F6;
font-size: 0;
line-height: 0;
}
.content-wrap {
padding: 40px 36px 20px;
}
.footer {
padding: 20px 8px 0;
text-align: center;
color: #64748B;
font-size: 13px;
line-height: 21px;
}
.footer a {
color: #2781F6;
font-weight: 600;
text-decoration: none;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1 {
font-size: 22px !important;
font-weight: 800 !important;
margin: 20px 0 5px !important;
font-size: 28px !important;
line-height: 34px !important;
}
h2 {
font-size: 18px !important;
font-weight: 800 !important;
margin: 20px 0 5px !important;
.page-wrap {
padding: 24px 12px 32px !important;
}
h3 {
font-size: 16px !important;
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
.main-card {
border-radius: 20px !important;
}
.content-wrap {
padding: 10px !important;
padding: 32px 24px 18px !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #F8FAFC; margin: 0;" bgcolor="#F8FAFC">
<table class="body-wrap" style="width: 100%; background-color: #F8FAFC; margin: 0;" bgcolor="#F8FAFC">
{% assign brand_name = global_config['BRAND_NAME'] %}
{% if brand_name == nil %}
{% assign brand_name = 'Chatwoot' %}
{% endif %}
{% assign brand_url = global_config['BRAND_URL'] %}
<body itemscope itemtype="http://schema.org/EmailMessage" style="font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,Helvetica,Arial,sans-serif; box-sizing: border-box; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; line-height: 21px; background-color: #F4F7FB; margin: 0; padding: 0;" bgcolor="#F4F7FB">
<table class="body-wrap" role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; background-color: #F4F7FB; margin: 0;" bgcolor="#F4F7FB">
<tr style="margin: 0;">
<td class="container" width="600" style="display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;" valign="top">
<div class="content" style="display: block; margin: 0 auto; padding: 20px; text-align:center;">
<table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="border-radius: 6px; background-color: #fff; text-align:left; margin: 0; border: 1px solid #e9e9e9; border-top:3px solid #0080f8;" bgcolor="#fff">
<td align="center" class="page-wrap" style="padding: 32px 16px 40px;" valign="top">
<table class="email-container" role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 640px; margin: 0 auto;">
<tr style="margin: 0;">
<td style="margin: 0;">
<table class="main-card" role="presentation" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="width: 100%; border: 1px solid #DCE7F5; border-radius: 24px; background-color: #FFFFFF; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06); overflow: hidden;" bgcolor="#FFFFFF">
<tr style="margin: 0;">
<td class="accent-bar" style="height: 6px; background-color: #2781F6; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<tr style="margin: 0;">
<td class="content-wrap" style="vertical-align: top; margin: 0; padding: 40px 36px 20px; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" valign="top">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; margin: 0;">
{{ content_for_layout }}
</table>
</td>
</tr>
</table>
</td>
</tr>
{% if brand_name != '' %}
<tr style="margin: 0;">
<td class="content-wrap" style="vertical-align: top; margin: 0; padding: 20px; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0;">
{{ content_for_layout }}
</table>
<td class="footer" style="padding: 20px 8px 0; text-align: center; color: #64748B; font-size: 13px; line-height: 21px;">
This email was sent by
{% if brand_url != nil and brand_url != '' %}
<a href="{{ brand_url }}" style="color: #2781F6; font-weight: 600; text-decoration: none;">{{ brand_name }}</a>.
{% else %}
<span style="color: #0F172A; font-weight: 600;">{{ brand_name }}</span>.
{% endif %}
</td>
</tr>
</table>
</div>
<div class="footer" style="color: #93AFC8; margin: 0; padding: 0 20px 40px; text-align: center">
<table width="100%" style="margin: 0;">
<tr style="margin: 0;">
{% if global_config['BRAND_NAME'] != '' %}
<td class="content-block" style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
Powered by
<a href="{{ global_config['BRAND_URL'] }}" style="vertical-align: top; color: #93AFC8; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top">
{{ global_config['BRAND_NAME'] }}
</a>
</td>
{% endif %}
</tr>
</table>
</div>
{% endif %}
</table>
</td>
</tr>
</table>
@@ -32,7 +32,7 @@ as defined by the routes in the `admin/` namespace
<ul class="my-4">
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings"].include? resource.resource %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
<%= render partial: "nav_item", locals: {
icon: sidebar_icons[resource.resource.to_sym],
url: resource_index_route(resource),
@@ -48,6 +48,7 @@ as defined by the routes in the `admin/` namespace
<ul class="my-4">
<%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-mail-send-fill', url: super_admin_push_diagnostics_url, label: 'Push Diagnostics' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-logout-circle-r-line', url: super_admin_logout_url, label: 'Logout' } %>
</ul>
@@ -0,0 +1,190 @@
<% content_for(:title) do %>Push Diagnostics<% end %>
<header class="main-content__header" role="banner">
<h1 class="main-content__page-title" id="page-title">Push Diagnostics</h1>
</header>
<section class="main-content__body">
<p class="text-sm text-slate-600 mb-6">
Send a test push notification to a specific user's registered devices to diagnose delivery issues.
Results show the raw FCM / Web Push / relay response so you can see exactly what failed.
</p>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">1. Look up user</h2>
<%= form_with url: super_admin_push_diagnostics_path, method: :get, local: true, class: 'flex gap-2 items-center' do |f| %>
<%= f.text_field :user_query,
value: @query,
placeholder: 'user@example.com or numeric user ID',
class: 'border border-slate-100 p-1.5 rounded-md w-80' %>
<%= f.submit 'Look up', class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer' %>
<% end %>
<% if @query.present? && @user.nil? %>
<p class="text-sm text-red-600 mt-2">No user found for "<%= @query %>".</p>
<% end %>
</div>
<% if @user %>
<div class="mb-6 border border-slate-100 rounded-md p-4 bg-slate-25">
<p class="text-sm">
<strong><%= @user.name %></strong>
&middot; <%= @user.email %>
&middot; ID <%= @user.id %>
</p>
<% if @user.accounts.any? %>
<p class="text-xs text-slate-500 mt-1">
Accounts:
<% @user.accounts.each_with_index do |account, index| %>
<%= ', ' if index.positive? %>
<%= account.name %> (ID <%= account.id %>)
<% end %>
</p>
<% end %>
</div>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">2. Select subscriptions (<%= @subscriptions.count %>)</h2>
<p class="text-xs text-amber-700 mb-3">
⚠️ This sends a real push to every selected device. Use only when diagnosing a reported issue.
</p>
<% if @subscriptions.empty? %>
<p class="text-sm text-slate-600">This user has no push subscriptions registered.</p>
<% else %>
<%= form_with url: super_admin_push_diagnostics_path, method: :post, local: true do |f| %>
<%= f.hidden_field :user_id, value: @user.id %>
<div class="mb-4 max-w-2xl">
<div class="mb-3">
<%= label_tag :push_title, 'Title', class: 'block text-xs font-medium text-slate-600 mb-1' %>
<%= text_field_tag :push_title,
params[:push_title].presence || Notification::PushTestService.default_title,
class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
</div>
<div>
<%= label_tag :push_body, 'Body', class: 'block text-xs font-medium text-slate-600 mb-1' %>
<%= text_area_tag :push_body,
params[:push_body].presence || Notification::PushTestService.default_body,
rows: 2,
class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
</div>
<p class="text-xs text-slate-400 mt-1">Customers will see this text as a real push notification on their device.</p>
</div>
<table class="w-full text-sm border border-slate-100 rounded-md">
<thead class="text-left text-xs text-slate-500 bg-slate-25">
<tr>
<th class="px-3 py-2 align-middle first:!pl-4 last:!pr-4"><input type="checkbox" id="toggle-all"></th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">ID</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Type</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device / endpoint</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Push token</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device details</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Created</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Last updated</th>
</tr>
</thead>
<tbody>
<% @subscriptions.each do |sub| %>
<% attrs = (sub.subscription_attributes || {}).stringify_keys %>
<% if sub.browser_push? %>
<% endpoint = attrs['endpoint'].to_s %>
<% host = (begin; URI.parse(endpoint).host; rescue URI::InvalidURIError; nil; end) %>
<% device_display = host.presence || endpoint.presence || '—' %>
<% token_display = endpoint.present? ? "…#{endpoint.last(6)}" : '—' %>
<% else %>
<% device_display = attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—' %>
<% token_display = attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—' %>
<% end %>
<% extra_attrs = attrs.except('endpoint', 'p256dh', 'auth', 'push_token', 'device_id') %>
<tr class="border-t border-slate-100 align-middle">
<td class="px-3 py-2 first:!pl-4 last:!pr-4">
<%= check_box_tag 'subscription_ids[]', sub.id, false, class: 'subscription-checkbox' %>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= sub.id %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= sub.subscription_type %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= device_display %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= token_display %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 text-xs align-top">
<% if extra_attrs.present? %>
<% extra_attrs.each do |k, v| %>
<div><span class="text-slate-500"><%= k %>:</span> <span class="font-mono"><%= v.to_s.truncate(40) %></span></div>
<% end %>
<% else %>
<span class="text-slate-400"></span>
<% end %>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 whitespace-nowrap"><%= sub.created_at.strftime('%Y-%m-%d %H:%M') %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 whitespace-nowrap">
<%= sub.updated_at.strftime('%Y-%m-%d %H:%M') %>
<span class="text-xs text-slate-400">(<%= time_ago_in_words(sub.updated_at) %> ago)</span>
</td>
</tr>
<% end %>
</tbody>
</table>
<div class="mt-4 flex gap-2">
<%= f.submit 'Send Test Push to Selected',
class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
<%= submit_tag 'Delete Selected Subscriptions',
formaction: destroy_subscriptions_super_admin_push_diagnostics_path,
formmethod: 'post',
data: { confirm: "Delete the selected subscription(s)? The user won't receive pushes on those devices until their app re-registers." },
class: 'border border-red-200 bg-red-50 text-red-700 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
</div>
<% end %>
<% end %>
</div>
<% if @results.present? %>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">3. Results</h2>
<table class="w-full text-sm border border-slate-100 rounded-md">
<thead class="text-left text-xs text-slate-500 bg-slate-25">
<tr>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Sub ID</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Type</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Push token</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Status</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Details</th>
</tr>
</thead>
<tbody>
<% @results.each do |r| %>
<tr class="border-t border-slate-100 align-top">
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= r[:id] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= r[:type] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= r[:device] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= r[:token_tail] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4">
<%
color = {
success: 'bg-green-100 text-green-800',
failure: 'bg-red-100 text-red-800',
skipped: 'bg-slate-100 text-slate-600'
}[r[:status]]
%>
<span class="px-2 py-0.5 rounded-full text-xs <%= color %>"><%= r[:status] %></span>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all whitespace-pre-wrap"><%= r[:message] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% end %>
</section>
<% content_for :javascript do %>
<script>
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('toggle-all');
if (!toggle) return;
toggle.addEventListener('change', (event) => {
document.querySelectorAll('.subscription-checkbox').forEach((cb) => {
cb.checked = event.target.checked;
});
});
});
</script>
<% end %>
+1
View File
@@ -37,6 +37,7 @@ module Chatwoot
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0
config.rails_i18n.enabled_modules = [:pluralization]
config.eager_load_paths << Rails.root.join('lib')
config.eager_load_paths << Rails.root.join('enterprise/lib')
@@ -0,0 +1,8 @@
# frozen_string_literal: true
other_plural_rule = ->(_count) { :other }
Rails.application.config.after_initialize do
I18n.backend.store_translations(:zh_CN, i18n: { plural: { rule: other_plural_rule } })
I18n.backend.store_translations(:zh_TW, i18n: { plural: { rule: other_plural_rule } })
end
-1
View File
@@ -57,5 +57,4 @@ id:
not_found: "tidak ditemukan"
not_locked: "tidak terkunci"
not_saved:
one: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:"
other: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:"
-1
View File
@@ -57,5 +57,4 @@ ja:
not_found: "見つかりませんでした"
not_locked: "はロックされていません"
not_saved:
one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
-1
View File
@@ -57,5 +57,4 @@ ko:
not_found: "찾을 수 없습니다"
not_locked: "잠겨 있지 않습니다"
not_saved:
one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
-1
View File
@@ -57,5 +57,4 @@ ms:
not_found: "not found"
not_locked: "was not locked"
not_saved:
one: "%{count} errors prohibited this %{resource} from being saved:"
other: "%{count} errors prohibited this %{resource} from being saved:"
-1
View File
@@ -57,5 +57,4 @@ th:
not_found: "not found"
not_locked: "was not locked"
not_saved:
one: "%{count} errors prohibited this %{resource} from being saved:"
other: "%{count} errors prohibited this %{resource} from being saved:"
-1
View File
@@ -57,5 +57,4 @@ vi:
not_found: "không tìm thấy"
not_locked: "không được khoá"
not_saved:
one: "Có %{count} lỗi được tìm thấy từ %{resource}:"
other: "Có %{count} lỗi được tìm thấy từ %{resource}:"
-1
View File
@@ -57,5 +57,4 @@ zh_CN:
not_found: "找不到"
not_locked: "未锁定"
not_saved:
one: "%{count} 个错误禁止保存 %{resource}"
other: "%{count} 个错误禁止保存 %{resource}"
-1
View File
@@ -57,5 +57,4 @@ zh_TW:
not_found: "找不到。"
not_locked: "並未被鎖定。"
not_saved:
one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
+12
View File
@@ -488,6 +488,12 @@ en:
agent_capacity_policy:
inbox_already_assigned: 'Inbox has already been assigned to this policy'
portals:
articles:
captain_not_available: 'Translation requires Captain to be enabled for this account'
locale_not_available: 'Locale not available in this portal'
category_not_found: 'Category not found in this portal'
no_articles_found: 'No articles found to process'
invalid_status: 'Invalid status value'
send_instructions:
email_required: 'Email is required'
invalid_email_format: 'Invalid email format'
@@ -496,3 +502,9 @@ en:
subject: 'Finish setting up %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
super_admin:
push_diagnostics:
user_not_found: 'User not found.'
no_subscriptions_to_test: 'Select at least one subscription to test.'
no_subscriptions_to_delete: 'Select at least one subscription to delete.'
subscriptions_deleted: "Deleted %{count} subscription(s). The user's device(s) will re-register on next app launch."
-4
View File
@@ -435,16 +435,12 @@ id:
button: Buka percakapan
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ ja:
button: 会話を開く
time_units:
days:
one: '%{count} 日'
other: '%{count} 日'
hours:
one: '%{count} 時間'
other: '%{count} 時間'
minutes:
one: '%{count} 分'
other: '%{count} 分'
seconds:
one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ ko:
button: 대화 열기
time_units:
days:
one: '%{count}일'
other: '%{count}일'
hours:
one: '%{count}시간'
other: '%{count}시간'
minutes:
one: '%{count}분'
other: '%{count}분'
seconds:
one: '%{count}초'
other: '%{count}초'
auto_assignment:
default_policy_name: '기본 정책'
-4
View File
@@ -435,16 +435,12 @@ ms:
button: Open conversation
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ th:
button: เปิดดูการสนทนา
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ vi:
button: Mở cuộc trò chuyện
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ zh_CN:
button: 重新打开会话
time_units:
days:
one: '%{count} 天'
other: '%{count} 天'
hours:
one: '%{count} 小时'
other: '%{count} 小时'
minutes:
one: '%{count} 分钟'
other: '%{count} 分钟'
seconds:
one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ zh_TW:
button: '開啟對話'
time_units:
days:
one: '%{count} 天'
other: '%{count} 天'
hours:
one: '%{count} 小時'
other: '%{count} 小時'
minutes:
one: '%{count} 分鐘'
other: '%{count} 分鐘'
seconds:
one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: '預設策略'
+10
View File
@@ -358,6 +358,13 @@ Rails.application.routes.draw do
resources :categories do
post :reorder, on: :collection
end
namespace :articles do
resource :bulk_actions, only: [] do
post :translate
patch :update_status
delete :delete_articles
end
end
resources :articles do
post :reorder, on: :collection
end
@@ -625,6 +632,9 @@ Rails.application.routes.draw do
root to: 'dashboard#index'
resource :app_config, only: [:show, :create]
resource :push_diagnostics, only: [:show, :create] do
post :destroy_subscriptions, on: :collection
end
# order of resources affect the order of sidebar navigation in super admin
resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
@@ -0,0 +1,68 @@
module Enterprise::Api::V1::Accounts::Articles::BulkActionsController
def translate
return unless validate_translate_params?
duplicates = find_existing_translations
if duplicates.any? && !ActiveModel::Type::Boolean.new.cast(permitted_params[:force])
return render json: {
duplicate_articles: duplicates.map { |a| { id: a.id, title: a.title } }
}, status: :conflict
end
@articles.find_each do |article|
Captain::Articles::TranslateJob.perform_later(
Current.account, article.id, @locale, @category&.id, Current.user
)
end
head :ok
end
private
def permitted_params
params.permit(:locale, :category_id, :force, ids: [])
end
def validate_translate_params?
@locale = permitted_params[:locale]
@category = @portal.categories.find_by(id: permitted_params[:category_id], locale: @locale)
@articles = @portal.articles.where(id: permitted_params[:ids])
captain_available? && valid_locale? && valid_category? && valid_articles?
end
def find_existing_translations
root_ids = @articles.map { |a| Article.find_root_article_id(a) }
@portal.articles.where(associated_article_id: root_ids, locale: @locale)
end
def captain_available?
return true if Current.account.feature_enabled?('captain_tasks')
render_could_not_create_error(I18n.t('portals.articles.captain_not_available'))
false
end
def valid_locale?
return true if @portal.config['allowed_locales']&.include?(@locale)
render_could_not_create_error(I18n.t('portals.articles.locale_not_available'))
false
end
def valid_category?
return true if permitted_params[:category_id].blank?
return true if @category.present?
render_could_not_create_error(I18n.t('portals.articles.category_not_found'))
false
end
def valid_articles?
return true if @articles.any?
render_could_not_create_error(I18n.t('portals.articles.no_articles_found'))
false
end
end
@@ -0,0 +1,59 @@
class Captain::Articles::TranslateJob < ApplicationJob
queue_as :low
def perform(account, article_id, target_locale, target_category_id, user)
@account = account
@source_article = account.articles.find(article_id)
target_language = language_name_for(target_locale)
translated_title = translate(@source_article.title, target_language: target_language, type: :title)
translated_content = if @source_article.content.present?
translate(@source_article.content, target_language: target_language, type: :content)
else
@source_article.content
end
existing = find_existing_translation(target_locale)
if existing
existing.update!(title: translated_title, content: translated_content, description: @source_article.description)
else
create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
end
end
private
def translate(text, target_language:, type:)
response = Captain::Llm::ArticleTranslationService.new(
account: @account, text: text, target_language: target_language, type: type
).perform
raise "Translation failed: #{response[:error]}" if response[:error]
response[:message]
end
def find_existing_translation(target_locale)
root_id = Article.find_root_article_id(@source_article)
@source_article.portal.articles.find_by(associated_article_id: root_id, locale: target_locale)
end
def create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
@source_article.portal.articles.create!(
title: translated_title,
content: translated_content,
description: @source_article.description,
category_id: target_category_id,
locale: target_locale,
author_id: user.id,
status: :draft,
associated_article_id: Article.find_root_article_id(@source_article)
)
end
def language_name_for(locale_code)
language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml'))
language_map[locale_code] || locale_code
end
end
@@ -0,0 +1,97 @@
class Captain::Documents::PerformSyncJob < MutexApplicationJob
queue_as :low
LOCK_TIMEOUT = 10.minutes
# Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips,
# random infra issues. Three attempts lets a real hiccup recover. The exhaustion block
# absorbs the final exception so Sidekiq doesn't layer its own retry policy on top, and
# is the single place we report to Sentry — handle_unexpected_failure logs but does not
# capture, so a deterministic bug emits one Sentry event instead of one per attempt.
# Goes first because retry_on handlers dispatch bottom-to-top.
retry_on StandardError, wait: 5.seconds, attempts: 3 do |job, error|
document = job.arguments.first
ChatwootExceptionTracker.new(error, account: document.account).capture_exception
job.send(:log_sync_outcome, document, result: :unexpected_retry_exhausted,
error_code: 'sync_error',
exception_class: error.class.name)
end
# Permanent errors (404, 403, empty content) — no point retrying, discard immediately.
# Document is already marked failed by SyncService before the exception reaches here.
discard_on(Captain::Documents::SyncService::PermanentSyncError)
# TransientSyncError is raised by SyncService when the customer's site is unreachable —
# timeouts, TLS errors, 5xx, connection drops. Four attempts with backoff gives the site
# a chance to recover before we give up.
#
# The exhaustion block absorbs the exception so it doesn't propagate to Sentry —
# site flakiness isn't an application bug.
retry_on(
Captain::Documents::SyncService::TransientSyncError,
wait: ->(executions) { [30.seconds, 2.minutes, 5.minutes][executions - 1] || 5.minutes },
attempts: 4
) do |job, error|
document = job.arguments.first
job.send(:log_sync_outcome, document, result: :transient_retry_exhausted, error_code: error.message)
end
discard_on ActiveJob::DeserializationError
discard_on ActiveRecord::RecordNotFound
def perform(document)
start_time = Time.current
return if document.pdf_document?
with_lock(lock_key(document), LOCK_TIMEOUT) do
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
result = Captain::Documents::SyncService.new(document.reload).perform
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
end
rescue LockAcquisitionError
log_sync_outcome(document, result: :already_syncing)
rescue Captain::Documents::SyncService::PermanentSyncError => e
log_failure_and_raise(document, :permanent_failure, e, start_time)
rescue Captain::Documents::SyncService::TransientSyncError => e
log_failure_and_raise(document, :transient_failure, e, start_time)
rescue StandardError => e
handle_unexpected_failure(document, e, start_time)
end
private
def log_sync_outcome(document, **fields)
payload = {
document_id: document.id,
account_id: document.account_id,
assistant_id: document.assistant_id
}.merge(fields)
Rails.logger.info("[Captain::Documents::PerformSyncJob] #{payload.to_json}")
end
def log_failure_and_raise(document, result, error, start_time)
log_sync_outcome(document, result: result, error_code: error.message,
duration_ms: duration_ms_since(start_time))
raise error
end
def handle_unexpected_failure(document, error, start_time)
document.update!(
sync_status: :failed,
last_sync_error_code: 'sync_error',
last_sync_attempted_at: Time.current
)
log_sync_outcome(document, result: :unexpected_failure, error_code: 'sync_error',
exception_class: error.class.name,
duration_ms: duration_ms_since(start_time))
raise error
end
def lock_key(document)
format(::Redis::Alfred::CAPTAIN_DOCUMENT_SYNC_MUTEX, document_id: document.id)
end
def duration_ms_since(start_time)
((Time.current - start_time) * 1000).round
end
end
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def generate_standard_faqs(document)
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
Captain::Llm::FaqGeneratorService.new(document: document).generate
end
def build_paginated_service(document, options)
@@ -62,7 +62,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def reset_previous_responses(response_document)
response_document.responses.destroy_all
response_document.responses.where(edited: false).destroy_all
end
def create_response(faq, document)
+13
View File
@@ -62,6 +62,7 @@ class Captain::Document < ApplicationRecord
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
return true if external_link&.start_with?('PDF:')
external_link&.ends_with?('.pdf')
end
@@ -90,6 +91,14 @@ class Captain::Document < ApplicationRecord
self.metadata = (metadata || {}).merge('last_sync_error_code' => value)
end
def sync_step
metadata&.dig('sync_step')
end
def store_sync_step(step)
update!(metadata: (metadata || {}).merge('sync_step' => step))
end
def openai_file_id
metadata&.dig('openai_file_id')
end
@@ -108,6 +117,10 @@ class Captain::Document < ApplicationRecord
end
end
def to_llm_metadata
{ document_id: id, assistant_id: assistant_id, external_link: external_link }
end
private
def enqueue_crawl_job
@@ -0,0 +1,80 @@
class Captain::Documents::SinglePageFetcher
Result = Struct.new(:success, :title, :content, :error_code, keyword_init: true)
CONTENT_MAX_LENGTH = 200_000
TITLE_MAX_LENGTH = 255 # captain_documents.name is a varchar(255)
def initialize(url)
@url = url
end
def fetch
result = firecrawl_configured? ? fetch_with_firecrawl : fetch_with_fallback
validate_content(result)
rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ETIMEDOUT
Result.new(success: false, error_code: 'timeout')
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, OpenSSL::SSL::SSLError
Result.new(success: false, error_code: 'fetch_failed')
end
private
def firecrawl_configured?
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
end
def fetch_with_firecrawl
response = Captain::Tools::FirecrawlService.new.scrape(@url)
handle_firecrawl_response(response)
end
def handle_firecrawl_response(response)
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
data = response.parsed_response&.dig('data')
target_error = firecrawl_target_error_code(data)
return Result.new(success: false, error_code: target_error) if target_error
Result.new(
success: true,
title: data&.dig('metadata', 'title')&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: data&.dig('markdown')&.truncate(CONTENT_MAX_LENGTH, omission: '')
)
end
# Firecrawl returns API 200 even when the scraped page itself failed —
# the target page's real status lives in data.metadata.statusCode.
def firecrawl_target_error_code(data)
status = data&.dig('metadata', 'statusCode')
return nil if status.blank? || (200..299).cover?(status)
http_error_code(status)
end
def fetch_with_fallback
response = HTTParty.get(@url)
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
parser = Captain::Tools::HtmlPageParser.new(response.body)
Result.new(
success: true,
title: parser.title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: parser.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
)
end
def validate_content(result)
return result unless result.success && result.content.blank?
Result.new(success: false, error_code: 'content_empty')
end
def http_error_code(status_code)
case status_code
when 404 then 'not_found'
when 401, 403 then 'access_denied'
when 408, 504 then 'timeout'
else 'fetch_failed'
end
end
end
@@ -0,0 +1,76 @@
class Captain::Documents::SyncService
class PermanentSyncError < StandardError
end
class TransientSyncError < StandardError
end
PERMANENT_ERROR_CODES = %w[not_found access_denied content_empty].freeze
def initialize(document)
@document = document
end
def perform
@document.store_sync_step('fetching')
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
unless result.success
mark_failed(result.error_code)
raise_for_error_code(result.error_code)
end
@document.store_sync_step('comparing')
fingerprint = compute_fingerprint(result.content)
if fingerprint == @document.content_fingerprint
mark_synced
return :unchanged
end
@document.store_sync_step('updating')
update_content(result, fingerprint)
:updated
end
private
def compute_fingerprint(content)
Digest::SHA256.hexdigest(content.gsub(/\s+/, ' ').strip)
end
def mark_failed(error_code)
@document.update!(
sync_status: :failed,
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
end
def mark_synced
@document.update!(
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
)
end
def update_content(result, fingerprint)
@document.update!(
content: result.content,
name: result.title.presence || @document.name,
content_fingerprint: fingerprint,
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
)
end
def raise_for_error_code(error_code)
raise PermanentSyncError, error_code if PERMANENT_ERROR_CODES.include?(error_code)
raise TransientSyncError, error_code
end
end
@@ -0,0 +1,62 @@
class Captain::Llm::ArticleTranslationService < Captain::BaseTaskService
TYPES = %i[title content].freeze
pattr_initialize [:account!, :text!, :target_language!, :type!]
def perform
raise ArgumentError, "Invalid type: #{type}" unless TYPES.include?(type)
response = make_api_call(model: translation_model, messages: messages)
return response if response[:error]
response.merge(message: response[:message].strip)
end
private
def messages
[
{ role: 'system', content: system_prompt },
{ role: 'user', content: text }
]
end
def system_prompt
type == :title ? title_system_prompt : content_system_prompt
end
def event_name
'article_translation'
end
def llm_credential
@llm_credential ||= system_llm_credential
end
def translation_model
@translation_model ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL
end
def title_system_prompt
<<~SYSTEM_PROMPT_MESSAGE
You are a professional translator.
Translate the following text to #{target_language}.
Return only the translated text, no explanations or extra formatting.
SYSTEM_PROMPT_MESSAGE
end
def content_system_prompt
<<~SYSTEM_PROMPT_MESSAGE
You are a professional translator. Translate the following content to #{target_language}.
The content is markdown that may contain embedded HTML blocks.
Rules:
- Translate ONLY the visible text content (headings, paragraphs, list items, table cells, etc.).
- Preserve ALL markdown formatting exactly: headings (#), bold (**), italic (*), links, lists, code blocks, blockquotes, tables, horizontal rules.
- Preserve ALL HTML tags, attributes, and structure exactly as they are.
- Do NOT translate or modify: URLs, image src/alt attributes, link href values, class names, IDs, data attributes, code blocks, or any HTML attribute values.
- Keep all image tags (both markdown ![](url) and HTML <img>), iframes, and embedded media completely unchanged.
- Preserve all line breaks, blank lines, and whitespace patterns.
- Return ONLY the translated content, no wrapping or explanations.
SYSTEM_PROMPT_MESSAGE
end
end
@@ -1,11 +1,12 @@
class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
include Integrations::LlmInstrumentation
def initialize(content, language = 'english', account_id: nil)
def initialize(document:)
super()
@language = language
@content = content
@account_id = account_id
@document = document
@content = document.content
@language = document.account.locale_english_name
@account_id = document.account_id
end
def generate
@@ -40,10 +41,15 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
]
],
metadata: document_metadata
}
end
def document_metadata
@document&.to_llm_metadata || {}
end
def parse_response(content)
return [] if content.nil?
@@ -51,7 +51,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
account_id: @document&.account_id,
feature_name: 'faq_generation',
model: @model,
messages: params[:messages]
messages: params[:messages],
metadata: document_metadata
}
response = instrument_llm_call(instrumentation_params) do
@@ -214,12 +215,11 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
feature_name: 'paginated_faq_generation',
model: @model,
messages: params[:messages],
metadata: {
document_id: @document&.id,
start_page: start_page,
end_page: end_page,
iteration: @iterations_completed + 1
}
metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1)
}
end
def document_metadata
@document&.to_llm_metadata || {}
end
end
@@ -3,11 +3,15 @@ class Captain::Llm::SystemPromptsService
class << self
def faq_generator(language = 'english')
<<~PROMPT
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any information.
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any substantive information.
## Core Requirements
**Completeness**: Extract ALL information from the source content. Every detail, example, procedure, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the original content entirely.
**Completeness**: Extract ALL substantive information from the source content. Every detail, example, procedure, warning, code block, identifier, limit, definition, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the substantive source content entirely.
**Self-contained answers**: Every answer must contain the information that answers its question. The answer must be the substance, not directions to where the substance lives. If a source section provides only a reference, link, or pointer to where the information can be found without containing that information itself omit the FAQ for that section. An FAQ whose answer redirects the reader is worse than no FAQ at all.
**Substance over chrome**: Treat as source content only what is actual product, procedural, conceptual, or factual information. Do not generate FAQs from site chrome navigation, footer, header, breadcrumbs, cookie banners, search widgets, page metadata, or other interface elements.
**Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material.
@@ -29,18 +33,21 @@ class Captain::Llm::SystemPromptsService
## Guidelines
- **Question Creation**: Formulate questions that naturally arise from the content (What is...? How do I...? When should...? Why does...?). Do not generate questions that are not related to the content.
- **Answer Completeness**: Include all relevant details, steps, examples, and context from the original content
- **Information Preservation**: Ensure no examples, procedures, warnings, or explanatory details are omitted
- **Answer Completeness**: Include all relevant details, steps, examples, code, identifiers, limits, and definitions present in the source.
- **Information Preservation**: Never omit examples, procedures, warnings, code, IDs, limits, or definitions in the name of brevity.
- **No Deflecting FAQs**: Do not create FAQs whose answer would only tell the reader to open another link, guide, or document. If the source contains useful factual content in link text, labels, lists, or summaries (e.g., a curated list of supported integrations, plan features, resources, or article indexes), preserve that content as the answer. If it only points elsewhere without providing the answer itself, skip it.
- **JSON Validity**: Always return properly formatted, valid JSON
- **No Content Scenario**: If no suitable content is found, return: `{"faqs": []}`
## Process
1. Read the entire provided content carefully
2. Identify all key information points, procedures, and examples
3. Create questions that cover each information point
4. Write comprehensive short answers that capture all related detail, include bullet points if needed.
5. Verify that combined FAQs represent the complete original content.
6. Format as valid JSON
2. Identify all key information points: procedures, examples, code, identifiers, limits, definitions, warnings, and explanations
3. For each candidate section, verify the source contains the substance that would answer the question. If the source only points to where the substance lives, skip the section.
4. Disregard interface chrome (navigation, footer, header, cookie banners, breadcrumbs, page metadata).
5. Create questions that cover each remaining substantive information point
6. Write self-contained answers that preserve all relevant details from the source. Be concise where possible, but never trade away steps, examples, warnings, code, IDs, limits, or definitions for brevity.
7. Verify the combined FAQs represent the complete substantive source content (excluding redirect-only sections and chrome).
8. Format as valid JSON
PROMPT
end
@@ -1,4 +1,7 @@
class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
raise 'Missing API key' if @api_key.empty?
@@ -6,7 +9,7 @@ class Captain::Tools::FirecrawlService
def perform(url, webhook_url, crawl_limit = 10)
HTTParty.post(
'https://api.firecrawl.dev/v1/crawl',
"#{BASE_URL}/crawl",
body: crawl_payload(url, webhook_url, crawl_limit),
headers: headers
)
@@ -14,6 +17,14 @@ class Captain::Tools::FirecrawlService
raise "Failed to crawl URL: #{e.message}"
end
def scrape(url)
HTTParty.post(
"#{BASE_URL}/scrape",
body: scrape_payload(url),
headers: headers
)
end
private
def crawl_payload(url, webhook_url, crawl_limit)
@@ -23,14 +34,22 @@ class Captain::Tools::FirecrawlService
ignoreSitemap: false,
limit: crawl_limit,
webhook: webhook_url,
scrapeOptions: {
onlyMainContent: false,
formats: ['markdown'],
excludeTags: ['iframe']
}
scrapeOptions: scrape_options
}.to_json
end
def scrape_payload(url)
{ url: url }.merge(scrape_options).to_json
end
def scrape_options
{
onlyMainContent: true,
formats: ['markdown'],
excludeTags: FIRECRAWL_EXCLUDE_TAGS
}
end
def headers
{
'Authorization' => "Bearer #{@api_key}",
@@ -0,0 +1,15 @@
class Captain::Tools::HtmlPageParser
attr_reader :doc
def initialize(html)
@doc = Nokogiri::HTML(html)
end
def title
@doc.at_xpath('//title')&.text&.strip
end
def body_markdown
ReverseMarkdown.convert(@doc.at_xpath('//body'), unknown_tags: :bypass, github_flavored: true)
end
end
@@ -3,7 +3,8 @@ class Captain::Tools::SimplePageCrawlService
def initialize(external_link)
@external_link = external_link
@doc = Nokogiri::HTML(HTTParty.get(external_link).body)
@parser = Captain::Tools::HtmlPageParser.new(HTTParty.get(external_link).body)
@doc = @parser.doc
end
def page_links
@@ -11,12 +12,11 @@ class Captain::Tools::SimplePageCrawlService
end
def page_title
title_element = @doc.at_xpath('//title')
title_element&.text&.strip
@parser.title
end
def body_text_content
ReverseMarkdown.convert @doc.at_xpath('//body'), unknown_tags: :bypass, github_flavored: true
@parser.body_markdown
end
def meta_description
@@ -79,7 +79,12 @@ class Enterprise::Billing::TopupCheckoutService
def finalize_and_pay(invoice_id)
Stripe::Invoice.finalize_invoice(invoice_id, { auto_advance: false })
invoice = Stripe::Invoice.retrieve(invoice_id)
Stripe::Invoice.pay(invoice_id) unless invoice.status == 'paid'
return if invoice.status == 'paid'
Stripe::Invoice.pay(invoice_id)
rescue Stripe::CardError
Stripe::Invoice.void_invoice(invoice_id)
raise
end
def fulfill_credits(credits, topup_option)
@@ -52,9 +52,9 @@ class Internal::Accounts::InternalAttributesService
# Get list of valid features that can be manually managed
def valid_feature_list
# Business and Enterprise plan features only
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES +
%w[inbound_emails]
end
# Account notes functionality removed for now
@@ -1,45 +1,99 @@
<p>Hi <%= @resource.name %>,</p>
<%
brand_name = global_config['BRAND_NAME'] || 'Chatwoot'
recipient_name = @resource.name.presence || @resource.email
account_user = @resource&.account_users&.first
inviter = account_user&.inviter
account_name = account_user&.account&.name
is_saml_account = account_user&.account&.saml_enabled?
invited_user = inviter.present? && @resource.unconfirmed_email.blank?
<% account_user = @resource&.account_users&.first %>
<% is_saml_account = account_user&.account&.saml_enabled? %>
eyebrow = 'Welcome'
heading = 'Confirm your email to get started'
intro_text =
"Welcome to #{brand_name}. We just need to verify your email address before you can start using your account."
supporting_text = 'This only takes a moment.'
action_text = 'Confirm my account'
action_url = frontend_url('auth/confirmation', confirmation_token: @token)
info_title = nil
info_text = nil
detail_rows = []
detail_rows << ['New email', @resource.unconfirmed_email] if @resource.unconfirmed_email.present?
<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
<% if is_saml_account %>
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to access <%= global_config['BRAND_NAME'] || 'Chatwoot' %> via Single Sign-On (SSO).</p>
<p>Your organization uses SSO for secure authentication. You will not need a password to access your account.</p>
<% else %>
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.</p>
<% end %>
<% end %>
if @resource.unconfirmed_email.present?
eyebrow = 'Email update'
heading = 'Confirm your new email address'
intro_text = "We received a request to update the email address on your #{brand_name} account."
supporting_text = 'Confirm the new address below to finish the change.'
action_text = 'Confirm email address'
elsif @resource.confirmed?
eyebrow = 'Account ready'
<% if @resource.confirmed? %>
<p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
<% else %>
<% if account_user&.inviter.blank? %>
<p>
Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
</p>
<% end %>
<% unless is_saml_account %>
<p>Please take a moment and click the link below and activate your account.</p>
<% end %>
<% end %>
if is_saml_account
heading = 'Your access is ready'
intro_text = "Your #{brand_name} access is already set up."
supporting_text = "Use your organization's Single Sign-On (SSO) portal to access #{brand_name}."
action_text = nil
action_url = nil
info_title = "Sign in with your organization's SSO"
info_text =
"You won't need a separate password for #{brand_name}. Start from your company identity provider portal."
detail_rows = []
detail_rows << ['Workspace', account_name] if account_name.present?
detail_rows << ['Sign-in method', 'Single Sign-On (SSO)']
else
heading = 'Your account is ready'
intro_text = "Your #{brand_name} account is already active."
supporting_text = 'Use the button below to sign in and continue where you left off.'
action_text = 'Open my account'
action_url = frontend_url('auth/sign_in')
detail_rows = []
end
elsif invited_user
eyebrow = 'Workspace invitation'
heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
if is_saml_account
intro_text = if account_name.present?
"#{inviter.name} invited you to access the #{account_name} workspace on #{brand_name}."
else
"#{inviter.name} invited you to access #{brand_name}."
end
supporting_text =
"Your organization uses Single Sign-On (SSO), so you won't need to create a separate password."
action_text = nil
action_url = nil
info_title = "Use your organization's SSO portal"
info_text = "Continue from your company identity provider portal to access #{brand_name}."
detail_rows = [['Invited by', inviter.name]]
detail_rows << ['Workspace', account_name] if account_name.present?
detail_rows << ['Sign-in method', 'Single Sign-On (SSO)']
else
intro_text = if account_name.present?
"#{inviter.name} invited you to join the #{account_name} workspace on #{brand_name}."
else
"#{inviter.name} invited you to try #{brand_name}."
end
supporting_text = 'Create your account to start collaborating with your team.'
action_text = 'Accept invitation'
action_url = frontend_url(
'auth/password/edit',
reset_password_token: @resource.send(:set_reset_password_token)
)
detail_rows = [['Invited by', inviter.name]]
detail_rows << ['Workspace', account_name] if account_name.present?
end
end
%>
<% if @resource.unconfirmed_email.present? %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% elsif @resource.confirmed? %>
<% if is_saml_account %>
<p>You can now access your account by logging in through your organization's SSO portal.</p>
<% else %>
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
<% end %>
<% elsif account_user&.inviter.present? %>
<% if is_saml_account %>
<p>You can access your account by logging in through your organization's SSO portal.</p>
<% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
<% end %>
<% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% end %>
<%= render partial: 'devise/mailer/confirmation_body', locals: {
action_text: action_text,
action_url: action_url,
detail_rows: detail_rows,
eyebrow: eyebrow,
heading: heading,
info_text: info_text,
info_title: info_title,
intro_text: intro_text,
recipient_name: recipient_name,
supporting_text: supporting_text
} %>
@@ -2,9 +2,8 @@
# Get all feature names and their display names
all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names
# Business and Enterprise plan features only
premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
# Features that can be manually managed
premium_features = Internal::Accounts::InternalAttributesService.new(field.resource).valid_feature_list
# Get only premium features with display names
premium_features_with_display = premium_features.map do |feature|
+6 -2
View File
@@ -29,11 +29,15 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
def render_img_tag(src, title, height = nil)
title_attribute = title.present? ? " title=\"#{title}\"" : ''
height_attribute = height ? " height=\"#{height}\" width=\"auto\"" : ''
# Use inline style instead of the HTML height attribute: email clients and
# the in-app Letter view both run images through CSS (e.g. prose /
# lettersanitizer's `img { height: auto }`) which overrides presentational
# attributes. Inline style has higher specificity and survives.
style_attribute = height ? " style=\"height: #{height};\"" : ''
plain do
# plain ensures that the content is not wrapped in a paragraph tag
out("<img src=\"#{src}\"#{title_attribute}#{height_attribute} />")
out("<img src=\"#{src}\"#{title_attribute}#{style_attribute} />")
end
end
end
+6 -2
View File
@@ -106,14 +106,18 @@ class ChatwootHub
end
def self.send_push(fcm_options)
info = { fcm_options: fcm_options }
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
send_push_with_response(fcm_options)
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def self.send_push_with_response(fcm_options)
info = { fcm_options: fcm_options }
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
end
def self.emit_event(event_name, event_data)
return if ENV['DISABLE_TELEMETRY']
+1
View File
@@ -44,6 +44,7 @@ module Redis::RedisKeys
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
CAPTAIN_DOCUMENT_SYNC_MUTEX = 'CAPTAIN_DOCUMENT_SYNC_LOCK::%<document_id>s'.freeze
## Auto Assignment Keys
# Track conversation assignments to agents for rate limiting
+41 -15
View File
@@ -6,7 +6,11 @@ module SafeFetch
DEFAULT_READ_TIMEOUT = 20
DEFAULT_MAX_BYTES_FALLBACK_MB = 40
Result = Data.define(:tempfile, :filename, :content_type)
Result = Data.define(:tempfile, :filename, :content_type) do
def original_filename
filename
end
end
class Error < StandardError; end
class InvalidUrlError < Error; end
@@ -18,19 +22,15 @@ module SafeFetch
def self.fetch(url,
max_bytes: nil,
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES)
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
allowed_content_types: [])
raise ArgumentError, 'block required' unless block_given?
effective_max_bytes = max_bytes || default_max_bytes
uri = parse_and_validate_url!(url)
filename = filename_for(uri)
filename = filename_for(parse_and_validate_url!(url))
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
response = stream_to_tempfile(url, tempfile, effective_max_bytes, allowed_content_type_prefixes)
raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
tempfile.rewind
yield Result.new(tempfile: tempfile, filename: filename, content_type: response['content-type'])
response = fetch_response(url, tempfile, effective_max_bytes, allowed_content_type_prefixes, allowed_content_types)
yield build_result(tempfile, filename, response)
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
raise InvalidUrlError, e.message
rescue SsrfFilter::Error, Resolv::ResolvError => e
@@ -44,18 +44,23 @@ module SafeFetch
class << self
private
def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes)
def fetch_response(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
end
def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
response = nil
bytes_written = 0
SsrfFilter.get(
url,
request_proc: ->(request) { apply_url_basic_auth(request) },
http_options: { open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT }
) do |res|
response = res
next unless res.is_a?(Net::HTTPSuccess)
unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes)
unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes, allowed_content_types)
raise UnsupportedContentTypeError, "content-type not allowed: #{res['content-type']}"
end
@@ -74,6 +79,14 @@ module SafeFetch
File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
end
def build_result(tempfile, filename, response)
raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
tempfile.rewind
content_type = normalized_content_type(response['content-type'])
Result.new(tempfile: tempfile, filename: filename, content_type: content_type)
end
def default_max_bytes
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
limit_mb = DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
@@ -88,11 +101,24 @@ module SafeFetch
uri
end
def allowed_content_type?(value, prefixes)
mime = value.to_s.split(';').first&.strip&.downcase
def allowed_content_type?(value, prefixes, content_types)
mime = normalized_content_type(value)
return false if mime.blank?
prefixes.any? { |prefix| mime.start_with?(prefix) }
prefixes.any? { |prefix| mime.start_with?(prefix) } || content_types.include?(mime)
end
def normalized_content_type(value)
value.to_s.split(';').first&.strip&.downcase
end
def apply_url_basic_auth(request)
uri = request.uri
return if uri.user.blank?
username = URI.decode_uri_component(uri.user)
password = URI.decode_uri_component(uri.password.to_s)
request.basic_auth(username, password)
end
end
end
@@ -0,0 +1,141 @@
require 'rails_helper'
RSpec.describe 'Article Bulk Actions API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, name: 'test_portal', account: account, config: { allowed_locales: %w[en es] }) }
let!(:category) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') }
let!(:article_one) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) }
let!(:article_two) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) }
let!(:article_three) { create(:article, category: category, portal: portal, account: account, author: admin, status: :published) }
let(:base_url) { "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/bulk_actions" }
describe 'PATCH articles/bulk_actions/update_status' do
let(:update_status_url) { "#{base_url}/update_status" }
context 'when unauthenticated' do
it 'returns unauthorized' do
patch update_status_url, params: { ids: [article_one.id], status: 'published' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as agent' do
it 'returns unauthorized' do
patch update_status_url,
headers: agent.create_new_auth_token,
params: { ids: [article_one.id], status: 'published' },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as admin' do
it 'publishes multiple articles' do
patch update_status_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id, article_two.id], status: 'published' },
as: :json
expect(response).to have_http_status(:ok)
expect(article_one.reload.status).to eq('published')
expect(article_two.reload.status).to eq('published')
end
it 'archives multiple articles' do
patch update_status_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id, article_three.id], status: 'archived' },
as: :json
expect(response).to have_http_status(:ok)
expect(article_one.reload.status).to eq('archived')
expect(article_three.reload.status).to eq('archived')
end
it 'sets articles to draft' do
patch update_status_url,
headers: admin.create_new_auth_token,
params: { ids: [article_three.id], status: 'draft' },
as: :json
expect(response).to have_http_status(:ok)
expect(article_three.reload.status).to eq('draft')
end
it 'does not affect articles not in the list' do
patch update_status_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], status: 'published' },
as: :json
expect(article_one.reload.status).to eq('published')
expect(article_three.reload.status).to eq('published')
end
it 'returns unprocessable entity when no articles found' do
patch update_status_url,
headers: admin.create_new_auth_token,
params: { ids: [0], status: 'published' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE articles/bulk_actions/delete_articles' do
let(:destroy_url) { "#{base_url}/delete_articles" }
context 'when unauthenticated' do
it 'returns unauthorized' do
delete destroy_url, params: { ids: [article_one.id] }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as agent' do
it 'returns unauthorized' do
delete destroy_url,
headers: agent.create_new_auth_token,
params: { ids: [article_one.id] },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as admin' do
it 'deletes multiple articles' do
expect do
delete destroy_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id, article_two.id] },
as: :json
end.to change(Article, :count).by(-2)
expect(response).to have_http_status(:ok)
end
it 'does not delete articles not in the list' do
delete destroy_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id] },
as: :json
expect(Article.exists?(article_one.id)).to be(false)
expect(Article.exists?(article_three.id)).to be(true)
end
it 'returns unprocessable entity when no articles found' do
delete destroy_url,
headers: admin.create_new_auth_token,
params: { ids: [0] },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
@@ -0,0 +1,179 @@
require 'rails_helper'
RSpec.describe 'Article Bulk Actions API', type: :request do
include ActiveJob::TestHelper
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, name: 'test_portal', account: account, config: { allowed_locales: %w[en es fr] }) }
let!(:category_en) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') }
let!(:category_es) { create(:category, portal: portal, account: account, locale: 'es', slug: 'primeros-pasos') }
let!(:article_one) { create(:article, category: category_en, portal: portal, account: account, author_id: admin.id) }
let!(:article_two) { create(:article, category: category_en, portal: portal, account: account, author_id: admin.id) }
let(:translate_url) { "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/bulk_actions/translate" }
describe 'POST articles/bulk_actions/translate' do
context 'when unauthenticated' do
it 'returns unauthorized' do
post translate_url, params: { ids: [article_one.id], locale: 'es', category_id: category_es.id }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as agent' do
it 'returns unauthorized' do
post translate_url,
headers: agent.create_new_auth_token,
params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when captain is not enabled' do
it 'returns unprocessable entity' do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'when authenticated as admin' do
before do
account.enable_features!('captain_tasks')
end
it 'enqueues translation jobs for each article' do
expect do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id, article_two.id], locale: 'es', category_id: category_es.id },
as: :json
end.to have_enqueued_job(Captain::Articles::TranslateJob).exactly(2).times
expect(response).to have_http_status(:ok)
end
it 'enqueues job with correct arguments' do
expect do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
as: :json
end.to have_enqueued_job(Captain::Articles::TranslateJob).with(
account, article_one.id, 'es', category_es.id, admin
)
expect(response).to have_http_status(:ok)
end
it 'returns unprocessable entity for invalid locale' do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], locale: 'zh', category_id: category_es.id },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns unprocessable entity for invalid category' do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], locale: 'es', category_id: 0 },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns unprocessable entity when category locale does not match requested locale' do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], locale: 'es', category_id: category_en.id },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'enqueues job with nil category when category_id is omitted' do
expect do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], locale: 'es' },
as: :json
end.to have_enqueued_job(Captain::Articles::TranslateJob).with(
account, article_one.id, 'es', nil, admin
)
expect(response).to have_http_status(:ok)
end
it 'enqueues job with nil category when category_id is blank' do
expect do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], locale: 'es', category_id: '' },
as: :json
end.to have_enqueued_job(Captain::Articles::TranslateJob).with(
account, article_one.id, 'es', nil, admin
)
expect(response).to have_http_status(:ok)
end
it 'returns unprocessable entity when no articles found' do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [0], locale: 'es', category_id: category_es.id },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
context 'when translations already exist' do
let!(:existing_translation) do
create(:article, portal: portal, category: category_es, account: account, author_id: admin.id,
locale: 'es', associated_article_id: article_one.id)
end
it 'returns conflict with duplicate articles' do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
as: :json
expect(response).to have_http_status(:conflict)
body = response.parsed_body
expect(body['duplicate_articles'].length).to eq(1)
expect(body['duplicate_articles'].first['id']).to eq(existing_translation.id)
end
it 'does not enqueue jobs when duplicates found without force' do
expect do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], locale: 'es', category_id: category_es.id },
as: :json
end.not_to have_enqueued_job(Captain::Articles::TranslateJob)
end
it 'enqueues jobs when force is true' do
expect do
post translate_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], locale: 'es', category_id: category_es.id, force: true },
as: :json
end.to have_enqueued_job(Captain::Articles::TranslateJob).exactly(1).times
expect(response).to have_http_status(:ok)
end
end
end
end
end
@@ -0,0 +1,134 @@
require 'rails_helper'
RSpec.describe Captain::Articles::TranslateJob, type: :job do
let(:account) { create(:account) }
let(:user) { create(:user, account: account, role: :administrator) }
let!(:portal) { create(:portal, account: account, config: { allowed_locales: %w[en es] }) }
let!(:category_en) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') }
let!(:category_es) { create(:category, portal: portal, account: account, locale: 'es', slug: 'primeros-pasos') }
let!(:article) do
create(:article, portal: portal, category: category_en, account: account, author: user,
title: 'Getting Started', content: '# Welcome\nThis is a guide.')
end
let(:title_service) { instance_double(Captain::Llm::ArticleTranslationService) }
let(:content_service) { instance_double(Captain::Llm::ArticleTranslationService) }
before do
allow(Captain::Llm::ArticleTranslationService).to receive(:new).with(hash_including(type: :title)).and_return(title_service)
allow(Captain::Llm::ArticleTranslationService).to receive(:new).with(hash_including(type: :content)).and_return(content_service)
allow(title_service).to receive(:perform).and_return(message: 'Primeros pasos')
allow(content_service).to receive(:perform).and_return(message: '# Bienvenido\nEsta es una guía.')
end
it 'queues on the low queue' do
expect { described_class.perform_later(account, article.id, 'es', category_es.id, user) }
.to have_enqueued_job.on_queue('low')
end
it 'creates a translated article as draft' do
expect do
described_class.perform_now(account, article.id, 'es', category_es.id, user)
end.to change(Article, :count).by(1)
translated = Article.last
expect(translated).to have_attributes(
title: 'Primeros pasos',
content: '# Bienvenido\nEsta es una guía.',
locale: 'es',
category_id: category_es.id,
author_id: user.id,
status: 'draft',
associated_article_id: article.id
)
end
it 'creates a translated article without a category when target_category_id is nil' do
expect do
described_class.perform_now(account, article.id, 'es', nil, user)
end.to change(Article, :count).by(1)
translated = Article.last
expect(translated).to have_attributes(
title: 'Primeros pasos',
locale: 'es',
category_id: nil,
status: 'draft',
associated_article_id: article.id
)
end
it 'calls the translation service with the correct language' do
described_class.perform_now(account, article.id, 'es', category_es.id, user)
expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with(
account: account, text: 'Getting Started', target_language: 'Spanish', type: :title
)
expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with(
account: account, text: '# Welcome\nThis is a guide.', target_language: 'Spanish', type: :content
)
end
it 'uses language_map for locale name resolution' do
described_class.perform_now(account, article.id, 'pt_BR', category_es.id, user)
expect(Captain::Llm::ArticleTranslationService).to have_received(:new).with(
hash_including(target_language: 'Portuguese (Brazil)', type: :title)
)
end
context 'when a translation already exists' do
let!(:existing_translation) do
create(:article, portal: portal, category: category_es, account: account, author: user,
title: 'Old title', content: 'Old content', locale: 'es',
associated_article_id: article.id)
end
it 'updates the existing translation instead of creating a new one' do
expect do
described_class.perform_now(account, article.id, 'es', category_es.id, user)
end.not_to change(Article, :count)
existing_translation.reload
expect(existing_translation).to have_attributes(
title: 'Primeros pasos',
content: '# Bienvenido\nEsta es una guía.',
description: article.description
)
end
end
context 'when the source article has blank content' do
let!(:draft_article) do
create(:article, portal: portal, category: category_en, account: account, author: user,
title: 'Empty draft', content: nil, status: :draft)
end
it 'creates the translated article with the original blank content and skips the content LLM call' do
expect do
described_class.perform_now(account, draft_article.id, 'es', category_es.id, user)
end.to change(Article, :count).by(1)
expect(content_service).not_to have_received(:perform)
translated = Article.last
expect(translated).to have_attributes(
title: 'Primeros pasos',
content: nil,
locale: 'es',
associated_article_id: draft_article.id
)
end
end
context 'when translation service fails' do
before do
allow(title_service).to receive(:perform).and_return(error: 'LLM timeout')
end
it 'raises the error and does not create an article' do
expect do
described_class.perform_now(account, article.id, 'es', category_es.id, user)
end.to raise_error(RuntimeError, /LLM timeout/).and not_change(Article, :count)
end
end
end
@@ -12,9 +12,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
end
before do
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
.with(document.content, document.account.locale_english_name, account_id: document.account_id)
.and_return(faq_generator)
allow(Captain::Llm::FaqGeneratorService).to receive(:new).with(document: document).and_return(faq_generator)
allow(faq_generator).to receive(:generate).and_return(faqs)
end
@@ -51,17 +49,14 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
let(:spanish_faq_generator) { instance_double(Captain::Llm::FaqGeneratorService) }
before do
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
.with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id)
.and_return(spanish_faq_generator)
allow(Captain::Llm::FaqGeneratorService).to receive(:new).with(document: spanish_document).and_return(spanish_faq_generator)
allow(spanish_faq_generator).to receive(:generate).and_return(faqs)
end
it 'passes the correct locale to FAQ generator' do
it 'passes the correct document to FAQ generator' do
described_class.new.perform(spanish_document)
expect(Captain::Llm::FaqGeneratorService).to have_received(:new)
.with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id)
expect(Captain::Llm::FaqGeneratorService).to have_received(:new).with(document: spanish_document)
end
end
+39 -20
View File
@@ -8,12 +8,23 @@ RSpec.describe 'Devise::Mailer' do
let!(:confirmable_user) { create(:user, inviter: inviter_val, account: account) }
let(:inviter_val) { nil }
let(:mail) { Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {}) }
let(:mail_body) { CGI.unescapeHTML(mail.body.to_s) }
before do
confirmable_user.update!(confirmed_at: nil)
confirmable_user.send(:generate_confirmation_token)
end
context 'when brand name is intentionally blank' do
before do
create(:installation_config, name: 'BRAND_NAME', value: '')
end
it 'preserves the blank brand override' do
expect(mail_body).not_to include('Chatwoot')
end
end
context 'with SAML enabled account' do
let(:saml_settings) { create(:account_saml_settings, account: account) }
@@ -21,12 +32,13 @@ RSpec.describe 'Devise::Mailer' do
context 'when user has no inviter' do
it 'shows standard welcome message without SSO references' do
expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.')
expect(mail.body).not_to match('via Single Sign-On')
expect(mail_body).to include('Confirm your email to get started')
expect(mail_body).to include('We just need to verify your email address before you can start using your account.')
expect(mail_body).not_to include('Single Sign-On (SSO)')
end
it 'does not show activation instructions for SAML accounts' do
expect(mail.body).not_to match('Please take a moment and click the link below and activate your account')
it 'shows the standard confirmation CTA' do
expect(mail_body).to include('Confirm my account')
end
it 'shows confirmation link' do
@@ -38,22 +50,21 @@ RSpec.describe 'Devise::Mailer' do
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
it 'mentions SSO invitation' do
expect(mail.body).to match(
"#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to access.*via Single Sign-On \\(SSO\\)"
)
expect(mail_body).to include("You're invited to join #{account.name}")
expect(mail_body).to include("#{inviter_val.name} invited you to access the #{account.name} workspace on Chatwoot.")
end
it 'explains SSO authentication' do
expect(mail.body).to match('Your organization uses SSO for secure authentication')
expect(mail.body).to match('You will not need a password to access your account')
expect(mail_body).to include("Your organization uses Single Sign-On (SSO), so you won't need to create a separate password.")
end
it 'does not show standard invitation message' do
expect(mail.body).not_to match('has invited you to try out')
expect(mail_body).not_to include('invited you to join')
expect(mail_body).not_to include('Accept invitation')
end
it 'directs to SSO portal instead of password reset' do
expect(mail.body).to match('You can access your account by logging in through your organization\'s SSO portal')
expect(mail_body).to include("Use your organization's SSO portal")
expect(mail.body).not_to include('app/auth/password/edit')
end
end
@@ -66,7 +77,9 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows SSO login instructions' do
expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal')
expect(mail_body).to include('Your access is ready')
expect(mail_body).to include("Sign in with your organization's SSO")
expect(mail_body).to include("Use your organization's Single Sign-On (SSO) portal to access")
expect(mail.body).not_to include('/auth/sign_in')
end
end
@@ -79,6 +92,7 @@ RSpec.describe 'Devise::Mailer' do
end
it 'still shows confirmation link for email verification' do
expect(mail_body).to include('Confirm your new email address')
expect(mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmable_user.unconfirmed_email.blank?).to be false
end
@@ -90,7 +104,8 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows SSO login instructions instead of regular login' do
expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal')
expect(mail_body).to include('Your access is ready')
expect(mail_body).to include("Sign in with your organization's SSO")
expect(mail.body).not_to include('/auth/sign_in')
end
end
@@ -101,9 +116,10 @@ RSpec.describe 'Devise::Mailer' do
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
it 'shows standard invitation without SSO references' do
expect(mail.body).to match('has invited you to try out Chatwoot')
expect(mail.body).not_to match('via Single Sign-On')
expect(mail.body).not_to match('SSO portal')
expect(mail_body).to include("You're invited to join #{account.name}")
expect(mail_body).to include("#{inviter_val.name} invited you to join the #{account.name} workspace on")
expect(mail_body).not_to include('Single Sign-On (SSO)')
expect(mail_body).not_to include("Use your organization's SSO portal")
end
it 'shows password reset link' do
@@ -112,9 +128,10 @@ RSpec.describe 'Devise::Mailer' do
end
context 'when user has no inviter' do
it 'shows standard welcome message and activation instructions' do
expect(mail.body).to match('We have a suite of powerful tools ready for you to explore')
expect(mail.body).to match('Please take a moment and click the link below and activate your account')
it 'shows the standard confirmation state' do
expect(mail_body).to include('Confirm your email to get started')
expect(mail_body).to include('We just need to verify your email address before you can start using your account.')
expect(mail_body).to include('Confirm my account')
end
it 'shows confirmation link' do
@@ -130,8 +147,9 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows regular login link' do
expect(mail_body).to include('Your account is ready')
expect(mail.body).to include('/auth/sign_in')
expect(mail.body).not_to match('SSO portal')
expect(mail_body).not_to include('SSO portal')
end
end
@@ -141,6 +159,7 @@ RSpec.describe 'Devise::Mailer' do
end
it 'shows confirmation link for email verification' do
expect(mail_body).to include('Confirm your new email address')
expect(mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmable_user.unconfirmed_email.blank?).to be false
end
@@ -55,6 +55,11 @@ RSpec.describe Captain::Document, type: :model do
expect(doc.pdf_document?).to be true
end
it 'returns true for PDF:-prefixed external links even when the blob is missing' do
doc = build(:captain_document, external_link: 'PDF: report_20250101120000')
expect(doc.pdf_document?).to be true
end
it 'returns false for non-PDF documents' do
doc = build(:captain_document, external_link: 'https://example.com')
expect(doc.pdf_document?).to be false
@@ -0,0 +1,67 @@
require 'rails_helper'
RSpec.describe Captain::Llm::ArticleTranslationService do
let(:account) { create(:account) }
let(:target_language) { 'Spanish' }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
end
describe '#perform with type: :title' do
let(:service) do
described_class.new(account: account, text: 'Getting Started', target_language: target_language, type: :title)
end
it 'returns the stripped translated title' do
expect(service).to receive(:make_api_call) do |args|
expect(args[:messages][0][:content]).to include('professional translator')
expect(args[:messages][0][:content]).to include(target_language)
expect(args[:messages][1][:content]).to eq('Getting Started')
{ message: " Primeros pasos \n" }
end
expect(service.perform).to include(message: 'Primeros pasos')
end
end
describe '#perform with type: :content' do
let(:content) { "# Welcome\nSome markdown." }
let(:service) do
described_class.new(account: account, text: content, target_language: target_language, type: :content)
end
it 'returns the stripped translated content using the markdown system prompt' do
expect(service).to receive(:make_api_call) do |args|
expect(args[:messages][0][:content]).to include('markdown')
expect(args[:messages][0][:content]).to include('Preserve ALL HTML tags')
expect(args[:messages][1][:content]).to eq(content)
{ message: "# Bienvenido\nAlgo de markdown.\n" }
end
expect(service.perform).to include(message: "# Bienvenido\nAlgo de markdown.")
end
end
describe '#perform with an invalid type' do
it 'raises ArgumentError' do
service = described_class.new(account: account, text: 'hi', target_language: target_language, type: :invalid)
expect { service.perform }.to raise_error(ArgumentError, /Invalid type/)
end
end
describe '#perform when the API call fails' do
let(:service) do
described_class.new(account: account, text: 'Getting Started', target_language: target_language, type: :title)
end
it 'returns the error hash unchanged' do
allow(service).to receive(:make_api_call).and_return(error: 'LLM timeout', error_code: 500)
expect(service.perform).to eq(error: 'LLM timeout', error_code: 500)
end
end
end
@@ -2,8 +2,8 @@ require 'rails_helper'
RSpec.describe Captain::Llm::FaqGeneratorService do
let(:content) { 'Sample content for FAQ generation' }
let(:language) { 'english' }
let(:service) { described_class.new(content, language) }
let(:document) { create(:captain_document, content: content) }
let(:service) { described_class.new(document: document) }
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:sample_faqs) do
[
@@ -36,14 +36,15 @@ RSpec.describe Captain::Llm::FaqGeneratorService do
service.generate
end
it 'uses SystemPromptsService with the specified language' do
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(language).at_least(:once).and_call_original
it 'uses SystemPromptsService with the account language' do
account_language = document.account.locale_english_name
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(account_language).at_least(:once).and_call_original
service.generate
end
end
context 'with different language' do
let(:language) { 'spanish' }
before { allow(document.account).to receive(:locale_english_name).and_return('spanish') }
it 'passes the correct language to SystemPromptsService' do
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with('spanish').at_least(:once).and_call_original
@@ -58,9 +58,9 @@ RSpec.describe Captain::Tools::FirecrawlService do
limit: crawl_limit,
webhook: webhook_url,
scrapeOptions: {
onlyMainContent: false,
onlyMainContent: true,
formats: ['markdown'],
excludeTags: ['iframe']
excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS
}
}.to_json
end
+2
View File
@@ -0,0 +1,2 @@
name,email,phone_number
Ahmed,ahmed@example.com,+971501234567
1 name email phone_number
2 Ahmed ahmed@example.com +971501234567
@@ -85,6 +85,13 @@ RSpec.describe Account::ContactsExportJob do
expect(phone_numbers).to include('+910808080818', '+910808080808')
end
it 'prepends UTF-8 BOM to the exported CSV for spreadsheet compatibility' do
described_class.perform_now(account.id, user.id, [], {})
raw = account.contacts_export.download
expect(raw.bytes[0..2]).to eq([0xEF, 0xBB, 0xBF])
end
it 'returns all resolved contacts as results when filter is not prvoided' do
create(:contact, account: account, email: nil, phone_number: nil)
described_class.perform_now(account.id, user.id, %w[id name email column_not_present], {})

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