Merge branch 'develop' into feat/voice-as-twilio-capability
This commit is contained in:
@@ -2,3 +2,8 @@
|
||||
ignore:
|
||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
||||
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
||||
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
|
||||
# storage setup uses local/cloud storage with optional direct uploads to the
|
||||
# storage provider rather than Rails proxy mode. Revisit if we enable
|
||||
# rails_storage_proxy or other app-served Active Storage proxy routes.
|
||||
- CVE-2026-33658
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||
before_action :set_article, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :set_category, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show]
|
||||
before_action :portal
|
||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
@@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
def redirect_to_portal_with_locale
|
||||
return if params[:locale].present?
|
||||
|
||||
portal
|
||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,4 +18,11 @@ class PublicController < ActionController::Base
|
||||
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
||||
}, status: :unauthorized and return
|
||||
end
|
||||
|
||||
def ensure_portal_feature_enabled
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
return if @portal.account.feature_enabled?('help_center')
|
||||
|
||||
render 'public/api/v1/portals/not_active', status: :payment_required
|
||||
end
|
||||
end
|
||||
|
||||
@@ -98,7 +98,9 @@ export default {
|
||||
mql.onchange = e => setColorTheme(e.matches);
|
||||
},
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
if (locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
}
|
||||
},
|
||||
async initializeAccount() {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
|
||||
@@ -106,6 +106,10 @@ select {
|
||||
&[disabled] {
|
||||
@apply field-disabled;
|
||||
}
|
||||
|
||||
option:not(:disabled) {
|
||||
@apply bg-n-solid-2 text-n-slate-12;
|
||||
}
|
||||
}
|
||||
|
||||
// Textarea
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
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 Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -34,14 +35,34 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSelectionControl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showMenu: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const emit = defineEmits(['action', 'select', 'hover']);
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
@@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => {
|
||||
</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 v-model="modelValue" />
|
||||
</div>
|
||||
<div class="flex gap-1 justify-between w-full">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</span>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="flex relative items-center group"
|
||||
|
||||
+11
-5
@@ -21,16 +21,22 @@ const emit = defineEmits(['deleteSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const bulkDeleteDialogRef = ref(null);
|
||||
const i18nKey = computed(() => props.type.toUpperCase());
|
||||
const i18nKey = computed(() => {
|
||||
const i18nTypeMap = {
|
||||
AssistantResponse: 'RESPONSES',
|
||||
AssistantDocument: 'DOCUMENTS',
|
||||
};
|
||||
return i18nTypeMap[props.type];
|
||||
});
|
||||
|
||||
const handleBulkDelete = async ids => {
|
||||
if (!ids) return;
|
||||
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkDelete',
|
||||
Array.from(props.bulkIds)
|
||||
);
|
||||
await store.dispatch('captainBulkActions/handleBulkDelete', {
|
||||
ids: Array.from(props.bulkIds),
|
||||
type: props.type,
|
||||
});
|
||||
|
||||
emit('deleteSuccess');
|
||||
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
|
||||
|
||||
@@ -253,6 +253,9 @@ export default {
|
||||
if (this.isAnInstagramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||
}
|
||||
if (this.isATelegramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TELEGRAM;
|
||||
}
|
||||
if (this.isATiktokChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TIKTOK;
|
||||
}
|
||||
@@ -545,7 +548,10 @@ export default {
|
||||
},
|
||||
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
this.copilotAcceptedMessages[key] = trimContent(message || '');
|
||||
this.copilotAcceptedMessages[key] = trimContent(
|
||||
message || '',
|
||||
this.maxLength
|
||||
);
|
||||
},
|
||||
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
@@ -603,7 +609,7 @@ export default {
|
||||
saveDraft(conversationId, replyType) {
|
||||
if (this.message || this.message === '') {
|
||||
const key = this.getDraftKey(conversationId, replyType);
|
||||
const draftToSave = trimContent(this.message || '');
|
||||
const draftToSave = trimContent(this.message || '', this.maxLength);
|
||||
|
||||
this.$store.dispatch('draftMessages/set', {
|
||||
key,
|
||||
|
||||
@@ -738,6 +738,17 @@
|
||||
"DOCUMENTS": {
|
||||
"HEADER": "Documents",
|
||||
"ADD_NEW": "Create a new document",
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_DELETE": {
|
||||
"TITLE": "Delete documents?",
|
||||
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
|
||||
"CONFIRM": "Yes, delete all",
|
||||
"SUCCESS_MESSAGE": "Documents deleted successfully",
|
||||
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
|
||||
},
|
||||
"RELATED_RESPONSES": {
|
||||
"TITLE": "Related FAQs",
|
||||
"DESCRIPTION": "These FAQs are generated directly from the document."
|
||||
|
||||
@@ -4,9 +4,13 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
|
||||
@@ -14,9 +18,12 @@ import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponen
|
||||
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
|
||||
@@ -25,9 +32,13 @@ const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const documentsMeta = useMapGetter('captainDocuments/getMeta');
|
||||
|
||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||
const canManageDocuments = computed(() => checkPermissions(['administrator']));
|
||||
|
||||
const selectedDocument = ref(null);
|
||||
const deleteDocumentDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDocumentDialog.value.dialogRef.open();
|
||||
@@ -78,7 +89,14 @@ const fetchDocuments = (page = 1) => {
|
||||
store.dispatch('captainDocuments/get', filterParams);
|
||||
};
|
||||
|
||||
const onPageChange = page => fetchDocuments(page);
|
||||
const onPageChange = page => {
|
||||
const hadSelection = bulkSelectedIds.value.size > 0;
|
||||
fetchDocuments(page);
|
||||
|
||||
if (hadSelection) {
|
||||
bulkSelectedIds.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
|
||||
@@ -86,6 +104,58 @@ const onDeleteSuccess = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = documents.value?.length || 0;
|
||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() => {
|
||||
return t('CAPTAIN.DOCUMENTS.SELECTED', {
|
||||
count: bulkSelectedIds.value.size,
|
||||
});
|
||||
});
|
||||
|
||||
const hasBulkSelection = computed(() => bulkSelectedIds.value.size > 0);
|
||||
|
||||
const shouldShowSelectionControl = docId => {
|
||||
return (
|
||||
canManageDocuments.value &&
|
||||
(hoveredCard.value === docId || hasBulkSelection.value)
|
||||
);
|
||||
};
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const handleCardSelect = id => {
|
||||
if (!canManageDocuments.value) return;
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const fetchDocumentsAfterBulkAction = () => {
|
||||
const hasNoDocumentsLeft = documents.value?.length === 0;
|
||||
const currentPage = documentsMeta.value?.page;
|
||||
|
||||
if (hasNoDocumentsLeft) {
|
||||
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
|
||||
fetchDocuments(pageToFetch);
|
||||
} else {
|
||||
fetchDocuments(currentPage);
|
||||
}
|
||||
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchDocumentsAfterBulkAction();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchDocuments();
|
||||
});
|
||||
@@ -106,6 +176,21 @@ onMounted(() => {
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreateDocument"
|
||||
>
|
||||
<template #subHeader>
|
||||
<Policy :permissions="['administrator']">
|
||||
<BulkSelectBar
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="documents"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
|
||||
class="w-fit"
|
||||
:class="{ 'mb-2': bulkSelectedIds.size > 0 }"
|
||||
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
||||
/>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
<template #knowMore>
|
||||
<FeatureSpotlightPopover
|
||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||
@@ -138,7 +223,13 @@ onMounted(() => {
|
||||
:external-link="doc.external_link"
|
||||
:assistant="doc.assistant"
|
||||
:created-at="doc.created_at"
|
||||
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
|
||||
:selectable="canManageDocuments"
|
||||
:show-selection-control="shouldShowSelectionControl(doc.id)"
|
||||
:show-menu="!bulkSelectedIds.has(doc.id)"
|
||||
@action="handleAction"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, doc.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -162,5 +253,12 @@ onMounted(() => {
|
||||
type="Documents"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="AssistantDocument"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
@@ -316,7 +316,7 @@ onMounted(() => {
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="Responses"
|
||||
type="AssistantResponse"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
|
||||
@@ -361,7 +361,7 @@ onMounted(() => {
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="Responses"
|
||||
type="AssistantResponse"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
|
||||
@@ -103,7 +103,10 @@ export default {
|
||||
const { name, locale, id, domain, support_email, features } =
|
||||
this.getAccount(this.accountId);
|
||||
|
||||
this.$root.$i18n.locale = this.uiSettings?.locale || locale;
|
||||
const effectiveLocale = this.uiSettings?.locale || locale;
|
||||
if (effectiveLocale) {
|
||||
this.$root.$i18n.locale = effectiveLocale;
|
||||
}
|
||||
this.name = name;
|
||||
this.locale = locale;
|
||||
this.id = id;
|
||||
@@ -129,11 +132,9 @@ export default {
|
||||
support_email: this.supportEmail,
|
||||
});
|
||||
// If user locale is set, update the locale with user locale
|
||||
if (this.uiSettings?.locale) {
|
||||
this.$root.$i18n.locale = this.uiSettings?.locale;
|
||||
} else {
|
||||
// If user locale is not set, update the locale with account locale
|
||||
this.$root.$i18n.locale = this.locale;
|
||||
const updatedLocale = this.uiSettings?.locale || this.locale;
|
||||
if (updatedLocale) {
|
||||
this.$root.$i18n.locale = updatedLocale;
|
||||
}
|
||||
this.getAccount(this.id).locale = this.locale;
|
||||
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
|
||||
|
||||
@@ -57,7 +57,10 @@ export default {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE'));
|
||||
useAlert(
|
||||
error.message ||
|
||||
this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -25,17 +25,26 @@ export default createStore({
|
||||
}
|
||||
},
|
||||
|
||||
handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) {
|
||||
handleBulkDelete: async function handleBulkDelete(
|
||||
{ dispatch },
|
||||
{ type = 'AssistantResponse', ids }
|
||||
) {
|
||||
const response = await dispatch('processBulkAction', {
|
||||
type: 'AssistantResponse',
|
||||
type,
|
||||
actionType: 'delete',
|
||||
ids,
|
||||
});
|
||||
|
||||
// Update the response store after successful API call
|
||||
await dispatch('captainResponses/removeBulkResponses', ids, {
|
||||
root: true,
|
||||
});
|
||||
if (type === 'AssistantResponse') {
|
||||
// Update the response store after successful API call
|
||||
await dispatch('captainResponses/removeBulkResponses', ids, {
|
||||
root: true,
|
||||
});
|
||||
} else if (type === 'AssistantDocument') {
|
||||
await dispatch('captainDocuments/removeBulkRecords', ids, {
|
||||
root: true,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
},
|
||||
|
||||
|
||||
@@ -4,4 +4,12 @@ import { createStore } from '../storeFactory';
|
||||
export default createStore({
|
||||
name: 'CaptainDocument',
|
||||
API: CaptainDocumentAPI,
|
||||
actions: mutations => ({
|
||||
removeBulkRecords({ commit, getters }, ids) {
|
||||
const records = getters.getRecords.filter(
|
||||
record => !ids.includes(record.id)
|
||||
);
|
||||
commit(mutations.SET, records);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -220,9 +220,8 @@ export const actions = {
|
||||
sendAnalyticsEvent(channel.type);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const errorMessage = error?.response?.data?.message;
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
throw new Error(errorMessage);
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
createWebsiteChannel: async ({ commit }, params) => {
|
||||
|
||||
@@ -35,7 +35,9 @@ export default {
|
||||
};
|
||||
},
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
if (locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply antialiased h-full;
|
||||
@apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1;
|
||||
}
|
||||
|
||||
.is-mobile {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from 'vue';
|
||||
import { computed, watchEffect } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const isDarkModeAuto = mode => mode === 'auto';
|
||||
@@ -23,6 +23,10 @@ export function useDarkMode() {
|
||||
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', prefersDarkMode.value);
|
||||
});
|
||||
|
||||
return {
|
||||
darkMode,
|
||||
prefersDarkMode,
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white h-full">
|
||||
<div class="bg-n-solid-1 h-full">
|
||||
<IframeLoader :url="$route.query.link" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
class Inboxes::BulkAutoAssignmentJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
include BillingHelper
|
||||
|
||||
def perform
|
||||
Account.feature_assignment_v2.find_each do |account|
|
||||
if should_skip_auto_assignment?(account)
|
||||
Rails.logger.info("Skipping auto assignment for account #{account.id}")
|
||||
next
|
||||
end
|
||||
|
||||
account.inboxes.where(enable_auto_assignment: true).find_each do |inbox|
|
||||
process_assignment(inbox)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_assignment(inbox)
|
||||
allowed_agent_ids = inbox.member_ids_with_assignment_capacity
|
||||
|
||||
if allowed_agent_ids.blank?
|
||||
Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}")
|
||||
return
|
||||
end
|
||||
|
||||
assign_conversations(inbox, allowed_agent_ids)
|
||||
end
|
||||
|
||||
def assign_conversations(inbox, allowed_agent_ids)
|
||||
unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT)
|
||||
unassigned_conversations.find_each do |conversation|
|
||||
::AutoAssignment::AgentAssignmentService.new(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: allowed_agent_ids
|
||||
).perform
|
||||
Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}")
|
||||
end
|
||||
end
|
||||
|
||||
def should_skip_auto_assignment?(account)
|
||||
return false unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
default_plan?(account)
|
||||
end
|
||||
end
|
||||
@@ -17,6 +17,7 @@ module AccountEmailRateLimitable
|
||||
end
|
||||
|
||||
def within_email_rate_limit?
|
||||
return true unless ChatwootApp.chatwoot_cloud?
|
||||
return true if emails_sent_today < email_rate_limit
|
||||
|
||||
Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}")
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
module SocialLinkParser
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
SOCIAL_DOMAIN_MAP = {
|
||||
whatsapp: %w[wa.me api.whatsapp.com],
|
||||
line: %w[line.me],
|
||||
facebook: %w[facebook.com fb.com fb.me],
|
||||
instagram: %w[instagram.com],
|
||||
telegram: %w[t.me telegram.me],
|
||||
tiktok: %w[tiktok.com]
|
||||
}.freeze
|
||||
|
||||
private
|
||||
|
||||
def extract_social_from_links(links)
|
||||
handles = {}
|
||||
SOCIAL_DOMAIN_MAP.each do |platform, domains|
|
||||
handles[platform] = find_social_handle(links, platform, domains)
|
||||
end
|
||||
handles
|
||||
end
|
||||
|
||||
def find_social_handle(links, platform, domains)
|
||||
matching_links = links.select do |l|
|
||||
uri = URI.parse(l)
|
||||
domains.any? { |d| match_social_domain?(uri.host, d) }
|
||||
rescue URI::InvalidURIError
|
||||
false
|
||||
end
|
||||
|
||||
matching_links.each do |link|
|
||||
handle = parse_social_handle(platform, link)
|
||||
return handle if handle.present?
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def match_social_domain?(host, domain)
|
||||
return false if host.blank?
|
||||
|
||||
host == domain || host.end_with?(".#{domain}")
|
||||
end
|
||||
|
||||
SHARE_PATH_PREFIXES = %w[sharer share intent dialog].freeze
|
||||
|
||||
def parse_social_handle(platform, link)
|
||||
uri = URI.parse(link)
|
||||
return extract_whatsapp_phone(uri) if platform == :whatsapp
|
||||
|
||||
handle = uri.path.to_s.delete_prefix('/').delete_suffix('/')
|
||||
return nil if handle.blank?
|
||||
return nil if SHARE_PATH_PREFIXES.any? { |prefix| handle.start_with?(prefix) }
|
||||
|
||||
handle.presence
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
# wa.me/1234567890 or api.whatsapp.com/send?phone=1234567890
|
||||
def extract_whatsapp_phone(uri)
|
||||
phone = CGI.parse(uri.query.to_s)['phone']&.first
|
||||
phone = uri.path.to_s.delete_prefix('/').delete_suffix('/') if phone.blank?
|
||||
phone.presence&.gsub(/[^\d]/, '')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,93 @@
|
||||
class WebsiteBrandingService
|
||||
include SocialLinkParser
|
||||
|
||||
def initialize(url)
|
||||
@url = normalize_url(url)
|
||||
end
|
||||
|
||||
def perform
|
||||
doc = fetch_page
|
||||
return nil if doc.nil?
|
||||
|
||||
links = extract_links(doc)
|
||||
|
||||
{
|
||||
business_name: extract_business_name(doc),
|
||||
language: extract_language(doc),
|
||||
industry_category: nil,
|
||||
social_handles: extract_social_from_links(links),
|
||||
branding: extract_branding(doc)
|
||||
}
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WebsiteBranding] #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_url(url)
|
||||
url.match?(%r{\Ahttps?://}) ? url : "https://#{url}"
|
||||
end
|
||||
|
||||
def fetch_page
|
||||
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
|
||||
return nil unless response.success?
|
||||
|
||||
Nokogiri::HTML(response.body)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_business_name(doc)
|
||||
og_site_name = doc.at_css('meta[property="og:site_name"]')&.[]('content')
|
||||
return og_site_name.strip if og_site_name.present?
|
||||
|
||||
title = doc.at_xpath('//title')&.text
|
||||
title&.strip&.split(/\s*[|\-–—·:]+\s*/)&.first
|
||||
end
|
||||
|
||||
def extract_language(doc)
|
||||
doc.at_css('html')&.[]('lang')&.split('-')&.first&.downcase
|
||||
end
|
||||
|
||||
def extract_links(doc)
|
||||
doc.css('a[href]').filter_map do |a|
|
||||
href = a['href']&.strip
|
||||
next if href.blank? || href.start_with?('#', 'javascript:', 'mailto:', 'tel:')
|
||||
|
||||
href.start_with?('http') ? href : URI.join(@url, href).to_s
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def extract_branding(doc)
|
||||
{
|
||||
favicon: extract_favicon(doc),
|
||||
primary_color: extract_theme_color(doc)
|
||||
}
|
||||
end
|
||||
|
||||
def extract_favicon(doc)
|
||||
favicon = doc.at_css('link[rel*="icon"]')&.[]('href')
|
||||
return nil if favicon.blank?
|
||||
|
||||
resolve_url(favicon)
|
||||
end
|
||||
|
||||
def extract_theme_color(doc)
|
||||
doc.at_css('meta[name="theme-color"]')&.[]('content')
|
||||
end
|
||||
|
||||
def resolve_url(url)
|
||||
return nil if url.blank?
|
||||
return url if url.start_with?('http')
|
||||
|
||||
URI.join(@url, url).to_s
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService')
|
||||
@@ -58,9 +58,9 @@ By default, it renders:
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-white dark:bg-slate-900">
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<%= render "public/api/v1/portals/header", portal: @portal %>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="max-w-6xl h-full w-full flex-grow flex flex-col items-center justify-center mx-auto py-16 px-4 relative">
|
||||
<div class="text-center mb-12">
|
||||
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300">
|
||||
<span class="text-5xl font-medium">i</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-6xl text-center font-semibold text-slate-800 dark:text-slate-100 leading-relaxed"><%= I18n.t('public_portal.not_active.title') %></h1>
|
||||
<p class="text-center text-slate-700 dark:text-slate-300 my-1"><%= I18n.t('public_portal.not_active.description') %></p>
|
||||
<div class="text-center my-8">
|
||||
<p class="text-slate-600 dark:text-slate-400 text-sm"><%= I18n.t('public_portal.not_active.action') %></p>
|
||||
</div>
|
||||
</div>
|
||||
+3
-3
@@ -104,10 +104,10 @@
|
||||
display_name: Audit Logs
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: response_bot
|
||||
display_name: Response Bot
|
||||
- name: custom_tools
|
||||
display_name: Custom Tools
|
||||
enabled: false
|
||||
deprecated: true
|
||||
premium: true
|
||||
- name: message_reply_to
|
||||
display_name: Message Reply To
|
||||
enabled: false
|
||||
|
||||
@@ -42,7 +42,8 @@ LANGUAGES_CONFIG = {
|
||||
37 => { name: 'עִברִית (he)', iso_639_3_code: 'heb', iso_639_1_code: 'he', enabled: true },
|
||||
38 => { name: 'lietuvių (lt)', iso_639_3_code: 'lit', iso_639_1_code: 'lt', enabled: true },
|
||||
39 => { name: 'Српски (sr)', iso_639_3_code: 'srp', iso_639_1_code: 'sr', enabled: true },
|
||||
40 => { name: 'български (bg)', iso_639_3_code: 'bul', iso_639_1_code: 'bg', enabled: true }
|
||||
40 => { name: 'български (bg)', iso_639_3_code: 'bul', iso_639_1_code: 'bg', enabled: true },
|
||||
41 => { name: 'Eesti keel (et)', iso_639_3_code: 'est', iso_639_1_code: 'et', enabled: true }
|
||||
}.filter { |_key, val| val[:enabled] }.freeze
|
||||
|
||||
Rails.configuration.i18n.available_locales = LANGUAGES_CONFIG.map { |_index, lang| lang[:iso_639_1_code].to_sym }
|
||||
|
||||
@@ -34,7 +34,18 @@ end
|
||||
|
||||
# https://github.com/ondrejbartas/sidekiq-cron
|
||||
Rails.application.reloader.to_prepare do
|
||||
# TODO: Switch to `load_from_hash!(..., source: 'schedule')` once we have a
|
||||
# safe cleanup path for YAML-backed cron jobs already persisted in Redis.
|
||||
Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) if File.exist?(schedule_file) && Sidekiq.server?
|
||||
# load_from_hash! upserts jobs from the YAML and removes any Redis-persisted
|
||||
# jobs that share the same source tag but are no longer in the file.
|
||||
# This ensures deleted schedule entries are cleaned up on deploy.
|
||||
if File.exist?(schedule_file) && Sidekiq.server?
|
||||
schedule = YAML.load_file(schedule_file)
|
||||
|
||||
# Cron entries removed from schedule.yml but possibly still in Redis
|
||||
# with source:'dynamic' (predating the source tag). load_from_hash!
|
||||
# only cleans up source:'schedule' entries, so these need explicit removal.
|
||||
# Remove names from this list once they've been through a deploy cycle.
|
||||
%w[bulk_auto_assignment_job].each { |name| Sidekiq::Cron::Job.destroy(name) }
|
||||
|
||||
Sidekiq::Cron::Job.load_from_hash!(schedule, source: 'schedule')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -423,6 +423,10 @@ en:
|
||||
title: Page not found
|
||||
description: We couldn't find the page you were looking for.
|
||||
back_to_home: Go to home page
|
||||
not_active:
|
||||
title: Help Center Unavailable
|
||||
description: Please contact the site administrator for more information.
|
||||
action: If you are the administrator, please upgrade your plan to restore access.
|
||||
slack_unfurl:
|
||||
fields:
|
||||
name: Name
|
||||
|
||||
@@ -134,6 +134,18 @@ codepen:
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
guidejar:
|
||||
regex: 'https?://(?:www\.)?guidejar\.com/(?:embed|guides)/(?<guide_id>[^&/?]+)'
|
||||
template: |
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://www.guidejar.com/embed/%{guide_id}?type=1&controls=on"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||
</div>
|
||||
|
||||
github_gist:
|
||||
regex: 'https?://gist\.github\.com/(?<username>[^/]+)/(?<gist_id>[a-f0-9]+)'
|
||||
template: |
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
# executed daily at 0000 UTC
|
||||
# schedules daily deferred jobs at stable times for each installation
|
||||
# keep the existing schedule key while the cron loader still uses load_from_hash
|
||||
internal_check_new_versions_job:
|
||||
cron: '0 0 * * *'
|
||||
class: 'Internal::TriggerDailyScheduledItemsJob'
|
||||
@@ -50,13 +49,6 @@ delete_accounts_job:
|
||||
class: 'Internal::DeleteAccountsJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed every 15 minutes
|
||||
# to assign unassigned conversations for all inboxes
|
||||
bulk_auto_assignment_job:
|
||||
cron: '*/15 * * * *'
|
||||
class: 'Inboxes::BulkAutoAssignmentJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed every 30 minutes for assignment_v2
|
||||
periodic_assignment_job:
|
||||
cron: '*/30 * * * *'
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class RepurposeResponseBotFlagForCustomTools < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
# The response_bot flag (deprecated) has been renamed to custom_tools.
|
||||
# Disable it on any accounts that had response_bot enabled so the repurposed
|
||||
# flag starts in its intended default-off state.
|
||||
Account.feature_custom_tools.find_each(batch_size: 100) do |account|
|
||||
account.disable_features(:custom_tools)
|
||||
account.save!(validate: false)
|
||||
end
|
||||
|
||||
# Remove the stale response_bot entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
|
||||
# ConfigLoader only adds new flags; it never removes renamed ones.
|
||||
# Leaving it would cause NoMethodError in enable_default_features when
|
||||
# creating new accounts (feature_response_bot= no longer exists).
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
return if config&.value.blank?
|
||||
|
||||
config.value = config.value.reject { |f| f['name'] == 'response_bot' }
|
||||
config.save!
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
before_action :validate_params
|
||||
before_action :type_matches?
|
||||
|
||||
MODEL_TYPE = ['AssistantResponse'].freeze
|
||||
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
|
||||
|
||||
def create
|
||||
@responses = process_bulk_action
|
||||
@@ -28,6 +28,8 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
case params[:type]
|
||||
when 'AssistantResponse'
|
||||
handle_assistant_responses
|
||||
when 'AssistantDocument'
|
||||
handle_documents
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,6 +47,16 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
end
|
||||
end
|
||||
|
||||
def handle_documents
|
||||
return [] unless params[:fields][:status] == 'delete'
|
||||
|
||||
documents = Current.account.captain_documents.where(id: params[:ids])
|
||||
return [] unless documents.exists?
|
||||
|
||||
documents.destroy_all
|
||||
[]
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:type, ids: [], fields: [:status])
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module Enterprise::Inbox
|
||||
def member_ids_with_assignment_capacity
|
||||
return super unless enable_auto_assignment?
|
||||
return filter_by_capacity(available_agents).map(&:user_id) if auto_assignment_v2_enabled?
|
||||
|
||||
max_assignment_limit = auto_assignment_config['max_assignment_limit']
|
||||
overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : []
|
||||
|
||||
@@ -21,7 +21,7 @@ module Enterprise::InboxAgentAvailability
|
||||
end
|
||||
|
||||
def capacity_filtering_enabled?
|
||||
account.feature_enabled?('assignment_v2') &&
|
||||
account.feature_enabled?('advanced_assignment') &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
|
||||
@@ -7,21 +7,12 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
return if existing_subscription?
|
||||
|
||||
customer_id = prepare_customer_id
|
||||
subscription = Stripe::Subscription.create(
|
||||
{
|
||||
customer: customer_id,
|
||||
items: [{ price: price_id, quantity: default_quantity }]
|
||||
}
|
||||
)
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
stripe_customer_id: customer_id,
|
||||
stripe_price_id: subscription['plan']['id'],
|
||||
stripe_product_id: subscription['plan']['product'],
|
||||
plan_name: default_plan['name'],
|
||||
subscribed_quantity: subscription['quantity']
|
||||
}
|
||||
)
|
||||
subscription = Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
|
||||
custom_attributes = build_custom_attributes(customer_id, subscription)
|
||||
custom_attributes.except!('is_creating_customer')
|
||||
|
||||
account.update!(custom_attributes: custom_attributes)
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
|
||||
end
|
||||
|
||||
private
|
||||
@@ -66,4 +57,23 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
)
|
||||
subscriptions.data.present?
|
||||
end
|
||||
|
||||
def build_custom_attributes(customer_id, subscription)
|
||||
(account.custom_attributes || {}).merge(
|
||||
'stripe_customer_id' => customer_id,
|
||||
'stripe_price_id' => subscription['plan']['id'],
|
||||
'stripe_product_id' => subscription['plan']['product'],
|
||||
'plan_name' => default_plan['name'],
|
||||
'subscribed_quantity' => subscription['quantity'],
|
||||
'subscription_status' => subscription['status'],
|
||||
'subscription_ends_on' => subscription_ends_on(subscription)
|
||||
)
|
||||
end
|
||||
|
||||
def subscription_ends_on(subscription)
|
||||
period_end = subscription['current_period_end']
|
||||
return if period_end.blank?
|
||||
|
||||
Time.zone.at(period_end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,29 +2,9 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
|
||||
|
||||
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
|
||||
# Each higher tier includes all features from the lower tiers
|
||||
|
||||
# Basic features available starting with the Startups plan
|
||||
STARTUP_PLAN_FEATURES = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
advanced_search
|
||||
linear_integration
|
||||
].freeze
|
||||
|
||||
# Additional features available starting with the Business plan
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
|
||||
|
||||
# Additional features available only in the Enterprise plan
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
STARTUP_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::STARTUP_PLAN_FEATURES
|
||||
BUSINESS_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES
|
||||
ENTERPRISE_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES
|
||||
|
||||
def perform(event:)
|
||||
@event = event
|
||||
@@ -49,7 +29,7 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
|
||||
previous_usage = capture_previous_usage
|
||||
update_account_attributes(subscription, plan)
|
||||
update_plan_features
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
|
||||
|
||||
if billing_period_renewed?
|
||||
ActiveRecord::Base.transaction do
|
||||
@@ -94,34 +74,6 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
end
|
||||
|
||||
def update_plan_features
|
||||
if default_plan?
|
||||
disable_all_premium_features
|
||||
else
|
||||
enable_features_for_current_plan
|
||||
end
|
||||
|
||||
# Enable any manually managed features configured in internal_attributes
|
||||
enable_account_manually_managed_features
|
||||
|
||||
account.save!
|
||||
end
|
||||
|
||||
def disable_all_premium_features
|
||||
# Disable all features (for default Hacker plan)
|
||||
account.disable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.disable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
|
||||
def enable_features_for_current_plan
|
||||
# First disable all premium features to handle downgrades
|
||||
disable_all_premium_features
|
||||
|
||||
# Then enable features based on the current plan
|
||||
enable_plan_specific_features
|
||||
end
|
||||
|
||||
def handle_subscription_credits(plan, previous_usage)
|
||||
current_limits = account.limits || {}
|
||||
|
||||
@@ -153,19 +105,6 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
config[plan_name.downcase]&.symbolize_keys
|
||||
end
|
||||
|
||||
def enable_plan_specific_features
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
return if plan_name.blank?
|
||||
|
||||
case plan_name
|
||||
when 'Startups' then account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
when 'Business'
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES)
|
||||
when 'Enterprise'
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES, *ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
end
|
||||
|
||||
def subscription
|
||||
@subscription ||= @event.data.object
|
||||
end
|
||||
@@ -197,19 +136,4 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
|
||||
end
|
||||
|
||||
def default_plan?
|
||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||
default_plan = cloud_plans.first || {}
|
||||
account.custom_attributes['plan_name'] == default_plan['name']
|
||||
end
|
||||
|
||||
def enable_account_manually_managed_features
|
||||
# Get manually managed features from internal attributes using the service
|
||||
service = Internal::Accounts::InternalAttributesService.new(account)
|
||||
features = service.manually_managed_features
|
||||
|
||||
# Enable each feature
|
||||
account.enable_features(*features) if features.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||
|
||||
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
|
||||
# Each higher tier includes all features from the lower tiers
|
||||
STARTUP_PLAN_FEATURES = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
advanced_search
|
||||
linear_integration
|
||||
].freeze
|
||||
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
|
||||
|
||||
pattr_initialize [:account!]
|
||||
|
||||
def perform
|
||||
account.disable_features(*PREMIUM_PLAN_FEATURES)
|
||||
account.enable_features(*current_plan_features)
|
||||
account.enable_features(*manually_managed_features)
|
||||
account.save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_plan_features
|
||||
return [] if default_plan?
|
||||
|
||||
case account.custom_attributes['plan_name']
|
||||
when 'Startups' then STARTUP_PLAN_FEATURES
|
||||
when 'Business' then STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES
|
||||
when 'Enterprise' then PREMIUM_PLAN_FEATURES
|
||||
else []
|
||||
end
|
||||
end
|
||||
|
||||
def default_plan?
|
||||
default_plan_name = cloud_plans.first&.dig('name')
|
||||
return false if default_plan_name.blank?
|
||||
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
plan_name.blank? || plan_name == default_plan_name
|
||||
end
|
||||
|
||||
def cloud_plans
|
||||
@cloud_plans ||= InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||
end
|
||||
|
||||
def manually_managed_features
|
||||
@manually_managed_features ||= Internal::Accounts::InternalAttributesService.new(account).manually_managed_features
|
||||
end
|
||||
end
|
||||
@@ -73,8 +73,13 @@ class Enterprise::Billing::TopupCheckoutService
|
||||
description: description
|
||||
)
|
||||
|
||||
Stripe::Invoice.finalize_invoice(invoice.id, { auto_advance: false })
|
||||
Stripe::Invoice.pay(invoice.id)
|
||||
finalize_and_pay(invoice.id)
|
||||
end
|
||||
|
||||
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'
|
||||
end
|
||||
|
||||
def fulfill_credits(credits, topup_option)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
module Enterprise::WebsiteBrandingService
|
||||
FIRECRAWL_SCRAPE_ENDPOINT = 'https://api.firecrawl.dev/v2/scrape'.freeze
|
||||
|
||||
INDUSTRY_CATEGORIES = [
|
||||
'Technology',
|
||||
'E-commerce',
|
||||
'Healthcare',
|
||||
'Education',
|
||||
'Finance',
|
||||
'Real Estate',
|
||||
'Marketing',
|
||||
'Travel & Hospitality',
|
||||
'Food & Beverage',
|
||||
'Media & Entertainment',
|
||||
'Professional Services',
|
||||
'Non-profit',
|
||||
'Other'
|
||||
].freeze
|
||||
|
||||
def perform
|
||||
return super unless firecrawl_enabled?
|
||||
|
||||
response = perform_firecrawl_request
|
||||
process_firecrawl_response(response)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WebsiteBranding] Firecrawl failed: #{e.message}, falling back to basic scrape"
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def firecrawl_enabled?
|
||||
firecrawl_api_key.present?
|
||||
end
|
||||
|
||||
def firecrawl_api_key
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value
|
||||
end
|
||||
|
||||
def perform_firecrawl_request
|
||||
HTTParty.post(
|
||||
FIRECRAWL_SCRAPE_ENDPOINT,
|
||||
body: scrape_payload.to_json,
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{firecrawl_api_key}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def scrape_payload
|
||||
{
|
||||
url: @url,
|
||||
onlyMainContent: false,
|
||||
formats: [
|
||||
{
|
||||
type: 'json',
|
||||
schema: extract_schema,
|
||||
prompt: 'Extract the business name, primary language, and industry category from this website.'
|
||||
},
|
||||
'branding',
|
||||
'links'
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def extract_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
business_name: { type: 'string', description: 'The name of the business or company' },
|
||||
language: { type: 'string', description: 'Primary language as ISO 639-1 code (e.g., en, es, fr)' },
|
||||
industry_category: { type: 'string', enum: INDUSTRY_CATEGORIES, description: 'Industry category for this business' }
|
||||
},
|
||||
required: %w[business_name]
|
||||
}
|
||||
end
|
||||
|
||||
def process_firecrawl_response(response)
|
||||
raise "API Error: #{response.message} (Status: #{response.code})" unless response.success?
|
||||
|
||||
format_firecrawl_response(response)
|
||||
end
|
||||
|
||||
def format_firecrawl_response(response)
|
||||
data = response.parsed_response
|
||||
extract = data.dig('data', 'json') || {}
|
||||
brand = data.dig('data', 'branding') || {}
|
||||
links = data.dig('data', 'links') || []
|
||||
|
||||
{
|
||||
business_name: extract['business_name'],
|
||||
language: extract['language'],
|
||||
industry_category: extract['industry_category'],
|
||||
social_handles: extract_social_from_links(links),
|
||||
branding: extract_firecrawl_branding(brand)
|
||||
}
|
||||
end
|
||||
|
||||
def extract_firecrawl_branding(brand)
|
||||
{
|
||||
favicon: url_or_nil(brand.dig('images', 'favicon')),
|
||||
primary_color: brand.dig('colors', 'primary')
|
||||
}
|
||||
end
|
||||
|
||||
def url_or_nil(value)
|
||||
return nil if value.blank? || !value.start_with?('http')
|
||||
|
||||
value
|
||||
end
|
||||
end
|
||||
@@ -53,8 +53,8 @@ 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::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
|
||||
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES
|
||||
end
|
||||
|
||||
# Account notes functionality removed for now
|
||||
|
||||
@@ -3,8 +3,6 @@ You are evaluating whether a customer support conversation is complete and can b
|
||||
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
|
||||
|
||||
A conversation is INCOMPLETE (keep open) if ANY of these apply:
|
||||
- The assistant suggested the customer try something or take an action — they may still be attempting it
|
||||
- The assistant directed the customer to an external resource, link, or contact — they may still be following up
|
||||
- The assistant asked a question or requested information that the customer hasn't provided
|
||||
- The customer asked a question that wasn't fully answered
|
||||
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
|
||||
|
||||
@@ -5,7 +5,7 @@ class ChatwootMarkdownRenderer
|
||||
|
||||
def render_message
|
||||
markdown_renderer = BaseMarkdownRenderer.new
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough, :autolink])
|
||||
html = markdown_renderer.render(doc)
|
||||
render_as_html_safe(html)
|
||||
end
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.7",
|
||||
"@chatwoot/prosemirror-schema": "1.3.8",
|
||||
"@chatwoot/utils": "^0.0.52",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
|
||||
Generated
+5
-5
@@ -26,8 +26,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.7
|
||||
version: 1.3.7
|
||||
specifier: 1.3.8
|
||||
version: 1.3.8
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.52
|
||||
version: 0.0.52
|
||||
@@ -454,8 +454,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.7':
|
||||
resolution: {integrity: sha512-N+Gicecp18TSEJQoRtGZXkp8R+kC0iPSms8ezu1k8U+ySY9FAENzFQQ1rBVSSC4hDFwb9/EbSI9IFqDjHGds7g==}
|
||||
'@chatwoot/prosemirror-schema@1.3.8':
|
||||
resolution: {integrity: sha512-Vr8eUdydmVr7iRnNky4jXKX3XD4z5HAS4bV7zJXxA4av4ig5qjTldDOg7c/C8rqYNKGR5UEOEu9CQfGcjfKVXg==}
|
||||
|
||||
'@chatwoot/utils@0.0.52':
|
||||
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
|
||||
@@ -4966,7 +4966,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.7':
|
||||
'@chatwoot/prosemirror-schema@1.3.8':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.6.0
|
||||
|
||||
@@ -21,7 +21,7 @@ describe 'Markdown Embeds Configuration' do
|
||||
end
|
||||
|
||||
it 'contains expected embed types' do
|
||||
expected_types = %w[youtube loom vimeo mp4 arcade_tab arcade wistia bunny codepen github_gist]
|
||||
expected_types = %w[youtube loom vimeo mp4 arcade_tab arcade wistia bunny codepen guidejar github_gist]
|
||||
expect(config.keys).to match_array(expected_types)
|
||||
end
|
||||
end
|
||||
@@ -73,6 +73,12 @@ describe 'Markdown Embeds Configuration' do
|
||||
{ url: 'https://codepen.io/username/pen/abcdef', expected: { 'user' => 'username', 'pen_id' => 'abcdef' } },
|
||||
{ url: 'https://www.codepen.io/testuser/pen/xyz123', expected: { 'user' => 'testuser', 'pen_id' => 'xyz123' } }
|
||||
],
|
||||
'guidejar' => [
|
||||
{ url: 'https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA', expected: { 'guide_id' => 'i2qMQRp26rtRxpZczmaA' } },
|
||||
{ url: 'https://guidejar.com/guides/i2qMQRp26rtRxpZczmaA', expected: { 'guide_id' => 'i2qMQRp26rtRxpZczmaA' } },
|
||||
{ url: 'https://guidejar.com/guides/d6a6fdc2-4812-4777-897e-ec1b0c64238f',
|
||||
expected: { 'guide_id' => 'd6a6fdc2-4812-4777-897e-ec1b0c64238f' } }
|
||||
],
|
||||
'github_gist' => [
|
||||
{ url: 'https://gist.github.com/username/1234567890abcdef1234567890abcdef',
|
||||
expected: { 'username' => 'username', 'gist_id' => '1234567890abcdef1234567890abcdef' } },
|
||||
|
||||
@@ -13,6 +13,12 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /public/api/v1/portals/{portal_slug}' do
|
||||
it 'redirects to the portal default locale when locale is not present' do
|
||||
get "/hc/#{portal.slug}"
|
||||
|
||||
expect(response).to redirect_to("/hc/#{portal.slug}/#{portal.default_locale}")
|
||||
end
|
||||
|
||||
it 'Show portal and categories belonging to the portal' do
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
status: 'pending'
|
||||
)
|
||||
end
|
||||
let!(:documents) do
|
||||
create_list(
|
||||
:captain_document,
|
||||
2,
|
||||
assistant: assistant,
|
||||
account: account
|
||||
)
|
||||
end
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
@@ -98,6 +106,28 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when deleting documents' do
|
||||
let(:document_delete_params) do
|
||||
{
|
||||
type: 'AssistantDocument',
|
||||
ids: documents.map(&:id),
|
||||
fields: { status: 'delete' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'deletes the documents and returns an empty array' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: document_delete_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Captain::Document, :count).by(-2)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing parameters' do
|
||||
let(:missing_params) do
|
||||
{
|
||||
|
||||
@@ -281,6 +281,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
|
||||
allow(Stripe::InvoiceItem).to receive(:create)
|
||||
allow(Stripe::Invoice).to receive(:finalize_invoice)
|
||||
allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('open'))
|
||||
allow(Stripe::Invoice).to receive(:pay)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:create)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Public Help Center Access', type: :request do
|
||||
let(:plan_name) { 'Startups' }
|
||||
let!(:account) { create(:account, custom_attributes: { 'plan_name' => plan_name }) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:portal) { create(:portal, account: account, custom_domain: 'docs-helpcenter.example.com') }
|
||||
let!(:category) { create(:category, portal: portal, account: account, locale: 'en', slug: 'category-slug') }
|
||||
let!(:article) { create(:article, category: category, portal: portal, account: account, author: agent, status: :published) }
|
||||
|
||||
around do |example|
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com', HELPCENTER_URL: 'https://help.chatwoot.com' do
|
||||
previous_deployment_env = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')&.value
|
||||
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud')
|
||||
|
||||
example.run
|
||||
ensure
|
||||
config = InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize
|
||||
previous_deployment_env.present? ? config.update!(value: previous_deployment_env) : config.destroy!
|
||||
host! 'www.example.com'
|
||||
end
|
||||
end
|
||||
|
||||
it 'blocks chatwoot-hosted portal pages when the help center feature is disabled' do
|
||||
account.disable_features!(:help_center)
|
||||
host! 'help.chatwoot.com'
|
||||
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Help Center Unavailable')
|
||||
end
|
||||
|
||||
context 'when the account is on the default plan' do
|
||||
let(:plan_name) { 'Hacker' }
|
||||
|
||||
it 'still allows access if the feature flag is enabled' do
|
||||
account.enable_features!(:help_center)
|
||||
host! portal.custom_domain
|
||||
|
||||
get "/hc/#{portal.slug}/articles/#{article.slug}"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -37,6 +37,103 @@ RSpec.describe Inbox do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'member_ids_with_assignment_capacity with V2 capacity' do
|
||||
let(:account) { create(:account) }
|
||||
let(:v2_inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
|
||||
let(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
|
||||
|
||||
let!(:agent1) { create(:user, account: account, role: :agent, auto_offline: false) }
|
||||
let!(:agent2) { create(:user, account: account, role: :agent, auto_offline: false) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: v2_inbox, user: agent1)
|
||||
create(:inbox_member, inbox: v2_inbox, user: agent2)
|
||||
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return(
|
||||
agent1.id.to_s => 'online',
|
||||
agent2.id.to_s => 'online'
|
||||
)
|
||||
end
|
||||
|
||||
context 'when assignment_v2 is enabled with capacity policies' do
|
||||
before do
|
||||
account.enable_features('assignment_v2', 'advanced_assignment')
|
||||
account.save!
|
||||
|
||||
create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: v2_inbox, conversation_limit: 1)
|
||||
agent1.account_users.find_by(account: account).update!(agent_capacity_policy: agent_capacity_policy)
|
||||
agent2.account_users.find_by(account: account).update!(agent_capacity_policy: agent_capacity_policy)
|
||||
end
|
||||
|
||||
it 'filters out agents at capacity' do
|
||||
create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open)
|
||||
|
||||
result = v2_inbox.member_ids_with_assignment_capacity
|
||||
expect(result).to include(agent2.id)
|
||||
expect(result).not_to include(agent1.id)
|
||||
end
|
||||
|
||||
it 'filters out all agents when all are at capacity' do
|
||||
create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open)
|
||||
create(:conversation, inbox: v2_inbox, account: account, assignee: agent2, status: :open)
|
||||
|
||||
expect(v2_inbox.member_ids_with_assignment_capacity).to be_empty
|
||||
end
|
||||
|
||||
it 'skips V1 max_assignment_limit when V2 is enabled' do
|
||||
v2_inbox.update(auto_assignment_config: { max_assignment_limit: 100 })
|
||||
|
||||
create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open)
|
||||
|
||||
result = v2_inbox.member_ids_with_assignment_capacity
|
||||
expect(result).not_to include(agent1.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment_v2 is enabled without capacity policies' do
|
||||
before do
|
||||
account.enable_features('assignment_v2', 'advanced_assignment')
|
||||
account.save!
|
||||
end
|
||||
|
||||
it 'returns all online agents' do
|
||||
result = v2_inbox.member_ids_with_assignment_capacity
|
||||
expect(result).to contain_exactly(agent1.id, agent2.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when advanced_assignment is disabled (downgraded account with stale policies)' do
|
||||
before do
|
||||
account.enable_features('assignment_v2')
|
||||
account.save!
|
||||
|
||||
create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: v2_inbox, conversation_limit: 1)
|
||||
agent1.account_users.find_by(account: account).update!(agent_capacity_policy: agent_capacity_policy)
|
||||
|
||||
create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open)
|
||||
end
|
||||
|
||||
it 'does not enforce capacity limits' do
|
||||
result = v2_inbox.member_ids_with_assignment_capacity
|
||||
expect(result).to include(agent1.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment_v2 is disabled (V1 path)' do
|
||||
before do
|
||||
v2_inbox.update(auto_assignment_config: { max_assignment_limit: 2 })
|
||||
end
|
||||
|
||||
it 'uses V1 max_assignment_limit' do
|
||||
create_list(:conversation, 2, inbox: v2_inbox, account: account, assignee: agent1, status: :open)
|
||||
|
||||
result = v2_inbox.member_ids_with_assignment_capacity
|
||||
expect(result).not_to include(agent1.id)
|
||||
expect(result).to include(agent2.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'audit log' do
|
||||
context 'when inbox is created' do
|
||||
it 'has associated audit log created' do
|
||||
|
||||
+51
-23
@@ -7,6 +7,16 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
let!(:admin1) { create(:user, account: account, role: :administrator) }
|
||||
let(:admin2) { create(:user, account: account, role: :administrator) }
|
||||
let(:subscriptions_list) { double }
|
||||
let(:current_period_end) { 1_686_567_520 }
|
||||
let(:subscription_ends_on) { Time.zone.at(current_period_end).as_json }
|
||||
let(:created_subscription) do
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2,
|
||||
status: 'active',
|
||||
current_period_end: current_period_end
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
@@ -18,18 +28,44 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
)
|
||||
end
|
||||
|
||||
it 'preserves unrelated custom attributes, clears is_creating_customer, and reconciles default-plan features' do
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
'is_creating_customer' => true,
|
||||
'onboarding_source' => 'billing_page',
|
||||
'subscription_status' => 'past_due',
|
||||
'subscription_ends_on' => 1.day.ago
|
||||
}
|
||||
)
|
||||
account.enable_features!(:help_center)
|
||||
|
||||
customer = double
|
||||
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
||||
allow(customer).to receive(:id).and_return('cus_random_number')
|
||||
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(account.reload.custom_attributes).to include(
|
||||
'stripe_customer_id' => customer.id,
|
||||
'stripe_price_id' => 'price_random_number',
|
||||
'stripe_product_id' => 'prod_random_number',
|
||||
'subscribed_quantity' => 2,
|
||||
'plan_name' => 'A Plan Name',
|
||||
'onboarding_source' => 'billing_page',
|
||||
'subscription_status' => 'active',
|
||||
'subscription_ends_on' => subscription_ends_on
|
||||
)
|
||||
expect(account.custom_attributes).not_to have_key('is_creating_customer')
|
||||
expect(account).not_to be_feature_enabled('help_center')
|
||||
end
|
||||
|
||||
it 'does not call stripe methods if customer id is present' do
|
||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
|
||||
allow(subscriptions_list).to receive(:data).and_return([])
|
||||
allow(Stripe::Customer).to receive(:create)
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
||||
allow(Stripe::Subscription).to receive(:create)
|
||||
.and_return(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
@@ -44,7 +80,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
stripe_price_id: 'price_random_number',
|
||||
stripe_product_id: 'prod_random_number',
|
||||
subscribed_quantity: 2,
|
||||
plan_name: 'A Plan Name'
|
||||
plan_name: 'A Plan Name',
|
||||
subscription_status: 'active',
|
||||
subscription_ends_on: subscription_ends_on
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
@@ -53,14 +91,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
customer = double
|
||||
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
||||
allow(customer).to receive(:id).and_return('cus_random_number')
|
||||
allow(Stripe::Subscription)
|
||||
.to receive(:create)
|
||||
.and_return(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
@@ -75,7 +106,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
stripe_price_id: 'price_random_number',
|
||||
stripe_product_id: 'prod_random_number',
|
||||
subscribed_quantity: 2,
|
||||
plan_name: 'A Plan Name'
|
||||
plan_name: 'A Plan Name',
|
||||
subscription_status: 'active',
|
||||
subscription_ends_on: subscription_ends_on
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
@@ -96,12 +129,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
customer = double
|
||||
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
||||
allow(customer).to receive(:id).and_return('cus_random_number')
|
||||
allow(Stripe::Subscription).to receive(:create).and_return(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ describe Enterprise::Billing::TopupCheckoutService do
|
||||
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
|
||||
allow(Stripe::InvoiceItem).to receive(:create)
|
||||
allow(Stripe::Invoice).to receive(:finalize_invoice)
|
||||
allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('open'))
|
||||
allow(Stripe::Invoice).to receive(:pay)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:create)
|
||||
end
|
||||
@@ -58,5 +59,19 @@ describe Enterprise::Billing::TopupCheckoutService do
|
||||
expect(error.message).to eq(I18n.t('errors.topup.plan_not_eligible'))
|
||||
end
|
||||
end
|
||||
|
||||
it 'calls pay when invoice is open after finalization' do
|
||||
service.create_checkout_session(credits: 1000)
|
||||
|
||||
expect(Stripe::Invoice).to have_received(:pay).with('inv_test123')
|
||||
end
|
||||
|
||||
it 'skips pay when invoice is already paid via Stripe credits' do
|
||||
allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('paid'))
|
||||
|
||||
service.create_checkout_session(credits: 1000)
|
||||
|
||||
expect(Stripe::Invoice).not_to have_received(:pay)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# Simulate the prepend_mod_with behavior for testing
|
||||
test_klass = Class.new(WebsiteBrandingService) do
|
||||
prepend Enterprise::WebsiteBrandingService
|
||||
end
|
||||
|
||||
RSpec.describe Enterprise::WebsiteBrandingService do
|
||||
describe '#perform' do
|
||||
subject(:service) { test_klass.new(url) }
|
||||
|
||||
let(:url) { 'https://example.com' }
|
||||
let(:api_key) { 'test-firecrawl-api-key' }
|
||||
let(:scrape_endpoint) { described_class::FIRECRAWL_SCRAPE_ENDPOINT }
|
||||
let(:fallback_html) { '<html lang="en"><head><title>Fallback</title></head><body></body></html>' }
|
||||
let(:success_response_body) do
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
json: {
|
||||
business_name: 'Acme Corp',
|
||||
language: 'en',
|
||||
industry_category: 'Technology'
|
||||
},
|
||||
branding: {
|
||||
images: { logo: 'https://example.com/logo.png', favicon: 'https://example.com/favicon.png' },
|
||||
colors: { primary: '#FF5733' }
|
||||
},
|
||||
links: [
|
||||
'https://example.com/about',
|
||||
'https://facebook.com/acmecorp',
|
||||
'https://instagram.com/acme_corp',
|
||||
'https://wa.me/1234567890',
|
||||
'https://t.me/acmecorp',
|
||||
'https://tiktok.com/@acmetok'
|
||||
]
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, url).to_return(status: 200, body: fallback_html, headers: { 'content-type' => 'text/html' })
|
||||
end
|
||||
|
||||
context 'when firecrawl is configured and API returns success' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.with(headers: { 'Authorization' => "Bearer #{api_key}", 'Content-Type' => 'application/json' })
|
||||
.to_return(status: 200, body: success_response_body, headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'returns business info and branding from firecrawl' do
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq({
|
||||
business_name: 'Acme Corp',
|
||||
language: 'en',
|
||||
industry_category: 'Technology',
|
||||
social_handles: {
|
||||
whatsapp: '1234567890',
|
||||
line: nil,
|
||||
facebook: 'acmecorp',
|
||||
instagram: 'acme_corp',
|
||||
telegram: 'acmecorp',
|
||||
tiktok: '@acmetok'
|
||||
},
|
||||
branding: {
|
||||
favicon: 'https://example.com/favicon.png',
|
||||
primary_color: '#FF5733'
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when firecrawl API returns an error' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 422, body: '{"error": "Invalid URL"}', headers: {})
|
||||
end
|
||||
|
||||
it 'falls back to basic scrape' do
|
||||
result = service.perform
|
||||
expect(result[:business_name]).to eq('Fallback')
|
||||
expect(result[:industry_category]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when firecrawl raises an exception' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
stub_request(:post, scrape_endpoint).to_raise(StandardError.new('connection refused'))
|
||||
end
|
||||
|
||||
it 'falls back to basic scrape' do
|
||||
result = service.perform
|
||||
expect(result[:business_name]).to eq('Fallback')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when firecrawl is not configured' do
|
||||
it 'uses basic scrape' do
|
||||
expect(HTTParty).not_to receive(:post)
|
||||
result = service.perform
|
||||
expect(result[:business_name]).to eq('Fallback')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when WhatsApp link uses api.whatsapp.com format' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
response = {
|
||||
success: true,
|
||||
data: {
|
||||
json: { business_name: 'Acme Corp' },
|
||||
links: ['https://api.whatsapp.com/send?phone=5511999999999&text=Hello']
|
||||
}
|
||||
}.to_json
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'extracts phone number from query param' do
|
||||
result = service.perform
|
||||
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when WhatsApp link uses wa.me format' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
response = {
|
||||
success: true,
|
||||
data: {
|
||||
json: { business_name: 'Acme Corp' },
|
||||
links: ['https://wa.me/+5511999999999']
|
||||
}
|
||||
}.to_json
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'extracts phone number from path' do
|
||||
result = service.perform
|
||||
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when links contain lookalike domains' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
response = {
|
||||
success: true,
|
||||
data: {
|
||||
json: { business_name: 'Acme Corp' },
|
||||
links: ['https://notfacebook.com/page', 'https://fakeinstagram.com/user']
|
||||
}
|
||||
}.to_json
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'does not match lookalike domains' do
|
||||
result = service.perform
|
||||
expect(result[:social_handles][:facebook]).to be_nil
|
||||
expect(result[:social_handles][:instagram]).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,93 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::BulkAutoAssignmentJob do
|
||||
let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) }
|
||||
let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) }
|
||||
let(:assignment_service) { double }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(assignment_service).to receive(:perform)
|
||||
end
|
||||
|
||||
context 'when inbox has inbox members' do
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
account.enable_features!('assignment_v2')
|
||||
inbox.update!(enable_auto_assignment: true)
|
||||
end
|
||||
|
||||
it 'assigns unassigned conversations in enabled inboxes' do
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
).and_return(assignment_service)
|
||||
|
||||
described_class.perform_now
|
||||
expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
)
|
||||
end
|
||||
|
||||
it 'skips inboxes with auto assignment disabled' do
|
||||
inbox.update!(enable_auto_assignment: false)
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
)
|
||||
end
|
||||
|
||||
context 'when account is on default plan in chatwoot cloud' do
|
||||
before do
|
||||
account.update!(custom_attributes: {})
|
||||
InstallationConfig.create(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }])
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
end
|
||||
|
||||
it 'skips auto assignment' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}")
|
||||
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox has no members' do
|
||||
before do
|
||||
account.enable_features!('assignment_v2')
|
||||
inbox.update!(enable_auto_assignment: true)
|
||||
end
|
||||
|
||||
it 'does not assign conversations' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}")
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment_v2 feature is disabled' do
|
||||
before do
|
||||
account.disable_features!('assignment_v2')
|
||||
end
|
||||
|
||||
it 'skips auto assignment' do
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,11 +6,9 @@ RSpec.describe ChatwootMarkdownRenderer do
|
||||
let(:doc) { instance_double(CommonMarker::Node) }
|
||||
let(:renderer) { described_class.new(markdown_content) }
|
||||
let(:markdown_renderer) { instance_double(CustomMarkdownRenderer) }
|
||||
let(:base_markdown_renderer) { instance_double(BaseMarkdownRenderer) }
|
||||
let(:html_content) { '<p>This is a <em>test</em> content with <sup>markdown</sup></p>' }
|
||||
|
||||
before do
|
||||
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT, [:strikethrough]).and_return(doc)
|
||||
allow(CustomMarkdownRenderer).to receive(:new).and_return(markdown_renderer)
|
||||
allow(markdown_renderer).to receive(:render).with(doc).and_return(html_content)
|
||||
end
|
||||
@@ -64,22 +62,28 @@ RSpec.describe ChatwootMarkdownRenderer do
|
||||
end
|
||||
|
||||
describe '#render_message' do
|
||||
let(:message_html_content) { '<p>This is a <em>test</em> content with ^markdown^</p>' }
|
||||
let(:rendered_message) { renderer.render_message }
|
||||
|
||||
before do
|
||||
allow(CommonMarker).to receive(:render_html).with(markdown_content).and_return(message_html_content)
|
||||
allow(BaseMarkdownRenderer).to receive(:new).and_return(base_markdown_renderer)
|
||||
allow(base_markdown_renderer).to receive(:render).with(doc).and_return(message_html_content)
|
||||
allow(CommonMarker).to receive(:render_doc).and_call_original
|
||||
allow(BaseMarkdownRenderer).to receive(:new).and_call_original
|
||||
end
|
||||
|
||||
it 'renders the markdown message to html' do
|
||||
expect(rendered_message.to_s).to eq(message_html_content)
|
||||
expect(rendered_message.to_s).to eq("<p>This is a <em>test</em> content with ^markdown^</p>\n")
|
||||
end
|
||||
|
||||
it 'returns an html safe string' do
|
||||
expect(rendered_message).to be_html_safe
|
||||
end
|
||||
|
||||
context 'with bare URLs' do
|
||||
let(:markdown_content) { 'Visit https://example.com for details' }
|
||||
|
||||
it 'converts bare URLs to links' do
|
||||
expect(renderer.render_message.to_s).to eq("<p>Visit <a href=\"https://example.com\">https://example.com</a> for details</p>\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#render_markdown_to_plain_text' do
|
||||
|
||||
@@ -184,6 +184,32 @@ describe CustomMarkdownRenderer do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a GuideJar embed URL' do
|
||||
let(:guidejar_url) { 'https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA' }
|
||||
|
||||
it 'renders an iframe with GuideJar embed code' do
|
||||
output = render_markdown_link(guidejar_url)
|
||||
expect(output).to include('src="https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA?type=1&controls=on"')
|
||||
expect(output).to include('allowfullscreen')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a GuideJar guides URL' do
|
||||
let(:guidejar_url) { 'https://guidejar.com/guides/d6a6fdc2-4812-4777-897e-ec1b0c64238f' }
|
||||
|
||||
it 'renders an iframe with GuideJar embed code' do
|
||||
output = render_markdown_link(guidejar_url)
|
||||
expect(output).to include('src="https://www.guidejar.com/embed/d6a6fdc2-4812-4777-897e-ec1b0c64238f?type=1&controls=on"')
|
||||
expect(output).to include('allowfullscreen')
|
||||
end
|
||||
|
||||
it 'wraps iframe in responsive container' do
|
||||
output = render_markdown_link(guidejar_url)
|
||||
expect(output).to include('position: relative; padding-bottom: 62.5%; height: 0;')
|
||||
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a Bunny.net iframe URL' do
|
||||
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ RSpec.describe AccountEmailRateLimitable do
|
||||
|
||||
describe '#within_email_rate_limit?' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
account.update!(limits: { 'emails' => 2 })
|
||||
end
|
||||
|
||||
@@ -34,6 +35,28 @@ RSpec.describe AccountEmailRateLimitable do
|
||||
2.times { account.increment_email_sent_count }
|
||||
expect(account).not_to be_within_email_rate_limit
|
||||
end
|
||||
|
||||
context 'when self-hosted' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
2.times { account.increment_email_sent_count }
|
||||
end
|
||||
|
||||
it 'always returns true regardless of limit' do
|
||||
expect(account).to be_within_email_rate_limit
|
||||
end
|
||||
end
|
||||
|
||||
context 'when chatwoot cloud' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
2.times { account.increment_email_sent_count }
|
||||
end
|
||||
|
||||
it 'returns false when at limit' do
|
||||
expect(account).not_to be_within_email_rate_limit
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#increment_email_sent_count' do
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe WebsiteBrandingService do
|
||||
describe '#perform' do
|
||||
let(:url) { 'https://example.com' }
|
||||
let(:html_body) do
|
||||
<<~HTML
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Acme Corp | Home</title>
|
||||
<meta property="og:site_name" content="Acme Corp" />
|
||||
<meta property="og:image" content="https://example.com/og-image.png" />
|
||||
<meta name="theme-color" content="#FF5733" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<header><a href="/">Home</a></header>
|
||||
<footer>
|
||||
<a href="https://facebook.com/acmecorp">Facebook</a>
|
||||
<a href="https://instagram.com/acme_corp">Instagram</a>
|
||||
<a href="https://wa.me/1234567890">WhatsApp</a>
|
||||
<a href="https://t.me/acmecorp">Telegram</a>
|
||||
<a href="https://tiktok.com/@acmetok">TikTok</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, url).to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' })
|
||||
end
|
||||
|
||||
it 'extracts business info, branding, and social handles' do
|
||||
result = described_class.new(url).perform
|
||||
|
||||
expect(result).to eq({
|
||||
business_name: 'Acme Corp',
|
||||
language: 'en',
|
||||
industry_category: nil,
|
||||
social_handles: {
|
||||
whatsapp: '1234567890',
|
||||
line: nil,
|
||||
facebook: 'acmecorp',
|
||||
instagram: 'acme_corp',
|
||||
telegram: 'acmecorp',
|
||||
tiktok: '@acmetok'
|
||||
},
|
||||
branding: {
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
primary_color: '#FF5733'
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
context 'when og:site_name is missing' do
|
||||
let(:html_body) do
|
||||
<<~HTML
|
||||
<html lang="fr">
|
||||
<head><title>Mon Entreprise - Bienvenue</title></head>
|
||||
<body></body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
it 'falls back to the first segment of the title' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:business_name]).to eq('Mon Entreprise')
|
||||
expect(result[:language]).to eq('fr')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the page fails to load' do
|
||||
before { stub_request(:get, url).to_return(status: 500, body: '') }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(described_class.new(url).perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a network error occurs' do
|
||||
before { stub_request(:get, url).to_raise(StandardError.new('connection refused')) }
|
||||
|
||||
it 'logs the error and returns nil' do
|
||||
expect(Rails.logger).to receive(:error).with(/connection refused/)
|
||||
expect(described_class.new(url).perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when URL has no scheme' do
|
||||
before do
|
||||
stub_request(:get, 'https://example.com').to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' })
|
||||
end
|
||||
|
||||
it 'prepends https://' do
|
||||
result = described_class.new('example.com').perform
|
||||
expect(result[:business_name]).to eq('Acme Corp')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when WhatsApp link uses api.whatsapp.com format' do
|
||||
let(:html_body) do
|
||||
<<~HTML
|
||||
<html lang="en">
|
||||
<head><title>Test</title></head>
|
||||
<body><a href="https://api.whatsapp.com/send?phone=5511999999999&text=Hello">Chat</a></body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
it 'extracts phone from query param' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when links contain lookalike domains' do
|
||||
let(:html_body) do
|
||||
<<~HTML
|
||||
<html lang="en">
|
||||
<head><title>Test</title></head>
|
||||
<body>
|
||||
<a href="https://notfacebook.com/page">Not FB</a>
|
||||
<a href="https://fakeinstagram.com/user">Not IG</a>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
it 'does not match lookalike domains' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:social_handles][:facebook]).to be_nil
|
||||
expect(result[:social_handles][:instagram]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when favicon uses a relative path without leading slash' do
|
||||
let(:html_body) do
|
||||
<<~HTML
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Test</title>
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
it 'resolves the relative favicon URL' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:branding][:favicon]).to eq('https://example.com/favicon.ico')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
tags:
|
||||
- Messages API
|
||||
operationId: list-all-converation-messages
|
||||
operationId: list-all-conversation-messages
|
||||
summary: List all messages
|
||||
description: List all messages in the conversation
|
||||
security: []
|
||||
|
||||
@@ -1366,7 +1366,7 @@
|
||||
"tags": [
|
||||
"Messages API"
|
||||
],
|
||||
"operationId": "list-all-converation-messages",
|
||||
"operationId": "list-all-conversation-messages",
|
||||
"summary": "List all messages",
|
||||
"description": "List all messages in the conversation",
|
||||
"security": [],
|
||||
|
||||
@@ -536,7 +536,7 @@
|
||||
"tags": [
|
||||
"Messages API"
|
||||
],
|
||||
"operationId": "list-all-converation-messages",
|
||||
"operationId": "list-all-conversation-messages",
|
||||
"summary": "List all messages",
|
||||
"description": "List all messages in the conversation",
|
||||
"security": [],
|
||||
|
||||
Reference in New Issue
Block a user