Compare commits

..
176 changed files with 447 additions and 10385 deletions
+3 -6
View File
@@ -77,8 +77,7 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -119,8 +118,7 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -151,8 +149,7 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -2,5 +2,12 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
private
def validate_token_api_access
render_unauthorized('Invalid Access Token') unless Current.account.feature_enabled?('api_and_webhooks')
end
end
@@ -1,159 +0,0 @@
require 'csv'
class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
DATA_IMPORT_FEATURE = 'data_import'.freeze
before_action :ensure_data_import_feature_enabled
before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs]
before_action :check_authorization
def index
@data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
data_import_ids = @data_imports.map(&:id)
@import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
@skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
end
def show
render_show
end
def validate_source
totals = validate_intercom_source
render json: { valid: true, totals: totals }
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
end
def create
@data_import = creation_service.perform
unless @data_import
render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
return
end
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
render_show
rescue DataImports::Intercom::Client::AuthenticationError
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
rescue DataImports::Intercom::Client::Error
render_source_validation_error('Intercom could not be reached. Please try again.')
rescue ArgumentError => e
render_source_validation_error(e.message)
end
def start
restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import)
restart_result = restart_service.perform
@data_import = restart_service.data_import
if restart_result == :access_token_missing
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
return
end
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue
render_show
end
def abandon
@data_import.abandon!
render_show
end
def skip_logs
send_data(
skip_logs_csv,
filename: "data-import-#{@data_import.id}-skip-logs.csv",
type: 'text/csv'
)
end
def error_logs
send_data(
error_logs_csv,
filename: "data-import-#{@data_import.id}-error-logs.csv",
type: 'text/csv'
)
end
private
def ensure_data_import_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
end
def set_data_import
@data_import = Current.account.data_imports.find(params[:id])
end
def check_authorization
authorize(@data_import || DataImport)
end
def permitted_params
params.permit(:name, :source_provider, :access_token, import_types: [])
end
def creation_service
DataImports::Intercom::CreationService.new(
account: Current.account,
initiated_by: Current.user,
source_params: permitted_params.to_h
)
end
def import_types
return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
Array(permitted_params[:import_types]).compact_blank
end
def validate_intercom_source
raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom'
DataImports::Intercom::CredentialsValidator.new(
access_token: permitted_params[:access_token],
import_types: import_types
).perform
end
def render_source_validation_error(message)
render json: { valid: false, message: message }, status: :unprocessable_entity
end
def render_show
@import_errors_finder = DataImportErrorFinder.new(@data_import)
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
render :show
end
def skip_logs_csv
logs_csv(@data_import.import_errors.skip_logs)
end
def error_logs_csv
logs_csv(@data_import.import_errors.non_skip_logs)
end
def logs_csv(logs)
CSV.generate(headers: true) do |csv|
csv << %w[created_at kind source_object_type source_object_id error_code message details]
logs.order(:created_at).find_each do |log|
csv << [
log.created_at.iso8601,
log.details['kind'],
log.source_object_type,
log.source_object_id,
log.error_code,
log.message,
log.details.to_json
]
end
end
end
end
@@ -124,8 +124,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def reauthorize_and_update_channel(channel_attributes)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
end
def update_channel_feature_flags
@@ -1,4 +1,5 @@
class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
before_action :ensure_api_and_webhooks_enabled
before_action :check_authorization
before_action :fetch_webhook, only: [:update, :destroy]
@@ -22,6 +23,10 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private
def ensure_api_and_webhooks_enabled
render_unauthorized('You are not authorized to do this action') unless Current.account.feature_enabled?('api_and_webhooks')
end
def webhook_params
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
end
@@ -59,10 +59,6 @@ class Api::V1::Widget::BaseController < ApplicationController
permitted_params.dig(:contact, :phone_number)
end
def contact_custom_attributes
permitted_params.dig(:contact, :custom_attributes)&.to_h
end
def browser_params
{
browser_name: browser.name,
@@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def process_update_contact
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes },
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
retain_original_contact_name: true,
discard_invalid_attrs: true
).perform
@@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end
-11
View File
@@ -1,11 +0,0 @@
class DataImportErrorFinder
RESULTS_LIMIT = 5
def initialize(data_import)
@data_import = data_import
end
def import_errors
@data_import.import_errors.non_skip_logs.order(created_at: :desc).limit(RESULTS_LIMIT)
end
end
@@ -1,35 +0,0 @@
class DataImportSkipLogFinder
RESULTS_LIMIT = 5
SOURCE_OBJECT_TYPES = %w[contact conversation message].freeze
attr_reader :selected_source_object_type
def initialize(data_import, params = {})
@data_import = data_import
@selected_source_object_type = valid_source_object_type(params[:skip_logs_type])
end
def skip_logs
filtered_scope.order(created_at: :desc).limit(RESULTS_LIMIT)
end
def counts_by_type
base_scope.group(:source_object_type).count
end
private
def base_scope
@base_scope ||= @data_import.import_errors.skip_logs
end
def filtered_scope
return base_scope if selected_source_object_type.blank?
base_scope.where(source_object_type: selected_source_object_type)
end
def valid_source_object_type(source_object_type)
source_object_type if SOURCE_OBJECT_TYPES.include?(source_object_type)
end
end
@@ -1,39 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class DataImportsAPI extends ApiClient {
constructor() {
super('data_imports', { accountScoped: true });
}
start(id) {
return axios.post(`${this.url}/${id}/start`);
}
abandon(id) {
return axios.post(`${this.url}/${id}/abandon`);
}
show(id, params = {}) {
return axios.get(`${this.url}/${id}`, { params });
}
validateSource(payload) {
return axios.post(`${this.url}/validate_source`, payload);
}
downloadSkipLogs(id) {
return axios.get(`${this.url}/${id}/skip_logs.csv`, {
responseType: 'blob',
});
}
downloadErrorLogs(id) {
return axios.get(`${this.url}/${id}/error_logs.csv`, {
responseType: 'blob',
});
}
}
export default new DataImportsAPI();
@@ -61,10 +61,10 @@ const triggerAction = () => {
<div>
<slot />
</div>
<div class="flex-shrink-0">
<div>
<button
v-if="actionLabel"
class="px-3 py-1 w-auto grid place-content-center rounded-lg whitespace-nowrap"
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
:class="buttonClass"
@click="triggerAction"
>
@@ -107,7 +107,6 @@ const onClickCancel = () => {
<TextArea
v-model="state.description"
:max-length="500"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
@@ -66,10 +66,6 @@ const props = defineProps({
type: Number,
default: null,
},
responsesCount: {
type: Number,
default: 0,
},
isSelected: {
type: Boolean,
default: false,
@@ -116,10 +112,10 @@ const showSyncStatus = computed(() => !isPdf.value);
const menuItems = computed(() => {
const allOptions = [
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'),
value: 'viewDetails',
action: 'viewDetails',
icon: 'i-lucide-eye',
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
value: 'viewRelatedQuestions',
action: 'viewRelatedQuestions',
icon: 'i-ph-tree-view-duotone',
},
];
@@ -147,9 +143,6 @@ const menuItems = computed(() => {
});
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
const responsesCountLabel = computed(() =>
t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount })
);
const displayLink = computed(() =>
isPdf.value
@@ -165,10 +158,6 @@ const handleAction = ({ action, value }) => {
emit('action', { action, value, id: props.id });
};
const handleViewDetails = () => {
emit('action', { action: 'viewDetails', id: props.id });
};
const handleRetry = () => {
emit('action', { action: 'sync', id: props.id });
};
@@ -188,13 +177,9 @@ const handleRetry = () => {
<Checkbox v-model="modelValue" />
</div>
<div class="flex gap-1 justify-between w-full">
<button
type="button"
class="p-0 text-base text-left bg-transparent border-0 outline-transparent text-n-slate-12 line-clamp-1 underline-offset-2 hover:underline focus-visible:underline"
@click="handleViewDetails"
>
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</button>
</span>
<div v-if="showMenu" class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
@@ -243,9 +228,6 @@ const handleRetry = () => {
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<span class="text-sm shrink-0 text-n-slate-11">
{{ responsesCountLabel }}
</span>
<DocumentSyncStatus
v-if="showSyncStatus"
:status="syncStatus"
@@ -213,7 +213,6 @@ const renderInstruction = instruction => () =>
<TextArea
v-model="state.description"
:max-length="500"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
@@ -122,7 +122,6 @@ watch(
<Editor
v-model="state.description"
:max-length="500"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
@@ -117,7 +117,6 @@ watch(
<Editor
v-model="state.description"
:max-length="500"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
@@ -1,86 +0,0 @@
import { flushPromises, shallowMount } from '@vue/test-utils';
import DocumentDetails from './DocumentDetails.vue';
const { dispatch, getterValues } = vi.hoisted(() => ({
dispatch: vi.fn(),
getterValues: {
'captainResponses/getUIFlags': { value: { fetchingList: false } },
'captainResponses/getRecords': { value: [] },
'captainResponses/getMeta': { value: { totalCount: 26, page: 1 } },
},
}));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
useMapGetter: key => getterValues[key],
}));
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
const captainDocument = {
id: 42,
name: 'FAQ source',
external_link: 'https://example.com/docs',
assistant: { id: 7 },
content: 'Document content',
pdf_document: false,
};
const DialogStub = {
name: 'Dialog',
template: '<div><slot /></div>',
};
const TabBarStub = {
name: 'TabBar',
template:
'<button data-test="faq-tab" @click="$emit(\'tabChanged\', { key: \'faqs\' })" />',
};
const PaginationFooterStub = {
name: 'PaginationFooter',
template:
'<button data-test="next-page" @click="$emit(\'update:currentPage\', 2)" />',
};
describe('DocumentDetails', () => {
beforeEach(() => {
vi.clearAllMocks();
dispatch.mockResolvedValue([]);
});
it('requests another FAQ page when the document has more than 25 FAQs', async () => {
const wrapper = shallowMount(DocumentDetails, {
props: { captainDocument },
global: {
directives: { dompurifyHtml: {} },
stubs: {
Dialog: DialogStub,
TabBar: TabBarStub,
PaginationFooter: PaginationFooterStub,
},
},
});
await flushPromises();
expect(dispatch).toHaveBeenCalledWith('captainResponses/get', {
page: 1,
assistantId: 7,
documentId: 42,
});
await wrapper.get('[data-test="faq-tab"]').trigger('click');
await wrapper.get('[data-test="next-page"]').trigger('click');
expect(dispatch).toHaveBeenLastCalledWith('captainResponses/get', {
page: 2,
assistantId: 7,
documentId: 42,
});
});
});
@@ -1,374 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import {
isSafeHttpLink,
formatDocumentLink,
getDocumentDisplayPath,
} from 'shared/helpers/documentHelper';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
captainDocument: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const TAB_KEYS = {
CONTENT: 'content',
FAQS: 'faqs',
};
const RESPONSES_PER_PAGE = 25;
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const documentDetails = computed(() => props.captainDocument);
const showRawContent = ref(false);
const activeTabIndex = ref(0);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const currentPage = computed(() => meta.value.page || 1);
const showPaginationFooter = computed(
() => totalCount.value > RESPONSES_PER_PAGE
);
const documentContent = computed(() => documentDetails.value?.content?.trim());
const documentContentLength = computed(
() => documentContent.value?.length || 0
);
const isPdf = computed(() => documentDetails.value?.pdf_document);
const displayUrl = computed(() => documentDetails.value?.display_url);
const externalLink = computed(() => documentDetails.value?.external_link);
const sourceHref = computed(() => displayUrl.value || externalLink.value);
const hasSafeLink = computed(() => isSafeHttpLink(sourceHref.value));
const displayLink = computed(() => {
if (isPdf.value) return formatDocumentLink(externalLink.value);
return getDocumentDisplayPath(displayUrl.value || externalLink.value);
});
const contentTabLabel = computed(() =>
isPdf.value
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TAB')
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TAB')
);
const tabs = computed(() => [
{ key: TAB_KEYS.CONTENT, label: contentTabLabel.value },
{
key: TAB_KEYS.FAQS,
label: t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE'),
count: totalCount.value,
},
]);
const activeTabKey = computed(() => tabs.value[activeTabIndex.value]?.key);
const isUnreadableContent = computed(() => {
if (!documentContent.value) return false;
const content = documentContent.value;
const sample = content.slice(0, 2000);
const characters = Array.from(sample);
const nonPrintableCharacters = characters.filter(character => {
const charCode = character.charCodeAt(0);
return (
(charCode <= 31 && ![9, 10, 13].includes(charCode)) ||
(charCode >= 127 && charCode <= 159)
);
});
const nonPrintableRatio =
nonPrintableCharacters.length / Math.max(characters.length, 1);
const replacementCharacterRatio =
characters.filter(character => character === '\uFFFD').length /
Math.max(characters.length, 1);
const hasPdfObjectMarkers =
content.includes(' obj') &&
content.includes(' endobj') &&
content.includes(' stream');
return (
content.startsWith('%PDF') ||
hasPdfObjectMarkers ||
nonPrintableRatio > 0.02 ||
replacementCharacterRatio > 0.05
);
});
const formattedDocumentContent = computed(() => {
if (!documentContent.value || isUnreadableContent.value) return '';
const formatter = new MessageFormatter(documentContent.value);
formatter.disableImageRendering();
return formatter.formattedMessage;
});
const updatedAtLabel = computed(() => {
if (!documentDetails.value?.updated_at) return null;
return messageTimestamp(
documentDetails.value.updated_at,
'MMM d, yyyy h:mm a'
);
});
const syncedAtLabel = computed(() => {
if (!documentDetails.value?.last_synced_at) return null;
return messageTimestamp(
documentDetails.value.last_synced_at,
'MMM d, yyyy h:mm a'
);
});
const handleClose = () => {
emit('close');
};
const handleCopyContent = async () => {
try {
await copyTextToClipboard(documentContent.value);
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_SUCCESS'));
} catch {
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_ERROR'));
}
};
const handleTabChanged = tab => {
activeTabIndex.value = tabs.value.findIndex(item => item.key === tab.key);
};
const fetchResponses = (page = 1) => {
return store.dispatch('captainResponses/get', {
page,
assistantId: props.captainDocument.assistant.id,
documentId: props.captainDocument.id,
});
};
const handlePageChange = page => {
fetchResponses(page);
};
onMounted(() => {
fetchResponses();
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="documentDetails.name || documentDetails.external_link"
:description="t('CAPTAIN.DOCUMENTS.DETAILS.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-6 min-h-48">
<section class="flex flex-col gap-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.SOURCE') }}
</span>
<a
v-if="hasSafeLink"
:href="sourceHref"
:title="sourceHref"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center min-w-0 gap-1 text-sm text-n-slate-12 hover:underline"
>
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</a>
<span v-else class="text-sm truncate text-n-slate-12">
{{ displayLink }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.GENERATED_FAQS') }}
</span>
<span class="text-sm text-n-slate-12">
{{ totalCount }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.LAST_UPDATED') }}
</span>
<span class="text-sm text-n-slate-12">
{{
syncedAtLabel ||
updatedAtLabel ||
t('CAPTAIN.DOCUMENTS.DETAILS.NOT_AVAILABLE')
}}
</span>
</div>
</div>
</section>
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="handleTabChanged"
/>
<div class="h-[32rem] overflow-y-auto">
<section
v-if="activeTabKey === TAB_KEYS.CONTENT"
class="flex flex-col gap-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex flex-col gap-1">
<h4 class="text-sm font-medium text-n-slate-12">
{{
isPdf
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TITLE')
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TITLE')
}}
</h4>
<span
v-if="documentContent && !isPdf"
class="text-xs text-n-slate-10"
>
{{
t('CAPTAIN.DOCUMENTS.DETAILS.CHARACTER_COUNT', {
count: documentContentLength.toLocaleString(),
})
}}
</span>
</div>
<div
v-if="documentContent && !isPdf"
class="flex flex-wrap items-center justify-end gap-4"
>
<Button
:label="
showRawContent
? t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_PREVIEW')
: t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_RAW')
"
sm
slate
link
@click="showRawContent = !showRawContent"
/>
<Button
:label="t('CAPTAIN.DOCUMENTS.DETAILS.COPY_CONTENT')"
icon="i-lucide-copy"
sm
slate
link
@click="handleCopyContent"
/>
</div>
</div>
<div
v-if="isPdf"
class="rounded-lg border border-n-weak bg-n-alpha-1 p-4 text-sm text-n-slate-11"
>
<p class="mb-3">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.PDF_DESCRIPTION') }}
</p>
<a
v-if="hasSafeLink"
:href="sourceHref"
:title="sourceHref"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-medium text-n-blue-11 hover:underline"
>
<Icon icon="i-ph-file-pdf" class="size-4" />
{{ displayLink }}
<Icon icon="i-lucide-external-link" class="size-3" />
</a>
<span v-else class="inline-flex items-center gap-1 text-n-slate-12">
<Icon icon="i-ph-file-pdf" class="size-4" />
{{ displayLink }}
</span>
</div>
<template v-else-if="documentContent">
<div
v-if="isUnreadableContent && !showRawContent"
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.DETAILS.UNREADABLE_CONTENT') }}
</div>
<div
v-else
class="h-[26rem] overflow-y-auto rounded-lg border border-n-weak bg-n-alpha-1 p-4"
>
<pre
v-if="showRawContent || isUnreadableContent"
class="m-0 whitespace-pre-wrap break-words text-xs leading-5 text-n-slate-12"
><code>{{ documentContent }}</code></pre>
<div
v-else
v-dompurify-html="formattedDocumentContent"
class="prose prose-sm max-w-none break-words text-n-slate-12 prose-p:my-2 prose-headings:mb-2 prose-headings:mt-4 prose-a:text-n-blue-11 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 prose-img:hidden"
/>
</div>
</template>
<div
v-else
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.DETAILS.EMPTY_CONTENT') }}
</div>
</section>
<section
v-if="activeTabKey === TAB_KEYS.FAQS"
class="flex flex-col gap-3"
>
<div v-if="responses.length" class="flex flex-col gap-3">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:status="response.status"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
compact
/>
</div>
<div
v-else
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.EMPTY') }}
</div>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="RESPONSES_PER_PAGE"
class="!px-0"
@update:current-page="handlePageChange"
/>
</footer>
</section>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,71 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
captainDocument: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const handleClose = () => {
emit('close');
};
onMounted(() => {
store.dispatch('captainResponses/get', {
assistantId: props.captainDocument.assistant.id,
documentId: props.captainDocument.id,
});
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-3 min-h-48">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:status="response.status"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
compact
/>
</div>
</Dialog>
</template>
@@ -85,13 +85,6 @@ const hasFilteredUnreadCounts = computed(() => {
);
});
const hasDataImport = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.DATA_IMPORT
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
@@ -863,16 +856,6 @@ const menuItems = computed(() => {
icon: 'i-lucide-blocks',
to: accountScopedRoute('settings_applications'),
},
...(hasDataImport.value
? [
{
name: 'Settings Data',
label: t('SIDEBAR.DATA'),
icon: 'i-lucide-database',
to: accountScopedRoute('settings_data_imports'),
},
]
: []),
{
name: 'Settings Audit Logs',
label: t('SIDEBAR.AUDIT_LOGS'),
@@ -44,12 +44,6 @@ const showChatSupport = computed(() => {
);
});
const toggleChatSupport = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
const menuItems = computed(() => {
return [
{
@@ -57,7 +51,9 @@ const menuItems = computed(() => {
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy',
click: toggleChatSupport,
click: () => {
window.$chatwoot.toggle();
},
},
{
show: true,
@@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
const { modalType, closeOnBackdropClick, onClose } = defineProps({
closeOnBackdropClick: { type: Boolean, default: true },
showCloseButton: { type: Boolean, default: true },
onClose: { type: Function, default: null },
onClose: { type: Function, required: true },
fullWidth: { type: Boolean, default: false },
modalType: { type: String, default: 'centered' },
size: { type: String, default: '' },
@@ -35,7 +35,7 @@ const handleMouseDown = () => {
const close = () => {
show.value = false;
emit('close');
onClose?.();
onClose();
};
const onMouseUp = () => {
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import ChannelSelector from '../ChannelSelector.vue';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const props = defineProps({
channel: {
@@ -20,7 +21,10 @@ const hasFbConfigured = computed(() => {
});
const hasInstagramConfigured = computed(() => {
return window.chatwootConfig?.instagramAppId;
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.instagramAppId
);
});
const hasTiktokConfigured = computed(() => {
@@ -52,10 +56,19 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice' || key === 'whatsapp_call') {
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
if (key === 'whatsapp_call') {
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
}
return [
'website',
'twilio',
@@ -62,7 +62,6 @@ import {
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
createVariableInputRule,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -307,10 +306,6 @@ const plugins = computed(() => {
searchTerm: variableSearchTerm,
isAllowed: () => !props.isPrivate,
}),
createVariableInputRule({
isPrivate: () => props.isPrivate,
getVariables: () => props.variables,
}),
createSuggestionPlugin({
trigger: ':',
minChars: 2,
@@ -33,9 +33,7 @@ import {
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -95,7 +93,6 @@ export default {
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -173,12 +170,6 @@ export default {
instagramInbox
);
},
isInstagramRestrictionBannerVisible() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
@@ -464,15 +455,7 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-else-if="!currentChat.can_reply"
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -48,8 +48,6 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
getAgentVariables,
getContactVariables,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -146,7 +144,6 @@ export default {
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
currentContact() {
const senderId = this.currentChat?.meta?.sender?.id;
@@ -174,9 +171,6 @@ export default {
return this.isATwilioWhatsAppChannel && !this.isPrivate;
},
isPrivate() {
if (this.isInstagramReplyRestricted) {
return true;
}
if (
this.currentChat.can_reply ||
this.isAWhatsAppChannel ||
@@ -201,16 +195,10 @@ export default {
);
return !!stripped.trim();
},
// Instagram replies are disabled on Chatwoot Cloud during the temporary
// Meta platform restriction; private notes remain available.
isInstagramReplyRestricted() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
isReplyRestricted() {
return (
this.isInstagramReplyRestricted ||
(!this.currentChat?.can_reply &&
!(this.isAWhatsAppChannel || this.isAPIInbox))
!this.currentChat?.can_reply &&
!(this.isAWhatsAppChannel || this.isAPIInbox)
);
},
inboxId() {
@@ -405,13 +393,7 @@ export default {
contact: this.currentContact,
inbox: this.inbox,
});
// Match the backend drops: names are Ruby-capitalized and
// {{agent.*}} is the message sender, not the assignee.
return {
...variables,
...getContactVariables(this.currentContact),
...getAgentVariables(this.currentUser),
};
return variables;
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
@@ -480,10 +462,7 @@ export default {
return;
}
if (
!this.isInstagramReplyRestricted &&
(canReply || this.isAWhatsAppChannel || this.isAPIInbox)
) {
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
this.replyType = REPLY_EDITOR_MODES.REPLY;
} else {
this.replyType = REPLY_EDITOR_MODES.NOTE;
@@ -950,10 +929,7 @@ export default {
this.$store.dispatch('draftMessages/setReplyEditorMode', {
mode,
});
if (
!this.isInstagramReplyRestricted &&
(canReply || this.isAWhatsAppChannel || this.isAPIInbox)
)
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
@@ -78,5 +78,8 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
export const META_RESTRICTION_STATUS_URL =
'https://status.chatwoot.com/incident/948346';
// Temporarily disables Instagram and WhatsApp inbox creation
// (WhatsApp embedded signup popup, Instagram OAuth, WhatsApp Call).
// Flip to false when the channels are brought back.
export const IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED = true;
-2
View File
@@ -7,11 +7,9 @@ export const FEATURE_FLAGS = {
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
DATA_IMPORT: 'data_import',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
@@ -9,7 +9,6 @@ import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { InputRule, inputRules } from 'prosemirror-inputrules';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
@@ -429,55 +428,6 @@ export function stripUnsupportedFormatting(content, schema) {
* - emoji
*/
// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send.
const LIQUID_SYNTAX = /\{\{|\{%/;
// Value when set (and not itself Liquid), else the {{placeholder}} for the backend.
export const resolveVariableText = (key, variables) => {
const value = String(variables?.[key] ?? '');
return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`;
};
// Name variables normalized like the backend drops (UserDrop/ContactDrop):
// name split on whitespace, each word Ruby-capitalized (rest downcased).
const getNameVariables = (prefix, name) => {
const names = (name || '')
.split(/\s+/)
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
return {
[`${prefix}.name`]: names.join(' '),
[`${prefix}.first_name`]: names[0] || '',
[`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '',
};
};
// {{agent.*}} values for the message sender.
export const getAgentVariables = user => ({
...getNameVariables('agent', user.name),
'agent.email': user.email,
});
// {{contact.*}} name values.
export const getContactVariables = contact =>
getNameVariables('contact', contact?.name);
// Resolves a manually typed {{variable}} to its value on the closing braces.
// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note.
export const createVariableInputRule = ({ isPrivate, getVariables }) => {
const rule = new InputRule(
/\{\{([^{}]+)\}\}$/,
(editorState, match, from, to) => {
if (isPrivate()) return null;
const [, key] = match;
const text = resolveVariableText(key, getVariables());
if (text === `{{${key}}}`) return null;
return editorState.tr.insertText(text, from, to);
}
);
return inputRules({ rules: [rule] });
};
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
@@ -512,7 +462,7 @@ const createNode = (editorView, nodeType, content) => {
);
}
case 'variable':
return state.schema.text(content);
return state.schema.text(`{{${content}}}`);
case 'emoji':
return state.schema.text(content);
case 'tool': {
@@ -547,12 +497,8 @@ const nodeCreators = {
to,
};
},
variable: (editorView, content, from, to, variables) => ({
node: createNode(
editorView,
'variable',
resolveVariableText(content, variables)
),
variable: (editorView, content, from, to) => ({
node: createNode(editorView, 'variable', content),
from,
to,
}),
@@ -94,56 +94,16 @@ describe('getContentNode', () => {
});
describe('getVariableNode', () => {
it('should render the resolved value directly when the variable has a value', () => {
getContentNode(
editorView,
'variable',
'contact.name',
{ from: 0, to: 10 },
{ 'contact.name': 'John' }
);
it('should create a variable node', () => {
const content = 'name';
const from = 0;
const to = 10;
getContentNode(editorView, 'variable', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('John');
});
it('should resolve camelCase custom attributes and non-string values', () => {
getContentNode(
editorView,
'variable',
'contact.custom_attribute.cloudCustomer',
{ from: 0, to: 10 },
{ 'contact.custom_attribute.cloudCustomer': true }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith('true');
});
it('should keep the placeholder when the variable has no value', () => {
getContentNode(
editorView,
'variable',
'contact.email',
{ from: 0, to: 10 },
{}
);
expect(editorView.state.schema.text).toHaveBeenCalledWith(
'{{contact.email}}'
);
});
it('should keep the placeholder when the value contains Liquid syntax', () => {
getContentNode(
editorView,
'variable',
'contact.name',
{ from: 0, to: 10 },
{ 'contact.name': '{{agent.email}}' }
);
expect(editorView.state.schema.text).toHaveBeenCalledWith(
'{{contact.name}}'
);
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
});
});
@@ -11,12 +11,9 @@ import {
calculateMenuPosition,
cleanSignature,
collapseSelection,
createVariableInputRule,
extractTextFromMarkdown,
findNodeToInsertImage,
findSignatureInBody,
getAgentVariables,
getContactVariables,
getContentNode,
getFormattingForEditor,
getMenuAnchor,
@@ -1231,149 +1228,3 @@ describe('Menu positioning helpers', () => {
});
});
});
describe('getAgentVariables', () => {
it('builds agent variables from the user', () => {
expect(
getAgentVariables({ name: 'John Doe', email: 'john@example.com' })
).toEqual({
'agent.name': 'John Doe',
'agent.first_name': 'John',
'agent.last_name': 'Doe',
'agent.email': 'john@example.com',
});
});
it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => {
const variables = getAgentVariables({ name: 'JANE doE' });
expect(variables['agent.name']).toBe('Jane Doe');
expect(variables['agent.first_name']).toBe('Jane');
expect(variables['agent.last_name']).toBe('Doe');
});
it('ignores extra whitespace between words', () => {
expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe(
'John Doe'
);
});
it('leaves last_name empty for single-word names', () => {
const variables = getAgentVariables({ name: 'john' });
expect(variables['agent.first_name']).toBe('John');
expect(variables['agent.last_name']).toBe('');
});
it('handles a missing name', () => {
const variables = getAgentVariables({ email: 'john@example.com' });
expect(variables['agent.name']).toBe('');
expect(variables['agent.first_name']).toBe('');
expect(variables['agent.last_name']).toBe('');
});
});
describe('getContactVariables', () => {
it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => {
expect(getContactVariables({ name: 'JANE doE' })).toEqual({
'contact.name': 'Jane Doe',
'contact.first_name': 'Jane',
'contact.last_name': 'Doe',
});
});
it('leaves last_name empty for single-word names', () => {
const variables = getContactVariables({ name: 'john' });
expect(variables['contact.first_name']).toBe('John');
expect(variables['contact.last_name']).toBe('');
});
it('handles a missing contact', () => {
expect(getContactVariables(undefined)['contact.name']).toBe('');
});
});
describe('createVariableInputRule', () => {
// Editor holding `{{key}` so we can simulate typing the final `}`.
const buildView = (typed, { isPrivate = false, variables = {} } = {}) => {
const plugin = createVariableInputRule({
isPrivate: () => isPrivate,
getVariables: () => variables,
});
const state = EditorState.create({
schema,
doc: schema.node('doc', null, [
schema.node('paragraph', null, [schema.text(typed)]),
]),
plugins: [plugin],
});
return new EditorView(document.body, { state });
};
// Types the closing `}`; when the rule declines, insert it like the browser would.
const typeClosingBrace = view => {
const end = view.state.doc.content.size - 1;
const handled = view.someProp('handleTextInput', fn =>
fn(view, end, end, '}')
);
if (!handled) {
view.dispatch(view.state.tr.insertText('}', end, end));
}
};
it('resolves a manually typed {{variable}} to its value on the closing brace', () => {
const view = buildView('{{contact.name}', {
variables: { 'contact.name': 'John' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('John');
view.destroy();
});
it('resolves boolean/non-string values', () => {
const view = buildView('{{contact.custom_attribute.cloudCustomer}', {
variables: { 'contact.custom_attribute.cloudCustomer': true },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('true');
view.destroy();
});
it('keeps the placeholder when the variable has no value', () => {
const view = buildView('{{contact.email}', { variables: {} });
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.email}}');
view.destroy();
});
it('keeps the placeholder when the value itself contains Liquid syntax', () => {
const view = buildView('{{contact.name}', {
variables: { 'contact.name': '{{agent.email}}' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.name}}');
view.destroy();
});
it('does not resolve inside a private note', () => {
const view = buildView('{{contact.name}', {
isPrivate: true,
variables: { 'contact.name': 'John' },
});
typeClosingBrace(view);
expect(view.state.doc.textContent).toBe('{{contact.name}}');
view.destroy();
});
});
@@ -44,8 +44,6 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You wont be able to send messages from this conversation anymore.",
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -58,10 +58,7 @@
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore.",
"RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. Well restore support as soon as possible.",
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"STATUS_LINK": "View status update"
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore."
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
@@ -323,8 +320,6 @@
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow",
"RESTRICTED_WARNING": "WhatsApp embedded signup is temporarily unavailable due to current Meta platform restrictions. Well restore support as soon as possible.",
"STATUS_LINK": "View status update",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
@@ -820,15 +815,6 @@
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
"WHATSAPP_MANUAL_TRANSFER_TITLE": "Switch to Manual Setup",
"WHATSAPP_MANUAL_TRANSFER_SUBHEADER": "This inbox was connected through WhatsApp embedded signup, which is no longer supported. Enter the WhatsApp Cloud API credentials from your own Meta app to switch this inbox to a manual setup. Configure the webhook in your Meta app using the verification token above before switching.",
"WHATSAPP_MANUAL_TRANSFER_PHONE_NUMBER_ID_LABEL": "Phone Number ID",
"WHATSAPP_MANUAL_TRANSFER_BUSINESS_ACCOUNT_ID_LABEL": "Business Account ID",
"WHATSAPP_MANUAL_TRANSFER_API_KEY_LABEL": "API Key",
"WHATSAPP_MANUAL_TRANSFER_API_KEY_PLACEHOLDER": "Enter a permanent access token from your Meta app",
"WHATSAPP_MANUAL_TRANSFER_BUTTON": "Switch to Manual Setup",
"WHATSAPP_MANUAL_TRANSFER_SUCCESS": "Inbox switched to manual setup successfully.",
"WHATSAPP_MANUAL_TRANSFER_ERROR": "Could not switch to manual setup. Please verify the credentials and try again.",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
@@ -846,70 +832,6 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_MANUAL_MIGRATION": {
"BANNER": {
"TITLE": "WhatsApp setup action required",
"DESCRIPTION": "Meta restrictions are affecting WhatsApp setup and management features. Reconnect this inbox manually to keep your WhatsApp configuration up to date.",
"START": "Start manual migration",
"GUIDE": "View guide"
},
"DIALOG": {
"EYEBROW": "WhatsApp manual migration",
"TITLE": "Reconnect WhatsApp inbox",
"CLOSE": "Close",
"ACTION_REQUIRED_TITLE": "Action required for this WhatsApp inbox",
"ACTION_REQUIRED_DESCRIPTION": "Meta restrictions are affecting setup and management features. This guided flow updates the WhatsApp API connection without creating a new inbox.",
"GUIDE_LINK": "Open the manual setup guide",
"PRESERVED_TITLE": "Preserved",
"PRESERVED_DESCRIPTION": "Conversations, contacts, collaborators, routing, business hours, and inbox settings.",
"UPDATED_TITLE": "Updated",
"UPDATED_DESCRIPTION": "WABA ID, phone number ID, access token, and webhook configuration.",
"WABA_ID": "WABA ID",
"WABA_PLACEHOLDER": "Enter WABA ID",
"WABA_HELP": "The WhatsApp Business Account that owns this phone number.",
"PHONE_NUMBER_ID": "Phone Number ID",
"PHONE_NUMBER_PLACEHOLDER": "Enter Phone Number ID",
"PHONE_NUMBER_HELP": "Meta's unique ID for the WhatsApp number connected to this inbox.",
"DISPLAY_PHONE_NUMBER": "Display phone number",
"DISPLAY_PHONE_NUMBER_PLACEHOLDER": "Enter display phone number",
"DISPLAY_PHONE_NUMBER_HELP": "The customer-facing WhatsApp number. This cannot be changed during migration.",
"ACCESS_TOKEN": "Permanent access token or system user token",
"ACCESS_TOKEN_PLACEHOLDER": "Paste access token",
"TOKEN_HELP_PREFIX": "The token must include",
"TOKEN_HELP_MIDDLE": "Add",
"TOKEN_HELP_SUFFIX": "for template sync and template management.",
"MESSAGING_PERMISSION": "whatsapp_business_messaging.",
"MANAGEMENT_PERMISSION": "whatsapp_business_management",
"REVIEW_TITLE": "Review before reconnecting",
"INBOX": "Inbox",
"PHONE_NUMBER": "Phone number",
"NOT_ENTERED": "Not entered",
"VERIFY_NOTICE": "Chatwoot will verify these credentials with Meta before applying any changes. If verification fails, the current configuration is left untouched.",
"BACK": "Back",
"CANCEL": "Cancel",
"CONTINUE": "Continue",
"REVIEW_MIGRATION": "Review migration",
"RECONNECT": "Reconnect WhatsApp inbox"
},
"STEPS": {
"BEFORE_YOU_START": {
"TITLE": "Before you start",
"DESCRIPTION": "This reconnects the WhatsApp API details for this inbox. Conversations, collaborators, routing, business hours, CSAT, and bot settings will be preserved."
},
"BUSINESS_DETAILS": {
"TITLE": "Business details",
"DESCRIPTION": "Enter the WhatsApp assets from the customer Meta Business account."
},
"ACCESS_TOKEN": {
"TITLE": "Access token",
"DESCRIPTION": "Paste a permanent access token or system user token with WhatsApp permissions."
},
"REVIEW_MIGRATION": {
"TITLE": "Review migration",
"DESCRIPTION": "Confirm the connection details before reconnecting this inbox. Chatwoot will verify the token, WABA, and phone number with Meta before applying changes."
}
}
},
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
@@ -811,7 +811,6 @@
"DOCUMENTS": {
"HEADER": "Documents",
"ADD_NEW": "Create a new document",
"FAQ_COUNT": "{n} FAQ | {n} FAQs",
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
@@ -871,27 +870,7 @@
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"EMPTY": "No FAQs have been generated from this document yet."
},
"DETAILS": {
"DESCRIPTION": "Review the crawled content and the FAQs generated from this source.",
"SOURCE": "Source",
"GENERATED_FAQS": "Generated FAQs",
"LAST_UPDATED": "Last updated",
"NOT_AVAILABLE": "Not available",
"CONTENT_TAB": "Crawled content",
"PDF_TAB": "PDF details",
"CONTENT_TITLE": "Crawled content",
"PDF_TITLE": "PDF file",
"PDF_DESCRIPTION": "Review the original PDF source.",
"CHARACTER_COUNT": "{count} characters extracted",
"COPY_CONTENT": "Copy",
"COPY_SUCCESS": "Crawled content copied to clipboard",
"COPY_ERROR": "Could not copy crawled content",
"VIEW_RAW": "View raw",
"VIEW_PREVIEW": "View preview",
"UNREADABLE_CONTENT": "Readable content could not be extracted from this document. You can view the raw extracted content.",
"EMPTY_CONTENT": "No crawled content is available for this document yet."
"DESCRIPTION": "These FAQs are generated directly from the document."
},
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
"CREATE": {
@@ -932,7 +911,7 @@
},
"OPTIONS": {
"VIEW_DETAILS": "View details",
"VIEW_RELATED_RESPONSES": "View Related Responses",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document"
@@ -263,7 +263,7 @@
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
"ACCOUNT_SUSPENDED": {
"TITLE": "Account Suspended",
"MESSAGE": "Your account has been suspended due to activity that may violate our policies. If you believe this is a mistake, please contact our support team."
"MESSAGE": "Your account has been suspended after we detected activity that may violate our policies or put other users at risk. If you believe this is a mistake, please contact our support team."
},
"NO_ACCOUNTS": {
"TITLE": "No account found",
@@ -340,7 +340,6 @@
"NOTIFICATIONS": "Notifications",
"CANNED_RESPONSES": "Canned Responses",
"INTEGRATIONS": "Integrations",
"DATA": "Data",
"PROFILE_SETTINGS": "Profile Settings",
"ACCOUNT_SETTINGS": "Account Settings",
"APPLICATIONS": "Applications",
@@ -412,104 +411,6 @@
"CAPTAIN_AI": "Captain",
"CONVERSATION_WORKFLOW": "Conversation Workflow"
},
"DATA_IMPORTS": {
"HEADER": "Data",
"DESCRIPTION": "Bring your existing contacts and past conversations into this account from another support tool. Each import runs in the background, so you can keep working while it finishes, track its progress, and review anything that was skipped along the way.",
"LOADING": "Fetching imports",
"DEFAULT_IMPORT_NAME": "Intercom import",
"TABS": {
"IMPORT": "Import",
"EXPORT": "Export"
},
"TYPES": {
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
},
"DRAWER": {
"TITLE": "New import",
"SOURCE": "Source",
"NAME": "Import name",
"NAME_PLACEHOLDER": "July Intercom migration",
"ACCESS_KEY": "Intercom access key",
"ACCESS_KEY_PLACEHOLDER": "Paste your Intercom access key",
"DATA_TYPES": "Data to import",
"VALIDATING": "Validating access key...",
"VALID_KEY": "Access key validated.",
"INVALID_KEY": "Could not validate this access key.",
"ACTIVE_IMPORT": "Wait for the active import to finish before starting another one.",
"CANCEL": "Cancel",
"IMPORT": "Import"
},
"EXPORT": {
"TITLE": "Exports are on the way",
"DESCRIPTION": "Export your contacts and conversations out of this account. This workflow is coming soon.",
"COMING_SOON": "Coming soon"
},
"TABLE": {
"TITLE": "Recent imports",
"EMPTY": "No imports yet",
"EMPTY_DESCRIPTION": "Start an import to bring your existing customer history into this account.",
"NEW_IMPORT": "Import",
"COUNT": "{count} imports",
"UNNAMED": "Untitled import",
"IMPORTED_COUNT": "{count} imported",
"VIEW": "View import",
"NAME": "Name",
"TYPE": "Type",
"STATUS": "Status",
"IMPORTED": "Imported",
"CREATED": "Created",
"ABANDON": "Abandon"
},
"DETAIL": {
"BACK": "Back to imports",
"ERRORS": "Errors",
"SKIP_LOGS": "Skip logs",
"SOURCE": "Source",
"IMPORT_TYPES": "Import types",
"CREATED": "Created",
"DURATION": "Duration",
"INITIATED_BY": "Started by",
"PROGRESS": "Import progress",
"PROGRESS_WITH_TOTAL": "{imported} of {total} imported",
"PROGRESS_WITHOUT_TOTAL": "{imported} imported",
"PROGRESS_OF_TOTAL": "of {total} imported",
"PROGRESS_IMPORTED": "imported",
"LAST_UPDATED_TOOLTIP": "Last updated {time}",
"NO_SKIP_LOGS": "No skipped or failed records recorded.",
"DOWNLOAD_SKIP_LOGS": "Download CSV",
"DOWNLOAD_ERROR_LOGS": "Download CSV",
"ALL_SKIP_LOGS": "All",
"KIND": "Kind",
"NO_ERRORS": "No errors recorded.",
"ERROR_CODE": "Code",
"SOURCE_OBJECT": "Source object",
"MESSAGE": "Message"
},
"MONITOR": {
"LIVE": "Live updates every {seconds}s",
"LAST_UPDATED": "Last updated {time}",
"REFRESH": "Refresh",
"REFRESHING": "Refreshing",
"STAGES": {
"unknown": "Waiting for update",
"queued": "Queued",
"contacts": "Importing contacts",
"conversations": "Importing conversations",
"finalizing": "Finalizing import",
"completed": "Completed",
"completed_with_errors": "Completed with errors",
"failed": "Failed",
"abandoned": "Abandoned"
}
},
"ALERTS": {
"IMPORT_STARTED": "Intercom import has started.",
"IMPORT_ABANDONED": "Intercom import has been abandoned.",
"IMPORT_FAILED": "Could not start the Intercom import."
}
},
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
@@ -17,7 +17,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Policy from 'dashboard/components/policy.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import DocumentDetails from 'dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
@@ -51,22 +51,22 @@ const handleDelete = () => {
deleteDocumentDialog.value.dialogRef.open();
};
const showDocumentDetails = ref(false);
const showRelatedResponses = ref(false);
const showCreateDialog = ref(false);
const createDocumentDialog = ref(null);
const documentDetailsDialog = ref(null);
const relationQuestionDialog = ref(null);
const handleShowDocumentDetails = () => {
showDocumentDetails.value = true;
nextTick(() => documentDetailsDialog.value.dialogRef.open());
const handleShowRelatedDocument = () => {
showRelatedResponses.value = true;
nextTick(() => relationQuestionDialog.value.dialogRef.open());
};
const handleCreateDocument = () => {
showCreateDialog.value = true;
nextTick(() => createDocumentDialog.value.dialogRef.open());
};
const handleDocumentDetailsClose = () => {
showDocumentDetails.value = false;
const handleRelatedResponseClose = () => {
showRelatedResponses.value = false;
};
const handleCreateDialogClose = () => {
@@ -235,8 +235,8 @@ const handleAction = ({ action, id }) => {
nextTick(() => {
if (action === 'delete') {
handleDelete();
} else if (action === 'viewDetails') {
handleShowDocumentDetails();
} else if (action === 'viewRelatedQuestions') {
handleShowRelatedDocument();
} else if (action === 'sync') {
handleSync(id);
}
@@ -416,7 +416,6 @@ onUnmounted(() => {
:last-sync-error-code="doc.last_sync_error_code"
:sync-in-progress="doc.sync_in_progress"
:sync-stale-after-hours="syncIntervalHours"
:responses-count="doc.responses_count"
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
:selectable="canManageDocuments"
:show-selection-control="shouldShowSelectionControl(doc.id)"
@@ -428,11 +427,11 @@ onUnmounted(() => {
</div>
</template>
<DocumentDetails
v-if="showDocumentDetails"
ref="documentDetailsDialog"
<RelatedResponses
v-if="showRelatedResponses"
ref="relationQuestionDialog"
:captain-document="selectedDocument"
@close="handleDocumentDetailsClose"
@close="handleRelatedResponseClose"
/>
<CreateDocumentDialog
v-if="showCreateDialog"
@@ -1,4 +1,5 @@
import { useMapGetter } from 'dashboard/composables/store';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
@@ -7,20 +8,20 @@ import { useMapGetter } from 'dashboard/composables/store';
// Mirrors the availability checks in ChannelItem.vue.
export function useChannelConfig() {
const globalConfig = useMapGetter('globalConfig/get');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const installationConfig = window.chatwootConfig || {};
const CHANNEL_CONFIGURED = {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
!isOnChatwootCloud.value &&
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () =>
!isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId),
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
@@ -1,7 +1,6 @@
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import googleClient from 'dashboard/api/channel/googleClient';
@@ -24,17 +23,11 @@ export function useChannelConnect() {
const { t } = useI18n();
const store = useStore();
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const { isOnChatwootCloud } = useAccount();
const connectViaOAuth = async provider => {
const client = OAUTH_CLIENTS[provider];
if (!client) return;
if (provider === 'instagram' && isOnChatwootCloud.value) {
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING'));
return;
}
try {
const {
data: { url },
@@ -6,25 +6,21 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Neutralize the temporary Instagram/WhatsApp kill switch so these specs keep
// covering the credential-based gating it currently short-circuits.
vi.mock('dashboard/constants/globals', async importOriginal => ({
...(await importOriginal()),
IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED: false,
}));
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
// used, so assertions validate against the actual channel identity (label keys,
// channel_type, social ordering) derived from CHANNEL_LIST.
const mountComposable = ({
brandInfo,
inboxes = [],
isOnChatwootCloud = false,
} = {}) => {
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
const store = createStore({
modules: {
globalConfig: {
namespaced: true,
getters: {
get: () => ({}),
isOnChatwootCloud: () => isOnChatwootCloud,
},
},
accounts: {
namespaced: true,
getters: {
@@ -206,22 +202,6 @@ describe('useDetectedChannels', () => {
'line',
]);
});
it('hides Instagram from onboarding on Chatwoot Cloud', () => {
const { displayedChannels } = mountComposable({
isOnChatwootCloud: true,
brandInfo: {
socials: [
{ type: 'instagram', url: 'https://instagram.com/acme' },
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'tiktok',
]);
});
});
describe('remainingChannels', () => {
@@ -52,11 +52,9 @@ const helpURL = getHelpUrlForFeature(props.featureName);
v-if="title"
class="flex items-center justify-between w-full gap-4 min-h-8 mb-2"
>
<slot name="title">
<h1 class="text-heading-1 text-n-slate-12">
{{ title }}
</h1>
</slot>
<h1 class="text-heading-1 text-n-slate-12">
{{ title }}
</h1>
</div>
<div
v-if="description || $slots.description || linkText || helpURL"
@@ -1,378 +0,0 @@
<script setup>
import {
computed,
onActivated,
onBeforeUnmount,
onDeactivated,
ref,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStoreGetters } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import DataImportsAPI from 'dashboard/api/dataImports';
import NewImportDialog from './NewImportDialog.vue';
import { importSourceFor } from './importSources';
import {
POLL_INTERVAL_MS,
formatDate,
formatStatus,
importedCount,
isActiveImport,
isActiveIntercomImport,
statusDotClass,
} from './importStatus';
const { t } = useI18n();
const getters = useStoreGetters();
const router = useRouter();
const dataImports = ref([]);
const isLoading = ref(true);
const isRefreshing = ref(false);
const isPolling = ref(false);
const showImportDrawer = ref(false);
const activeTab = ref('import');
let pollTimer;
let isPageActive = false;
const accountId = getters.getCurrentAccountId;
const tabs = computed(() => [
{ key: 'import', label: t('DATA_IMPORTS.TABS.IMPORT') },
{ key: 'export', label: t('DATA_IMPORTS.TABS.EXPORT') },
]);
const activeTabIndex = computed(() =>
tabs.value.findIndex(tab => tab.key === activeTab.value)
);
const hasActiveImport = computed(() => dataImports.value.some(isActiveImport));
const hasActiveIntercomImport = computed(() =>
dataImports.value.some(isActiveIntercomImport)
);
const dataImportRoute = dataImport => ({
name: 'settings_data_import_show',
params: { accountId: accountId.value, dataImportId: dataImport.id },
});
const importTypesFor = dataImport =>
dataImport.import_types?.length
? dataImport.import_types
: [dataImport.data_type];
const importTypeLabel = dataImport =>
importTypesFor(dataImport)
.map(type => {
if (type === 'contacts') return t('DATA_IMPORTS.TYPES.CONTACTS');
if (type === 'conversations') {
return t('DATA_IMPORTS.TYPES.CONVERSATIONS');
}
return type;
})
.join(', ');
const fetchImports = async () => {
const response = await DataImportsAPI.get();
dataImports.value = response.data.payload || [];
};
const stopPolling = () => {
if (!pollTimer) return;
window.clearInterval(pollTimer);
pollTimer = null;
};
const refreshImportsInBackground = async () => {
if (
!isPageActive ||
isPolling.value ||
!hasActiveImport.value ||
document.hidden
) {
return;
}
isPolling.value = true;
try {
await fetchImports();
} finally {
isPolling.value = false;
if (!hasActiveImport.value) stopPolling();
}
};
const startPolling = () => {
stopPolling();
if (!isPageActive || !hasActiveImport.value) return;
pollTimer = window.setInterval(refreshImportsInBackground, POLL_INTERVAL_MS);
};
const refresh = async ({ showLoader = true } = {}) => {
if (showLoader) isLoading.value = true;
else isRefreshing.value = true;
try {
await fetchImports();
} finally {
isLoading.value = false;
isRefreshing.value = false;
if (isPageActive) {
if (hasActiveImport.value && !pollTimer) startPolling();
if (!hasActiveImport.value) stopPolling();
}
}
};
const openImport = dataImport => {
router.push(dataImportRoute(dataImport));
};
const openImportDrawer = () => {
if (!hasActiveIntercomImport.value) showImportDrawer.value = true;
};
const onImportCreated = dataImportId => {
showImportDrawer.value = false;
router.push({
name: 'settings_data_import_show',
params: { accountId: accountId.value, dataImportId },
});
};
const onTabChanged = tab => {
activeTab.value = tab.key;
};
const handleVisibilityChange = () => {
if (isPageActive && !document.hidden && hasActiveImport.value) {
refreshImportsInBackground();
}
};
onActivated(async () => {
isPageActive = true;
await refresh();
if (!isPageActive) return;
startPolling();
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onDeactivated(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
<template>
<SettingsLayout
:is-loading="isLoading"
:loading-message="$t('DATA_IMPORTS.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="$t('DATA_IMPORTS.HEADER')"
:description="$t('DATA_IMPORTS.DESCRIPTION')"
>
<template #tabs>
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="onTabChanged"
/>
</template>
<template v-if="activeTab === 'import' && dataImports.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('DATA_IMPORTS.TABLE.COUNT', { count: dataImports.length }) }}
</span>
</template>
<template v-if="activeTab === 'import'" #actions>
<span
v-if="hasActiveImport"
class="hidden items-center gap-1.5 text-body-main text-n-slate-11 sm:inline-flex"
>
<span class="size-2 rounded-full bg-n-teal-9 animate-pulse" />
{{
$t('DATA_IMPORTS.MONITOR.LIVE', {
seconds: POLL_INTERVAL_MS / 1000,
})
}}
</span>
<Button
ghost
slate
size="sm"
icon="i-lucide-refresh-cw"
:is-loading="isRefreshing"
:aria-label="$t('DATA_IMPORTS.MONITOR.REFRESH')"
:title="$t('DATA_IMPORTS.MONITOR.REFRESH')"
@click="refresh({ showLoader: false })"
/>
<Button
size="sm"
:label="$t('DATA_IMPORTS.TABLE.NEW_IMPORT')"
:disabled="hasActiveIntercomImport"
:title="
hasActiveIntercomImport
? $t('DATA_IMPORTS.DRAWER.ACTIVE_IMPORT')
: undefined
"
@click="openImportDrawer"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div
v-if="activeTab === 'export'"
class="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-n-weak bg-n-solid-1 px-6 py-16 text-center"
>
<span
class="flex size-12 items-center justify-center rounded-full bg-n-alpha-2"
>
<Icon icon="i-lucide-upload" class="size-5 text-n-slate-11" />
</span>
<div class="flex flex-col gap-1">
<h3 class="text-heading-2 text-n-slate-12">
{{ $t('DATA_IMPORTS.EXPORT.TITLE') }}
</h3>
<p class="max-w-sm text-body-main text-n-slate-11">
{{ $t('DATA_IMPORTS.EXPORT.DESCRIPTION') }}
</p>
</div>
<span
class="inline-flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-2 py-1 text-label-small text-n-slate-11"
>
<Icon icon="i-lucide-clock" class="size-3.5" />
{{ $t('DATA_IMPORTS.EXPORT.COMING_SOON') }}
</span>
</div>
<div
v-else-if="!dataImports.length"
class="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-n-weak bg-n-solid-1 px-6 py-16 text-center"
>
<span
class="flex size-12 items-center justify-center rounded-full bg-n-alpha-2"
>
<Icon icon="i-lucide-database" class="size-5 text-n-slate-11" />
</span>
<div class="flex flex-col gap-1">
<h3 class="text-heading-2 text-n-slate-12">
{{ $t('DATA_IMPORTS.TABLE.EMPTY') }}
</h3>
<p class="max-w-sm text-body-main text-n-slate-11">
{{ $t('DATA_IMPORTS.TABLE.EMPTY_DESCRIPTION') }}
</p>
</div>
<Button
size="sm"
icon="i-lucide-download"
:label="$t('DATA_IMPORTS.TABLE.NEW_IMPORT')"
@click="openImportDrawer"
/>
</div>
<div v-else class="divide-y divide-n-weak border-t border-n-weak">
<div
v-for="dataImport in dataImports"
:key="dataImport.id"
class="group flex cursor-pointer items-center justify-between gap-4 py-4"
role="button"
tabindex="0"
@click="openImport(dataImport)"
@keydown.enter="openImport(dataImport)"
@keydown.space.prevent="openImport(dataImport)"
>
<div class="flex min-w-0 items-center gap-3">
<img
v-if="importSourceFor(dataImport).icon"
v-tooltip.top="importSourceFor(dataImport).label"
:src="importSourceFor(dataImport).icon"
alt=""
class="size-10 justify-center bg-n-alpha-3 rounded-xl shrink-0 object-contain border border-n-strong"
/>
<span
v-else
v-tooltip.top="importSourceFor(dataImport).label"
class="size-10 justify-center bg-n-alpha-3 rounded-xl ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
>
<Icon
:icon="importSourceFor(dataImport).iconClass"
class="size-4"
/>
</span>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="truncate text-heading-3 text-n-slate-12">
{{ dataImport.name || $t('DATA_IMPORTS.TABLE.UNNAMED') }}
</span>
<span class="flex shrink-0 items-center gap-1.5">
<span
class="size-2 rounded-full"
:class="[
statusDotClass(dataImport.status),
{ 'animate-pulse': isActiveImport(dataImport) },
]"
/>
<span
class="whitespace-nowrap capitalize text-body-main text-n-slate-11"
>
{{ formatStatus(dataImport.status) }}
</span>
</span>
</div>
<div
class="flex flex-wrap items-center gap-2 text-body-main text-n-slate-11"
>
<span>{{ importTypeLabel(dataImport) }}</span>
<div class="h-3 w-px rounded-lg bg-n-strong" />
<span class="tabular-nums">
{{
$t('DATA_IMPORTS.TABLE.IMPORTED_COUNT', {
count: importedCount(dataImport),
})
}}
</span>
<div class="h-3 w-px rounded-lg bg-n-strong" />
<span>{{ formatDate(dataImport.created_at) }}</span>
</div>
</div>
</div>
<Button
v-tooltip.top="$t('DATA_IMPORTS.TABLE.VIEW')"
icon="i-lucide-eye"
slate
sm
class="shrink-0"
@click.stop="openImport(dataImport)"
/>
</div>
</div>
</template>
</SettingsLayout>
<NewImportDialog
:show="showImportDrawer"
:has-active-import="hasActiveIntercomImport"
@close="showImportDrawer = false"
@created="onImportCreated"
/>
</template>
@@ -1,210 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Select from 'dashboard/components-next/select/Select.vue';
import DataImportsAPI from 'dashboard/api/dataImports';
import { IMPORT_SOURCES } from './importSources';
const props = defineProps({
show: { type: Boolean, default: false },
hasActiveImport: { type: Boolean, default: false },
});
const emit = defineEmits(['close', 'created']);
const { t } = useI18n();
const dialogRef = ref(null);
const sourceProvider = ref('intercom');
const importName = ref(t('DATA_IMPORTS.DEFAULT_IMPORT_NAME'));
const accessToken = ref('');
const selectedImportTypes = ref(['contacts', 'conversations']);
const validationState = ref('idle');
const validationMessage = ref('');
const isCreating = ref(false);
let validationRequestId = 0;
const closeDrawer = () => emit('close');
const sourceOptions = computed(() =>
IMPORT_SOURCES.map(({ value, label }) => ({ value, label }))
);
const tokenMessageType = computed(() => {
if (validationState.value === 'valid') return 'success';
if (validationState.value === 'invalid') return 'error';
return 'info';
});
const canCreate = computed(
() =>
validationState.value === 'valid' &&
selectedImportTypes.value.length > 0 &&
!props.hasActiveImport &&
!isCreating.value
);
const validationPayload = () => ({
source_provider: sourceProvider.value,
access_token: accessToken.value.trim(),
import_types: selectedImportTypes.value,
});
const invalidateValidation = () => {
validationRequestId += 1;
validationState.value = 'idle';
validationMessage.value = '';
};
const validateSource = async () => {
if (!accessToken.value.trim() || !selectedImportTypes.value.length) {
invalidateValidation();
return;
}
validationRequestId += 1;
const requestId = validationRequestId;
validationState.value = 'validating';
validationMessage.value = t('DATA_IMPORTS.DRAWER.VALIDATING');
try {
await DataImportsAPI.validateSource(validationPayload());
if (requestId !== validationRequestId) return;
validationState.value = 'valid';
validationMessage.value = t('DATA_IMPORTS.DRAWER.VALID_KEY');
} catch (error) {
if (requestId !== validationRequestId) return;
validationState.value = 'invalid';
validationMessage.value =
error?.response?.data?.message || t('DATA_IMPORTS.DRAWER.INVALID_KEY');
}
};
const toggleImportType = type => {
selectedImportTypes.value = selectedImportTypes.value.includes(type)
? selectedImportTypes.value.filter(item => item !== type)
: [...selectedImportTypes.value, type];
};
const createImport = async () => {
if (!canCreate.value) return;
isCreating.value = true;
try {
const response = await DataImportsAPI.create({
...validationPayload(),
name: importName.value.trim() || t('DATA_IMPORTS.DEFAULT_IMPORT_NAME'),
});
useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_STARTED'));
emit('created', response.data.id);
} catch (error) {
useAlert(
error?.response?.data?.message || t('DATA_IMPORTS.ALERTS.IMPORT_FAILED')
);
} finally {
isCreating.value = false;
}
};
watch(accessToken, invalidateValidation);
watch(selectedImportTypes, () => {
invalidateValidation();
if (accessToken.value.trim() && selectedImportTypes.value.length) {
validateSource();
}
});
watch(
() => props.show,
show => {
if (show) {
dialogRef.value?.open();
return;
}
dialogRef.value?.close();
accessToken.value = '';
validationState.value = 'idle';
validationMessage.value = '';
}
);
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t('DATA_IMPORTS.DRAWER.TITLE')"
:confirm-button-label="$t('DATA_IMPORTS.DRAWER.IMPORT')"
:cancel-button-label="$t('DATA_IMPORTS.DRAWER.CANCEL')"
:disable-confirm-button="!canCreate"
:is-loading="isCreating || validationState === 'validating'"
width="md"
@confirm="createImport"
@close="closeDrawer"
>
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1.5 text-heading-3 text-n-slate-12">
{{ $t('DATA_IMPORTS.DRAWER.SOURCE') }}
<Select
v-model="sourceProvider"
class="!w-full [&>select]:w-full"
:options="sourceOptions"
/>
</label>
<Input
v-model="importName"
:label="$t('DATA_IMPORTS.DRAWER.NAME')"
:placeholder="$t('DATA_IMPORTS.DRAWER.NAME_PLACEHOLDER')"
/>
<Input
v-model="accessToken"
type="password"
autocomplete="off"
:label="$t('DATA_IMPORTS.DRAWER.ACCESS_KEY')"
:placeholder="$t('DATA_IMPORTS.DRAWER.ACCESS_KEY_PLACEHOLDER')"
:message="validationMessage"
:message-type="tokenMessageType"
@blur="validateSource"
/>
<fieldset class="flex flex-col gap-2.5">
<legend class="mb-1.5 text-heading-3 text-n-slate-12">
{{ $t('DATA_IMPORTS.DRAWER.DATA_TYPES') }}
</legend>
<label
class="inline-flex cursor-pointer items-center gap-2 text-body-main text-n-slate-12"
>
<Checkbox
:model-value="selectedImportTypes.includes('contacts')"
@change="toggleImportType('contacts')"
/>
{{ $t('DATA_IMPORTS.TYPES.CONTACTS') }}
</label>
<label
class="inline-flex cursor-pointer items-center gap-2 text-body-main text-n-slate-12"
>
<Checkbox
:model-value="selectedImportTypes.includes('conversations')"
@change="toggleImportType('conversations')"
/>
{{ $t('DATA_IMPORTS.TYPES.CONVERSATIONS') }}
</label>
</fieldset>
<p
v-if="hasActiveImport"
class="rounded-lg bg-n-amber-2 px-3 py-2 text-body-main text-n-amber-11"
>
{{ $t('DATA_IMPORTS.DRAWER.ACTIVE_IMPORT') }}
</p>
</div>
</Dialog>
</template>
@@ -1,238 +0,0 @@
<script setup>
import {
computed,
onActivated,
onBeforeUnmount,
onDeactivated,
ref,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import DataImportsAPI from 'dashboard/api/dataImports';
import { POLL_INTERVAL_MS, isActiveImport } from './importStatus';
import SettingsLayout from '../SettingsLayout.vue';
import ImportDetailHeader from './components/ImportDetailHeader.vue';
import ImportSummaryTiles from './components/ImportSummaryTiles.vue';
import ImportProgress from './components/ImportProgress.vue';
import ImportErrorsSection from './components/ImportErrorsSection.vue';
import ImportSkipLogsSection from './components/ImportSkipLogsSection.vue';
const { t } = useI18n();
const route = useRoute();
const dataImport = ref(null);
const isLoading = ref(true);
const isRefreshing = ref(false);
const isPolling = ref(false);
const isAbandoning = ref(false);
const isDownloadingErrorLogs = ref(false);
const isDownloadingSkipLogs = ref(false);
const isChangingSkipLogsType = ref(false);
const selectedSkipLogsType = ref('');
const errorsOpen = ref(true);
const skipLogsOpen = ref(true);
let pollTimer;
let isPageActive = false;
const hasActiveImport = computed(() => isActiveImport(dataImport.value));
const stopPolling = () => {
if (!pollTimer) return;
window.clearInterval(pollTimer);
pollTimer = null;
};
const fetchImport = async ({
showLoader = false,
manual = false,
requestedSkipLogsType = selectedSkipLogsType.value,
} = {}) => {
if (showLoader) {
isLoading.value = true;
} else if (manual) {
isRefreshing.value = true;
}
try {
const response = await DataImportsAPI.show(route.params.dataImportId, {
skip_logs_type: requestedSkipLogsType || undefined,
});
dataImport.value = response.data;
selectedSkipLogsType.value =
response.data.skip_logs_filters?.selected_source_object_type ||
requestedSkipLogsType ||
'';
} finally {
if (showLoader) isLoading.value = false;
if (manual) isRefreshing.value = false;
if (!hasActiveImport.value) stopPolling();
}
};
const changeSkipLogsType = async type => {
if (type === selectedSkipLogsType.value || isChangingSkipLogsType.value) {
return;
}
selectedSkipLogsType.value = type;
isChangingSkipLogsType.value = true;
try {
await fetchImport({ requestedSkipLogsType: type });
} finally {
isChangingSkipLogsType.value = false;
}
};
const refreshImportInBackground = async () => {
if (
!isPageActive ||
isPolling.value ||
!hasActiveImport.value ||
document.hidden
) {
return;
}
isPolling.value = true;
try {
await fetchImport();
} finally {
isPolling.value = false;
if (!hasActiveImport.value) stopPolling();
}
};
const abandonImport = async () => {
isAbandoning.value = true;
try {
const response = await DataImportsAPI.abandon(dataImport.value.id);
dataImport.value = response.data;
stopPolling();
useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_ABANDONED'));
} finally {
isAbandoning.value = false;
}
};
const downloadCsv = (response, filename) => {
const url = window.URL.createObjectURL(
new Blob([response.data], { type: 'text/csv' })
);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
};
const downloadErrorLogs = async () => {
isDownloadingErrorLogs.value = true;
try {
const response = await DataImportsAPI.downloadErrorLogs(
dataImport.value.id
);
downloadCsv(response, `data-import-${dataImport.value.id}-error-logs.csv`);
} finally {
isDownloadingErrorLogs.value = false;
}
};
const downloadSkipLogs = async () => {
isDownloadingSkipLogs.value = true;
try {
const response = await DataImportsAPI.downloadSkipLogs(dataImport.value.id);
downloadCsv(response, `data-import-${dataImport.value.id}-skip-logs.csv`);
} finally {
isDownloadingSkipLogs.value = false;
}
};
const startPolling = () => {
stopPolling();
if (!isPageActive || !hasActiveImport.value) return;
pollTimer = window.setInterval(refreshImportInBackground, POLL_INTERVAL_MS);
};
const handleVisibilityChange = () => {
if (isPageActive && !document.hidden && hasActiveImport.value) {
refreshImportInBackground();
}
};
onActivated(async () => {
isPageActive = true;
await fetchImport({ showLoader: true });
if (!isPageActive) return;
// Collapse empty sections by default; expand the ones with records.
errorsOpen.value = Boolean(dataImport.value?.import_errors_count);
skipLogsOpen.value = Boolean(dataImport.value?.skip_logs_count);
startPolling();
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onDeactivated(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
isPageActive = false;
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
<template>
<SettingsLayout
:is-loading="isLoading"
:loading-message="$t('DATA_IMPORTS.LOADING')"
>
<template #header>
<ImportDetailHeader
:data-import="dataImport"
:is-refreshing="isRefreshing"
:is-abandoning="isAbandoning"
:is-polling="isPolling"
@refresh="fetchImport({ manual: true })"
@abandon="abandonImport"
/>
</template>
<template #body>
<div v-if="dataImport" class="flex flex-col gap-3">
<ImportSummaryTiles :data-import="dataImport" />
<ImportProgress
v-if="dataImport.import_types?.length"
:data-import="dataImport"
:title="$t('DATA_IMPORTS.DETAIL.PROGRESS')"
/>
<ImportErrorsSection
:data-import="dataImport"
:is-open="errorsOpen"
:is-downloading="isDownloadingErrorLogs"
@toggle="errorsOpen = !errorsOpen"
@download="downloadErrorLogs"
/>
<ImportSkipLogsSection
:data-import="dataImport"
:is-open="skipLogsOpen"
:is-downloading="isDownloadingSkipLogs"
:selected-type="selectedSkipLogsType"
:is-changing-type="isChangingSkipLogsType"
@toggle="skipLogsOpen = !skipLogsOpen"
@download="downloadSkipLogs"
@change-type="changeSkipLogsType"
/>
</div>
</template>
</SettingsLayout>
</template>
@@ -1,128 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import {
POLL_INTERVAL_MS,
importStageKey,
isAbandonableImport,
isActiveImport,
statusDotClass as getStatusDotClass,
} from '../importStatus';
const props = defineProps({
dataImport: {
type: Object,
default: null,
},
isRefreshing: {
type: Boolean,
default: false,
},
isAbandoning: {
type: Boolean,
default: false,
},
isPolling: {
type: Boolean,
default: false,
},
});
defineEmits(['refresh', 'abandon']);
const { t } = useI18n();
const pollIntervalSeconds = POLL_INTERVAL_MS / 1000;
const title = computed(
() => props.dataImport?.name || t('DATA_IMPORTS.TABLE.UNNAMED')
);
const stageLabels = computed(() => ({
unknown: t('DATA_IMPORTS.MONITOR.STAGES.unknown'),
queued: t('DATA_IMPORTS.MONITOR.STAGES.queued'),
contacts: t('DATA_IMPORTS.MONITOR.STAGES.contacts'),
conversations: t('DATA_IMPORTS.MONITOR.STAGES.conversations'),
finalizing: t('DATA_IMPORTS.MONITOR.STAGES.finalizing'),
completed: t('DATA_IMPORTS.MONITOR.STAGES.completed'),
completed_with_errors: t('DATA_IMPORTS.MONITOR.STAGES.completed_with_errors'),
failed: t('DATA_IMPORTS.MONITOR.STAGES.failed'),
abandoned: t('DATA_IMPORTS.MONITOR.STAGES.abandoned'),
}));
const monitorTitle = computed(
() =>
stageLabels.value[importStageKey(props.dataImport)] ||
stageLabels.value.unknown
);
const statusDotClass = computed(() =>
getStatusDotClass(props.dataImport?.status)
);
const hasActiveImport = computed(() => isActiveImport(props.dataImport));
const canAbandonImport = computed(() => isAbandonableImport(props.dataImport));
</script>
<template>
<BaseSettingsHeader
:title="title"
:back-button-label="$t('DATA_IMPORTS.DETAIL.BACK')"
>
<template #title>
<div class="flex w-full items-center justify-between gap-4">
<h1 class="min-w-0 truncate text-heading-1 text-n-slate-12">
{{ title }}
</h1>
<div class="flex shrink-0 items-center gap-2">
<Button
v-if="hasActiveImport"
outline
slate
size="sm"
icon="i-lucide-refresh-cw"
:is-loading="isRefreshing"
:aria-label="$t('DATA_IMPORTS.MONITOR.REFRESH')"
:title="$t('DATA_IMPORTS.MONITOR.REFRESH')"
@click="$emit('refresh')"
/>
<Button
v-if="canAbandonImport"
ruby
size="sm"
:is-loading="isAbandoning"
:label="$t('DATA_IMPORTS.TABLE.ABANDON')"
@click="$emit('abandon')"
/>
</div>
</div>
</template>
<template #description>
<span class="inline-flex items-center gap-1.5 align-middle">
<span
class="size-2 rounded-full"
:class="[statusDotClass, { 'animate-pulse': hasActiveImport }]"
/>
{{ monitorTitle }}
</span>
<template v-if="hasActiveImport">
<span
class="mx-2 inline-block h-3 w-px rounded-lg bg-n-strong align-middle"
/>
<span class="text-n-teal-11">
{{
isPolling
? $t('DATA_IMPORTS.MONITOR.REFRESHING')
: $t('DATA_IMPORTS.MONITOR.LIVE', {
seconds: pollIntervalSeconds,
})
}}
</span>
</template>
</template>
</BaseSettingsHeader>
</template>
@@ -1,78 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
import ImportLogSection from './ImportLogSection.vue';
import { formatDate, sourceObjectLabel } from '../importStatus';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
isOpen: {
type: Boolean,
default: false,
},
isDownloading: {
type: Boolean,
default: false,
},
});
defineEmits(['toggle', 'download']);
const { t } = useI18n();
const errors = computed(() => props.dataImport?.import_errors || []);
const headers = computed(() => [
t('DATA_IMPORTS.DETAIL.ERROR_CODE'),
t('DATA_IMPORTS.DETAIL.SOURCE_OBJECT'),
t('DATA_IMPORTS.DETAIL.MESSAGE'),
t('DATA_IMPORTS.DETAIL.CREATED'),
]);
</script>
<template>
<ImportLogSection
:title="$t('DATA_IMPORTS.DETAIL.ERRORS')"
:count="dataImport.import_errors_count"
:is-open="isOpen"
:is-downloading="isDownloading"
:download-label="$t('DATA_IMPORTS.DETAIL.DOWNLOAD_ERROR_LOGS')"
:headers="headers"
:items="errors"
:empty-message="$t('DATA_IMPORTS.DETAIL.NO_ERRORS')"
@toggle="$emit('toggle')"
@download="$emit('download')"
>
<template #row="{ items }">
<BaseTableRow v-for="error in items" :key="error.id" :item="error">
<template #default>
<BaseTableCell>
<span class="text-body-main text-n-slate-12">
{{ error.error_code }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-12">
{{ sourceObjectLabel(error) }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ error.message || '-' }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="whitespace-nowrap text-body-main text-n-slate-11">
{{ formatDate(error.created_at) }}
</span>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</ImportLogSection>
</template>
@@ -1,105 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { BaseTable } from 'dashboard/components-next/table';
defineProps({
title: {
type: String,
required: true,
},
count: {
type: Number,
default: 0,
},
isOpen: {
type: Boolean,
default: false,
},
isDownloading: {
type: Boolean,
default: false,
},
downloadLabel: {
type: String,
default: '',
},
headers: {
type: Array,
default: () => [],
},
items: {
type: Array,
default: () => [],
},
emptyMessage: {
type: String,
default: '',
},
});
defineEmits(['toggle', 'download']);
</script>
<template>
<section class="overflow-hidden rounded-xl border border-n-weak bg-n-solid-1">
<div class="flex items-center justify-between gap-3 px-4 py-3">
<button
type="button"
class="flex min-w-0 items-center gap-2 !p-0"
:aria-expanded="isOpen"
@click="$emit('toggle')"
>
<h2 class="text-heading-3 text-n-slate-12">{{ title }}</h2>
<span
v-if="count"
class="rounded-md bg-n-alpha-2 px-1.5 text-label-small tabular-nums text-n-slate-11"
>
{{ count }}
</span>
<Icon
icon="i-lucide-chevron-down"
class="size-4 shrink-0 text-n-slate-10 transition-transform duration-200"
:class="{ '-rotate-90 rtl:rotate-90': !isOpen }"
/>
</button>
<Button
ghost
slate
xs
icon="i-lucide-download"
:is-loading="isDownloading"
:disabled="!count"
:label="downloadLabel"
@click="$emit('download')"
/>
</div>
<div
class="grid transition-[grid-template-rows] duration-300 ease-in-out"
:class="isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<div class="min-h-0 overflow-hidden">
<div class="border-t border-n-weak">
<slot name="filters" />
<p
v-if="!items.length"
class="px-4 py-8 text-center text-body-main text-n-slate-11"
>
{{ emptyMessage }}
</p>
<div v-else class="overflow-x-auto">
<BaseTable
class="[&_td:first-child]:ps-4 [&_th:first-child]:ps-4 [&_th]:text-n-slate-11 [&_thead]:border-t-0"
:headers="headers"
:items="items"
>
<template #row="{ items: rows }">
<slot name="row" :items="rows" />
</template>
</BaseTable>
</div>
</div>
</div>
</div>
</section>
</template>
@@ -1,100 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
title: {
type: String,
required: true,
},
});
const { t } = useI18n();
const items = computed(() => {
const importTypes = props.dataImport?.import_types || [];
const groups = [];
if (importTypes.includes('contacts')) {
groups.push({ key: 'contacts', label: t('DATA_IMPORTS.TYPES.CONTACTS') });
}
if (importTypes.includes('conversations')) {
groups.push(
{ key: 'conversations', label: t('DATA_IMPORTS.TYPES.CONVERSATIONS') },
{ key: 'messages', label: t('DATA_IMPORTS.TYPES.MESSAGES') }
);
}
return groups.map(({ key, label }) => {
const stats = props.dataImport?.stats?.[key] || {};
const imported = Number(stats.imported || 0);
const hasTotal = Object.prototype.hasOwnProperty.call(stats, 'total');
const total = hasTotal ? Number(stats.total) : null;
const percent =
hasTotal && total > 0
? Math.min(100, Math.round((imported / total) * 100))
: null;
return {
key,
label,
percent,
importedLabel: imported.toLocaleString(),
caption: hasTotal
? t('DATA_IMPORTS.DETAIL.PROGRESS_OF_TOTAL', {
total: total.toLocaleString(),
})
: t('DATA_IMPORTS.DETAIL.PROGRESS_IMPORTED'),
};
});
});
// Fit the grid to the number of groups so no empty cells show.
const columnsClass = computed(() => {
if (items.value.length >= 3) return 'sm:grid-cols-3';
if (items.value.length === 2) return 'sm:grid-cols-2';
return 'sm:grid-cols-1';
});
</script>
<template>
<section class="overflow-hidden rounded-xl border border-n-weak bg-n-solid-1">
<h2 class="border-b border-n-weak px-4 py-3 text-heading-3 text-n-slate-12">
{{ title }}
</h2>
<div class="grid grid-cols-1 gap-px bg-n-weak" :class="columnsClass">
<div
v-for="item in items"
:key="item.key"
class="flex flex-col gap-2 bg-n-solid-1 px-4 py-3"
>
<span class="text-label-small text-n-slate-11">{{ item.label }}</span>
<div class="flex items-end justify-between gap-2">
<span
class="text-xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
{{ item.importedLabel }}
</span>
<span
v-if="item.percent !== null"
class="text-label-small tabular-nums text-n-slate-11"
>
{{ `${item.percent}%` }}
</span>
</div>
<div
v-if="item.percent !== null"
class="h-1.5 w-full overflow-hidden rounded-full bg-n-alpha-2"
>
<div
class="h-full rounded-full bg-n-brand transition-all duration-500"
:style="{ width: `${item.percent}%` }"
/>
</div>
<span class="text-label-small text-n-slate-10">{{ item.caption }}</span>
</div>
</div>
</section>
</template>
@@ -1,127 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
import ImportLogSection from './ImportLogSection.vue';
import { formatDate, sourceObjectLabel } from '../importStatus';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
isOpen: {
type: Boolean,
default: false,
},
isDownloading: {
type: Boolean,
default: false,
},
selectedType: {
type: String,
default: '',
},
isChangingType: {
type: Boolean,
default: false,
},
});
defineEmits(['toggle', 'download', 'changeType']);
const { t } = useI18n();
const skipLogs = computed(() => props.dataImport?.skip_logs || []);
const headers = computed(() => [
t('DATA_IMPORTS.DETAIL.KIND'),
t('DATA_IMPORTS.DETAIL.SOURCE_OBJECT'),
t('DATA_IMPORTS.DETAIL.MESSAGE'),
t('DATA_IMPORTS.DETAIL.CREATED'),
]);
const typeOptions = computed(() => {
const counts = props.dataImport?.skip_logs_filters?.counts_by_type || {};
return [
{
value: '',
label: t('DATA_IMPORTS.DETAIL.ALL_SKIP_LOGS'),
count: props.dataImport?.skip_logs_count || 0,
},
{
value: 'contact',
label: t('DATA_IMPORTS.TYPES.CONTACTS'),
count: counts.contact || 0,
},
{
value: 'conversation',
label: t('DATA_IMPORTS.TYPES.CONVERSATIONS'),
count: counts.conversation || 0,
},
{
value: 'message',
label: t('DATA_IMPORTS.TYPES.MESSAGES'),
count: counts.message || 0,
},
];
});
</script>
<template>
<ImportLogSection
:title="$t('DATA_IMPORTS.DETAIL.SKIP_LOGS')"
:count="dataImport.skip_logs_count"
:is-open="isOpen"
:is-downloading="isDownloading"
:download-label="$t('DATA_IMPORTS.DETAIL.DOWNLOAD_SKIP_LOGS')"
:headers="headers"
:items="skipLogs"
:empty-message="$t('DATA_IMPORTS.DETAIL.NO_SKIP_LOGS')"
@toggle="$emit('toggle')"
@download="$emit('download')"
>
<template v-if="dataImport.skip_logs_count" #filters>
<div class="flex flex-wrap gap-2 border-b border-n-weak px-4 py-3">
<Button
v-for="option in typeOptions"
:key="option.value || 'all'"
:variant="option.value === selectedType ? 'solid' : 'faded'"
color="slate"
size="xs"
:disabled="!option.count || isChangingType"
:label="`${option.label} (${option.count})`"
@click="$emit('changeType', option.value)"
/>
</div>
</template>
<template #row="{ items }">
<BaseTableRow v-for="skipLog in items" :key="skipLog.id" :item="skipLog">
<template #default>
<BaseTableCell>
<span class="capitalize text-body-main text-n-slate-12">
{{ skipLog.kind || '-' }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ sourceObjectLabel(skipLog) }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ skipLog.message || '-' }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="whitespace-nowrap text-body-main text-n-slate-11">
{{ formatDate(skipLog.created_at) }}
</span>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</ImportLogSection>
</template>
@@ -1,110 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import formatDistanceStrict from 'date-fns/formatDistanceStrict';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { formatDate, isActiveImport } from '../importStatus';
import { importSourceFor } from '../importSources';
const props = defineProps({
dataImport: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const importTypeLabel = type => {
if (type === 'contacts') return t('DATA_IMPORTS.TYPES.CONTACTS');
if (type === 'conversations') return t('DATA_IMPORTS.TYPES.CONVERSATIONS');
return type;
};
const importTypesLabel = computed(() => {
const importTypes = props.dataImport?.import_types?.length
? props.dataImport.import_types
: [props.dataImport?.data_type].filter(Boolean);
return importTypes.map(importTypeLabel).join(', ');
});
const runDuration = computed(() => {
const startedAt =
props.dataImport?.started_at || props.dataImport?.created_at;
if (!startedAt) return '-';
const finishedAt =
props.dataImport?.completed_at ||
props.dataImport?.abandoned_at ||
(isActiveImport(props.dataImport)
? new Date()
: props.dataImport?.updated_at);
if (!finishedAt) return '-';
return formatDistanceStrict(new Date(startedAt), new Date(finishedAt));
});
const items = computed(() => [
{
key: 'source',
icon: 'i-lucide-plug',
label: t('DATA_IMPORTS.DETAIL.SOURCE'),
value: importSourceFor(props.dataImport).label,
},
{
key: 'import_types',
icon: 'i-lucide-layers',
label: t('DATA_IMPORTS.DETAIL.IMPORT_TYPES'),
value: importTypesLabel.value || '-',
},
{
key: 'created_at',
icon: 'i-lucide-calendar',
label: t('DATA_IMPORTS.DETAIL.CREATED'),
value: formatDate(props.dataImport?.created_at),
},
{
key: 'duration',
icon: 'i-lucide-clock',
label: t('DATA_IMPORTS.DETAIL.DURATION'),
value: runDuration.value,
tooltip: t('DATA_IMPORTS.DETAIL.LAST_UPDATED_TOOLTIP', {
time: formatDate(props.dataImport?.updated_at),
}),
},
{
key: 'initiated_by',
icon: 'i-lucide-user',
label: t('DATA_IMPORTS.DETAIL.INITIATED_BY'),
value:
props.dataImport?.initiated_by?.name ||
props.dataImport?.initiated_by?.email ||
'-',
},
]);
</script>
<template>
<dl
class="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-n-weak bg-n-weak sm:grid-cols-3 lg:grid-cols-5"
>
<div
v-for="item in items"
:key="item.key"
class="flex min-w-0 flex-col gap-1 bg-n-solid-1 px-4 py-3"
>
<dt class="flex items-center gap-1.5 text-label-small text-n-slate-10">
<Icon :icon="item.icon" class="size-3.5 shrink-0" />
{{ item.label }}
</dt>
<dd
v-tooltip.top="item.tooltip"
class="truncate text-heading-3 text-n-slate-12"
>
{{ item.value }}
</dd>
</div>
</dl>
</template>
@@ -1,34 +0,0 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
import Show from './Show.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/data'),
component: SettingsWrapper,
children: [
{
path: '',
name: 'settings_data_imports',
component: Index,
meta: {
featureFlag: FEATURE_FLAGS.DATA_IMPORT,
permissions: ['administrator'],
},
},
{
path: ':dataImportId',
name: 'settings_data_import_show',
component: Show,
meta: {
featureFlag: FEATURE_FLAGS.DATA_IMPORT,
permissions: ['administrator'],
},
},
],
},
],
};
@@ -1,17 +0,0 @@
export const IMPORT_SOURCES = [
{
value: 'intercom',
label: 'Intercom',
icon: '/dashboard/images/integrations/intercom.png',
},
];
const DEFAULT_IMPORT_SOURCE = {
value: 'file',
label: 'File import',
iconClass: 'i-lucide-file-text',
};
export const importSourceFor = dataImport =>
IMPORT_SOURCES.find(source => source.value === dataImport?.source_provider) ||
DEFAULT_IMPORT_SOURCE;
@@ -1,84 +0,0 @@
export const POLL_INTERVAL_MS = 5000;
export const ACTIVE_IMPORT_STATUSES = ['pending', 'processing'];
export const isActiveImport = dataImport =>
ACTIVE_IMPORT_STATUSES.includes(dataImport?.status);
export const isIntercomImport = dataImport =>
dataImport?.data_type === 'intercom' &&
dataImport?.source_provider === 'intercom';
export const isActiveIntercomImport = dataImport =>
isIntercomImport(dataImport) && isActiveImport(dataImport);
export const isAbandonableImport = dataImport =>
isActiveIntercomImport(dataImport);
export const importedCount = dataImport => {
if (!isIntercomImport(dataImport)) {
return Number(dataImport?.processed_records || 0);
}
return ['contacts', 'conversations', 'messages'].reduce(
(total, key) => total + Number(dataImport?.stats?.[key]?.imported || 0),
0
);
};
export const importStageKey = dataImport => {
if (!dataImport) return 'unknown';
if (dataImport.status === 'completed') return 'completed';
if (dataImport.status === 'completed_with_errors') {
return 'completed_with_errors';
}
if (dataImport.status === 'failed') return 'failed';
if (dataImport.status === 'abandoned') return 'abandoned';
if (dataImport.status === 'pending') return 'queued';
const importTypes = dataImport.import_types?.length
? dataImport.import_types
: [dataImport.data_type];
const cursor = dataImport.cursor || {};
if (importTypes.includes('contacts') && !cursor.contacts?.completed) {
return 'contacts';
}
if (
importTypes.includes('conversations') &&
!cursor.conversations?.completed
) {
return 'conversations';
}
return 'finalizing';
};
export const formatStatus = value => value?.replaceAll('_', ' ') || '-';
export const sourceObjectLabel = record =>
[record.source_object_type, record.source_object_id]
.filter(Boolean)
.join(': ') || '-';
export const formatDate = value => {
if (!value) return '-';
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
};
const STATUS_DOT_CLASS = {
pending: 'bg-n-amber-9',
processing: 'bg-n-blue-9',
completed: 'bg-n-teal-9',
completed_with_errors: 'bg-n-amber-9',
failed: 'bg-n-ruby-9',
abandoned: 'bg-n-slate-9',
};
export const statusDotClass = status =>
STATUS_DOT_CLASS[status] || 'bg-n-slate-9';
@@ -1,92 +0,0 @@
import {
formatDate,
importedCount,
isActiveIntercomImport,
statusDotClass,
} from '../importStatus';
describe('importStatus', () => {
describe('isActiveIntercomImport', () => {
it('only treats pending or processing Intercom imports as active', () => {
expect(
isActiveIntercomImport({
data_type: 'intercom',
source_provider: 'intercom',
status: 'processing',
})
).toBe(true);
expect(
isActiveIntercomImport({
data_type: 'contacts',
source_provider: null,
status: 'processing',
})
).toBe(false);
expect(
isActiveIntercomImport({
data_type: 'intercom',
source_provider: 'intercom',
status: 'completed',
})
).toBe(false);
});
});
describe('importedCount', () => {
it('sums Intercom imported stats', () => {
expect(
importedCount({
data_type: 'intercom',
source_provider: 'intercom',
processed_records: 20,
stats: {
contacts: { imported: 2 },
conversations: { imported: 3 },
messages: { imported: 10 },
},
})
).toBe(15);
});
it('uses processed records for legacy imports', () => {
expect(
importedCount({
data_type: 'contacts',
source_provider: null,
processed_records: 7,
stats: {},
})
).toBe(7);
});
});
describe('statusDotClass', () => {
it('maps each status to its dot color class', () => {
expect(statusDotClass('pending')).toBe('bg-n-amber-9');
expect(statusDotClass('processing')).toBe('bg-n-blue-9');
expect(statusDotClass('completed')).toBe('bg-n-teal-9');
expect(statusDotClass('completed_with_errors')).toBe('bg-n-amber-9');
expect(statusDotClass('failed')).toBe('bg-n-ruby-9');
expect(statusDotClass('abandoned')).toBe('bg-n-slate-9');
});
it('falls back to slate for unknown or missing status', () => {
expect(statusDotClass('unknown')).toBe('bg-n-slate-9');
expect(statusDotClass(undefined)).toBe('bg-n-slate-9');
});
});
describe('formatDate', () => {
it('returns a dash for empty values', () => {
expect(formatDate(null)).toBe('-');
expect(formatDate('')).toBe('-');
expect(formatDate(undefined)).toBe('-');
});
it('formats a valid date into a readable string', () => {
const formatted = formatDate('2026-07-10T18:09:00Z');
expect(formatted).not.toBe('-');
expect(formatted).toContain('2026');
});
});
});
@@ -1,121 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils';
import { KeepAlive, defineComponent, h, nextTick, ref } from 'vue';
import DataImportsAPI from 'dashboard/api/dataImports';
import Index from '../Index.vue';
import Show from '../Show.vue';
vi.mock('dashboard/api/dataImports', () => ({
default: {
get: vi.fn(),
show: vi.fn(),
},
}));
vi.mock('dashboard/composables/store', () => ({
useStoreGetters: () => ({ getCurrentAccountId: { value: 1 } }),
}));
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(),
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
vi.mock('vue-router', async importOriginal => ({
...(await importOriginal()),
useRoute: () => ({ params: { dataImportId: 1 } }),
useRouter: () => ({ push: vi.fn() }),
}));
const deferredRequest = () => {
let resolve;
const promise = new Promise(resolvePromise => {
resolve = resolvePromise;
});
return { promise, resolve };
};
const mountKeptAlive = component => {
const Host = defineComponent({
setup() {
const visible = ref(true);
return { visible };
},
render() {
return h(KeepAlive, null, {
default: () => (this.visible ? h(component) : null),
});
},
});
return mount(Host, {
global: {
stubs: {
SettingsLayout: true,
BaseSettingsHeader: true,
Button: true,
Icon: true,
TabBar: true,
NewImportDialog: true,
ImportDetailHeader: true,
ImportSummaryTiles: true,
ImportProgress: true,
ImportErrorsSection: true,
ImportSkipLogsSection: true,
},
mocks: {
$t: key => key,
},
},
});
};
describe('data import polling lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('does not start list polling after the page deactivates', async () => {
const request = deferredRequest();
DataImportsAPI.get.mockReturnValue(request.promise);
const wrapper = mountKeptAlive(Index);
await nextTick();
wrapper.vm.visible = false;
await nextTick();
request.resolve({ data: { payload: [{ status: 'processing' }] } });
await flushPromises();
await vi.advanceTimersByTimeAsync(5000);
expect(DataImportsAPI.get).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
it('does not start detail polling after the page deactivates', async () => {
const request = deferredRequest();
DataImportsAPI.show.mockReturnValue(request.promise);
const wrapper = mountKeptAlive(Show);
await nextTick();
wrapper.vm.visible = false;
await nextTick();
request.resolve({
data: {
status: 'processing',
skip_logs_filters: {},
},
});
await flushPromises();
await vi.advanceTimersByTimeAsync(5000);
expect(DataImportsAPI.show).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
});
@@ -1,5 +1,5 @@
<script setup>
import { computed, onActivated, ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
@@ -27,10 +27,6 @@ const searchQuery = ref('');
const inboxes = useMapGetter('inboxes/getInboxes');
onActivated(() => {
store.dispatch('inboxes/get');
});
const inboxesList = computed(() => {
return inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name));
});
@@ -4,8 +4,6 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
@@ -29,8 +27,6 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
import AccountHealth from './components/AccountHealth.vue';
import WhatsappManualMigrationDialog from './components/WhatsappManualMigrationDialog.vue';
import WhatsappManualMigrationBanner from './components/WhatsappManualMigrationBanner.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
@@ -46,11 +42,9 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
export default {
components: {
Banner,
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
@@ -80,11 +74,8 @@ export default {
ColorPicker,
SelectInput,
AccountHealth,
WhatsappManualMigrationDialog,
WhatsappManualMigrationBanner,
Widget,
AccessToken,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -116,7 +107,6 @@ export default {
isLoadingHealth: false,
healthError: null,
isRegisteringWebhook: false,
isTransferringWhatsAppToManual: false,
widgetBubblePosition: 'right',
widgetBubbleType: 'standard',
widgetBubbleLauncherTitle: '',
@@ -348,12 +338,6 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
showInstagramRestrictionSettingsBanner() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
metaRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
@@ -387,12 +371,10 @@ export default {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappUnauthorized() {
// The manual migration banner supersedes the embedded-signup reauthorize flow when the feature is enabled.
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.inbox.reauthorization_required &&
!this.showWhatsAppManualMigration
this.inbox.reauthorization_required
);
},
whatsappRegistrationIncomplete() {
@@ -409,16 +391,6 @@ export default {
this.healthData.throughput?.level === 'NOT_APPLICABLE'
);
},
showWhatsAppManualMigration() {
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
)
);
},
widgetBuilderStorageKey() {
return `${LOCAL_STORAGE_KEYS.WIDGET_BUILDER}${this.inbox.id}`;
},
@@ -430,7 +402,6 @@ export default {
if (inboxChanged) {
this.syncInboxData();
this.setTabFromRouteParam();
this.openWhatsAppManualMigrationIfRequested();
}
}
},
@@ -441,7 +412,6 @@ export default {
this.fetchHealthData();
this.$nextTick(() => {
this.setTabFromRouteParam();
this.openWhatsAppManualMigrationIfRequested();
});
} else {
this.selectedFeatureFlags = newInbox?.selected_feature_flags || [];
@@ -452,52 +422,8 @@ export default {
},
mounted() {
this.fetchSharedData();
this.openWhatsAppManualMigrationIfRequested();
},
methods: {
openWhatsAppManualMigrationDialog() {
this.$refs.whatsappManualMigrationDialog?.open();
},
openWhatsAppManualMigrationIfRequested() {
if (
this.showWhatsAppManualMigration &&
this.$route.query.migration === 'whatsapp_manual'
) {
this.$nextTick(() => {
this.openWhatsAppManualMigrationDialog();
});
}
},
async transferWhatsAppToManualSetup(form) {
this.isTransferringWhatsAppToManual = true;
try {
const providerConfig = { ...(this.inbox.provider_config || {}) };
delete providerConfig.source;
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...providerConfig,
phone_number_id: form.phoneNumberId,
business_account_id: form.wabaId,
api_key: form.accessToken,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_TRANSFER_SUCCESS')
);
this.$refs.whatsappManualMigrationDialog?.close();
} catch (error) {
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_TRANSFER_ERROR')
);
} finally {
this.isTransferringWhatsAppToManual = false;
}
},
async copyWebhookSecret(value) {
await copyTextToClipboard(value);
useAlert(
@@ -811,35 +737,6 @@ export default {
class="mx-6 mb-4"
:class="bannerMaxWidth"
/>
<WhatsappManualMigrationBanner
v-if="showWhatsAppManualMigration"
class="mx-6 mb-6"
:class="bannerMaxWidth"
@start="openWhatsAppManualMigrationDialog"
/>
<Banner
v-if="showInstagramRestrictionSettingsBanner"
color="amber"
class="mx-6 mb-4 max-w-4xl"
>
<div class="flex items-start gap-3 text-start">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
<a
:href="metaRestrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div
v-if="selectedTabKey === 'inbox-settings'"
@@ -1408,13 +1305,6 @@ export default {
@register-webhook="registerWebhook"
/>
</div>
<WhatsappManualMigrationDialog
v-if="showWhatsAppManualMigration"
ref="whatsappManualMigrationDialog"
:inbox="inbox"
:is-loading="isTransferringWhatsAppToManual"
@reconnect="transferWhatsAppToManualSetup"
/>
</div>
</section>
</div>
@@ -5,7 +5,6 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
import InboxesAPI from 'dashboard/api/inboxes';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -13,12 +12,6 @@ export default {
components: {
NextButton,
},
props: {
enableCallingOnComplete: {
type: Boolean,
default: false,
},
},
setup() {
return { v$: useVuelidate() };
},
@@ -66,14 +59,6 @@ export default {
}
);
if (this.enableCallingOnComplete) {
try {
await InboxesAPI.enableWhatsappCalling(whatsappChannel.id);
} catch (_) {
useAlert(this.$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'));
}
}
router.replace({
name: 'settings_inboxes_add_agents',
params: {
@@ -180,7 +165,6 @@ export default {
<div class="w-full mt-4">
<NextButton
:disabled="uiFlags.isCreating"
:is-loading="uiFlags.isCreating"
type="submit"
solid
@@ -1,23 +1,15 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import instagramClient from 'dashboard/api/channel/instagramClient';
import Button from 'dashboard/components-next/button/Button.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const hasError = ref(false);
const errorStateMessage = ref('');
const errorStateDescription = ref('');
const isRequestingAuthorization = ref(false);
const isInstagramConnectionRestricted = computed(() => {
return isOnChatwootCloud.value;
});
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
@@ -64,7 +56,7 @@ const requestAuthorization = async () => {
</div>
<div
v-else
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
@@ -76,36 +68,11 @@ const requestAuthorization = async () => {
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
lg
icon="i-ri-instagram-line"
:disabled="
isRequestingAuthorization || isInstagramConnectionRestricted
"
:disabled="isRequestingAuthorization"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
@click="requestAuthorization()"
/>
<Banner
v-if="isInstagramConnectionRestricted"
color="amber"
class="w-full max-w-2xl mt-6"
>
<div class="flex items-start gap-3 text-left">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING') }}
<a
:href="META_RESTRICTION_STATUS_URL"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
</div>
</div>
</div>
@@ -7,13 +7,11 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
@@ -24,12 +22,9 @@ const PROVIDER_TYPES = {
THREE_SIXTY_DIALOG: '360dialog',
};
const isWhatsappEmbeddedSignupRestricted = computed(() => {
return isOnChatwootCloud.value;
});
const hasWhatsappAppId = computed(() => {
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
@@ -108,11 +103,7 @@ const handleManualLinkClick = () => {
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
<WhatsappEmbeddedSignup
:is-disabled="isWhatsappEmbeddedSignupRestricted"
:show-restriction-alert="isWhatsappEmbeddedSignupRestricted"
:restriction-status-url="META_RESTRICTION_STATUS_URL"
/>
<WhatsappEmbeddedSignup />
<!-- Manual setup fallback option -->
<div class="pt-6 mt-6 border-t border-n-weak">
@@ -1,11 +1,11 @@
<script setup>
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
</script>
<template>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<div class="px-6 py-5 rounded-2xl border border-n-weak">
<CloudWhatsapp enable-calling-on-complete />
<WhatsappEmbeddedSignup enable-calling-on-complete />
</div>
</div>
</template>
@@ -7,7 +7,6 @@ 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 Banner from 'next/banner/Banner.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
@@ -18,22 +17,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
isDisabled: {
type: Boolean,
default: false,
},
showRestrictionAlert: {
type: Boolean,
default: false,
},
restrictionStatusUrl: {
type: String,
default: '',
},
restrictionWarningText: {
type: String,
default: '',
},
});
const store = useStore();
@@ -98,8 +81,6 @@ const handleSignupSuccess = async inboxData => {
};
const launchEmbeddedSignup = async () => {
if (props.isDisabled) return;
let credentials;
try {
credentials = await runEmbeddedSignup();
@@ -193,33 +174,9 @@ const launchEmbeddedSignup = async () => {
</I18nT>
</div>
<Banner v-if="showRestrictionAlert" color="amber" class="w-full mb-6">
<div class="flex items-start gap-3 text-left">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{
restrictionWarningText ||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
}}
<a
v-if="restrictionStatusUrl"
:href="restrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div class="flex mt-4">
<NextButton
:disabled="isAuthenticating || isDisabled"
:disabled="isAuthenticating"
:is-loading="isAuthenticating"
faded
slate
@@ -1,46 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const emit = defineEmits(['start']);
const { t } = useI18n();
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
const copy = computed(() => ({
title: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.TITLE'),
description: t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.DESCRIPTION'
),
start: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.START'),
guide: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.GUIDE'),
}));
</script>
<template>
<Banner color="amber" :action-label="copy.start" @action="emit('start')">
<div class="flex items-start gap-2">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 mt-0.5 size-4 text-n-amber-11"
/>
<div class="flex flex-col gap-0.5">
<span class="font-medium text-n-amber-12">{{ copy.title }}</span>
<span>
{{ copy.description }}
<a
:href="WHATSAPP_MANUAL_MIGRATION_GUIDE_URL"
target="_blank"
rel="noopener noreferrer"
class="underline link underline-offset-2"
>
{{ copy.guide }}
</a>
</span>
</div>
</div>
</Banner>
</template>
@@ -1,524 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useBranding } from 'shared/composables/useBranding';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['reconnect']);
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
const dialogRef = ref(null);
const currentStep = ref(0);
const buildForm = () => ({
wabaId: props.inbox.provider_config?.business_account_id || '',
phoneNumberId: props.inbox.provider_config?.phone_number_id || '',
displayPhoneNumber: props.inbox.phone_number || '',
accessToken: '',
});
const form = ref(buildForm());
const copy = computed(() => ({
eyebrow: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.EYEBROW`
),
title: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TITLE`),
close: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CLOSE`),
actionRequiredTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACTION_REQUIRED_TITLE`
),
actionRequiredDescription: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACTION_REQUIRED_DESCRIPTION`
),
guideLink: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.GUIDE_LINK`
),
preservedTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PRESERVED_TITLE`
),
preservedDescription: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PRESERVED_DESCRIPTION`
),
updatedTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.UPDATED_TITLE`
),
updatedDescription: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.UPDATED_DESCRIPTION`
),
wabaId: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_ID`
),
wabaPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_PLACEHOLDER`
),
wabaHelp: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_HELP`
),
phoneNumberId: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_ID`
),
phoneNumberPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_PLACEHOLDER`
),
phoneNumberHelp: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_HELP`
),
displayPhoneNumber: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER`
),
displayPhoneNumberPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER_PLACEHOLDER`
),
displayPhoneNumberHelp: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER_HELP`
),
accessToken: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACCESS_TOKEN`
),
accessTokenPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACCESS_TOKEN_PLACEHOLDER`
),
tokenHelpPrefix: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_PREFIX`
),
tokenHelpMiddle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_MIDDLE`
),
tokenHelpSuffix: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_SUFFIX`
),
messagingPermission: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.MESSAGING_PERMISSION`
),
managementPermission: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.MANAGEMENT_PERMISSION`
),
reviewTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.REVIEW_TITLE`
),
inbox: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.INBOX`),
phoneNumber: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER`
),
notEntered: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.NOT_ENTERED`
),
verifyNotice: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.VERIFY_NOTICE`
),
back: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.BACK`),
cancel: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CANCEL`
),
continue: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CONTINUE`
),
reviewMigration: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.REVIEW_MIGRATION`
),
reconnect: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.RECONNECT`
),
}));
const steps = computed(() => [
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BEFORE_YOU_START.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BEFORE_YOU_START.DESCRIPTION`
),
},
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BUSINESS_DETAILS.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BUSINESS_DETAILS.DESCRIPTION`
),
},
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.ACCESS_TOKEN.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.ACCESS_TOKEN.DESCRIPTION`
),
},
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.REVIEW_MIGRATION.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.REVIEW_MIGRATION.DESCRIPTION`
),
},
]);
const currentStepDetails = computed(() => steps.value[currentStep.value]);
const isFirstStep = computed(() => currentStep.value === 0);
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
const guideUrl = WHATSAPP_MANUAL_MIGRATION_GUIDE_URL;
const hasBusinessDetails = computed(
() => form.value.wabaId.trim() && form.value.phoneNumberId.trim()
);
const hasAccessToken = computed(() => form.value.accessToken.trim());
const canContinue = computed(() => {
if (currentStep.value === 1) return hasBusinessDetails.value;
if (currentStep.value === 2) return hasAccessToken.value;
if (isLastStep.value) {
return hasBusinessDetails.value && hasAccessToken.value;
}
return true;
});
const open = () => {
currentStep.value = 0;
form.value = buildForm();
dialogRef.value?.open();
};
const close = () => dialogRef.value?.close();
const goBack = () => {
if (!isFirstStep.value) currentStep.value -= 1;
};
const goNext = () => {
if (!isLastStep.value) currentStep.value += 1;
};
const reconnect = () => {
if (!canContinue.value) return;
emit('reconnect', {
wabaId: form.value.wabaId.trim(),
phoneNumberId: form.value.phoneNumberId.trim(),
accessToken: form.value.accessToken.trim(),
});
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
position="top"
:show-confirm-button="false"
:show-cancel-button="false"
overflow-y-auto
>
<div class="flex flex-col gap-5 h-[24rem]">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="mb-1 text-sm font-medium text-n-slate-11">
{{ copy.eyebrow }}
</p>
<h3 class="m-0 text-xl font-semibold text-n-slate-12">
{{ copy.title }}
</h3>
<p class="mt-2 mb-0 text-sm text-n-slate-11 min-h-[2.5rem]">
{{ replaceInstallationName(currentStepDetails.description) }}
</p>
</div>
<NextButton
v-tooltip="copy.close"
type="button"
variant="ghost"
color="slate"
size="sm"
icon="i-lucide-x"
:aria-label="copy.close"
@click="close"
/>
</div>
<div
class="grid flex-1 gap-6 overflow-y-auto min-h-0 grid-cols-[11rem,1fr]"
>
<ol class="flex flex-col p-0 m-0 list-none">
<li
v-for="(step, index) in steps"
:key="step.title"
class="relative flex gap-3"
>
<div class="relative flex flex-col items-center">
<span
class="z-10 grid text-xs font-semibold transition-colors rounded-full size-6 place-content-center"
:class="
index < currentStep
? 'bg-n-teal-9 text-white'
: index === currentStep
? 'bg-n-blue-9 text-white'
: 'bg-n-alpha-2 text-n-slate-11 outline outline-1 outline-n-container -outline-offset-1'
"
>
<Icon
v-if="index < currentStep"
icon="i-lucide-check"
class="size-3.5"
/>
<span v-else>{{ index + 1 }}</span>
</span>
<span
v-if="index < steps.length - 1"
class="flex-1 my-1 rounded-full w-0.5"
:class="index < currentStep ? 'bg-n-teal-9' : 'bg-n-slate-4'"
/>
</div>
<span
class="text-sm leading-6 truncate min-w-0"
:class="[
index < steps.length - 1 ? 'pb-6' : '',
index === currentStep
? 'font-medium text-n-slate-12'
: 'text-n-slate-11',
]"
>
{{ step.title }}
</span>
</li>
</ol>
<div class="min-w-0">
<section v-if="currentStep === 0" class="flex flex-col gap-5">
<div
class="flex gap-3 p-3 border rounded-xl border-n-weak bg-n-alpha-2"
>
<span
class="grid flex-shrink-0 rounded-lg size-8 place-content-center bg-n-amber-3 text-n-amber-11"
>
<Icon icon="i-lucide-triangle-alert" class="size-4" />
</span>
<div>
<h4 class="mt-0 mb-1 text-base font-medium text-n-slate-12">
{{ copy.actionRequiredTitle }}
</h4>
<p class="m-0 text-sm text-n-slate-11">
{{ copy.actionRequiredDescription }}
</p>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="flex flex-col gap-1 p-3 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
>
<div class="flex items-center gap-2">
<Icon icon="i-lucide-check" class="size-4 text-n-teal-11" />
<p class="m-0 text-sm font-medium text-n-slate-12">
{{ copy.preservedTitle }}
</p>
</div>
<p class="m-0 text-sm text-n-slate-11">
{{ copy.preservedDescription }}
</p>
</div>
<div
class="flex flex-col gap-1 p-3 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
>
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-refresh-cw"
class="size-4 text-n-blue-11"
/>
<p class="m-0 text-sm font-medium text-n-slate-12">
{{ copy.updatedTitle }}
</p>
</div>
<p class="m-0 text-sm text-n-slate-11">
{{ copy.updatedDescription }}
</p>
</div>
</div>
<a
:href="guideUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-sm font-medium text-n-blue-11 hover:underline"
>
{{ copy.guideLink }}
<Icon icon="i-lucide-external-link" class="size-3.5" />
</a>
</section>
<section v-else-if="currentStep === 1" class="grid gap-4">
<div class="flex flex-col gap-1">
<Input
v-model="form.wabaId"
:label="copy.wabaId"
:placeholder="copy.wabaPlaceholder"
/>
<span class="text-xs leading-5 text-n-slate-11">
{{ copy.wabaHelp }}
</span>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<Input
v-model="form.phoneNumberId"
:label="copy.phoneNumberId"
:placeholder="copy.phoneNumberPlaceholder"
/>
<span class="text-xs leading-5 text-n-slate-11">
{{ copy.phoneNumberHelp }}
</span>
</div>
<div class="flex flex-col gap-1">
<Input
v-model="form.displayPhoneNumber"
disabled
:label="copy.displayPhoneNumber"
:placeholder="copy.displayPhoneNumberPlaceholder"
/>
<span class="text-xs leading-5 text-n-slate-11">
{{ copy.displayPhoneNumberHelp }}
</span>
</div>
</div>
</section>
<section v-else-if="currentStep === 2" class="grid gap-4">
<TextArea
v-model="form.accessToken"
:label="copy.accessToken"
:placeholder="copy.accessTokenPlaceholder"
auto-height
min-height="6rem"
max-height="12rem"
/>
<div
class="flex gap-3 p-3 border rounded-xl bg-n-blue-3 border-n-blue-4 text-n-blue-11"
>
<Icon icon="i-lucide-info" class="flex-shrink-0 size-4 mt-0.5" />
<p class="m-0 text-sm">
{{ copy.tokenHelpPrefix }}
<code>{{ copy.messagingPermission }}</code>
{{ copy.tokenHelpMiddle }}
<code>{{ copy.managementPermission }}</code>
{{ copy.tokenHelpSuffix }}
</p>
</div>
</section>
<section
v-else
class="flex flex-col gap-4 p-4 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
>
<h4 class="m-0 text-base font-medium text-n-slate-12">
{{ copy.reviewTitle }}
</h4>
<dl class="grid gap-3 m-0 sm:grid-cols-2">
<div>
<dt class="text-xs text-n-slate-11">{{ copy.inbox }}</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ inbox.name }}
</dd>
</div>
<div>
<dt class="text-xs text-n-slate-11">
{{ copy.phoneNumber }}
</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ form.displayPhoneNumber || inbox.phone_number }}
</dd>
</div>
<div>
<dt class="text-xs text-n-slate-11">{{ copy.wabaId }}</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ form.wabaId || copy.notEntered }}
</dd>
</div>
<div>
<dt class="text-xs text-n-slate-11">
{{ copy.phoneNumberId }}
</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ form.phoneNumberId || copy.notEntered }}
</dd>
</div>
</dl>
<div
class="flex gap-3 p-3 border rounded-lg bg-n-alpha-2 border-n-weak text-n-slate-11"
>
<Icon
icon="i-lucide-shield-check"
class="flex-shrink-0 size-4 mt-0.5 text-n-teal-11"
/>
<p class="m-0 text-sm">
{{ replaceInstallationName(copy.verifyNotice) }}
</p>
</div>
</section>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<NextButton
type="button"
variant="faded"
color="slate"
:disabled="isFirstStep"
@click="goBack"
>
{{ copy.back }}
</NextButton>
<div class="flex items-center gap-2">
<NextButton
type="button"
variant="ghost"
color="slate"
@click="close"
>
{{ copy.cancel }}
</NextButton>
<NextButton
v-if="!isLastStep"
type="button"
:disabled="!canContinue"
@click="goNext"
>
{{ currentStep === 2 ? copy.reviewMigration : copy.continue }}
</NextButton>
<NextButton
v-else
type="button"
color="teal"
:disabled="!canContinue"
:is-loading="isLoading"
@click="reconnect"
>
{{ copy.reconnect }}
</NextButton>
</div>
</div>
</template>
</Dialog>
</template>
@@ -10,6 +10,7 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TextArea from 'next/textarea/TextArea.vue';
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
export default {
@@ -21,6 +22,7 @@ export default {
SmtpSettings,
NextButton,
TextArea,
WhatsappReauthorize,
},
mixins: [inboxMixin],
props: {
@@ -37,6 +39,7 @@ export default {
hmacMandatory: false,
allowMobileWebview: false,
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
@@ -50,6 +53,9 @@ export default {
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappAppId() {
return window.chatwootConfig?.whatsappAppId;
},
isForwardingEnabled() {
return !!this.inbox.forwarding_enabled;
},
@@ -160,6 +166,11 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async handleReconfigure() {
if (this.$refs.whatsappReauth) {
await this.$refs.whatsappReauth.requestAuthorization();
}
},
async syncTemplates() {
this.isSyncingTemplates = true;
try {
@@ -351,17 +362,22 @@ export default {
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
v-if="whatsappAppId"
:label="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
"
:help-text="`${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')} ${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')}`"
>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
<div class="flex flex-col gap-1 items-start">
<NextButton @click="handleReconfigure">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</div>
</SettingsFieldSection>
</template>
<!-- Manual Setup Section -->
<template v-else-if="!isEmbeddedSignupWhatsApp">
<template v-else>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:help-text="
@@ -419,6 +435,12 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<WhatsappReauthorize
v-if="isEmbeddedSignupWhatsApp"
ref="whatsappReauth"
:inbox="inbox"
class="hidden"
/>
</div>
</template>
@@ -26,7 +26,6 @@ import profile from './profile/profile.routes';
import security from './security/security.routes';
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
import captain from './captain/captain.routes';
import data from './data/data.routes';
export default {
routes: [
@@ -58,7 +57,6 @@ export default {
...canned.routes,
...inbox.routes,
...integrations.routes,
...data.routes,
...labels.routes,
...macros.routes,
...reports.routes,
@@ -1,6 +1,5 @@
<script setup>
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { onMounted } from 'vue';
const toggleSupportWidgetVisibility = () => {
@@ -9,12 +8,6 @@ const toggleSupportWidgetVisibility = () => {
}
};
const toggleSupportWidget = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
const setupListenerForWidgetEvent = () => {
window.addEventListener('chatwoot:on-message', () => {
toggleSupportWidgetVisibility();
@@ -32,14 +25,6 @@ onMounted(() => {
<EmptyState
:title="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.TITLE')"
:message="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.MESSAGE')"
>
<div class="flex justify-center">
<NextButton
icon="i-lucide-life-buoy"
:label="$t('SIDEBAR_ITEMS.CONTACT_SUPPORT')"
@click="toggleSupportWidget"
/>
</div>
</EmptyState>
/>
</div>
</template>
@@ -104,11 +104,6 @@ class MessageFormatter {
return this.md.render(updatedMessage);
}
disableImageRendering() {
this.md.disable(['add-image-sizing']);
this.md.renderer.rules.image = () => '';
}
get formattedMessage() {
return this.formatMessage();
}
@@ -68,25 +68,6 @@ describe('#MessageFormatter', () => {
});
});
describe('#disableImageRendering', () => {
it('omits nested and reference images with relative URLs', () => {
const message = `Before ![nested [alt]](/relative.png)
![reference][logo]
[logo]: /logo.png
After`;
const formatter = new MessageFormatter(message);
formatter.disableImageRendering();
expect(formatter.formattedMessage).not.toContain('<img');
expect(formatter.formattedMessage).toContain('Before');
expect(formatter.formattedMessage).toContain('After');
});
});
describe('tweets', () => {
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
-1
View File
@@ -11,7 +11,6 @@ const createConversation = params => {
name: params.fullName,
email: params.emailAddress,
phone_number: params.phoneNumber,
custom_attributes: params.contactCustomAttributes,
},
message: {
content: params.message,
@@ -32,50 +32,6 @@ describe('#sendMessage', () => {
});
});
describe('#createConversation', () => {
it('includes contact custom attributes in the payload', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
toString: () => 'mock date',
}));
vi.spyOn(window, 'location', 'get').mockReturnValue({
...window.location,
search: '?param=1',
});
window.WOOT_WIDGET = {
$root: { $i18n: { locale: 'ar' } },
};
const result = endPoints.createConversation({
fullName: 'John',
emailAddress: 'john@example.com',
phoneNumber: '+919745313456',
message: 'hey',
customAttributes: { order_id: '12345' },
contactCustomAttributes: { cpf: '123.456.789-09' },
});
expect(result).toEqual({
url: `/api/v1/widget/conversations?param=1&locale=ar`,
params: {
contact: {
name: 'John',
email: 'john@example.com',
phone_number: '+919745313456',
custom_attributes: { cpf: '123.456.789-09' },
},
message: {
content: 'hey',
timestamp: 'mock date',
referer_url: '',
},
custom_attributes: { order_id: '12345' },
},
});
spy.mockRestore();
});
});
describe('#sendMessage with pending metadata', () => {
it('includes custom_attributes and labels in payload', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
+7 -6
View File
@@ -3,6 +3,7 @@ import { mapActions } from 'vuex';
import { useRouter } from 'vue-router';
import PreChatForm from '../components/PreChat/Form.vue';
import configMixin from '../mixins/configMixin';
import { isEmptyObject } from 'widget/helpers/utils';
import { ON_CONVERSATION_CREATED } from '../constants/widgetBusEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -41,10 +42,6 @@ export default {
contactCustomAttributes,
conversationCustomAttributes,
}) {
// Contact custom attributes are sent within the same request that
// identifies the contact. A separate update call would race the contact
// merge on the server (matching email/phone) and write the values to
// the destroyed contact, silently losing them.
if (activeCampaignId) {
emitter.emit('execute-campaign', {
campaignId: activeCampaignId,
@@ -55,7 +52,6 @@ export default {
email: emailAddress,
name: fullName,
phone_number: phoneNumber,
custom_attributes: contactCustomAttributes,
},
});
} else {
@@ -67,9 +63,14 @@ export default {
message: message,
phoneNumber: phoneNumber,
customAttributes: conversationCustomAttributes,
contactCustomAttributes: contactCustomAttributes,
});
}
if (!isEmptyObject(contactCustomAttributes)) {
this.$store.dispatch(
'contacts/setCustomAttributes',
contactCustomAttributes
);
}
},
},
};
@@ -1,89 +0,0 @@
import { shallowMount, flushPromises } from '@vue/test-utils';
import { createStore } from 'vuex';
import PreChatFormView from '../PreChatForm.vue';
global.chatwootWebChannel = {
preChatFormEnabled: true,
preChatFormOptions: { pre_chat_fields: [], pre_chat_message: '' },
};
describe('PreChatForm view', () => {
let createConversation;
let setCustomAttributes;
let updateContact;
let store;
beforeEach(() => {
createConversation = vi.fn();
setCustomAttributes = vi.fn();
updateContact = vi.fn();
store = createStore({
modules: {
conversation: {
namespaced: true,
actions: { createConversation, clearConversations: vi.fn() },
},
conversationAttributes: {
namespaced: true,
actions: { clearConversationAttributes: vi.fn() },
},
contacts: {
namespaced: true,
actions: { setCustomAttributes, update: updateContact },
},
},
});
});
const mountView = () =>
shallowMount(PreChatFormView, { global: { plugins: [store] } });
it('sends contact custom attributes with the conversation create request', async () => {
const wrapper = mountView();
wrapper.vm.onSubmit({
fullName: 'John',
emailAddress: 'john@example.com',
message: 'hey',
contactCustomAttributes: { cpf: '123.456.789-09' },
conversationCustomAttributes: { order_id: '12345' },
});
await flushPromises();
expect(createConversation).toHaveBeenCalledWith(expect.anything(), {
fullName: 'John',
emailAddress: 'john@example.com',
message: 'hey',
phoneNumber: undefined,
customAttributes: { order_id: '12345' },
contactCustomAttributes: { cpf: '123.456.789-09' },
});
// attributes ride along in the create request itself; a separate call
// would race the contact merge on the server and write to a destroyed
// contact
expect(setCustomAttributes).not.toHaveBeenCalled();
});
it('sends contact custom attributes along with the contact update for campaigns', async () => {
const wrapper = mountView();
wrapper.vm.onSubmit({
fullName: 'John',
emailAddress: 'john@example.com',
phoneNumber: null,
activeCampaignId: 42,
contactCustomAttributes: { cpf: '123.456.789-09' },
conversationCustomAttributes: {},
});
await flushPromises();
expect(updateContact).toHaveBeenCalledWith(expect.anything(), {
user: {
email: 'john@example.com',
name: 'John',
phone_number: null,
custom_attributes: { cpf: '123.456.789-09' },
},
});
expect(createConversation).not.toHaveBeenCalled();
expect(setCustomAttributes).not.toHaveBeenCalled();
});
});
@@ -1,43 +0,0 @@
class DataImports::Intercom::BaseJob < ApplicationJob
queue_as :low
retry_on DataImports::Intercom::Client::Error, wait: 1.minute, attempts: 3 do |job, error|
job.fail_import!(error)
end
retry_on DataImports::Intercom::Client::RateLimitError, wait: 1.minute, attempts: 5 do |job, error|
job.fail_import!(error)
end
def fail_import!(error)
data_import = arguments.first
run_id = arguments.length > 1 ? arguments.last : nil
return if data_import.blank? || skip_import?(data_import, run_id)
DataImports::Intercom::Importer.new(data_import: data_import, run_id: run_id).fail!(error)
end
private
def skip_import?(data_import, run_id = nil)
data_import.reload
data_import.abandoned? || data_import.failed? || data_import.completed? ||
data_import.completed_with_errors? || stale_import_run?(data_import, run_id)
end
def stale_import_run?(data_import, run_id)
active_run_id = data_import.active_intercom_import_run_id
active_run_id.present? && active_run_id != run_id
end
def importer_for(data_import, run_id = nil)
DataImports::Intercom::Importer.new(data_import: data_import, run_id: run_id)
end
def fail_unexpected_error(importer, error)
raise error if error.is_a?(DataImports::Intercom::Client::Error)
importer&.fail!(error)
raise error
end
end
@@ -1,26 +0,0 @@
class DataImports::Intercom::ContactsPageJob < DataImports::Intercom::BaseJob
def perform(data_import, starting_after = nil, run_id = nil)
return if skip_import?(data_import, run_id)
importer = importer_for(data_import, run_id)
return enqueue_conversations_or_finish(data_import, importer, run_id) if importer.contacts_completed?
result = importer.import_contacts_page(starting_after: starting_after)
return if skip_import?(data_import, run_id)
return self.class.perform_later(data_import, result.next_cursor, run_id) unless result.done?
enqueue_conversations_or_finish(data_import, importer, run_id)
rescue StandardError => e
fail_unexpected_error(importer, e)
end
private
def enqueue_conversations_or_finish(data_import, importer, run_id)
if importer.import_conversations? && !importer.conversations_completed?
DataImports::Intercom::ConversationsPageJob.perform_later(data_import, importer.cursor_for('conversations'), run_id)
else
importer.finish!
end
end
end
@@ -1,16 +0,0 @@
class DataImports::Intercom::ConversationsPageJob < DataImports::Intercom::BaseJob
def perform(data_import, starting_after = nil, run_id = nil)
return if skip_import?(data_import, run_id)
importer = importer_for(data_import, run_id)
return importer.finish! if importer.conversations_completed?
result = importer.import_conversations_page(starting_after: starting_after)
return if skip_import?(data_import, run_id)
return self.class.perform_later(data_import, result.next_cursor, run_id) unless result.done?
importer.finish!
rescue StandardError => e
fail_unexpected_error(importer, e)
end
end
@@ -1,24 +0,0 @@
class DataImports::Intercom::ImportJob < DataImports::Intercom::BaseJob
def perform(data_import, run_id = nil)
return if skip_import?(data_import, run_id)
importer = importer_for(data_import, run_id)
return unless importer.start!
enqueue_next_stage(data_import, importer, run_id)
rescue StandardError => e
fail_unexpected_error(importer, e)
end
private
def enqueue_next_stage(data_import, importer, run_id)
if importer.import_contacts? && !importer.contacts_completed?
DataImports::Intercom::ContactsPageJob.perform_later(data_import, importer.cursor_for('contacts'), run_id)
elsif importer.import_conversations? && !importer.conversations_completed?
DataImports::Intercom::ConversationsPageJob.perform_later(data_import, importer.cursor_for('conversations'), run_id)
else
importer.finish!
end
end
end
+2
View File
@@ -108,6 +108,8 @@ class WebhookListener < BaseListener
end
def deliver_account_webhooks(payload, account)
return unless account.feature_enabled?('api_and_webhooks')
account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event])
-2
View File
@@ -22,8 +22,6 @@
# index_assignment_policies_on_enabled (enabled)
#
class AssignmentPolicy < ApplicationRecord
DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168
belongs_to :account
has_many :inbox_assignment_policies, dependent: :destroy
has_many :inboxes, through: :inbox_assignment_policies
-10
View File
@@ -33,7 +33,6 @@ class Channel::Whatsapp < ApplicationRecord
validate :validate_provider_config
after_create :sync_templates
after_update_commit :log_credentials_transfer, if: :saved_change_to_provider_config?
before_destroy :teardown_webhooks
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
@@ -130,15 +129,6 @@ class Channel::Whatsapp < ApplicationRecord
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
# Logs only credential changes, so config-only saves (e.g. calling toggles) stay silent.
def log_credentials_transfer
before, after = saved_change_to_provider_config
keys = %w[api_key phone_number_id business_account_id]
return if before.nil? || before.values_at(*keys) == after.values_at(*keys)
Rails.logger.info("[WHATSAPP_MANUAL_TRANSFER] success account_id=#{account_id} channel_id=#{id}")
end
def perform_webhook_setup
webhook_setup_service.perform
end
+3 -85
View File
@@ -3,115 +3,33 @@
# Table name: data_imports
#
# id :bigint not null, primary key
# abandoned_at :datetime
# access_token :text
# completed_at :datetime
# cursor :jsonb not null
# data_type :string not null
# import_types :jsonb not null
# last_error_at :datetime
# name :string
# processed_records :integer
# processing_errors :text
# source_metadata :jsonb not null
# source_provider :string
# source_type :string
# started_at :datetime
# stats :jsonb not null
# status :integer default("pending"), not null
# total_records :integer
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# initiated_by_id :integer
#
# Indexes
#
# index_data_imports_on_account_id (account_id)
# index_data_imports_on_initiated_by_id (initiated_by_id)
# index_data_imports_on_source_provider (source_provider)
# index_data_imports_on_account_id (account_id)
#
class DataImport < ApplicationRecord
ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY = 'active_intercom_import_run_id'.freeze
LEGACY_DATA_TYPES = ['contacts'].freeze
INTEGRATION_DATA_TYPES = ['intercom'].freeze
IMPORT_TYPES = %w[contacts conversations].freeze
belongs_to :account
belongs_to :initiated_by, class_name: 'User', optional: true
encrypts :access_token if Chatwoot.encryption_configured?
has_many :items, class_name: 'DataImportItem', dependent: :destroy_async
has_many :mappings, class_name: 'DataImportMapping', dependent: :destroy_async
has_many :import_errors, class_name: 'DataImportError', dependent: :destroy_async
validates :data_type, inclusion: { in: LEGACY_DATA_TYPES + INTEGRATION_DATA_TYPES, message: I18n.t('errors.data_import.data_type.invalid') }
validates :access_token, presence: true, on: :create, if: :intercom_import?
validate :validate_import_types
enum status: { pending: 0, processing: 1, completed: 2, failed: 3, completed_with_errors: 6, abandoned: 7 }
scope :active_intercom, -> { where(data_type: 'intercom', source_provider: 'intercom', status: [:pending, :processing]) }
validates :data_type, inclusion: { in: ['contacts'], message: I18n.t('errors.data_import.data_type.invalid') }
enum status: { pending: 0, processing: 1, completed: 2, failed: 3 }
has_one_attached :import_file
has_one_attached :failed_records
after_create_commit :process_data_import
def legacy_contacts_csv_import?
data_type == 'contacts' && source_provider.blank?
end
def intercom_import?
data_type == 'intercom' && source_provider == 'intercom'
end
def restartable?
failed? || abandoned?
end
def abandonable?
intercom_import? && (pending? || processing?)
end
def abandon!
self.class.transaction do
abandonable_import = self.class.lock.find_by(
id: id,
data_type: 'intercom',
source_provider: 'intercom',
status: [:pending, :processing]
)
abandonable_import&.update!(status: :abandoned, abandoned_at: Time.current)
end
reload
end
def active_intercom_import_run_id
source_metadata.to_h[ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY]
end
def assign_active_intercom_import_run_id
self.source_metadata = source_metadata.to_h.merge(ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => SecureRandom.uuid)
active_intercom_import_run_id
end
private
def process_data_import
return unless legacy_contacts_csv_import?
# we wait for the file to be uploaded to the cloud
DataImportJob.set(wait: 1.minute).perform_later(self)
end
def validate_import_types
return if import_types.blank?
invalid_types = import_types - IMPORT_TYPES
return if invalid_types.blank?
errors.add(:import_types, "contains unsupported values: #{invalid_types.join(', ')}")
end
end
-33
View File
@@ -1,33 +0,0 @@
# == Schema Information
#
# Table name: data_import_errors
#
# id :bigint not null, primary key
# details :jsonb not null
# error_code :string not null
# message :text
# source_object_type :string
# created_at :datetime not null
# updated_at :datetime not null
# data_import_id :bigint not null
# data_import_item_id :bigint
# source_object_id :string
#
# Indexes
#
# idx_data_import_errors_on_source (source_object_type,source_object_id)
# index_data_import_errors_on_data_import_id (data_import_id)
# index_data_import_errors_on_data_import_item_id (data_import_item_id)
#
class DataImportError < ApplicationRecord
SKIP_LOG_KINDS = %w[failed skipped].freeze
belongs_to :data_import
belongs_to :data_import_item, optional: true
validates :error_code, presence: true
scope :skip_logs, -> { where("details ->> 'kind' IN (:kinds)", kinds: SKIP_LOG_KINDS) }
scope :failed, -> { where("details ->> 'kind' = ?", 'failed') }
scope :non_skip_logs, -> { where("details ->> 'kind' IS NULL OR details ->> 'kind' NOT IN (:kinds)", kinds: SKIP_LOG_KINDS) }
end
-35
View File
@@ -1,35 +0,0 @@
# == Schema Information
#
# Table name: data_import_items
#
# id :bigint not null, primary key
# attempt_count :integer default(0), not null
# chatwoot_record_type :string
# last_error_code :string
# last_error_message :text
# metadata :jsonb not null
# source_object_type :string not null
# source_provider :string not null
# status :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# chatwoot_record_id :bigint
# data_import_id :bigint not null
# source_object_id :string not null
#
# Indexes
#
# idx_data_import_items_on_import_and_source (data_import_id,source_object_type,source_object_id) UNIQUE
# idx_data_import_items_on_record (chatwoot_record_type,chatwoot_record_id)
# idx_data_import_items_on_source (source_provider,source_object_type,source_object_id)
# index_data_import_items_on_data_import_id (data_import_id)
#
class DataImportItem < ApplicationRecord
belongs_to :data_import
has_many :import_errors, class_name: 'DataImportError', dependent: :destroy_async
validates :source_provider, :source_object_type, :source_object_id, presence: true
validates :source_object_id, uniqueness: { scope: [:data_import_id, :source_object_type] }
enum status: { pending: 0, processing: 1, imported: 2, skipped: 3, failed: 4 }
end
-33
View File
@@ -1,33 +0,0 @@
# == Schema Information
#
# Table name: data_import_mappings
#
# id :bigint not null, primary key
# chatwoot_record_type :string not null
# metadata :jsonb not null
# source_object_type :string not null
# source_provider :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# chatwoot_record_id :bigint not null
# data_import_id :bigint not null
# source_object_id :string not null
#
# Indexes
#
# idx_data_import_mappings_on_account_and_source (account_id,source_provider,source_object_type,source_object_id) UNIQUE
# idx_data_import_mappings_on_record (chatwoot_record_type,chatwoot_record_id)
# index_data_import_mappings_on_data_import_id (data_import_id)
#
class DataImportMapping < ApplicationRecord
belongs_to :data_import
belongs_to :account
validates :source_provider, :source_object_type, :source_object_id, :chatwoot_record_type, :chatwoot_record_id, presence: true
validates :source_object_id, uniqueness: { scope: [:account_id, :source_provider, :source_object_type] }
def chatwoot_record
chatwoot_record_type.constantize.find_by(id: chatwoot_record_id)
end
end
-41
View File
@@ -1,41 +0,0 @@
class DataImportPolicy < ApplicationPolicy
def index?
@account_user.administrator?
end
def show?
@account_user.administrator? && record.account_id == account.id
end
def create?
@account_user.administrator?
end
def validate_source?
create?
end
def start?
show?
end
def abandon?
show?
end
def skip_logs?
show?
end
def error_logs?
show?
end
class Scope < Scope
def resolve
return scope.where(account_id: account.id) if account_user.administrator?
scope.none
end
end
end
@@ -35,9 +35,9 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Skip stale backlog with no activity beyond the age threshold
# Skip stale backlog with no activity beyond the policy's age threshold (defaults to 7 days)
policy = inbox.assignment_policy
scope = apply_age_exclusions(scope, age_exclusion_hours(policy))
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
# Apply conversation priority using assignment policy if available
scope = if policy&.longest_waiting?
@@ -49,12 +49,6 @@ class AutoAssignment::AssignmentService
scope.limit(limit)
end
def age_exclusion_hours(policy)
return policy.exclude_older_than_hours if policy
AssignmentPolicy::DEFAULT_EXCLUDE_OLDER_THAN_HOURS
end
def apply_age_exclusions(scope, hours_threshold)
return scope if hours_threshold.blank?
+3 -3
View File
@@ -2,9 +2,9 @@ module Conversations::UnreadCounts
READY_TTL = 24.hours.to_i
SET_TTL = 25.hours.to_i
FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i
FILTERED_COUNT_STALE_WINDOW = 1.hour.to_i
FILTERED_COUNT_STALE_WINDOW = 30.minutes.to_i
FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW
FILTERED_COUNT_VERSION_TTL = SET_TTL
FILTERED_COUNT_MIN_REFRESH_INTERVAL = 5.minutes.to_i
MAX_INLINE_FILTER_BUILDS = 3
FILTERED_COUNT_MIN_REFRESH_INTERVAL = 30.seconds.to_i
MAX_INLINE_FILTER_BUILDS = 10
end
@@ -1,84 +0,0 @@
class DataImports::Intercom::ActivityContentBuilder
EVENT_KEYS = {
'assignment' => :assignment,
'assign_and_reopen' => :assign_and_reopen,
'open' => :open,
'close' => :close,
'snoozed' => :snoozed,
'participant_added' => :participant_added,
'participant_removed' => :participant_removed,
'conversation_attribute_updated_by_admin' => :conversation_attribute_updated,
'conversation_attribute_updated_by_user' => :conversation_attribute_updated,
'conversation_attribute_updated_by_workflow' => :conversation_attribute_updated,
'ticket_attribute_updated_by_admin' => :ticket_attribute_updated,
'ticket_state_updated_by_admin' => :ticket_state_updated,
'custom_action_started' => :custom_action_started,
'custom_action_finished' => :custom_action_finished,
'quick_reply' => :quick_reply
}.freeze
def initialize(part)
@part = part.to_h
end
def perform
append_body(translated_content)
end
private
def translated_content
key = EVENT_KEYS.fetch(event_type, :generic)
key = "#{key}_with_target" if target_aware_event?(key) && target_name.present?
I18n.t(
"data_imports.intercom.activities.#{key}",
actor: actor_name,
target: target_name,
event: event_type.tr('_', ' ')
)
end
def event_type
@part['part_type'].to_s
end
def actor_name
author = @part['author'].to_h
return author['name'] if author['name'].present?
case author['type']
when 'user', 'contact', 'lead'
'Contact'
when 'bot'
'Intercom automation'
else
automation_event? ? 'Intercom automation' : 'Intercom teammate'
end
end
def target_name
assigned_to = @part['assigned_to'].to_h
event_details = @part['event_details'].to_h
participant = event_details['participant'].to_h
assigned_to['name'].presence || participant['name'].presence || event_details['participant_name'].presence || event_details['name'].presence
end
def target_aware_event?(key)
%i[assignment assign_and_reopen participant_added participant_removed].include?(key)
end
def automation_event?
event_type.include?('workflow') || event_type.start_with?('custom_action')
end
def append_body(content)
fragment = Nokogiri::HTML5.fragment(@part['body'].to_s)
fragment.css('script, style').remove
body = fragment.text.squish
return content if body.blank? || content.downcase.include?(body.downcase)
"#{content}: #{body}"
end
end
@@ -1,107 +0,0 @@
class DataImports::Intercom::Client
class Error < StandardError
attr_reader :status, :body
def initialize(message, status: nil, body: nil)
super(message)
@status = status
@body = body
end
end
class AuthenticationError < Error; end
class RateLimitError < Error
attr_reader :retry_after
def initialize(message, retry_after: nil, **)
super(message, **)
@retry_after = retry_after
end
end
BASE_URL = 'https://api.intercom.io'.freeze
API_VERSION = '2.15'.freeze
DEFAULT_PER_PAGE = 50
def initialize(access_token:)
@access_token = access_token
end
def list_contacts(starting_after: nil, per_page: DEFAULT_PER_PAGE)
get('/contacts', query: pagination_query(starting_after, per_page))
end
def list_conversations(starting_after: nil, per_page: DEFAULT_PER_PAGE)
get('/conversations', query: pagination_query(starting_after, per_page))
end
def retrieve_conversation(id)
get("/conversations/#{id}")
end
def retrieve_contact(id)
get("/contacts/#{id}")
end
private
def pagination_query(starting_after, per_page)
{ per_page: per_page, starting_after: starting_after }.compact
end
def get(path, query: {})
response =
begin
HTTParty.get(
"#{BASE_URL}#{path}",
query: query,
headers: headers,
timeout: 30
)
rescue StandardError => e
raise Error.new(
"Intercom API request failed before receiving a response: #{e.message}",
body: { transport_error_class: e.class.name }
)
end
parse_response(response)
end
def headers
{
'Authorization' => "Bearer #{@access_token}",
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'Intercom-Version' => API_VERSION
}
end
def parse_response(response)
body = parsed_body(response)
return body if response.success?
message = error_message(body, response)
case response.code
when 401, 403
raise AuthenticationError.new(message, status: response.code, body: body)
when 429
raise RateLimitError.new(message, status: response.code, body: body, retry_after: response.headers['retry-after'])
else
raise Error.new(message, status: response.code, body: body)
end
end
def parsed_body(response)
response.parsed_response.presence || {}
rescue JSON::ParserError
{}
end
def error_message(body, response)
errors = body.is_a?(Hash) ? body['errors'] : nil
first_error = errors.is_a?(Array) ? errors.first : nil
first_error&.dig('message').presence || "Intercom API request failed with status #{response.code}"
end
end
@@ -1,67 +0,0 @@
class DataImports::Intercom::CreationService
def initialize(account:, initiated_by:, source_params:)
@account = account
@initiated_by = initiated_by
@source_params = source_params.symbolize_keys
@access_token = @source_params[:access_token].to_s.strip
end
def perform
return if active_import?
totals = validate_source
@account.with_lock do
next if active_import?
@account.data_imports.new(attributes(totals)).tap do |data_import|
data_import.assign_active_intercom_import_run_id
data_import.save!
end
end
end
private
def validate_source
raise ArgumentError, 'Unsupported import source.' unless @source_params[:source_provider] == 'intercom'
DataImports::Intercom::CredentialsValidator.new(
access_token: @access_token,
import_types: import_types
).perform
end
def attributes(totals)
{
name: @source_params[:name].presence || 'Intercom import',
data_type: 'intercom',
source_type: 'api',
source_provider: 'intercom',
import_types: import_types,
initiated_by: @initiated_by,
access_token: @access_token,
stats: initial_stats(totals)
}
end
def import_types
return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless @source_params.key?(:import_types)
Array(@source_params[:import_types]).compact_blank
end
def initial_stats(totals)
{
'contacts' => { 'imported' => 0, 'skipped' => 0 },
'conversations' => { 'imported' => 0, 'skipped' => 0 },
'messages' => { 'imported' => 0, 'skipped' => 0 },
'errors' => { 'count' => 0 }
}.tap do |stats|
totals.each { |type, total| stats[type]['total'] = total unless total.nil? }
end
end
def active_import?
@account.data_imports.active_intercom.exists?
end
end
@@ -1,36 +0,0 @@
class DataImports::Intercom::CredentialsValidator
def initialize(access_token:, import_types:)
@access_token = access_token.to_s.strip
@import_types = Array(import_types).compact_blank
end
def perform
validate_parameters!
{}.tap do |totals|
contacts_response = client.list_contacts(per_page: 1) if @import_types.intersect?(%w[contacts conversations])
totals['contacts'] = total_count(contacts_response) if @import_types.include?('contacts')
totals['conversations'] = total_count(client.list_conversations(per_page: 1)) if @import_types.include?('conversations')
end.compact
end
private
def validate_parameters!
raise ArgumentError, 'Intercom access key is required.' if @access_token.blank?
raise ArgumentError, 'Select at least one data type to import.' if @import_types.blank?
invalid_types = @import_types - DataImport::IMPORT_TYPES
return if invalid_types.blank?
raise ArgumentError, "Unsupported import types: #{invalid_types.join(', ')}"
end
def client
@client ||= DataImports::Intercom::Client.new(access_token: @access_token)
end
def total_count(response)
response['total_count'] if response.key?('total_count')
end
end
@@ -1,991 +0,0 @@
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Rails/SkipsModelValidations
class DataImports::Intercom::Importer
PageResult = Struct.new(:next_cursor, keyword_init: true) do
def done?
next_cursor.blank?
end
end
DEFAULT_IMPORT_TYPES = %w[contacts conversations].freeze
PROVIDER = 'intercom'.freeze
ALREADY_IMPORTED_ERROR_CODE = 'DataImports::Intercom::AlreadyImported'.freeze
SKIPPED_MESSAGE_ERROR_CODE = 'DataImports::Intercom::SkippedMessage'.freeze
TRUNCATED_PARTS_ERROR_CODE = 'DataImports::Intercom::TruncatedConversationParts'.freeze
E164_REGEX = /\A\+[1-9]\d{1,14}\z/
INTERCOM_NUMBER_REGEX = /\A[1-9]\d{1,14}\z/
REGULAR_MESSAGE_PART_TYPES = %w[comment note source].freeze
def initialize(data_import:, run_id: nil)
@data_import = data_import
@run_id = run_id
@account = data_import.account
@client = DataImports::Intercom::Client.new(access_token: data_import.access_token)
@placeholder_inboxes = DataImports::Intercom::PlaceholderInboxBuilder.new(account: @account)
@stats = default_stats.deep_merge(data_import.stats || {})
end
def perform
return unless start!
import_contacts if import_type?('contacts')
import_conversations if import_type?('conversations')
finish!
rescue StandardError => e
fail!(e)
raise
end
def start!
return if @data_import.reload.abandoned?
@data_import.update!(status: :processing, started_at: @data_import.started_at || Time.current)
end
def finish!
return if @data_import.reload.abandoned?
has_failures = @data_import.import_errors.non_skip_logs.exists? || @data_import.import_errors.failed.exists?
status = has_failures ? :completed_with_errors : :completed
@data_import.update!(
status: status,
completed_at: Time.current,
stats: @stats,
total_records: total_processed_records,
processed_records: total_successful_records
)
end
def fail!(error)
return if @data_import.reload.abandoned?
record_run_error(error)
@data_import.update!(status: :failed, last_error_at: Time.current)
end
def import_contacts_page(starting_after: cursor_for('contacts'))
response = @client.list_contacts(starting_after: starting_after)
update_stat_total('contacts', response['total_count']) if response['total_count'].present?
Array(response['data'] || response['contacts']).each do |contact|
break if import_stopped?
import_contact(contact)
end
return PageResult.new(next_cursor: nil) if import_stopped?
next_cursor = response.dig('pages', 'next', 'starting_after')
update_cursor('contacts', next_cursor)
PageResult.new(next_cursor: next_cursor)
end
def import_conversations_page(starting_after: cursor_for('conversations'))
response = @client.list_conversations(starting_after: starting_after)
update_stat_total('conversations', response['total_count']) if response['total_count'].present?
Array(response['data'] || response['conversations']).each do |conversation_summary|
break if import_stopped?
import_conversation_from_summary(conversation_summary)
end
return PageResult.new(next_cursor: nil) if import_stopped?
next_cursor = response.dig('pages', 'next', 'starting_after')
update_cursor('conversations', next_cursor)
PageResult.new(next_cursor: next_cursor)
end
def import_contacts?
import_type?('contacts')
end
def import_conversations?
import_type?('conversations')
end
def contacts_completed?
stage_completed?('contacts')
end
def conversations_completed?
stage_completed?('conversations')
end
def cursor_for(key)
@data_import.cursor&.dig(key, 'starting_after')
end
private
def import_contacts
cursor = cursor_for('contacts')
loop do
result = import_contacts_page(starting_after: cursor)
break if result.done?
cursor = result.next_cursor
end
end
def import_conversations
cursor = cursor_for('conversations')
loop do
result = import_conversations_page(starting_after: cursor)
break if result.done?
cursor = result.next_cursor
end
end
def import_conversation_from_summary(conversation_summary)
source_id = source_id_for(conversation_summary)
already_handled = item_handled?('conversation', source_id)
item = import_item('conversation', source_id, conversation_summary)
mapping = find_mapping('conversation', source_id)
conversation = @client.retrieve_conversation(source_id)
return if import_stopped?
update_message_total(item, conversation)
contact = import_contact(primary_conversation_contact(conversation), required_for_conversation: true)
source_type = conversation_source_type(conversation, conversation_summary)
inbox = @placeholder_inboxes.inbox_for(source_type)
contact_inbox = contact_inbox_for(contact, inbox)
mapped_conversation = mapping&.chatwoot_record
if mapped_conversation && mapping.data_import_id != @data_import.id
skip_already_imported_item(item, mapping, already_handled: already_handled)
import_source_message(conversation, mapped_conversation, contact)
import_conversation_parts(conversation, mapped_conversation, contact)
update_conversation_activity(mapped_conversation)
return
end
chatwoot_conversation = mapped_conversation || create_conversation(conversation, contact, contact_inbox, inbox, source_type)
if mapped_conversation
record_mapping('conversation', source_id, chatwoot_conversation, metadata: conversation_metadata(conversation, inbox, source_type))
end
item.update!(status: :imported, chatwoot_record_type: 'Conversation', chatwoot_record_id: chatwoot_conversation.id)
increment_stat('conversations', 'imported') unless already_handled
import_source_message(conversation, chatwoot_conversation, contact)
import_conversation_parts(conversation, chatwoot_conversation, contact)
update_conversation_activity(chatwoot_conversation)
rescue StandardError => e
raise if e.is_a?(DataImports::Intercom::Client::Error)
fail_item(item, e)
ensure
persist_stats
end
def import_stopped?
return true if @import_stopped
@data_import.reload
@import_stopped = @data_import.abandoned? || @data_import.completed? || @data_import.completed_with_errors? || stale_import_run?
end
def stale_import_run?
active_run_id = @data_import.active_intercom_import_run_id
@run_id.present? && active_run_id.present? && active_run_id != @run_id
end
def import_contact(contact_payload, required_for_conversation: false)
source_id = source_id_for(contact_payload)
if source_id.present? && (mapping = find_mapping('contact', source_id)) && (mapped_contact = mapping.chatwoot_record)
return reuse_mapped_contact(contact_payload, source_id, mapping, mapped_contact)
end
contact_payload = retrieve_contact_payload(contact_payload)
source_id = source_id_for(contact_payload)
already_handled = item_handled?('contact', source_id)
item = import_item('contact', source_id, contact_payload)
mapping = find_mapping('contact', source_id)
mapped_contact = mapping&.chatwoot_record
if mapped_contact && mapping.data_import_id != @data_import.id
skip_already_imported_item(item, mapping, already_handled: already_handled)
return mapped_contact
end
contact = Contact.transaction do
imported_contact = mapped_contact || find_existing_contact(contact_payload) || create_contact(contact_payload)
update_existing_contact(imported_contact, contact_payload)
record_mapping('contact', source_id, imported_contact, metadata: contact_metadata(contact_payload))
item.update!(status: :imported, chatwoot_record_type: 'Contact', chatwoot_record_id: imported_contact.id)
imported_contact
end
increment_stat('contacts', 'imported') unless already_handled
contact
rescue StandardError => e
raise if e.is_a?(DataImports::Intercom::Client::Error)
fail_item(item, e)
raise if required_for_conversation
ensure
persist_stats
end
def retrieve_contact_payload(contact_payload)
return contact_payload if contact_payload.blank?
return contact_payload if contact_payload['email'].present? || contact_payload['phone'].present? || contact_payload['name'].present?
return contact_payload if contact_payload['id'].blank?
@client.retrieve_contact(contact_payload['id'])
rescue DataImports::Intercom::Client::Error => e
raise unless e.status == 404
contact_payload
end
def create_contact(contact_payload)
attrs = contact_attributes(contact_payload).merge(created_at: timestamp_for(contact_payload['created_at']), updated_at: Time.current)
result = Contact.insert_all!([attrs], returning: %w[id])
Contact.find(result.rows.first.first)
rescue ActiveRecord::RecordNotUnique
find_existing_contact(contact_payload)
end
def reuse_mapped_contact(contact_payload, source_id, mapping, mapped_contact)
if mapping.data_import_id == @data_import.id
reconcile_current_run_contact(contact_payload, source_id, mapped_contact)
return mapped_contact
end
already_handled = item_handled?('contact', source_id)
item = import_item('contact', source_id, contact_payload)
skip_already_imported_item(item, mapping, already_handled: already_handled)
mapped_contact
end
def update_existing_contact(contact, contact_payload)
attrs = contact_attributes(contact_payload)
updates = {}
updates[:name] = attrs[:name] if contact.name.blank? && attrs[:name].present?
updates[:email] = attrs[:email] if contact_email_available?(contact, attrs[:email])
updates[:phone_number] = attrs[:phone_number] if contact_phone_number_available?(contact, attrs[:phone_number])
updates[:identifier] = attrs[:identifier] if contact.identifier.blank? && attrs[:identifier].present?
updates[:last_activity_at] = attrs[:last_activity_at] if contact.last_activity_at.blank? && attrs[:last_activity_at].present?
updates[:additional_attributes] = contact.additional_attributes.to_h.deep_merge(attrs[:additional_attributes])
updates[:custom_attributes] = contact.custom_attributes.to_h.deep_merge(attrs[:custom_attributes])
if contact.visitor? && attrs[:contact_type].present? && contact_resolved_after_update?(contact, updates)
updates[:contact_type] = attrs[:contact_type]
end
updates[:updated_at] = Time.current
contact.update_columns(updates) if updates.present?
contact.reload
end
def contact_email_available?(contact, email)
return false if contact.email.present? || email.blank?
@account.contacts.where.not(id: contact.id).where('LOWER(email) = ?', email.downcase).empty?
end
def contact_phone_number_available?(contact, phone_number)
return false if contact.phone_number.present? || phone_number.blank?
@account.contacts.where.not(id: contact.id).where(phone_number: phone_number).empty?
end
def contact_resolved_after_update?(contact, updates)
contact.email.present? || contact.phone_number.present? || updates[:email].present? || updates[:phone_number].present?
end
def find_existing_contact(contact_payload)
identifier = normalized_identifier(contact_payload)
email = normalized_email(contact_payload)
phone_number = normalized_phone(contact_payload)
if identifier.present?
contact = @account.contacts.find_by(identifier: identifier)
return contact if contact.present?
end
if email.present?
contact = @account.contacts.from_email(email)
return contact if contact.present?
end
return @account.contacts.find_by(phone_number: phone_number) if phone_number.present?
nil
end
def contact_attributes(contact_payload)
attrs = {
account_id: @account.id,
name: contact_payload['name'].presence || contact_payload['email'].presence || '',
email: normalized_email(contact_payload),
phone_number: normalized_phone(contact_payload),
identifier: normalized_identifier(contact_payload),
last_activity_at: contact_activity_at(contact_payload),
additional_attributes: {
source: {
provider: PROVIDER,
contact_id: contact_payload['id'],
external_id: contact_payload['external_id'],
raw_phone: contact_payload['phone']
}.compact
},
custom_attributes: {
intercom_contact_id: contact_payload['id'],
intercom_external_id: contact_payload['external_id']
}.compact
}
attrs[:contact_type] = Contact.contact_types[:lead] if attrs[:email].present? || attrs[:phone_number].present?
attrs
end
def create_conversation(conversation, contact, contact_inbox, inbox, source_type)
source_id = source_id_for(conversation)
metadata = conversation_metadata(conversation, inbox, source_type)
if (existing_conversation = @account.conversations.find_by(identifier: conversation_identifier(conversation)))
record_mapping('conversation', source_id, existing_conversation, metadata: metadata)
return existing_conversation
end
attrs = {
account_id: @account.id,
inbox_id: inbox.id,
status: Conversation.statuses['resolved'],
contact_id: contact.id,
contact_inbox_id: contact_inbox.id,
identifier: conversation_identifier(conversation),
additional_attributes: metadata,
custom_attributes: { intercom_conversation_id: source_id },
created_at: timestamp_for(conversation['created_at']),
updated_at: timestamp_for(conversation['updated_at']),
last_activity_at: timestamp_for(conversation['updated_at'])
}
Conversation.transaction do
result = Conversation.insert_all!([attrs], returning: %w[id])
chatwoot_conversation = Conversation.find(result.rows.first.first)
record_mapping('conversation', source_id, chatwoot_conversation, metadata: metadata)
chatwoot_conversation
end
rescue ActiveRecord::RecordNotUnique
@account.conversations.find_by!(identifier: conversation_identifier(conversation)).tap do |chatwoot_conversation|
record_mapping('conversation', source_id, chatwoot_conversation, metadata: metadata)
end
end
def import_source_message(conversation, chatwoot_conversation, contact)
source = conversation['source'].to_h
return unless source_message_importable?(source)
message_source_id = "conversation:#{source_id_for(conversation)}:source:#{source['id'].presence || 'initial'}"
source_part = source.merge('part_type' => 'source', 'created_at' => conversation['created_at'])
if (mapping = find_mapping('message', message_source_id)) && message_mapping_handled?(mapping, source_part)
if mapping.data_import_id == @data_import.id
reconcile_current_run_message_mapping(chatwoot_conversation, mapping, source_part)
return
end
skip_existing_message_mapping(chatwoot_conversation, mapping, source_part)
return
end
create_message(chatwoot_conversation, contact, source_part, message_source_id)
rescue StandardError => e
fail_message(chatwoot_conversation, message_source_id, source_part, e)
end
def import_conversation_parts(conversation, chatwoot_conversation, contact)
parts_payload = conversation['conversation_parts'].to_h
parts = Array(parts_payload['conversation_parts'])
record_truncated_conversation_parts(conversation, parts.size)
parts.each do |part|
message_source_id = "conversation:#{source_id_for(conversation)}:part:#{part['id']}"
if (mapping = find_mapping('message', message_source_id)) && message_mapping_handled?(mapping, part)
if mapping.data_import_id == @data_import.id
reconcile_current_run_message_mapping(chatwoot_conversation, mapping, part)
next
end
skip_existing_message_mapping(chatwoot_conversation, mapping, part)
next
end
create_message(chatwoot_conversation, contact, part, message_source_id)
rescue StandardError => e
fail_message(chatwoot_conversation, message_source_id, part, e)
end
end
def create_message(conversation, contact, part, message_source_id)
content = content_for(part)
return record_skipped_message(conversation, message_source_id, part) if content.blank?
attrs = message_attributes(conversation, contact, part, message_source_id, content)
message = nil
Message.transaction do
message = conversation.messages.find_by(source_id: attrs[:source_id])
unless message
result = Message.insert_all!([attrs], returning: %w[id])
message = Message.find(result.rows.first.first)
end
record_mapping('message', message_source_id, message, metadata: message_metadata(part))
end
increment_stat('messages', 'imported')
reindex_message_for_search(message)
message
end
def reindex_message_for_search(message)
return unless message.should_index?
message.__send__(:reindex_for_search)
rescue StandardError => e
Rails.logger.warn("Intercom import message reindex failed for message #{message.id}: #{e.class} - #{e.message}")
end
def record_skipped_message(conversation, message_source_id, part)
mapping = find_mapping('message', message_source_id)
if mapping
already_recorded = skip_log_recorded?('message', message_source_id, SKIPPED_MESSAGE_ERROR_CODE)
record_skipped_message_log(conversation, message_source_id, part)
increment_stat('messages', 'skipped') unless already_recorded
return mapping.chatwoot_record
end
DataImportMapping.create!(
account: @account,
data_import: @data_import,
source_provider: PROVIDER,
source_object_type: 'message',
source_object_id: message_source_id,
chatwoot_record_type: 'Conversation',
chatwoot_record_id: conversation.id,
metadata: message_metadata(part).merge(skipped: true, reason: 'blank_or_unsupported_intercom_part')
)
record_skipped_message_log(conversation, message_source_id, part)
increment_stat('messages', 'skipped')
end
def message_attributes(conversation, contact, part, message_source_id, content)
message_type = message_type_for(part)
created_at = timestamp_for(part['created_at'])
{
account_id: @account.id,
inbox_id: conversation.inbox_id,
conversation_id: conversation.id,
message_type: Message.message_types[message_type],
content_type: Message.content_types['text'],
content: content,
processed_message_content: content,
private: message_type != 'activity' && part['part_type'] == 'note',
status: Message.statuses['sent'],
sender_type: message_type == 'incoming' ? 'Contact' : nil,
sender_id: message_type == 'incoming' ? contact.id : nil,
source_id: "intercom:#{message_source_id}",
external_source_ids: { intercom: message_source_id },
content_attributes: {},
additional_attributes: message_metadata(part),
created_at: created_at,
updated_at: part['updated_at'].present? ? timestamp_for(part['updated_at']) : created_at
}
end
def message_type_for(part)
return 'activity' if activity_part?(part)
author_type = part.dig('author', 'type').to_s
return 'incoming' if %w[user contact lead].include?(author_type)
'outgoing'
end
def content_for(part)
return DataImports::Intercom::ActivityContentBuilder.new(part).perform if activity_part?(part)
message_content(part)
end
def activity_part?(part)
part_type = part['part_type'].to_s
part_type.present? && REGULAR_MESSAGE_PART_TYPES.exclude?(part_type)
end
def message_content(part)
body = sanitized_text(part['body'])
subject = sanitized_text(part['subject'])
attachments = Array(part['attachments'])
content = [subject, body].reject(&:blank?).join("\n\n")
return content if attachments.blank?
[content.presence, "[Intercom attachment skipped: #{attachments.size}]"].compact.join("\n\n")
end
def sanitized_text(value)
Rails::HTML5::FullSanitizer.new.sanitize(value.to_s).squish
end
def update_conversation_activity(conversation)
latest_message = conversation.messages.reorder(created_at: :desc).first
return if latest_message.blank?
conversation.update_columns(last_activity_at: latest_message.created_at, updated_at: Time.current)
end
def contact_inbox_for(contact, inbox)
ContactInbox.find_or_create_by!(contact: contact, inbox: inbox) do |contact_inbox|
contact_inbox.source_id = "intercom:#{contact.id}"
end
end
def primary_conversation_contact(conversation)
contacts = conversation.dig('contacts', 'contacts') || []
contacts.first || conversation.dig('source', 'author') || {}
end
def conversation_source_type(conversation, conversation_summary)
conversation.dig('source', 'type').presence ||
conversation.dig('first_contact_reply', 'type').presence ||
conversation_summary.dig('source', 'type').presence ||
conversation_summary.dig('first_contact_reply', 'type').presence
end
def normalized_identifier(contact_payload)
contact_payload['external_id'].presence
end
def normalized_email(contact_payload)
email = contact_payload['email'].to_s.strip.downcase
email.match?(Devise.email_regexp) ? email : nil
end
def normalized_phone(contact_payload)
phone = contact_payload['phone'].to_s.strip
phone = "+#{phone}" if phone.match?(INTERCOM_NUMBER_REGEX)
phone.match?(E164_REGEX) ? phone : nil
end
def contact_activity_at(contact_payload)
return timestamp_for(contact_payload['last_seen_at']) if contact_payload['last_seen_at'].present?
return timestamp_for(contact_payload['last_replied_at']) if contact_payload['last_replied_at'].present?
nil
end
def source_id_for(payload)
payload['id'].presence || payload['external_id'].presence || payload['email'].presence
end
def conversation_identifier(conversation)
"intercom:#{source_id_for(conversation)}"
end
def import_item(object_type, source_id, metadata)
@data_import.items.find_or_initialize_by(
source_provider: PROVIDER,
source_object_type: object_type,
source_object_id: source_id
).tap do |item|
item.status = :processing
item.attempt_count += 1
item.metadata = item.metadata.to_h.merge(metadata.to_h)
item.save!
end
end
def item_handled?(object_type, source_id)
@data_import.items.where(status: [:imported, :skipped]).exists?(
source_provider: PROVIDER,
source_object_type: object_type,
source_object_id: source_id
)
end
def find_mapping(object_type, source_id)
DataImportMapping.find_by(
account: @account,
source_provider: PROVIDER,
source_object_type: object_type,
source_object_id: source_id
)
end
def record_mapping(object_type, source_id, record, metadata: {})
DataImportMapping.find_or_initialize_by(
account: @account,
source_provider: PROVIDER,
source_object_type: object_type,
source_object_id: source_id
).tap do |mapping|
mapping.data_import = @data_import
mapping.chatwoot_record_type = record.class.name
mapping.chatwoot_record_id = record.id
mapping.metadata = metadata
mapping.save!
end
end
def reconcile_current_run_contact(contact_payload, source_id, mapped_contact)
item = @data_import.items.find_by(
source_provider: PROVIDER,
source_object_type: 'contact',
source_object_id: source_id
)
item = import_item('contact', source_id, contact_payload) unless item&.imported?
item.update!(status: :imported, chatwoot_record_type: 'Contact', chatwoot_record_id: mapped_contact.id)
reconcile_item_stats('contact')
end
def reconcile_item_stats(source_object_type)
items = @data_import.items.where(source_provider: PROVIDER, source_object_type: source_object_type)
group = stat_group_for(source_object_type)
@stats[group]['imported'] = items.imported.count
@stats[group]['skipped'] = items.skipped.count
persist_stats
end
def reconcile_current_run_message_mapping(conversation, mapping, part)
record_skipped_message_log(conversation, mapping.source_object_id, part) if mapping.metadata['skipped']
mappings = @data_import.mappings.where(source_provider: PROVIDER, source_object_type: 'message')
skipped_mappings = mappings.where("metadata ->> 'skipped' = ?", 'true').count
message_logs = @data_import.import_errors.where(source_object_type: 'message')
@stats['messages']['imported'] = mappings.count - skipped_mappings
@stats['messages']['skipped'] = message_logs.where("details ->> 'kind' = ?", 'skipped').count
persist_stats
end
def skip_already_imported_item(item, mapping, already_handled:)
item.update!(
status: :skipped,
chatwoot_record_type: mapping.chatwoot_record_type,
chatwoot_record_id: mapping.chatwoot_record_id,
last_error_code: ALREADY_IMPORTED_ERROR_CODE,
last_error_message: 'Already imported in a previous import.'
)
record_already_imported_log(
data_import_item: item,
source_object_type: item.source_object_type,
source_object_id: item.source_object_id,
mapping: mapping
)
increment_stat(stat_group_for(item.source_object_type), 'skipped') unless already_handled
end
def skip_existing_message_mapping(conversation, mapping, part)
if mapping.metadata['skipped']
already_recorded = skip_log_recorded?('message', mapping.source_object_id, SKIPPED_MESSAGE_ERROR_CODE)
record_skipped_message_log(conversation, mapping.source_object_id, part)
else
already_recorded = skip_log_recorded?('message', mapping.source_object_id, ALREADY_IMPORTED_ERROR_CODE)
record_already_imported_log(source_object_type: 'message', source_object_id: mapping.source_object_id, mapping: mapping)
end
increment_stat('messages', 'skipped') unless already_recorded
end
def message_mapping_handled?(mapping, part)
return false if mapping.metadata['skipped'] && activity_part?(part)
mapping.metadata['skipped'] || mapping.chatwoot_record.present?
end
def fail_item(item, error)
increment_stat('errors', 'count')
item&.update!(status: :failed, last_error_code: error.class.name, last_error_message: error.message)
record_skip_log(
data_import_item: item,
source_object_type: item&.source_object_type,
source_object_id: item&.source_object_id,
error_code: error.class.name,
message: error.message,
details: {
kind: 'failed',
source_provider: PROVIDER,
error_class: error.class.name
}
)
end
def fail_message(conversation, message_source_id, part, error)
increment_stat('errors', 'count')
record_skip_log(
source_object_type: 'message',
source_object_id: message_source_id,
error_code: error.class.name,
message: error.message,
details: message_metadata(part).merge(
kind: 'failed',
source_provider: PROVIDER,
error_class: error.class.name,
conversation_id: conversation.identifier
)
)
end
def record_skipped_message_log(conversation, message_source_id, part)
record_skip_log(
source_object_type: 'message',
source_object_id: message_source_id,
error_code: SKIPPED_MESSAGE_ERROR_CODE,
message: skipped_message_log_message(part),
details: skipped_message_details(conversation, part)
)
end
def record_already_imported_log(source_object_type:, source_object_id:, mapping:, data_import_item: nil)
record_skip_log(
data_import_item: data_import_item,
source_object_type: source_object_type,
source_object_id: source_object_id,
error_code: ALREADY_IMPORTED_ERROR_CODE,
message: 'Already imported in a previous import.',
details: {
kind: 'skipped',
reason: 'already_imported',
source_provider: PROVIDER,
previous_data_import_id: mapping.data_import_id,
chatwoot_record_type: mapping.chatwoot_record_type,
chatwoot_record_id: mapping.chatwoot_record_id
}
)
end
def record_truncated_conversation_parts(conversation, imported_parts_count)
total_parts_count = total_conversation_parts_count(conversation)
return if total_parts_count <= imported_parts_count
source_id = source_id_for(conversation)
already_recorded = @data_import.import_errors.exists?(
source_object_type: 'conversation',
source_object_id: source_id,
error_code: TRUNCATED_PARTS_ERROR_CODE
)
record_import_error(
source_object_type: 'conversation',
source_object_id: source_id,
error_code: TRUNCATED_PARTS_ERROR_CODE,
message: "Intercom returned #{imported_parts_count} of #{total_parts_count} conversation parts.",
details: {
kind: 'incomplete',
source_provider: PROVIDER,
imported_parts_count: imported_parts_count,
total_parts_count: total_parts_count
}
)
increment_stat('errors', 'count') unless already_recorded
end
def total_conversation_parts_count(conversation)
conversation_parts_total_count = conversation.dig('conversation_parts', 'total_count')
return conversation_parts_total_count.to_i if conversation_parts_total_count.present?
[
conversation.dig('statistics', 'count_conversation_parts'),
conversation.dig('statistics', 'count_conversations_parts')
].compact.map(&:to_i).max || 0
end
def source_message_importable?(source)
source['body'].present? || source['subject'].present? || source['attachments'].present?
end
def skipped_message_log_message(part)
"Skipped Intercom #{intercom_event_name(part)} event#{intercom_part_id_suffix(part)}: #{skipped_message_reason_details(part)}."
end
def skipped_message_details(conversation, part)
author = part['author'].to_h
message_metadata(part).merge(
{
kind: 'skipped',
reason: 'blank_or_unsupported_intercom_part',
reason_details: skipped_message_reason_details(part),
event_name: intercom_event_name(part),
event_type: part['part_type'],
author_type: author['type'],
author_name: author['name'],
conversation_id: conversation.identifier
}.compact
)
end
def skipped_message_reason_details(part)
return 'message body did not contain readable text after HTML sanitization' if part['body'].present?
return 'attachments are present but no importable message text was found' if Array(part['attachments']).present?
'no message body or attachments to import'
end
def intercom_event_name(part)
part['part_type'].to_s.tr('_', ' ').presence || 'message part'
end
def intercom_part_id_suffix(part)
part['id'].present? ? " #{part['id']}" : ''
end
def skip_log_recorded?(source_object_type, source_object_id, error_code)
@data_import.import_errors.skip_logs.exists?(
source_object_type: source_object_type,
source_object_id: source_object_id,
error_code: error_code
)
end
def record_run_error(error)
@data_import.import_errors.create!(
error_code: error.class.name,
message: error.message,
details: {
kind: 'run_error',
source_provider: PROVIDER,
error_class: error.class.name
}
)
end
def record_skip_log(attributes)
record_import_error(attributes)
end
def record_import_error(attributes)
@data_import.import_errors.find_or_initialize_by(
data_import_item: attributes[:data_import_item],
source_object_type: attributes[:source_object_type],
source_object_id: attributes[:source_object_id],
error_code: attributes[:error_code]
).tap do |import_error|
import_error.message = attributes[:message]
import_error.details = attributes[:details]
import_error.save!
end
end
def conversation_metadata(conversation, inbox, source_type)
{
source: {
provider: PROVIDER,
conversation_id: source_id_for(conversation),
source_type: source_type,
delivered_as: conversation.dig('source', 'delivered_as'),
source_url: conversation.dig('source', 'url'),
admin_assignee_id: conversation['admin_assignee_id'],
team_assignee_id: conversation['team_assignee_id'],
state: conversation['state'],
open: conversation['open'],
routing_method: 'source_bucket_api_inbox',
routed_inbox_id: inbox.id,
import_id: @data_import.id
}.compact
}
end
def contact_metadata(contact_payload)
{
source: {
provider: PROVIDER,
contact_id: contact_payload['id'],
external_id: contact_payload['external_id']
}.compact
}
end
def message_metadata(part)
{
source: {
provider: PROVIDER,
part_id: part['id'],
part_type: part['part_type'],
author: part['author'],
assigned_to: part['assigned_to'],
state: part['state'],
tags: part['tags'],
event_details: part['event_details'],
app_package_code: part['app_package_code'],
metadata: part['metadata'],
attachments: part['attachments'],
redacted: part['redacted']
}.compact
}
end
def timestamp_for(value)
return Time.current if value.blank?
Time.zone.at(value.to_i)
end
def update_cursor(key, cursor)
@data_import.cursor = @data_import.cursor.to_h.merge(
key => { starting_after: cursor, completed: cursor.blank?, updated_at: Time.current.iso8601 }
)
@data_import.save!
end
def stage_completed?(key)
@data_import.cursor&.dig(key, 'completed') == true
end
def import_type?(type)
import_types.include?(type)
end
def import_types
@import_types ||= (@data_import.import_types.presence || DEFAULT_IMPORT_TYPES)
end
def increment_stat(group, key)
@stats[group] ||= {}
@stats[group][key] = @stats[group][key].to_i + 1
end
def update_stat_total(group, total)
@stats[group] ||= {}
@stats[group]['total'] = total.to_i
persist_stats
end
def update_message_total(item, conversation)
parts = conversation['conversation_parts'].to_h
conversation_parts_total = if parts.key?('total_count')
parts['total_count'].to_i
else
Array(parts['conversation_parts']).size
end
contribution = conversation_parts_total
contribution += 1 if source_message_importable?(conversation['source'].to_h)
previous_contribution = item.metadata.to_h['message_total_contribution'].to_i
@stats['messages']['total'] = @stats['messages']['total'].to_i + contribution - previous_contribution
item.update!(metadata: item.metadata.to_h.merge('message_total_contribution' => contribution))
persist_stats
end
def stat_group_for(source_object_type)
"#{source_object_type}s"
end
def persist_stats
@data_import.update_columns(stats: @stats, updated_at: Time.current)
end
def default_stats
{
'contacts' => { 'imported' => 0, 'skipped' => 0 },
'conversations' => { 'imported' => 0, 'skipped' => 0 },
'messages' => { 'imported' => 0, 'skipped' => 0 },
'errors' => { 'count' => 0 }
}
end
def total_processed_records
total_successful_records +
@stats.fetch('contacts', {}).fetch('skipped', 0).to_i +
@stats.fetch('conversations', {}).fetch('skipped', 0).to_i +
@stats.fetch('messages', {}).fetch('skipped', 0).to_i +
@stats.fetch('errors', {}).fetch('count', 0).to_i
end
def total_successful_records
@stats.fetch('contacts', {}).fetch('imported', 0).to_i +
@stats.fetch('conversations', {}).fetch('imported', 0).to_i +
@stats.fetch('messages', {}).fetch('imported', 0).to_i
end
end
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Rails/SkipsModelValidations
@@ -1,41 +0,0 @@
class DataImports::Intercom::PlaceholderInboxBuilder
AGENT_REPLY_TIME_WINDOW_HOURS = 1
def initialize(account:)
@account = account
end
def inbox_for(source_type)
bucket = DataImports::Intercom::SourceBucket.for(source_type)
placeholder_inboxes[bucket[:key]] ||= create_placeholder_inbox(bucket)
end
private
def placeholder_inboxes
@placeholder_inboxes ||= @account.inboxes.includes(:channel).where(channel_type: 'Channel::Api').each_with_object({}) do |inbox, inboxes|
attrs = inbox.channel.additional_attributes || {}
next unless attrs['source_provider'] == 'intercom' && attrs['import_placeholder'] == true
inboxes[attrs['source_bucket']] = inbox
end
end
def create_placeholder_inbox(bucket)
channel = @account.api_channels.create!(
additional_attributes: {
source_provider: 'intercom',
source_bucket: bucket[:key],
import_placeholder: true,
agent_reply_time_window: AGENT_REPLY_TIME_WINDOW_HOURS
}
)
@account.inboxes.create!(
name: "Intercom Import - #{bucket[:name]}",
channel: channel,
enable_auto_assignment: false,
allow_messages_after_resolved: false
)
end
end
@@ -1,55 +0,0 @@
class DataImports::Intercom::RestartService
attr_reader :data_import
def initialize(account:, data_import:)
@account = account
@data_import = data_import
end
def perform
@account.with_lock do
@data_import.reload
next :render_show unless @data_import.restartable?
if (active_import = find_active_import)
@data_import = active_import
next :render_show
end
next :access_token_missing if @data_import.access_token.blank?
@data_import.assign_active_intercom_import_run_id
retained_skip_logs = @data_import.import_errors.where("details ->> 'kind' = ?", 'skipped')
@data_import.import_errors.where.not(id: retained_skip_logs.select(:id)).delete_all
@data_import.update!(restart_attributes(retained_skip_logs))
:enqueue
end
end
private
def find_active_import
@account.data_imports.active_intercom.first
end
def restart_attributes(retained_skip_logs)
{
status: :pending,
abandoned_at: nil,
completed_at: nil,
last_error_at: nil,
started_at: nil,
stats: restart_stats(retained_skip_logs)
}
end
def restart_stats(retained_skip_logs)
@data_import.stats.to_h.deep_dup.tap do |stats|
%w[contact conversation message].each do |object_type|
stats["#{object_type}s"] ||= {}
stats["#{object_type}s"]['skipped'] = retained_skip_logs.where(source_object_type: object_type).count
end
stats['errors'] = { 'count' => 0 }
end
end
end
@@ -1,23 +0,0 @@
class DataImports::Intercom::SourceBucket
BUCKETS = {
'email' => { key: 'email', name: 'Email' },
'instagram' => { key: 'instagram', name: 'Instagram' },
'facebook' => { key: 'facebook', name: 'Facebook' },
'sms' => { key: 'sms', name: 'SMS' },
'twitter' => { key: 'twitter', name: 'Twitter' },
'whatsapp' => { key: 'whatsapp', name: 'WhatsApp' },
'phone' => { key: 'phone', name: 'Phone' },
'phone_call' => { key: 'phone', name: 'Phone' },
'phone_switch' => { key: 'phone', name: 'Phone' },
'inapp' => { key: 'messenger', name: 'Messenger' },
'messenger' => { key: 'messenger', name: 'Messenger' },
'conversation' => { key: 'messenger', name: 'Messenger' },
'push' => { key: 'messenger', name: 'Messenger' }
}.freeze
DEFAULT_BUCKET = { key: 'unknown', name: 'Unknown' }.freeze
def self.for(source_type)
BUCKETS[source_type.to_s.downcase] || DEFAULT_BUCKET
end
end
@@ -38,8 +38,7 @@ class Imap::BaseFetchEmailService
end
def email_already_present?(channel, message_id)
# exists? avoids Message's default_scope ORDER BY, which full-scans large inboxes
channel.inbox.messages.exists?(source_id: message_id) || deleted_message_tracker.deleted?(message_id)
channel.inbox.messages.find_by(source_id: message_id).present? || deleted_message_tracker.deleted?(message_id)
end
def deleted_message_tracker
@@ -40,11 +40,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def fetch_whatsapp_templates(url)
response = HTTParty.get(url)
unless response.success?
Rails.logger.warn "[WHATSAPP] Template sync failed for account #{whatsapp_channel.account_id} " \
"inbox #{whatsapp_channel.inbox&.id}: #{response.code} #{error_message(response)}"
return []
end
return [] unless response.success?
next_url = next_url(response)
@@ -58,17 +54,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def validate_provider_config?
config = whatsapp_channel.provider_config
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{config['api_key']}")
return log_transfer_failure('waba_or_token_check', response) unless response.success?
# The templates check only proves the WABA/token pair, so verify the phone_number_id belongs to this WABA when it changes.
return true unless whatsapp_channel.provider_config_changed?
phone_response = HTTParty.get("#{business_account_path}/phone_numbers?fields=id&limit=100&access_token=#{config['api_key']}")
ids = phone_response.parsed_response.is_a?(Hash) ? Array(phone_response.parsed_response['data']) : []
return true if phone_response.success? && ids.any? { |number| number['id'] == config['phone_number_id'].to_s }
log_transfer_failure('phone_number_id_check', phone_response)
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
response.success?
end
def api_headers
@@ -94,16 +81,6 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
private
# Only credential updates on existing channels are transfer attempts; creation failures are regular setup errors. Returns false.
def log_transfer_failure(check, response)
return false unless whatsapp_channel.persisted? && whatsapp_channel.provider_config_changed?
error_message = response.parsed_response.is_a?(Hash) ? response.parsed_response.dig('error', 'message') : nil
Rails.logger.warn("[WHATSAPP_MANUAL_TRANSFER] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
"check=#{check} http_status=#{response.code} meta_error=#{error_message}")
false
end
def csat_template_service
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
end
@@ -159,7 +136,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def error_message(response)
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
response.parsed_response.dig('error', 'message') if response.parsed_response.is_a?(Hash)
response.parsed_response&.dig('error', 'message')
end
def voice_message?(type, attachment)
@@ -23,6 +23,7 @@ class Whatsapp::WebhookTeardownService
def should_teardown_webhook?
@channel.provider == 'whatsapp_cloud' &&
provider_config['source'] == 'embedded_signup' &&
provider_config['api_key'].present? &&
(provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?)
end
@@ -37,11 +38,8 @@ class Whatsapp::WebhookTeardownService
Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}"
end
# Embedded signup only — a manual token's subscribed app is the customer's, not ours to unsubscribe.
# The subscription is shared across the WABA, so only unsubscribe when this is the last inbox.
# The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one.
def unsubscribe_app_if_last_inbox(api_client)
return unless provider_config['source'] == 'embedded_signup'
waba_id = provider_config['business_account_id']
return if waba_id.blank?
return if waba_sibling_exists?(waba_id)
@@ -1,24 +0,0 @@
json.id data_import.id
json.name data_import.name
json.data_type data_import.data_type
json.source_type data_import.source_type
json.source_provider data_import.source_provider
json.import_types data_import.import_types
json.status data_import.status
json.total_records data_import.total_records
json.processed_records data_import.processed_records
json.stats data_import.stats
json.cursor data_import.cursor
json.created_at data_import.created_at
json.updated_at data_import.updated_at
json.started_at data_import.started_at
json.completed_at data_import.completed_at
json.abandoned_at data_import.abandoned_at
json.initiated_by data_import.initiated_by&.slice(:id, :name, :email)
if @import_errors_counts
json.import_errors_count @import_errors_counts.fetch(data_import.id, 0)
json.skip_logs_count (@skip_logs_counts || {}).fetch(data_import.id, 0)
else
json.import_errors_count data_import.import_errors.non_skip_logs.count
json.skip_logs_count data_import.import_errors.skip_logs.count
end
@@ -1,5 +0,0 @@
json.payload do
json.array! @data_imports do |data_import|
json.partial! 'api/v1/accounts/data_imports/data_import', formats: [:json], data_import: data_import
end
end
@@ -1,31 +0,0 @@
json.partial! 'api/v1/accounts/data_imports/data_import', formats: [:json], data_import: @data_import
json.import_errors do
json.array! @import_errors_finder.import_errors do |import_error|
json.id import_error.id
json.error_code import_error.error_code
json.message import_error.message
json.source_object_type import_error.source_object_type
json.source_object_id import_error.source_object_id
json.details import_error.details
json.created_at import_error.created_at
end
end
json.skip_logs do
json.array! @skip_logs_finder.skip_logs do |skip_log|
json.id skip_log.id
json.kind skip_log.details['kind']
json.error_code skip_log.error_code
json.message skip_log.message
json.source_object_type skip_log.source_object_type
json.source_object_id skip_log.source_object_id
json.details skip_log.details
json.created_at skip_log.created_at
end
end
json.skip_logs_filters do
json.selected_source_object_type @skip_logs_finder.selected_source_object_type
json.counts_by_type @skip_logs_finder.counts_by_type
end

Some files were not shown because too many files have changed in this diff Show More