Compare commits

...
Author SHA1 Message Date
Vishnu Narayanan 04c7349499 chore: refactor 2024-12-19 11:29:18 +05:30
Vishnu Narayanan 85a987400f chore: move fetch_imap_emails_job to scheduled_jobs queue 2024-12-19 11:27:12 +05:30
Vishnu Narayanan dd25751a19 chore: fix tests 2024-12-19 09:31:50 +05:30
Vishnu Narayanan 07c920f693 chore: fix tests 2024-12-18 16:59:38 +05:30
Vishnu NarayananandGitHub 1c10d97c76 Merge branch 'develop' into feat/sidekiq_tuning 2024-12-18 16:20:57 +05:30
Sojan 9279175199 Merge branch 'release/3.16.0' into develop 2024-12-17 23:03:32 +05:30
Sojan 361878d346 Bump version to 3.16.0 2024-12-17 23:02:59 +05:30
Sivin VargheseandGitHub 2a4c0dfa2a chore: Adds the ability to remove labels from label card (#10591) 2024-12-17 18:43:36 +05:30
Sivin VargheseandGitHub 6b348da807 feat(v4): Compose a new conversation from a phone number. (#10568) 2024-12-17 18:07:58 +05:30
Vishnu Narayanan 66d013c0d4 debug: move imap_inbox_fetch jobs to 1_min_mailer queue 2024-12-17 17:28:32 +05:30
96ae298464 fix: Dotenv::Railtie is deprecated (#10515)
https://github.com/bkeepers/dotenv/pull/468

Renames Dotenv::Railtie => Dotenv::Rails

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-17 17:20:44 +05:30
932244a1ec feat: Add support for Arcade videos on articles (#10585)
Fixes
https://linear.app/chatwoot/issue/CW-3779/add-support-for-arcade-videos-on-articles-loom-alternative

**Loom video**

https://www.loom.com/share/917bdecb4eaf4d3f9782b4fa84ee4bd4?sid=d11f0d71-0cf5-424a-9268-9d9fb3797ee2


Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-17 17:17:42 +05:30
PranavandGitHub d69571f6f8 fix: Update old data to fix login issues (#10594)
The following lines caused issues for some users, specifically those who
signed up in 2021 when audio alerts were implemented as a flag. The data
type update for the flag was not handled correctly. This PR fixes the
issue by updating it to a compatible value.


https://github.com/chatwoot/chatwoot/blob/9410b3bcbbb7aba4b790f33bea52a430bf69466c/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js#L76-L81
2024-12-17 17:17:11 +05:30
Sojan JoseandGitHub 1d88e0dd28 fix: Contact form breaks if name is empty (#10597)
- Handles the case where the form and contact display page breaks if
name is `null`
2024-12-17 17:16:50 +05:30
Shivam MishraandGitHub b34dac7bbe feat: validate query conditions (#10595)
Query conditions can take in arbitrary values, this can cause SQL
errors. This PR fixes it
2024-12-17 17:16:37 +05:30
Vishnu NarayananandGitHub 9ac6b86e42 Merge branch 'develop' into feat/sidekiq_tuning 2024-12-17 16:57:58 +05:30
Vishnu NarayananandGitHub e3109dbb22 chore: pin bundler version to 2.5.x in circleci (#10596) 2024-12-17 16:31:55 +05:30
Vishnu NarayananandGitHub cc71a86651 Merge branch 'develop' into feat/sidekiq_tuning 2024-12-14 12:01:34 +05:30
Vishnu Narayanan 39cec8b42d chore: refactor 2024-12-02 16:58:17 +05:30
Vishnu Narayanan 5de4341222 chore 2024-11-25 17:44:06 +05:30
Vishnu Narayanan b7cedb673e chore: split queues between workers 2024-11-25 17:43:24 +05:30
Vishnu Narayanan 926aa8559d chjore 2024-11-25 17:41:25 +05:30
Vishnu Narayanan d16486ffdc feat: split sidekiq queues into multiple workers 2024-11-25 17:39:15 +05:30
37 changed files with 852 additions and 107 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
source ~/.rvm/scripts/rvm
rvm install "3.3.3"
rvm use 3.3.3 --default
gem install bundler
gem install bundler -v 2.5.16
- run:
name: Install Application Dependencies
+2 -1
View File
@@ -1,3 +1,4 @@
release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare && echo $SOURCE_VERSION > .git_sha
web: bundle exec rails ip_lookup:setup && bin/rails server -p $PORT -e $RAILS_ENV
worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq.yml
default_worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq/default.yml
quarantine_worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq/quarantine.yml
@@ -68,6 +68,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contacts = fetch_contacts(contacts)
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
@@ -46,6 +46,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
+8
View File
@@ -81,4 +81,12 @@ module FilterHelper
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
operator = condition['query_operator'].upcase
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
end
end
@@ -18,6 +18,11 @@ const route = useRoute();
const showDropdown = ref(false);
// Store the currently hovered label's ID
// Using JS state management instead of CSS :hover / group hover
// This will solve the flickering issue when hovering over the last label item
const hoveredLabel = ref(null);
const allLabels = useMapGetter('labels/getLabels');
const contactLabels = useMapGetter('contactLabels/getContactLabels');
@@ -37,7 +42,7 @@ const labelMenuItems = computed(() => {
isSelected: savedLabels.value.some(
savedLabel => savedLabel.id === label.id
),
action: 'addLabel',
action: 'contactLabel',
}))
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
});
@@ -49,7 +54,7 @@ const fetchLabels = async contactId => {
store.dispatch('contactLabels/get', contactId);
};
const handleLabelAction = async ({ action, value }) => {
const handleLabelAction = async ({ value }) => {
try {
// Get current label titles
const currentLabels = savedLabels.value.map(label => label.title);
@@ -59,16 +64,15 @@ const handleLabelAction = async ({ action, value }) => {
if (!selectedLabel) return;
let updatedLabels;
if (action === 'addLabel') {
// If label is already selected, remove it (toggle behavior)
if (currentLabels.includes(selectedLabel.title)) {
updatedLabels = currentLabels.filter(
labelTitle => labelTitle !== selectedLabel.title
);
} else {
// Add the new label
updatedLabels = [...currentLabels, selectedLabel.title];
}
// If label is already selected, remove it (toggle behavior)
if (currentLabels.includes(selectedLabel.title)) {
updatedLabels = currentLabels.filter(
labelTitle => labelTitle !== selectedLabel.title
);
} else {
// Add the new label
updatedLabels = [...currentLabels, selectedLabel.title];
}
await store.dispatch('contactLabels/update', {
@@ -82,6 +86,10 @@ const handleLabelAction = async ({ action, value }) => {
}
};
const handleRemoveLabel = labelId => {
return handleLabelAction({ value: labelId });
};
watch(
() => props.contactId,
(newVal, oldVal) => {
@@ -95,11 +103,31 @@ onMounted(() => {
fetchLabels(route.params.contactId);
}
});
const handleMouseLeave = () => {
// Reset hover state when mouse leaves the container
// This ensures all labels return to their default state
hoveredLabel.value = null;
};
const handleLabelHover = labelId => {
// Added this to prevent flickering on when showing remove button on hover
// If the label item is at end of the line, it will show the remove button
// when hovering over the last label item
hoveredLabel.value = labelId;
};
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<LabelItem v-for="label in savedLabels" :key="label.id" :label="label" />
<div class="flex flex-wrap items-center gap-2" @mouseleave="handleMouseLeave">
<LabelItem
v-for="label in savedLabels"
:key="label.id"
:label="label"
:is-hovered="hoveredLabel === label.id"
@remove="handleRemoveLabel"
@hover="handleLabelHover(label.id)"
/>
<AddLabel
:label-menu-items="labelMenuItems"
@update-label="handleLabelAction"
@@ -94,7 +94,7 @@ const prepareStateBasedOnProps = () => {
phoneNumber,
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name);
const { firstName, lastName } = splitName(name || '');
const {
description = '',
companyName = '',
@@ -1,22 +1,56 @@
<script setup>
defineProps({
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
label: {
type: Object,
default: null,
},
isHovered: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['remove', 'hover']);
const handleRemoveLabel = () => {
emit('remove', props.label?.id);
};
const handleMouseEnter = () => {
// Notify parent component when this label is hovered
// Added this to show the remove button with transition when hovering over the label
// This will solve the flickering issue when hovering over the last label item
emit('hover', props.label?.id);
};
</script>
<template>
<div
class="bg-n-alpha-2 rounded-md flex items-center h-7 w-fit py-1 ltr:pl-1 rtl:pr-1 ltr:pr-1.5 rtl:pl-1.5"
class="flex items-center px-1 py-1 overflow-hidden transition-all duration-300 ease-out rounded-md bg-n-alpha-2 h-7"
@mouseenter="handleMouseEnter"
>
<div
class="w-2 h-2 m-1 rounded-sm"
:style="{ backgroundColor: label.color }"
/>
<span class="text-sm text-n-slate-12">
<span class="text-sm text-n-slate-12 ltr:mr-px rtl:ml-px">
{{ label.title }}
</span>
<div
class="w-0 flex relative ltr:left-1 rtl:right-1 flex-shrink-0 overflow-hidden transition-[width] duration-300 ease-out"
:class="{ 'w-6': isHovered }"
>
<Button
class="transition-opacity duration-200 !h-7 ltr:rounded-r-md rtl:rounded-l-md ltr:rounded-l-none rtl:rounded-r-none w-6 bg-transparent"
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
slate
xs
faded
icon="i-lucide-x"
@click="handleRemoveLabel"
/>
</div>
</div>
</template>
@@ -1,9 +1,11 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { INPUT_TYPES } from 'dashboard/components-next/taginput/helper/tagInputHelper.js';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
contacts: {
type: Array,
@@ -42,15 +44,19 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'searchContacts',
'setSelectedContact',
'clearSelectedContact',
'updateDropdown',
]);
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
const { t } = useI18n();
const inputType = ref(INPUT_TYPES.EMAIL);
const contactsList = computed(() => {
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
id,
@@ -80,6 +86,14 @@ const errorClass = computed(() => {
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: '';
});
const handleInput = value => {
// Update input type based on whether input starts with '+'
// If it does, set input type to 'tel'
// Otherwise, set input type to 'email'
inputType.value = value.startsWith('+') ? INPUT_TYPES.TEL : INPUT_TYPES.EMAIL;
emit('searchContacts', value);
};
</script>
<template>
@@ -126,11 +140,11 @@ const errorClass = computed(() => {
:is-loading="isLoading"
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
allow-create
type="email"
:type="inputType"
class="flex-1 min-h-7"
:class="errorClass"
focus-on-mount
@input="emit('searchContacts', $event)"
@input="handleInput"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@remove="emit('clearSelectedContact')"
@@ -193,10 +193,12 @@ export const searchContacts = async ({ keys, query }) => {
return filteredPayload || [];
};
export const createNewContact = async email => {
export const createNewContact = async input => {
const payload = {
name: getCapitalizedNameFromEmail(email),
email,
name: input.startsWith('+')
? input.slice(1) // Remove the '+' prefix if it exists
: getCapitalizedNameFromEmail(input),
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
};
const {
@@ -429,6 +429,28 @@ describe('composeConversationHelper', () => {
email: 'john@example.com',
});
});
it('creates new contact with phone number', async () => {
const mockContact = {
id: 1,
name: '919999999999',
phone_number: '+919999999999',
};
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('+919999999999');
expect(result).toEqual({
id: 1,
name: '919999999999',
phoneNumber: '+919999999999',
});
expect(ContactAPI.create).toHaveBeenCalledWith({
name: '919999999999',
phone_number: '+919999999999',
});
});
});
describe('fetchContactableInboxes', () => {
@@ -1,16 +1,24 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import {
MODE,
INPUT_TYPES,
getValidationRules,
checkTagTypeValidity,
buildTagMenuItems,
canAddTag,
findMatchingMenuItem,
} from './helper/tagInputHelper';
const props = defineProps({
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
type: { type: String, default: 'text' },
type: { type: String, default: INPUT_TYPES.TEXT },
isLoading: { type: Boolean, default: false },
menuItems: {
type: Array,
@@ -23,8 +31,8 @@ const props = defineProps({
showDropdown: { type: Boolean, default: false },
mode: {
type: String,
default: 'multiple',
validator: value => ['single', 'multiple'].includes(value),
default: MODE.MULTIPLE,
validator: value => [MODE.SINGLE, MODE.MULTIPLE].includes(value),
},
focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
@@ -45,22 +53,17 @@ const modelValue = defineModel({
default: () => [],
});
const MODE = {
SINGLE: 'single',
MULTIPLE: 'multiple',
};
const tagInputRef = ref(null);
const tags = ref(props.modelValue);
const newTag = ref('');
const isFocused = ref(true);
const rules = computed(() => ({
newTag: props.type === 'email' ? { email } : {},
}));
const rules = computed(() => getValidationRules(props.type));
const v$ = useVuelidate(rules, { newTag });
const isNewTagInValidType = computed(() => v$.value.$invalid);
const isNewTagInValidType = computed(() =>
checkTagTypeValidity(props.type, newTag.value, v$.value)
);
const showInput = computed(() =>
props.mode === MODE.SINGLE
@@ -74,68 +77,49 @@ const showDropdownMenu = computed(() =>
: props.showDropdown
);
const filteredMenuItems = computed(() => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return [];
const availableMenuItems = props.menuItems.filter(
item => !tags.value.includes(item.label)
);
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. Email validation passes (if type is email) and There are no menu items available
const trimmedNewTag = newTag.value?.trim();
const shouldShowTypedValue =
trimmedNewTag &&
!tags.value.includes(trimmedNewTag) &&
!props.isLoading &&
!availableMenuItems.length &&
(props.type === 'email' ? !isNewTagInValidType.value : true);
if (shouldShowTypedValue) {
return [
{
label: trimmedNewTag,
value: trimmedNewTag,
email: trimmedNewTag,
thumbnail: { name: trimmedNewTag, src: '' },
action: 'create',
},
];
}
return availableMenuItems;
});
const emitDataOnAdd = emailValue => {
const matchingMenuItem = props.menuItems.find(
item => item.email === emailValue
);
const filteredMenuItems = computed(() =>
buildTagMenuItems({
mode: props.mode,
tags: tags.value,
menuItems: props.menuItems,
newTag: newTag.value,
isLoading: props.isLoading,
type: props.type,
isNewTagInValidType: isNewTagInValidType.value,
})
);
const emitDataOnAdd = value => {
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
return matchingMenuItem
? emit('add', { email: emailValue, ...matchingMenuItem })
: emit('add', { value: emailValue, action: 'create' });
? emit('add', { value: value, ...matchingMenuItem })
: emit('add', { value: value, action: 'create' });
};
const updateValueAndFocus = value => {
tags.value.push(value);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
};
const addTag = async () => {
const trimmedTag = newTag.value?.trim();
if (!trimmedTag) return;
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
if (!canAddTag(props.mode, tags.value.length)) {
newTag.value = '';
return;
}
if (props.type === 'email' || props.allowCreate) {
if (
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
props.allowCreate
) {
if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag);
}
tags.value.push(trimmedTag);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
updateValueAndFocus(trimmedTag);
};
const removeTag = index => {
@@ -144,19 +128,25 @@ const removeTag = index => {
emit('remove');
};
const handleDropdownAction = async ({ email: emailAddress, ...rest }) => {
const handleDropdownAction = async ({
email: emailAddress,
phoneNumber,
...rest
}) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (!props.showDropdown) return;
if (props.type === 'email' && props.showDropdown) {
newTag.value = emailAddress;
if (!(await v$.value.$validate())) return;
emit('add', { email: emailAddress, ...rest });
}
const isEmail = props.type === 'email';
newTag.value = isEmail ? emailAddress : phoneNumber;
tags.value.push(emailAddress);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
if (!(await v$.value.$validate())) return;
const payload = isEmail
? { email: emailAddress, ...rest }
: { phoneNumber, ...rest };
emit('add', payload);
updateValueAndFocus(emailAddress);
};
const handleFocus = () => {
@@ -226,7 +216,6 @@ const handleBlur = e => emit('blur', e);
ref="tagInputRef"
v-model="newTag"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
class="w-full"
:focus-on-mount="focusOnMount"
@@ -0,0 +1,299 @@
import {
validatePhoneNumber,
formatPhoneNumber,
buildTagMenuItems,
MODE,
INPUT_TYPES,
getValidationRules,
validateAndFormatNewTag,
createNewTagMenuItem,
canAddTag,
findMatchingMenuItem,
} from '../tagInputHelper';
import { email } from '@vuelidate/validators';
describe('tagInputHelper', () => {
describe('validatePhoneNumber', () => {
it('returns true for empty value', () => {
expect(validatePhoneNumber('')).toBe(true);
});
it('validates correct phone number', () => {
expect(validatePhoneNumber('+918283838283')).toBe(true);
});
it('validates correct phone number id + is present and number is not valid', () => {
expect(validatePhoneNumber('+91828383834283')).toBe(false);
});
it('validates correct phone number if + is not present', () => {
expect(validatePhoneNumber('91828383834283')).toBe(false);
});
it('invalidates incorrect phone number', () => {
expect(validatePhoneNumber('invalid')).toBe(false);
});
it('handles null value', () => {
expect(validatePhoneNumber(null)).toBe(true);
});
});
describe('formatPhoneNumber', () => {
it('formats valid phone number', () => {
const result = formatPhoneNumber('+918283838283');
expect(result.isValid).toBe(true);
expect(result.formattedValue).toBe('+91 82838 38283');
});
it('handles invalid phone number', () => {
const result = formatPhoneNumber('invalid');
expect(result.isValid).toBe(false);
expect(result.formattedValue).toBe('invalid');
});
it('handles error case', () => {
const result = formatPhoneNumber(null);
expect(result.isValid).toBe(false);
expect(result.formattedValue).toBe(null);
});
});
describe('getValidationRules', () => {
it('returns email validation for email type', () => {
const rules = getValidationRules(INPUT_TYPES.EMAIL);
expect(rules.newTag).toHaveProperty('email');
expect(rules.newTag).not.toHaveProperty('isValidPhone');
expect(rules.newTag.email).toBe(email);
});
it('returns phone validation for tel type', () => {
const rules = getValidationRules(INPUT_TYPES.TEL);
expect(rules.newTag).toHaveProperty('isValidPhone');
expect(rules.newTag).not.toHaveProperty('email');
expect(rules.newTag.isValidPhone).toBe(validatePhoneNumber);
});
it('returns empty rules for text type', () => {
const rules = getValidationRules(INPUT_TYPES.TEXT);
expect(Object.keys(rules.newTag)).toHaveLength(0);
});
});
describe('validateAndFormatNewTag', () => {
it('validates and formats email tag', () => {
const result = validateAndFormatNewTag(
'test@example.com',
INPUT_TYPES.EMAIL,
false
);
expect(result).toEqual({
isValid: true,
formattedValue: 'test@example.com',
});
});
it('validates and formats phone tag', () => {
const result = validateAndFormatNewTag(
'+918283838283',
INPUT_TYPES.TEL,
false
);
expect(result.isValid).toBe(true);
expect(result.formattedValue).toBe('+91 82838 38283');
});
it('handles invalid email', () => {
const result = validateAndFormatNewTag(
'test@example.com',
INPUT_TYPES.EMAIL,
true
);
expect(result.isValid).toBe(false);
expect(result.formattedValue).toBe('test@example.com');
});
it('handles text type', () => {
const result = validateAndFormatNewTag(
'sample text',
INPUT_TYPES.TEXT,
false
);
expect(result.isValid).toBe(true);
expect(result.formattedValue).toBe('sample text');
});
});
describe('createNewTagMenuItem', () => {
it('creates email menu item', () => {
const result = createNewTagMenuItem(
'test@example.com',
'test@example.com',
INPUT_TYPES.EMAIL
);
expect(result).toEqual({
label: 'test@example.com',
value: 'test@example.com',
email: 'test@example.com',
thumbnail: { name: 'test@example.com', src: '' },
action: 'create',
});
});
it('creates phone menu item', () => {
const result = createNewTagMenuItem(
'+91 82838 38283',
'+918283838283',
INPUT_TYPES.TEL
);
expect(result).toEqual({
label: '+91 82838 38283',
value: '+918283838283',
phoneNumber: '+918283838283',
thumbnail: { name: '+91 82838 38283', src: '' },
action: 'create',
});
});
it('creates text menu item', () => {
const result = createNewTagMenuItem(
'sample text',
'sample text',
INPUT_TYPES.TEXT
);
expect(result).toEqual({
label: 'sample text',
value: 'sample text',
thumbnail: { name: 'sample text', src: '' },
action: 'create',
});
});
});
describe('buildTagMenuItems', () => {
const baseParams = {
mode: MODE.MULTIPLE,
tags: [],
menuItems: [],
newTag: '',
isLoading: false,
type: INPUT_TYPES.TEXT,
isNewTagInValidType: false,
};
it('returns empty array in single mode with existing tag', () => {
const result = buildTagMenuItems({
...baseParams,
mode: MODE.SINGLE,
tags: ['existing'],
});
expect(result).toEqual([]);
});
it('filters out existing tags', () => {
const result = buildTagMenuItems({
...baseParams,
menuItems: [
{ label: 'item1', value: '1' },
{ label: 'item2', value: '2' },
],
tags: ['item1'],
});
expect(result).toHaveLength(1);
expect(result[0].label).toBe('item2');
});
it('creates new email item when valid', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
menuItems: [],
});
expect(result[0]).toMatchObject({
label: 'test@example.com',
email: 'test@example.com',
action: 'create',
});
});
it('creates new phone item when valid', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.TEL,
newTag: '+918283838283',
menuItems: [],
});
expect(result[0]).toMatchObject({
value: '+918283838283',
label: '+91 82838 38283',
action: 'create',
});
});
it('returns empty array when loading', () => {
const result = buildTagMenuItems({
...baseParams,
isLoading: true,
newTag: 'test',
});
expect(result).toEqual([]);
});
it('returns empty array for invalid tag', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'invalid-email',
isNewTagInValidType: true,
});
expect(result).toEqual([]);
});
it('returns available menu items when no new tag', () => {
const menuItems = [
{ label: 'item1', value: '1' },
{ label: 'item2', value: '2' },
];
const result = buildTagMenuItems({
...baseParams,
menuItems,
});
expect(result).toEqual(menuItems);
});
});
describe('canAddTag', () => {
it('prevents adding tags in single mode when tag exists', () => {
expect(canAddTag(MODE.SINGLE, 1)).toBe(false);
expect(canAddTag(MODE.SINGLE, 0)).toBe(true);
});
it('allows adding tags in multiple mode', () => {
expect(canAddTag(MODE.MULTIPLE, 1)).toBe(true);
expect(canAddTag(MODE.MULTIPLE, 0)).toBe(true);
});
});
describe('findMatchingMenuItem', () => {
const menuItems = [
{ email: 'test1@example.com', label: 'Test 1' },
{ email: 'test2@example.com', label: 'Test 2' },
];
it('finds matching menu item by email', () => {
const result = findMatchingMenuItem(menuItems, 'test1@example.com');
expect(result).toEqual(menuItems[0]);
});
it('returns undefined when no match found', () => {
const result = findMatchingMenuItem(menuItems, 'nonexistent@example.com');
expect(result).toBeUndefined();
});
it('handles empty menu items', () => {
const result = findMatchingMenuItem([], 'test@example.com');
expect(result).toBeUndefined();
});
});
});
@@ -0,0 +1,124 @@
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js';
import { email } from '@vuelidate/validators';
export const MODE = {
SINGLE: 'single',
MULTIPLE: 'multiple',
};
export const INPUT_TYPES = {
EMAIL: 'email',
TEL: 'tel',
TEXT: 'text',
};
export const validatePhoneNumber = value => {
if (!value) return true;
try {
return isValidPhoneNumber(value);
} catch (error) {
return false;
}
};
export const formatPhoneNumber = value => {
try {
const phoneNumber = parsePhoneNumber(value);
return {
isValid: phoneNumber?.isValid() || false,
formattedValue: phoneNumber?.formatInternational() || value,
};
} catch (error) {
return { isValid: false, formattedValue: value };
}
};
export const getValidationRules = type => ({
newTag: {
...(type === INPUT_TYPES.EMAIL ? { email } : {}),
...(type === INPUT_TYPES.TEL ? { isValidPhone: validatePhoneNumber } : {}),
},
});
export const checkTagTypeValidity = (type, value, v$) => {
if (type === INPUT_TYPES.TEL) {
return !validatePhoneNumber(value);
}
return v$.$invalid;
};
export const validateAndFormatNewTag = (
trimmedNewTag,
type,
isNewTagInValidType
) => {
let isValid = true;
let formattedValue = trimmedNewTag;
if (type === INPUT_TYPES.EMAIL) {
isValid = !isNewTagInValidType;
} else if (type === INPUT_TYPES.TEL) {
const { isValid: phoneValid, formattedValue: phoneFormatted } =
formatPhoneNumber(trimmedNewTag);
isValid = phoneValid;
formattedValue = phoneFormatted;
}
return { isValid, formattedValue };
};
export const createNewTagMenuItem = (formattedValue, trimmedNewTag, type) => ({
label: formattedValue,
value: trimmedNewTag,
...(type === INPUT_TYPES.EMAIL ? { email: trimmedNewTag } : {}),
...(type === INPUT_TYPES.TEL ? { phoneNumber: trimmedNewTag } : {}),
thumbnail: { name: formattedValue, src: '' },
action: 'create',
});
export const buildTagMenuItems = ({
mode,
tags,
menuItems,
newTag,
isLoading,
type,
isNewTagInValidType,
}) => {
if (mode === MODE.SINGLE && tags.length >= 1) return [];
const availableMenuItems = menuItems.filter(
item => !tags.includes(item.label)
);
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. Validation passes (email/phone) and There are no menu items available
const trimmedNewTag = newTag?.trim();
const shouldShowTypedValue =
trimmedNewTag &&
!tags.includes(trimmedNewTag) &&
!isLoading &&
!availableMenuItems.length;
if (shouldShowTypedValue) {
const { isValid, formattedValue } = validateAndFormatNewTag(
trimmedNewTag,
type,
isNewTagInValidType
);
if (isValid) {
return [createNewTagMenuItem(formattedValue, trimmedNewTag, type)];
}
}
return availableMenuItems;
};
export const canAddTag = (mode, tagsLength) =>
!(mode === MODE.SINGLE && tagsLength >= 1);
export const findMatchingMenuItem = (menuItems, value) =>
menuItems.find(item => item.email === value);
@@ -1,5 +1,5 @@
class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
queue_as :scheduled_jobs
queue_as :one_min_mailer
def perform
Inbox.where(channel_type: 'Channel::Email').all.find_each(batch_size: 100) do |inbox|
+19
View File
@@ -27,6 +27,7 @@ class AutomationRule < ApplicationRecord
validate :json_conditions_format
validate :json_actions_format
validate :query_operator_presence
validate :query_operator_value
validates :account_id, presence: true
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
@@ -83,6 +84,24 @@ class AutomationRule < ApplicationRecord
operators = conditions.select { |obj, _| obj['query_operator'].nil? }
errors.add(:conditions, 'Automation conditions should have query operator.') if operators.length > 1
end
# This validation ensures logical operators are being used correctly in automation conditions.
# And we don't push any unsanitized query operators to the database.
def query_operator_value
conditions.each do |obj|
validate_single_condition(obj)
end
end
def validate_single_condition(condition)
query_operator = condition['query_operator']
return if query_operator.nil?
return if query_operator.empty?
operator = query_operator.upcase
errors.add(:conditions, 'Query operator must be either "AND" or "OR"') unless %w[AND OR].include?(operator)
end
end
AutomationRule.include_mod_with('Audit::AutomationRule')
@@ -15,7 +15,7 @@ class AutomationRules::ConditionValidationService
def perform
@rule.conditions.each do |condition|
return false unless valid_condition?(condition)
return false unless valid_condition?(condition) && valid_query_operator?(condition)
end
true
@@ -23,6 +23,15 @@ class AutomationRules::ConditionValidationService
private
def valid_query_operator?(condition)
query_operator = condition['query_operator']
return true if query_operator.nil?
return true if query_operator.empty?
%w[AND OR].include?(query_operator.upcase)
end
def valid_condition?(condition)
key = condition['attribute_key']
+1
View File
@@ -9,6 +9,7 @@ class Contacts::FilterService < FilterService
end
def perform
validate_query_operator
@contacts = query_builder(@filters['contacts'])
{
@@ -7,6 +7,7 @@ class Conversations::FilterService < FilterService
end
def perform
validate_query_operator
@conversations = query_builder(@filters['conversations'])
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
+6
View File
@@ -204,4 +204,10 @@ class FilterService
end
base_relation.where(@query_string, @filter_values.with_indifferent_access)
end
def validate_query_operator
@params[:payload].each do |query_hash|
validate_single_condition(query_hash)
end
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '3.15.0'
version: '3.16.0'
development:
<<: *shared
+1 -1
View File
@@ -11,7 +11,7 @@ Bundler.require(*Rails.groups)
## Load the specific APM agent
# We rely on DOTENV to load the environment variables
# We need these environment variables to load the specific APM agent
Dotenv::Railtie.load
Dotenv::Rails.load
require 'ddtrace' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
require 'elastic-apm' if ENV.fetch('ELASTIC_APM_SECRET_TOKEN', false).present?
require 'scout_apm' if ENV.fetch('SCOUT_KEY', false).present?
+1
View File
@@ -80,6 +80,7 @@ en:
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
reports:
period: Reporting period %{since} to %{until}
+1 -1
View File
@@ -18,7 +18,7 @@ trigger_scheduled_items_job:
trigger_imap_email_inboxes_job:
cron: '*/1 * * * *'
class: 'Inboxes::FetchImapEmailInboxesJob'
queue: scheduled_jobs
queue: one_min_mailer
# executed daily at 2230 UTC
# which is our lowest traffic time
+29
View File
@@ -0,0 +1,29 @@
# Sample configuration file for Sidekiq.
# Options here can still be overridden by cmd line args.
# Place this file at config/sidekiq.yml and Sidekiq will
# pick it up automatically.
---
:verbose: false
:concurrency: <%= ENV.fetch("SIDEKIQ_CONCURRENCY", 10) %>
:timeout: 25
:max_retries: 3
# Sidekiq will run this file through ERB when reading it so you can
# even put in dynamic logic, like a host-specific queue.
# http://www.mikeperham.com/2013/11/13/advanced-sidekiq-host-specific-queues/
# https://github.com/sidekiq/sidekiq/wiki/Advanced-Options
# Since queues are declared without waits, the jobs in lower ranking queues will only be processed
# if there are no jobs in higher ranking queues.
:queues:
- critical
- high
- medium
- default
- mailers
- action_mailbox_routing
- low
- one_min_mailer
- async_database_migration
- active_storage_analysis
- active_storage_purge
- action_mailbox_incineration
+18
View File
@@ -0,0 +1,18 @@
# Sample configuration file for Sidekiq.
# Options here can still be overridden by cmd line args.
# Place this file at config/sidekiq.yml and Sidekiq will
# pick it up automatically.
---
:verbose: false
:concurrency: <%= ENV.fetch("SIDEKIQ_CONCURRENCY", 10) %>
:timeout: 25
:max_retries: 3
# Sidekiq will run this file through ERB when reading it so you can
# even put in dynamic logic, like a host-specific queue.
# http://www.mikeperham.com/2013/11/13/advanced-sidekiq-host-specific-queues/
# https://github.com/sidekiq/sidekiq/wiki/Advanced-Options
# Since queues are declared without waits, the jobs in lower ranking queues will only be processed
# if there are no jobs in higher ranking queues.
:queues:
- scheduled_jobs
@@ -0,0 +1,18 @@
class FixOldAudioAlertData < ActiveRecord::Migration[7.0]
def change
# rubocop:disable Rails/SkipsModelValidations
# Update users with audio alerts enabled to 'mine'
User.where(
"users.ui_settings #>> '{enable_audio_alerts}' = ?", 'true'
).update_all(
"ui_settings = jsonb_set(ui_settings, '{enable_audio_alerts}', '\"mine\"')"
)
# Update users with audio alerts enabled to 'none'
User
.where("users.ui_settings #>> '{enable_audio_alerts}' = ?", 'true')
.update_all("ui_settings = jsonb_set(ui_settings, '{enable_audio_alerts}', '\"mine\"')")
# rubocop:enable Rails/SkipsModelValidations
end
end
+1 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_09_23_215335) do
ActiveRecord::Schema[7.0].define(version: 2024_12_17_041352) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
+6
View File
@@ -11,6 +11,12 @@ module CustomExceptions::CustomFilter
end
end
class InvalidQueryOperator < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_query_operator')
end
end
class InvalidValue < CustomExceptions::Base
def message
I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name])
+22 -1
View File
@@ -1,8 +1,11 @@
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
# TODO: let move this regex from here to a config file where we can update this list much more easily
# the config file will also have the matching embed template as well.
YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)}
LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)}
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
def text(node)
content = node.string_content
@@ -46,7 +49,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
YOUTUBE_REGEX => :make_youtube_embed,
VIMEO_REGEX => :make_vimeo_embed,
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_embed
LOOM_REGEX => :make_loom_embed,
ARCADE_REGEX => :make_arcade_embed
}
embedding_methods.each do |regex, method|
@@ -118,4 +122,21 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
</video>
)
end
def make_arcade_embed(arcade_match)
video_id = arcade_match[1]
%(
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
<iframe
src="https://app.arcade.software/embed/#{video_id}"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen
allow="fullscreen"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
</iframe>
</div>
)
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "3.15.0",
"version": "3.16.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -76,6 +76,23 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
}
end
it 'processes invalid query operator' do
expect(account.automation_rules.count).to eq(0)
params[:conditions] << {
'attribute_key': 'browser_language',
'filter_operator': 'equal_to',
'values': ['en'],
'query_operator': 'invalid'
}
post "/api/v1/accounts/#{account.id}/automation_rules",
headers: administrator.create_new_auth_token,
params: params
expect(response).to have_http_status(:unprocessable_entity)
expect(account.automation_rules.count).to eq(0)
end
it 'throws an error for unknown attributes in condtions' do
expect(account.automation_rules.count).to eq(0)
params[:conditions] << {
@@ -10,7 +10,7 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
.on_queue('one_min_mailer')
end
context 'when called' do
+28
View File
@@ -137,5 +137,33 @@ describe CustomMarkdownRenderer do
expect(output).to include('src="https://player.vimeo.com/video/1234567"')
end
end
context 'when link is an Arcade URL' do
let(:arcade_url) { 'https://app.arcade.software/share/ARCADE_ID' }
it 'renders an iframe with Arcade embed code' do
output = render_markdown_link(arcade_url)
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
expect(output).to include('<iframe')
expect(output).to include('webkitallowfullscreen')
expect(output).to include('mozallowfullscreen')
expect(output).to include('allowfullscreen')
end
it 'wraps iframe in responsive container' do
output = render_markdown_link(arcade_url)
expect(output).to include('position: relative; padding-bottom: 62.5%; height: 0;')
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
end
end
context 'when multiple links including Arcade are present' do
it 'renders Arcade embed along with other content types' do
markdown = "\n[arcade](https://app.arcade.software/share/ARCADE_ID)\n\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n"
output = render_markdown(markdown)
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
expect(output).to include('src="https://www.youtube.com/embed/VIDEO_ID"')
end
end
end
end
@@ -46,6 +46,17 @@ RSpec.describe AutomationRules::ConditionValidationService do
end
end
context 'with wrong query operator' do
before do
rule.conditions = [{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': 'invalid', 'filter_operator': 'attribute_changed' }]
rule.save
end
it 'returns false' do
expect(described_class.new(rule).perform).to be(false)
end
end
context 'with "attribute_changed" filter operator' do
before do
rule.conditions = [
@@ -146,6 +146,19 @@ describe Contacts::FilterService do
expect(result[:contacts].length).to be 1
expect(result[:contacts].first.id).to eq el_contact.id
end
it 'handles invalid query conditions' do
params[:payload] = [
{
attribute_key: 'labels',
filter_operator: 'is_not_present',
values: [],
query_operator: 'INVALID'
}.with_indifferent_access
]
expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
end
end
context 'with standard attributes - last_activity_at' do
@@ -185,6 +185,30 @@ describe Conversations::FilterService do
expect(result[:count][:all_count]).to be 2
expect(result[:conversations].pluck(:campaign_id).sort).to eq [campaign_2.id, campaign_1.id].sort
end
it 'handles invalid query conditions' do
params[:payload] = [
{
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: [
user_1.id,
user_2.id
],
query_operator: 'INVALID',
custom_attribute_type: ''
}.with_indifferent_access,
{
attribute_key: 'campaign_id',
filter_operator: 'is_present',
values: [],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
expect { filter_service.new(params, user_1).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
end
end
end