Compare commits

..
Author SHA1 Message Date
Tanmay Sharma 1cdd37be99 Merge remote-tracking branch 'origin/captain/pdf_support_fe' into captain/pdf_support_merged 2025-08-27 16:39:23 +07:00
Muhsin KelothandGitHub 021aafac0a Merge branch 'develop' into captain/pdf_support_fe 2025-08-27 12:29:45 +05:30
Tanmay Sharma ce5d251bd4 Merge remote-tracking branch 'origin/develop' into captain/pdf_support_be 2025-08-27 13:37:49 +07:00
Tanmay Deep Sharma 0f82b11128 self review changes 2025-08-20 23:49:29 +05:30
Tanmay Deep SharmaandGitHub efe5609ba4 Merge branch 'develop' into captain/pdf_support_fe 2025-08-20 20:50:27 +05:30
Tanmay Deep Sharma ba5ae0dfd9 fix rspec 2025-08-20 14:47:34 +05:30
Tanmay Deep Sharma ce7847364b self review changes 2025-08-20 13:46:57 +05:30
Tanmay Deep Sharma 6dd73972f6 Merge remote-tracking branch 'origin/develop' into captain/pdf_support_be 2025-08-20 13:30:54 +05:30
Tanmay Deep Sharma 4c406a6aeb update pdf processing rspec 2025-08-19 19:45:33 +05:30
Tanmay Deep Sharma 06dce1691a fix rubocop 2025-08-19 15:54:57 +05:30
Tanmay Deep Sharma 2a0873155f Merge remote-tracking branch 'origin/develop' into captain/pdf_support_be 2025-08-19 11:52:57 +05:30
Tanmay Deep Sharma 62b01fba6f self review changes 2025-08-18 21:18:59 +05:30
Tanmay Sharma b50ed7d5b6 fix the rspec related to pdf_document 2025-08-13 21:34:37 +05:30
Tanmay Sharma 3beb7950fc fix the rspec related to installation config 2025-08-12 14:37:55 +05:30
Tanmay Sharma e580fd8d21 fix the rspec 2025-08-12 13:46:21 +05:30
Tanmay Sharma 18bb006d58 fix the rspec 2025-08-12 12:54:54 +05:30
Tanmay Sharma c56df32e7f Merge remote-tracking branch 'origin/develop' into captain/pdf_support_be 2025-08-12 12:15:34 +05:30
Tanmay Sharma efa3236bdd Merge remote-tracking branch 'origin/develop' into captain/pdf_support_be 2025-08-11 21:20:02 +05:30
Tanmay Sharma efc3de165a self review 2025-08-11 21:19:06 +05:30
Tanmay Deep Sharma d8c0da6015 add test cases 2025-08-06 19:08:30 +05:30
Tanmay Deep Sharma 1b54513a03 move system prompt to user prompt for better accuracy 2025-08-06 16:13:33 +05:30
Tanmay Deep Sharma 4939508da6 update the helper text to 10 MB 2025-08-06 16:12:51 +05:30
Tanmay Deep Sharma c310fe2f76 update the helper text to 20 MB 2025-08-06 13:33:06 +05:30
Tanmay Deep Sharma 693bed20df self review changes for pdf support for captain 2025-08-06 13:05:14 +05:30
Tanmay Deep Sharma a56c4a5f8c self review changes for pdf support for captain 2025-08-06 12:44:39 +05:30
Tanmay Deep Sharma 7317b95441 feat: add be changes for captain pdf support for faq generation 2025-08-06 12:22:20 +05:30
Tanmay Deep Sharma 8627400e6a feat: add fe changes for captain pdf support for faq generation 2025-08-06 12:07:52 +05:30
79 changed files with 600 additions and 2162 deletions
+22 -14
View File
@@ -1,7 +1,6 @@
version: 2.1
orbs:
node: circleci/node@6.1.0
qlty-orb: qltysh/qlty-orb@0.0
defaults: &defaults
working_directory: ~/build
@@ -90,6 +89,14 @@ jobs:
command: |
source ~/.rvm/scripts/rvm
bundle install
# pnpm install
- run:
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
# Swagger verification
- run:
@@ -101,11 +108,10 @@ jobs:
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# Configure environment and database
# we remove the FRONTED_URL from the .env before running the tests
- run:
name: Database Setup and Configure Environment Variables
command: |
@@ -143,11 +149,17 @@ jobs:
command: pnpm run eslint
- run:
name: Run frontend tests (with coverage)
name: Run frontend tests
command: |
mkdir -p ~/build/coverage/frontend
~/tmp/cc-test-reporter before-build
pnpm run test:coverage
- run:
name: Code Climate Test Coverage (Frontend)
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests
- run:
name: Run backend tests
@@ -155,18 +167,18 @@ jobs:
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
bundle exec rspec --format progress \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
# Qlty coverage publish
- qlty-orb/coverage_publish:
files: |
coverage/coverage.json
coverage/lcov.info
- run:
name: Code Climate Test Coverage (Backend)
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
- run:
name: List coverage directory contents
@@ -177,7 +189,3 @@ jobs:
root: ~/build
paths:
- coverage
- store_artifacts:
path: coverage
destination: coverage
+1
View File
@@ -16,6 +16,7 @@ Metrics/ClassLength:
Exclude:
- 'app/models/message.rb'
- 'app/models/conversation.rb'
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
Metrics/MethodLength:
Max: 19
+1 -6
View File
@@ -62,10 +62,6 @@ gem 'redis-namespace'
# super fast record imports in bulk
gem 'activerecord-import'
gem 'searchkick'
gem 'opensearch-ruby'
gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
@@ -227,7 +223,6 @@ group :test do
gem 'webmock'
# test profiling
gem 'test-prof'
gem 'simplecov_json_formatter', require: false
end
group :development, :test do
@@ -252,7 +247,7 @@ group :development, :test do
gem 'rubocop-factory_bot', require: false
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false
gem 'simplecov', '0.17.1', require: false
gem 'spring'
gem 'spring-watcher-listen'
end
+7 -21
View File
@@ -219,7 +219,7 @@ GEM
diff-lcs (1.5.1)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.1)
docile (1.4.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (3.1.2)
@@ -299,9 +299,6 @@ GEM
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
faraday (>= 2.0, < 3)
fast-mcp (1.5.0)
addressable (~> 2.8)
base64
@@ -447,7 +444,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.13.2)
json (2.12.0)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -604,9 +601,6 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -808,9 +802,6 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
searchkick (5.5.2)
activemodel (>= 7.1)
hashie
securerandom (0.4.1)
seed_dump (3.3.1)
activerecord (>= 4)
@@ -857,12 +848,11 @@ GEM
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simplecov (0.22.0)
simplecov (0.17.1)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
slack-ruby-client (2.5.2)
faraday (>= 2.0)
faraday-mashify
@@ -1006,7 +996,6 @@ DEPENDENCIES
facebook-messenger
factory_bot_rails (>= 6.4.3)
faker
faraday_middleware-aws-sigv4
fcm
flag_shih_tzu
foreman
@@ -1047,7 +1036,6 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
opensearch-ruby
pg
pg_search
pgvector
@@ -1076,7 +1064,6 @@ DEPENDENCIES
ruby_llm-schema
scout_apm
scss_lint
searchkick
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
@@ -1086,8 +1073,7 @@ DEPENDENCIES
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
simplecov (= 0.17.1)
slack-ruby-client (~> 2.5.2)
spring
spring-watcher-listen
-4
View File
@@ -4,7 +4,3 @@
require_relative 'config/application'
Rails.application.load_tasks
# Load Enterprise Edition rake tasks if they exist
enterprise_tasks_path = Rails.root.join('enterprise/lib/tasks.rb').to_s
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
-11
View File
@@ -74,17 +74,6 @@ module PortalHelper
end
end
def generate_portal_brand_url(brand_url, referer)
url = URI.parse(brand_url.to_s)
query_params = Rack::Utils.parse_query(url.query)
query_params['utm_medium'] = 'helpcenter'
query_params['utm_campaign'] = 'branding'
query_params['utm_source'] = URI.parse(referer).host if referer.present? && referer.match?(URI::DEFAULT_PARSER.make_regexp)
url.query = query_params.to_query
url.to_s
end
def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
@@ -11,14 +11,12 @@ import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
const props = defineProps({
attachedFiles: { type: Array, default: () => [] },
isWhatsappInbox: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
isTwilioWhatsAppInbox: { type: Boolean, default: false },
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
@@ -34,7 +32,6 @@ const emit = defineEmits([
'discard',
'sendMessage',
'sendWhatsappMessage',
'sendTwilioMessage',
'insertEmoji',
'addSignature',
'removeSignature',
@@ -66,20 +63,6 @@ const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(props.channelType);
});
const showTwilioContentTemplates = computed(() => {
return props.isTwilioWhatsAppInbox && props.inboxId;
});
const shouldShowEmojiButton = computed(() => {
return (
!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox && !props.hasNoInbox
);
});
const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const setSignature = () => {
if (signatureToApply.value) {
if (sendWithSignature.value) {
@@ -142,7 +125,7 @@ const keyboardEvents = {
action: () => {
if (
isEditorHotKeyEnabled('enter') &&
isRegularMessageMode.value &&
!props.isWhatsappInbox &&
!props.isDropdownActive
) {
emit('sendMessage');
@@ -153,7 +136,7 @@ const keyboardEvents = {
action: () => {
if (
isEditorHotKeyEnabled('cmd_enter') &&
isRegularMessageMode.value &&
!props.isWhatsappInbox &&
!props.isDropdownActive
) {
emit('sendMessage');
@@ -175,13 +158,8 @@ useKeyboardEvents(keyboardEvents);
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<ContentTemplateSelector
v-if="showTwilioContentTemplates"
:inbox-id="inboxId"
@send-message="emit('sendTwilioMessage', $event)"
/>
<div
v-if="shouldShowEmojiButton"
v-if="!isWhatsappInbox && !hasNoInbox"
v-on-click-outside="() => (isEmojiPickerOpen = false)"
class="relative"
>
@@ -194,7 +172,7 @@ useKeyboardEvents(keyboardEvents);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -221,7 +199,7 @@ useKeyboardEvents(keyboardEvents);
/>
</FileUpload>
<Button
v-if="hasSelectedInbox && isRegularMessageMode"
v-if="hasSelectedInbox && !isWhatsappInbox"
icon="i-lucide-signature"
color="slate"
size="sm"
@@ -240,7 +218,7 @@ useKeyboardEvents(keyboardEvents);
@click="emit('discard')"
/>
<Button
v-if="isRegularMessageMode"
v-if="!isWhatsappInbox"
:label="sendButtonLabel"
size="sm"
class="!text-xs font-medium"
@@ -74,9 +74,6 @@ const inboxTypes = computed(() => ({
isTwilioSMS:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'sms',
isTwilioWhatsapp:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'whatsapp',
}));
const whatsappMessageTemplates = computed(() =>
@@ -264,28 +261,6 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
isFromWhatsApp: true,
});
};
const handleSendTwilioMessage = async ({ message, templateParams }) => {
const twilioMessagePayload = prepareWhatsAppMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
templateParams,
currentUser: props.currentUser,
});
await emit('createConversation', {
payload: twilioMessagePayload,
isFromWhatsApp: true,
});
};
const shouldShowMessageEditor = computed(() => {
return (
!inboxTypes.value.isWhatsapp &&
!showNoInboxAlert.value &&
!inboxTypes.value.isTwilioWhatsapp
);
});
</script>
<template>
@@ -336,7 +311,7 @@ const shouldShowMessageEditor = computed(() => {
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@@ -356,7 +331,6 @@ const shouldShowMessageEditor = computed(() => {
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:is-loading="isCreating"
@@ -373,7 +347,6 @@ const shouldShowMessageEditor = computed(() => {
@discard="$emit('discard')"
@send-message="handleSendMessage"
@send-whatsapp-message="handleSendWhatsappMessage"
@send-twilio-message="handleSendTwilioMessage"
/>
</div>
</template>
@@ -1,56 +0,0 @@
<script setup>
import ContentTemplateParser from 'dashboard/components-next/content-templates/ContentTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleBack = () => {
emit('back');
};
</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="w-full">
<ContentTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
>
<template #actions="{ sendMessage, goBack, disabled }">
<div class="flex gap-3 justify-between items-end w-full h-14">
<Button
:label="t('CONTENT_TEMPLATES.FORM.BACK_BUTTON')"
color="slate"
variant="faded"
class="w-full font-medium"
@click="goBack"
/>
<Button
:label="t('CONTENT_TEMPLATES.FORM.SEND_MESSAGE_BUTTON')"
class="w-full font-medium"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</ContentTemplateParser>
</div>
</div>
</template>
@@ -1,124 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
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 ContentTemplateForm from './ContentTemplateForm.vue';
const props = defineProps({
inboxId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
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);
return inboxData?.content_templates?.templates || [];
});
const filteredTemplates = computed(() => {
return contentTemplates.value.filter(
template =>
template.friendly_name
.toLowerCase()
.includes(searchQuery.value.toLowerCase()) &&
template.status === 'approved'
);
});
const handleTriggerClick = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
emit('sendMessage', template);
selectedTemplate.value = null;
};
</script>
<template>
<div class="relative">
<Button
icon="i-ph-whatsapp-logo"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
color="slate"
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>
<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>
<ContentTemplateForm
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
</template>
@@ -25,7 +25,7 @@ export const generateLabelForContactableInboxesList = ({
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
) {
return phoneNumber ? `${name} (${phoneNumber})` : name;
return `${name} (${phoneNumber})`;
}
return name;
};
@@ -53,7 +53,6 @@ const transformInbox = ({
email,
phoneNumber,
channelType,
medium,
...rest,
});
@@ -63,6 +63,20 @@ const menuItems = computed(() => {
const createdAt = computed(() => dynamicTime(props.createdAt));
const isPdfDocument = computed(() => props.externalLink?.startsWith('PDF:'));
const displayLink = computed(() => {
if (isPdfDocument.value) {
// Remove 'PDF: ' prefix and timestamp suffix for display
const fullName = props.externalLink.substring(5);
// Remove timestamp suffix (_YYYYMMDDHHMMSS) if present
return fullName.replace(/_\d{14}$/, '');
}
return props.externalLink;
});
const linkIcon = computed(() =>
isPdfDocument.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
@@ -106,8 +120,8 @@ const handleAction = ({ action, value }) => {
<span
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1"
>
<i class="i-ph-link-simple shrink-0" />
<span class="truncate">{{ externalLink }}</span>
<i :class="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
{{ createdAt }}
@@ -23,7 +23,9 @@ const handleSubmit = async newDocument => {
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
error?.response?.data?.message ||
error?.response?.message ||
t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
@@ -1,9 +1,10 @@
<script setup>
import { reactive, computed } from 'vue';
import { reactive, computed, ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, url } from '@vuelidate/validators';
import { required, minLength, requiredIf } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -20,14 +21,25 @@ const formState = {
const initialState = {
name: '',
url: '',
assistantId: null,
documentType: 'url',
pdfFile: null,
};
const state = reactive({ ...initialState });
const fileInputRef = ref(null);
const validationRules = {
url: { required, url, minLength: minLength(1) },
url: {
required: requiredIf(() => state.documentType === 'url'),
url: requiredIf(() => state.documentType === 'url'),
minLength: minLength(1),
},
assistantId: { required },
pdfFile: {
required: requiredIf(() => state.documentType === 'pdf'),
},
};
const assistantList = computed(() =>
@@ -50,14 +62,57 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({
url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
}));
const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({
external_link: state.url,
assistant_id: state.assistantId,
});
const handleFileChange = event => {
const file = event.target.files[0];
if (file) {
if (file.type !== 'application/pdf') {
useAlert(t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.INVALID_TYPE'));
event.target.value = '';
return;
}
if (file.size > 10 * 1024 * 1024) {
// 10MB
useAlert(t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.TOO_LARGE'));
event.target.value = '';
return;
}
state.pdfFile = file;
state.name = file.name.replace('.pdf', '');
}
};
const openFileDialog = () => {
// Use nextTick to ensure the ref is available
nextTick(() => {
if (fileInputRef.value) {
fileInputRef.value.click();
}
});
};
const prepareDocumentDetails = () => {
const formData = new FormData();
formData.append('document[assistant_id]', state.assistantId);
if (state.documentType === 'url') {
formData.append('document[external_link]', state.url);
formData.append('document[name]', state.name || state.url);
} else {
formData.append('document[pdf_file]', state.pdfFile);
formData.append(
'document[name]',
state.name || state.pdfFile.name.replace('.pdf', '')
);
// No need to send external_link for PDF - it's auto-generated in the backend
}
return formData;
};
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
@@ -71,13 +126,102 @@ const handleSubmit = async () => {
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.TYPE.LABEL') }}
</label>
<div class="grid grid-cols-2 gap-3 p-1 bg-n-slate-3 rounded-lg">
<button
type="button"
class="relative flex items-center justify-center gap-2 px-4 py-2.5 rounded-md font-medium text-sm transition-all duration-200"
:class="
state.documentType === 'url'
? 'bg-n-white text-n-slate-12 shadow-sm'
: 'text-n-slate-11 hover:text-n-slate-12'
"
@click="state.documentType = 'url'"
>
<i class="i-ph-link-simple text-base" />
<span>{{ t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') }}</span>
</button>
<button
type="button"
class="relative flex items-center justify-center gap-2 px-4 py-2.5 rounded-md font-medium text-sm transition-all duration-200"
:class="
state.documentType === 'pdf'
? 'bg-n-white text-n-slate-12 shadow-sm'
: 'text-n-slate-11 hover:text-n-slate-12'
"
@click="state.documentType = 'pdf'"
>
<i class="i-ph-file-pdf text-base" />
<span>{{ t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') }}</span>
</button>
</div>
</div>
<Input
v-if="state.documentType === 'url'"
v-model="state.url"
:label="t('CAPTAIN.DOCUMENTS.FORM.URL.LABEL')"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.URL.PLACEHOLDER')"
:message="formErrors.url"
:message-type="formErrors.url ? 'error' : 'info'"
/>
<div v-if="state.documentType === 'pdf'" class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.LABEL') }}
</label>
<div class="relative">
<input
ref="fileInputRef"
type="file"
accept=".pdf"
class="hidden"
@change="handleFileChange"
/>
<button
type="button"
class="flex items-center gap-3 px-4 py-3 border border-n-slate-6 rounded-lg cursor-pointer hover:border-n-slate-8 transition-colors w-full text-left"
:class="state.pdfFile ? 'bg-n-slate-2' : 'bg-n-white'"
@click="openFileDialog"
>
<div
class="flex items-center justify-center w-10 h-10 bg-n-slate-3 rounded-lg"
>
<i class="i-ph-file-pdf text-xl text-n-slate-11" />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-n-slate-12">
{{
state.pdfFile
? state.pdfFile.name
: t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.CHOOSE_FILE')
}}
</p>
<p class="text-xs text-n-slate-11">
{{
state.pdfFile
? `${(state.pdfFile.size / 1024 / 1024).toFixed(2)} MB`
: t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.HELP_TEXT')
}}
</p>
</div>
<i class="i-lucide-upload text-n-slate-11" />
</button>
</div>
<p v-if="formErrors.pdfFile" class="text-sm text-n-red-11">
{{ formErrors.pdfFile }}
</p>
</div>
<Input
v-model="state.name"
:label="t('CAPTAIN.DOCUMENTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
/>
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
@@ -1,278 +0,0 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
template: {
type: Object,
default: () => ({}),
validator: value => {
if (!value || typeof value !== 'object') return false;
if (!value.friendly_name) return false;
return true;
},
},
});
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
const VARIABLE_PATTERN = /{{([^}]+)}}/g;
const { t } = useI18n();
const processedParams = ref({});
const languageLabel = computed(() => {
return `${t('CONTENT_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`;
});
const categoryLabel = computed(() => {
return `${t('CONTENT_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'utility'}`;
});
const templateBody = computed(() => {
return props.template.body || '';
});
const hasMediaTemplate = computed(() => {
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
});
const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
});
const mediaVariableKey = computed(() => {
if (!hasMediaTemplate.value) return null;
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
if (!mediaUrl) return null;
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
});
const hasMediaVariable = computed(() => {
return hasMediaTemplate.value && mediaVariableKey.value !== null;
});
const templateMediaUrl = computed(() => {
if (!hasMediaTemplate.value) return '';
return props.template?.types?.['twilio/media']?.media?.[0] || '';
});
const variablePattern = computed(() => {
if (!hasVariables.value) return [];
const matches = templateBody.value.match(VARIABLE_PATTERN) || [];
return matches.map(match => match.replace(/[{}]/g, ''));
});
const renderedTemplate = computed(() => {
let rendered = templateBody.value;
if (processedParams.value && Object.keys(processedParams.value).length > 0) {
// Replace variables in the format {{1}}, {{2}}, etc.
rendered = rendered.replace(VARIABLE_PATTERN, (match, variable) => {
const cleanVariable = variable.trim();
return processedParams.value[cleanVariable] || match;
});
}
return rendered;
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaVariable.value) return false;
if (hasVariables.value) {
const hasEmptyVariable = variablePattern.value.some(
variable => !processedParams.value[variable]
);
if (hasEmptyVariable) return true;
}
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
!processedParams.value[mediaVariableKey.value]
) {
return true;
}
return false;
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(
() => hasVariables.value || hasMediaVariable.value
),
},
},
{ processedParams }
);
const initializeTemplateParameters = () => {
processedParams.value = {};
if (hasVariables.value) {
variablePattern.value.forEach(variable => {
processedParams.value[variable] = '';
});
}
if (hasMediaVariable.value && mediaVariableKey.value) {
processedParams.value[mediaVariableKey.value] = '';
}
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid || isFormInvalid.value) return;
const { friendly_name, language } = props.template;
// Process parameters and extract filename from media URL if needed
const processedParameters = { ...processedParams.value };
// For media templates, extract filename from full URL
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
processedParameters[mediaVariableKey.value]
) {
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
processedParameters[mediaVariableKey.value]
);
}
const payload = {
message: renderedTemplate.value,
templateParams: {
name: friendly_name,
language,
processed_params: processedParameters,
},
};
emit('sendMessage', payload);
};
const resetTemplate = () => {
emit('resetTemplate');
};
const goBack = () => {
emit('back');
};
onMounted(initializeTemplateParameters);
watch(
() => props.template,
() => {
initializeTemplateParameters();
v$.value.$reset();
},
{ deep: true }
);
defineExpose({
processedParams,
hasVariables,
hasMediaTemplate,
renderedTemplate,
v$,
sendMessage,
resetTemplate,
goBack,
});
</script>
<template>
<div>
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ template.friendly_name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ languageLabel }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ renderedTemplate }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ categoryLabel }}
</div>
</div>
<div v-if="hasVariables || hasMediaVariable">
<!-- Media URL for media templates -->
<div v-if="hasMediaVariable" class="mb-4">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_LABEL') }}
</p>
<div class="flex items-center mb-2.5">
<Input
v-model="processedParams[mediaVariableKey]"
type="url"
class="flex-1"
:placeholder="
templateMediaUrl ||
t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_PLACEHOLDER')
"
/>
</div>
</div>
<!-- Body Variables Section -->
<div v-if="hasVariables">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('CONTENT_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="variable in variablePattern"
:key="`variable-${variable}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams[variable]"
type="text"
class="flex-1"
:placeholder="
t('CONTENT_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
variable: variable,
})
"
/>
</div>
</div>
<p
v-if="v$.$dirty && (v$.$invalid || isFormInvalid)"
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
>
{{ $t('CONTENT_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<slot
name="actions"
:send-message="sendMessage"
:reset-template="resetTemplate"
:go-back="goBack"
:is-valid="!v$.$invalid && !isFormInvalid"
:disabled="isFormInvalid"
/>
</div>
</template>
@@ -1,49 +1,25 @@
<script setup>
import { ref, nextTick, onMounted } from 'vue';
import { ref } from 'vue';
const emit = defineEmits(['send']);
const message = ref('');
const textareaRef = ref(null);
const adjustHeight = () => {
if (!textareaRef.value) return;
// Reset height to auto to get the correct scrollHeight
textareaRef.value.style.height = 'auto';
// Set the height to the scrollHeight
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`;
};
const sendMessage = () => {
if (message.value.trim()) {
emit('send', message.value);
message.value = '';
// Reset textarea height after sending
nextTick(() => {
adjustHeight();
});
}
};
const handleInput = () => {
nextTick(adjustHeight);
};
onMounted(() => {
nextTick(adjustHeight);
});
</script>
<template>
<form class="relative" @submit.prevent="sendMessage">
<textarea
ref="textareaRef"
<input
v-model="message"
type="text"
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1"
@input="handleInput"
@keydown.enter.exact.prevent="sendMessage"
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11"
@keyup.enter="sendMessage"
/>
<button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
@@ -128,7 +128,7 @@ const menuItems = computed(() => {
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
count: 'notifications/getUnreadCount',
badge: 'notifications/getHasUnreadNotifications',
},
},
{
@@ -1,5 +1,4 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import Icon from 'next/icon/Icon.vue';
@@ -17,16 +16,12 @@ const props = defineProps({
const emit = defineEmits(['toggle']);
const showBadge = useMapGetter(props.getterKeys.badge);
const dynamicCount = useMapGetter(props.getterKeys.count);
const count = computed(() =>
dynamicCount.value > 99 ? '99+' : dynamicCount.value
);
</script>
<template>
<component
:is="to ? 'router-link' : 'div'"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 min-w-0"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8"
role="button"
draggable="false"
:to="to"
@@ -45,21 +40,9 @@ const count = computed(() =>
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<div class="flex items-center gap-1.5 flex-grow min-w-0">
<span class="text-sm font-medium leading-5 truncate">
{{ label }}
</span>
<span
v-if="dynamicCount && !expandable"
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
:class="{
'text-n-blue-text outline-n-slate-6': isActive,
'text-n-slate-11 outline-n-strong': !isActive,
}"
>
{{ count }}
</span>
</div>
<span class="text-sm font-medium leading-5 flex-grow">
{{ label }}
</span>
<span
v-if="expandable"
v-show="isExpanded"
@@ -1,6 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import { useMapGetter } from 'dashboard/composables/store';
@@ -101,24 +100,20 @@ const handleReset = () => {
};
const sendMessage = async message => {
try {
if (selectedCopilotThreadId.value) {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
threadId: selectedCopilotThreadId.value,
message,
});
} else {
const response = await store.dispatch('copilotThreads/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
message,
});
selectedCopilotThreadId.value = response.id;
}
} catch (error) {
useAlert(error.message);
if (selectedCopilotThreadId.value) {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
threadId: selectedCopilotThreadId.value,
message,
});
} else {
const response = await store.dispatch('copilotThreads/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
message,
});
selectedCopilotThreadId.value = response.id;
}
};
@@ -95,10 +95,6 @@ export default {
type: Boolean,
default: false,
},
enableContentTemplates: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
required: true,
@@ -125,7 +121,6 @@ export default {
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
],
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
@@ -352,15 +347,6 @@ export default {
sm
@click="$emit('selectWhatsappTemplate')"
/>
<NextButton
v-if="enableContentTemplates"
v-tooltip.top-end="'Content Templates'"
icon="i-ph-whatsapp-logo"
slate
faded
sm
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
@@ -375,7 +361,7 @@ export default {
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="text-2xl break-words text-n-slate-12">
@@ -1,97 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import TemplatesPicker from './ContentTemplatesPicker.vue';
import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSend', 'cancel', 'update:show']);
const { t } = useI18n();
const selectedContentTemplate = ref(null);
const localShow = computed({
get() {
return props.show;
},
set(value) {
emit('update:show', value);
},
});
const modalHeaderContent = computed(() => {
return selectedContentTemplate.value
? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
templateName: selectedContentTemplate.value.friendly_name,
})
: t('CONTENT_TEMPLATES.MODAL.SUBTITLE');
});
const pickTemplate = template => {
selectedContentTemplate.value = template;
};
const onResetTemplate = () => {
selectedContentTemplate.value = null;
};
const onSendMessage = message => {
emit('onSend', message);
};
const onClose = () => {
emit('cancel');
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
<woot-modal-header
:header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')"
:header-content="modalHeaderContent"
/>
<div class="px-8 py-6 row">
<TemplatesPicker
v-if="!selectedContentTemplate"
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<TemplateParser
v-else
:template="selectedContentTemplate"
@reset-template="onResetTemplate"
@send-message="onSendMessage"
>
<template #actions="{ sendMessage, resetTemplate, disabled }">
<div class="flex gap-2 mt-6">
<Button
:label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')"
color="slate"
variant="faded"
class="flex-1"
@click="resetTemplate"
/>
<Button
:label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
class="flex-1"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</TemplateParser>
</div>
</woot-modal>
</template>
@@ -1,169 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
const props = defineProps({
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSelect']);
const { t } = useI18n();
const store = useStore();
const query = ref('');
const isRefreshing = ref(false);
const twilioTemplates = computed(() => {
const inbox = store.getters['inboxes/getInbox'](props.inboxId);
return inbox?.content_templates?.templates || [];
});
const filteredTemplateMessages = computed(() =>
twilioTemplates.value.filter(
template =>
template.friendly_name
.toLowerCase()
.includes(query.value.toLowerCase()) && template.status === 'approved'
)
);
const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
const refreshTemplates = async () => {
isRefreshing.value = true;
try {
await store.dispatch('inboxes/syncTemplates', props.inboxId);
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
isRefreshing.value = false;
}
};
</script>
<template>
<div class="w-full">
<div class="flex gap-2 mb-2.5">
<div
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
>
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input
v-model="query"
type="search"
:placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
</div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
icon="i-lucide-refresh-ccw"
class="text-n-slate-12 size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</button>
</div>
<div
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
>
<div
v-for="(template, i) in filteredTemplateMessages"
:key="template.content_sid"
>
<button
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="emit('onSelect', template)"
>
<div>
<div class="flex justify-between items-center mb-2.5">
<p class="text-sm">
{{ template.friendly_name }}
</p>
<div class="flex gap-2">
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ getTemplateType(template) }}
</span>
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{
`${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}`
}}
</span>
</div>
</div>
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.BODY') }}
</p>
<p class="text-sm label-body">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<div class="flex justify-between items-center mt-3">
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }}
</p>
<p class="text-sm">{{ template.category || 'utility' }}</p>
</div>
<div class="text-xs text-n-slate-11">
{{ new Date(template.created_at).toLocaleDateString() }}
</div>
</div>
</div>
</button>
<hr
v-if="i != filteredTemplateMessages.length - 1"
:key="`hr-${i}`"
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/>
</div>
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && twilioTemplates.length">
<p>
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
<div v-else-if="!twilioTemplates.length" class="space-y-4">
<p class="text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.label-body {
font-family: monospace;
}
</style>
@@ -15,7 +15,7 @@ import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
@@ -27,7 +27,6 @@ import {
replaceVariablesInMessage,
} from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
@@ -53,8 +52,8 @@ export default {
ArticleSearchPopover,
AttachmentPreview,
AudioRecorder,
Banner,
CannedResponse,
ReplyBoxBanner,
EmojiInput,
MessageSignatureMissingAlert,
ReplyBottomPanel,
@@ -62,7 +61,6 @@ export default {
ReplyToMessage,
ReplyTopPanel,
ResizableTextArea,
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
},
@@ -111,7 +109,6 @@ export default {
toEmails: '',
doAutoSaveDraft: () => {},
showWhatsAppTemplatesModal: false,
showContentTemplatesModal: false,
updateEditorSelectionWith: '',
undefinedVariableMessage: '',
showMentions: false,
@@ -158,12 +155,38 @@ export default {
return false;
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
this.$store.dispatch('setCurrentChatAssignee', agent);
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
});
},
},
showSelfAssignBanner() {
if (this.message !== '' && !this.isOnPrivateNote) {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
return true;
}
}
return false;
},
showWhatsappTemplates() {
return this.isAWhatsAppCloudChannel && !this.isPrivate;
},
showContentTemplates() {
return this.isATwilioWhatsAppChannel && !this.isPrivate;
},
isPrivate() {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
return this.isOnPrivateNote;
@@ -636,11 +659,28 @@ export default {
hideWhatsappTemplatesModal() {
this.showWhatsAppTemplatesModal = false;
},
openContentTemplateModal() {
this.showContentTemplatesModal = true;
},
hideContentTemplatesModal() {
this.showContentTemplatesModal = false;
onClickSelfAssign() {
const {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
avatar_url,
} = this.currentUser;
const selfAssign = {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail: avatar_url,
};
this.assignedAgent = selfAssign;
},
confirmOnSendReply() {
if (this.isReplyButtonDisabled) {
@@ -734,13 +774,6 @@ export default {
});
this.hideWhatsappTemplatesModal();
},
async onSendContentTemplateReply(messagePayload) {
this.sendMessage({
conversationId: this.currentChat.id,
...messagePayload,
});
this.hideContentTemplatesModal();
},
replaceText(message) {
if (this.sendWithSignature && !this.private) {
// if signature is enabled, append it to the message
@@ -1066,7 +1099,16 @@ export default {
</script>
<template>
<ReplyBoxBanner :message="message" :is-on-private-note="isOnPrivateNote" />
<Banner
v-if="showSelfAssignBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg banner--self-assign"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@primary-action="onClickSelfAssign"
/>
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
<ReplyTopPanel
:mode="replyType"
@@ -1175,7 +1217,6 @@ export default {
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
@@ -1198,7 +1239,6 @@ export default {
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@@ -1211,14 +1251,6 @@ export default {
@cancel="hideWhatsappTemplatesModal"
/>
<ContentTemplates
:inbox-id="inbox.id"
:show="showContentTemplatesModal"
@close="hideContentTemplatesModal"
@on-send="onSendContentTemplateReply"
@cancel="hideContentTemplatesModal"
/>
<woot-confirm-modal
ref="confirmDialog"
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
@@ -1232,6 +1264,10 @@ export default {
@apply mb-0;
}
.banner--self-assign {
@apply py-2;
}
.attachment-preview-box {
@apply bg-transparent py-0 px-4;
}
@@ -1,129 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import wootConstants from 'dashboard/constants/globals';
import Banner from 'dashboard/components/ui/Banner.vue';
const props = defineProps({
message: {
type: String,
default: '',
},
isOnPrivateNote: {
type: Boolean,
default: false,
},
});
const store = useStore();
const { t } = useI18n();
const currentChat = useMapGetter('getSelectedChat');
const currentUser = useMapGetter('getCurrentUser');
const assignedAgent = computed({
get() {
return currentChat.value?.meta?.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
store.dispatch('setCurrentChatAssignee', agent);
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,
agentId,
});
},
});
const isUserTyping = computed(
() => props.message !== '' && !props.isOnPrivateNote
);
const isUnassigned = computed(() => !assignedAgent.value);
const isAssignedToOtherAgent = computed(
() => assignedAgent.value?.id !== currentUser.value?.id
);
const showSelfAssignBanner = computed(() => {
return (
isUserTyping.value && (isUnassigned.value || isAssignedToOtherAgent.value)
);
});
const showBotHandoffBanner = computed(
() =>
isUserTyping.value &&
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
);
const botHandoffActionLabel = computed(() => {
return assignedAgent.value?.id === currentUser.value?.id
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
: t('CONVERSATION.BOT_HANDOFF_ACTION');
});
const selfAssignConversation = async () => {
const { avatar_url, ...rest } = currentUser.value || {};
assignedAgent.value = { ...rest, thumbnail: avatar_url };
};
const needsAssignmentToCurrentUser = computed(() => {
return isUnassigned.value || isAssignedToOtherAgent.value;
});
const onClickSelfAssign = async () => {
try {
await selfAssignConversation();
useAlert(t('CONVERSATION.CHANGE_AGENT'));
} catch (error) {
useAlert(t('CONVERSATION.CHANGE_AGENT_FAILED'));
}
};
const reopenConversation = async () => {
await store.dispatch('toggleStatus', {
conversationId: currentChat.value?.id,
status: wootConstants.STATUS_TYPE.OPEN,
});
};
const onClickBotHandoff = async () => {
try {
await reopenConversation();
if (needsAssignmentToCurrentUser.value) {
await selfAssignConversation();
}
useAlert(t('CONVERSATION.BOT_HANDOFF_SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION.BOT_HANDOFF_ERROR'));
}
};
</script>
<template>
<Banner
v-if="showSelfAssignBanner && !showBotHandoffBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@primary-action="onClickSelfAssign"
/>
<Banner
v-if="showBotHandoffBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
has-action-button
:action-button-label="botHandoffActionLabel"
@primary-action="onClickBotHandoff"
/>
</template>
@@ -125,23 +125,3 @@ export const getHostNameFromURL = url => {
return null;
}
};
/**
* Extracts filename from a URL
* @param {string} url - The URL to extract filename from
* @returns {string} - The extracted filename or original URL if extraction fails
*/
export const extractFilenameFromUrl = url => {
if (!url || typeof url !== 'string') return url;
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
return filename || url;
} catch (error) {
// If URL parsing fails, try to extract filename using regex
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
return match ? match[1] : url;
}
};
@@ -7,7 +7,6 @@ import {
hasValidAvatarUrl,
timeStampAppendedURL,
getHostNameFromURL,
extractFilenameFromUrl,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -264,58 +263,4 @@ describe('#URL Helpers', () => {
expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help');
});
});
describe('extractFilenameFromUrl', () => {
it('should extract filename from a valid URL', () => {
expect(
extractFilenameFromUrl('https://example.com/path/to/file.jpg')
).toBe('file.jpg');
expect(extractFilenameFromUrl('https://example.com/image.png')).toBe(
'image.png'
);
expect(
extractFilenameFromUrl(
'https://example.com/folder/document.pdf?query=1'
)
).toBe('document.pdf');
expect(
extractFilenameFromUrl('https://example.com/file.txt#section')
).toBe('file.txt');
});
it('should handle URLs without filename', () => {
expect(extractFilenameFromUrl('https://example.com/')).toBe(
'https://example.com/'
);
expect(extractFilenameFromUrl('https://example.com')).toBe(
'https://example.com'
);
});
it('should handle invalid URLs gracefully', () => {
expect(extractFilenameFromUrl('not-a-url/file.txt')).toBe('file.txt');
expect(extractFilenameFromUrl('invalid-url')).toBe('invalid-url');
});
it('should handle edge cases', () => {
expect(extractFilenameFromUrl('')).toBe('');
expect(extractFilenameFromUrl(null)).toBe(null);
expect(extractFilenameFromUrl(undefined)).toBe(undefined);
expect(extractFilenameFromUrl(123)).toBe(123);
});
it('should handle URLs with query parameters and fragments', () => {
expect(
extractFilenameFromUrl(
'https://example.com/file.jpg?size=large&format=png'
)
).toBe('file.jpg');
expect(
extractFilenameFromUrl('https://example.com/file.pdf#page=1')
).toBe('file.pdf');
expect(
extractFilenameFromUrl('https://example.com/file.doc?v=1#section')
).toBe('file.doc');
});
});
});
@@ -612,15 +612,6 @@
"SEND_MESSAGE": "Send message"
}
},
"TWILIO_OPTIONS": {
"LABEL": "Select template",
"SEARCH_PLACEHOLDER": "Search templates",
"EMPTY_STATE": "No templates found",
"TEMPLATE_PARSER": {
"BACK": "Go back",
"SEND_MESSAGE": "Send message"
}
},
"ACTION_BUTTONS": {
"DISCARD": "Discard",
"SEND": "Send ({keyCode})"
@@ -1,51 +0,0 @@
{
"CONTENT_TEMPLATES": {
"MODAL": {
"TITLE": "Twilio Templates",
"SUBTITLE": "Select the Twilio template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"NO_CONTENT": "No content",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"CATEGORY": "Category",
"MEDIA_CONTENT": "Media Content",
"MEDIA_CONTENT_FALLBACK": "media content",
"NO_TEMPLATES_AVAILABLE": "No Twilio templates available. Click refresh to sync templates from Twilio.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
},
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"TEXT": "Text"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
"MEDIA_HEADER_LABEL": "{type} Header",
"MEDIA_URL_LABEL": "Enter full media URL",
"MEDIA_URL_PLACEHOLDER": "https://example.com/image.jpg"
},
"FORM": {
"BACK_BUTTON": "Back",
"SEND_MESSAGE_BUTTON": "Send Message"
}
}
}
@@ -35,11 +35,6 @@
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
"ASSIGN_TO_ME": "Assign to me",
"BOT_HANDOFF_MESSAGE": "You are responding to a conversation which is currently handled by an assistant or a bot.",
"BOT_HANDOFF_ACTION": "Mark open and assign to you",
"BOT_HANDOFF_REOPEN_ACTION": "Mark conversation open",
"BOT_HANDOFF_SUCCESS": "Conversation has been handed over to you",
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
@@ -807,6 +807,58 @@
"ERROR_MESSAGE": "Unable to update portal"
}
}
},
"PDF_UPLOAD": {
"TITLE": "Upload PDF Document",
"DESCRIPTION": "Upload a PDF document to automatically generate FAQs using AI",
"DRAG_DROP_TEXT": "Drag and drop your PDF file here, or click to select",
"SELECT_FILE": "Select PDF File",
"ADDITIONAL_CONTEXT_LABEL": "Additional Context (Optional)",
"ADDITIONAL_CONTEXT_PLACEHOLDER": "Provide any additional context or instructions for FAQ generation...",
"UPLOADING": "Uploading...",
"UPLOAD": "Upload & Process",
"CANCEL": "Cancel",
"ERROR_INVALID_TYPE": "Please select a valid PDF file",
"ERROR_FILE_TOO_LARGE": "File size must be less than 512MB",
"ERROR_UPLOAD_FAILED": "Failed to upload PDF. Please try again."
},
"PDF_DOCUMENTS": {
"TITLE": "PDF Documents",
"DESCRIPTION": "Manage uploaded PDF documents and generate FAQs from them",
"UPLOAD_PDF": "Upload PDF",
"UPLOAD_FIRST_PDF": "Upload your first PDF",
"UPLOADED_BY": "Uploaded by",
"GENERATE_FAQS": "Generate FAQs",
"GENERATING": "Generating...",
"CONFIRM_DELETE": "Are you sure you want to delete {filename}?",
"EMPTY_STATE": {
"TITLE": "No PDF documents yet",
"DESCRIPTION": "Upload PDF documents to automatically generate FAQs using AI"
},
"STATUS": {
"UPLOADED": "Ready",
"PROCESSING": "Processing",
"PROCESSED": "Completed",
"FAILED": "Failed"
}
},
"CONTENT_GENERATION": {
"TITLE": "Content Generation",
"DESCRIPTION": "Upload PDF documents to automatically generate FAQ content using AI",
"UPLOAD_TITLE": "Upload PDF Document",
"DRAG_DROP": "Drag and drop your PDF file here, or click to select",
"SELECT_FILE": "Select PDF File",
"UPLOADING": "Processing document...",
"UPLOAD_SUCCESS": "Document processed successfully!",
"UPLOAD_ERROR": "Failed to upload document. Please try again.",
"INVALID_FILE_TYPE": "Please select a valid PDF file",
"FILE_TOO_LARGE": "File size must be less than 512MB",
"GENERATED_CONTENT": "Generated FAQ Content",
"PUBLISH_SELECTED": "Publish Selected",
"PUBLISHING": "Publishing...",
"FROM_DOCUMENT": "From document",
"NO_CONTENT": "No generated content available. Upload a PDF document to get started.",
"LOADING": "Loading generated content..."
}
}
}
@@ -35,7 +35,6 @@ import signup from './signup.json';
import sla from './sla.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
export default {
...advancedFilters,
@@ -75,5 +74,4 @@ export default {
...sla,
...teamsSettings,
...whatsappTemplates,
...contentTemplates,
};
@@ -701,11 +701,28 @@
"ERROR_MESSAGE": "There was an error creating the document, please try again."
},
"FORM": {
"TYPE": {
"LABEL": "Document Type",
"URL": "URL",
"PDF": "PDF File"
},
"URL": {
"LABEL": "URL",
"PLACEHOLDER": "Enter the URL of the document",
"ERROR": "Please provide a valid URL for the document"
},
"PDF_FILE": {
"LABEL": "PDF File",
"CHOOSE_FILE": "Choose PDF file",
"ERROR": "Please select a PDF file",
"HELP_TEXT": "Maximum file size: 10MB",
"INVALID_TYPE": "Please select a valid PDF file",
"TOO_LARGE": "File size exceeds 10MB limit"
},
"NAME": {
"LABEL": "Document Name (Optional)",
"PLACEHOLDER": "Enter a name for the document"
},
"ASSISTANT": {
"LABEL": "Assistant",
"PLACEHOLDER": "Select the assistant",
@@ -1,5 +1,5 @@
<script setup>
import { ref, useTemplateRef, onMounted, watch, nextTick, computed } from 'vue';
import { ref, useTemplateRef, onMounted, watch, nextTick } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import ReadMore from './ReadMore.vue';
@@ -8,9 +8,9 @@ const props = defineProps({
type: String,
default: '',
},
message: {
type: Object,
default: () => ({}),
content: {
type: String,
default: '',
},
searchTerm: {
type: String,
@@ -18,24 +18,6 @@ const props = defineProps({
},
});
const messageContent = computed(() => {
// We perform search on either content or email subject or transcribed text
if (props.message.content) {
return props.message.content;
}
const { content_attributes = {} } = props.message;
const { email = {} } = content_attributes || {};
if (email.subject) {
return email.subject;
}
const audioAttachment = props.message.attachments.find(
attachment => attachment.file_type === 'audio'
);
return audioAttachment?.transcribed_text || '';
});
const { highlightContent } = useMessageFormatter();
const messageContainer = useTemplateRef('messageContainer');
@@ -56,8 +38,7 @@ const escapeHtml = html => {
return p.innerText;
};
const prepareContent = () => {
const content = messageContent.value || '';
const prepareContent = (content = '') => {
const escapedText = escapeHtml(content);
return highlightContent(
escapedText,
@@ -84,7 +65,7 @@ onMounted(() => {
{{ $t('SEARCH.WROTE') }}
</p>
<ReadMore :shrink="isOverflowing" @expand="isOverflowing = false">
<div v-dompurify-html="prepareContent()" class="message-content" />
<div v-dompurify-html="prepareContent(content)" class="message-content" />
</ReadMore>
</blockquote>
</template>
@@ -93,7 +74,6 @@ onMounted(() => {
.message {
@apply py-0 px-2 mt-2;
}
.message-content::v-deep p,
.message-content::v-deep li::marker {
@apply text-n-slate-11 mb-1;
@@ -54,7 +54,7 @@ const getName = message => {
>
<MessageContent
:author="getName(message)"
:message="message"
:content="message.content"
:search-term="query"
/>
</SearchResultConversationItem>
@@ -94,7 +94,12 @@ const loadMoreNotifications = () => {
const markNotificationAsRead = async notificationItem => {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const { id, primaryActorId, primaryActorType } = notificationItem;
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notificationItem;
try {
await store.dispatch('notifications/read', {
id,
@@ -131,13 +131,7 @@ async function generateQRCodes() {
// WhatsApp (both Cloud and Twilio)
if (currentInbox.value.phone_number && isAWhatsAppChannel.value) {
// For Twilio WhatsApp, phone_number format is "whatsapp:+1234567890"
// Extract just the phone number part for QR code generation
const phoneNumber = currentInbox.value.phone_number.replace(
'whatsapp:',
''
);
await generateQRCode('whatsapp', phoneNumber);
await generateQRCode('whatsapp', currentInbox.value.phone_number);
}
// Facebook Messenger
@@ -236,47 +230,47 @@ onMounted(() => {
</div>
<div
v-if="isAWhatsAppChannel && qrCodes.whatsapp"
class="flex flex-col gap-3 items-center mt-8"
class="flex flex-col items-center mt-8 gap-3"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.WHATSAPP_QR_INSTRUCTION') }}
</p>
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
<img
:src="qrCodes.whatsapp"
alt="WhatsApp QR Code"
class="rounded-lg size-48 dark:invert"
class="size-48 dark:invert rounded-lg"
/>
</div>
</div>
<div
v-if="isAFacebookInbox && qrCodes.messenger"
class="flex flex-col gap-3 items-center mt-8"
class="flex flex-col items-center mt-8 gap-3"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.MESSENGER_QR_INSTRUCTION') }}
</p>
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
<img
:src="qrCodes.messenger"
alt="Messenger QR Code"
class="rounded-lg size-48 dark:invert"
class="size-48 dark:invert rounded-lg"
/>
</div>
</div>
<div
v-if="isATelegramChannel && qrCodes.telegram"
class="flex flex-col gap-4 items-center mt-8"
class="flex flex-col items-center mt-8 gap-4"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.TELEGRAM_QR_INSTRUCTION') }}
</p>
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
<img
:src="qrCodes.telegram"
alt="Telegram QR Code"
class="rounded-lg size-48 dark:invert"
class="size-48 dark:invert rounded-lg"
/>
</div>
</div>
@@ -125,19 +125,6 @@ export default {
>
<woot-code :script="inbox.callback_webhook_url" lang="html" />
</SettingsSection>
<SettingsSection
v-if="isATwilioWhatsAppChannel"
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
"
>
<div class="flex justify-start items-center mt-2">
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</div>
<div v-else-if="isAVoiceChannel" class="mx-8">
<SettingsSection
@@ -38,7 +38,4 @@ export const getters = {
getHasUnreadNotifications: $state => {
return $state.meta.unreadCount > 0;
},
getUnreadCount: $state => {
return $state.meta.unreadCount;
},
};
@@ -33,15 +33,13 @@ export default {
brandRedirectURL() {
try {
const referrerHost = this.$store.getters['appConfig/getReferrerHost'];
const url = new URL(this.globalConfig.widgetBrandURL);
const baseURL = `${this.globalConfig.widgetBrandURL}?utm_source=${
referrerHost ? 'widget_branding' : 'survey_branding'
}`;
if (referrerHost) {
url.searchParams.set('utm_source', referrerHost);
url.searchParams.set('utm_medium', 'widget');
} else {
url.searchParams.set('utm_medium', 'survey');
return `${baseURL}&utm_referrer=${referrerHost}`;
}
url.searchParams.set('utm_campaign', 'branding');
return url.toString();
return baseURL;
} catch (e) {
// Suppressing the error as getter is not defined in some cases
}
@@ -162,9 +162,3 @@ export const ATTACHMENT_ICONS = {
location: 'location',
fallback: 'link',
};
export const TWILIO_CONTENT_TEMPLATE_TYPES = {
TEXT: 'text',
MEDIA: 'media',
QUICK_REPLY: 'quick_reply',
};
+2 -7
View File
@@ -2,16 +2,11 @@
* Writes a text string to the system clipboard.
*
* @async
* @param {string} data text to be written to the clipboard
* @param {string} text text to be written to the clipboard
* @throws {Error} unable to copy text to clipboard
*/
export const copyTextToClipboard = async data => {
export const copyTextToClipboard = async text => {
try {
const text =
typeof data === 'object' && data !== null
? JSON.stringify(data, null, 2)
: String(data ?? '');
await navigator.clipboard.writeText(text);
} catch (error) {
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
@@ -1,174 +0,0 @@
import { copyTextToClipboard } from '../clipboard';
const mockWriteText = vi.fn();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
describe('copyTextToClipboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('with string input', () => {
it('copies plain text string to clipboard', async () => {
const text = 'Hello World';
await copyTextToClipboard(text);
expect(mockWriteText).toHaveBeenCalledWith('Hello World');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('copies empty string to clipboard', async () => {
const text = '';
await copyTextToClipboard(text);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with number input', () => {
it('converts number to string', async () => {
await copyTextToClipboard(42);
expect(mockWriteText).toHaveBeenCalledWith('42');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts zero to string', async () => {
await copyTextToClipboard(0);
expect(mockWriteText).toHaveBeenCalledWith('0');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with boolean input', () => {
it('converts true to string', async () => {
await copyTextToClipboard(true);
expect(mockWriteText).toHaveBeenCalledWith('true');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts false to string', async () => {
await copyTextToClipboard(false);
expect(mockWriteText).toHaveBeenCalledWith('false');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with null/undefined input', () => {
it('converts null to empty string', async () => {
await copyTextToClipboard(null);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts undefined to empty string', async () => {
await copyTextToClipboard(undefined);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with object input', () => {
it('stringifies simple object with proper formatting', async () => {
const obj = { name: 'John', age: 30 };
await copyTextToClipboard(obj);
const expectedJson = JSON.stringify(obj, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies nested object with proper formatting', async () => {
const nestedObj = {
severity: {
user_id: 1181505,
user_name: 'test',
server_name: '[1253]test1253',
},
};
await copyTextToClipboard(nestedObj);
const expectedJson = JSON.stringify(nestedObj, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies array with proper formatting', async () => {
const arr = [1, 2, { name: 'test' }];
await copyTextToClipboard(arr);
const expectedJson = JSON.stringify(arr, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies empty object', async () => {
const obj = {};
await copyTextToClipboard(obj);
expect(mockWriteText).toHaveBeenCalledWith('{}');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies empty array', async () => {
const arr = [];
await copyTextToClipboard(arr);
expect(mockWriteText).toHaveBeenCalledWith('[]');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('error handling', () => {
it('throws error when clipboard API fails', async () => {
const error = new Error('Clipboard access denied');
mockWriteText.mockRejectedValueOnce(error);
await expect(copyTextToClipboard('test')).rejects.toThrow(
'Unable to copy text to clipboard: Clipboard access denied'
);
});
it('handles clipboard API not available', async () => {
// Temporarily remove clipboard API
const originalClipboard = navigator.clipboard;
delete navigator.clipboard;
await expect(copyTextToClipboard('test')).rejects.toThrow(
'Unable to copy text to clipboard:'
);
// Restore clipboard API
navigator.clipboard = originalClipboard;
});
});
describe('edge cases', () => {
it('handles Date objects', async () => {
const date = new Date('2023-01-01T00:00:00.000Z');
await copyTextToClipboard(date);
const expectedJson = JSON.stringify(date, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('handles functions by converting to string', async () => {
const func = () => 'test';
await copyTextToClipboard(func);
expect(mockWriteText).toHaveBeenCalledWith(func.toString());
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
});
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, watch } from 'vue';
import { computed, onMounted } from 'vue';
import ArticleBlock from 'widget/components/pageComponents/Home/Article/ArticleBlock.vue';
import ArticleCardSkeletonLoader from 'widget/components/pageComponents/Home/Article/SkeletonLoader.vue';
import { useI18n } from 'vue-i18n';
@@ -26,7 +26,7 @@ const locale = computed(() => {
});
const fetchArticles = () => {
if (portal.value && locale.value) {
if (portal.value && !popularArticles.value.length) {
store.dispatch('article/fetch', {
slug: portal.value.slug,
locale: locale.value,
@@ -60,14 +60,6 @@ const hasArticles = computed(
!!popularArticles.value.length &&
!!locale.value
);
// Watch for locale changes and refetch articles
watch(locale, (newLocale, oldLocale) => {
if (newLocale && newLocale !== oldLocale) {
fetchArticles();
}
});
onMounted(() => fetchArticles());
</script>
@@ -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
@@ -1,22 +1,10 @@
class AdministratorNotifications::AccountNotificationMailer < AdministratorNotifications::BaseMailer
def account_deletion_user_initiated(account, reason)
subject = 'Your Chatwoot account deletion has been scheduled'
def account_deletion(account, reason = 'manual_deletion')
subject = 'Your account has been marked for deletion'
action_url = settings_url('general')
meta = {
'account_name' => account.name,
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
'reason' => reason
}
send_notification(subject, action_url: action_url, meta: meta)
end
def account_deletion_for_inactivity(account, reason)
subject = 'Your Chatwoot account is scheduled for deletion due to inactivity'
action_url = settings_url('general')
meta = {
'account_name' => account.name,
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
'deletion_date' => account.custom_attributes['marked_for_deletion_at'],
'reason' => reason
}
@@ -57,14 +45,4 @@ class AdministratorNotifications::AccountNotificationMailer < AdministratorNotif
send_notification(subject, action_url: action_url, meta: meta)
end
private
def format_deletion_date(deletion_date_str)
return 'Unknown' if deletion_date_str.blank?
Time.zone.parse(deletion_date_str).strftime('%B %d, %Y')
rescue StandardError
'Unknown'
end
end
+2 -21
View File
@@ -39,8 +39,6 @@
#
class Message < ApplicationRecord
searchkick callbacks: :async if ChatwootApp.advanced_search_allowed?
include MessageFilterHelpers
include Liquidable
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
@@ -141,23 +139,14 @@ class Message < ApplicationRecord
data = attributes.symbolize_keys.merge(
created_at: created_at.to_i,
message_type: message_type_before_type_cast,
conversation_id: conversation&.display_id,
conversation: conversation.present? ? conversation_push_event_data : nil
conversation_id: conversation.display_id,
conversation: conversation_push_event_data
)
data[:echo_id] = echo_id if echo_id.present?
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
merge_sender_attributes(data)
end
def search_data
data = attributes.symbolize_keys
data[:conversation] = conversation.present? ? conversation_push_event_data : nil
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
data[:sender] = sender.push_event_data if sender
data[:inbox] = inbox
data
end
def conversation_push_event_data
{
assignee_id: conversation.assignee_id,
@@ -239,14 +228,6 @@ class Message < ApplicationRecord
previous_changes: previous_changes)
end
def should_index?
return false unless ChatwootApp.advanced_search_allowed?
return false unless account.feature_enabled?('advanced_search')
return false unless incoming? || outgoing?
true
end
private
def prevent_message_flooding
-10
View File
@@ -43,19 +43,11 @@ class SearchService
def filter_messages
@messages = if use_gin_search
filter_messages_with_gin
elsif should_run_advanced_search?
advanced_search
else
filter_messages_with_like
end
end
def should_run_advanced_search?
ChatwootApp.advanced_search_allowed? && current_account.feature_enabled?('advanced_search')
end
def advanced_search; end
def filter_messages_with_gin
base_query = message_base_query
@@ -123,5 +115,3 @@ class SearchService
.per(15)
end
end
SearchService.prepend_mod_with('SearchService')
+15 -18
View File
@@ -17,7 +17,21 @@ class Twilio::TemplateSyncService
end
def update_channel_templates
formatted_templates = @templates.map { |template| format_template(template) }
formatted_templates = @templates.map do |template|
{
content_sid: template.sid,
friendly_name: template.friendly_name,
language: template.language,
status: derive_status(template),
template_type: derive_template_type(template),
media_type: derive_media_type(template),
variables: template.variables || {},
category: derive_category(template),
body: extract_body_content(template),
created_at: template.date_created,
updated_at: template.date_updated
}
end
channel.update!(
content_templates: { templates: formatted_templates },
@@ -25,23 +39,6 @@ class Twilio::TemplateSyncService
)
end
def format_template(template)
{
content_sid: template.sid,
friendly_name: template.friendly_name,
language: template.language,
status: derive_status(template),
template_type: derive_template_type(template),
media_type: derive_media_type(template),
variables: template.variables || {},
category: derive_category(template),
body: extract_body_content(template),
types: template.types,
created_at: template.date_created,
updated_at: template.date_updated
}
end
def mark_templates_updated
channel.update!(content_templates_last_updated: Time.current)
end
@@ -1 +1,12 @@
json.partial! 'api/v1/models/message', message: message
json.id message.id
json.content message.content
json.message_type message.message_type_before_type_cast
json.content_type message.content_type
json.source_id message.source_id
json.inbox_id message.inbox_id
json.conversation_id message.conversation.try(:display_id)
json.created_at message.created_at.to_i
json.sender message.sender.push_event_data if message.sender
json.inbox do
json.partial! 'inbox', formats: [:json], inbox: message.inbox if message.inbox.present? && message.try(:inbox).present?
end
@@ -0,0 +1,16 @@
<p>Hello,</p>
<p>Your account <strong>{{ meta.account_name }}</strong> has been marked for deletion. The account will be permanently deleted on <strong>{{ meta.deletion_date }}</strong>.</p>
{% if meta.reason == 'manual_deletion' %}
<p>This action was requested by one of the administrators of your account.</p>
{% else %}
<p>Reason for deletion: {{ meta.reason }}</p>
{% endif %}
<p>If this was done in error, you can cancel the deletion process by visiting your account settings.</p>
<p><a href="{{ action_url }}">Cancel Account Deletion</a></p>
<p>Thank you,<br>
Team Chatwoot</p>
@@ -1,21 +0,0 @@
<p>Hello there,</p>
<p>We've noticed that your Chatwoot account <strong>{{ meta.account_name }}</strong> has been inactive for some time. Because of this, it's scheduled for deletion on <strong>{{ meta.deletion_date }}</strong>.</p>
<p><strong>How do I keep my account?</strong></p>
<p>Log in to your Chatwoot account before <strong>{{ meta.deletion_date }}</strong>. From your account settings, you can <a href="{{ action_url }}">cancel the deletion</a> and continue using your account.</p>
<p><strong>What happens if I don't cancel?</strong></p>
<p>Unless you cancel the account deletion before <strong>{{ meta.deletion_date }}</strong>, your account and all associated data — including conversations, contacts, reports, and settings — will be permanently deleted.</p>
<p><strong>Why are we doing this?</strong></p>
<p>To keep things secure and efficient, we regularly remove inactive accounts so our systems remain optimized for active teams.</p>
<p>If you have any questions, feel free to reach us at <a href="mailto:hello@chatwoot.com">hello@chatwoot.com</a>.</p>
<p>— The Chatwoot Team</p>
@@ -1,16 +0,0 @@
<p>Hello there,</p>
<p>An account administrator has requested deletion of the Chatwoot account <strong>{{ meta.account_name }}</strong>. The account is scheduled for deletion on <strong>{{ meta.deletion_date }}</strong>.</p>
<p><strong>What happens next?</strong></p>
<ul>
<li>The account will remain accessible until the scheduled deletion date.</li>
<li>After that, all data including conversations, contacts, integrations, and settings will be permanently removed.</li>
</ul>
<p>If you change your mind before the deletion date, you can <a href="{{ action_url }}">cancel this request</a> by visiting your account settings.</p>
<p>— The Chatwoot Team</p>
@@ -8,12 +8,12 @@
alt="<%= @global_config['BRAND_NAME'] %>"
src="<%= @global_config['LOGO_THUMBNAIL'] %>"
/>
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
<%= I18n.t('public_portal.footer.made_with') %>
<a class="hover:underline" href="<%= generate_portal_brand_url(@global_config['BRAND_URL'], request.referer) %>" target="_blank" rel="noopener noreferrer nofollow"><%= @global_config['BRAND_NAME'] %></a>
</p>
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
<%= I18n.t('public_portal.footer.made_with') %>
<a class="hover:underline" href="<%= @global_config['BRAND_URL'] %>" target="_blank" rel="noopener noreferrer nofoll/ow"><%= @global_config['BRAND_NAME'] %></a>
</p>
</div>
</div>
</footer>
-6
View File
@@ -198,9 +198,3 @@
- name: twilio_content_templates
display_name: Twilio Content Templates
enabled: false
deprecated: true
- name: advanced_search
display_name: Advanced Search
enabled: false
premium: true
chatwoot_internal: true
-14
View File
@@ -1,14 +0,0 @@
Searchkick.queue_name = :async_database_migration if ENV.fetch('OPENSEARCH_URL', '').present?
access_key_id = ENV.fetch('OPENSEARCH_AWS_ACCESS_KEY_ID', '')
secret_access_key = ENV.fetch('OPENSEARCH_AWS_SECRET_ACCESS_KEY', '')
if access_key_id.present? && secret_access_key.present?
region = ENV.fetch('OPENSEARCH_AWS_REGION', 'us-east-1')
Searchkick.aws_credentials = {
access_key_id: access_key_id,
secret_access_key: secret_access_key,
region: region
}
end
-7
View File
@@ -46,10 +46,3 @@ delete_accounts_job:
cron: '0 1 * * *'
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
+2 -14
View File
@@ -4,22 +4,10 @@ module Enterprise::Account
def manually_managed_features; end
def mark_for_deletion(reason = 'manual_deletion')
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
result = custom_attributes.merge!(
'marked_for_deletion_at' => 7.days.from_now.iso8601,
'marked_for_deletion_reason' => reason
) && save
result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save
# Send notification to admin users if the account was successfully marked for deletion
if result
mailer = AdministratorNotifications::AccountNotificationMailer.with(account: self)
if reason == 'manual_deletion'
mailer.account_deletion_user_initiated(self, reason).deliver_later
else
mailer.account_deletion_for_inactivity(self, reason).deliver_later
end
end
AdministratorNotifications::AccountNotificationMailer.with(account: self).account_deletion(self, reason).deliver_later if result
result
end
@@ -12,7 +12,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService
@max_pages = options[:max_pages] # Optional limit from UI
@total_pages_processed = 0
@iterations_completed = 0
@model = OpenAiConstants::PDF_PROCESSING_MODEL
@model = 'gpt-4.1-mini'
end
def generate
@@ -1,4 +1,3 @@
# rubocop:disable Metrics/ClassLength
class Captain::Llm::SystemPromptsService
class << self
def faq_generator(language = 'english')
@@ -288,4 +287,3 @@ class Captain::Llm::SystemPromptsService
# rubocop:enable Metrics/MethodLength
end
end
# rubocop:enable Metrics/ClassLength
@@ -1,15 +0,0 @@
module Enterprise::SearchService
def advanced_search
where_conditions = { account_id: current_account.id }
where_conditions[:inbox_id] = accessable_inbox_ids unless should_skip_inbox_filtering?
Message.search(
search_query,
fields: %w[content attachments.transcribed_text content_attributes.email.subject],
where: where_conditions,
order: { created_at: :desc },
page: params[:page] || 1,
per_page: 15
)
end
end
@@ -60,9 +60,5 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
attachment.update!(meta: { transcribed_text: transcribed_text })
message.reload.send_update_event
message.account.increment_response_usage
return unless ChatwootApp.advanced_search_allowed?
message.reindex
end
end
@@ -1,15 +0,0 @@
class Messages::ReindexService
pattr_initialize [:account!]
def perform
return unless ChatwootApp.advanced_search_allowed?
reindex_messages
end
private
def reindex_messages
account.messages.reindex(mode: :async)
end
end
-4
View File
@@ -1,4 +0,0 @@
# Load all rake tasks from the enterprise/lib/tasks directory
module Tasks
Dir.glob(File.join(File.dirname(__FILE__), 'tasks', '*.rake')).each { |r| load r }
end
-49
View File
@@ -1,49 +0,0 @@
module Tasks::SearchTaskHelpers
def check_opensearch_config
if ENV['OPENSEARCH_URL'].blank?
puts 'Skipping reindex as OPENSEARCH_URL is not configured'
return false
end
true
end
def reindex_account(account)
Messages::ReindexService.new(account: account).perform
puts "Reindex task queued for account #{account.id}"
end
end
namespace :search do
desc 'Reindex messages using searchkick'
include Tasks::SearchTaskHelpers
desc 'Reindex messages for all accounts'
task all: :environment do
next unless check_opensearch_config
puts 'Starting reindex for all accounts...'
account_count = Account.count
puts "Found #{account_count} accounts"
Account.find_each.with_index(1) do |account, index|
puts "[#{index}/#{account_count}] Reindexing messages for account #{account.id}"
reindex_account(account)
end
puts 'Reindex task queued for all accounts'
end
desc 'Reindex messages for a specific account: rake search:account ACCOUNT_ID=1'
task account: :environment do
next unless check_opensearch_config
account_id = ENV.fetch('ACCOUNT_ID', nil)
account = Account.find_by(id: account_id)
if account.nil?
puts 'Please provide a valid account ID. Account not found'
next
end
puts "Reindexing messages for account #{account.id}"
reindex_account(account)
end
end
-4
View File
@@ -38,8 +38,4 @@ module ChatwootApp
%w[]
end
end
def self.advanced_search_allowed?
enterprise? && ENV.fetch('OPENSEARCH_URL', nil).present?
end
end
-1
View File
@@ -5,7 +5,6 @@ module Limits
OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 10_000
GREETING_MESSAGE_MAX_LENGTH = 10_000
CATEGORIES_PER_PAGE = 1000
AUTO_ASSIGNMENT_BULK_LIMIT = 100
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
-1
View File
@@ -4,5 +4,4 @@ module OpenAiConstants
DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_ENDPOINT = 'https://api.openai.com'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
end
@@ -1,42 +0,0 @@
# frozen_string_literal: true
# Run with:
# bundle exec rake chatwoot:ops:cleanup_orphan_conversations
namespace :chatwoot do
namespace :ops do
desc 'Identify and delete conversations without a valid contact or inbox in a timeframe'
task cleanup_orphan_conversations: :environment do
print 'Enter Account ID: '
account_id = $stdin.gets.to_i
account = Account.find(account_id)
print 'Enter timeframe in days (default: 7): '
days_input = $stdin.gets.strip
days = days_input.empty? ? 7 : days_input.to_i
# Build a common base relation with identical joins for OR compatibility
base = account
.conversations
.where('conversations.created_at > ?', days.days.ago)
.left_outer_joins(:contact, :inbox)
# Find conversations whose associated contact or inbox record is missing
conversations = base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
count = conversations.count
puts "Found #{count} conversations without a valid contact or inbox."
if count.positive?
print 'Do you want to delete these conversations? (y/N): '
confirm = $stdin.gets.strip.downcase
if %w[y yes].include?(confirm)
conversations.destroy_all
puts 'Conversations deleted.'
else
puts 'No conversations were deleted.'
end
end
end
end
end
+9 -8
View File
@@ -38,6 +38,7 @@
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@heroicons/vue": "^2.2.0",
"@highlightjs/vue-plugin": "^2.1.0",
"@iconify-json/material-symbols": "^1.2.10",
"@lk77/vue3-color": "^3.0.6",
@@ -55,16 +56,16 @@
"@vuelidate/validators": "^2.0.4",
"@vueuse/components": "^12.0.0",
"@vueuse/core": "^12.0.0",
"activestorage": "^5.2.6",
"activestorage": "^5.2.8",
"axios": "^1.8.2",
"camelcase-keys": "^9.1.3",
"chart.js": "~4.4.4",
"color2k": "^2.0.2",
"color2k": "^2.0.3",
"company-email-validator": "^1.1.0",
"core-js": "3.38.1",
"countries-and-timezones": "^3.6.0",
"date-fns": "2.21.1",
"date-fns-tz": "^1.3.3",
"date-fns-tz": "^1.3.8",
"dompurify": "3.2.4",
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
@@ -99,7 +100,7 @@
"vue-multiselect": "3.1.0",
"vue-router": "~4.4.5",
"vue-upload-component": "^3.1.17",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vue-virtual-scroller": "2.0.0-beta.8",
"vue3-click-away": "^1.2.4",
"vuedraggable": "^4.1.0",
"vuex": "~4.1.0",
@@ -115,7 +116,7 @@
"@iconify-json/ri": "^1.2.3",
"@iconify-json/teenyicons": "^1.2.1",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@size-limit/file": "^8.2.4",
"@size-limit/file": "^8.2.6",
"@vitest/coverage-v8": "3.0.5",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20",
@@ -130,16 +131,16 @@
"eslint-plugin-vue": "^9.28.0",
"fake-indexeddb": "^6.0.0",
"histoire": "0.17.15",
"husky": "^7.0.0",
"husky": "^7.0.4",
"jsdom": "^24.1.3",
"lint-staged": "14.0.1",
"postcss": "^8.4.47",
"postcss-preset-env": "^8.5.1",
"prettier": "^3.3.3",
"prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4",
"size-limit": "^8.2.6",
"tailwindcss": "^3.4.13",
"vite": "^5.4.19",
"vite": "5.4.19",
"vite-plugin-ruby": "^5.0.0",
"vitest": "3.0.5"
},
+25 -13
View File
@@ -34,6 +34,9 @@ importers:
'@hcaptcha/vue3-hcaptcha':
specifier: ^1.3.0
version: 1.3.0(vue@3.5.12(typescript@5.6.2))
'@heroicons/vue':
specifier: ^2.2.0
version: 2.2.0(vue@3.5.12(typescript@5.6.2))
'@highlightjs/vue-plugin':
specifier: ^2.1.0
version: 2.1.0(highlight.js@11.10.0)(vue@3.5.12(typescript@5.6.2))
@@ -86,7 +89,7 @@ importers:
specifier: ^12.0.0
version: 12.0.0(typescript@5.6.2)
activestorage:
specifier: ^5.2.6
specifier: ^5.2.8
version: 5.2.8
axios:
specifier: ^1.8.2
@@ -98,7 +101,7 @@ importers:
specifier: ~4.4.4
version: 4.4.4
color2k:
specifier: ^2.0.2
specifier: ^2.0.3
version: 2.0.3
company-email-validator:
specifier: ^1.1.0
@@ -113,7 +116,7 @@ importers:
specifier: 2.21.1
version: 2.21.1
date-fns-tz:
specifier: ^1.3.3
specifier: ^1.3.8
version: 1.3.8(date-fns@2.21.1)
dompurify:
specifier: 3.2.4
@@ -218,7 +221,7 @@ importers:
specifier: ^3.1.17
version: 3.1.17
vue-virtual-scroller:
specifier: ^2.0.0-beta.8
specifier: 2.0.0-beta.8
version: 2.0.0-beta.8(vue@3.5.12(typescript@5.6.2))
vue3-click-away:
specifier: ^1.2.4
@@ -261,7 +264,7 @@ importers:
specifier: ^3.2.0
version: 3.2.0(eslint@8.57.0)
'@size-limit/file':
specifier: ^8.2.4
specifier: ^8.2.6
version: 8.2.6(size-limit@8.2.6)
'@vitest/coverage-v8':
specifier: 3.0.5
@@ -306,7 +309,7 @@ importers:
specifier: 0.17.15
version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
husky:
specifier: ^7.0.0
specifier: ^7.0.4
version: 7.0.4
jsdom:
specifier: ^24.1.3
@@ -327,7 +330,7 @@ importers:
specifier: ^1.22.3
version: 1.22.3
size-limit:
specifier: ^8.2.4
specifier: ^8.2.6
version: 8.2.6
tailwindcss:
specifier: ^3.4.13
@@ -842,6 +845,11 @@ packages:
peerDependencies:
vue: ^3.0.0
'@heroicons/vue@2.2.0':
resolution: {integrity: sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==}
peerDependencies:
vue: '>= 3'
'@highlightjs/vue-plugin@2.1.0':
resolution: {integrity: sha512-E+bmk4ncca+hBEYRV2a+1aIzIV0VSY/e5ArjpuSN9IO7wBJrzUE2u4ESCwrbQD7sAy+jWQjkV5qCCWgc+pu7CQ==}
peerDependencies:
@@ -1685,8 +1693,8 @@ packages:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'}
caniuse-lite@1.0.30001651:
resolution: {integrity: sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==}
caniuse-lite@1.0.30001731:
resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
capital-case@1.0.4:
resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
@@ -5183,6 +5191,10 @@ snapshots:
dependencies:
vue: 3.5.12(typescript@5.6.2)
'@heroicons/vue@2.2.0(vue@3.5.12(typescript@5.6.2))':
dependencies:
vue: 3.5.12(typescript@5.6.2)
'@highlightjs/vue-plugin@2.1.0(highlight.js@11.10.0)(vue@3.5.12(typescript@5.6.2))':
dependencies:
highlight.js: 11.10.0
@@ -6089,7 +6101,7 @@ snapshots:
autoprefixer@10.4.20(postcss@8.4.47):
dependencies:
browserslist: 4.23.3
caniuse-lite: 1.0.30001651
caniuse-lite: 1.0.30001731
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.0.1
@@ -6144,14 +6156,14 @@ snapshots:
browserslist@4.23.0:
dependencies:
caniuse-lite: 1.0.30001651
caniuse-lite: 1.0.30001731
electron-to-chromium: 1.4.783
node-releases: 2.0.14
update-browserslist-db: 1.0.16(browserslist@4.23.0)
browserslist@4.23.3:
dependencies:
caniuse-lite: 1.0.30001651
caniuse-lite: 1.0.30001731
electron-to-chromium: 1.5.13
node-releases: 2.0.18
update-browserslist-db: 1.1.0(browserslist@4.23.3)
@@ -6201,7 +6213,7 @@ snapshots:
camelcase@8.0.0: {}
caniuse-lite@1.0.30001651: {}
caniuse-lite@1.0.30001731: {}
capital-case@1.0.4:
dependencies:
-66
View File
@@ -1,71 +1,5 @@
/* eslint-disable no-restricted-globals, no-console */
/* globals clients */
const CACHE_NAME = 'vite-assets-cache-v1'; // Bump this to invalidate old caches
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
const CACHE_HEADER_NAME = 'x-woot-sw-cache-time';
async function addCacheTime(response) {
const headers = new Headers(response.headers);
headers.set(CACHE_HEADER_NAME, Date.now().toString());
const body = await response.blob();
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function isFresh(response) {
const cachedTime = response.headers.get(CACHE_HEADER_NAME);
if (!cachedTime) return false;
return Date.now() - Number(cachedTime) < CACHE_TTL;
}
async function handleAssetRequest(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached && isFresh(cached)) {
return cached;
}
try {
const response = await fetch(request);
if (response.ok) {
const responseToCache = await addCacheTime(response);
// We naively cache everything - the browser will purge when needed
cache.put(request, responseToCache.clone());
return responseToCache;
}
return cached || response;
} catch {
return cached || new Response('', { status: 504, statusText: 'Gateway Timeout' });
}
}
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/vite/assets/')) {
event.respondWith(handleAssetRequest(event.request));
}
});
// Clean up old caches on activate
// To trigger a cleanup we can simply change the `CACHE_NAME`
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
)
)
);
self.clients.claim();
});
self.addEventListener('push', event => {
let notification = event.data && event.data.json();
-8
View File
@@ -1,8 +0,0 @@
require 'simplecov'
require 'simplecov_json_formatter'
# Configure SimpleCov to emit JSON for Qlty and HTML locally if needed
SimpleCov.formatter = SimpleCov::Formatter::JSONFormatter
SimpleCov.start 'rails' do
SimpleCov.coverage_dir 'coverage'
end
+5 -14
View File
@@ -229,27 +229,18 @@ RSpec.describe Account, type: :model do
describe '#mark_for_deletion' do
it 'sets the marked_for_deletion_at and marked_for_deletion_reason attributes' do
expect do
account.mark_for_deletion('inactivity')
account.mark_for_deletion('test_reason')
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(nil).to(be_present)
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('inactivity')
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('test_reason')
end
it 'sends a user-initiated deletion email when reason is manual_deletion' do
it 'sends a notification email to admin users' do
mailer = double
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
expect(mailer).to receive(:account_deletion_user_initiated).with(account, 'manual_deletion').and_return(mailer)
expect(mailer).to receive(:account_deletion).with(account, 'test_reason').and_return(mailer)
expect(mailer).to receive(:deliver_later)
account.mark_for_deletion('manual_deletion')
end
it 'sends a system-initiated deletion email when reason is not manual_deletion' do
mailer = double
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
expect(mailer).to receive(:account_deletion_for_inactivity).with(account, 'inactivity').and_return(mailer)
expect(mailer).to receive(:deliver_later)
account.mark_for_deletion('inactivity')
account.mark_for_deletion('test_reason')
end
it 'returns true when successful' do
-36
View File
@@ -291,40 +291,4 @@ describe PortalHelper do
end
end
end
describe '#generate_portal_brand_url' do
it 'builds URL with UTM params and referer host as source (happy path)' do
result = helper.generate_portal_brand_url('https://brand.example.com', 'https://app.chatwoot.com/some/page')
uri = URI.parse(result)
params = Rack::Utils.parse_query(uri.query)
expect(uri.scheme).to eq('https')
expect(uri.host).to eq('brand.example.com')
expect(params['utm_medium']).to eq('helpcenter')
expect(params['utm_campaign']).to eq('branding')
expect(params['utm_source']).to eq('app.chatwoot.com')
end
it 'returns utm string when brand_url is nil or empty' do
expect(helper.generate_portal_brand_url(nil,
'https://app.chatwoot.com')).to eq(
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
)
expect(helper.generate_portal_brand_url('',
'https://app.chatwoot.com')).to eq(
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
)
end
it 'omits utm_source when referer is nil or invalid' do
r1 = helper.generate_portal_brand_url('https://brand.example.com', nil)
p1 = Rack::Utils.parse_query(URI.parse(r1).query)
expect(p1.key?('utm_source')).to be(false)
r2 = helper.generate_portal_brand_url('https://brand.example.com', '::not-a-valid-url')
p2 = Rack::Utils.parse_query(URI.parse(r2).query)
expect(p2.key?('utm_source')).to be(false)
expect(p2['utm_medium']).to eq('helpcenter')
expect(p2['utm_campaign']).to eq('branding')
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
@@ -1,46 +1,116 @@
require 'rails_helper'
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
RSpec.describe AdministratorNotifications::AccountNotificationMailer do
let(:account) { create(:account, name: 'Test Account') }
let(:mailer) { described_class.with(account: account) }
let(:class_instance) { described_class.new }
include_context 'with smtp config'
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
account.custom_attributes['marked_for_deletion_at'] = 7.days.from_now.iso8601
account.save!
end
let!(:account) { create(:account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
describe '#account_deletion_user_initiated' do
it 'sets the correct subject for user-initiated deletion' do
mail = mailer.account_deletion_user_initiated(account, 'manual_deletion')
expect(mail.subject).to eq('Your Chatwoot account deletion has been scheduled')
describe 'account_deletion' do
let(:reason) { 'manual_deletion' }
let(:mail) { described_class.with(account: account).account_deletion(account, reason) }
let(:deletion_date) { 7.days.from_now.iso8601 }
before do
account.update!(custom_attributes: {
'marked_for_deletion_at' => deletion_date,
'marked_for_deletion_reason' => reason
})
end
it 'renders the subject' do
expect(mail.subject).to eq('Your account has been marked for deletion')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
it 'includes the account name in the email body' do
expect(mail.body.encoded).to include(account.name)
end
it 'includes the deletion date in the email body' do
expect(mail.body.encoded).to include(deletion_date)
end
it 'includes a link to cancel the deletion' do
expect(mail.body.encoded).to include('Cancel Account Deletion')
end
context 'when reason is manual_deletion' do
it 'includes the administrator message' do
expect(mail.body.encoded).to include('This action was requested by one of the administrators of your account')
end
end
context 'when reason is not manual_deletion' do
let(:reason) { 'inactivity' }
it 'includes the reason directly' do
expect(mail.body.encoded).to include('Reason for deletion: inactivity')
end
end
end
describe '#account_deletion_for_inactivity' do
it 'sets the correct subject for system-initiated deletion' do
mail = mailer.account_deletion_for_inactivity(account, 'Account Inactive')
expect(mail.subject).to eq('Your Chatwoot account is scheduled for deletion due to inactivity')
describe 'contact_import_complete' do
let!(:data_import) { build(:data_import, total_records: 10, processed_records: 8) }
let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Contact Import Completed')
end
it 'renders the processed records' do
expect(mail.body.encoded).to include('Number of records imported: 8')
expect(mail.body.encoded).to include('Number of records failed: 2')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
end
describe '#format_deletion_date' do
it 'formats a valid date string' do
date_str = '2024-12-31T23:59:59Z'
formatted = described_class.new.send(:format_deletion_date, date_str)
expect(formatted).to eq('December 31, 2024')
describe 'contact_import_failed' do
let(:mail) { described_class.with(account: account).contact_import_failed.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Contact Import Failed')
end
it 'handles blank dates' do
formatted = described_class.new.send(:format_deletion_date, nil)
expect(formatted).to eq('Unknown')
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
end
describe 'contact_export_complete' do
let!(:file_url) { 'http://test.com/test' }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url, admin.email).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Your contact's export file is available to download.")
end
it 'handles invalid dates' do
formatted = described_class.new.send(:format_deletion_date, 'invalid-date')
expect(formatted).to eq('Unknown')
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
end
describe 'automation_rule_disabled' do
let(:rule) { instance_double(AutomationRule, name: 'Test Rule') }
let(:mail) { described_class.with(account: account).automation_rule_disabled(rule).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Automation rule disabled due to validation errors.')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
it 'includes the rule name in the email body' do
expect(mail.body.encoded).to include('Test Rule')
end
end
end
-53
View File
@@ -613,57 +613,4 @@ RSpec.describe Message do
end
end
end
describe '#should_index?' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:message) { create(:message, conversation: conversation, account: account) }
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
account.enable_features('advanced_search')
end
context 'when advanced search is not allowed globally' do
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
end
it 'returns false' do
expect(message.should_index?).to be false
end
end
context 'when advanced search feature is not enabled for account' do
before do
account.disable_features('advanced_search')
end
it 'returns false' do
expect(message.should_index?).to be false
end
end
context 'when message type is not incoming or outgoing' do
before do
message.message_type = 'activity'
end
it 'returns false' do
expect(message.should_index?).to be false
end
end
context 'when all conditions are met' do
it 'returns true for incoming message' do
message.message_type = 'incoming'
expect(message.should_index?).to be true
end
it 'returns true for outgoing message' do
message.message_type = 'outgoing'
expect(message.should_index?).to be true
end
end
end
end
+2
View File
@@ -1,5 +1,7 @@
require 'simplecov'
require 'webmock/rspec'
SimpleCov.start 'rails'
WebMock.disable_net_connect!(allow_localhost: true)
RSpec.configure do |config|
+2
View File
@@ -1,4 +1,6 @@
ENV['RAILS_ENV'] ||= 'test'
require 'simplecov'
SimpleCov.start 'rails'
require File.expand_path('../config/environment', __dir__)
require 'rails/test_help'