Compare commits

..
Author SHA1 Message Date
Vishnu Narayanan f16c7e59b8 perf: use update_columns for contact last_activity_at
Replace `sender.update(last_activity_at:)` with `update_columns` in
Message#update_contact_activity. This runs on every incoming message
(3.3M calls/day) and the full ActiveRecord update lifecycle is
unnecessary for a single timestamp write:

- Skips before_save :sync_contact_attributes (only relevant for
email/phone/identifier changes, not last_activity_at)
- Skips after_update_commit :dispatch_update_event which fires
CONTACT_UPDATED → ActionCable broadcasts, webhook deliveries,
and LeadSquared hooks on every incoming message with no
meaningful data change
- Eliminates 192K seconds/day of DB time from contact UPDATEs
that trigger full validation and callback chains
2026-02-17 18:40:36 +05:30
84 changed files with 1945 additions and 2155 deletions
-9
View File
@@ -4,11 +4,6 @@
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
@@ -98,7 +93,3 @@ Practical checklist for any change impacting core logic or public APIs
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
## Branding / White-labeling note
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
+1 -1
View File
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents'
gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
+8 -8
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.9.0)
ruby_llm (~> 1.9.1)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -314,7 +314,7 @@ GEM
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.4.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
@@ -540,7 +540,7 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.1.0)
marcel (1.0.4)
maxminddb (0.1.22)
meta_request (0.8.5)
rack-contrib (>= 1.1, < 3)
@@ -559,7 +559,7 @@ GEM
multi_json (1.15.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
@@ -825,7 +825,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.9.2)
ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -1004,7 +1004,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.4)
zeitwerk (2.6.17)
PLATFORMS
arm64-darwin-20
@@ -1024,7 +1024,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents
ai-agents (>= 0.7.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
+1 -1
View File
@@ -1 +1 @@
4.11.0
4.10.1
@@ -1,35 +0,0 @@
class Webhooks::ShopifyController < ActionController::API
before_action :verify_hmac!
def events
case request.headers['X-Shopify-Topic']
when 'shop/redact'
handle_shop_redact
end
head :ok
end
private
def verify_hmac!
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
return head :unauthorized if secret.blank?
data = request.body.read
request.body.rewind
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
return head :unauthorized if hmac_header.blank?
computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
end
def handle_shop_redact
shop_domain = params[:shop_domain]
return if shop_domain.blank?
Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
end
end
@@ -68,7 +68,8 @@ const hasActiveSegments = computed(
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = () => {
const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
createNewContactDialogRef.value?.dialogRef.open();
};
const openContactImportDialog = () =>
@@ -1,5 +1,5 @@
<script setup>
import { reactive, ref, computed, onMounted, watch } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
@@ -59,23 +59,6 @@ const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const formState = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
const clearFormState = () => {
Object.assign(formState, {
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
};
const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
@@ -157,14 +140,12 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
if (!inbox) clearFormState();
resetContacts();
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
clearFormState();
};
const closeCompose = () => {
@@ -179,12 +160,6 @@ const closeCompose = () => {
emit('close');
};
const discardCompose = () => {
clearFormState();
formState.message = '';
closeCompose();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
@@ -196,7 +171,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
discardCompose();
closeCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success
} catch (error) {
@@ -218,11 +193,7 @@ watch(
(currentContact, previousContact) => {
if (currentContact && props.contactId) {
// Reset on contact change
if (currentContact?.id !== previousContact?.id) {
clearSelectedContact();
clearFormState();
formState.message = '';
}
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(
@@ -294,7 +265,6 @@ useKeyboardEvents(keyboardEvents);
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
@@ -315,7 +285,7 @@ useKeyboardEvents(keyboardEvents);
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="discardCompose"
@discard="closeCompose"
/>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { reactive, ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -37,7 +37,6 @@ const props = defineProps({
contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
formState: { type: Object, required: true },
});
const emit = defineEmits([
@@ -58,13 +57,13 @@ const showBccEmailsDropdown = ref(false);
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
const state = props.formState || {
const state = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
};
});
const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
@@ -95,7 +95,6 @@ const inputClass = computed(() => {
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@@ -128,7 +127,6 @@ const inputClass = computed(() => {
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@@ -56,13 +56,8 @@ const selectedLabel = computed(() => {
});
const selectOption = option => {
if (selectedValue.value === option.value) {
selectedValue.value = '';
emit('update:modelValue', '');
} else {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
}
selectedValue.value = option.value;
emit('update:modelValue', option.value);
open.value = false;
search.value = '';
};
@@ -53,11 +53,6 @@ const props = defineProps({
default: 'lg',
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
},
position: {
type: String,
default: 'center',
validator: value => ['center', 'top'].includes(value),
},
});
const emit = defineEmits(['confirm', 'close']);
@@ -66,7 +61,6 @@ const { t } = useI18n();
const dialogRef = ref(null);
const dialogContentRef = ref(null);
const isOpen = ref(false);
const maxWidthClass = computed(() => {
const classesMap = {
@@ -81,19 +75,13 @@ const maxWidthClass = computed(() => {
return classesMap[props.width] ?? 'max-w-md';
});
const positionClass = computed(() =>
props.position === 'top' ? 'dialog-position-top' : ''
);
const open = () => {
isOpen.value = true;
dialogRef.value?.showModal();
};
const close = () => {
emit('close');
dialogRef.value?.close();
isOpen.value = false;
};
const confirm = () => {
@@ -110,7 +98,6 @@ defineExpose({ open, close });
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="[
maxWidthClass,
positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]"
@close="close"
@@ -118,7 +105,7 @@ defineExpose({ open, close });
<OnClickOutside @trigger="close">
<form
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@submit.prevent="confirm"
@click.stop
>
@@ -132,7 +119,7 @@ defineExpose({ open, close });
</p>
</slot>
</div>
<slot v-if="isOpen" />
<slot />
<!-- Dialog content will be injected here -->
<slot name="footer">
<div
@@ -169,9 +156,4 @@ defineExpose({ open, close });
dialog::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
.dialog-position-top {
margin-top: clamp(2rem, 5vh, 5rem);
margin-bottom: auto;
}
</style>
@@ -4,10 +4,6 @@ defineProps({
type: String,
default: '',
},
height: {
type: String,
default: 'max-h-96',
},
});
</script>
@@ -19,10 +15,7 @@ defineProps({
>
{{ title }}
</div>
<ul
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
:class="height"
>
<ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto">
<slot />
</ul>
</div>
@@ -50,12 +50,12 @@ const currentFilter = computed(() =>
);
const getOperator = (filter, selectedOperator) => {
const operatorFromOptions = filter?.filterOperators?.find(
const operatorFromOptions = filter.filterOperators.find(
operator => operator.value === selectedOperator
);
if (!operatorFromOptions) {
return filter?.filterOperators?.[0];
return filter.filterOperators[0];
}
return operatorFromOptions;
@@ -138,11 +138,7 @@ const validate = () => {
return !validationError.value;
};
const resetValidation = () => {
showErrors.value = false;
};
defineExpose({ validate, resetValidation });
defineExpose({ validate });
</script>
<template>
@@ -170,20 +166,18 @@ defineExpose({ validate, resetValidation });
<FilterSelect
v-model="filterOperator"
variant="ghost"
:options="currentFilter?.filterOperators"
:options="currentFilter.filterOperators"
/>
<template v-if="currentOperator?.hasInput">
<template v-if="currentOperator.hasInput">
<MultiSelect
v-if="inputType === 'multiSelect'"
v-model="values"
:options="currentFilter.options"
dropdown-max-height="max-h-72"
/>
<SingleSelect
v-else-if="inputType === 'searchSelect'"
v-model="values"
:options="currentFilter.options"
dropdown-max-height="max-h-64"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
@@ -45,7 +45,7 @@ const { height } = useWindowSize();
const { height: dropdownHeight } = useElementBounding(dropdownRef);
const selectedOption = computed(() => {
return props.options?.find(o => o.value === selected.value) || {};
return props.options.find(o => o.value === selected.value) || {};
});
const iconToRender = computed(() => {
@@ -87,25 +87,18 @@ const updateSelected = newValue => {
</template>
<DropdownBody
ref="dropdownRef"
class="min-w-56 z-50"
class="min-w-48 z-50"
:class="dropdownPosition"
strong
>
<DropdownSection class="[&>ul]:max-h-72">
<template v-for="option in options" :key="option.value">
<li
v-if="option.disabled"
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
>
{{ option.label }}
</li>
<DropdownItem
v-else
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</template>
<DropdownSection class="[&>ul]:max-h-80">
<DropdownItem
v-for="option in options"
:key="option.value"
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
@@ -8,7 +8,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options, maxChips, dropdownMaxHeight } = defineProps({
const { options, maxChips } = defineProps({
options: {
type: Array,
required: true,
@@ -17,10 +17,6 @@ const { options, maxChips, dropdownMaxHeight } = defineProps({
type: Number,
default: 3,
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
});
const { t } = useI18n();
@@ -127,7 +123,7 @@ const toggleOption = option => {
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownSection :height="dropdownMaxHeight">
<DropdownSection class="[&>ul]:max-h-80">
<DropdownItem
v-for="option in options"
:key="option.id"
@@ -12,12 +12,10 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const {
options,
disableSearch,
disableDeselect,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
dropdownMaxHeight,
} = defineProps({
options: {
type: Array,
@@ -43,14 +41,6 @@ const {
type: String,
default: '',
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
disableDeselect: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -73,8 +63,6 @@ const selectedItem = computed(() => {
const optionToSearch = Array.isArray(selected.value)
? selected.value[0]
: selected.value;
if (!optionToSearch) return null;
// extract the selected item from the options array
// this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id);
@@ -89,7 +77,7 @@ const toggleSelected = option => {
};
if (selected.value && selected.value.id === optionToToggle.id) {
if (!disableDeselect) selected.value = null;
selected.value = null;
} else {
selected.value = optionToToggle;
}
@@ -136,7 +124,7 @@ const toggleSelected = option => {
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection :height="dropdownMaxHeight">
<DropdownSection class="[&>ul]:max-h-80">
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
@@ -43,7 +43,6 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import { useBranding } from 'shared/composables/useBranding';
/**
* @typedef {Object} Attachment
@@ -144,7 +143,6 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
const { replaceInstallationName } = useBranding();
/**
* Computes the message variant based on props
@@ -391,17 +389,13 @@ const shouldRenderMessage = computed(() => {
const isUnsupported = props.contentAttributes?.isUnsupported;
const isAnIntegrationMessage =
props.contentType === CONTENT_TYPES.INTEGRATIONS;
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
const hasExternalError = !!props.contentAttributes?.externalError;
return (
hasAttachments ||
props.content ||
isEmailContentType ||
isUnsupported ||
isAnIntegrationMessage ||
isFailedMessage ||
hasExternalError
isAnIntegrationMessage
);
});
@@ -478,7 +472,7 @@ const avatarInfo = computed(() => {
const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) {
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
return t('CONVERSATION.NATIVE_APP_ADVISORY');
}
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
@@ -12,16 +12,11 @@ defineProps({
const emit = defineEmits(['retry']);
const { orientation, status, createdAt, content, attachments } =
useMessageContext();
const { orientation, status, createdAt } = useMessageContext();
const { t } = useI18n();
const canRetry = computed(() => {
const hasContent = content.value !== null;
const hasAttachments = attachments.value && attachments.value.length > 0;
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
});
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
</script>
<template>
@@ -36,11 +36,6 @@ const props = defineProps({
},
focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
// Skip label-based dedup when the consumer already filters menuItems by ID.
// Prevents removing all same-name items when one is selected (e.g. duplicate agent names).
skipLabelDedup: { type: Boolean, default: false },
// When false, the dropdown won't auto-open on mount; it opens only on click/focus.
autoOpenDropdown: { type: Boolean, default: true },
});
const emit = defineEmits([
@@ -61,7 +56,7 @@ const modelValue = defineModel({
const tagInputRef = ref(null);
const tags = ref(props.modelValue);
const newTag = ref('');
const isFocused = ref(props.autoOpenDropdown);
const isFocused = ref(true);
const rules = computed(() => getValidationRules(props.type));
const v$ = useVuelidate(rules, { newTag });
@@ -79,11 +74,11 @@ const showInput = computed(() =>
const showDropdownMenu = computed(() =>
props.mode === MODE.SINGLE && tags.value.length >= 1
? false
: props.showDropdown && isFocused.value
: props.showDropdown
);
const filteredMenuItems = computed(() => {
const items = buildTagMenuItems({
const filteredMenuItems = computed(() =>
buildTagMenuItems({
mode: props.mode,
tags: tags.value,
menuItems: props.menuItems,
@@ -91,14 +86,8 @@ const filteredMenuItems = computed(() => {
isLoading: props.isLoading,
type: props.type,
isNewTagInValidType: isNewTagInValidType.value,
allowCreate: props.allowCreate,
skipLabelDedup: props.skipLabelDedup,
});
if (props.type !== INPUT_TYPES.TEXT) return items;
const query = newTag.value?.trim()?.toLowerCase();
if (!query) return items;
return items.filter(item => item.label?.toLowerCase().includes(query));
});
})
);
const emitDataOnAdd = value => {
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
@@ -123,13 +112,10 @@ const addTag = async () => {
return;
}
const isValidatedType = [INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(
props.type
);
if (!isValidatedType && !props.allowCreate && props.showDropdown) return;
if (isValidatedType || props.allowCreate) {
if (
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
props.allowCreate
) {
if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag);
}
@@ -139,31 +125,28 @@ const addTag = async () => {
const removeTag = index => {
tags.value.splice(index, 1);
modelValue.value = tags.value;
emit('remove', index);
emit('remove');
};
const handleDropdownAction = async ({
email: emailAddress,
phoneNumber,
label,
...rest
}) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (!props.showDropdown) return;
const isEmail = props.type === INPUT_TYPES.EMAIL;
const tagValue = isEmail ? emailAddress : phoneNumber || label;
const isEmail = props.type === 'email';
newTag.value = isEmail ? emailAddress : phoneNumber;
if (isEmail || props.type === INPUT_TYPES.TEL) {
newTag.value = tagValue;
if (!(await v$.value.$validate())) return;
}
if (!(await v$.value.$validate())) return;
emit(
'add',
isEmail ? { email: emailAddress, ...rest } : { phoneNumber, label, ...rest }
);
updateValueAndFocus(tagValue);
const payload = isEmail
? { email: emailAddress, ...rest }
: { phoneNumber, ...rest };
emit('add', payload);
updateValueAndFocus(emailAddress);
};
const handleFocus = () => {
@@ -180,7 +163,7 @@ const handleKeydown = event => {
};
const handleClickOutside = () => {
isFocused.value = false;
if (tags.value.length) isFocused.value = false;
emit('onClickOutside');
};
@@ -261,71 +261,6 @@ describe('tagInputHelper', () => {
});
expect(result).toEqual(menuItems);
});
it('does not create suggestion when allowCreate is false', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
allowCreate: false,
});
expect(result).toEqual([]);
});
it('still returns available menu items when allowCreate is false', () => {
const menuItems = [
{ label: 'Agent 1', value: '1' },
{ label: 'Agent 2', value: '2' },
];
const result = buildTagMenuItems({
...baseParams,
menuItems,
newTag: 'Agent',
allowCreate: false,
});
expect(result).toEqual(menuItems);
});
it('creates new item when allowCreate is true (default)', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
allowCreate: true,
});
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
label: 'test@example.com',
action: 'create',
});
});
it('skips label dedup when skipLabelDedup is true', () => {
const menuItems = [
{ label: 'HDMA', value: 1 },
{ label: 'HDMA', value: 2 },
];
const result = buildTagMenuItems({
...baseParams,
tags: ['HDMA'],
menuItems,
skipLabelDedup: true,
});
expect(result).toEqual(menuItems);
});
it('filters by label when skipLabelDedup is false (default)', () => {
const menuItems = [
{ label: 'HDMA', value: 1 },
{ label: 'HDMA', value: 2 },
];
const result = buildTagMenuItems({
...baseParams,
tags: ['HDMA'],
menuItems,
});
expect(result).toEqual([]);
});
});
describe('canAddTag', () => {
@@ -84,29 +84,25 @@ export const buildTagMenuItems = ({
isLoading,
type,
isNewTagInValidType,
allowCreate = true,
skipLabelDedup = false,
}) => {
if (mode === MODE.SINGLE && tags.length >= 1) return [];
const availableMenuItems = skipLabelDedup
? menuItems
: menuItems.filter(item => !tags.includes(item.label));
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
// 4. allowCreate is enabled
const trimmedNewTag = newTag?.trim();
const shouldShowCreateSuggestion =
allowCreate &&
const shouldShowTypedValue =
trimmedNewTag &&
!tags.includes(trimmedNewTag) &&
!isLoading &&
!availableMenuItems.length;
if (shouldShowCreateSuggestion) {
if (shouldShowTypedValue) {
const { isValid, formattedValue } = validateAndFormatNewTag(
trimmedNewTag,
type,
@@ -145,7 +145,6 @@ const {
isConversationSelected,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
@@ -860,7 +859,6 @@ provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
@@ -10,7 +10,6 @@ export default {
'assignAgent',
'assignTeam',
'assignLabels',
'removeLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
@@ -64,7 +63,6 @@ export default {
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@remove-label="removeLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@@ -3,9 +3,6 @@ import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput
import AutomationActionFileInput from './AutomationFileInput.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
export default {
components: {
@@ -13,9 +10,6 @@ export default {
AutomationActionFileInput,
WootMessageEditor,
NextButton,
SingleSelect,
MultiSelect,
NextInput,
},
props: {
modelValue: {
@@ -46,10 +40,6 @@ export default {
type: Boolean,
default: false,
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
},
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
computed: {
@@ -79,21 +69,11 @@ export default {
return this.actionTypes.find(action => action.key === this.action_name)
.inputType;
},
actionNameAsSelectModel: {
get() {
if (!this.action_name) return null;
const found = this.actionTypes.find(a => a.key === this.action_name);
return found ? { id: found.key, name: found.label } : null;
},
set(value) {
this.action_name = value?.id || value;
},
},
actionTypesAsOptions() {
return this.actionTypes.map(a => ({ id: a.key, name: a.label }));
},
isVerticalLayout() {
return ['team_message', 'textarea'].includes(this.inputType);
actionInputStyles() {
return {
'has-error': this.errorMessage,
'is-a-macro': this.isMacro,
};
},
castMessageVmodel: {
get() {
@@ -114,89 +94,203 @@ export default {
resetAction() {
this.$emit('resetAction');
},
onActionNameChange(value) {
this.actionNameAsSelectModel = value;
this.resetAction();
},
},
};
</script>
<template>
<li class="list-none py-2 first:pt-0 last:pb-0">
<div
class="flex flex-col gap-2"
:class="{ 'animate-wiggle': errorMessage }"
>
<div class="flex items-center gap-2">
<SingleSelect
:model-value="actionNameAsSelectModel"
:options="actionTypesAsOptions"
:dropdown-max-height="dropdownMaxHeight"
disable-deselect
class="flex-shrink-0"
@update:model-value="onActionNameChange"
/>
<template v-if="showActionInput && !isVerticalLayout">
<SingleSelect
<div class="filter" :class="actionInputStyles">
<div class="filter-inputs">
<select
v-model="action_name"
class="action__question"
:class="{ 'full-width': !showActionInput }"
@change="resetAction()"
>
<option
v-for="attribute in actionTypes"
:key="attribute.key"
:value="attribute.key"
>
{{ attribute.label }}
</option>
</select>
<div v-if="showActionInput" class="filter__answer--wrap">
<div v-if="inputType" class="w-full">
<div
v-if="inputType === 'search_select'"
v-model="action_params"
:options="dropdownValues"
:dropdown-max-height="dropdownMaxHeight"
/>
<MultiSelect
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'multi_select'"
v-model="action_params"
:options="dropdownValues"
:dropdown-max-height="dropdownMaxHeight"
/>
<NextInput
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<input
v-else-if="inputType === 'email'"
v-model="action_params"
type="email"
size="sm"
class="answer--text-input"
:placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')"
/>
<NextInput
<input
v-else-if="inputType === 'url'"
v-model="action_params"
type="url"
size="sm"
class="answer--text-input"
:placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')"
/>
<AutomationActionFileInput
v-else-if="inputType === 'attachment'"
v-if="inputType === 'attachment'"
v-model="action_params"
:initial-file-name="initialFileName"
/>
</template>
<NextButton
v-if="!isMacro"
sm
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click="removeAction"
/>
</div>
</div>
<AutomationActionTeamMessageInput
v-if="inputType === 'team_message'"
v-model="action_params"
:teams="dropdownValues"
:dropdown-max-height="dropdownMaxHeight"
/>
<WootMessageEditor
v-if="inputType === 'textarea'"
v-model="castMessageVmodel"
rows="4"
enable-variables
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="[&_.ProseMirror-menubar]:hidden px-3 py-1 bg-n-alpha-1 rounded-lg outline outline-1 outline-n-weak dark:outline-n-strong"
<NextButton
v-if="!isMacro"
icon="i-lucide-x"
slate
ghost
class="flex-shrink-0"
@click="removeAction"
/>
</div>
<span v-if="errorMessage" class="text-sm text-n-ruby-11">
<AutomationActionTeamMessageInput
v-if="inputType === 'team_message'"
v-model="action_params"
:teams="dropdownValues"
/>
<WootMessageEditor
v-if="inputType === 'textarea'"
v-model="castMessageVmodel"
rows="4"
enable-variables
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="action-message"
/>
<p v-if="errorMessage" class="filter-error">
{{ errorMessage }}
</span>
</li>
</p>
</div>
</template>
<style lang="scss" scoped>
.filter {
@apply bg-n-background p-2 border border-solid border-n-strong dark:border-n-strong rounded-lg mb-2;
&.is-a-macro {
@apply mb-0 bg-n-background dark:bg-n-solid-1 p-0 border-0 rounded-none;
}
}
.no-margin-bottom {
@apply mb-0;
}
.filter.has-error {
@apply bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5;
&.is-a-macro {
@apply bg-transparent;
}
}
.filter-inputs {
@apply flex gap-1;
}
.filter-error {
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
}
.action__question,
.filter__operator {
@apply mb-0 mr-1;
}
.action__question {
@apply max-w-[50%];
}
.action__question.full-width {
@apply max-w-full;
}
.filter__answer--wrap {
@apply max-w-[50%] flex-grow mr-1 flex w-full items-center justify-start;
input {
@apply mb-0;
}
}
.filter__answer {
&.answer--text-input {
@apply mb-0;
}
}
.filter__join-operator-wrap {
@apply relative z-20 m-0;
}
.filter__join-operator {
@apply flex items-center justify-center relative my-2.5 mx-0;
.operator__line {
@apply absolute w-full border-b border-solid border-n-weak;
}
.operator__select {
margin-bottom: 0 !important;
@apply relative w-auto;
}
}
.multiselect {
@apply mb-0;
}
.action-message {
@apply mt-2 mx-0 mb-0;
}
// Prosemirror does not have a native way of hiding the menu bar, hence
::v-deep .ProseMirror-menubar {
@apply hidden;
}
</style>
@@ -1,14 +1,8 @@
<script>
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
export default {
components: {
MultiSelect,
},
props: {
teams: { type: Array, required: true },
modelValue: { type: Object, required: true },
dropdownMaxHeight: { type: String, default: 'max-h-80' },
},
emits: ['update:modelValue'],
data() {
@@ -18,9 +12,9 @@ export default {
};
},
mounted() {
const { team_ids: teamIds, message } = this.modelValue || {};
this.selectedTeams = teamIds || [];
this.message = message || '';
const { team_ids: teamIds } = this.modelValue;
this.selectedTeams = teamIds;
this.message = this.modelValue.message;
},
methods: {
updateValue() {
@@ -34,19 +28,37 @@ export default {
</script>
<template>
<div class="flex flex-col gap-2">
<MultiSelect
v-model="selectedTeams"
:options="teams"
:dropdown-max-height="dropdownMaxHeight"
@update:model-value="updateValue"
/>
<textarea
v-model="message"
class="mb-0 !text-sm"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
/>
<div>
<div class="multiselect-wrap--small flex flex-col gap-1 mt-1">
<multiselect
v-model="selectedTeams"
track-by="id"
label="name"
:placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="teams"
:allow-empty="false"
@update:model-value="updateValue"
/>
<textarea
v-model="message"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
/>
</div>
</div>
</template>
<style scoped>
.multiselect {
margin: 0.25rem 0;
}
textarea {
margin-bottom: 0;
}
</style>
@@ -79,7 +79,7 @@ input[type='file'] {
@apply hidden;
}
.input-wrapper {
@apply flex h-8 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-lg border border-dashed border-n-strong;
@apply flex h-9 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-sm border border-dashed border-n-strong;
}
.success-icon {
@apply text-n-teal-9 mr-2;
@@ -0,0 +1,305 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'FilterInput',
components: {
NextButton,
},
props: {
modelValue: {
type: Object,
default: () => {},
},
filterAttributes: {
type: Array,
default: () => [],
},
inputType: {
type: String,
default: 'plain_text',
},
operators: {
type: Array,
default: () => [],
},
dropdownValues: {
type: Array,
default: () => [],
},
showQueryOperator: {
type: Boolean,
default: false,
},
showUserInput: {
type: Boolean,
default: true,
},
groupedFilters: {
type: Boolean,
default: false,
},
filterGroups: {
type: Array,
default: () => [],
},
customAttributeType: {
type: String,
default: '',
},
errorMessage: {
type: String,
default: '',
},
},
emits: ['update:modelValue', 'removeFilter', 'resetFilter'],
computed: {
attributeKey: {
get() {
if (!this.modelValue) return null;
return this.modelValue.attribute_key;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, attribute_key: value });
},
},
filterOperator: {
get() {
if (!this.modelValue) return null;
return this.modelValue.filter_operator;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, filter_operator: value });
},
},
values: {
get() {
if (!this.modelValue) return null;
return this.modelValue.values;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, values: value });
},
},
query_operator: {
get() {
if (!this.modelValue) return null;
return this.modelValue.query_operator;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, query_operator: value });
},
},
custom_attribute_type: {
get() {
if (!this.customAttributeType) return '';
return this.customAttributeType;
},
set() {
const payload = this.modelValue || {};
this.$emit('update:modelValue', {
...payload,
custom_attribute_type: this.customAttributeType,
});
},
},
},
watch: {
customAttributeType: {
handler(value) {
if (
value === 'conversation_attribute' ||
value === 'contact_attribute'
) {
// eslint-disable-next-line vue/no-mutating-props
this.modelValue.custom_attribute_type = this.customAttributeType;
// eslint-disable-next-line vue/no-mutating-props
} else this.modelValue.custom_attribute_type = '';
},
immediate: true,
},
},
methods: {
removeFilter() {
this.$emit('removeFilter');
},
resetFilter() {
this.$emit('resetFilter');
},
getInputErrorClass(errorMessage) {
return errorMessage
? 'bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5'
: 'bg-n-background border-n-weak dark:border-n-weak';
},
},
};
</script>
<!-- eslint-disable vue/no-mutating-props -->
<template>
<div>
<div
class="p-2 border border-solid rounded-lg"
:class="getInputErrorClass(errorMessage)"
>
<div class="flex gap-1">
<select
v-if="groupedFilters"
v-model="attributeKey"
class="max-w-[30%] mb-0 mr-1"
@change="resetFilter()"
>
<optgroup
v-for="(group, i) in filterGroups"
:key="i"
:label="group.name"
>
<option
v-for="attribute in group.attributes"
:key="attribute.key"
:value="attribute.key"
:selected="true"
>
{{ attribute.name }}
</option>
</optgroup>
</select>
<select
v-else
v-model="attributeKey"
class="max-w-[30%] mb-0 mr-1"
@change="resetFilter()"
>
<option
v-for="attribute in filterAttributes"
:key="attribute.key"
:value="attribute.key"
:disabled="attribute.disabled"
>
{{ attribute.name }}
</option>
</select>
<select v-model="filterOperator" class="max-w-[20%] mb-0 mr-1">
<option
v-for="(operator, o) in operators"
:key="o"
:value="operator.value"
>
{{ $t(`FILTER.OPERATOR_LABELS.${operator.value}`) }}
</option>
</select>
<div v-if="showUserInput" class="flex-grow mr-1 filter__answer--wrap">
<div
v-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="values"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'search_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="values"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
<input
v-model="values"
type="date"
:editable="false"
class="!mb-0 datepicker"
/>
</div>
<input
v-else
v-model="values"
type="text"
class="!mb-0"
:placeholder="$t('FILTER.INPUT_PLACEHOLDER')"
/>
</div>
<NextButton
icon="i-lucide-x"
slate
ghost
class="flex-shrink-0"
@click="removeFilter"
/>
</div>
<p v-if="errorMessage" class="filter-error">
{{ errorMessage }}
</p>
</div>
<div
v-if="showQueryOperator"
class="flex items-center justify-center relative my-2.5 mx-0"
>
<hr class="absolute w-full border-b border-solid border-n-weak" />
<select
v-model="query_operator"
class="relative w-auto mb-0 bg-n-background text-n-slate-12 border-n-weak"
>
<option value="and">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.AND') }}
</option>
<option value="or">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.OR') }}
</option>
</select>
</div>
</div>
</template>
<style lang="scss" scoped>
.filter__answer--wrap {
input {
@apply bg-n-background mb-0 text-n-slate-12 border-n-weak;
}
}
.filter-error {
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
}
.multiselect {
@apply mb-0;
}
</style>
@@ -28,10 +28,7 @@ import { useAlert } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
@@ -89,7 +86,6 @@ const props = defineProps({
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
conversationId: { type: Number, default: null },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
@@ -400,14 +396,7 @@ function openFileBrowser() {
}
function handleCopilotClick() {
const isOpening = !showSelectionMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'inline',
});
}
showSelectionMenu.value = isOpening;
showSelectionMenu.value = !showSelectionMenu.value;
}
function handleClickOutside(event) {
@@ -830,13 +819,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
</script>
<template>
<div
ref="editorRoot"
class="relative w-full"
:class="{
'opacity-50 cursor-not-allowed pointer-events-none': disabled,
}"
>
<div ref="editorRoot" class="relative w-full">
<TagAgents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@@ -122,10 +122,6 @@ export default {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
},
emits: [
'replaceText',
@@ -134,7 +130,7 @@ export default {
'selectContentTemplate',
'toggleQuotedReply',
],
setup(props) {
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
@@ -143,9 +139,6 @@ export default {
const keyboardEvents = {
'$mod+Alt+KeyA': {
action: () => {
// Skip if editor is disabled (e.g., WhatsApp 24-hour window expired)
if (props.isEditorDisabled) return;
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
@@ -153,7 +146,7 @@ export default {
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
if (uploadTriggerButton) uploadTriggerButton.click();
uploadTriggerButton.click();
},
allowOnFocusedInput: true,
},
@@ -184,11 +177,9 @@ export default {
};
},
showAttachButton() {
if (this.isEditorDisabled) return false;
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
if (this.isEditorDisabled) return false;
if (this.isALineChannel) {
return false;
}
@@ -206,7 +197,6 @@ export default {
);
},
showAudioPlayStopButton() {
if (this.isEditorDisabled) return false;
return this.showAudioRecorder && this.isRecordingAudio;
},
isInstagramDM() {
@@ -246,7 +236,6 @@ export default {
}
},
showMessageSignatureButton() {
if (this.isEditorDisabled) return false;
return !this.isOnPrivateNote;
},
sendWithSignature() {
@@ -291,7 +280,6 @@ export default {
<div class="flex justify-between p-3" :class="wrapClass">
<div class="left-wrap">
<NextButton
v-if="!isEditorDisabled"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="i-ph-smiley-sticker"
slate
@@ -300,7 +288,6 @@ export default {
@click="toggleEmojiPicker"
/>
<FileUpload
v-if="showAttachButton"
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
@@ -2,10 +2,8 @@
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
@@ -33,14 +31,6 @@ export default {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
default: null,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -79,14 +69,7 @@ export default {
};
const toggleCopilotMenu = () => {
const isOpening = !showCopilotMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'top_panel',
});
}
showCopilotMenu.value = isOpening;
showCopilotMenu.value = !showCopilotMenu.value;
};
const handleClickOutside = () => {
@@ -161,7 +144,7 @@ export default {
<div class="relative">
<NextButton
ghost
:disabled="disabled || isEditorDisabled"
:disabled="disabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
@@ -34,7 +34,6 @@ const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
@@ -204,10 +203,7 @@ const onAssignAgent = agent => {
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
closeContextMenu();
};
const onAssignTeam = team => {
@@ -383,13 +379,11 @@ const deleteConversation = () => {
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@@ -41,10 +41,7 @@ import {
truncatePreviewText,
appendQuotedTextToMessage,
} from 'dashboard/helper/quotedEmailHelper';
import {
CONVERSATION_EVENTS,
CAPTAIN_EVENTS,
} from '../../../helper/AnalyticsHelper/events';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
@@ -139,7 +136,6 @@ export default {
newConversationModalActive: false,
showArticleSearchPopover: false,
hasRecordedAudio: false,
copilotAcceptedMessages: {},
};
},
computed: {
@@ -199,11 +195,6 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
messagePlaceHolder() {
if (this.isEditorDisabled) {
return this.isAWhatsAppChannel
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
}
return this.isPrivate
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
: this.$t('CONVERSATION.FOOTER.MSG_INPUT');
@@ -215,7 +206,6 @@ export default {
return this.maxLength - this.message.length;
},
isReplyButtonDisabled() {
if (this.isEditorDisabled) return true;
if (this.isATwitterInbox) return true;
if (this.hasAttachments || this.hasRecordedAudio) return false;
@@ -424,13 +414,6 @@ export default {
isDefaultEditorMode() {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
isEditorDisabled() {
return (
this.isAWhatsAppChannel &&
!this.isOnPrivateNote &&
!this.currentChat.can_reply
);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -525,24 +508,6 @@ export default {
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
getDraftKey(
conversationId = this.conversationIdByRoute,
replyType = this.replyType
) {
return `draft-${conversationId}-${replyType}`;
},
getCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
return this.copilotAcceptedMessages[key] || '';
},
setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent(message || '');
},
clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
delete this.copilotAcceptedMessages[key];
},
handleInsert(article) {
const { url, title } = article;
// Removing empty lines from the title
@@ -594,7 +559,7 @@ export default {
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const key = this.getDraftKey(conversationId, replyType);
const key = `draft-${conversationId}-${replyType}`;
const draftToSave = trimContent(this.message || '');
this.$store.dispatch('draftMessages/set', {
@@ -609,7 +574,7 @@ export default {
},
getFromDraft() {
if (this.conversationIdByRoute) {
const key = this.getDraftKey();
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const messageFromStore =
this.$store.getters['draftMessages/get'](key) || '';
@@ -632,7 +597,7 @@ export default {
},
removeFromDraft() {
if (this.conversationIdByRoute) {
const key = this.getDraftKey();
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
this.$store.dispatch('draftMessages/delete', { key });
}
},
@@ -690,9 +655,6 @@ export default {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return;
// Don't handle paste if editor is disabled
if (this.isEditorDisabled) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
@@ -746,7 +708,6 @@ export default {
return;
}
if (!this.showMentions) {
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
const isOnWhatsApp =
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
@@ -756,17 +717,10 @@ export default {
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
const isOnInstagram = this.isAnInstagramChannel;
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
this.sendMessageAsMultipleMessages(
this.message,
copilotAcceptedMessage
);
this.sendMessageAsMultipleMessages(this.message);
} else {
const messagePayload = this.getMessagePayload(this.message);
this.sendMessage(
messagePayload,
this.message,
copilotAcceptedMessage
);
this.sendMessage(messagePayload);
}
if (!this.isPrivate) {
@@ -778,53 +732,13 @@ export default {
this.$emit('update:popOutReplyBox', false);
}
},
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
sendMessageAsMultipleMessages(message) {
const messages = this.getMultipleMessagesPayload(message);
messages.forEach(messagePayload => {
this.sendMessage(
messagePayload,
messagePayload.message || '',
copilotAcceptedMessage
);
this.sendMessage(messagePayload);
});
},
sendMessageAnalyticsData(
isPrivate,
{ editorMessage = '', copilotAcceptedMessage = '' } = {}
) {
const normalizeForComparison = message => {
let normalizedMessage = message || '';
if (this.sendWithSignature && this.messageSignature && !isPrivate) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
normalizedMessage = removeSignature(
normalizedMessage,
this.messageSignature,
effectiveChannelType
);
}
return trimContent(normalizedMessage);
};
const normalizedAcceptedMessage = normalizeForComparison(
copilotAcceptedMessage
);
const normalizedEditorMessage = normalizeForComparison(editorMessage);
if (normalizedAcceptedMessage && normalizedEditorMessage) {
useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
conversationId: this.conversationIdByRoute,
channelType: this.channelType,
editedBeforeSend:
normalizedAcceptedMessage !== normalizedEditorMessage,
isPrivate,
});
}
sendMessageAnalyticsData(isPrivate) {
// Analytics data for message signature is enabled or not in channels
return isPrivate
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
@@ -858,11 +772,7 @@ export default {
this.confirmOnSendReply();
}
},
async sendMessage(
messagePayload,
editorMessage = '',
copilotAcceptedMessage = ''
) {
async sendMessage(messagePayload) {
try {
await this.$store.dispatch(
'createPendingMessageAndSend',
@@ -871,10 +781,7 @@ export default {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
this.sendMessageAnalyticsData(messagePayload.private, {
editorMessage,
copilotAcceptedMessage,
});
this.sendMessageAnalyticsData(messagePayload.private);
} catch (error) {
const errorMessage =
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
@@ -948,7 +855,6 @@ export default {
},
clearMessage() {
this.message = '';
this.clearCopilotAcceptedMessage();
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType(
@@ -1213,9 +1119,7 @@ export default {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
const acceptedMessage = this.copilot.accept();
this.message = acceptedMessage;
this.setCopilotAcceptedMessage(acceptedMessage);
this.message = this.copilot.accept();
},
},
};
@@ -1226,13 +1130,11 @@ export default {
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
<ReplyTopPanel
:mode="replyType"
:conversation-id="conversationId"
:is-reply-restricted="isReplyRestricted"
:disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
showAudioRecorderEditor
"
:is-editor-disabled="isEditorDisabled"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popOutReplyBox"
@@ -1302,14 +1204,12 @@ export default {
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
v-model="message"
:conversation-id="conversationId"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
:disabled="isEditorDisabled"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
@@ -1385,7 +1285,6 @@ export default {
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:is-editor-disabled="isEditorDisabled"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
@@ -53,10 +53,6 @@ export default {
type: String,
default: null,
},
conversationLabels: {
type: Array,
default: () => [],
},
conversationUrl: {
type: String,
default: '',
@@ -74,7 +70,6 @@ export default {
'assignAgent',
'assignTeam',
'assignLabel',
'removeLabel',
'deleteConversation',
'close',
],
@@ -339,16 +334,8 @@ export default {
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
@@ -1,6 +1,5 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
option: {
@@ -23,9 +22,7 @@ defineProps({
class="flex-shrink-0"
/>
<span
v-if="
(variant === 'label' || variant === 'label-assigned') && option.color
"
v-if="variant === 'label' && option.color"
class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }"
/>
@@ -40,11 +37,6 @@ defineProps({
<p class="menu-label truncate min-w-0 flex-1">
{{ option.label }}
</p>
<Icon
v-if="variant === 'label-assigned'"
icon="i-lucide-check"
class="flex-shrink-0 size-3.5 mr-1"
/>
</div>
</template>
@@ -2,7 +2,6 @@
// components
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useBranding } from 'shared/composables/useBranding';
// composables
import { useCaptain } from 'dashboard/composables/useCaptain';
@@ -35,9 +34,8 @@ export default {
},
setup() {
const { captainTasksEnabled } = useCaptain();
const { replaceInstallationName } = useBranding();
return { captainTasksEnabled, replaceInstallationName };
return { captainTasksEnabled };
},
data() {
return {
@@ -230,9 +228,7 @@ export default {
<div class="sender--info has-tooltip" data-original-title="null">
<Avatar
v-tooltip.top="{
content: replaceInstallationName(
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
),
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
@@ -1,12 +0,0 @@
export const CAPTAIN_ERROR_TYPES = Object.freeze({
ABORTED: 'aborted',
API_ERROR: 'api_error',
HTTP_PREFIX: 'http_',
ABORT_ERROR: 'AbortError',
CANCELED_ERROR: 'CanceledError',
});
export const CAPTAIN_GENERATION_FAILURE_REASONS = Object.freeze({
EMPTY_RESPONSE: 'empty_response',
EXCEPTION: 'exception',
});
@@ -102,28 +102,6 @@ export function useBulkActions() {
}
}
// Only used in context menu
async function onRemoveLabels(labelsToRemove, conversationId = null) {
try {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: conversationId || selectedConversations.value,
labels: {
remove: labelsToRemove,
},
});
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
} catch (err) {
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
}
}
async function onAssignTeamsForBulk(team) {
try {
await store.dispatch('bulkActions/process', {
@@ -211,7 +189,6 @@ export function useBulkActions() {
isConversationSelected,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
};
@@ -1,4 +1,4 @@
import { ref, reactive, computed } from 'vue';
import { ref, computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
@@ -40,7 +40,7 @@ export function useAutomation(startValue = null) {
} = useAutomationValues();
const automation = ref(startValue);
const automationTypes = reactive(structuredClone(AUTOMATIONS));
const automationTypes = structuredClone(AUTOMATIONS);
const eventName = computed(() => automation.value?.event_name);
/**
@@ -160,24 +160,14 @@ export function useAutomation(startValue = null) {
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
);
const CUSTOM_ATTR_HEADER_KEYS = new Set([
'conversation_custom_attribute',
'contact_custom_attribute',
]);
[
'message_created',
'conversation_created',
'conversation_updated',
'conversation_opened',
].forEach(eventToUpdate => {
const standardConditions = automationTypes[
eventToUpdate
].conditions.filter(
c => !c.customAttributeType && !CUSTOM_ATTR_HEADER_KEYS.has(c.key)
);
automationTypes[eventToUpdate].conditions = [
...standardConditions,
...automationTypes[eventToUpdate].conditions,
...manifestedCustomAttributes,
];
});
@@ -11,7 +11,6 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
import { CAPTAIN_ERROR_TYPES } from 'dashboard/composables/captain/constants';
export function useCaptain() {
const store = useStore();
@@ -70,10 +69,7 @@ export function useCaptain() {
* @param {Error} error - The error object from the API call.
*/
const handleAPIError = error => {
if (
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return;
}
const errorMessage =
@@ -82,24 +78,6 @@ export function useCaptain() {
useAlert(errorMessage);
};
/**
* Classifies API error types for downstream analytics.
* @param {Error} error
* @returns {string}
*/
const getErrorType = error => {
if (
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return CAPTAIN_ERROR_TYPES.ABORTED;
}
if (error.response?.status) {
return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`;
}
return CAPTAIN_ERROR_TYPES.API_ERROR;
};
// === Task Methods ===
/**
* Rewrites content with a specific operation.
@@ -125,7 +103,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '', errorType: getErrorType(error) };
return { message: '' };
}
};
@@ -147,7 +125,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '', errorType: getErrorType(error) };
return { message: '' };
}
};
@@ -169,7 +147,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '', errorType: getErrorType(error) };
return { message: '' };
}
};
@@ -193,11 +171,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext: updatedContext };
} catch (error) {
handleAPIError(error);
return {
message: '',
followUpContext,
errorType: getErrorType(error),
};
return { message: '', followUpContext };
}
};
@@ -3,10 +3,6 @@ import { useCaptain } from 'dashboard/composables/useCaptain';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
CAPTAIN_ERROR_TYPES,
CAPTAIN_GENERATION_FAILURE_REASONS,
} from 'dashboard/composables/captain/constants';
// Actions that map to REWRITE events (with operation attribute)
const REWRITE_ACTIONS = [
@@ -56,20 +52,6 @@ function buildPayload(action, conversationId, followUpCount = undefined) {
return payload;
}
function trackGenerationFailure({
action,
conversationId,
followUpCount = undefined,
stage,
reason,
}) {
useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, {
...buildPayload(action, conversationId, followUpCount),
stage,
reason,
});
}
/**
* Composable for managing Copilot reply generation state and actions.
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
@@ -164,8 +146,7 @@ export function useCopilotReply() {
// Reset without tracking dismiss (starting new action)
reset(false);
const requestController = new AbortController();
abortController.value = requestController;
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
currentAction.value = action;
@@ -173,66 +154,28 @@ export function useCopilotReply() {
trackedConversationId.value = conversationId.value;
try {
const {
message: content,
followUpContext: newContext,
errorType,
} = await processEvent(action, data, {
signal: requestController.signal,
});
const { message: content, followUpContext: newContext } =
await processEvent(action, data, {
signal: abortController.value.signal,
});
if (requestController.signal.aborted) return;
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
if (abortController.value === requestController) {
isGenerating.value = false;
if (!abortController.value?.signal.aborted) {
generatedContent.value = content;
followUpContext.value = newContext;
if (content) {
showEditor.value = true;
// Track "Used" event on successful generation
const eventKey = `${getEventPrefix(action)}_USED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(action, trackedConversationId.value)
);
}
return;
isGenerating.value = false;
}
generatedContent.value = content;
followUpContext.value = newContext;
if (content) {
showEditor.value = true;
// Track "Used" event on successful generation
const eventKey = `${getEventPrefix(action)}_USED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(action, trackedConversationId.value)
);
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: errorType,
});
} else {
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
});
}
isGenerating.value = false;
} catch (error) {
if (
requestController.signal.aborted ||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return;
}
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
});
isGenerating.value = false;
} finally {
if (abortController.value === requestController) {
abortController.value = null;
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
}
}
}
@@ -244,8 +187,7 @@ export function useCopilotReply() {
async function sendFollowUp(message) {
if (!followUpContext.value || !message.trim()) return;
const requestController = new AbortController();
abortController.value = requestController;
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
@@ -256,65 +198,24 @@ export function useCopilotReply() {
followUpCount.value += 1;
try {
const {
message: content,
followUpContext: updatedContext,
errorType,
} = await followUp({
followUpContext: followUpContext.value,
message,
signal: requestController.signal,
});
const { message: content, followUpContext: updatedContext } =
await followUp({
followUpContext: followUpContext.value,
message,
signal: abortController.value.signal,
});
if (requestController.signal.aborted) return;
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
if (abortController.value === requestController) {
isGenerating.value = false;
if (!abortController.value?.signal.aborted) {
if (content) {
generatedContent.value = content;
followUpContext.value = updatedContext;
showEditor.value = true;
}
return;
isGenerating.value = false;
}
if (content) {
generatedContent.value = content;
followUpContext.value = updatedContext;
showEditor.value = true;
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: errorType,
});
} else {
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
});
}
isGenerating.value = false;
} catch (error) {
if (
requestController.signal.aborted ||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return;
}
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
});
isGenerating.value = false;
} finally {
if (abortController.value === requestController) {
abortController.value = null;
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
}
}
}
@@ -85,11 +85,6 @@ export const PORTALS_EVENTS = Object.freeze({
});
export const CAPTAIN_EVENTS = Object.freeze({
// Editor funnel events
EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened',
GENERATION_FAILED: 'Captain: Generation failed',
AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent',
// Rewrite events (with operation attribute in payload)
REWRITE_USED: 'Captain: Rewrite used',
REWRITE_APPLIED: 'Captain: Rewrite applied',
@@ -169,19 +169,19 @@ export const getFileName = (action, files = []) => {
export const getDefaultConditions = eventName => {
if (eventName === 'message_created') {
return structuredClone(DEFAULT_MESSAGE_CREATED_CONDITION);
return DEFAULT_MESSAGE_CREATED_CONDITION;
}
if (
eventName === 'conversation_opened' ||
eventName === 'conversation_resolved'
) {
return structuredClone(DEFAULT_CONVERSATION_CONDITION);
return DEFAULT_CONVERSATION_CONDITION;
}
return structuredClone(DEFAULT_OTHER_CONDITION);
return DEFAULT_OTHER_CONDITION;
};
export const getDefaultActions = () => {
return structuredClone(DEFAULT_ACTIONS);
return DEFAULT_ACTIONS;
};
export const filterCustomAttributes = customAttributes => {
@@ -174,10 +174,6 @@
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
"FAILED": "Couldn't assign label. Please try again."
},
"LABEL_REMOVAL": {
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
"FAILED": "Couldn't remove label. Please try again."
},
"TEAM_ASSIGNMENT": {
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
"FAILED": "Couldn't assign team. Please try again."
@@ -190,8 +186,6 @@
"DISABLE_SIGN_TOOLTIP": "Disable signature",
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
@@ -1,101 +1,91 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
<script>
import { useAlert, useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ContactAPI from 'dashboard/api/contacts';
import { mapGetters } from 'vuex';
import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events';
const props = defineProps({
primaryContact: {
type: Object,
required: true,
export default {
components: { MergeContact },
props: {
show: {
type: Boolean,
default: false,
},
primaryContact: {
type: Object,
required: true,
},
},
emits: ['close', 'update:show'],
data() {
return {
isSearching: false,
searchResults: [],
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
},
});
const emit = defineEmits(['close']);
methods: {
onClose() {
this.$emit('close');
},
async onContactSearch(query) {
this.isSearching = true;
this.searchResults = [];
const { t } = useI18n();
const store = useStore();
const uiFlags = useMapGetter('contacts/getUIFlags');
const dialogRef = ref(null);
const isSearching = ref(false);
const searchResults = ref([]);
watch(
() => props.primaryContact.id,
() => {
isSearching.value = false;
searchResults.value = [];
}
);
const open = () => {
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
const onClose = () => {
close();
emit('close');
};
const onContactSearch = async query => {
isSearching.value = true;
searchResults.value = [];
try {
const {
data: { payload },
} = await ContactAPI.search(query);
searchResults.value = payload.filter(
contact => contact.id !== props.primaryContact.id
);
} catch (error) {
useAlert(t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
};
const onMergeContacts = async parentContactId => {
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await store.dispatch('contacts/merge', {
childId: props.primaryContact.id,
parentId: parentContactId,
});
useAlert(t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
close();
emit('close');
} catch (error) {
useAlert(t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
}
try {
const {
data: { payload },
} = await ContactAPI.search(query);
this.searchResults = payload.filter(
contact => contact.id !== this.primaryContact.id
);
} catch (error) {
useAlert(this.$t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE'));
} finally {
this.isSearching = false;
}
},
async onMergeContacts(parentContactId) {
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await this.$store.dispatch('contacts/merge', {
childId: this.primaryContact.id,
parentId: parentContactId,
});
useAlert(this.$t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
this.onClose();
} catch (error) {
useAlert(this.$t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
}
},
},
};
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
width="2xl"
:title="$t('MERGE_CONTACTS.TITLE')"
:description="$t('MERGE_CONTACTS.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
>
<woot-modal v-model:show="localShow" :on-close="onClose">
<woot-modal-header
:header-title="$t('MERGE_CONTACTS.TITLE')"
:header-content="$t('MERGE_CONTACTS.DESCRIPTION')"
/>
<MergeContact
:key="primaryContact.id"
:primary-contact="primaryContact"
:is-searching="isSearching"
:is-merging="uiFlags.isMerging"
@@ -104,5 +94,5 @@ const onMergeContacts = async parentContactId => {
@cancel="onClose"
@submit="onMergeContacts"
/>
</Dialog>
</woot-modal>
</template>
@@ -0,0 +1,87 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
defineProps({
name: {
type: String,
default: '',
},
thumbnail: {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
phoneNumber: {
type: String,
default: '',
},
identifier: {
type: [String, Number],
required: true,
},
});
</script>
<template>
<div class="option-item--user">
<Avatar :src="thumbnail" :size="28" :name="name" rounded-full />
<div class="option__user-data">
<h5 class="option__title">
{{ name }}
<span v-if="identifier" class="user-identifier">
{{ $t('MERGE_CONTACTS.DROPDOWN_ITEM.ID', { identifier }) }}
</span>
</h5>
<p class="option__body">
<span v-if="email" class="email-icon-wrap">
<fluent-icon class="merge-contact--icon" icon="mail" size="12" />
{{ email }}
</span>
<span v-if="phoneNumber" class="phone-icon-wrap">
<fluent-icon class="merge-contact--icon" icon="call" size="12" />
{{ phoneNumber }}
</span>
<span v-if="!phoneNumber && !email">{{ '---' }}</span>
</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.option-item--user {
@apply flex items-center;
}
.user-identifier {
@apply text-xs ml-0.5 text-n-slate-12;
}
.option__user-data {
@apply flex flex-col flex-grow ml-2 mr-2;
}
.option__body,
.option__title {
@apply flex items-center justify-start leading-[1.2] text-sm;
}
.option__body .icon {
@apply relative top-px mr-0.5 rtl:mr-0 rtl:ml-0.5;
}
.option__title {
@apply text-n-slate-12 font-medium mb-0.5;
}
.option__body {
@apply text-xs text-n-slate-12 mt-1;
}
.option__user-data .option__body {
> .phone-icon-wrap,
> .email-icon-wrap {
@apply w-auto flex items-center;
}
}
.merge-contact--icon {
@apply -mb-0.5 mr-0.5;
}
</style>
@@ -1,105 +1,174 @@
<script setup>
import { ref, computed } from 'vue';
<script>
import { required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import MergeContactSummary from 'dashboard/modules/contact/components/MergeContactSummary.vue';
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
import ContactDropdownItem from './ContactDropdownItem.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
primaryContact: {
type: Object,
required: true,
},
isSearching: {
type: Boolean,
default: false,
},
isMerging: {
type: Boolean,
default: false,
},
searchResults: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['search', 'submit', 'cancel']);
const { t } = useI18n();
const parentContactId = ref(null);
const validationRules = {
parentContactId: { required },
};
const v$ = useVuelidate(validationRules, { parentContactId });
const parentContact = computed(() => {
if (!parentContactId.value) return null;
return props.searchResults.find(
contact => contact.id === parentContactId.value
);
});
const parentContactName = computed(() => {
return parentContact.value ? parentContact.value.name : '';
});
const primaryContactList = computed(() => {
return props.searchResults.map(contact => ({
id: contact.id,
label: contact.name,
value: contact.id,
meta: {
thumbnail: contact.thumbnail,
email: contact.email,
phoneNumber: contact.phone_number,
export default {
components: { MergeContactSummary, ContactDropdownItem, NextButton },
props: {
primaryContact: {
type: Object,
required: true,
},
}));
});
isSearching: {
type: Boolean,
default: false,
},
isMerging: {
type: Boolean,
default: false,
},
searchResults: {
type: Array,
default: () => [],
},
},
emits: ['search', 'submit', 'cancel'],
setup() {
return { v$: useVuelidate() };
},
validations: {
primaryContact: {
required,
},
parentContact: {
required,
},
},
data() {
return {
parentContact: undefined,
};
},
const hasValidationError = computed(() => v$.value.parentContactId.$error);
const validationErrorMessage = computed(() => {
if (v$.value.parentContactId.$error) {
return t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR');
}
return '';
});
const onSearch = query => {
emit('search', query);
};
const onSubmit = () => {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
emit('submit', parentContactId.value);
};
const onCancel = () => {
emit('cancel');
computed: {
parentContactName() {
return this.parentContact ? this.parentContact.name : '';
},
},
methods: {
searchChange(query) {
this.$emit('search', query);
},
onSubmit() {
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
this.$emit('submit', this.parentContact.id);
},
onCancel() {
this.$emit('cancel');
},
},
};
</script>
<template>
<form @submit.prevent="onSubmit">
<ContactMergeForm
:selected-contact="primaryContact"
:primary-contact-id="parentContactId"
:primary-contact-list="primaryContactList"
:is-searching="isSearching"
:has-error="hasValidationError"
:error-message="validationErrorMessage"
@update:primary-contact-id="parentContactId = $event"
@search="onSearch"
/>
<div>
<div>
<div
class="mt-1 multiselect-wrap--medium"
:class="{ error: v$.parentContact.$error }"
>
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.PARENT.TITLE') }}
<woot-label
:title="$t('MERGE_CONTACTS.PARENT.HELP_LABEL')"
color-scheme="success"
small
class="ml-2"
/>
</label>
<multiselect
v-model="parentContact"
:options="searchResults"
label="name"
track-by="id"
:internal-search="false"
:clear-on-select="false"
:show-labels="false"
:placeholder="$t('MERGE_CONTACTS.PARENT.PLACEHOLDER')"
allow-empty
:loading="isSearching"
:max-height="150"
open-direction="top"
@search-change="searchChange"
>
<template #singleLabel="props">
<ContactDropdownItem
:thumbnail="props.option.thumbnail"
:identifier="props.option.id"
:name="props.option.name"
:email="props.option.email"
:phone-number="props.option.phone_number"
/>
</template>
<template #option="props">
<ContactDropdownItem
:thumbnail="props.option.thumbnail"
:identifier="props.option.id"
:name="props.option.name"
:email="props.option.email"
:phone-number="props.option.phone_number"
/>
</template>
<template #noResult>
<span>
{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}
</span>
</template>
</multiselect>
<span v-if="v$.parentContact.$error" class="message">
{{ $t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR') }}
</span>
</div>
</div>
<div class="flex multiselect-wrap--medium">
<div
class="w-8 relative text-base text-n-strong after:content-[''] after:h-12 after:w-0 ltr:after:left-4 rtl:after:right-4 after:absolute after:border-l after:border-solid after:border-n-strong before:content-[''] before:h-0 before:w-4 ltr:before:left-4 rtl:before:right-4 before:top-12 before:absolute before:border-b before:border-solid before:border-n-strong"
>
<fluent-icon
icon="arrow-up"
class="absolute -top-1 ltr:left-2 rtl:right-2"
size="17"
/>
</div>
<div class="flex flex-col w-full ltr:pl-8 rtl:pr-8">
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }}
<woot-label
:title="$t('MERGE_CONTACTS.PRIMARY.HELP_LABEL')"
color-scheme="alert"
small
class="ml-2"
/>
</label>
<multiselect
:model-value="primaryContact"
disabled
:options="[]"
:show-labels="false"
label="name"
track-by="id"
>
<template #singleLabel="props">
<ContactDropdownItem
:thumbnail="props.option.thumbnail"
:name="props.option.name"
:identifier="props.option.id"
:email="props.option.email"
:phone-number="props.option.phoneNumber"
/>
</template>
</multiselect>
</div>
</div>
</div>
<MergeContactSummary
:primary-contact-name="primaryContact.name"
:parent-contact-name="parentContactName"
@@ -120,3 +189,32 @@ const onCancel = () => {
</div>
</form>
</template>
<style lang="scss" scoped>
/* TDOD: Clean errors in forms style */
.error .message {
@apply mt-0;
}
::v-deep {
.multiselect {
@apply rounded-md;
}
.multiselect--disabled {
@apply border-0;
.multiselect__tags {
@apply border;
}
}
.multiselect__tags {
@apply h-auto;
}
.multiselect__select {
@apply mt-px mr-1;
}
}
</style>
@@ -11,13 +11,11 @@ import { isPhoneNumberValid } from 'shared/helpers/Validators';
import parsePhoneNumber from 'libphonenumber-js';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
export default {
components: {
NextButton,
Avatar,
ComboBox,
},
props: {
contact: {
@@ -135,12 +133,6 @@ export default {
if (!name && !id) return '';
return `${name} (${id})`;
},
onCountryChange(value) {
const selected = this.countries.find(c => c.id === value);
this.country = selected
? { id: selected.id, name: selected.name }
: { id: '', name: '' };
},
setDialCode() {
if (
this.phoneNumber !== '' &&
@@ -371,23 +363,26 @@ export default {
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
/>
<div class="w-full mb-4">
<label>
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
</label>
<ComboBox
:model-value="country.id"
:options="
countries.map(c => ({
value: c.id,
label: countryNameWithCode(c),
}))
"
class="[&>div>button]:!bg-n-alpha-black2"
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
:search-placeholder="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
@update:model-value="onCountryChange"
/>
<div>
<div class="w-full">
<label>
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
</label>
<multiselect
v-model="country"
track-by="id"
label="name"
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
selected-label
:select-label="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
:deselect-label="$t('CONTACT_FORM.FORM.COUNTRY.REMOVE')"
:custom-label="countryNameWithCode"
:max-height="160"
:options="countries"
allow-empty
:option-height="104"
/>
</div>
</div>
<woot-input
v-model="city"
@@ -431,3 +426,11 @@ export default {
</div>
</form>
</template>
<style scoped lang="scss">
::v-deep {
.multiselect .multiselect__tags .multiselect__single {
@apply pl-0;
}
}
</style>
@@ -51,6 +51,7 @@ export default {
data() {
return {
showEditModal: false,
showMergeModal: false,
showDeleteModal: false,
};
},
@@ -166,8 +167,11 @@ export default {
);
}
},
closeMergeModal() {
this.showMergeModal = false;
},
openMergeModal() {
this.$refs.mergeModal?.open();
this.showMergeModal = true;
},
},
};
@@ -320,7 +324,12 @@ export default {
:contact="contact"
@cancel="toggleEditModal"
/>
<ContactMergeModal ref="mergeModal" :primary-contact="contact" />
<ContactMergeModal
v-if="showMergeModal"
:primary-contact="contact"
:show="showMergeModal"
@close="closeMergeModal"
/>
</div>
<woot-delete-modal
v-if="showDeleteModal"
@@ -7,12 +7,10 @@ import { convertToAttributeSlug } from 'dashboard/helper/commons.js';
import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
export default {
components: {
NextButton,
TagInput,
},
props: {
onClose: {
@@ -43,8 +41,9 @@ export default {
regexCue: null,
regexEnabled: false,
values: [],
options: [],
show: true,
tagInputTouched: false,
isTouched: false,
};
},
@@ -64,21 +63,21 @@ export default {
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
}));
},
isTagInputEmpty() {
return this.isAttributeTypeList && this.values.length === 0;
isMultiselectInvalid() {
return this.isTouched && this.values.length === 0;
},
isTagInputInvalid() {
return this.tagInputTouched && this.isTagInputEmpty;
return this.isAttributeTypeList && this.values.length === 0;
},
attributeListValues() {
return this.values;
return this.values.map(item => item.name);
},
isButtonDisabled() {
return (
this.v$.displayName.$invalid ||
this.v$.description.$invalid ||
this.uiFlags.isCreating ||
this.isTagInputEmpty
this.isTagInputInvalid
);
},
keyErrorMessage() {
@@ -120,14 +119,17 @@ export default {
},
},
watch: {
attributeType() {
this.tagInputTouched = false;
this.values = [];
},
},
methods: {
addTagValue(tagValue) {
const tag = {
name: tagValue,
};
this.values.push(tag);
this.$refs.tagInput.$el.focus();
},
onTouch() {
this.isTouched = true;
},
onDisplayNameChange() {
this.attributeKey = convertToAttributeSlug(this.displayName);
},
@@ -235,25 +237,27 @@ export default {
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
</span>
</label>
<div v-if="isAttributeTypeList" class="mb-4">
<label class="mb-1 block">
<div v-if="isAttributeTypeList" class="multiselect--wrap">
<label>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }}
</label>
<div
class="rounded-xl border px-3 py-2"
:class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'"
>
<TagInput
v-model="values"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
"
allow-create
@blur="tagInputTouched = true"
/>
</div>
<multiselect
ref="tagInput"
v-model="values"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
"
label="name"
track-by="name"
:class="{ invalid: isMultiselectInvalid }"
:options="options"
multiple
taggable
@close="onTouch"
@tag="addTagValue"
/>
<label
v-show="isTagInputInvalid"
v-show="isMultiselectInvalid"
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
@@ -308,4 +312,22 @@ export default {
padding: 0 0.5rem 0.5rem 0;
font-family: monospace;
}
.multiselect--wrap {
margin-bottom: 1rem;
}
::v-deep {
.multiselect {
margin-bottom: 0;
}
.multiselect__content-wrapper {
display: none;
}
.multiselect--active .multiselect__tags {
border-radius: 0.3125rem;
}
}
</style>
@@ -5,12 +5,10 @@ import { required, minLength } from '@vuelidate/validators';
import { getRegexp } from 'shared/helpers/Validators';
import { ATTRIBUTE_TYPES } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
export default {
components: {
NextButton,
TagInput,
},
props: {
selectedAttribute: {
@@ -37,7 +35,8 @@ export default {
show: true,
attributeKey: '',
values: [],
tagInputTouched: false,
options: [],
isTouched: true,
};
},
validations: {
@@ -66,19 +65,20 @@ export default {
}));
},
setAttributeListValue() {
return this.selectedAttribute.attribute_values || [];
return this.selectedAttribute.attribute_values.map(values => ({
name: values,
}));
},
updatedAttributeListValues() {
return this.values;
return this.values.map(item => item.name);
},
isButtonDisabled() {
return this.v$.description.$invalid || this.isTagInputEmpty;
return this.v$.description.$invalid || this.isMultiselectInvalid;
},
isTagInputEmpty() {
return this.isAttributeTypeList && this.values.length === 0;
},
isTagInputInvalid() {
return this.tagInputTouched && this.isTagInputEmpty;
isMultiselectInvalid() {
return (
this.isAttributeTypeList && this.isTouched && this.values.length === 0
);
},
pageTitle() {
@@ -116,6 +116,13 @@ export default {
onClose() {
this.$emit('onClose');
},
addTagValue(tagValue) {
const tag = {
name: tagValue,
};
this.values.push(tag);
this.$refs.tagInput.$el.focus();
},
setFormValues() {
const regexPattern = this.selectedAttribute.regex_pattern
? getRegexp(this.selectedAttribute.regex_pattern).source
@@ -218,25 +225,24 @@ export default {
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
</span>
</label>
<div v-if="isAttributeTypeList" class="mb-4">
<label class="mb-1 block">
<div v-if="isAttributeTypeList" class="multiselect--wrap">
<label>
{{ $t('ATTRIBUTES_MGMT.EDIT.TYPE.LIST.LABEL') }}
</label>
<div
class="rounded-xl border px-3 py-2"
:class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'"
>
<TagInput
v-model="values"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
"
allow-create
@blur="tagInputTouched = true"
/>
</div>
<multiselect
ref="tagInput"
v-model="values"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')"
label="name"
track-by="name"
:class="{ invalid: isMultiselectInvalid }"
:options="options"
multiple
taggable
@tag="addTagValue"
/>
<label
v-show="isTagInputInvalid"
v-show="isMultiselectInvalid"
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
@@ -291,4 +297,22 @@ export default {
padding: 0 0.5rem 0.5rem 0;
font-family: monospace;
}
.multiselect--wrap {
margin-bottom: 1rem;
}
::v-deep {
.multiselect {
margin-bottom: 0;
}
.multiselect__content-wrapper {
display: none;
}
.multiselect--active .multiselect__tags {
border-radius: 0.3125rem;
}
}
</style>
@@ -1,12 +1,21 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
<script>
import { mapGetters } from 'vuex';
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useAutomation } from 'dashboard/composables/useAutomation';
import AutomationRuleForm from './AutomationRuleForm.vue';
import { validateAutomation } from 'dashboard/helper/validations';
import {
generateAutomationPayload,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
} from 'dashboard/helper/automationHelper';
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
const emit = defineEmits(['saveAutomation']);
const START_VALUE = {
const start_value = {
name: null,
description: null,
event_name: 'conversation_created',
@@ -27,60 +36,318 @@ const START_VALUE = {
],
};
const store = useStore();
const formRef = ref(null);
export default {
components: {
FilterInputBox,
AutomationActionInput,
NextButton,
},
props: {
onClose: {
type: Function,
default: () => {},
},
},
emits: ['saveAutomation'],
setup() {
const {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
} = useAutomation(start_value);
return {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
};
},
data() {
return {
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
automationMutated: false,
show: true,
showDeleteConfirmationModal: false,
allCustomAttributes: [],
mode: 'create',
errors: {},
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
automationRuleEvents() {
return AUTOMATION_RULE_EVENTS.map(event => ({
...event,
value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
}));
},
hasAutomationMutated() {
if (
this.automation.conditions[0].values ||
this.automation.actions[0].action_params.length
)
return true;
return false;
},
automationActionTypes() {
const actionTypes = this.isFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
const {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
} = useAutomation(START_VALUE);
return actionTypes.map(action => ({
...action,
label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
}));
},
},
mounted() {
this.$store.dispatch('inboxes/get');
this.$store.dispatch('agents/get');
this.$store.dispatch('contacts/get');
this.$store.dispatch('teams/get');
this.$store.dispatch('labels/get');
this.$store.dispatch('campaigns/get');
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
this.manifestCustomAttributes();
},
methods: {
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
isFeatureEnabled(flag) {
return this.isFeatureEnabledonAccount(this.accountId, flag);
},
emitSaveAutomation() {
this.errors = validateAutomation(this.automation);
if (Object.keys(this.errors).length === 0) {
const automation = generateAutomationPayload(this.automation);
this.$emit('saveAutomation', automation, this.mode);
}
},
getTranslatedAttributes(type, event) {
return getAttributes(type, event).map(attribute => {
// Skip translation
// 1. If customAttributeType key is present then its rendering attributes from API
// 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
const skipTranslation =
attribute.customAttributeType ||
[
'contact_custom_attribute',
'conversation_custom_attribute',
].includes(attribute.key);
const open = () => {
automation.value = structuredClone(START_VALUE);
manifestCustomAttributes();
formRef.value?.open();
return {
...attribute,
name: skipTranslation
? attribute.name
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
};
});
},
},
};
const close = () => formRef.value?.close();
const onSave = (payload, mode) => {
emit('saveAutomation', payload, mode);
};
onMounted(() => {
store.dispatch('inboxes/get');
store.dispatch('agents/get');
store.dispatch('contacts/get');
store.dispatch('teams/get');
store.dispatch('labels/get');
store.dispatch('campaigns/get');
});
defineExpose({ open, close });
</script>
<template>
<AutomationRuleForm
ref="formRef"
v-model:automation="automation"
mode="create"
:automation-types="automationTypes"
:get-condition-dropdown-values="getConditionDropdownValues"
:get-action-dropdown-values="getActionDropdownValues"
:append-new-condition="appendNewCondition"
:append-new-action="appendNewAction"
:remove-filter="removeFilter"
:remove-action="removeAction"
:reset-action="resetAction"
:on-event-change="onEventChange"
@save="onSave"
/>
<div>
<woot-modal-header :header-title="$t('AUTOMATION.ADD.TITLE')" />
<div class="flex flex-col modal-content">
<div class="w-full">
<woot-input
v-model="automation.name"
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
type="text"
:class="{ error: errors.name }"
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
/>
<woot-input
v-model="automation.description"
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
type="text"
:class="{ error: errors.description }"
:error="
errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''
"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<div class="mb-6">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange(automation)"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
<p
v-if="hasAutomationMutated"
class="text-xs text-right text-n-teal-10 pt-1"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
</div>
<!-- // Conditions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<FilterInputBox
v-for="(condition, i) in automation.conditions"
:key="i"
v-model="automation.conditions[i]"
:filter-attributes="
getTranslatedAttributes(automationTypes, automation.event_name)
"
:input-type="
getInputType(
allCustomAttributes,
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:operators="
getOperators(
allCustomAttributes,
automationTypes,
automation,
mode,
automation.conditions[i].attribute_key
)
"
:dropdown-values="
getConditionDropdownValues(
automation.conditions[i].attribute_key
)
"
:show-query-operator="i !== automation.conditions.length - 1"
:custom-attribute-type="
getCustomAttributeType(
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:error-message="
errors[`condition_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
: ''
"
@reset-filter="resetFilter(i, automation.conditions[i])"
@remove-filter="removeFilter(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</div>
</section>
<!-- // Conditions End -->
<!-- // Actions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<AutomationActionInput
v-for="(action, i) in automation.actions"
:key="i"
v-model="automation.actions[i]"
:action-types="automationActionTypes"
:dropdown-values="
getActionDropdownValues(automation.actions[i].action_name)
"
:show-action-input="
showActionInput(
automationActionTypes,
automation.actions[i].action_name
)
"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
@reset-action="resetAction(i)"
@remove-action="removeAction(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
@click="appendNewAction"
/>
</div>
</div>
</section>
<!-- // Actions End -->
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('AUTOMATION.ADD.CANCEL_BUTTON_TEXT')"
@click.prevent="onClose"
/>
<NextButton
solid
blue
type="submit"
:label="$t('AUTOMATION.ADD.SUBMIT')"
@click="emitSaveAutomation"
/>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,414 +0,0 @@
<script setup>
import { ref, computed, h, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useOperators } from 'dashboard/components-next/filter/operators';
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import {
generateAutomationPayload,
getAttributes,
getFileName,
showActionInput,
} from 'dashboard/helper/automationHelper';
import { validateAutomation } from 'dashboard/helper/validations';
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['create', 'edit'].includes(value),
},
automationTypes: {
type: Object,
required: true,
},
getConditionDropdownValues: {
type: Function,
required: true,
},
getActionDropdownValues: {
type: Function,
required: true,
},
appendNewCondition: {
type: Function,
required: true,
},
appendNewAction: {
type: Function,
required: true,
},
removeFilter: {
type: Function,
required: true,
},
removeAction: {
type: Function,
required: true,
},
resetAction: {
type: Function,
required: true,
},
onEventChange: {
type: Function,
required: true,
},
});
const emit = defineEmits(['save']);
const automation = defineModel('automation', { type: Object, default: null });
const INPUT_TYPE_MAP = {
multi_select: 'multiSelect',
search_select: 'searchSelect',
plain_text: 'plainText',
comma_separated_plain_text: 'plainText',
date: 'date',
};
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const { operators } = useOperators();
const dialogRef = ref(null);
const conditionsRef = useTemplateRef('conditionsRef');
const errors = ref({});
const isEditMode = computed(() => props.mode === 'edit');
const titleKey = computed(() =>
isEditMode.value ? 'AUTOMATION.EDIT.TITLE' : 'AUTOMATION.ADD.TITLE'
);
const cancelKey = computed(() =>
isEditMode.value
? 'AUTOMATION.EDIT.CANCEL_BUTTON_TEXT'
: 'AUTOMATION.ADD.CANCEL_BUTTON_TEXT'
);
const submitKey = computed(() =>
isEditMode.value ? 'AUTOMATION.EDIT.SUBMIT' : 'AUTOMATION.ADD.SUBMIT'
);
const getTranslatedAttributes = (type, event) => {
return getAttributes(type, event).map(attribute => {
const skipTranslation =
attribute.customAttributeType ||
['contact_custom_attribute', 'conversation_custom_attribute'].includes(
attribute.key
);
return {
...attribute,
name: skipTranslation
? attribute.name
: t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
};
});
};
const eventName = computed(() => automation.value?.event_name);
const filterTypes = computed(() => {
const event = eventName.value;
if (!event || !props.automationTypes[event]) return [];
const attributes = getTranslatedAttributes(props.automationTypes, event);
return attributes.map(attr => {
if (attr.disabled) {
return { value: attr.key, label: attr.name, disabled: true };
}
const mappedInputType = INPUT_TYPE_MAP[attr.inputType] || 'plainText';
const options = props.getConditionDropdownValues(attr.key) || [];
const filterOperators = (attr.filterOperators || []).map(op => {
const enriched = operators.value[op.value];
if (enriched) return enriched;
return {
value: op.value,
label: t(`FILTER.OPERATOR_LABELS.${op.value}`),
hasInput: true,
inputOverride: null,
icon: h('span', { class: 'i-ph-equals-bold !text-n-blue-11' }),
};
});
return {
attributeKey: attr.key,
value: attr.key,
attributeName: attr.name,
label: attr.name,
inputType: mappedInputType,
options,
filterOperators,
dataType: 'text',
attributeModel: attr.customAttributeType || 'standard',
};
});
});
const automationRuleEvents = computed(() =>
AUTOMATION_RULE_EVENTS.map(event => ({
...event,
value: t(`AUTOMATION.EVENTS.${event.value}`),
}))
);
const hasAutomationMutated = computed(() => {
return Boolean(
automation.value?.conditions[0]?.values ||
automation.value?.actions[0]?.action_params?.length
);
});
const automationActionTypes = computed(() => {
const actionTypes = isCloudFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
return actionTypes.map(action => ({
...action,
label: t(`AUTOMATION.ACTIONS.${action.label}`),
}));
});
const hasConditionErrors = computed(() =>
Object.keys(errors.value).some(key => key.startsWith('condition_'))
);
const hasActionErrors = computed(() =>
Object.keys(errors.value).some(key => key.startsWith('action_'))
);
watch(
() => automation.value,
() => {
if (Object.keys(errors.value).length) {
errors.value = {};
}
},
{ deep: true }
);
const isConditionsValid = () => {
if (!conditionsRef.value) return true;
return conditionsRef.value.every(condition => condition.validate());
};
const resetValidation = () => {
errors.value = {};
conditionsRef.value?.forEach(c => c.resetValidation());
};
const syncCustomAttributeTypes = () => {
automation.value.conditions.forEach(condition => {
const filterType = filterTypes.value.find(
ft => ft.attributeKey === condition.attribute_key
);
condition.custom_attribute_type =
filterType?.attributeModel === 'standard'
? ''
: filterType?.attributeModel || '';
});
};
const open = () => {
resetValidation();
dialogRef.value?.open();
};
const close = () => {
resetValidation();
dialogRef.value?.close();
};
const emitSaveAutomation = () => {
syncCustomAttributeTypes();
const conditionsValid = isConditionsValid();
errors.value = validateAutomation(automation.value);
if (Object.keys(errors.value).length === 0 && conditionsValid) {
const payload = generateAutomationPayload(automation.value);
emit('save', payload, props.mode);
}
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
position="top"
:title="$t(titleKey)"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
>
<div v-if="automation" class="flex flex-col w-full">
<woot-input
v-model="automation.name"
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
type="text"
:class="{ error: errors.name }"
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
/>
<woot-input
v-model="automation.description"
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
type="text"
:class="{ error: errors.description }"
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<div class="mb-6">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange()"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
<p
v-if="!isEditMode && hasAutomationMutated"
class="text-xs text-right text-n-teal-10 pt-1"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
</div>
<!-- Conditions Start -->
<section class="mb-5">
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<ul
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
:class="
hasConditionErrors
? 'outline-n-ruby-5 bg-n-ruby-2/50'
: 'outline-n-weak dark:outline-n-strong'
"
>
<template v-for="(condition, i) in automation.conditions" :key="i">
<ConditionRow
v-if="i === 0"
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="automation.conditions[i].filter_operator"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(i)"
/>
<ConditionRow
v-else
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="automation.conditions[i].filter_operator"
v-model:query-operator="
automation.conditions[i - 1].query_operator
"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
show-query-operator
@remove="removeFilter(i)"
/>
</template>
<div>
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</ul>
</section>
<!-- Conditions End -->
<!-- Actions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
</label>
<ul
class="grid list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1 border-solid"
:class="
hasActionErrors
? 'outline-n-ruby-5 bg-n-ruby-2/50'
: 'outline-n-weak dark:outline-n-strong'
"
>
<AutomationActionInput
v-for="(action, i) in automation.actions"
:key="i"
v-model="automation.actions[i]"
:action-types="automationActionTypes"
dropdown-max-height="max-h-[7.5rem]"
:dropdown-values="getActionDropdownValues(action.action_name)"
:show-action-input="
showActionInput(automationActionTypes, action.action_name)
"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
:initial-file-name="
isEditMode ? getFileName(action, automation.files) : ''
"
@reset-action="resetAction(i)"
@remove-action="removeAction(i)"
/>
<div class="pt-2">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
@click="appendNewAction"
/>
</div>
</ul>
</section>
<!-- Actions End -->
<div class="w-full mt-8">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-4">
<NextButton
faded
slate
type="reset"
:label="$t(cancelKey)"
@click.prevent="close"
/>
<NextButton
solid
blue
type="submit"
:label="$t(submitKey)"
@click="emitSaveAutomation"
/>
</div>
</div>
</div>
</Dialog>
</template>
@@ -1,80 +1,345 @@
<script setup>
import { ref, watch } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
<script>
import { mapGetters } from 'vuex';
import { useAutomation } from 'dashboard/composables/useAutomation';
import { useEditableAutomation } from 'dashboard/composables/useEditableAutomation';
import AutomationRuleForm from './AutomationRuleForm.vue';
import { AUTOMATION_ACTION_TYPES } from './constants';
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import {
getFileName,
generateAutomationPayload,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
} from 'dashboard/helper/automationHelper';
import { validateAutomation } from 'dashboard/helper/validations';
const props = defineProps({
selectedResponse: {
type: Object,
default: () => ({}),
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
export default {
components: {
FilterInputBox,
NextButton,
AutomationActionInput,
},
});
const emit = defineEmits(['saveAutomation']);
const allCustomAttributes = useMapGetter('attributes/getAttributes');
const formRef = ref(null);
const {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
} = useAutomation();
const { formatAutomation } = useEditableAutomation();
const open = () => formRef.value?.open();
const close = () => formRef.value?.close();
const onSave = (payload, mode) => {
emit('saveAutomation', payload, mode);
};
watch(
() => props.selectedResponse,
value => {
if (!value?.conditions) return;
manifestCustomAttributes();
automation.value = formatAutomation(
value,
allCustomAttributes.value,
props: {
onClose: {
type: Function,
default: () => {},
},
selectedResponse: {
type: Object,
default: () => {},
},
},
emits: ['saveAutomation'],
setup() {
const {
automation,
automationTypes,
AUTOMATION_ACTION_TYPES
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
} = useAutomation();
const { formatAutomation } = useEditableAutomation();
return {
automation,
automationTypes,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
formatAutomation,
manifestCustomAttributes,
};
},
data() {
return {
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
automationMutated: false,
show: true,
showDeleteConfirmationModal: false,
allCustomAttributes: [],
mode: 'edit',
errors: {},
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
automationRuleEvents() {
return AUTOMATION_RULE_EVENTS.map(event => ({
...event,
value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
}));
},
hasAutomationMutated() {
if (
this.automation.conditions[0].values ||
this.automation.actions[0].action_params.length
)
return true;
return false;
},
automationActionTypes() {
const actionTypes = this.isFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
return actionTypes.map(action => ({
...action,
label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
}));
},
},
mounted() {
this.manifestCustomAttributes();
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
this.automation = this.formatAutomation(
this.selectedResponse,
this.allCustomAttributes,
this.automationTypes,
this.automationActionTypes
);
},
{ immediate: true }
);
methods: {
getFileName,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
isFeatureEnabled(flag) {
return this.isFeatureEnabledonAccount(this.accountId, flag);
},
emitSaveAutomation() {
this.errors = validateAutomation(this.automation);
if (Object.keys(this.errors).length === 0) {
const automation = generateAutomationPayload(this.automation);
this.$emit('saveAutomation', automation, this.mode);
}
},
getTranslatedAttributes(type, event) {
return getAttributes(type, event).map(attribute => {
// Skip translation
// 1. If customAttributeType key is present then its rendering attributes from API
// 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
const skipTranslation =
attribute.customAttributeType ||
[
'contact_custom_attribute',
'conversation_custom_attribute',
].includes(attribute.key);
defineExpose({ open, close });
return {
...attribute,
name: skipTranslation
? attribute.name
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
};
});
},
},
};
</script>
<template>
<AutomationRuleForm
ref="formRef"
v-model:automation="automation"
mode="edit"
:automation-types="automationTypes"
:get-condition-dropdown-values="getConditionDropdownValues"
:get-action-dropdown-values="getActionDropdownValues"
:append-new-condition="appendNewCondition"
:append-new-action="appendNewAction"
:remove-filter="removeFilter"
:remove-action="removeAction"
:reset-action="resetAction"
:on-event-change="onEventChange"
@save="onSave"
/>
<div>
<woot-modal-header :header-title="$t('AUTOMATION.EDIT.TITLE')" />
<div class="flex flex-col modal-content">
<div v-if="automation" class="w-full">
<woot-input
v-model="automation.name"
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
type="text"
:class="{ error: errors.name }"
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
/>
<woot-input
v-model="automation.description"
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
type="text"
:class="{ error: errors.description }"
:error="
errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''
"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<div class="event_wrapper">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
@change="onEventChange(automation)"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
</div>
<!-- // Conditions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<FilterInputBox
v-for="(condition, i) in automation.conditions"
:key="i"
v-model="automation.conditions[i]"
:filter-attributes="
getTranslatedAttributes(automationTypes, automation.event_name)
"
:input-type="
getInputType(
allCustomAttributes,
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:operators="
getOperators(
allCustomAttributes,
automationTypes,
automation,
mode,
automation.conditions[i].attribute_key
)
"
:dropdown-values="
getConditionDropdownValues(
automation.conditions[i].attribute_key
)
"
:custom-attribute-type="
getCustomAttributeType(
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:show-query-operator="i !== automation.conditions.length - 1"
:error-message="
errors[`condition_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
: ''
"
@reset-filter="resetFilter(i, automation.conditions[i])"
@remove-filter="removeFilter(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</div>
</section>
<!-- // Conditions End -->
<!-- // Actions Start -->
<section>
<label>
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
</label>
<div
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
>
<AutomationActionInput
v-for="(action, i) in automation.actions"
:key="i"
v-model="automation.actions[i]"
:action-types="automationActionTypes"
:dropdown-values="getActionDropdownValues(action.action_name)"
:show-action-input="
showActionInput(automationActionTypes, action.action_name)
"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
:initial-file-name="getFileName(action, automation.files)"
@reset-action="resetAction(i)"
@remove-action="removeAction(i)"
/>
<div class="mt-4">
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
@click="appendNewAction"
/>
</div>
</div>
</section>
<!-- // Actions End -->
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('AUTOMATION.EDIT.CANCEL_BUTTON_TEXT')"
@click.prevent="onClose"
/>
<NextButton
solid
blue
type="submit"
:label="$t('AUTOMATION.EDIT.SUBMIT')"
@click="emitSaveAutomation"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.event_wrapper {
select {
@apply m-0;
}
.info-message {
@apply text-xs text-n-teal-10 text-right;
}
@apply mb-6;
}
</style>
@@ -16,8 +16,8 @@ const { t } = useI18n();
const confirmDialog = ref(null);
const loading = ref({});
const addDialogRef = ref(null);
const editDialogRef = ref(null);
const showAddPopup = ref(false);
const showEditPopup = ref(false);
const showDeleteConfirmationPopup = ref(false);
const selectedAutomation = ref({});
const toggleModalTitle = ref(t('AUTOMATION.TOGGLE.ACTIVATION_TITLE'));
@@ -57,18 +57,18 @@ onMounted(() => {
});
const openAddPopup = () => {
addDialogRef.value?.open();
showAddPopup.value = true;
};
const hideAddPopup = () => {
addDialogRef.value?.close();
showAddPopup.value = false;
};
const openEditPopup = response => {
selectedAutomation.value = { ...response };
editDialogRef.value?.open();
selectedAutomation.value = response;
showEditPopup.value = true;
};
const hideEditPopup = () => {
editDialogRef.value?.close();
showEditPopup.value = false;
};
const openDeletePopup = response => {
@@ -221,7 +221,17 @@ const tableHeaders = computed(() => {
</table>
</template>
<AddAutomationRule ref="addDialogRef" @save-automation="submitAutomation" />
<woot-modal
v-model:show="showAddPopup"
size="medium"
:on-close="hideAddPopup"
>
<AddAutomationRule
v-if="showAddPopup"
:on-close="hideAddPopup"
@save-automation="submitAutomation"
/>
</woot-modal>
<woot-delete-modal
v-model:show="showDeleteConfirmationPopup"
@@ -234,11 +244,18 @@ const tableHeaders = computed(() => {
:reject-text="deleteRejectText"
/>
<EditAutomationRule
ref="editDialogRef"
:selected-response="selectedAutomation"
@save-automation="submitAutomation"
/>
<woot-modal
v-model:show="showEditPopup"
size="medium"
:on-close="hideEditPopup"
>
<EditAutomationRule
v-if="showEditPopup"
:on-close="hideEditPopup"
:selected-response="selectedAutomation"
@save-automation="submitAutomation"
/>
</woot-modal>
<woot-confirm-modal
ref="confirmDialog"
:title="toggleModalTitle"
@@ -5,7 +5,6 @@ import { useAlert } from 'dashboard/composables';
import InboxMembersAPI from '../../../../api/inboxMembers';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import { useVuelidate } from '@vuelidate/core';
@@ -14,12 +13,11 @@ export default {
components: {
PageHeader,
NextButton,
TagInput,
},
validations: {
selectedAgentIds: {
selectedAgents: {
isEmpty() {
return !!this.selectedAgentIds.length;
return !!this.selectedAgents.length;
},
},
},
@@ -28,7 +26,7 @@ export default {
},
data() {
return {
selectedAgentIds: [],
selectedAgents: [],
isCreating: false,
};
},
@@ -36,43 +34,18 @@ export default {
...mapGetters({
agentList: 'agents/getAgents',
}),
selectedAgentNames() {
return this.selectedAgentIds.map(
id => this.agentList.find(a => a.id === id)?.name ?? ''
);
},
agentMenuItems() {
return this.agentList
.filter(({ id }) => !this.selectedAgentIds.includes(id))
.map(({ id, name, thumbnail, avatar_url }) => ({
label: name,
value: id,
action: 'select',
thumbnail: { name, src: thumbnail || avatar_url || '' },
}));
},
},
mounted() {
this.$store.dispatch('agents/get');
},
methods: {
handleAgentAdd({ value }) {
if (!this.selectedAgentIds.includes(value)) {
this.selectedAgentIds.push(value);
}
},
handleAgentRemove(index) {
this.selectedAgentIds.splice(index, 1);
},
async addAgents() {
this.isCreating = true;
const inboxId = this.$route.params.inbox_id;
const selectedAgents = this.selectedAgents.map(x => x.id);
try {
await InboxMembersAPI.update({
inboxId,
agentList: this.selectedAgentIds,
});
await InboxMembersAPI.update({ inboxId, agentList: selectedAgents });
router.replace({
name: 'settings_inbox_finish',
params: {
@@ -99,23 +72,25 @@ export default {
/>
</div>
<div>
<div class="w-full mb-4">
<label :class="{ error: v$.selectedAgentIds.$error }">
<div class="w-full">
<label :class="{ error: v$.selectedAgents.$error }">
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
<div
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
>
<TagInput
:model-value="selectedAgentNames"
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
:menu-items="agentMenuItems"
show-dropdown
skip-label-dedup
@add="handleAgentAdd"
@remove="handleAgentRemove"
/>
</div>
<span v-if="v$.selectedAgentIds.$error" class="message">
<multiselect
v-model="selectedAgents"
:options="agentList"
track-by="id"
label="name"
multiple
:close-on-select="false"
:clear-on-select="false"
hide-selected
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
@select="v$.selectedAgents.$touch"
/>
<span v-if="v$.selectedAgents.$error" class="message">
{{ $t('INBOX_MGMT.ADD.AGENTS.VALIDATION_ERROR') }}
</span>
</label>
@@ -12,7 +12,6 @@ import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/vue';
@@ -22,7 +21,6 @@ export default {
LoadingState,
PageHeader,
NextButton,
ComboBox,
},
setup() {
const { accountId } = useAccount();
@@ -69,12 +67,6 @@ export default {
getSelectablePages() {
return this.pageList.filter(item => !item.exists);
},
comboBoxPageOptions() {
return this.getSelectablePages.map(({ id, name }) => ({
value: id,
label: name,
}));
},
},
mounted() {
@@ -102,16 +94,9 @@ export default {
}
},
setPageName(pageId) {
const page = this.pageList.find(p => p.id === pageId);
if (page) {
this.selectedPage = page;
this.pageName = page.name;
} else {
this.selectedPage = { name: null, id: null };
this.pageName = '';
}
setPageName({ name }) {
this.v$.selectedPage.$touch();
this.pageName = name;
},
initChannelAuth(channel) {
@@ -260,20 +245,23 @@ export default {
/>
</div>
<div class="w-3/5">
<div class="w-full mb-2">
<div class="w-full">
<div class="input-wrap" :class="{ error: v$.selectedPage.$error }">
<span class="text-n-slate-12 text-start">
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }}
</span>
<ComboBox
:model-value="selectedPage.id"
:options="comboBoxPageOptions"
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }}
<multiselect
v-model="selectedPage"
close-on-select
allow-empty
:options="getSelectablePages"
track-by="id"
label="name"
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
:placeholder="$t('INBOX_MGMT.ADD.FB.PICK_A_VALUE')"
:has-error="v$.selectedPage.$error"
class="[&>div>button]:!bg-n-alpha-black2 mt-1"
@update:model-value="setPageName"
selected-label
@select="setPageName"
/>
<span v-if="v$.selectedPage.$error" class="message mt-0.5">
<span v-if="v$.selectedPage.$error" class="message">
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PLACEHOLDER') }}
</span>
</div>
@@ -19,18 +19,10 @@ module AutoAssignmentHandler
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
else
# Use legacy assignment system
# If conversation has a team, only consider team members for assignment
allowed_agent_ids = team_id.present? ? team_member_ids_with_capacity : inbox.member_ids_with_assignment_capacity
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: allowed_agent_ids).perform
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
end
def team_member_ids_with_capacity
return [] if team.blank? || team.allow_auto_assign.blank?
inbox.member_ids_with_assignment_capacity & team.members.ids
end
def should_run_auto_assignment?
return false unless inbox.enable_auto_assignment?
+3 -1
View File
@@ -318,7 +318,9 @@ class Message < ApplicationRecord
end
def update_contact_activity
sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact)
# rubocop:disable Rails/SkipsModelValidations
sender.update_columns(last_activity_at: DateTime.now) if sender.is_a?(Contact)
# rubocop:enable Rails/SkipsModelValidations
end
def update_waiting_since
@@ -19,7 +19,7 @@ class AutoAssignment::AssignmentService
def perform_for_conversation(conversation)
return false unless assignable?(conversation)
agent = find_available_agent(conversation)
agent = find_available_agent
return false unless agent
assign_conversation(conversation, agent)
@@ -44,26 +44,13 @@ class AutoAssignment::AssignmentService
scope.limit(limit)
end
def find_available_agent(conversation = nil)
agents = filter_agents_by_team(inbox.available_agents, conversation)
return nil if agents.nil?
agents = filter_agents_by_rate_limit(agents)
def find_available_agent
agents = filter_agents_by_rate_limit(inbox.available_agents)
return nil if agents.empty?
round_robin_selector.select_agent(agents)
end
def filter_agents_by_team(agents, conversation)
return agents if conversation&.team_id.blank?
team = conversation.team
return nil if team.blank? || team.allow_auto_assign.blank?
team_member_ids = team.members.ids
agents.where(user_id: team_member_ids)
end
def filter_agents_by_rate_limit(agents)
agents.select do |agent_member|
rate_limiter = build_rate_limiter(agent_member.user)
+1 -13
View File
@@ -47,10 +47,8 @@ class Twilio::DeliveryStatusService
@twilio_channel ||= if params[:MessagingServiceSid].present?
::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
elsif params[:AccountSid].present? && params[:From].present?
::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: params[:From])
::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid], phone_number: params[:From])
end
log_channel_not_found if @twilio_channel.blank?
@twilio_channel
end
def message
@@ -58,14 +56,4 @@ class Twilio::DeliveryStatusService
@message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid])
end
def log_channel_not_found
Rails.logger.warn(
'[TWILIO] Delivery status channel lookup failed ' \
"account_sid=#{params[:AccountSid]} " \
"from=#{params[:From]} " \
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
"message_sid=#{params[:MessageSid]}"
)
end
end
@@ -26,23 +26,12 @@ class Twilio::IncomingMessageService
def twilio_channel
@twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
if params[:AccountSid].present? && params[:To].present?
@twilio_channel ||= ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid],
phone_number: params[:To])
@twilio_channel ||= ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid],
phone_number: params[:To])
end
log_channel_not_found if @twilio_channel.blank?
@twilio_channel
end
def log_channel_not_found
Rails.logger.warn(
'[TWILIO] Incoming message channel lookup failed ' \
"account_sid=#{params[:AccountSid]} " \
"to=#{params[:To]} " \
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
"sms_sid=#{params[:SmsSid]}"
)
end
def inbox
@inbox ||= twilio_channel.inbox
end
@@ -28,19 +28,19 @@ class Whatsapp::IncomingMessageBaseService
# if the webhook event is a reaction or an ephermal message or an unsupported message.
return if unprocessable_message_type?(message_type)
# Multiple webhook events can be received for the same message due to
# misconfigurations in the Meta business manager account.
# We use an atomic Redis SET NX to prevent concurrent workers from both
# processing the same message simultaneously.
return if find_message_by_source_id(messages_data.first[:id])
return unless lock_message_source_id!
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
# business manager account. While we have not found the core reason yet, the following line ensure that
# there are no duplicate messages created.
return if find_message_by_source_id(messages_data.first[:id]) || message_under_process?
cache_message_source_id_in_redis
set_contact
return unless @contact
ActiveRecord::Base.transaction do
set_conversation
create_messages
clear_message_source_id_from_redis
end
end
@@ -69,9 +69,20 @@ module Whatsapp::IncomingMessageServiceHelpers
@message = Message.find_by(source_id: source_id)
end
def lock_message_source_id!
return false if messages_data.blank?
def message_under_process?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
Redis::Alfred.get(key)
end
Whatsapp::MessageDedupLock.new(messages_data.first[:id]).acquire!
def cache_message_source_id_in_redis
return if messages_data.blank?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.setex(key, true)
end
def clear_message_source_id_from_redis
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.delete(key)
end
end
@@ -1,19 +0,0 @@
# Atomic dedup lock for WhatsApp incoming messages.
#
# Meta can deliver the same webhook event multiple times. This lock uses
# Redis SET NX EX to ensure only one worker processes a given source_id.
class Whatsapp::MessageDedupLock
KEY_PREFIX = Redis::RedisKeys::MESSAGE_SOURCE_KEY
DEFAULT_TTL = 1.day.to_i
def initialize(source_id, ttl: DEFAULT_TTL)
@key = format(KEY_PREFIX, id: source_id)
@ttl = ttl
end
# Returns true when the lock is acquired (caller should proceed).
# Returns false when another worker already holds the lock.
def acquire!
::Redis::Alfred.set(@key, true, nx: true, ex: @ttl)
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.11.0'
version: '4.10.1'
development:
<<: *shared
-1
View File
@@ -564,7 +564,6 @@ Rails.application.routes.draw do
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
post 'webhooks/instagram', to: 'webhooks/instagram#events'
post 'webhooks/tiktok', to: 'webhooks/tiktok#events'
post 'webhooks/shopify', to: 'webhooks/shopify#events'
namespace :twitter do
resource :callback, only: [:show]
@@ -1,13 +1,10 @@
module Captain::ChatResponseHelper
include Integrations::LlmInstrumentationConstants
private
def build_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
parsed = parse_json_response(response.content)
apply_credit_usage_metadata(parsed)
persist_message(parsed, 'assistant')
parsed
@@ -22,26 +19,6 @@ module Captain::ChatResponseHelper
{ 'content' => content }
end
def apply_credit_usage_metadata(parsed_response)
return unless captain_v1_assistant?
OpenTelemetry::Trace.current_span.set_attribute(
format(ATTR_LANGFUSE_METADATA, 'credit_used'),
credit_used_for_response?(parsed_response).to_s
)
rescue StandardError => e
Rails.logger.warn "#{self.class.name} Assistant: #{@assistant.id}, Failed to set credit usage metadata: #{e.message}"
end
def credit_used_for_response?(parsed_response)
response = parsed_response['response']
response.present? && response != 'conversation_handoff'
end
def captain_v1_assistant?
feature_name == 'assistant' && !@assistant.account.feature_enabled?('captain_integration_v2')
end
def persist_thinking_message(tool_call)
return if @copilot_thread.blank?
@@ -13,7 +13,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
if captain_v2_enabled?
generate_response_with_v2
else
generate_and_process_response
ActiveRecord::Base.transaction do
generate_and_process_response
end
end
rescue StandardError => e
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
@@ -42,15 +44,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def process_response
ActiveRecord::Base.transaction do
if handoff_requested?
process_action('handoff')
else
create_messages
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
end
end
return process_action('handoff') if handoff_requested?
create_messages
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
end
def collect_previous_messages
@@ -1,15 +1,6 @@
class Messages::AudioTranscriptionJob < ApplicationJob
queue_as :low
discard_on Faraday::BadRequestError do |job, error|
log_context = {
attachment_id: job.arguments.first,
job_id: job.job_id,
status_code: error.response&.dig(:status)
}
Rails.logger.warn("Discarding audio transcription job due to bad request: #{log_context}")
end
retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
def perform(attachment_id)
@@ -1,9 +1,6 @@
require 'agents'
require 'agents/instrumentation'
class Captain::Assistant::AgentRunnerService
include Integrations::LlmInstrumentationConstants
CONVERSATION_STATE_ATTRIBUTES = %i[
id display_id inbox_id contact_id status priority
label_list custom_attributes additional_attributes
@@ -25,9 +22,7 @@ class Captain::Assistant::AgentRunnerService
context = build_context(message_history)
message_to_process = extract_last_user_message(message_history)
runner = Agents::Runner.with_agents(*agents)
runner = add_usage_metadata_callback(runner)
runner = add_callbacks_to_runner(runner) if @callbacks.any?
install_instrumentation(runner)
result = runner.run(message_to_process, context: context, max_turns: 100)
process_agent_result(result)
@@ -55,7 +50,6 @@ class Captain::Assistant::AgentRunnerService
end
{
session_id: "#{@assistant.account_id}_#{@conversation&.display_id}",
conversation_history: conversation_history,
state: build_state
}
@@ -130,31 +124,6 @@ class Captain::Assistant::AgentRunnerService
[assistant_agent] + scenario_agents
end
def install_instrumentation(runner)
return unless ChatwootApp.otel_enabled?
Agents::Instrumentation.install(
runner,
tracer: OpentelemetryConfig.tracer,
trace_name: 'llm.captain_v2',
span_attributes: {
ATTR_LANGFUSE_TAGS => ['captain_v2'].to_json
},
attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) }
)
end
def dynamic_trace_attributes(context_wrapper)
state = context_wrapper&.context&.dig(:state) || {}
conversation = state[:conversation] || {}
{
ATTR_LANGFUSE_USER_ID => state[:account_id],
format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id],
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id]
}.compact.transform_values(&:to_s)
end
def add_callbacks_to_runner(runner)
runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
@@ -163,36 +132,6 @@ class Captain::Assistant::AgentRunnerService
runner
end
def add_usage_metadata_callback(runner)
return runner unless ChatwootApp.otel_enabled?
handoff_tool_name = Captain::Tools::HandoffTool.new(@assistant).name
runner.on_tool_complete do |tool_name, _tool_result, context_wrapper|
track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
end
runner.on_run_complete do |_agent_name, _result, context_wrapper|
write_credits_used_metadata(context_wrapper)
end
runner
end
def track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
return unless context_wrapper&.context
return unless tool_name.to_s == handoff_tool_name
context_wrapper.context[:captain_v2_handoff_tool_called] = true
end
def write_credits_used_metadata(context_wrapper)
root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span)
return unless root_span
credit_used = !context_wrapper.context[:captain_v2_handoff_tool_called]
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), credit_used.to_s)
end
def add_agent_thinking_callback(runner)
runner.on_agent_thinking do |*args|
@callbacks[:on_agent_thinking].call(*args)
@@ -14,11 +14,8 @@ module Enterprise::AutoAssignment::AssignmentService
end
# Extend agent finding to add capacity checks
def find_available_agent(conversation = nil)
agents = filter_agents_by_team(inbox.available_agents, conversation)
return nil if agents.nil?
agents = filter_agents_by_rate_limit(agents)
def find_available_agent
agents = filter_agents_by_rate_limit(inbox.available_agents)
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
return nil if agents.empty?
@@ -31,20 +31,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
end
def fetch_audio_file
blob = attachment.file.blob
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
FileUtils.mkdir_p(temp_dir)
temp_file_name = "#{blob.key}-#{blob.filename}"
if blob.filename.extension_without_delimiter.blank?
extension = extension_from_content_type(blob.content_type)
temp_file_name = "#{temp_file_name}.#{extension}" if extension.present?
end
temp_file_path = File.join(temp_dir, temp_file_name)
temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}")
File.open(temp_file_path, 'wb') do |file|
blob.open do |blob_file|
attachment.file.blob.open do |blob_file|
IO.copy_stream(blob_file, file)
end
end
@@ -57,12 +49,13 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
return transcribed_text if transcribed_text.present?
temp_file_path = fetch_audio_file
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
parameters: {
model: WHISPER_MODEL,
model: 'whisper-1',
file: file,
temperature: 0.4
}
@@ -70,10 +63,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcribed_text = response['text']
end
FileUtils.rm_f(temp_file_path)
update_transcription(transcribed_text)
transcribed_text
ensure
FileUtils.rm_f(temp_file_path) if temp_file_path.present?
end
def instrumentation_params(file_path)
@@ -97,15 +90,4 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
message.reindex
end
def extension_from_content_type(content_type)
subtype = content_type.to_s.downcase.split(';').first.to_s.split('/').last.to_s
return if subtype.blank?
{
'x-m4a' => 'm4a',
'x-wav' => 'wav',
'x-mp3' => 'mp3'
}.fetch(subtype, subtype)
end
end
-9
View File
@@ -15,7 +15,6 @@ module Captain::ToolInstrumentation
response = yield
executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
end
response
rescue StandardError => e
@@ -30,14 +29,6 @@ module Captain::ToolInstrumentation
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
end
def set_tool_session_error_attributes(span, response)
error = response[:error] || response['error']
return if error.blank?
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
end
def record_generation(chat, message, model)
return unless ChatwootApp.otel_enabled?
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
-3
View File
@@ -37,7 +37,6 @@ module Integrations::LlmInstrumentation
result = yield
executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
set_error_attributes(span, result) if result.is_a?(Hash)
result
end
rescue StandardError => e
@@ -51,11 +50,9 @@ module Integrations::LlmInstrumentation
return yield unless ChatwootApp.otel_enabled?
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
result = yield
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
set_error_attributes(span, result) if result.is_a?(Hash)
result
end
end
@@ -26,7 +26,6 @@ module Integrations::LlmInstrumentationConstants
ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s'
ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input'
ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output'
ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type'
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
end
@@ -39,7 +39,6 @@ module Integrations::LlmInstrumentationSpans
tool_name = tool_call.name.to_s
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
@pending_tool_spans ||= []
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.11.0",
"version": "4.10.1",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -330,7 +330,6 @@ RSpec.describe 'Conversations API', type: :request do
context 'when it is an authenticated user who has access to the inbox' do
before do
create(:inbox_member, user: agent, inbox: inbox)
create(:team_member, user: agent, team: team)
end
it 'creates a new conversation' do
@@ -75,7 +75,6 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
it 'runs agent with extracted user message and context' do
expected_context = {
session_id: "#{account.id}_#{conversation.display_id}",
conversation_history: [
{ role: :user, content: 'Hello there', agent_name: nil },
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' },
@@ -307,60 +306,6 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
end
describe '#add_usage_metadata_callback' do
it 'sets credit_used=false when handoff tool is used' do
service = described_class.new(assistant: assistant, conversation: conversation)
runner = instance_double(Agents::AgentRunner)
tool_complete_callback = nil
run_complete_callback = nil
span_class = Class.new do
def set_attribute(*); end
end
root_span = instance_double(span_class)
context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
allow(runner).to receive(:on_tool_complete) do |&block|
tool_complete_callback = block
runner
end
allow(runner).to receive(:on_run_complete) do |&block|
run_complete_callback = block
runner
end
service.send(:add_usage_metadata_callback, runner)
tool_complete_callback.call(Captain::Tools::HandoffTool.new(assistant).name, 'ok', context_wrapper)
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'false')
run_complete_callback.call('assistant', nil, context_wrapper)
end
it 'sets credit_used=true when handoff tool is not used' do
service = described_class.new(assistant: assistant, conversation: conversation)
runner = instance_double(Agents::AgentRunner)
run_complete_callback = nil
span_class = Class.new do
def set_attribute(*); end
end
root_span = instance_double(span_class)
context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
allow(runner).to receive(:on_tool_complete).and_return(runner)
allow(runner).to receive(:on_run_complete) do |&block|
run_complete_callback = block
runner
end
service.send(:add_usage_metadata_callback, runner)
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'true')
run_complete_callback.call('assistant', nil, context_wrapper)
end
end
describe 'constants' do
it 'defines conversation state attributes' do
expect(described_class::CONVERSATION_STATE_ATTRIBUTES).to include(
@@ -8,8 +8,8 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
before do
# Create required installation configs
InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_API_KEY') { |config| config.value = 'test-api-key' }
InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' }
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-api-key')
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
# Mock usage limits for transcription to be available
allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
@@ -64,24 +64,4 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
end
end
end
describe '#fetch_audio_file' do
let(:service) { described_class.new(attachment) }
before do
attachment.file.attach(
io: File.open(Rails.public_path.join('audio/widget/ding.mp3')),
filename: 'speech',
content_type: 'audio/mpeg'
)
end
it 'adds extension from content type when filename has no extension' do
temp_file_path = service.send(:fetch_audio_file)
expect(File.extname(temp_file_path)).to eq('.mpeg')
ensure
FileUtils.rm_f(temp_file_path) if temp_file_path.present?
end
end
end
@@ -307,52 +307,5 @@ RSpec.describe AutoAssignment::AssignmentService do
end
end
end
context 'with team assignments' do
let(:team) { create(:team, account: account, allow_auto_assign: true) }
let(:team_member) { create(:user, account: account, role: :agent, availability: :online) }
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
before do
create(:team_member, team: team, user: team_member)
create(:inbox_member, inbox: inbox, user: team_member)
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ team_member.id.to_s => 'online' })
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
allow(rate_limiter).to receive(:within_limit?).and_return(true)
allow(rate_limiter).to receive(:track_assignment)
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
allow(round_robin_selector).to receive(:select_agent).and_return(team_member)
end
it 'assigns conversation with team to team member' do
conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
service.perform_bulk_assignment(limit: 1)
expect(conversation_with_team.reload.assignee).to eq(team_member)
end
it 'skips assignment when team has allow_auto_assign false' do
team.update!(allow_auto_assign: false)
conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
service.perform_bulk_assignment(limit: 1)
expect(conversation_with_team.reload.assignee).to be_nil
end
it 'skips assignment when no team members are available' do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
service.perform_bulk_assignment(limit: 1)
expect(conversation_with_team.reload.assignee).to be_nil
end
end
end
end
@@ -6,14 +6,6 @@ describe Whatsapp::IncomingMessageService do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
end
after do
# The atomic dedup lock lives in Redis and is not rolled back by
# transactional fixtures. Clean up any keys created during the test.
Redis::Alfred.scan_each(match: 'MESSAGE_SOURCE_KEY::*') do |key|
Redis::Alfred.delete(key)
end
end
let!(:whatsapp_channel) { create(:channel_whatsapp, sync_templates: false) }
let(:wa_id) { '2423423243' }
let!(:params) do
@@ -401,8 +393,8 @@ describe Whatsapp::IncomingMessageService do
end
end
describe 'when another worker already holds the dedup lock' do
it 'skips message creation' do
describe 'when message processing is in progress' do
it 'ignores the current message creation request' do
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
'messages' => [{ 'from' => '919446284490',
'id' => 'wamid.SDFADSf23sfasdafasdfa',
@@ -417,14 +409,17 @@ describe Whatsapp::IncomingMessageService do
'phones' => [{ 'phone' => '+1 (415) 341-8386' }] }
] }] }.with_indifferent_access
# Simulate another worker holding the lock
lock = Whatsapp::MessageDedupLock.new('wamid.SDFADSf23sfasdafasdfa')
expect(lock.acquire!).to be_truthy
expect(Message.find_by(source_id: 'wamid.SDFADSf23sfasdafasdfa')).not_to be_present
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: 'wamid.SDFADSf23sfasdafasdfa')
Redis::Alfred.setex(key, true)
expect(Redis::Alfred.get(key)).to be_truthy
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.messages.count).to eq(0)
ensure
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: 'wamid.SDFADSf23sfasdafasdfa')
expect(Message.find_by(source_id: 'wamid.SDFADSf23sfasdafasdfa')).not_to be_present
expect(Redis::Alfred.get(key)).to be_truthy
Redis::Alfred.delete(key)
end
end
@@ -2,10 +2,6 @@ require 'rails_helper'
describe Whatsapp::IncomingMessageWhatsappCloudService do
describe '#perform' do
after do
Redis::Alfred.scan_each(match: 'MESSAGE_SOURCE_KEY::*') { |key| Redis::Alfred.delete(key) }
end
let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
let(:params) do
{
@@ -1,43 +0,0 @@
require 'rails_helper'
describe Whatsapp::MessageDedupLock do
let(:source_id) { "wamid.test_#{SecureRandom.hex(8)}" }
let(:lock) { described_class.new(source_id) }
let(:redis_key) { format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: source_id) }
after { Redis::Alfred.delete(redis_key) }
describe '#acquire!' do
it 'returns truthy on first acquire' do
expect(lock.acquire!).to be_truthy
end
it 'returns falsy on second acquire for the same source_id' do
lock.acquire!
expect(described_class.new(source_id).acquire!).to be_falsy
end
it 'allows different source_ids to acquire independently' do
lock.acquire!
other = described_class.new("wamid.other_#{SecureRandom.hex(8)}")
expect(other.acquire!).to be_truthy
end
it 'lets exactly one thread win when two race for the same source_id' do
results = Concurrent::Array.new
barrier = Concurrent::CyclicBarrier.new(2)
threads = Array.new(2) do
Thread.new do
barrier.wait
results << described_class.new(source_id).acquire!
end
end
threads.each(&:join)
wins = results.count { |r| r }
expect(wins).to eq(1), "Expected exactly 1 winner but got #{wins}. Results: #{results.inspect}"
end
end
end