Merge branch 'develop' into feat/expand-idb-coverage

This commit is contained in:
Shivam Mishra
2026-05-27 12:10:39 +05:30
committed by GitHub
40 changed files with 765 additions and 67 deletions
+4
View File
@@ -234,6 +234,10 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
## SafeFetch private network access
## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :set_articles, only: [:update_status, :delete_articles]
before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
def translate
head :not_implemented
@@ -19,6 +19,18 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
render_could_not_create_error(e.message)
end
def update_category
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid?
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(category_id: params[:category_id]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@@ -39,5 +51,9 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
def category_valid?
@portal.categories.exists?(id: params[:category_id])
end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
@@ -87,6 +87,13 @@ class ArticlesAPI extends PortalsAPI {
);
}
bulkUpdateCategory({ portalSlug, articleIds, categoryId }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_category`,
{ ids: articleIds, category_id: categoryId }
);
}
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
@@ -153,4 +153,33 @@ describe('#PortalAPI', () => {
);
});
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#bulkUpdateCategory', () => {
articlesAPI.bulkUpdateCategory({
portalSlug: 'room-rental',
articleIds: [1, 2, 3],
categoryId: 7,
});
expect(axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles/bulk_actions/update_category',
{ ids: [1, 2, 3], category_id: 7 }
);
});
});
});
@@ -2,6 +2,7 @@
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
@@ -18,6 +19,7 @@ import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/A
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
@@ -62,6 +64,7 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const { isEnterprise } = useConfig();
@@ -221,6 +224,34 @@ const bulkUpdateStatus = async status => {
}
};
const categoryMenuItems = computed(() =>
props.categories.map(category => ({
label: category.name,
value: category.id,
action: 'move',
emoji: category.icon,
}))
);
const handleBulkUpdateCategory = async ({ value }) => {
isCategoryMenuOpen.value = false;
try {
await articlesAPI.bulkUpdateCategory({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
categoryId: value,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_SUCCESS')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_ERROR')
);
}
};
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
@@ -340,6 +371,30 @@ watch(
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
<div v-if="categoryMenuItems.length" class="relative group">
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
sm
faded
slate
icon="i-lucide-folder-input"
:label="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.MOVE_TO_CATEGORY'
)
"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
/>
<DropdownMenu
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="right-0 w-48 mt-2 top-full max-h-60"
@action="handleBulkUpdateCategory"
/>
</OnClickOutside>
</div>
<Button
v-if="isTranslationAvailable"
sm
@@ -202,7 +202,7 @@ defineExpose({ state, isSubmitDisabled });
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
@@ -0,0 +1,65 @@
<script setup>
import ChannelIcon from './ChannelIcon.vue';
import { useChannelBrandIcon } from './provider';
const inboxes = [
{ name: 'API', channel_type: 'Channel::Api' },
{ name: 'Email', channel_type: 'Channel::Email' },
{ name: 'Gmail', channel_type: 'Channel::Email', provider: 'google' },
{ name: 'Outlook', channel_type: 'Channel::Email', provider: 'microsoft' },
{ name: 'Messenger', channel_type: 'Channel::FacebookPage' },
{ name: 'Instagram', channel_type: 'Channel::Instagram' },
{ name: 'Line', channel_type: 'Channel::Line' },
{ name: 'Telegram', channel_type: 'Channel::Telegram' },
{ name: 'WhatsApp', channel_type: 'Channel::Whatsapp' },
{ name: 'TikTok', channel_type: 'Channel::Tiktok' },
{ name: 'SMS', channel_type: 'Channel::Sms' },
{ name: 'Twilio SMS', channel_type: 'Channel::TwilioSms' },
{
name: 'Twilio WhatsApp',
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
},
{
name: 'Voice',
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
},
{ name: 'Website', channel_type: 'Channel::WebWidget' },
];
const brandInboxes = inboxes.filter(inbox => useChannelBrandIcon(inbox).value);
</script>
<template>
<Story title="Components/Icons/ChannelIcon">
<Variant title="Glyph (default)">
<div class="grid grid-cols-4 gap-5">
<div
v-for="inbox in inboxes"
:key="inbox.name"
class="flex items-center gap-2"
>
<ChannelIcon :inbox="inbox" class="size-6 text-n-slate-11" />
<span>{{ inbox.name }}</span>
</div>
</div>
</Variant>
<Variant title="Brand icon">
<div class="grid grid-cols-4 gap-5">
<div
v-for="inbox in brandInboxes"
:key="inbox.name"
class="flex items-center gap-2"
>
<ChannelIcon
:inbox="inbox"
use-brand-icon
class="size-6 text-n-slate-11"
/>
<span>{{ inbox.name }}</span>
</div>
</div>
</Variant>
</Story>
</template>
@@ -1,7 +1,7 @@
<script setup>
import { computed, toRef } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useChannelIcon } from './provider';
import { useChannelIcon, useChannelBrandIcon } from './provider';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
@@ -9,17 +9,30 @@ const props = defineProps({
type: Object,
required: true,
},
// When true, render the full-color brand icon (when one exists for the
// channel type) and fall back to the monochrome glyph otherwise.
useBrandIcon: {
type: Boolean,
default: false,
},
});
defineOptions({ inheritAttrs: false });
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
const inboxRef = toRef(props, 'inbox');
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
const channelIcon = useChannelIcon(inboxRef);
const brandIcon = useChannelBrandIcon(inboxRef);
const icon = computed(() =>
props.useBrandIcon && brandIcon.value ? brandIcon.value : channelIcon.value
);
</script>
<template>
<span class="relative inline-flex" v-bind="$attrs">
<Icon :icon="channelIcon" class="size-full" />
<Icon :icon="icon" class="size-full" />
<span
v-if="hasVoiceBadge"
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
@@ -1,29 +1,49 @@
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { computed } from 'vue';
const channelTypeIconMap = {
'Channel::Api': 'i-woot-api',
'Channel::Email': 'i-woot-mail',
'Channel::FacebookPage': 'i-woot-messenger',
'Channel::Line': 'i-woot-line',
'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-woot-x',
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
};
const providerIconMap = {
microsoft: 'i-woot-outlook',
google: 'i-woot-gmail',
};
// Full-color brand icons. Most come from the `logos` Iconify set; Instagram,
// Outlook and Line use custom `woot` glyphs since the `logos` versions are
// monochrome or missing. Channels not listed here have no brand variant and
// callers should fall back to the monochrome glyph via useChannelIcon.
const channelTypeBrandIconMap = {
'Channel::FacebookPage': 'i-logos-messenger',
'Channel::Line': 'i-woot-line-color',
'Channel::Telegram': 'i-logos-telegram',
'Channel::Whatsapp': 'i-logos-whatsapp-icon',
'Channel::Instagram': 'i-woot-instagram-color',
'Channel::Tiktok': 'i-logos-tiktok-icon',
};
const providerBrandIconMap = {
microsoft: 'i-woot-outlook-color',
google: 'i-logos-google-gmail',
};
const resolveInbox = inbox => inbox?.value ?? inbox;
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
'Channel::Api': 'i-woot-api',
'Channel::Email': 'i-woot-mail',
'Channel::FacebookPage': 'i-woot-messenger',
'Channel::Line': 'i-woot-line',
'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-woot-x',
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
};
const providerIconMap = {
microsoft: 'i-woot-outlook',
google: 'i-woot-gmail',
};
const channelIcon = computed(() => {
const inboxDetails = inbox.value || inbox;
const inboxDetails = resolveInbox(inbox);
const type = inboxDetails.channel_type;
let icon = channelTypeIconMap[type];
@@ -58,3 +78,26 @@ export function useChannelIcon(inbox) {
return channelIcon;
}
export function useChannelBrandIcon(inbox) {
return computed(() => {
const inboxDetails = resolveInbox(inbox);
const type = inboxDetails.channel_type;
let icon = channelTypeBrandIconMap[type];
if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
if (Object.keys(providerBrandIconMap).includes(inboxDetails.provider)) {
icon = providerBrandIconMap[inboxDetails.provider];
}
}
if (
type === INBOX_TYPES.TWILIO &&
inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = channelTypeBrandIconMap['Channel::Whatsapp'];
}
return icon ?? null;
});
}
@@ -6,9 +6,12 @@ import BaseBubble from './Base.vue';
const { inboxId } = useMessageContext();
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
inboxId.value
);
const {
isAFacebookInbox,
isAnInstagramChannel,
isATiktokChannel,
isAWhatsAppChannel,
} = useInbox(inboxId.value);
const unsupportedMessageKey = computed(() => {
if (isAFacebookInbox.value)
@@ -16,6 +19,8 @@ const unsupportedMessageKey = computed(() => {
if (isAnInstagramChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
if (isAWhatsAppChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP';
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
});
</script>
@@ -14,6 +14,11 @@ const props = defineProps({
type: String,
default: 'conversation',
},
action: {
type: String,
default: 'assign',
validator: value => ['assign', 'remove'].includes(value),
},
isLoading: {
type: Boolean,
default: false,
@@ -22,9 +27,13 @@ const props = defineProps({
type: Boolean,
default: false,
},
appliedLabels: {
type: Array,
default: null,
},
});
const emit = defineEmits(['assign']);
const emit = defineEmits(['assign', 'remove']);
const { t } = useI18n();
@@ -35,17 +44,43 @@ const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
const isRemoveAction = computed(() => props.action === 'remove');
const buttonLabel = computed(() =>
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
const buttonLabel = computed(() => {
if (!isTypeContact.value) return '';
return isRemoveAction.value
? t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS')
: t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
});
const tooltipLabel = computed(() =>
isRemoveAction.value
? t('BULK_ACTION.LABELS.REMOVE_LABELS')
: t('BULK_ACTION.LABELS.ASSIGN_LABELS')
);
const confirmLabel = computed(() =>
isRemoveAction.value
? t('BULK_ACTION.LABELS.REMOVE_SELECTED_LABELS')
: t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')
);
const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(labelTitle);
};
const visibleLabels = computed(() => {
if (!isRemoveAction.value || props.appliedLabels === null) {
return labels.value;
}
const applied = new Set(props.appliedLabels);
return labels.value.filter(label => applied.has(label.title));
});
const labelMenuItems = computed(() => {
return labels.value.map(label => ({
return visibleLabels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
@@ -64,9 +99,13 @@ const toggleLabelSelection = labelTitle => {
}
};
const handleAssign = () => {
const handleApply = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
if (isRemoveAction.value) {
emit('remove', selectedLabels.value);
} else {
emit('assign', selectedLabels.value);
}
toggleDropdown(false);
selectedLabels.value = [];
}
@@ -81,9 +120,9 @@ const handleDismiss = () => {
<template>
<div ref="containerRef" class="relative">
<NextButton
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
v-tooltip="tooltipLabel"
:label="buttonLabel"
icon="i-lucide-tag"
:icon="isRemoveAction ? 'i-woot-tag-remove' : 'i-lucide-tag'"
slate
:size="isTypeContact ? 'sm' : 'xs'"
ghost
@@ -148,9 +187,9 @@ const handleDismiss = () => {
<NextButton
sm
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:label="confirmLabel"
:disabled="!selectedLabels.length"
@click="handleAssign"
@click="handleApply"
/>
</div>
</template>
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt';
@@ -55,12 +56,25 @@ defineOptions({
const attrs = useAttrs();
const {
selectedConversations,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk: onAssignTeam,
onUpdateConversations,
} = useBulkActions();
const getConversationById = useMapGetter('getConversationById');
const appliedLabelsForSelection = computed(() => {
const applied = new Set();
selectedConversations.value.forEach(id => {
const conversation = getConversationById.value(id);
(conversation?.labels || []).forEach(label => applied.add(label));
});
return Array.from(applied);
});
const showCustomTimeSnoozeModal = ref(false);
function onCmdSnoozeConversation(snoozeType) {
@@ -161,6 +175,11 @@ onUnmounted(() => {
</div>
<div class="flex items-center gap-2">
<BulkLabelActions @assign="onAssignLabels" />
<BulkLabelActions
action="remove"
:applied-labels="appliedLabelsForSelection"
@remove="onRemoveLabels"
/>
<BulkUpdateActions
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
@@ -108,7 +108,7 @@ export function useBulkActions() {
}
}
// Only used in context menu
// Used by both context menu and bulk action bar.
async function onRemoveLabels(labelsToRemove, conversationId = null) {
try {
await store.dispatch('bulkActions/process', {
@@ -119,14 +119,24 @@ export function useBulkActions() {
},
});
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
// Context-menu remove should not disturb an existing bulk selection.
if (conversationId) {
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
} else {
store.dispatch('bulkActions/clearSelectedConversationIds');
useAlert(t('BULK_ACTION.LABELS.REMOVE_SUCCESFUL'));
}
} catch (err) {
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
useAlert(
conversationId
? t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED')
: t('BULK_ACTION.LABELS.REMOVE_FAILED')
);
}
}
@@ -27,9 +27,13 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"REMOVE_LABELS": "Remove labels",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"REMOVE_SELECTED_LABELS": "Remove selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
"REMOVE_SUCCESFUL": "Labels removed successfully.",
"REMOVE_FAILED": "Failed to remove labels. Please try again."
},
"TEAMS": {
"NONE": "None",
@@ -586,8 +586,11 @@
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"REMOVE_LABELS": "Remove Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
"REMOVE_LABELS_FAILED": "Failed to remove labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
@@ -62,6 +62,7 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",
@@ -607,9 +607,12 @@
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"MOVE_TO_CATEGORY": "Category",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
@@ -25,6 +25,7 @@ const props = defineProps({
const emit = defineEmits([
'clearSelection',
'assignLabels',
'removeLabels',
'toggleAll',
'deleteSelected',
]);
@@ -72,6 +73,10 @@ const selectionModel = computed({
const handleAssignLabels = labels => {
emit('assignLabels', labels);
};
const handleRemoveLabels = labels => {
emit('removeLabels', labels);
};
</script>
<template>
@@ -103,6 +108,13 @@ const handleAssignLabels = labels => {
:disabled="!selectedCount"
@assign="handleAssignLabels"
/>
<BulkLabelActions
type="contact"
action="remove"
:is-loading="isLoading"
:disabled="!selectedCount"
@remove="handleRemoveLabels"
/>
<div class="w-px h-3 bg-n-weak rounded-lg" />
<Policy :permissions="['administrator']">
<Button
@@ -351,6 +351,28 @@ const assignLabels = async labels => {
}
};
const removeLabels = async labels => {
if (!labels.length || !selectedContactIds.value.length) {
return;
}
isBulkActionLoading.value = true;
try {
await BulkActionsAPI.create({
type: 'Contact',
ids: selectedContactIds.value,
labels: { remove: labels },
});
useAlert(t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS_SUCCESS'));
clearSelection();
await fetchContactsBasedOnContext(pageNumber.value);
} catch (error) {
useAlert(t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS_FAILED'));
} finally {
isBulkActionLoading.value = false;
}
};
const deleteContacts = async () => {
if (!selectedContactIds.value.length) {
return;
@@ -514,6 +536,7 @@ onMounted(async () => {
@toggle-all="toggleSelectAll"
@clear-selection="clearSelection"
@assign-labels="assignLabels"
@remove-labels="removeLabels"
@delete-selected="openBulkDeleteDialog"
/>
<ContactEmptyState
@@ -8,6 +8,7 @@ class Contacts::BulkActionService
def perform
return delete_contacts if delete_requested?
return assign_labels if labels_to_add.any?
return remove_labels if labels_to_remove.any?
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
{ success: false, error: 'unknown_operation' }
@@ -23,6 +24,14 @@ class Contacts::BulkActionService
).perform
end
def remove_labels
Contacts::BulkRemoveLabelsService.new(
account: @account,
contact_ids: ids,
labels: labels_to_remove
).perform
end
def delete_contacts
Contacts::BulkDeleteService.new(
account: @account,
@@ -38,6 +47,10 @@ class Contacts::BulkActionService
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
end
def labels_to_remove
@labels_to_remove ||= Array(@params.dig(:labels, :remove)).reject(&:blank?)
end
def delete_requested?
@params[:action_name] == 'delete'
end
@@ -0,0 +1,19 @@
class Contacts::BulkRemoveLabelsService
def initialize(account:, contact_ids:, labels:)
@account = account
@contact_ids = Array(contact_ids)
@labels = Array(labels).compact_blank
end
def perform
return { success: true, updated_contact_ids: [] } if @contact_ids.blank? || @labels.blank?
contacts = @account.contacts.where(id: @contact_ids)
contacts.find_each do |contact|
contact.update!(label_list: contact.label_list - @labels)
end
{ success: true, updated_contact_ids: contacts.pluck(:id) }
end
end
@@ -67,6 +67,8 @@ class Whatsapp::IncomingMessageBaseService
def create_messages
message = messages_data.first
return create_unsupported_message(message) if message_type == 'unsupported'
log_error(message) && return if error_webhook_event?(message)
process_in_reply_to(message)
@@ -74,6 +76,18 @@ class Whatsapp::IncomingMessageBaseService
message_type == 'contacts' ? create_contact_messages(message) : create_regular_message(message)
end
# WhatsApp delivers messages it cannot render (e.g. coexistence companion-device syncs that
# fail with error 131060) as type: unsupported with no content. We still persist a placeholder
# so the contact/conversation isn't created "headless" and agents know to check the WhatsApp app.
def create_unsupported_message(message)
log_error(message) if error_webhook_event?(message)
process_in_reply_to(message)
create_message(message, source_id: message[:id])
@message.content = I18n.t('conversations.messages.whatsapp.unsupported_message')
@message.content_attributes = @message.content_attributes.merge(is_unsupported: true)
@message.save!
end
def create_contact_messages(message)
message['contacts'].each do |contact|
# Pass source_id from parent message since contact objects don't have :id
@@ -44,7 +44,7 @@ module Whatsapp::IncomingMessageServiceHelpers
end
def unprocessable_message_type?(message_type)
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
%w[reaction ephemeral request_welcome].include?(message_type)
end
def processed_waid(waid)
+1
View File
@@ -258,6 +258,7 @@ en:
whatsapp:
list_button_label: 'Choose an item'
call_permission_request_body: 'We would like to call you regarding your conversation.'
unsupported_message: 'This message is unavailable.'
voice_call:
twilio: 'Voice Call'
whatsapp: 'WhatsApp Call'
+1
View File
@@ -394,6 +394,7 @@ Rails.application.routes.draw do
resource :bulk_actions, only: [] do
post :translate
patch :update_status
patch :update_category
delete :delete_articles
end
end
@@ -0,0 +1,16 @@
class ChangeCaptainDocumentExternalLinkToText < ActiveRecord::Migration[7.0]
OLD_INDEX_NAME = 'index_captain_documents_on_assistant_id_and_external_link'.freeze
NEW_INDEX_NAME = 'idx_captain_documents_on_assistant_id_and_external_link_md5'.freeze
def up
remove_index :captain_documents, name: OLD_INDEX_NAME, if_exists: true
change_column :captain_documents, :external_link, :text, null: false
add_index :captain_documents, 'assistant_id, md5(external_link)', unique: true, name: NEW_INDEX_NAME, if_not_exists: true
end
def down
remove_index :captain_documents, name: NEW_INDEX_NAME, if_exists: true
change_column :captain_documents, :external_link, :string, null: false
add_index :captain_documents, [:assistant_id, :external_link], unique: true, name: OLD_INDEX_NAME, if_not_exists: true
end
end
+3 -3
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -370,7 +370,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
create_table "captain_documents", force: :cascade do |t|
t.string "name"
t.string "external_link", null: false
t.text "external_link", null: false
t.text "content"
t.bigint "assistant_id", null: false
t.bigint "account_id", null: false
@@ -381,10 +381,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.integer "sync_status"
t.datetime "last_synced_at"
t.datetime "last_sync_attempted_at"
t.index "assistant_id, md5(external_link)", name: "idx_captain_documents_on_assistant_id_and_external_link_md5", unique: true
t.index ["account_id", "assistant_id", "sync_status", "last_synced_at"], name: "idx_captain_documents_on_account_assistant_sync_stats"
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["status"], name: "index_captain_documents_on_status"
end
+7 -7
View File
@@ -5,7 +5,7 @@
# id :bigint not null, primary key
# content :text
# content_fingerprint :string
# external_link :string not null
# external_link :text not null
# last_sync_attempted_at :datetime
# last_sync_error_code :string
# last_synced_at :datetime
@@ -20,12 +20,12 @@
#
# Indexes
#
# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at)
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status)
# index_captain_documents_on_assistant_id (assistant_id)
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
# index_captain_documents_on_status (status)
# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at)
# idx_captain_documents_on_assistant_id_and_external_link_md5 (assistant_id, md5(external_link)) UNIQUE
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status)
# index_captain_documents_on_assistant_id (assistant_id)
# index_captain_documents_on_status (status)
#
class Captain::Document < ApplicationRecord
class LimitExceededError < StandardError; end
@@ -11,6 +11,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
channel_facebook
channel_email
channel_instagram
channel_tiktok
captain_integration
advanced_search_indexing
advanced_search
+4
View File
@@ -34,4 +34,8 @@ module SafeFetch
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
raise FetchError, e.message
end
def self.allow_private_network?
ActiveModel::Type::Boolean.new.cast(ENV.fetch('SAFE_FETCH_ALLOW_PRIVATE_NETWORK', false))
end
end
+6 -4
View File
@@ -29,18 +29,20 @@ class SafeFetch::Fetcher
end
def stream_response(tempfile)
response = nil
bytes_written = 0
SsrfFilter.public_send(options.method, options.url, **options.request_options) do |res|
response = res
perform_request do |res|
next unless res.is_a?(Net::HTTPSuccess)
validate_content_type!(res['content-type'])
bytes_written = write_response_body(res, tempfile, bytes_written)
end
end
response
def perform_request(&)
return SafeFetch::PrivateNetworkRequest.new(options).perform(&) if SafeFetch.allow_private_network?
SsrfFilter.public_send(options.method, options.url, **options.request_options, &)
end
def validate_content_type!(content_type)
+102
View File
@@ -0,0 +1,102 @@
class SafeFetch::PrivateNetworkRequest
def initialize(options)
@options = options
end
def perform(&)
url = options.url
original_url = url
original_uri = URI(url)
(SsrfFilter::DEFAULT_MAX_REDIRECTS + 1).times do
uri = URI(url)
validate_scheme!(uri)
response, next_url = fetch_once(uri, resolved_addresses(uri.hostname).sample.to_s, original_uri, &)
return response if next_url.nil?
url = next_url
end
raise SsrfFilter::TooManyRedirects, "Got #{SsrfFilter::DEFAULT_MAX_REDIRECTS} redirects fetching #{original_url}"
end
private
attr_reader :options
def validate_scheme!(uri)
return if SsrfFilter::DEFAULT_SCHEME_WHITELIST.include?(uri.scheme)
raise SsrfFilter::InvalidUriScheme, "URI scheme '#{uri.scheme}' not in whitelist: #{SsrfFilter::DEFAULT_SCHEME_WHITELIST}"
end
def resolved_addresses(hostname)
ip_addresses = options.resolver.call(hostname)
raise SsrfFilter::UnresolvedHostname, "Could not resolve hostname '#{hostname}'" if ip_addresses.empty?
ip_addresses
end
def fetch_once(uri, ip_address, original_uri, &)
request = build_request(uri)
strip_sensitive_headers!(request, original_uri, uri)
validate_request!(request)
Net::HTTP.start(uri.hostname, uri.port, **http_options(uri, ip_address)) do |http|
response = http.request(request, &)
return response, redirect_location(response, uri)
end
end
def build_request(uri)
request = SsrfFilter::VERB_MAP[options.method].new(uri)
request['host'] = normalized_hostname(uri)
Array(options.request_options[:headers]).each { |header, value| request[header] = value }
request.body = options.body if options.body
options.request_options[:request_proc].call(request) if options.request_options[:request_proc].respond_to?(:call)
request
end
def http_options(uri, ip_address)
options.request_options[:http_options].merge(
use_ssl: uri.scheme == 'https',
ipaddr: ip_address
)
end
def strip_sensitive_headers!(request, original_uri, uri)
return unless different_origin?(original_uri, uri)
options.request_options[:sensitive_headers].each { |header| request.delete(header) }
end
def validate_request!(request)
request.each do |header, value|
next if header.count("\r\n").zero? && value.count("\r\n").zero?
raise SsrfFilter::CRLFInjection, "CRLF injection in header #{header} with value #{value}"
end
end
def redirect_location(response, uri)
return unless response.is_a?(Net::HTTPRedirection)
location = response['location']
return "#{uri.scheme}://#{normalized_hostname(uri)}#{location}" if location&.start_with?('/')
location
end
def normalized_hostname(uri)
return uri.hostname if (uri.port == 80 && uri.scheme == 'http') || (uri.port == 443 && uri.scheme == 'https')
"#{uri.hostname}:#{uri.port}"
end
def different_origin?(uri, other_uri)
uri.scheme != other_uri.scheme || uri.hostname != other_uri.hostname || uri.port != other_uri.port
end
end
+4
View File
@@ -53,6 +53,10 @@ class SafeFetch::RequestOptions
@validate_content_type
end
def resolver
SsrfFilter::DEFAULT_RESOLVER
end
private
def default_max_bytes
@@ -263,6 +263,31 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
expect(response).to have_http_status(:success)
end
it 'permits contact label removal params' do
contact_one = create(:contact, account: account)
contact_two = create(:contact, account: account)
expect do
post "/api/v1/accounts/#{account.id}/bulk_actions",
headers: agent.create_new_auth_token,
params: {
type: 'Contact',
ids: [contact_one.id, contact_two.id],
labels: { remove: %w[vip support] },
extra: 'ignored'
}
end.to have_enqueued_job(Contacts::BulkActionJob).with(
account.id,
agent.id,
hash_including(
'ids' => [contact_one.id.to_s, contact_two.id.to_s],
'labels' => hash_including('remove' => %w[vip support])
)
)
expect(response).to have_http_status(:success)
end
it 'returns unauthorized for delete action when user is not admin' do
contact = create(:contact, account: account)
@@ -61,6 +61,16 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
end
end
it 'stores external links longer than 255 characters' do
long_url = "https://example.com/#{'arabic-product-slug-' * 300}"
payload[:metadata]['url'] = long_url
described_class.perform_now(assistant_id: assistant.id, payload: payload)
expect(assistant.documents.last.external_link).to eq(long_url)
expect(assistant.documents.last.external_link.length).to be > 255
end
context 'when an error occurs' do
it 'raises an error with a descriptive message' do
allow(Captain::Assistant).to receive(:find).and_raise(ActiveRecord::RecordNotFound)
+44
View File
@@ -205,6 +205,50 @@ RSpec.describe SafeFetch do
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
end
end
it 'allows private IP literals when private network access is enabled' do
private_url = 'http://192.168.3.21/image.png'
allow(Resolv).to receive(:getaddresses).with('192.168.3.21').and_return(['192.168.3.21'])
stub_request(:get, private_url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
expect { described_class.fetch(private_url) { nil } }.not_to raise_error
end
end
it 'allows private hostnames when private network access is enabled' do
private_url = 'http://internal-webhook-service/image.png'
allow(Resolv).to receive(:getaddresses).with('internal-webhook-service').and_return(['10.0.0.5'])
stub_request(:get, private_url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
expect { described_class.fetch(private_url) { nil } }.not_to raise_error
end
end
it 'allows redirects to private hostnames when private network access is enabled' do
redirect_url = 'http://example.com/redirect.png'
private_url = 'http://private.example.com/image.png'
allow(Resolv).to receive(:getaddresses).with('private.example.com').and_return(['10.0.0.5'])
stub_request(:get, redirect_url).to_return(status: 302, headers: { 'Location' => private_url })
stub_request(:get, private_url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
expect { described_class.fetch(redirect_url) { nil } }.not_to raise_error
end
end
end
context 'with content-type allowlist' do
@@ -34,5 +34,19 @@ RSpec.describe Contacts::BulkActionService do
service.perform
end
end
context 'when labels are removed' do
let(:params) { { ids: [10, 20], labels: { remove: %w[vip] }, extra: 'ignored' } }
it 'delegates to the bulk remove labels service with permitted params' do
bulk_remove_service = instance_double(Contacts::BulkRemoveLabelsService, perform: true)
expect(Contacts::BulkRemoveLabelsService).to receive(:new)
.with(account: account, contact_ids: [10, 20], labels: %w[vip])
.and_return(bulk_remove_service)
service.perform
end
end
end
end
@@ -0,0 +1,54 @@
require 'rails_helper'
RSpec.describe Contacts::BulkRemoveLabelsService do
subject(:service) do
described_class.new(
account: account,
contact_ids: [contact_one.id, contact_two.id, other_contact.id],
labels: labels
)
end
let(:account) { create(:account) }
let!(:contact_one) { create(:contact, account: account) }
let!(:contact_two) { create(:contact, account: account) }
let!(:other_contact) { create(:contact) }
let(:labels) { %w[vip] }
before do
contact_one.add_labels(%w[vip support])
contact_two.add_labels(%w[vip priority])
other_contact.add_labels(%w[vip support])
end
it 'removes labels from contacts that belong to the account' do
service.perform
expect(contact_one.reload.label_list).to contain_exactly('support')
expect(contact_two.reload.label_list).to contain_exactly('priority')
end
it 'does not remove labels from contacts outside the account' do
service.perform
expect(other_contact.reload.label_list).to contain_exactly('vip', 'support')
end
it 'returns ids of contacts that were updated' do
result = service.perform
expect(result[:success]).to be(true)
expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id)
end
it 'returns success with no updates when labels are blank' do
result = described_class.new(
account: account,
contact_ids: [contact_one.id],
labels: []
).perform
expect(result).to eq(success: true, updated_contact_ids: [])
expect(contact_one.reload.label_list).to contain_exactly('vip', 'support')
end
end
@@ -206,7 +206,7 @@ describe Whatsapp::IncomingMessageService do
expect(whatsapp_channel.inbox.messages.count).to eq(0)
end
it 'ignores type unsupported and does not create ghost conversation' do
it 'stores type unsupported as a placeholder message so the conversation is not headless' do
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{
@@ -217,9 +217,12 @@ describe Whatsapp::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).to eq(0)
expect(Contact.count).to eq(0)
expect(whatsapp_channel.inbox.messages.count).to eq(0)
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
expect(Contact.count).to eq(1)
expect(whatsapp_channel.inbox.messages.count).to eq(1)
message = whatsapp_channel.inbox.messages.last
expect(message.content).to eq('This message is unavailable.')
expect(message.content_attributes['is_unsupported']).to be(true)
end
end
+20
View File
File diff suppressed because one or more lines are too long