Merge branch 'develop' into feat/app-store-reviews
This commit is contained in:
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
gem 'puma'
|
||||
gem 'puma', '~> 7.2', '>= 7.2.1'
|
||||
gem 'vite_rails'
|
||||
# metrics on heroku
|
||||
gem 'barnes'
|
||||
|
||||
+3
-3
@@ -593,7 +593,7 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
@@ -682,7 +682,7 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (7.0.5)
|
||||
puma (6.4.3)
|
||||
puma (7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -1132,7 +1132,7 @@ DEPENDENCIES
|
||||
pgvector
|
||||
procore-sift
|
||||
pry-rails
|
||||
puma
|
||||
puma (~> 7.2, >= 7.2.1)
|
||||
pundit
|
||||
rack-attack (>= 6.7.0)
|
||||
rack-cors (= 2.0.0)
|
||||
|
||||
@@ -80,10 +80,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
|
||||
)
|
||||
end
|
||||
|
||||
def locale_translation_keys
|
||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||
end
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(:inbox_id)
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
'api/v1/accounts/conversations/assignments' => ['create'],
|
||||
'api/v1/accounts/conversations/labels' => %w[index create]
|
||||
}.freeze
|
||||
|
||||
def ensure_access_token
|
||||
|
||||
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
|
||||
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
|
||||
end
|
||||
|
||||
def sitemap
|
||||
|
||||
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize)
|
||||
end
|
||||
|
||||
def last_name
|
||||
|
||||
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize)
|
||||
end
|
||||
|
||||
def last_name
|
||||
|
||||
@@ -46,9 +46,8 @@ const formattedLastActivityAt = computed(() => {
|
||||
:src="avatarSource"
|
||||
class="shrink-0"
|
||||
:name="name"
|
||||
:size="48"
|
||||
:size="42"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
||||
|
||||
-1
@@ -141,7 +141,6 @@ const handleUpdateCompany = async () => {
|
||||
:src="avatarSource"
|
||||
:size="72"
|
||||
:allow-upload="!isAvatarBusy"
|
||||
rounded-full
|
||||
hide-offline-status
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
|
||||
@@ -124,10 +124,9 @@ const handleAvatarHover = isHovered => {
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:size="42"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<template v-if="selectable" #overlay="{ size }">
|
||||
<label
|
||||
|
||||
@@ -53,6 +53,9 @@ const localeMenuLabels = computed(() => ({
|
||||
'publish-locale': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
||||
),
|
||||
'customize-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
@@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => {
|
||||
<DropdownMenu
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="localeMenuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const activeLocale = ref('');
|
||||
const name = ref('');
|
||||
const pageTitle = ref('');
|
||||
const headerText = ref('');
|
||||
|
||||
const localeTranslations = computed(
|
||||
() => props.portal?.config?.locale_translations || {}
|
||||
);
|
||||
|
||||
const openForLocale = localeCode => {
|
||||
const existing = localeTranslations.value[localeCode] || {};
|
||||
activeLocale.value = localeCode;
|
||||
name.value = existing.name || '';
|
||||
pageTitle.value = existing.page_title || '';
|
||||
headerText.value = existing.header_text || '';
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
const translations = { ...localeTranslations.value };
|
||||
const fields = {};
|
||||
if (name.value.trim()) fields.name = name.value.trim();
|
||||
if (pageTitle.value.trim()) fields.page_title = pageTitle.value.trim();
|
||||
if (headerText.value.trim()) fields.header_text = headerText.value.trim();
|
||||
|
||||
if (Object.keys(fields).length) {
|
||||
translations[activeLocale.value] = fields;
|
||||
} else {
|
||||
delete translations[activeLocale.value];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: { locale_translations: translations },
|
||||
});
|
||||
dialogRef.value?.close();
|
||||
useAlert(t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ openForLocale });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.DESCRIPTION')"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.NAME.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="name" :placeholder="portal.name" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.PAGE_TITLE.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="pageTitle" :placeholder="portal.page_title" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.HEADER_TEXT.LABEL') }}
|
||||
</label>
|
||||
<Input v-model="headerText" :placeholder="portal.header_text" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -23,6 +25,8 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contentDialogRef = ref(null);
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
};
|
||||
@@ -148,6 +152,8 @@ const handleAction = ({ action }, localeCode) => {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'customize-content') {
|
||||
contentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -167,5 +173,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
+6
-1
@@ -182,7 +182,10 @@ const MIN_SEARCH_LENGTH = 2;
|
||||
export const createContactSearcher = () => {
|
||||
let controller = null;
|
||||
|
||||
return async (query, { skipMinLength = false } = {}) => {
|
||||
return async (
|
||||
query,
|
||||
{ skipMinLength = false, reachableOnly = true } = {}
|
||||
) => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
|
||||
controller?.abort();
|
||||
@@ -199,6 +202,8 @@ export const createContactSearcher = () => {
|
||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
||||
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
if (!reachableOnly) return camelCasedPayload || [];
|
||||
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, h, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import FilterSelect from './inputs/FilterSelect.vue';
|
||||
@@ -109,6 +110,34 @@ const inputFieldType = computed(() => {
|
||||
return 'text';
|
||||
});
|
||||
|
||||
const asyncOptions = ref([]);
|
||||
const isSearching = ref(false);
|
||||
const lastSearchQuery = ref('');
|
||||
|
||||
const performAsyncSearch = async query => {
|
||||
let results;
|
||||
try {
|
||||
results = await currentFilter.value.searchOptions(query);
|
||||
} catch {
|
||||
results = [];
|
||||
}
|
||||
// skip stale responses — a newer search in this row owns the UI
|
||||
if (query !== lastSearchQuery.value) return;
|
||||
// null means another row's search aborted ours, reset instead of staying stuck on the searching state
|
||||
if (results !== null) asyncOptions.value = results;
|
||||
isSearching.value = false;
|
||||
};
|
||||
|
||||
const debouncedAsyncSearch = debounce(performAsyncSearch, 300);
|
||||
|
||||
const onAsyncSearch = query => {
|
||||
const hasQuery = !!query.trim();
|
||||
lastSearchQuery.value = query;
|
||||
if (!hasQuery) asyncOptions.value = [];
|
||||
isSearching.value = hasQuery;
|
||||
debouncedAsyncSearch(query);
|
||||
};
|
||||
|
||||
const resetModelOnAttributeKeyChange = newAttributeKey => {
|
||||
/**
|
||||
* Resets the filter values and operator when the attribute key changes. This ensures that
|
||||
@@ -121,11 +150,16 @@ const resetModelOnAttributeKeyChange = newAttributeKey => {
|
||||
const newInputType = getInputType(newOperator, filter);
|
||||
if (newInputType === 'multiSelect') {
|
||||
values.value = [];
|
||||
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
|
||||
} else if (
|
||||
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
|
||||
newInputType
|
||||
)
|
||||
) {
|
||||
values.value = {};
|
||||
} else {
|
||||
values.value = '';
|
||||
}
|
||||
asyncOptions.value = [];
|
||||
filterOperator.value = newOperator.value;
|
||||
};
|
||||
|
||||
@@ -185,6 +219,16 @@ defineExpose({ validate, resetValidation });
|
||||
:options="currentFilter.options"
|
||||
dropdown-max-height="max-h-64"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'asyncSearchSelect'"
|
||||
v-model="values"
|
||||
async-search
|
||||
:options="asyncOptions"
|
||||
:is-searching="isSearching"
|
||||
:search-placeholder="currentFilter.searchPlaceholder"
|
||||
dropdown-max-height="max-h-64"
|
||||
@search="onAsyncSearch"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'booleanSelect'"
|
||||
v-model="values"
|
||||
|
||||
@@ -7,6 +7,7 @@ export const CONVERSATION_ATTRIBUTES = {
|
||||
ASSIGNEE_ID: 'assignee_id',
|
||||
INBOX_ID: 'inbox_id',
|
||||
TEAM_ID: 'team_id',
|
||||
CONTACT_ID: 'contact_id',
|
||||
DISPLAY_ID: 'display_id',
|
||||
CAMPAIGN_ID: 'campaign_id',
|
||||
LABELS: 'labels',
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { ref } from 'vue';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConversationFilterContext } from '../provider';
|
||||
import {
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
getCustomAttributeInputType,
|
||||
buildAttributesFilterTypes,
|
||||
replaceUnderscoreWithSpace,
|
||||
} from './filterHelper';
|
||||
|
||||
vi.mock('dashboard/api/contacts', () => ({
|
||||
default: {
|
||||
search: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store.js', () => ({
|
||||
useMapGetter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/icon/provider', () => ({
|
||||
useChannelIcon: () => ref('i-test-channel'),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params = {}) => {
|
||||
if (key === 'FILTER.CONTACT_FALLBACK') return `Contact #${params.id}`;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('filterHelper', () => {
|
||||
describe('getCustomAttributeInputType', () => {
|
||||
it('returns date for date type', () => {
|
||||
@@ -135,3 +163,64 @@ describe('filterHelper', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const storeValues = {
|
||||
'attributes/getConversationAttributes': ref([]),
|
||||
'labels/getLabels': ref([]),
|
||||
'agents/getAgents': ref([]),
|
||||
'inboxes/getInboxes': ref([]),
|
||||
'teams/getTeams': ref([]),
|
||||
'campaigns/getAllCampaigns': ref([]),
|
||||
};
|
||||
|
||||
describe('useConversationFilterContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useMapGetter.mockImplementation(key => storeValues[key] || ref([]));
|
||||
});
|
||||
|
||||
it('exposes contact as an async searchable conversation filter', () => {
|
||||
const { filterTypes } = useConversationFilterContext();
|
||||
const contactFilter = filterTypes.value.find(
|
||||
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
|
||||
);
|
||||
|
||||
expect(contactFilter).toMatchObject({
|
||||
attributeKey: 'contact_id',
|
||||
label: 'FILTER.ATTRIBUTES.CONTACT',
|
||||
inputType: 'asyncSearchSelect',
|
||||
dataType: 'number',
|
||||
attributeModel: 'standard',
|
||||
});
|
||||
expect(
|
||||
contactFilter.filterOperators.map(operator => operator.value)
|
||||
).toEqual(['equal_to', 'not_equal_to']);
|
||||
});
|
||||
|
||||
it('uses the existing contact search API for contact filter options', async () => {
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: {
|
||||
payload: [
|
||||
{ id: 1, name: 'Jane Doe' },
|
||||
{ id: 2, email: 'alex@example.com' },
|
||||
{ id: 3 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { filterTypes } = useConversationFilterContext();
|
||||
const contactFilter = filterTypes.value.find(
|
||||
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
|
||||
);
|
||||
const options = await contactFilter.searchOptions('jane');
|
||||
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('jane', 1, 'name', '', {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(options).toEqual([
|
||||
{ id: 1, name: 'Jane Doe' },
|
||||
{ id: 2, name: 'alex@example.com' },
|
||||
{ id: 3, name: 'Contact #3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
const {
|
||||
options,
|
||||
asyncSearch,
|
||||
isSearching,
|
||||
disableSearch,
|
||||
disableDeselect,
|
||||
placeholderIcon,
|
||||
@@ -23,6 +25,14 @@ const {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
asyncSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -53,6 +63,12 @@ const {
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search']);
|
||||
|
||||
// the input is re-inserted on every dropdown open (v-if),
|
||||
// where the native autofocus attribute is ignored so focus it via a directive instead
|
||||
const vFocus = { mounted: el => el.focus() };
|
||||
|
||||
const { t } = useI18n();
|
||||
const selected = defineModel({
|
||||
type: Object,
|
||||
@@ -60,7 +76,9 @@ const selected = defineModel({
|
||||
});
|
||||
|
||||
const searchTerm = ref('');
|
||||
|
||||
const searchResults = computed(() => {
|
||||
if (asyncSearch) return options;
|
||||
if (!options) return [];
|
||||
return picoSearch(options, searchTerm.value, ['name']);
|
||||
});
|
||||
@@ -77,7 +95,11 @@ const selectedItem = computed(() => {
|
||||
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);
|
||||
return (
|
||||
options.find(option => option.id === optionToSearch.id) ||
|
||||
// async options may not include the selected option, fall back to it
|
||||
(asyncSearch && optionToSearch.id !== undefined ? optionToSearch : null)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleSelected = option => {
|
||||
@@ -131,13 +153,19 @@ const toggleSelected = option => {
|
||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
autofocus
|
||||
v-focus
|
||||
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
@input="emit('search', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection :height="dropdownMaxHeight">
|
||||
<template v-if="searchResults.length">
|
||||
<template v-if="isSearching">
|
||||
<DropdownItem disabled>
|
||||
{{ t('DROPDOWN_MENU.SEARCHING') }}
|
||||
</DropdownItem>
|
||||
</template>
|
||||
<template v-else-if="searchResults.length">
|
||||
<DropdownItem
|
||||
v-for="option in searchResults"
|
||||
:key="option.id"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useOperators } from './operators';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useChannelIcon } from 'next/icon/provider';
|
||||
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import {
|
||||
buildAttributesFilterTypes,
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
@@ -30,7 +31,7 @@ import languages from 'dashboard/components/widgets/conversation/advancedFilterI
|
||||
* @property {string} value - This is a proxy for the attribute key used in FilterSelect
|
||||
* @property {string} attributeName - The attribute name used to display on the UI
|
||||
* @property {string} label - This is a proxy for the attribute name used in FilterSelect
|
||||
* @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
|
||||
* @property {'multiSelect'|'searchSelect'|'asyncSearchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
|
||||
* @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
|
||||
* @property {'text'|'number'} dataType
|
||||
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
|
||||
@@ -68,6 +69,30 @@ export function useConversationFilterContext() {
|
||||
getOperatorTypes,
|
||||
} = useOperators();
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
|
||||
const contactOptionName = contact =>
|
||||
contact.name ||
|
||||
contact.email ||
|
||||
contact.phoneNumber ||
|
||||
contact.identifier ||
|
||||
t('FILTER.CONTACT_FALLBACK', { id: contact.id });
|
||||
|
||||
const searchContactOptions = async query => {
|
||||
const contacts = await searchContacts(query, {
|
||||
skipMinLength: true,
|
||||
reachableOnly: false,
|
||||
});
|
||||
|
||||
// null means the request was aborted (a newer search is in-flight)
|
||||
if (contacts === null) return null;
|
||||
|
||||
return contacts.map(contact => ({
|
||||
id: contact.id,
|
||||
name: contactOptionName(contact),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
@@ -158,6 +183,18 @@ export function useConversationFilterContext() {
|
||||
filterOperators: presenceOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.CONTACT_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.CONTACT_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.CONTACT'),
|
||||
label: t('FILTER.ATTRIBUTES.CONTACT'),
|
||||
inputType: 'asyncSearchSelect',
|
||||
searchOptions: searchContactOptions,
|
||||
searchPlaceholder: t('FILTER.CONTACT_SEARCH_PLACEHOLDER'),
|
||||
dataType: 'number',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
|
||||
@@ -145,7 +145,6 @@ const allowedMenuItems = computed(() => {
|
||||
:src="currentUser.avatar_url"
|
||||
:status="currentUserAvailability"
|
||||
class="flex-shrink-0"
|
||||
rounded-full
|
||||
/>
|
||||
<div v-if="!isCollapsed" class="min-w-0">
|
||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
||||
|
||||
@@ -151,6 +151,9 @@ const activeFolder = computed(() => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const getContact = useMapGetter('contacts/getContact');
|
||||
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
|
||||
|
||||
const activeFolderName = computed(() => {
|
||||
return activeFolder.value?.name;
|
||||
});
|
||||
@@ -456,6 +459,7 @@ function setParamsForEditFolderModal() {
|
||||
inboxes: inboxesList.value,
|
||||
labels: labels.value,
|
||||
campaigns: campaigns.value,
|
||||
contacts: [getContact.value(folderContactId.value)],
|
||||
languages: languages,
|
||||
countries: countries,
|
||||
priority: [
|
||||
|
||||
@@ -124,7 +124,6 @@ const copyConversationId = async () => {
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
|
||||
|
||||
@@ -46,6 +46,14 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'contact_id',
|
||||
attributeI18nKey: 'CONTACT',
|
||||
inputType: 'search_select',
|
||||
dataType: 'number',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'display_id',
|
||||
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
|
||||
@@ -133,6 +141,10 @@ export const filterAttributeGroups = [
|
||||
key: 'team_id',
|
||||
i18nKey: 'TEAM_NAME',
|
||||
},
|
||||
{
|
||||
key: 'contact_id',
|
||||
i18nKey: 'CONTACT',
|
||||
},
|
||||
{
|
||||
key: 'display_id',
|
||||
i18nKey: 'CONVERSATION_IDENTIFIER',
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useFacebookPageConnect } from '../useFacebookPageConnect';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import ChannelApi from 'dashboard/api/channels';
|
||||
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({ useMapGetter: vi.fn() }));
|
||||
vi.mock('dashboard/api/channels', () => ({
|
||||
default: { fetchFacebookPages: vi.fn() },
|
||||
}));
|
||||
vi.mock(
|
||||
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
|
||||
() => ({ setupFacebookSdk: vi.fn() })
|
||||
);
|
||||
|
||||
const flushPromises = () =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const ACCOUNT_ID = 42;
|
||||
const PAGES = [
|
||||
{ id: 'p1', name: 'Page One', access_token: 'page-token-1' },
|
||||
{ id: 'p2', name: 'Page Two', access_token: 'page-token-2', exists: true },
|
||||
];
|
||||
|
||||
const pagesResponse = {
|
||||
data: { data: { page_details: PAGES, user_access_token: 'long-token' } },
|
||||
};
|
||||
|
||||
// FB.login invokes its callback with the given response.
|
||||
const stubLogin = response => {
|
||||
window.FB = { login: vi.fn(callback => callback(response)) };
|
||||
};
|
||||
|
||||
const connected = {
|
||||
status: 'connected',
|
||||
authResponse: { accessToken: 'user-token' },
|
||||
};
|
||||
|
||||
describe('useFacebookPageConnect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.chatwootConfig = { fbAppId: 'fb-app', fbApiVersion: 'v22.0' };
|
||||
useMapGetter.mockReturnValue({ value: ACCOUNT_ID });
|
||||
setupFacebookSdk.mockResolvedValue();
|
||||
ChannelApi.fetchFacebookPages.mockResolvedValue(pagesResponse);
|
||||
stubLogin(connected);
|
||||
});
|
||||
|
||||
it('resolves the user token and pages on a connected login', async () => {
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).resolves.toEqual({
|
||||
userAccessToken: 'long-token',
|
||||
pages: PAGES,
|
||||
});
|
||||
expect(setupFacebookSdk).toHaveBeenCalledWith('fb-app', 'v22.0');
|
||||
expect(ChannelApi.fetchFacebookPages).toHaveBeenCalledWith(
|
||||
'user-token',
|
||||
ACCOUNT_ID
|
||||
);
|
||||
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
|
||||
scope: expect.stringContaining('pages_show_list'),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves null when the user is not authorized', async () => {
|
||||
stubLogin({ status: 'not_authorized' });
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).resolves.toBeNull();
|
||||
expect(ChannelApi.fetchFacebookPages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves null on an unknown login status', async () => {
|
||||
stubLogin({ status: 'unknown' });
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects when fetching pages fails', async () => {
|
||||
ChannelApi.fetchFacebookPages.mockRejectedValue(new Error('fetch failed'));
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).rejects.toThrow('fetch failed');
|
||||
});
|
||||
|
||||
it('rejects when the SDK fails to load', async () => {
|
||||
const error = new Error('script load failed');
|
||||
error.name = 'ScriptLoaderError';
|
||||
setupFacebookSdk.mockRejectedValue(error);
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
await expect(loginAndFetchPages()).rejects.toThrow('script load failed');
|
||||
});
|
||||
|
||||
it('ignores a second call while a run is in flight', async () => {
|
||||
const pending = createDeferred();
|
||||
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
|
||||
|
||||
const { loginAndFetchPages } = useFacebookPageConnect();
|
||||
const first = loginAndFetchPages();
|
||||
const second = loginAndFetchPages();
|
||||
|
||||
await expect(second).resolves.toBeNull();
|
||||
|
||||
pending.resolve(pagesResponse);
|
||||
await first;
|
||||
expect(window.FB.login).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles isAuthenticating across a run', async () => {
|
||||
const pending = createDeferred();
|
||||
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
|
||||
|
||||
const { isAuthenticating, loginAndFetchPages } = useFacebookPageConnect();
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
|
||||
const result = loginAndFetchPages();
|
||||
await flushPromises();
|
||||
expect(isAuthenticating.value).toBe(true);
|
||||
|
||||
pending.resolve(pagesResponse);
|
||||
await result;
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
});
|
||||
|
||||
it('preloads the SDK once and reuses it for login', async () => {
|
||||
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
|
||||
|
||||
preloadSdk();
|
||||
preloadSdk();
|
||||
await loginAndFetchPages();
|
||||
|
||||
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useWhatsappEmbeddedSignup } from '../useWhatsappEmbeddedSignup';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
vi.mock(
|
||||
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
|
||||
() => ({
|
||||
setupFacebookSdk: vi.fn(),
|
||||
initWhatsAppEmbeddedSignup: vi.fn(),
|
||||
createMessageHandler: vi.fn(),
|
||||
isValidBusinessData: vi.fn(data =>
|
||||
Boolean(data && data.business_id && data.waba_id)
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
const flushPromises = () =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const VALID_BUSINESS = {
|
||||
business_id: 'biz-1',
|
||||
waba_id: 'waba-1',
|
||||
phone_number_id: 'phone-1',
|
||||
};
|
||||
|
||||
describe('useWhatsappEmbeddedSignup', () => {
|
||||
// The mocked createMessageHandler captures the callback the composable
|
||||
// registers, so tests can simulate Meta's WA_EMBEDDED_SIGNUP postMessages
|
||||
// directly without the window-event + origin plumbing (that is covered by
|
||||
// the utils' own tests).
|
||||
let signupCallback;
|
||||
let registeredListener;
|
||||
|
||||
const emit = data => signupCallback(data);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
window.chatwootConfig = {
|
||||
whatsappAppId: 'app-id',
|
||||
whatsappConfigurationId: 'config-id',
|
||||
whatsappApiVersion: 'v22.0',
|
||||
};
|
||||
|
||||
setupFacebookSdk.mockResolvedValue();
|
||||
createMessageHandler.mockImplementation(callback => {
|
||||
signupCallback = callback;
|
||||
registeredListener = () => {};
|
||||
return registeredListener;
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves credentials when the auth code arrives before the business data', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
await flushPromises(); // SDK setup + FB.login resolve the code first
|
||||
emit({ event: 'FINISH', data: VALID_BUSINESS });
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
code: 'auth-code',
|
||||
business_id: 'biz-1',
|
||||
waba_id: 'waba-1',
|
||||
phone_number_id: 'phone-1',
|
||||
});
|
||||
expect(setupFacebookSdk).toHaveBeenCalledWith('app-id', 'v22.0');
|
||||
expect(initWhatsAppEmbeddedSignup).toHaveBeenCalledWith('config-id');
|
||||
});
|
||||
|
||||
it('resolves credentials when the business data arrives before the auth code', async () => {
|
||||
const code = createDeferred();
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(code.promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
// Business data lands first, while FB.login is still pending.
|
||||
emit({
|
||||
event: 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING',
|
||||
data: VALID_BUSINESS,
|
||||
});
|
||||
code.resolve('late-code');
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
code: 'late-code',
|
||||
business_id: 'biz-1',
|
||||
waba_id: 'waba-1',
|
||||
phone_number_id: 'phone-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults phone_number_id to an empty string when absent', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
await flushPromises();
|
||||
emit({
|
||||
event: 'FINISH',
|
||||
data: { business_id: 'biz-1', waba_id: 'waba-1' },
|
||||
});
|
||||
|
||||
await expect(result).resolves.toMatchObject({ phone_number_id: '' });
|
||||
});
|
||||
|
||||
it('resolves null when FB.login is cancelled', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('Login cancelled'));
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
|
||||
await expect(runEmbeddedSignup()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('resolves null on a CANCEL event', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
emit({ event: 'CANCEL' });
|
||||
|
||||
await expect(result).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects with the Meta error message on an error event', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
emit({ event: 'error', error_message: 'WABA not eligible' });
|
||||
|
||||
await expect(result).rejects.toThrow('WABA not eligible');
|
||||
});
|
||||
|
||||
it('rejects when the business data is invalid', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const result = runEmbeddedSignup();
|
||||
|
||||
emit({ event: 'FINISH', data: { business_id: 'biz-1' } }); // no waba_id
|
||||
|
||||
await expect(result).rejects.toThrow('Invalid business data');
|
||||
});
|
||||
|
||||
it('rejects when the SDK or login fails for a non-cancel reason', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('popup blocked'));
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
|
||||
await expect(runEmbeddedSignup()).rejects.toThrow('popup blocked');
|
||||
});
|
||||
|
||||
it('ignores a second call while a run is in flight', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
|
||||
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
const first = runEmbeddedSignup();
|
||||
const second = runEmbeddedSignup();
|
||||
|
||||
await expect(second).resolves.toBeNull();
|
||||
|
||||
// Let the first run finish so it doesn't leak into the next test.
|
||||
await flushPromises();
|
||||
emit({ event: 'FINISH', data: VALID_BUSINESS });
|
||||
await first;
|
||||
|
||||
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles isAuthenticating and removes the listener once settled', async () => {
|
||||
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
|
||||
const result = runEmbeddedSignup();
|
||||
expect(isAuthenticating.value).toBe(true);
|
||||
|
||||
await flushPromises();
|
||||
emit({ event: 'FINISH', data: VALID_BUSINESS });
|
||||
await result;
|
||||
|
||||
expect(isAuthenticating.value).toBe(false);
|
||||
expect(removeSpy).toHaveBeenCalledWith('message', registeredListener);
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import ChannelApi from 'dashboard/api/channels';
|
||||
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
// Page-management + messaging scopes required to list pages and create a
|
||||
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
|
||||
const FB_PAGE_SCOPES =
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
|
||||
|
||||
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
|
||||
// FB.login for page scopes, and fetch the user's pages. The caller owns the
|
||||
// page-picker UI and the channel creation, because choosing a page is an
|
||||
// interactive step (a user can manage several pages).
|
||||
//
|
||||
// Split into preloadSdk() + loginAndFetchPages() for popup safety: FB.login
|
||||
// opens a popup and needs the click's transient activation. Preloading the SDK
|
||||
// when the picker opens means the click-time `await` resolves within that
|
||||
// activation window; a cold load resolves on the script's `load` task seconds
|
||||
// later, after activation has expired, and the popup gets blocked.
|
||||
export function useFacebookPageConnect() {
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
let sdkSetupPromise = null;
|
||||
|
||||
// Idempotent — call this when the picker UI opens. A failed load clears the
|
||||
// cache so a later attempt can retry instead of being stuck on a rejection.
|
||||
const preloadSdk = () => {
|
||||
if (!sdkSetupPromise) {
|
||||
sdkSetupPromise = setupFacebookSdk(
|
||||
window.chatwootConfig?.fbAppId,
|
||||
window.chatwootConfig?.fbApiVersion
|
||||
).catch(error => {
|
||||
sdkSetupPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return sdkSetupPromise;
|
||||
};
|
||||
|
||||
// FB.login never rejects; resolve the user access token on success and null
|
||||
// for any other status (closed popup, not_authorized, unknown).
|
||||
const login = () =>
|
||||
new Promise(resolve => {
|
||||
window.FB.login(
|
||||
response => {
|
||||
resolve(
|
||||
response.status === 'connected'
|
||||
? response.authResponse?.accessToken || null
|
||||
: null
|
||||
);
|
||||
},
|
||||
{ scope: FB_PAGE_SCOPES }
|
||||
);
|
||||
});
|
||||
|
||||
// Resolves { userAccessToken, pages } on success, null when the user cancels,
|
||||
// and rejects on SDK-load or page-fetch failure (the caller maps it to UI).
|
||||
const loginAndFetchPages = async () => {
|
||||
if (isAuthenticating.value) return null;
|
||||
isAuthenticating.value = true;
|
||||
try {
|
||||
await preloadSdk();
|
||||
const token = await login();
|
||||
if (!token) return null;
|
||||
|
||||
const response = await ChannelApi.fetchFacebookPages(
|
||||
token,
|
||||
accountId.value
|
||||
);
|
||||
const { page_details: pages, user_access_token: userAccessToken } =
|
||||
response.data.data;
|
||||
return { userAccessToken, pages };
|
||||
} finally {
|
||||
isAuthenticating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { isAuthenticating, preloadSdk, loginAndFetchPages };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
isValidBusinessData,
|
||||
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
|
||||
|
||||
// Drives Meta's WhatsApp embedded-signup popup (Facebook JS SDK). FB.login()
|
||||
// resolves an auth `code` while the WABA identifiers (waba_id, phone_number_id)
|
||||
// arrive separately over a postMessage event — order isn't guaranteed, so we
|
||||
// hold both and resolve once both are present.
|
||||
//
|
||||
// `runEmbeddedSignup` returns the signup credentials; the caller exchanges them
|
||||
// for an inbox via `inboxes/createWhatsAppEmbeddedSignup` and owns its own UX
|
||||
// (alerts, navigation, etc). Resolves `null` when the user cancels the popup;
|
||||
// rejects on SDK load or signup errors. The window listener is scoped to a
|
||||
// single run, so this is safe to call from anywhere without lifecycle wiring.
|
||||
export function useWhatsappEmbeddedSignup() {
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
const runEmbeddedSignup = () => {
|
||||
if (isAuthenticating.value) return Promise.resolve(null);
|
||||
isAuthenticating.value = true;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let authCode = null;
|
||||
let businessData = null;
|
||||
let settled = false;
|
||||
let messageHandler;
|
||||
|
||||
const settle = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.removeEventListener('message', messageHandler);
|
||||
isAuthenticating.value = false;
|
||||
fn(value);
|
||||
};
|
||||
|
||||
// Both the auth code and the business data arrive asynchronously and in
|
||||
// no fixed order; only resolve once we're holding both.
|
||||
const resolveIfReady = () => {
|
||||
if (!authCode || !businessData) return;
|
||||
settle(resolve, {
|
||||
code: authCode,
|
||||
business_id: businessData.business_id,
|
||||
waba_id: businessData.waba_id,
|
||||
phone_number_id: businessData.phone_number_id || '',
|
||||
});
|
||||
};
|
||||
|
||||
messageHandler = createMessageHandler(data => {
|
||||
if (
|
||||
data.event === 'FINISH' ||
|
||||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
|
||||
) {
|
||||
if (!isValidBusinessData(data.data)) {
|
||||
settle(reject, new Error('Invalid business data'));
|
||||
return;
|
||||
}
|
||||
businessData = data.data;
|
||||
resolveIfReady();
|
||||
} else if (data.event === 'CANCEL') {
|
||||
settle(resolve, null);
|
||||
} else if (data.event === 'error') {
|
||||
settle(reject, new Error(data.error_message || 'Signup error'));
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await setupFacebookSdk(
|
||||
window.chatwootConfig?.whatsappAppId,
|
||||
window.chatwootConfig?.whatsappApiVersion
|
||||
);
|
||||
authCode = await initWhatsAppEmbeddedSignup(
|
||||
window.chatwootConfig?.whatsappConfigurationId
|
||||
);
|
||||
resolveIfReady();
|
||||
} catch (error) {
|
||||
// FB.login() rejects with 'Login cancelled' when the user dismisses
|
||||
// the popup — treat it as a cancel rather than an error.
|
||||
if (error.message === 'Login cancelled') {
|
||||
settle(resolve, null);
|
||||
} else {
|
||||
settle(reject, error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
};
|
||||
|
||||
return { isAuthenticating, runEmbeddedSignup };
|
||||
}
|
||||
@@ -37,6 +37,13 @@ export const getValuesName = (values, list, idKey, nameKey) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getValuesForContact = (values, contacts) => ({
|
||||
id: values[0],
|
||||
name:
|
||||
contacts?.find(contact => contact.id === values[0])?.name ||
|
||||
`Contact #${values[0]}`,
|
||||
});
|
||||
|
||||
export const getValuesForStatus = values => {
|
||||
return values.map(value => ({ id: value, name: value }));
|
||||
};
|
||||
@@ -84,6 +91,7 @@ export const getValuesForFilter = (filter, params) => {
|
||||
campaigns,
|
||||
labels,
|
||||
priority,
|
||||
contacts,
|
||||
} = params;
|
||||
switch (attribute_key) {
|
||||
case 'status':
|
||||
@@ -94,6 +102,8 @@ export const getValuesForFilter = (filter, params) => {
|
||||
return getValuesName(values, inboxes, 'id', 'name');
|
||||
case 'team_id':
|
||||
return getValuesName(values, teams, 'id', 'name');
|
||||
case 'contact_id':
|
||||
return getValuesForContact(values, contacts);
|
||||
case 'campaign_id':
|
||||
return getValuesName(values, campaigns, 'id', 'title');
|
||||
case 'labels':
|
||||
|
||||
@@ -159,6 +159,13 @@ export const LOCALE_MENU_ITEMS = {
|
||||
value: 'publish',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
customizeContent: {
|
||||
label:
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT',
|
||||
action: 'customize-content',
|
||||
value: 'customize-content',
|
||||
icon: 'i-lucide-pencil',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
@@ -172,20 +179,28 @@ const disableLocaleMenuItems = menuItems =>
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
return [
|
||||
...disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
]),
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
|
||||
];
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.publishLocale,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -273,6 +273,41 @@ describe('customViewsHelper', () => {
|
||||
};
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
|
||||
});
|
||||
|
||||
it('returns contact name for contact filters when contact is available', () => {
|
||||
const filter = {
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [123],
|
||||
};
|
||||
const params = {
|
||||
contacts: [{ id: 123, name: 'John Doe' }],
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: [],
|
||||
};
|
||||
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
|
||||
id: 123,
|
||||
name: 'John Doe',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns fallback contact display value when contact is not available', () => {
|
||||
const filter = {
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [123],
|
||||
};
|
||||
const params = {
|
||||
filterTypes: advancedFilterTypes,
|
||||
allCustomAttributes: [],
|
||||
};
|
||||
|
||||
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
|
||||
id: 123,
|
||||
name: 'Contact #123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#generateCustomAttributesInputType', () => {
|
||||
|
||||
@@ -64,4 +64,25 @@ describe('#filterQueryGenerator', () => {
|
||||
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes a selected contact object to contact id', () => {
|
||||
const result = filterQueryGenerator([
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: { id: 123, name: 'Jane Doe' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [123],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,37 +74,40 @@ describe('PortalHelper', () => {
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('returns disabled actions for the default locale', () => {
|
||||
it('disables other actions but keeps customize enabled for the default locale', () => {
|
||||
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
|
||||
const customize = items.find(item => item.action === 'customize-content');
|
||||
|
||||
expect(customize).toBeTruthy();
|
||||
expect(customize.disabled).toBeFalsy();
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'change-default', disabled: true }),
|
||||
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
|
||||
expect.objectContaining({ action: 'delete', disabled: true }),
|
||||
])
|
||||
);
|
||||
items
|
||||
.filter(item => item.action !== 'customize-content')
|
||||
.every(item => item.disabled)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns publish and delete actions for draft locales', () => {
|
||||
it('returns publish, customize, and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'delete']);
|
||||
).toEqual(['publish-locale', 'customize-content', 'delete']);
|
||||
});
|
||||
|
||||
it('returns default, draft, and delete actions for live locales', () => {
|
||||
it('returns default, draft, customize, and delete actions for live locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||
).toEqual([
|
||||
'change-default',
|
||||
'move-to-draft',
|
||||
'customize-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"OR": "OR"
|
||||
},
|
||||
"INPUT_PLACEHOLDER": "Enter value",
|
||||
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||
"CONTACT_FALLBACK": "Contact #{id}",
|
||||
"OPERATOR_LABELS": {
|
||||
"equal_to": "Equal to",
|
||||
"not_equal_to": "Not equal to",
|
||||
@@ -49,6 +51,7 @@
|
||||
"ASSIGNEE_NAME": "Assignee name",
|
||||
"INBOX_NAME": "Inbox name",
|
||||
"TEAM_NAME": "Team name",
|
||||
"CONTACT": "Contact",
|
||||
"CONVERSATION_IDENTIFIER": "Conversation identifier",
|
||||
"CAMPAIGN_NAME": "Campaign name",
|
||||
"LABELS": "Labels",
|
||||
|
||||
@@ -700,9 +700,27 @@
|
||||
"MAKE_DEFAULT": "Make default",
|
||||
"MOVE_TO_DRAFT": "Move to draft",
|
||||
"PUBLISH_LOCALE": "Publish locale",
|
||||
"CUSTOMIZE_CONTENT": "Localize content",
|
||||
"DELETE": "Delete"
|
||||
}
|
||||
},
|
||||
"CONTENT_DIALOG": {
|
||||
"TITLE": "Localize content",
|
||||
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||
"NAME": {
|
||||
"LABEL": "Name"
|
||||
},
|
||||
"PAGE_TITLE": {
|
||||
"LABEL": "Page title"
|
||||
},
|
||||
"HEADER_TEXT": {
|
||||
"LABEL": "Header text"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Locale content updated successfully",
|
||||
"ERROR_MESSAGE": "Unable to update locale content. Try again."
|
||||
}
|
||||
},
|
||||
"ADD_LOCALE_DIALOG": {
|
||||
"TITLE": "Add a new locale",
|
||||
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
|
||||
|
||||
@@ -188,7 +188,6 @@ export default {
|
||||
:status="contact.availability_status"
|
||||
:size="48"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<script>
|
||||
/* eslint-env browser */
|
||||
/* global FB */
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
|
||||
import ChannelApi from '../../../../../api/channels';
|
||||
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';
|
||||
|
||||
export default {
|
||||
@@ -25,11 +21,12 @@ export default {
|
||||
ComboBox,
|
||||
},
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
|
||||
return {
|
||||
accountId,
|
||||
replaceInstallationName,
|
||||
preloadSdk,
|
||||
loginAndFetchPages,
|
||||
v$: useVuelidate(),
|
||||
};
|
||||
},
|
||||
@@ -37,9 +34,7 @@ export default {
|
||||
return {
|
||||
isCreating: false,
|
||||
hasError: false,
|
||||
omniauth_token: '',
|
||||
user_access_token: '',
|
||||
channel: 'facebook',
|
||||
selectedPage: { name: null, id: null },
|
||||
pageName: '',
|
||||
pageList: [],
|
||||
@@ -78,24 +73,29 @@ export default {
|
||||
},
|
||||
|
||||
mounted() {
|
||||
window.fbAsyncInit = this.runFBInit;
|
||||
// Warm the SDK so the login click opens its popup within the gesture's
|
||||
// activation window (see useFacebookPageConnect).
|
||||
this.preloadSdk();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async startLogin() {
|
||||
this.hasLoginStarted = true;
|
||||
try {
|
||||
// this will load the SDK in a promise, and resolve it when the sdk is loaded
|
||||
// in case the SDK is already present, it will resolve immediately
|
||||
await this.loadFBsdk();
|
||||
this.runFBInit(); // run init anyway, `tryFBlogin` won't wait for `fbAsyncInit` otherwise.
|
||||
this.tryFBlogin(); // make an attempt to login
|
||||
const result = await this.loginAndFetchPages();
|
||||
if (!result) {
|
||||
// Cancelled popup / not authorized — surface a generic auth error.
|
||||
this.hasError = true;
|
||||
this.errorStateMessage = this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH');
|
||||
this.errorStateDescription = '';
|
||||
return;
|
||||
}
|
||||
this.pageList = result.pages;
|
||||
this.user_access_token = result.userAccessToken;
|
||||
} catch (error) {
|
||||
if (error.name === 'ScriptLoaderError') {
|
||||
// if the error was related to script loading, we show a toast
|
||||
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
|
||||
} else {
|
||||
// if the error was anything else, we capture it and show a toast
|
||||
Sentry.captureException(error);
|
||||
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
|
||||
}
|
||||
@@ -114,81 +114,6 @@ export default {
|
||||
this.v$.selectedPage.$touch();
|
||||
},
|
||||
|
||||
initChannelAuth(channel) {
|
||||
if (channel === 'facebook') {
|
||||
this.loadFBsdk();
|
||||
}
|
||||
},
|
||||
|
||||
runFBInit() {
|
||||
FB.init({
|
||||
appId: window.chatwootConfig.fbAppId,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig.fbApiVersion,
|
||||
status: true,
|
||||
});
|
||||
window.fbSDKLoaded = true;
|
||||
FB.AppEvents.logPageView();
|
||||
},
|
||||
|
||||
async loadFBsdk() {
|
||||
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
|
||||
id: 'facebook-jssdk',
|
||||
});
|
||||
},
|
||||
|
||||
tryFBlogin() {
|
||||
FB.login(
|
||||
response => {
|
||||
this.hasError = false;
|
||||
if (response.status === 'connected') {
|
||||
this.fetchPages(response.authResponse.accessToken);
|
||||
} else if (response.status === 'not_authorized') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('FACEBOOK AUTH ERROR', response);
|
||||
this.hasError = true;
|
||||
// The person is logged into Facebook, but not your app.
|
||||
this.errorStateMessage = this.$t(
|
||||
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED'
|
||||
);
|
||||
this.errorStateDescription = this.$t(
|
||||
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED_HELP'
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('FACEBOOK AUTH ERROR', response);
|
||||
this.hasError = true;
|
||||
// The person is not logged into Facebook, so we're not sure if
|
||||
// they are logged into this app or not.
|
||||
this.errorStateMessage = this.$t(
|
||||
'INBOX_MGMT.DETAILS.ERROR_FB_AUTH'
|
||||
);
|
||||
this.errorStateDescription = '';
|
||||
}
|
||||
},
|
||||
{
|
||||
scope:
|
||||
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async fetchPages(_token) {
|
||||
try {
|
||||
const response = await ChannelApi.fetchFacebookPages(
|
||||
_token,
|
||||
this.accountId
|
||||
);
|
||||
const {
|
||||
data: { data },
|
||||
} = response;
|
||||
this.pageList = data.page_details;
|
||||
this.user_access_token = data.user_access_token;
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
|
||||
channelParams() {
|
||||
return {
|
||||
user_access_token: this.user_access_token,
|
||||
|
||||
+25
-158
@@ -1,21 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import globalConstants from 'dashboard/constants/globals.js';
|
||||
import {
|
||||
setupFacebookSdk,
|
||||
initWhatsAppEmbeddedSignup,
|
||||
createMessageHandler,
|
||||
isValidBusinessData,
|
||||
} from './whatsapp/utils';
|
||||
|
||||
const props = defineProps({
|
||||
enableCallingOnComplete: {
|
||||
@@ -27,15 +22,10 @@ const props = defineProps({
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||
|
||||
// State
|
||||
const fbSdkLoaded = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const processingMessage = ref('');
|
||||
const authCodeReceived = ref(false);
|
||||
const authCode = ref(null);
|
||||
const businessData = ref(null);
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
const benefits = computed(() => [
|
||||
{
|
||||
@@ -54,25 +44,6 @@ const benefits = computed(() => [
|
||||
|
||||
const showLoader = computed(() => isAuthenticating.value || isProcessing.value);
|
||||
|
||||
// Error handling
|
||||
const handleSignupError = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
data.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
const handleSignupCancellation = () => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
};
|
||||
|
||||
const enableCallingForInbox = async inboxId => {
|
||||
try {
|
||||
await InboxesAPI.enableWhatsappCalling(inboxId);
|
||||
@@ -86,14 +57,12 @@ const enableCallingForInbox = async inboxId => {
|
||||
const handleSignupSuccess = async inboxData => {
|
||||
if (inboxData && inboxData.id) {
|
||||
if (props.enableCallingOnComplete) {
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING'
|
||||
);
|
||||
await enableCallingForInbox(inboxData.id);
|
||||
}
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
@@ -104,7 +73,6 @@ const handleSignupSuccess = async inboxData => {
|
||||
});
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
|
||||
router.replace({
|
||||
name: 'settings_inbox_list',
|
||||
@@ -112,12 +80,21 @@ const handleSignupSuccess = async inboxData => {
|
||||
}
|
||||
};
|
||||
|
||||
// Signup flow
|
||||
const completeSignupFlow = async businessDataParam => {
|
||||
if (!authCodeReceived.value || !authCode.value) {
|
||||
handleSignupError({
|
||||
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
|
||||
});
|
||||
const launchEmbeddedSignup = async () => {
|
||||
let credentials;
|
||||
try {
|
||||
credentials = await runEmbeddedSignup();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolves null when the user dismisses the Meta popup.
|
||||
if (!credentials) {
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -125,130 +102,20 @@ const completeSignupFlow = async businessDataParam => {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
|
||||
);
|
||||
|
||||
try {
|
||||
const params = {
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam?.phone_number_id || '',
|
||||
};
|
||||
|
||||
const responseData = await store.dispatch(
|
||||
const inboxData = await store.dispatch(
|
||||
'inboxes/createWhatsAppEmbeddedSignup',
|
||||
params
|
||||
credentials
|
||||
);
|
||||
|
||||
authCode.value = null;
|
||||
handleSignupSuccess(responseData);
|
||||
await handleSignupSuccess(inboxData);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
isProcessing.value = false;
|
||||
useAlert(
|
||||
parseAPIErrorResponse(error) ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
handleSignupError({ error: errorMessage });
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
if (
|
||||
data.event === 'FINISH' ||
|
||||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
|
||||
) {
|
||||
const businessDataLocal = data.data;
|
||||
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
businessData.value = businessDataLocal;
|
||||
if (authCodeReceived.value && authCode.value) {
|
||||
await completeSignupFlow(businessDataLocal);
|
||||
} else {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleSignupError({
|
||||
error: t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
handleSignupCancellation();
|
||||
} else if (data.event === 'error') {
|
||||
handleSignupError({
|
||||
error:
|
||||
data.error_message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
|
||||
error_id: data.error_id,
|
||||
session_id: data.session_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupMessage = createMessageHandler(handleEmbeddedSignupData);
|
||||
|
||||
const launchEmbeddedSignup = async () => {
|
||||
try {
|
||||
isAuthenticating.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
await setupFacebookSdk(
|
||||
window.chatwootConfig?.whatsappAppId,
|
||||
window.chatwootConfig?.whatsappApiVersion
|
||||
);
|
||||
fbSdkLoaded.value = true;
|
||||
|
||||
const code = await initWhatsAppEmbeddedSignup(
|
||||
window.chatwootConfig?.whatsappConfigurationId
|
||||
);
|
||||
|
||||
authCode.value = code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
if (businessData.value) {
|
||||
completeSignupFlow(businessData.value);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message === 'Login cancelled') {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
} else {
|
||||
handleSignupError({
|
||||
error:
|
||||
error.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const setupMessageListener = () => {
|
||||
window.addEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const cleanupMessageListener = () => {
|
||||
window.removeEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMessageListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -73,6 +73,12 @@ const getValueFromConversation = (conversation, attributeKey) => {
|
||||
return conversation.display_id || conversation.id;
|
||||
case 'assignee_id':
|
||||
return conversation.meta?.assignee?.id;
|
||||
case 'contact_id':
|
||||
return (
|
||||
conversation.meta?.sender?.id ||
|
||||
conversation.contact?.id ||
|
||||
conversation.contact_id
|
||||
);
|
||||
case 'inbox_id':
|
||||
return conversation.inbox_id;
|
||||
case 'team_id':
|
||||
|
||||
+26
@@ -244,6 +244,32 @@ describe('filterHelpers', () => {
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match conversation with equal_to operator for contact_id', () => {
|
||||
const conversation = { meta: { sender: { id: 42 } } };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: { id: 42, name: 'Jane Doe' },
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match conversation with saved contact_id filter values', () => {
|
||||
const conversation = { meta: { sender: { id: 42 } } };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [42],
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
// Standard attribute tests - priority
|
||||
it('should match conversation with equal_to operator for priority', () => {
|
||||
const conversation = { priority: 'urgent' };
|
||||
|
||||
@@ -15,6 +15,12 @@ const FILTER_KEYS = {
|
||||
[VIEW_TYPES.CONTACT]: VIEW_TYPES.CONTACT,
|
||||
};
|
||||
|
||||
// a folder's contact_id filter stores only the id, extract it so the
|
||||
// contact can be fetched and its name shown in the edit folder modal
|
||||
const getFolderContactId = folder =>
|
||||
folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id')
|
||||
?.values?.[0];
|
||||
|
||||
export const state = {
|
||||
[VIEW_TYPES.CONVERSATION]: {
|
||||
records: [],
|
||||
@@ -47,6 +53,9 @@ export const getters = {
|
||||
getActiveConversationFolder(_state) {
|
||||
return _state.activeConversationFolder;
|
||||
},
|
||||
getActiveFolderContactId(_state) {
|
||||
return getFolderContactId(_state.activeConversationFolder);
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -104,8 +113,11 @@ export const actions = {
|
||||
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false });
|
||||
}
|
||||
},
|
||||
setActiveConversationFolder({ commit }, data) {
|
||||
setActiveConversationFolder({ commit, dispatch }, data) {
|
||||
commit(types.SET_ACTIVE_CONVERSATION_FOLDER, data);
|
||||
// prefetch the contact of a contact filter so the UI can show its name
|
||||
const contactId = getFolderContactId(data);
|
||||
if (contactId) dispatch('contacts/show', { id: contactId }, { root: true });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../customViews';
|
||||
import * as types from '../../../mutation-types';
|
||||
import { customViewList, updateCustomViewList } from './fixtures';
|
||||
import { actions } from '../../customViews';
|
||||
import {
|
||||
contactFilterView,
|
||||
customViewList,
|
||||
updateCustomViewList,
|
||||
} from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
@@ -106,5 +110,27 @@ describe('#actions', () => {
|
||||
[types.default.SET_ACTIVE_CONVERSATION_FOLDER, customViewList[0]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefetches the contact of a contact filter', async () => {
|
||||
const dispatch = vi.fn();
|
||||
await actions.setActiveConversationFolder(
|
||||
{ commit, dispatch },
|
||||
contactFilterView
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
'contacts/show',
|
||||
{ id: 42 },
|
||||
{ root: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not prefetch without a contact filter', async () => {
|
||||
const dispatch = vi.fn();
|
||||
await actions.setActiveConversationFolder(
|
||||
{ commit, dispatch },
|
||||
customViewList[0]
|
||||
);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,21 @@ export const contactViewList = [
|
||||
},
|
||||
];
|
||||
|
||||
export const contactFilterView = {
|
||||
name: 'Contact view',
|
||||
filter_type: 0,
|
||||
query: {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [42],
|
||||
query_operator: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const customViewList = [
|
||||
{
|
||||
name: 'Custom view',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getters } from '../../customViews';
|
||||
import { contactViewList, customViewList } from './fixtures';
|
||||
import { contactFilterView, contactViewList, customViewList } from './fixtures';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('getCustomViewsByFilterType', () => {
|
||||
@@ -43,4 +43,20 @@ describe('#getters', () => {
|
||||
customViewList[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('getActiveFolderContactId', () => {
|
||||
expect(
|
||||
getters.getActiveFolderContactId({
|
||||
activeConversationFolder: contactFilterView,
|
||||
})
|
||||
).toEqual(42);
|
||||
});
|
||||
|
||||
it('getActiveFolderContactId returns undefined without a contact filter', () => {
|
||||
expect(
|
||||
getters.getActiveFolderContactId({
|
||||
activeConversationFolder: customViewList[0],
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,19 @@
|
||||
class MutexApplicationJob < ApplicationJob
|
||||
class LockAcquisitionError < StandardError; end
|
||||
|
||||
def self.retry_on_lock_conflict(wait:, attempts:, on_exhaustion: :raise)
|
||||
retry_on LockAcquisitionError, wait: wait, attempts: attempts do |job, error|
|
||||
raise error if on_exhaustion == :raise
|
||||
|
||||
job.public_send(on_exhaustion, *job.arguments)
|
||||
end
|
||||
end
|
||||
|
||||
# Redis::LockManager#unlock is not owner-checked. If a job runs past the TTL,
|
||||
# Redis can expire the key, a newer job can acquire it, and the older job can
|
||||
# then delete the newer job's lock on unlock. Current mutex users treat locks as
|
||||
# short race dampeners, so this is acceptable for now. Future iterations should
|
||||
# move Redis::LockManager to token-checked unlocks.
|
||||
def with_lock(lock_key, timeout = Redis::LockManager::LOCK_TIMEOUT)
|
||||
lock_manager = Redis::LockManager.new
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
class Webhooks::InstagramEventsJob < MutexApplicationJob
|
||||
queue_as :default
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
# This lock is only a short race dampener for first-message conversation creation.
|
||||
# ContactInbox creation is already protected by a unique index, but conversation
|
||||
# lookup is `find active conversation || create`, so concurrent first messages from
|
||||
# the same IG contact can create duplicate conversations.
|
||||
#
|
||||
# ActiveJob retries are not FIFO, so a longer retry window does not preserve message
|
||||
# order. Use deterministic backoff so the final attempt happens after the 3s lock TTL,
|
||||
# then process without the lock instead of dropping the webhook.
|
||||
retry_on_lock_conflict wait: ->(executions) { executions.seconds }, attempts: 3, on_exhaustion: :process_without_lock
|
||||
|
||||
# @return [Array] We will support further events like reaction or seen in future
|
||||
SUPPORTED_EVENTS = [:message, :read].freeze
|
||||
@@ -9,11 +17,19 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
||||
@entries = entries
|
||||
|
||||
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: contact_instagram_id, ig_account_id: ig_account_id)
|
||||
with_lock(key) do
|
||||
# Keep the lock TTL just long enough for the first job to fetch profile data and
|
||||
# create the contact/conversation. A longer TTL would add user-visible latency for
|
||||
# hot contacts without giving us ordering guarantees.
|
||||
with_lock(key, 3.seconds) do
|
||||
process_entries(entries)
|
||||
end
|
||||
end
|
||||
|
||||
def process_without_lock(entries)
|
||||
Rails.logger.warn("[#{self.class.name}] Processing without lock after lock retry exhaustion")
|
||||
process_entries(entries)
|
||||
end
|
||||
|
||||
# https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
|
||||
def process_entries(entries)
|
||||
entries.each do |entry|
|
||||
|
||||
@@ -65,11 +65,10 @@ class Imap::ImapMailbox
|
||||
end
|
||||
|
||||
def in_reply_to
|
||||
@processed_mail.in_reply_to
|
||||
sanitize_mailbox_value(@processed_mail.in_reply_to)
|
||||
end
|
||||
|
||||
def find_conversation_by_references
|
||||
references = Array.wrap(@inbound_mail.references)
|
||||
references.each do |message_id|
|
||||
match = FALLBACK_CONVERSATION_PATTERN.match(message_id)
|
||||
|
||||
@@ -80,8 +79,6 @@ class Imap::ImapMailbox
|
||||
def find_message_by_references
|
||||
message_to_return = nil
|
||||
|
||||
references = Array.wrap(@inbound_mail.references)
|
||||
|
||||
references.each do |message_id|
|
||||
message = @inbox.messages.find_by(source_id: message_id)
|
||||
message_to_return = message if message.present?
|
||||
@@ -100,7 +97,7 @@ class Imap::ImapMailbox
|
||||
source: 'email',
|
||||
in_reply_to: in_reply_to,
|
||||
auto_reply: @processed_mail.auto_reply?,
|
||||
mail_subject: @processed_mail.subject,
|
||||
mail_subject: sanitize_mailbox_value(@processed_mail.subject),
|
||||
initiated_at: {
|
||||
timestamp: Time.now.utc
|
||||
}
|
||||
@@ -110,7 +107,7 @@ class Imap::ImapMailbox
|
||||
end
|
||||
|
||||
def find_or_create_contact
|
||||
@contact = @inbox.contacts.from_email(@processed_mail.original_sender)
|
||||
@contact = @inbox.contacts.from_email(original_sender_email)
|
||||
if @contact.present?
|
||||
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
|
||||
else
|
||||
@@ -119,6 +116,14 @@ class Imap::ImapMailbox
|
||||
end
|
||||
|
||||
def identify_contact_name
|
||||
processed_mail.sender_name || processed_mail.from.first.split('@').first
|
||||
sanitize_mailbox_value(processed_mail.sender_name || processed_mail.from.first.split('@').first)
|
||||
end
|
||||
|
||||
def original_sender_email
|
||||
sanitize_mailbox_value(@processed_mail.original_sender)
|
||||
end
|
||||
|
||||
def references
|
||||
sanitize_mailbox_value(Array.wrap(@inbound_mail.references))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
module MailboxHelper
|
||||
include MailboxInlineAttachmentHelper
|
||||
include MailboxSanitizer
|
||||
include ::FileTypeHelper
|
||||
|
||||
private
|
||||
|
||||
def create_message
|
||||
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
|
||||
return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
|
||||
source_id = sanitize_mailbox_value(processed_mail.message_id)
|
||||
return if @conversation.messages.find_by(source_id: source_id).present?
|
||||
|
||||
@message = @conversation.messages.create!(
|
||||
account_id: @conversation.account_id,
|
||||
sender: @conversation.contact,
|
||||
content: mail_content&.truncate(150_000),
|
||||
inbox_id: @conversation.inbox_id,
|
||||
message_type: 'incoming',
|
||||
content_type: 'incoming_email',
|
||||
source_id: processed_mail.message_id,
|
||||
content_attributes: {
|
||||
email: processed_mail.serialized_data,
|
||||
cc_email: processed_mail.cc,
|
||||
bcc_email: processed_mail.bcc
|
||||
}
|
||||
)
|
||||
@message = @conversation.messages.create!(sanitized_message_attributes(source_id))
|
||||
end
|
||||
|
||||
def add_attachments_to_message
|
||||
@@ -101,13 +90,16 @@ module MailboxHelper
|
||||
end
|
||||
|
||||
def create_contact
|
||||
sender_email = sanitize_mailbox_value(processed_mail.original_sender)
|
||||
message_id = sanitize_mailbox_value(processed_mail.message_id)
|
||||
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: processed_mail.original_sender,
|
||||
source_id: sender_email,
|
||||
inbox: @inbox,
|
||||
contact_attributes: {
|
||||
name: identify_contact_name,
|
||||
email: processed_mail.original_sender,
|
||||
additional_attributes: { source_id: "email:#{processed_mail.message_id}" }
|
||||
name: sanitize_mailbox_value(identify_contact_name),
|
||||
email: sender_email,
|
||||
additional_attributes: { source_id: "email:#{message_id}" }
|
||||
}
|
||||
).perform
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
module MailboxSanitizer
|
||||
NULL_BYTE = "\u0000".freeze
|
||||
|
||||
private
|
||||
|
||||
def sanitized_message_attributes(source_id)
|
||||
{
|
||||
account_id: @conversation.account_id,
|
||||
sender: @conversation.contact,
|
||||
content: sanitize_mailbox_value(mail_content)&.truncate(150_000),
|
||||
inbox_id: @conversation.inbox_id,
|
||||
message_type: 'incoming',
|
||||
content_type: 'incoming_email',
|
||||
source_id: source_id,
|
||||
content_attributes: sanitized_content_attributes
|
||||
}
|
||||
end
|
||||
|
||||
def sanitized_content_attributes
|
||||
sanitize_mailbox_value(
|
||||
email: processed_mail.serialized_data,
|
||||
cc_email: processed_mail.cc,
|
||||
bcc_email: processed_mail.bcc
|
||||
)
|
||||
end
|
||||
|
||||
def sanitize_mailbox_value(value)
|
||||
return value.delete(NULL_BYTE) if value.is_a?(String)
|
||||
return value.map { |item| sanitize_mailbox_value(item) } if value.is_a?(Array)
|
||||
return value.transform_values { |item| sanitize_mailbox_value(item) } if value.is_a?(Hash)
|
||||
|
||||
value
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
module PortalConfigSchema
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Per-locale overrides for portal level fields. Any locale present in
|
||||
# `allowed_locales` may carry its own `name`, `page_title` and `header_text`.
|
||||
# Missing values fall back to the default locale and finally to the base column.
|
||||
LOCALE_TRANSLATION_SCHEMA = {
|
||||
'type' => 'object',
|
||||
'properties' => {
|
||||
'name' => { 'type' => %w[string null] },
|
||||
'page_title' => { 'type' => %w[string null] },
|
||||
'header_text' => { 'type' => %w[string null] }
|
||||
},
|
||||
'additionalProperties' => false
|
||||
}.freeze
|
||||
|
||||
CONFIG_PARAMS_SCHEMA = {
|
||||
'type' => 'object',
|
||||
'properties' => {
|
||||
'allowed_locales' => { 'type' => %w[array null], 'items' => { 'type' => 'string' } },
|
||||
'default_locale' => { 'type' => %w[string null] },
|
||||
'draft_locales' => { 'type' => %w[array null], 'items' => { 'type' => 'string' } },
|
||||
'layout' => { 'type' => %w[string null], 'enum' => ['classic', 'documentation', nil] },
|
||||
# TODO: unused reserved key; remove with a migration that scrubs it from existing portals' config
|
||||
'website_token' => { 'type' => %w[string null] },
|
||||
'social_profiles' => { 'type' => %w[object null] },
|
||||
'locale_translations' => {
|
||||
'type' => %w[object null],
|
||||
'additionalProperties' => LOCALE_TRANSLATION_SCHEMA
|
||||
}
|
||||
},
|
||||
'required' => [],
|
||||
'additionalProperties' => true
|
||||
}.to_json.freeze
|
||||
end
|
||||
+24
-5
@@ -26,6 +26,7 @@
|
||||
#
|
||||
class Portal < ApplicationRecord
|
||||
include Rails.application.routes.url_helpers
|
||||
include PortalConfigSchema
|
||||
|
||||
DEFAULT_COLOR = '#1f93ff'.freeze
|
||||
|
||||
@@ -43,11 +44,16 @@ class Portal < ApplicationRecord
|
||||
validates :slug, presence: true, uniqueness: true
|
||||
validates :custom_domain, uniqueness: true, allow_nil: true
|
||||
validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
|
||||
validate :config_json_format
|
||||
before_validation :normalize_config
|
||||
validate :validate_config
|
||||
validates_with JsonSchemaValidator,
|
||||
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
|
||||
attribute_resolver: ->(record) { record.config }
|
||||
|
||||
scope :active, -> { where(archived: false) }
|
||||
|
||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout].freeze
|
||||
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
|
||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze
|
||||
|
||||
def file_base_data
|
||||
{
|
||||
@@ -91,8 +97,18 @@ class Portal < ApplicationRecord
|
||||
self[:color].presence || DEFAULT_COLOR
|
||||
end
|
||||
|
||||
def display_title
|
||||
page_title.presence || name
|
||||
def display_title(locale = default_locale)
|
||||
localized_value('page_title', locale).presence || localized_value('name', locale)
|
||||
end
|
||||
|
||||
# Resolves a portal level field for a locale, falling back to the default
|
||||
# locale's value (its override or the base column) when the locale has no
|
||||
# override of its own.
|
||||
def localized_value(field, locale = default_locale)
|
||||
translations = config_value('locale_translations') || {}
|
||||
translations.dig(locale.to_s, field).presence ||
|
||||
translations.dig(default_locale, field).presence ||
|
||||
self[field]
|
||||
end
|
||||
|
||||
def layout
|
||||
@@ -105,11 +121,14 @@ class Portal < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def config_json_format
|
||||
def normalize_config
|
||||
self.config = persisted_config.merge((config || {}).deep_stringify_keys)
|
||||
config['allowed_locales'] = allowed_locale_codes
|
||||
config['default_locale'] = default_locale
|
||||
config['draft_locales'] = draft_locale_codes
|
||||
end
|
||||
|
||||
def validate_config
|
||||
denied_keys = config.keys - CONFIG_JSON_KEYS
|
||||
errors.add(:config, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
|
||||
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
|
||||
|
||||
@@ -45,12 +45,12 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def fb_text_message_params
|
||||
{
|
||||
params = {
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: fb_text_message_payload,
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: message_tag
|
||||
message: fb_text_message_payload
|
||||
}
|
||||
|
||||
merge_human_agent_tag(params)
|
||||
end
|
||||
|
||||
def fb_text_message_payload
|
||||
@@ -79,7 +79,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def fb_attachment_message_params(attachment)
|
||||
{
|
||||
params = {
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: {
|
||||
attachment: {
|
||||
@@ -88,14 +88,21 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
url: attachment.download_url
|
||||
}
|
||||
}
|
||||
},
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: message_tag
|
||||
}
|
||||
}
|
||||
|
||||
merge_human_agent_tag(params)
|
||||
end
|
||||
|
||||
def message_tag
|
||||
@message_tag ||= GlobalConfigService.load('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT', nil) ? 'HUMAN_AGENT' : 'ACCOUNT_UPDATE'
|
||||
def merge_human_agent_tag(params)
|
||||
unless GlobalConfigService.load('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT', nil)
|
||||
params[:messaging_type] = 'RESPONSE'
|
||||
return params
|
||||
end
|
||||
|
||||
params[:messaging_type] = 'MESSAGE_TAG'
|
||||
params[:tag] = 'HUMAN_AGENT'
|
||||
params
|
||||
end
|
||||
|
||||
def attachment_type(attachment)
|
||||
@@ -104,11 +111,6 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
'file'
|
||||
end
|
||||
|
||||
def sent_first_outgoing_message_after_24_hours?
|
||||
# we can send max 1 message after 24 hour window
|
||||
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
|
||||
end
|
||||
|
||||
def handle_facebook_error(exception)
|
||||
# Refer: https://github.com/jgorset/facebook-messenger/blob/64fe1f5cef4c1e3fca295b205037f64dfebdbcab/lib/facebook/messenger/error.rb
|
||||
return unless exception.to_s.include?('The session has been invalidated') || exception.to_s.include?('Error validating access token')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Mailbox::ConversationFinderStrategies::BaseStrategy
|
||||
include MailboxSanitizer
|
||||
|
||||
attr_reader :mail
|
||||
|
||||
def initialize(mail)
|
||||
|
||||
@@ -14,7 +14,7 @@ class Mailbox::ConversationFinderStrategies::InReplyToStrategy < Mailbox::Conver
|
||||
def find
|
||||
return nil if mail.in_reply_to.blank?
|
||||
|
||||
in_reply_to_addresses = Array.wrap(mail.in_reply_to)
|
||||
in_reply_to_addresses = sanitize_mailbox_value(Array.wrap(mail.in_reply_to))
|
||||
|
||||
in_reply_to_addresses.each do |in_reply_to|
|
||||
# Try extracting UUID from patterns
|
||||
|
||||
@@ -45,11 +45,11 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
|
||||
end
|
||||
|
||||
def original_sender_email
|
||||
@processed_mail.original_sender&.downcase
|
||||
sanitize_mailbox_value(@processed_mail.original_sender)&.downcase
|
||||
end
|
||||
|
||||
def identify_contact_name
|
||||
@processed_mail.sender_name || @processed_mail.from.first.split('@').first
|
||||
sanitize_mailbox_value(@processed_mail.sender_name || @processed_mail.from.first.split('@').first)
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@@ -63,7 +63,7 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
|
||||
in_reply_to: in_reply_to,
|
||||
source: 'email',
|
||||
auto_reply: @processed_mail.auto_reply?,
|
||||
mail_subject: @processed_mail.subject,
|
||||
mail_subject: sanitize_mailbox_value(@processed_mail.subject),
|
||||
initiated_at: {
|
||||
timestamp: Time.now.utc
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
|
||||
end
|
||||
|
||||
def in_reply_to
|
||||
mail['In-Reply-To'].try(:value)
|
||||
sanitize_mailbox_value(mail['In-Reply-To'].try(:value))
|
||||
end
|
||||
|
||||
def find_conversation_by_in_reply_to
|
||||
|
||||
@@ -21,7 +21,7 @@ class Mailbox::ConversationFinderStrategies::ReferencesStrategy < Mailbox::Conve
|
||||
return nil if mail.references.blank?
|
||||
return nil unless @channel # No valid channel found
|
||||
|
||||
references = Array.wrap(mail.references)
|
||||
references = sanitize_mailbox_value(Array.wrap(mail.references))
|
||||
|
||||
references.each do |reference|
|
||||
conversation = find_conversation_from_reference(reference)
|
||||
|
||||
@@ -18,6 +18,7 @@ json.config do
|
||||
json.default_locale portal.default_locale
|
||||
json.layout portal.layout
|
||||
json.social_profiles portal.social_profiles
|
||||
json.locale_translations portal.config['locale_translations'] || {}
|
||||
end
|
||||
|
||||
if portal.channel_web_widget
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<% if content_for?(:head) %>
|
||||
<%= yield(:head) %>
|
||||
<% else %>
|
||||
<title><%= @portal.display_title %></title>
|
||||
<title><%= @portal.display_title(@locale) %></title>
|
||||
<% end %>
|
||||
|
||||
<% if @portal.logo.present? %>
|
||||
|
||||
@@ -89,5 +89,9 @@ html.light {
|
||||
};
|
||||
</script>
|
||||
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
|
||||
<script>
|
||||
window.chatwootSettings = window.chatwootSettings || {};
|
||||
window.chatwootSettings.locale = '<%= @locale %>';
|
||||
</script>
|
||||
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
|
||||
<% end %>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<% if @portal.logo.present? %>
|
||||
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
|
||||
<% end %>
|
||||
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.name %></span>
|
||||
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.localized_value('name', @locale) %></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
<% if @portal.logo.present? %>
|
||||
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
|
||||
<% end %>
|
||||
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span>
|
||||
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.localized_value('name', @locale) %></span>
|
||||
</a>
|
||||
|
||||
<!-- Mobile Menu Component -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<% content_for :head do %>
|
||||
<title><%= @portal.display_title %></title>
|
||||
<meta name="title" content="<%= @portal.display_title %>">
|
||||
<title><%= @portal.display_title(@locale) %></title>
|
||||
<meta name="title" content="<%= @portal.display_title(@locale) %>">
|
||||
|
||||
<% if @og_image_url.present? %>
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
@@ -13,9 +13,9 @@
|
||||
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
|
||||
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
|
||||
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.name %></span>
|
||||
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.localized_value('name', @locale) %></span>
|
||||
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
|
||||
<%= portal.header_text %>
|
||||
<%= portal.localized_value('header_text', @locale) %>
|
||||
</h1>
|
||||
<p class="text-slate-600 dark:text-slate-200 text-start text-lg leading-normal pt-2 pb-4"><%= I18n.t('public_portal.hero.sub_title') %></p>
|
||||
<div id="search-wrap" class="w-full relative z-30"></div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
class="leading-8 text-slate-800 hover:underline"
|
||||
href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<%= @portal.name %> <%= I18n.t('public_portal.common.home') %>
|
||||
<%= @portal.localized_value('name', @locale) %> <%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span>/</span>
|
||||
<span>/</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= @article.title %> | <%= @portal.display_title %></title>
|
||||
<title><%= @article.title %> | <%= @portal.display_title(@locale) %></title>
|
||||
<% if @article.meta["title"].present? %>
|
||||
<meta name="title" content="<%= @article.meta["title"] %>">
|
||||
<meta property="og:title" content="<%= @article.meta["title"] %>">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
<section class="bg-slate-50 dark:bg-slate-800 py-16 flex flex-col items-center justify-center">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-4xl text-slate-900 dark:text-white font-semibold leading-relaxed text-center"><%= portal.header_text %></h1>
|
||||
<h1 class="text-4xl text-slate-900 dark:text-white font-semibold leading-relaxed text-center"><%= portal.localized_value('header_text', @locale) %></h1>
|
||||
<p class="text-slate-700 dark:text-slate-100 py-2 text-center"><%= I18n.t('public_portal.hero.sub_title') %></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= @category.name %> | <%= @portal.display_title %></title>
|
||||
<meta name="title" content="<%= @category.name %> | <%= @portal.display_title %>">
|
||||
<title><%= @category.name %> | <%= @portal.display_title(@locale) %></title>
|
||||
<meta name="title" content="<%= @category.name %> | <%= @portal.display_title(@locale) %>">
|
||||
<% if @category.description.present? %>
|
||||
<meta name="description" content="<%= @category.description %>">
|
||||
<meta property="og:description" content="<%= @category.description %>">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="relative w-full px-6 md:px-10 py-12 md:py-16">
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-4xl md:text-5xl leading-tight font-620 tracking-tight text-n-slate-12 text-balance">
|
||||
<%= portal.header_text.presence || 'How can we help?' %>
|
||||
<%= portal.localized_value('header_text', @locale).presence || 'How can we help?' %>
|
||||
</h1>
|
||||
<p class="mt-4 text-lg leading-normal text-n-slate-11 text-pretty">
|
||||
<%= I18n.t('public_portal.hero.sub_title') %>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<% if portal.logo.present? %>
|
||||
<img src="<%= url_for(portal.logo) %>" class="w-8 h-8 rounded-full" draggable="false" />
|
||||
<% end %>
|
||||
<span class="text-base font-semibold tracking-tight text-n-slate-12 truncate"><%= portal.name %></span>
|
||||
<span class="text-base font-semibold tracking-tight text-n-slate-12 truncate"><%= portal.localized_value('name', locale) %></span>
|
||||
<span class="hidden sm:inline-block w-px h-5 bg-n-weak mx-1"></span>
|
||||
<span class="hidden sm:inline-block text-sm font-medium text-n-slate-11 truncate"><%= I18n.t('public_portal.sidebar.help_center') %></span>
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<title><%= article.title %> | <%= portal.display_title %></title>
|
||||
<title><%= article.title %> | <%= portal.display_title(article.locale) %></title>
|
||||
<% if article.meta["title"].present? %>
|
||||
<meta name="title" content="<%= article.meta["title"] %>">
|
||||
<meta property="og:title" content="<%= article.meta["title"] %>">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<title><%= category.name %> | <%= portal.display_title %></title>
|
||||
<meta name="title" content="<%= category.name %> | <%= portal.display_title %>">
|
||||
<title><%= category.name %> | <%= portal.display_title(category.locale) %></title>
|
||||
<meta name="title" content="<%= category.name %> | <%= portal.display_title(category.locale) %>">
|
||||
<% if category.description.present? %>
|
||||
<meta name="description" content="<%= category.description %>">
|
||||
<meta property="og:description" content="<%= category.description %>">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %></title>
|
||||
<% end %>
|
||||
|
||||
<div class="px-6 md:px-10 py-8 md:py-10">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %></title>
|
||||
<% end %>
|
||||
|
||||
<% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %>
|
||||
|
||||
@@ -32,6 +32,10 @@ class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
|
||||
def query_in_target_language?(query)
|
||||
detector = CLD3::NNetLanguageIdentifier.new(0, 1000)
|
||||
result = detector.find_language(query)
|
||||
|
||||
@@ -62,6 +62,10 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
|
||||
def event_name
|
||||
'captain.conversation_completion'
|
||||
end
|
||||
|
||||
@@ -150,13 +150,12 @@ class Captain::BaseTaskService
|
||||
end
|
||||
|
||||
# Extension point consulted by the Enterprise quota wrapper. Subclasses
|
||||
# whose calls run on the operator's key (e.g. internal/onboarding tasks)
|
||||
# should override this to return false. When false, the wrapper neither
|
||||
# blocks the call on an exhausted captain_responses quota nor decrements
|
||||
# it on success — the call participates in the quota system in neither
|
||||
# direction.
|
||||
# whose calls should not consume captain_responses should override this to
|
||||
# return false. When false, the wrapper neither blocks the call on an
|
||||
# exhausted captain_responses quota nor decrements it on success — the call
|
||||
# participates in the quota system in neither direction.
|
||||
def counts_toward_usage?
|
||||
true
|
||||
llm_credential&.dig(:source) != :hook
|
||||
end
|
||||
|
||||
def api_key_configured?
|
||||
@@ -168,7 +167,15 @@ class Captain::BaseTaskService
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= hook_llm_credential || system_llm_credential
|
||||
@llm_credential ||= if use_account_openai_hook?
|
||||
hook_llm_credential || system_llm_credential
|
||||
else
|
||||
system_llm_credential
|
||||
end
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
false
|
||||
end
|
||||
|
||||
def hook_llm_credential
|
||||
|
||||
@@ -63,4 +63,8 @@ class Captain::CsatUtilityAnalysisService < Captain::BaseTaskService
|
||||
def event_name
|
||||
'csat_utility_analysis'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,4 +103,8 @@ class Captain::FollowUpService < Captain::BaseTaskService
|
||||
def event_name
|
||||
'follow_up'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -87,6 +87,10 @@ class Captain::LabelSuggestionService < Captain::BaseTaskService
|
||||
'label_suggestion'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
|
||||
@@ -37,6 +37,10 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
|
||||
def event_name
|
||||
'reply_suggestion'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')
|
||||
|
||||
@@ -56,4 +56,8 @@ class Captain::RewriteService < Captain::BaseTaskService
|
||||
def event_name
|
||||
operation
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,4 +24,8 @@ class Captain::SummaryService < Captain::BaseTaskService
|
||||
def event_name
|
||||
'summarize'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,6 +44,12 @@ conversations:
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
contact_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "number"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
priority:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
|
||||
@@ -173,7 +173,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
],
|
||||
'default_locale' => 'en',
|
||||
'layout' => 'classic',
|
||||
'social_profiles' => {}
|
||||
'social_profiles' => {},
|
||||
'locale_translations' => {}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -11,6 +11,11 @@ describe ContactDrop do
|
||||
expect(subject.first_name).to eq 'John'
|
||||
end
|
||||
|
||||
it 'returns the single word (capitalized) as first name when name has only one word' do
|
||||
contact.update!(name: 'john')
|
||||
expect(subject.first_name).to eq 'John'
|
||||
end
|
||||
|
||||
it('return the capitalized name') do
|
||||
contact.update!(name: 'john doe')
|
||||
expect(subject.name).to eq 'John Doe'
|
||||
|
||||
@@ -11,6 +11,11 @@ describe UserDrop do
|
||||
expect(subject.first_name).to eq 'John'
|
||||
end
|
||||
|
||||
it 'returns the single word as first name when name has only one word' do
|
||||
user.update!(name: 'John')
|
||||
expect(subject.first_name).to eq 'John'
|
||||
end
|
||||
|
||||
it('return the capitalized first name') do
|
||||
user.update!(name: 'john doe')
|
||||
expect(subject.first_name).to eq 'John'
|
||||
|
||||
@@ -32,6 +32,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
end
|
||||
|
||||
context 'when usage limit is exceeded' do
|
||||
@@ -111,6 +112,68 @@ RSpec.describe Captain::BaseTaskService, type: :model do
|
||||
end.to change { account.custom_attributes['captain_responses_usage'].to_i }.by(1)
|
||||
end
|
||||
|
||||
context 'when account has its own OpenAI hook key' do
|
||||
before do
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
|
||||
end
|
||||
|
||||
it 'still increments usage for services that do not opt into BYOK' do
|
||||
expect(account).to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
|
||||
context 'when the captain_responses quota is exhausted on Cloud' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
allow(account).to receive(:usage_limits).and_return({
|
||||
captain: { responses: { current_available: 0 } }
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns usage limit exceeded error for services that do not opt into BYOK' do
|
||||
result = service.perform
|
||||
expect(result[:error]).to eq(I18n.t('captain.copilot_limit'))
|
||||
expect(result[:error_code]).to eq(429)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when subclass opts into account OpenAI hook usage' do
|
||||
let(:test_service_class) do
|
||||
result = perform_result
|
||||
klass = Class.new(described_class) do
|
||||
define_method(:perform) { result }
|
||||
define_method(:event_name) { 'test_event' }
|
||||
define_method(:use_account_openai_hook?) { true }
|
||||
end
|
||||
klass.prepend(Enterprise::Captain::BaseTaskService)
|
||||
klass
|
||||
end
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
|
||||
end
|
||||
|
||||
it 'does not increment usage on a successful result' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
|
||||
context 'when the captain_responses quota is exhausted on Cloud' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
allow(account).to receive(:usage_limits).and_return({
|
||||
captain: { responses: { current_available: 0 } }
|
||||
})
|
||||
end
|
||||
|
||||
it 'bypasses the 429 gate and returns the underlying result' do
|
||||
result = service.perform
|
||||
expect(result).to eq(perform_result)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain is disabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
|
||||
@@ -55,4 +55,57 @@ RSpec.describe MutexApplicationJob do
|
||||
end.to raise_error(StandardError)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.retry_on_lock_conflict' do
|
||||
let(:job_class) do
|
||||
Class.new(described_class) do
|
||||
retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock
|
||||
|
||||
attr_reader :fallback_args
|
||||
|
||||
def perform(lock_key, _payload)
|
||||
with_lock(lock_key) { raise 'lock should not be acquired' }
|
||||
end
|
||||
|
||||
def process_without_lock(lock_key, payload)
|
||||
@fallback_args = [lock_key, payload]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:payload) { { 'message' => 'hello' } }
|
||||
|
||||
before do
|
||||
stub_const('LockConflictTestJob', job_class)
|
||||
end
|
||||
|
||||
it 'runs the configured handler with the original job arguments when lock retries are exhausted' do
|
||||
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
|
||||
|
||||
job = job_class.new(lock_key, payload)
|
||||
|
||||
expect { job.perform_now }.not_to raise_error
|
||||
expect(job.fallback_args).to eq([lock_key, payload])
|
||||
end
|
||||
|
||||
context 'without an exhaustion handler' do
|
||||
let(:job_class) do
|
||||
Class.new(described_class) do
|
||||
retry_on_lock_conflict wait: 1.second, attempts: 1
|
||||
|
||||
def perform(lock_key)
|
||||
with_lock(lock_key) { raise 'lock should not be acquired' }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises the lock acquisition error when retries are exhausted' do
|
||||
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
|
||||
|
||||
expect do
|
||||
job_class.perform_now(lock_key)
|
||||
end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -260,11 +260,12 @@ RSpec.describe Captain::BaseTaskService do
|
||||
expect(result[:request_messages]).to eq(messages)
|
||||
end
|
||||
|
||||
it 'does not track exceptions for account hook failures' do
|
||||
it 'tracks exceptions against the system key when an account hook exists' do
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' })
|
||||
|
||||
expect(Llm::Config).to receive(:with_api_key).with('hook-key', api_base: anything).and_raise(error)
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_raise(error)
|
||||
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
|
||||
expect(exception_tracker).to receive(:capture_exception)
|
||||
|
||||
result = service.send(:make_api_call, model: model, messages: messages)
|
||||
|
||||
@@ -279,11 +280,60 @@ RSpec.describe Captain::BaseTaskService do
|
||||
|
||||
before { hook }
|
||||
|
||||
it 'uses system api key by default' do
|
||||
expect(service.send(:api_key)).to eq('test-key')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when subclass opts into account OpenAI hook usage' do
|
||||
let(:test_service_class) do
|
||||
Class.new(described_class) do
|
||||
def event_name
|
||||
'test_event'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
|
||||
end
|
||||
|
||||
it 'uses api key from hook' do
|
||||
expect(service.send(:api_key)).to eq('hook-key')
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses account OpenAI hook for editor task services' do
|
||||
create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
|
||||
user = create(:user, account: account)
|
||||
follow_up_context = {
|
||||
'event_name' => 'professional',
|
||||
'original_context' => 'Original text',
|
||||
'last_response' => 'Last response'
|
||||
}
|
||||
|
||||
editor_services = [
|
||||
Captain::RewriteService.new(account: account, content: 'Text', operation: 'improve', conversation_display_id: conversation.display_id),
|
||||
Captain::SummaryService.new(account: account, conversation_display_id: conversation.display_id),
|
||||
Captain::ReplySuggestionService.new(account: account, conversation_display_id: conversation.display_id, user: user),
|
||||
Captain::LabelSuggestionService.new(account: account, conversation_display_id: conversation.display_id),
|
||||
Captain::FollowUpService.new(
|
||||
account: account,
|
||||
follow_up_context: follow_up_context,
|
||||
user_message: 'Make it shorter',
|
||||
conversation_display_id: conversation.display_id
|
||||
)
|
||||
]
|
||||
|
||||
editor_services.each do |editor_service|
|
||||
expect(editor_service.send(:api_key)).to eq('hook-key')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when openai hook is not configured' do
|
||||
it 'uses system api key' do
|
||||
expect(service.send(:api_key)).to eq('test-key')
|
||||
|
||||
@@ -4,6 +4,11 @@ RSpec.describe Captain::CsatUtilityAnalysisService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account, message: 'Test message', language: 'en', baseline: {}) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
@@ -21,4 +26,22 @@ RSpec.describe Captain::CsatUtilityAnalysisService do
|
||||
expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#api_key' do
|
||||
context 'when account has an OpenAI hook key' do
|
||||
before do
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
|
||||
end
|
||||
|
||||
it 'uses the account hook key' do
|
||||
expect(service.send(:api_key)).to eq('customer-own-key')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account does not have an OpenAI hook key' do
|
||||
it 'uses the system key' do
|
||||
expect(service.send(:api_key)).to eq('test-key')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -99,6 +99,33 @@ RSpec.describe Imap::ImapMailbox do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a new email contains null bytes' do
|
||||
let(:inbound_mail) do
|
||||
Mail.new.tap do |mail|
|
||||
mail.from = 'email@gmail.com'
|
||||
mail.to = 'imap@gmail.com'
|
||||
mail.subject = "Hello\u0000"
|
||||
mail.message_id = "message\u0000@example.com"
|
||||
mail['In-Reply-To'] = "source\u0000@example.com"
|
||||
mail.references = ["reference\u0000@example.com"]
|
||||
mail.content_type = 'text/plain'
|
||||
mail.body = "Body\u0000 text"
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates sanitized conversation and message records' do
|
||||
expect { class_instance.process(inbound_mail, channel) }.to change(Conversation, :count).by(1)
|
||||
|
||||
message = conversation.messages.last
|
||||
|
||||
expect(conversation.additional_attributes['in_reply_to']).to eq('source@example.com')
|
||||
expect(conversation.additional_attributes['mail_subject']).to eq('Hello')
|
||||
expect(message.source_id).to eq('message@example.com')
|
||||
expect(message.content).to eq('Body text')
|
||||
expect(message.content_attributes.to_json).not_to include('\u0000')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a new email with invalid from' do
|
||||
let(:inbound_mail) { create_inbound_email_from_mail(from: 'invalidemail', to: 'imap@gmail.com', subject: 'Hello!') }
|
||||
|
||||
|
||||
@@ -47,6 +47,31 @@ RSpec.describe MailboxHelper do
|
||||
helper_instance.send(:create_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message data contains null bytes' do
|
||||
let(:mail) do
|
||||
mail = Mail.new
|
||||
mail.from = 'Sender <sender@example.com>'
|
||||
mail.to = 'Inbox <inbox@example.com>'
|
||||
mail.subject = "Hello\u0000"
|
||||
mail.message_id = "message\u0000@example.com"
|
||||
mail.content_type = 'text/plain'
|
||||
mail.body = "Body\u0000 text"
|
||||
mail
|
||||
end
|
||||
|
||||
it 'creates the message with sanitized values' do
|
||||
helper_instance = mailbox_helper_obj.new(conversation, processed_mail)
|
||||
|
||||
expect { helper_instance.send(:create_message) }.to change(conversation.messages, :count).by(1)
|
||||
|
||||
message = conversation.messages.last
|
||||
expect(message.source_id).to eq('message@example.com')
|
||||
expect(message.content).to eq('Body text')
|
||||
expect(message.content_attributes.dig('email', 'message_id')).to eq('message@example.com')
|
||||
expect(message.content_attributes.to_json).not_to include('\u0000')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#embed_plain_text_email_with_inline_image' do
|
||||
|
||||
@@ -67,6 +67,40 @@ RSpec.describe ReplyMailbox do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when new conversation email contains null bytes' do
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:null_byte_mail) { create_inbound_email_from_mail(from: 'sender@example.com', to: email_channel.email, subject: 'Hello') }
|
||||
let(:mail_with_null_bytes) do
|
||||
Mail.new.tap do |mail|
|
||||
mail.from = 'sender@example.com'
|
||||
mail.to = email_channel.email
|
||||
mail.subject = "Hello\u0000"
|
||||
mail.message_id = "message\u0000@example.com"
|
||||
mail['In-Reply-To'] = "source\u0000@example.com"
|
||||
mail.references = ["reference\u0000@example.com"]
|
||||
mail.content_type = 'text/plain'
|
||||
mail.body = "Body\u0000 text"
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
allow(null_byte_mail).to receive(:mail).and_return(mail_with_null_bytes)
|
||||
end
|
||||
|
||||
it 'creates sanitized conversation and message records' do
|
||||
expect { described_class.receive null_byte_mail }.to change(Conversation, :count).by(1)
|
||||
|
||||
conversation = Conversation.last
|
||||
message = conversation.messages.last
|
||||
|
||||
expect(conversation.additional_attributes['in_reply_to']).to eq('source@example.com')
|
||||
expect(conversation.additional_attributes['mail_subject']).to eq('Hello')
|
||||
expect(message.source_id).to eq('message@example.com')
|
||||
expect(message.content).to eq('Body text')
|
||||
expect(message.content_attributes.to_json).not_to include('\u0000')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with inline attachments' do
|
||||
let(:mail_with_inline_images) { create_inbound_email_from_fixture('mail_with_inline_images.eml') }
|
||||
let(:described_subject) { described_class.receive mail_with_inline_images }
|
||||
|
||||
@@ -60,6 +60,94 @@ RSpec.describe Portal do
|
||||
portal.update(custom_domain: '')
|
||||
expect(portal.custom_domain).to be_nil
|
||||
end
|
||||
|
||||
context 'with locale_translations' do
|
||||
it 'allows valid locale translations' do
|
||||
portal.update(config: { allowed_locales: %w[en es], default_locale: 'en',
|
||||
locale_translations: { 'es' => { 'name' => 'Centro', 'page_title' => 'Título', 'header_text' => 'Hola' } } })
|
||||
|
||||
expect(portal).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects unknown fields within a locale translation' do
|
||||
portal.update(config: { allowed_locales: %w[en es], default_locale: 'en',
|
||||
locale_translations: { 'es' => { 'tagline' => 'nope' } } })
|
||||
|
||||
expect(portal).not_to be_valid
|
||||
end
|
||||
|
||||
it 'retains a locale override after it becomes the default so it can still be edited' do
|
||||
portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en',
|
||||
locale_translations: { 'es' => { 'name' => 'Centro' } } })
|
||||
|
||||
portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' })
|
||||
|
||||
expect(portal.config['locale_translations']).to eq({ 'es' => { 'name' => 'Centro' } })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#localized_value' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:portal) do
|
||||
create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme',
|
||||
config: { allowed_locales: %w[en es], default_locale: 'en',
|
||||
locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } })
|
||||
end
|
||||
|
||||
it 'returns the override for the requested locale' do
|
||||
expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda')
|
||||
end
|
||||
|
||||
it 'falls back to the base column when the locale has no override for the field' do
|
||||
expect(portal.localized_value('page_title', 'es')).to eq('Help Center | Acme')
|
||||
end
|
||||
|
||||
it 'falls back to the base column when the locale has no overrides at all' do
|
||||
expect(portal.localized_value('name', 'fr')).to eq('Help Center')
|
||||
end
|
||||
|
||||
it 'keeps serving the override for a locale that has become the default' do
|
||||
portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' })
|
||||
|
||||
expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda')
|
||||
end
|
||||
|
||||
it "inherits the default locale's override for a locale without its own" do
|
||||
portal.update!(config: { allowed_locales: %w[en es fr], default_locale: 'es' })
|
||||
|
||||
expect(portal.localized_value('name', 'fr')).to eq('Centro de ayuda')
|
||||
end
|
||||
|
||||
it 'uses the default locale when no locale is given' do
|
||||
expect(portal.localized_value('name')).to eq('Help Center')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#display_title' do
|
||||
let!(:account) { create(:account) }
|
||||
|
||||
it 'prefers the localized page_title' do
|
||||
portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme',
|
||||
config: { allowed_locales: %w[en es], default_locale: 'en',
|
||||
locale_translations: { 'es' => { 'page_title' => 'Centro | Acme' } } })
|
||||
|
||||
expect(portal.display_title('es')).to eq('Centro | Acme')
|
||||
end
|
||||
|
||||
it 'falls back to the localized name when no page_title is set' do
|
||||
portal = create(:portal, account_id: account.id, name: 'Help Center',
|
||||
config: { allowed_locales: %w[en es], default_locale: 'en',
|
||||
locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } })
|
||||
|
||||
expect(portal.display_title('es')).to eq('Centro de ayuda')
|
||||
end
|
||||
|
||||
it 'uses the base values for the default locale' do
|
||||
portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme')
|
||||
|
||||
expect(portal.display_title).to eq('Help Center | Acme')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -72,8 +72,8 @@ describe Conversations::FilterService do
|
||||
it 'filter conversations by additional_attributes and status' do
|
||||
params[:payload] = payload
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
|
||||
expect(result[:count][:all_count]).to be conversations.count
|
||||
conversations = account.conversations.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
|
||||
expect(result[:count][:all_count]).to eq conversations.count
|
||||
end
|
||||
|
||||
it 'filter conversations by priority' do
|
||||
@@ -133,12 +133,84 @@ describe Conversations::FilterService do
|
||||
expect(result[:conversations].pluck(:id)).to include(low_priority.id, medium_priority.id)
|
||||
end
|
||||
|
||||
it 'filters conversations by contact' do
|
||||
account.conversations.destroy_all
|
||||
|
||||
contact = create(:contact, :with_email, account: account)
|
||||
other_contact = create(:contact, :with_email, account: account)
|
||||
matching_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: other_contact)
|
||||
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [contact.id],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
|
||||
expect(result[:count][:all_count]).to eq 1
|
||||
expect(result[:conversations].pluck(:id)).to contain_exactly(matching_conversation.id)
|
||||
end
|
||||
|
||||
it 'filters conversations using not_equal_to contact operator' do
|
||||
account.conversations.destroy_all
|
||||
|
||||
contact = create(:contact, :with_email, account: account)
|
||||
other_contact = create(:contact, :with_email, account: account)
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
|
||||
other_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: other_contact)
|
||||
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: [contact.id],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
|
||||
expect(result[:count][:all_count]).to eq 1
|
||||
expect(result[:conversations].pluck(:id)).to contain_exactly(other_conversation.id)
|
||||
end
|
||||
|
||||
it 'applies inbox permissions when filtering conversations by contact' do
|
||||
account.conversations.destroy_all
|
||||
|
||||
contact = create(:contact, :with_email, account: account)
|
||||
restricted_inbox = create(:inbox, account: account)
|
||||
accessible_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
|
||||
create(:conversation, account: account, inbox: restricted_inbox, contact: contact)
|
||||
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'contact_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: [contact.id],
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
|
||||
expect(result[:count][:all_count]).to eq 1
|
||||
expect(result[:conversations].pluck(:id)).to contain_exactly(accessible_conversation.id)
|
||||
end
|
||||
|
||||
it 'filter conversations by additional_attributes and status with pagination' do
|
||||
params[:payload] = payload
|
||||
params[:page] = 2
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
|
||||
expect(result[:count][:all_count]).to be conversations.count
|
||||
conversations = account.conversations.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
|
||||
expect(result[:count][:all_count]).to eq conversations.count
|
||||
end
|
||||
|
||||
it 'filters items with contains filter_operator with values being an array' do
|
||||
@@ -184,10 +256,10 @@ describe Conversations::FilterService do
|
||||
custom_attribute_type: 'conversation_attribute' }.with_indifferent_access]
|
||||
params[:payload] = payload
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
conversations = Conversation.where(
|
||||
conversations = account.conversations.where(
|
||||
"custom_attributes ->> 'conversation_type' NOT IN (?) OR custom_attributes ->> 'conversation_type' IS NULL", ['platinum']
|
||||
)
|
||||
expect(result[:count][:all_count]).to be conversations.count
|
||||
expect(result[:count][:all_count]).to eq conversations.count
|
||||
end
|
||||
|
||||
it 'filter conversations by tags' do
|
||||
@@ -413,8 +485,8 @@ describe Conversations::FilterService do
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
expected_count = account.conversations.where('created_at > ?', DateTime.parse('2022-01-20')).count
|
||||
expect(result[:conversations].length).to eq expected_count
|
||||
end
|
||||
|
||||
it 'binds created_at comparison values as dates' do
|
||||
@@ -470,10 +542,10 @@ describe Conversations::FilterService do
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expected_count = Conversation.where("created_at > ? AND custom_attributes->>'conversation_type' = ?", DateTime.parse('2022-01-20'),
|
||||
'platinum').count
|
||||
expected_count = account.conversations.where("created_at > ? AND custom_attributes->>'conversation_type' = ?",
|
||||
DateTime.parse('2022-01-20'), 'platinum').count
|
||||
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
expect(result[:conversations].length).to eq expected_count
|
||||
end
|
||||
|
||||
context 'with x_days_before filter' do
|
||||
@@ -502,11 +574,11 @@ describe Conversations::FilterService do
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expected_count = Conversation.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?", (Time.zone.today - 3.days),
|
||||
'platinum').count
|
||||
expected_count = account.conversations.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?",
|
||||
(Time.zone.today - 3.days), 'platinum').count
|
||||
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
expect(result[:conversations].length).to eq expected_count
|
||||
end
|
||||
|
||||
it 'filter by last_activity_at 2_days_before' do
|
||||
@@ -520,10 +592,10 @@ describe Conversations::FilterService do
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expected_count = Conversation.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
|
||||
expected_count = account.conversations.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
|
||||
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
expect(result[:conversations].length).to eq expected_count
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -548,10 +620,10 @@ describe Conversations::FilterService do
|
||||
}.with_indifferent_access
|
||||
]
|
||||
result = filter_service.new(params, user_1, account).perform
|
||||
expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
|
||||
expected_count = account.conversations.where('created_at > ?', DateTime.parse('2022-01-20')).count
|
||||
|
||||
expect(Current.account).to be_nil
|
||||
expect(result[:conversations].length).to be expected_count
|
||||
expect(result[:conversations].length).to eq expected_count
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -73,8 +73,7 @@ describe Facebook::SendOnFacebookService do
|
||||
expect(bot).to have_received(:deliver).with({
|
||||
recipient: { id: contact_inbox.source_id },
|
||||
message: { text: message.content },
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: 'ACCOUNT_UPDATE'
|
||||
messaging_type: 'RESPONSE'
|
||||
}, { page_id: facebook_channel.page_id })
|
||||
expect(bot).to have_received(:deliver).with({
|
||||
recipient: { id: contact_inbox.source_id },
|
||||
@@ -86,17 +85,26 @@ describe Facebook::SendOnFacebookService do
|
||||
}
|
||||
}
|
||||
},
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: 'ACCOUNT_UPDATE'
|
||||
messaging_type: 'RESPONSE'
|
||||
}, { page_id: facebook_channel.page_id })
|
||||
end
|
||||
|
||||
it 'sends as a standard RESPONSE without a tag by default' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
|
||||
described_class.new(message: message).perform
|
||||
expect(bot).to have_received(:deliver).with(
|
||||
hash_including(messaging_type: 'RESPONSE'),
|
||||
{ page_id: facebook_channel.page_id }
|
||||
)
|
||||
expect(bot).not_to have_received(:deliver).with(hash_including(:tag), anything)
|
||||
end
|
||||
|
||||
it 'sends with HUMAN_AGENT tag when ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT is enabled' do
|
||||
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
|
||||
message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
|
||||
described_class.new(message: message).perform
|
||||
expect(bot).to have_received(:deliver).with(
|
||||
hash_including(tag: 'HUMAN_AGENT'),
|
||||
hash_including(messaging_type: 'MESSAGE_TAG', tag: 'HUMAN_AGENT'),
|
||||
{ page_id: facebook_channel.page_id }
|
||||
)
|
||||
end
|
||||
@@ -201,8 +209,7 @@ describe Facebook::SendOnFacebookService do
|
||||
{ content_type: 'text', payload: 'text 2', title: 'text 2' }
|
||||
]
|
||||
},
|
||||
messaging_type: 'MESSAGE_TAG',
|
||||
tag: 'ACCOUNT_UPDATE'
|
||||
messaging_type: 'RESPONSE'
|
||||
}, { page_id: facebook_channel.page_id })
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user