Merge branch 'develop' into feat/expand-idb-coverage
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+55
@@ -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
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+49
-10
@@ -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>
|
||||
|
||||
+19
@@ -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",
|
||||
|
||||
+12
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user