Compare commits

..
Author SHA1 Message Date
Sony Mathew a0f3967148 ci: pin pnpm version in CircleCI 2026-07-13 14:30:20 +05:30
191 changed files with 323 additions and 11375 deletions
+3 -3
View File
@@ -78,7 +78,7 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
version: '11.10.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -120,7 +120,7 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
version: '11.10.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -152,7 +152,7 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
version: '11.10.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def update
persist_article_changes if params[:article].present?
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
def destroy
@@ -67,26 +67,12 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
# Draft-only autosaves must not bump the public-facing updated_at, so write
# them with update_columns (which skips the timestamp). update_columns also
# skips validations, so assign and validate first to avoid persisting content
# that exceeds the column length limit.
def persist_article_changes
keys = article_params.to_h.keys
if keys.any? && (keys - %w[draft_title draft_content]).empty?
@article.assign_attributes(article_params)
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
else
@article.update!(article_params)
end
end
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:locale, :draft_title, :draft_content, meta: [:title,
:description,
{ tags: [] }]
:locale, meta: [:title,
:description,
{ tags: [] }]
)
end
@@ -2,14 +2,5 @@ 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
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
end
@@ -1,5 +1,4 @@
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_dashboard_apps, except: [:create]
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
@@ -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
@@ -1,6 +1,4 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -33,7 +31,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
end
def validate_reauthorization_required
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
render json: {
success: false,
@@ -41,13 +39,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
def can_reconfigure_channel?
def can_upgrade_to_embedded_signup?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
# Reconfiguring a live embedded-signup channel requires the feature flag.
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
end
@@ -8,7 +8,6 @@ class Api::V1::AccountsController < Api::BaseController
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -106,12 +105,6 @@ class Api::V1::AccountsController < Api::BaseController
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def validate_token_api_access
return if @account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
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();
@@ -1,5 +1,5 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
@@ -7,9 +7,7 @@ import {
ARTICLE_MENU_ITEMS,
ARTICLE_MENU_OPTIONS,
ARTICLE_STATUSES,
getArticleStatus,
} from 'dashboard/helper/portalHelper';
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
@@ -55,10 +53,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
hasPendingChanges: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
@@ -74,16 +68,12 @@ const emit = defineEmits([
'articleAction',
'toggleSelect',
'hover',
'draftResolved',
'draftFailed',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
@@ -115,18 +105,7 @@ const articleMenuItems = computed(() => {
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
const draftItems = props.hasPendingChanges
? [
{
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
value: 'discard-draft',
action: 'discard-draft',
icon: 'i-lucide-undo-2',
},
]
: [];
return [...statusItems, ...draftItems, commonItems.delete];
return [...statusItems, commonItems.delete];
});
const statusTextColor = computed(() => {
@@ -174,12 +153,6 @@ const lastUpdatedAt = computed(() => {
const handleArticleAction = ({ action, value }) => {
toggleDropdown(false);
// Un-publishing an article with staged edits — confirm apply/discard first;
// the popover applies the chosen status itself.
if (props.hasPendingChanges && (action === 'draft' || action === 'archive')) {
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
return;
}
emit('articleAction', { action, value, id: props.id });
};
@@ -211,18 +184,6 @@ const handleClick = id => {
</span>
</div>
<div class="flex items-center gap-2">
<span
v-if="hasPendingChanges"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS_TOOLTIP'
)
"
class="text-xs font-medium inline-flex items-center gap-1 h-6 px-2 py-0.5 rounded-md text-n-slate-11 bg-n-alpha-2 whitespace-nowrap shrink-0"
>
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
{{ t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS') }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
@@ -243,15 +204,9 @@ const handleClick = id => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="articleMenuItems"
class="mt-1 end-0 top-full w-40"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
@action="handleArticleAction($event)"
/>
<ArticlePendingChangesPopover
ref="pendingChangesPopoverRef"
:article-id="id"
@resolved="emit('draftResolved', $event)"
@failed="emit('draftFailed', $event)"
/>
</div>
</div>
</div>
@@ -1,173 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import {
renderInlineDiff,
buildDiffBlocks,
} from 'dashboard/helper/articleDiffHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const isOpen = defineModel({ type: Boolean, default: false });
const { t } = useI18n();
const liveTitle = computed(() => props.article?.title ?? '');
const liveContent = computed(() => props.article?.content ?? '');
const draftTitle = computed(() => props.article?.draftTitle ?? liveTitle.value);
const draftContent = computed(
() => props.article?.draftContent ?? liveContent.value
);
const titleChanged = computed(() => liveTitle.value !== draftTitle.value);
const titleDiff = computed(() =>
renderInlineDiff(liveTitle.value, draftTitle.value)
);
const contentBlocks = computed(() =>
buildDiffBlocks(liveContent.value, draftContent.value)
);
const contentChanged = computed(() =>
contentBlocks.value.some(block => block.type !== 'equal')
);
// HC tables store per-column widths (px, 0 = unset) in this marker, which the
// formatter strips. Re-apply them as a fixed-layout <colgroup>, defaulting
// unsized columns so they don't collapse.
const COLWIDTHS_RE = /<!--cw-colwidths:([\d,]+)-->/;
const DEFAULT_COL_WIDTH = 50;
const applyColumnWidths = (html, widths) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const table = doc.body.querySelector('table');
if (!table) return html;
const sized = widths.map(width => (width > 0 ? width : DEFAULT_COL_WIDTH));
const colgroup = doc.createElement('colgroup');
sized.forEach(width => {
const col = doc.createElement('col');
col.style.width = `${width}px`;
colgroup.appendChild(col);
});
table.insertBefore(colgroup, table.firstChild);
table.style.tableLayout = 'fixed';
table.style.width = `${sized.reduce((sum, width) => sum + width, 0)}px`;
return doc.body.innerHTML;
};
const renderMarkdown = markdown => {
if (!markdown) return '';
const html = new MessageFormatter(markdown).formattedMessage;
const match = markdown.match(COLWIDTHS_RE);
return match
? applyColumnWidths(html, match[1].split(',').map(Number))
: html;
};
const blockClass = type => {
if (type === 'added') {
return 'border-n-teal-9 bg-n-teal-2';
}
if (type === 'removed') {
return 'border-n-ruby-9 bg-n-ruby-2 line-through decoration-n-ruby-9/50';
}
return 'border-transparent';
};
const close = () => {
isOpen.value = false;
};
const dismissOnClickOutside = [close, { ignore: ['[data-diff-toggle]'] }];
useKeyboardEvents({ Escape: { action: close, allowOnFocusedInput: true } });
</script>
<template>
<TeleportWithDirection to="body">
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<aside
v-if="isOpen"
v-on-click-outside="dismissOnClickOutside"
class="fixed inset-y-0 z-40 flex flex-col w-full shadow-2xl end-0 max-w-lg bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak"
>
<header
class="flex items-start justify-between gap-3 px-6 py-4 border-b shrink-0 border-n-weak bg-n-solid-1"
>
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-2">
<span class="size-2 rounded-full bg-n-amber-9 shrink-0" />
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE') }}
</h3>
</div>
<p class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.DESCRIPTION') }}
</p>
</div>
<Button
icon="i-lucide-x"
variant="ghost"
color="slate"
size="sm"
class="shrink-0 hover:text-n-slate-11"
@click="close"
/>
</header>
<div
class="flex flex-col flex-1 min-h-0 gap-4 px-6 pt-4 pb-6 overflow-y-auto"
>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="titleChanged"
class="flex flex-col gap-1.5 border-s-[3px] border-transparent ps-3"
>
<span
class="text-[11px] font-medium tracking-wide uppercase text-n-slate-10"
>
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE_LABEL') }}
</span>
<h1
class="text-lg font-semibold leading-snug text-n-slate-12"
v-html="titleDiff"
/>
</div>
<div
v-if="contentChanged"
class="flex flex-col gap-1 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-n-weak [&_td]:border-n-weak [&_th]:p-2 [&_td]:p-2 [&_th]:bg-n-alpha-1 [&_th]:text-start [&_td]:align-top"
>
<div
v-for="(block, index) in contentBlocks"
:key="index"
class="px-3 py-1.5 overflow-x-auto text-sm leading-relaxed break-words border-s-[3px] rounded-e-md text-n-slate-12 prose-sm prose dark:prose-invert max-w-none [&_p]:my-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0"
:class="blockClass(block.type)"
v-html="renderMarkdown(block.md)"
/>
</div>
<!-- eslint-enable vue/no-v-html -->
</div>
</aside>
</Transition>
</TeleportWithDirection>
</template>
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { ref, computed, watch } from 'vue';
import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@@ -9,7 +9,6 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
import ArticleDiffPanel from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleDiffPanel.vue';
const props = defineProps({
article: {
@@ -39,75 +38,32 @@ const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
// Prefer the draft; `??` keeps a deliberately-cleared empty string instead of
// falling back to the live value.
const effectiveTitle = () =>
props.article?.draftTitle ?? props.article?.title ?? '';
const effectiveContent = () =>
props.article?.draftContent ?? props.article?.content ?? '';
const localTitle = ref(props.article?.title ?? '');
const localContent = ref(props.article?.content ?? '');
const hasPendingChanges = computed(
() => props.article?.draftTitle != null || props.article?.draftContent != null
);
const localTitle = ref(effectiveTitle());
const localContent = ref(effectiveContent());
const isDiffPanelOpen = ref(false);
// Autosave 500ms after the last edit. It sends both title and content so an
// edit to one never drops a recent edit to the other. `stop` cancels a queued
// save; `isPending` tells the header to wait before allowing a publish.
const {
isPending: isSaving,
start: debouncedSave,
stop: cancelSave,
} = useTimeoutFn(
() =>
emit('saveArticle', {
title: localTitle.value,
content: localContent.value,
}),
500,
{ immediate: false }
);
const syncLocalState = () => {
cancelSave();
localTitle.value = effectiveTitle();
localContent.value = effectiveContent();
};
// Reseed on article switch or once a draft is published/discarded; close the
// diff panel in the latter case since there's nothing left to compare.
// Sync local state when navigating to a different article or on initial fetch
watch(
[() => props.article?.id, hasPendingChanges],
([id, pending], [prevId, prevPending]) => {
if ((id && id !== prevId) || (prevPending && !pending)) syncLocalState();
if (prevPending && !pending) isDiffPanelOpen.value = false;
() => props.article?.id,
newId => {
if (newId) {
localTitle.value = props.article?.title ?? '';
localContent.value = props.article?.content ?? '';
}
}
);
const scheduleSave = () => {
if (isNewArticle.value) return;
debouncedSave();
};
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
// Flush a queued save on unmount so leaving the editor doesn't drop the last edit.
onBeforeUnmount(() => {
if (isNewArticle.value || !isSaving.value) return;
cancelSave();
emit('saveArticle', {
title: localTitle.value,
content: localContent.value,
});
});
const handleSave = value => {
if (isNewArticle.value) return;
debouncedSave(value);
};
const articleTitle = computed({
get: () => localTitle.value,
set: value => {
localTitle.value = value;
scheduleSave();
handleSave({ title: value });
},
});
@@ -115,7 +71,7 @@ const articleContent = computed({
get: () => localContent.value,
set: content => {
localContent.value = content;
scheduleSave();
handleSave({ content });
},
});
@@ -152,13 +108,9 @@ const handleCreateArticle = event => {
:is-saved="isSaved"
:status="article.status"
:article-id="article.id"
:pending-changes="hasPendingChanges"
:is-saving="isSaving"
@go-back="onClickGoBack"
@preview-article="previewArticle"
@show-diff="isDiffPanelOpen = !isDiffPanelOpen"
/>
<ArticleDiffPanel v-model="isDiffPanelOpen" :article="article" />
</template>
<template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
@@ -1,8 +1,8 @@
<script setup>
import { computed, ref, useTemplateRef } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
import { useStore } from 'dashboard/composables/store.js';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { OnClickOutside } from '@vueuse/components';
@@ -17,7 +17,6 @@ import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
const props = defineProps({
isUpdating: {
@@ -36,17 +35,9 @@ const props = defineProps({
type: Number,
default: 0,
},
pendingChanges: {
type: Boolean,
default: false,
},
isSaving: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['goBack', 'previewArticle', 'showDiff']);
const emit = defineEmits(['goBack', 'previewArticle']);
const { t } = useI18n();
const store = useStore();
@@ -58,30 +49,9 @@ const { ARTICLE_STATUS_TYPES } = wootConstants;
const showArticleActionMenu = ref(false);
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
// Per-article update flag the store already maintains.
const articleUiFlags = useMapGetter('articles/uiFlags');
const isUpdatingArticle = computed(
() => articleUiFlags.value(props.articleId).isUpdating
);
// Publishing while a save is still in flight would promote a stale draft, so we show an alert
const blockedWhileSaving = () => {
if (!props.isSaving && !isUpdatingArticle.value) return false;
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.SAVE_IN_PROGRESS'));
return true;
};
const isPublished = computed(() => props.status === ARTICLE_STATUSES.PUBLISHED);
const hasPendingChanges = computed(
() => isPublished.value && props.pendingChanges
);
const articleMenuItems = computed(() => {
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
const items = statusOptions.map(option => {
return statusOptions.map(option => {
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
return {
label: t(label),
@@ -90,17 +60,6 @@ const articleMenuItems = computed(() => {
icon,
};
});
if (hasPendingChanges.value) {
items.push({
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
value: 'discard-draft',
action: 'discard-draft',
icon: 'i-lucide-undo-2',
});
}
return items;
});
const statusText = computed(() =>
@@ -126,9 +85,8 @@ const getStatusMessage = (status, isSuccess) => {
: '';
};
// Pass draftAction (publishDraft/discardDraft) to resolve a draft in the same
// update; omit it for a plain status change.
const performStatusUpdate = async (value, draftAction) => {
const updateArticleStatus = async ({ value }) => {
showArticleActionMenu.value = false;
const status = getArticleStatus(value);
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
isArticlePublishing.value = true;
@@ -136,7 +94,7 @@ const performStatusUpdate = async (value, draftAction) => {
const { portalSlug } = route.params;
try {
await store.dispatch(`articles/${draftAction ?? 'update'}`, {
await store.dispatch('articles/update', {
portalSlug,
articleId: props.articleId,
status,
@@ -149,100 +107,12 @@ const performStatusUpdate = async (value, draftAction) => {
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
isArticlePublishing.value = false;
} catch (error) {
useAlert(error?.message ?? getStatusMessage(status, false));
} finally {
isArticlePublishing.value = false;
}
};
const updateArticleStatus = ({ value }) => {
showArticleActionMenu.value = false;
// Leaving published with unsaved draft edits — ask whether to apply or discard
// first; the popover applies the status itself once resolved.
if (hasPendingChanges.value) {
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
return;
}
performStatusUpdate(value);
};
const publishDraftChanges = async () => {
isArticlePublishing.value = true;
const { portalSlug } = route.params;
try {
await store.dispatch('articles/publishDraft', {
portalSlug,
articleId: props.articleId,
});
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_SUCCESS'));
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
} catch (error) {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
} finally {
isArticlePublishing.value = false;
}
};
const discardDraftChanges = async () => {
const { portalSlug } = route.params;
try {
await store.dispatch('articles/discardDraft', {
portalSlug,
articleId: props.articleId,
});
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS'));
} catch (error) {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR')
);
}
};
const onPrimaryAction = () => {
if (blockedWhileSaving()) return;
if (hasPendingChanges.value) {
publishDraftChanges();
} else if (props.pendingChanges) {
// Promote leftover draft edits on publish instead of republishing stale content.
performStatusUpdate(ARTICLE_STATUSES.PUBLISHED, 'publishDraft');
} else {
updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED });
}
};
const onMenuAction = event => {
showArticleActionMenu.value = false;
// Don't resolve a draft while an autosave is still in flight — it could land
// after and recreate the draft we just discarded/applied.
if (blockedWhileSaving()) return;
if (event.action === 'discard-draft') {
discardDraftChanges();
} else {
updateArticleStatus(event);
}
};
// The popover applies the draft + status itself; we just surface the outcome.
const onDraftResolved = status => {
useAlert(getStatusMessage(status, true));
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
};
const onDraftFailed = error => {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
};
</script>
<template>
@@ -257,24 +127,13 @@ const onDraftFailed = error => {
@click="onClickGoBack"
/>
<div class="flex items-center gap-4">
<button
v-if="hasPendingChanges"
type="button"
data-diff-toggle
:title="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.VIEW_CHANGES')"
class="flex items-center gap-1.5 px-2 py-1 text-xs font-medium transition-colors rounded-lg cursor-pointer text-n-amber-11 bg-n-amber-3 outline outline-1 outline-n-amber-5 hover:bg-n-amber-4"
@click="emit('showDiff')"
>
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PENDING_CHANGES') }}
</button>
<span
v-if="isUpdating || isSaved"
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
>
{{ statusText }}
</span>
<div class="relative flex items-center gap-2">
<div class="flex items-center gap-2">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
color="slate"
@@ -284,21 +143,17 @@ const onDraftFailed = error => {
/>
<ButtonGroup class="flex items-center">
<Button
:label="
hasPendingChanges
? t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES')
: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')
"
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing"
:disabled="
status === ARTICLE_STATUSES.PUBLISHED ||
!articleId ||
isArticlePublishing ||
(isPublished && !hasPendingChanges)
isArticlePublishing
"
@click="onPrimaryAction"
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
/>
<div class="relative">
<OnClickOutside @trigger="showArticleActionMenu = false">
@@ -314,17 +169,11 @@ const onDraftFailed = error => {
v-if="showArticleActionMenu"
:menu-items="articleMenuItems"
class="mt-2 ltr:right-0 rtl:left-0 top-full"
@action="onMenuAction($event)"
@action="updateArticleStatus($event)"
/>
</OnClickOutside>
</div>
</ButtonGroup>
<ArticlePendingChangesPopover
ref="pendingChangesPopoverRef"
:article-id="articleId"
@resolved="onDraftResolved"
@failed="onDraftFailed"
/>
</div>
</div>
</div>
@@ -1,132 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { onKeyStroke } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
articleId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['resolved', 'failed']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const isOpen = ref(false);
const requestedStatus = ref(null);
// Which button is in flight, so only that one shows the spinner.
const activeAction = ref(null);
const articleUiFlags = useMapGetter('articles/uiFlags');
const isLoading = computed(
() => articleUiFlags.value(props.articleId).isUpdating
);
// Open the confirmation for a target status; resolving it also applies that status.
const open = status => {
requestedStatus.value = status;
activeAction.value = null;
isOpen.value = true;
};
const close = () => {
isOpen.value = false;
};
// Don't let a click-outside or Escape dismiss the popover mid-action.
const dismiss = () => {
if (!isLoading.value) close();
};
const resolve = async draftAction => {
activeAction.value = draftAction === 'publishDraft' ? 'apply' : 'discard';
try {
await store.dispatch(`articles/${draftAction}`, {
portalSlug: route.params.portalSlug,
articleId: props.articleId,
status: requestedStatus.value,
});
emit('resolved', requestedStatus.value);
close();
} catch (error) {
emit('failed', error);
}
};
const onApply = () => resolve('publishDraft');
const onDiscard = () => resolve('discardDraft');
onKeyStroke('Escape', () => {
if (isOpen.value) dismiss();
});
defineExpose({ open, close });
</script>
<template>
<div
v-show="isOpen"
v-on-click-outside="dismiss"
class="absolute z-50 flex flex-col gap-4 p-4 mt-2 outline outline-1 shadow-lg w-96 end-0 top-full rounded-xl bg-n-alpha-3 backdrop-blur-[100px] outline-n-container"
>
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION'
)
}}
</p>
</div>
<Button
icon="i-lucide-x"
variant="ghost"
color="slate"
size="xs"
class="shrink-0 -me-1 -mt-1"
:disabled="isLoading"
@click="close"
/>
</div>
<div class="flex items-center justify-between gap-2">
<Button
type="button"
variant="faded"
color="ruby"
size="sm"
class="flex-1"
:is-loading="isLoading && activeAction === 'discard'"
:disabled="isLoading"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DISCARD')
"
@click="onDiscard"
/>
<Button
type="button"
color="blue"
size="sm"
class="flex-1"
:is-loading="isLoading && activeAction === 'apply'"
:disabled="isLoading"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.APPLY')
"
@click="onApply"
/>
</div>
</div>
</template>
@@ -5,12 +5,8 @@ import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
getArticleStatus,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper.js';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import wootConstants from 'dashboard/constants/globals';
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
@@ -149,24 +145,6 @@ const updateArticlesMeta = () => {
});
};
const refreshArticleMeta = async () => {
await updateArticlesMeta();
await updatePortalMeta();
};
// The card's pending-changes popover applies the status itself; surface the result.
const onDraftResolved = status => {
useAlert(getStatusMessage(status, true));
refreshArticleMeta();
};
const onDraftFailed = error => {
useAlert(
error?.message ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
};
const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params;
try {
@@ -176,14 +154,6 @@ const handleArticleAction = async (action, { status, id }) => {
articleId: id,
});
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
} else if (action === 'discard-draft') {
await store.dispatch('articles/discardDraft', {
portalSlug,
articleId: id,
});
useAlert(
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS')
);
} else {
await store.dispatch('articles/update', {
portalSlug,
@@ -198,16 +168,15 @@ const handleArticleAction = async (action, { status, id }) => {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
}
await refreshArticleMeta();
await updateArticlesMeta();
await updatePortalMeta();
} catch (error) {
const fallbackMessage =
{
delete: t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE'),
'discard-draft': t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR'
),
}[action] ?? getStatusMessage(status, false);
useAlert(error?.message || fallbackMessage);
const errorMessage =
error?.message ||
(action === 'delete'
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
: getStatusMessage(status, false));
useAlert(errorMessage);
}
};
@@ -241,15 +210,10 @@ const updateArticle = ({ action, value, id }) => {
:views="item.views || 0"
:updated-at="item.updatedAt"
:is-selected="selectedArticleIds.has(item.id)"
:has-pending-changes="
item.status === ARTICLE_STATUSES.PUBLISHED && hasPendingChanges(item)
"
selectable
:show-selection-control="shouldShowSelectionControl(item.id)"
@open-article="openArticle"
@article-action="updateArticle"
@draft-resolved="onDraftResolved"
@draft-failed="onDraftFailed"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, item.id)"
/>
@@ -3,15 +3,10 @@ import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { debounce } from '@chatwoot/utils';
import {
ARTICLE_TABS,
CATEGORY_ALL,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
@@ -65,7 +60,6 @@ const emit = defineEmits([
const router = useRouter();
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
@@ -233,46 +227,15 @@ const onBulkActionSuccess = message => {
};
const bulkUpdateStatus = async status => {
const selectedIds = [...selectedArticleIds.value];
const { portalSlug } = route.params;
const pendingIds = props.articles
.filter(
article =>
selectedIds.includes(article.id) &&
article.status === ARTICLE_STATUSES.PUBLISHED &&
hasPendingChanges(article)
)
.map(article => article.id);
// Publish promotes each pending draft; other status changes skip them.
const isPublishing = status === ARTICLE_STATUSES.PUBLISHED;
const draftIds = isPublishing ? pendingIds : [];
const skippedCount = isPublishing ? 0 : pendingIds.length;
const articleIds = selectedIds.filter(id => !pendingIds.includes(id));
if (!articleIds.length && !draftIds.length) {
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED_ALL'));
return;
}
try {
if (articleIds.length) {
await articlesAPI.bulkUpdateStatus({ portalSlug, articleIds, status });
}
await Promise.all(
draftIds.map(articleId =>
store.dispatch('articles/publishDraft', { portalSlug, articleId })
)
);
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
if (skippedCount) {
useAlert(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED', skippedCount)
);
}
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
@@ -14,7 +14,6 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -62,7 +61,6 @@ const handleClick = () => {
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
:disabled="disabled"
@click="handleClick"
@blur="resetConfirmMode"
>
@@ -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"
@@ -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>
@@ -88,7 +88,7 @@ const openCreateAssistantDialog = () => {
<template>
<div
class="pt-5 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] max-h-96 rounded-xl shadow-md flex flex-col gap-4"
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
@@ -114,10 +114,7 @@ const openCreateAssistantDialog = () => {
@click="openCreateAssistantDialog"
/>
</div>
<div
v-if="assistants.length > 0"
class="flex flex-col flex-1 min-h-0 gap-2 px-4 pb-3 overflow-y-auto overscroll-contain"
>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button
v-for="assistant in assistants"
:key="assistant.id"
@@ -22,21 +22,11 @@ const props = defineProps({
type: [Number, String],
default: 0,
},
manualMigrationRecommended: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['reviewManualMigration']);
const reauthorizationRequired = computed(() => {
return props.inbox.reauthorization_required;
});
const showManualMigrationRecommendation = computed(() => {
return props.manualMigrationRecommended && !reauthorizationRequired.value;
});
</script>
<template>
@@ -52,14 +42,4 @@ const showManualMigrationRecommendation = computed(() => {
>
<Icon icon="i-woot-alert" class="size-3 text-n-ruby-9" />
</div>
<button
v-else-if="showManualMigrationRecommendation"
v-tooltip.top-end="$t('SIDEBAR.WHATSAPP_MANUAL_MIGRATION')"
type="button"
:aria-label="$t('SIDEBAR.WHATSAPP_MANUAL_MIGRATION')"
class="grid place-content-center size-5 bg-n-blue-5/60 rounded-full hover:bg-n-blue-5 focus-visible:bg-n-blue-5 focus-visible:outline-none"
@click.stop.prevent="emit('reviewManualMigration')"
>
<Icon icon="i-lucide-info" class="size-3 text-n-blue-9" />
</button>
</template>
@@ -6,8 +6,6 @@ import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -45,8 +43,6 @@ const emit = defineEmits([
]);
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const { isAdmin } = useAdmin();
const router = useRouter();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
@@ -89,39 +85,6 @@ const hasFilteredUnreadCounts = computed(() => {
);
});
const hasDataImport = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.DATA_IMPORT
);
});
const hasWhatsAppManualTransfer = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
);
});
const isWhatsAppManualMigrationRecommended = inbox => {
return (
isAdmin.value &&
hasWhatsAppManualTransfer.value &&
inbox.channel_type === 'Channel::Whatsapp' &&
inbox.provider === 'whatsapp_cloud' &&
inbox.provider_config?.source === 'embedded_signup' &&
!inbox.reauthorization_required
);
};
const reviewWhatsAppManualMigration = inboxId => {
router.push(
accountScopedRoute('settings_inbox_show', {
inboxId,
})
);
};
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
@@ -488,10 +451,6 @@ const menuItems = computed(() => {
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
manualMigrationRecommended:
isWhatsAppManualMigrationRecommended(inbox),
onReviewManualMigration: () =>
reviewWhatsAppManualMigration(inbox.id),
}),
})),
},
@@ -897,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'),
@@ -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 = () => {
-3
View File
@@ -8,12 +8,9 @@ export const FEATURE_FLAGS = {
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
DATA_IMPORT: 'data_import',
API_AND_WEBHOOKS: 'api_and_webhooks',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
@@ -1,172 +0,0 @@
// Powers the "unpublished changes" preview: marks what changed between the live
// article and the draft — word by word in the title, block by block in the body.
import MarkdownIt from 'markdown-it';
// Matches the public renderer (CommonMark, no typographer). True when two
// markdown strings render the same — so blank-line/spacing-only edits don't count,
// but real changes (code indentation, smart quotes, width markers) do.
const commonmark = MarkdownIt('commonmark');
export const rendersIdentically = (a, b) =>
commonmark.render(a ?? '') === commonmark.render(b ?? '');
const INS_CLASS = '!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5';
const DEL_CLASS = '!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5';
// Detailed compare gets slow on huge texts; past this, show all old as removed
// and all new as added.
const MAX_DIFF_TOKENS = 2000;
const tokenizeWords = value => (value || '').match(/\S+/g) || [];
const escapeHtml = value =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Compares two lists in order and reports what's the same (`equal`), removed
// (`del`) or added (`ins`), keeping as much unchanged as possible. `keyOf` says
// how to compare items (title passes words, body passes blocks).
const diffSequence = (a, b, keyOf = item => item) => {
const n = a.length;
const m = b.length;
if (n > MAX_DIFF_TOKENS || m > MAX_DIFF_TOKENS) {
return [
...a.map(item => ({ type: 'del', item })),
...b.map(item => ({ type: 'ins', item })),
];
}
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i -= 1) {
for (let j = m - 1; j >= 0; j -= 1) {
dp[i][j] =
keyOf(a[i]) === keyOf(b[j])
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (keyOf(a[i]) === keyOf(b[j])) {
ops.push({ type: 'equal', item: a[i] });
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ type: 'del', item: a[i] });
i += 1;
} else {
ops.push({ type: 'ins', item: b[j] });
j += 1;
}
}
while (i < n) {
ops.push({ type: 'del', item: a[i] });
i += 1;
}
while (j < m) {
ops.push({ type: 'ins', item: b[j] });
j += 1;
}
return ops;
};
const wrapDiff = {
ins: text => `<ins class="${INS_CLASS}">${text}</ins>`,
del: text => `<del class="${DEL_CLASS}">${text}</del>`,
};
// Builds the highlighted title. Compares whole words (not single spaces) so
// repeated words/spaces don't make the highlights jump around, then rejoins
// with single spaces — a run of added/removed words shares one <ins>/<del> tag.
export const renderInlineDiff = (oldValue, newValue) => {
const ops = diffSequence(tokenizeWords(oldValue), tokenizeWords(newValue));
const segments = [];
let run = [];
let runType = null;
const flushRun = () => {
if (!run.length) return;
const text = run.map(escapeHtml).join(' ');
segments.push(wrapDiff[runType] ? wrapDiff[runType](text) : text);
run = [];
};
ops.forEach(({ type, item }) => {
if (type !== runType) flushRun();
runType = type;
run.push(item);
});
flushRun();
return segments.join(' ');
};
// A fenced code block opener: ``` or ~~~, indented up to 3 spaces (CommonMark).
const FENCE_RE = /^ {0,3}(```|~~~)/;
// A list item marker: -, *, + or "1." / "1)", indented up to 3 spaces.
const LIST_ITEM_RE = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
// Split on blank lines so each paragraph, heading or list compares as one piece.
// Blank lines inside a fenced code block, or between items of the same list, are
// content — splitting there would tear a code block or list apart and render it
// with broken structure (orphaned <li>/<p>), so we keep those together.
const splitBlocks = text => {
const lines = (text || '').split('\n');
const blocks = [];
let buffer = [];
let fence = null;
let inList = false;
const flush = () => {
const block = buffer.join('\n');
if (block.trim()) blocks.push(block);
buffer = [];
inList = false;
};
lines.forEach((line, index) => {
const marker = line.match(FENCE_RE)?.[1];
if (marker && !fence) fence = marker;
else if (fence && line.trimStart().startsWith(fence)) fence = null;
if (fence) {
buffer.push(line);
return;
}
if (LIST_ITEM_RE.test(line)) inList = true;
if (line.trim() !== '') {
buffer.push(line);
return;
}
// Blank line: keep it when the current list continues on the next non-blank
// line (another item or an indented continuation); otherwise end the block.
const next = lines.slice(index + 1).find(other => other.trim() !== '');
if (inList && next && (LIST_ITEM_RE.test(next) || /^\s/.test(next))) {
buffer.push(line);
} else {
flush();
}
});
flush();
return blocks;
};
const BLOCK_TYPE = { equal: 'equal', del: 'removed', ins: 'added' };
// Diffs the body block by block. Blocks match when they render to the same HTML
// (the check staging uses), so only edits that change the page show as a diff.
export const buildDiffBlocks = (oldText, newText) => {
const toBlocks = text =>
splitBlocks(text).map(md => ({ md, key: commonmark.render(md) }));
const ops = diffSequence(toBlocks(oldText), toBlocks(newText), b => b.key);
return ops.map(op => ({ type: BLOCK_TYPE[op.type], md: op.item.md }));
};
export const hasPendingChanges = article =>
article?.draftTitle != null || article?.draftContent != null;
@@ -1,170 +0,0 @@
import {
renderInlineDiff,
buildDiffBlocks,
hasPendingChanges,
rendersIdentically,
} from '../articleDiffHelper';
describe('articleDiffHelper', () => {
describe('renderInlineDiff', () => {
it('returns the text unchanged when there is no difference', () => {
const result = renderInlineDiff('hello world', 'hello world');
expect(result).toBe('hello world');
expect(result).not.toContain('<ins');
expect(result).not.toContain('<del');
});
it('wraps inserted words in <ins>', () => {
const result = renderInlineDiff('hello', 'hello there');
expect(result).toContain('hello');
expect(result).toContain('<ins');
expect(result).toContain('there');
});
it('wraps removed words in <del>', () => {
const result = renderInlineDiff('hello there', 'hello');
expect(result).toContain('<del');
expect(result).toContain('there');
});
it('keeps a single removal contiguous when a word repeats', () => {
const result = renderInlineDiff(
'How to use Agent bots?',
'How How to Agent bots?'
);
expect(result).toBe(
'How <ins class="!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5">How</ins> to <del class="!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5">use</del> Agent bots?'
);
});
it('escapes markup when diffing plain text', () => {
const result = renderInlineDiff('a', 'a <b>');
expect(result).toContain('&lt;b&gt;');
expect(result).not.toContain('<b>');
});
it('treats a cleared empty string as a full deletion', () => {
const result = renderInlineDiff('gone', '');
expect(result).toContain('<del');
expect(result).toContain('gone');
});
});
describe('buildDiffBlocks', () => {
it('passes an unchanged block through as equal', () => {
const blocks = buildDiffBlocks('same para', 'same para');
expect(blocks).toEqual([{ type: 'equal', md: 'same para' }]);
});
it('marks an appended block as added', () => {
const blocks = buildDiffBlocks('a', 'a\n\nb');
expect(blocks).toContainEqual({ type: 'equal', md: 'a' });
expect(blocks).toContainEqual({ type: 'added', md: 'b' });
});
it('marks a deleted block as removed', () => {
const blocks = buildDiffBlocks('a\n\nb', 'a');
expect(blocks).toContainEqual({ type: 'removed', md: 'b' });
});
it('emits the old block then the new block for a reworded section', () => {
const blocks = buildDiffBlocks('hello world', 'hello there');
expect(blocks).toEqual([
{ type: 'removed', md: 'hello world' },
{ type: 'added', md: 'hello there' },
]);
});
it('keeps a fenced code block whole when it contains blank lines', () => {
const code = '```\nline one\n\nline two\n```';
const blocks = buildDiffBlocks(code, code);
expect(blocks).toEqual([{ type: 'equal', md: code }]);
});
it('diffs an edited code block as one whole removed + added block', () => {
const live = '```\ncode line\n```';
const draft = '```\ncode line\n\nsd\n```';
const blocks = buildDiffBlocks(live, draft);
expect(blocks).toContainEqual({ type: 'removed', md: live });
expect(blocks).toContainEqual({ type: 'added', md: draft });
});
it('surfaces whitespace edits that change the rendered output', () => {
expect(
buildDiffBlocks('```\nx\n```', '```\n x\n```').some(
block => block.type !== 'equal'
)
).toBe(true);
expect(
buildDiffBlocks('line one\nline two', 'line one \nline two').some(
block => block.type !== 'equal'
)
).toBe(true);
});
it('surfaces an indented code block turning into a paragraph', () => {
const blocks = buildDiffBlocks(
' curl example.com',
'curl example.com'
);
expect(blocks).toContainEqual({
type: 'removed',
md: ' curl example.com',
});
expect(blocks).toContainEqual({ type: 'added', md: 'curl example.com' });
});
it('keeps spacing the renderer ignores as equal', () => {
const blocks = buildDiffBlocks('a\nb', 'a \nb');
expect(blocks.every(block => block.type === 'equal')).toBe(true);
});
it('keeps a loose list with item descriptions as one block', () => {
const list =
'1. **One**\n\n First item.\n\n2. **Two**\n\n Second item.';
const blocks = buildDiffBlocks(list, list);
expect(blocks).toEqual([{ type: 'equal', md: list }]);
});
});
describe('rendersIdentically', () => {
it('ignores blank-line / empty-paragraph differences', () => {
expect(rendersIdentically('a\n\nb', 'a\n\n\nb')).toBe(true);
expect(rendersIdentically('hello', 'hello\n\n')).toBe(true);
});
it('counts code-block indentation changes', () => {
expect(rendersIdentically('```\n x\n```', '```\nx\n```')).toBe(false);
});
it('counts smart vs straight quotes (no typographer)', () => {
expect(rendersIdentically('"hi"', '“hi”')).toBe(false);
});
it('counts real text changes', () => {
expect(rendersIdentically('hello world', 'hello there')).toBe(false);
});
it('treats nullish input as empty', () => {
expect(rendersIdentically(null, '')).toBe(true);
expect(rendersIdentically(undefined, 'x')).toBe(false);
});
});
describe('hasPendingChanges', () => {
it('is true when a draft title or content is staged', () => {
expect(hasPendingChanges({ draftContent: 'edit' })).toBe(true);
expect(hasPendingChanges({ draftTitle: 'edit' })).toBe(true);
});
it('treats a cleared empty-string draft as a pending change', () => {
expect(hasPendingChanges({ draftTitle: '' })).toBe(true);
});
it('is false with no draft columns', () => {
expect(hasPendingChanges({ title: 'live' })).toBe(false);
expect(hasPendingChanges({})).toBe(false);
expect(hasPendingChanges(null)).toBe(false);
});
});
});
@@ -533,8 +533,6 @@
"PUBLISHED": "Published",
"ARCHIVED": "Archived"
},
"PENDING_EDITS": "Unpublished edits",
"PENDING_EDITS_TOOLTIP": "This published article has unpublished edits",
"CATEGORY": {
"UNCATEGORISED": "Uncategorised"
}
@@ -618,8 +616,6 @@
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"STATUS_SKIPPED": "1 article with unpublished edits was skipped — open it to publish or discard. | {count} articles with unpublished edits were skipped — open them to publish or discard.",
"STATUS_SKIPPED_ALL": "These articles have unpublished edits — open each to publish or discard.",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
@@ -767,30 +763,10 @@
},
"PREVIEW": "Preview",
"PUBLISH": "Publish",
"PUBLISH_CHANGES": "Publish changes",
"PUBLISH_CHANGES_SUCCESS": "Changes published successfully",
"PUBLISH_CHANGES_ERROR": "Could not publish changes",
"SAVE_IN_PROGRESS": "Still saving your latest changes — please try again in a moment.",
"DISCARD_CHANGES": "Discard changes",
"DISCARD_CHANGES_SUCCESS": "Changes discarded",
"DISCARD_CHANGES_ERROR": "Could not discard changes",
"PENDING_CHANGES": "Pending changes",
"VIEW_CHANGES": "View unpublished changes",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"BACK_TO_ARTICLES": "Back to articles"
},
"PENDING_CHANGES_POPOVER": {
"TITLE": "Unpublished changes",
"DESCRIPTION": "This article has draft changes that aren't live yet. Apply them before changing the status, or discard them?",
"APPLY": "Apply changes",
"DISCARD": "Discard changes"
},
"DIFF_DIALOG": {
"TITLE": "Unpublished changes",
"DESCRIPTION": "Compare your draft against the version that's currently live.",
"TITLE_LABEL": "Title"
},
"EDIT_ARTICLE": {
"MORE_PROPERTIES": "More properties",
"UNCATEGORIZED": "Uncategorized",
@@ -11,9 +11,7 @@
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isn’t complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "There are no inboxes attached to this account.",
"REAUTHORIZATION_REQUIRED": "Reauthorization required",
"MANUAL_SETUP_RECOMMENDED": "Manual setup recommended"
"404": "There are no inboxes attached to this account."
},
"CREATE_FLOW": {
"CHANNEL": {
@@ -850,8 +848,8 @@
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_MANUAL_MIGRATION": {
"BANNER": {
"TITLE": "Manual setup recommended",
"DESCRIPTION": "This inbox connects through the shared Meta app used for embedded signup, which recent Meta restrictions have affected. To avoid similar issues in the future, we recommend reconnecting it with your own Meta app.",
"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"
},
@@ -859,8 +857,8 @@
"EYEBROW": "WhatsApp manual migration",
"TITLE": "Reconnect WhatsApp inbox",
"CLOSE": "Close",
"ACTION_REQUIRED_TITLE": "Reconnect with your own Meta app",
"ACTION_REQUIRED_DESCRIPTION": "Inboxes connected through your own Meta app are not affected by restrictions on the shared embedded signup app. This guided flow updates the WhatsApp API connection without creating a new inbox.",
"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.",
@@ -31,13 +31,6 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
"PAYWALL": {
"TITLE": "Webhooks are available on paid plans",
"AVAILABLE_ON": "Use webhooks to receive real-time events from your Chatwoot account.",
"UPGRADE_PROMPT": "Upgrade to the Startups, Business, or Enterprise plan to use webhooks.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "Change or cancel your plan anytime."
},
"SECRET": {
"LABEL": "Secret",
"COPY": "Copy secret to clipboard",
@@ -818,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})",
@@ -878,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": {
@@ -939,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"
@@ -100,7 +100,6 @@
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
"PAID_PLAN_NOTE": "API access tokens are available on paid plans.",
"COPY": "Copy",
"RESET": "Reset",
"CONFIRM_RESET": "Are you sure?",
@@ -341,7 +340,6 @@
"NOTIFICATIONS": "Notifications",
"CANNED_RESPONSES": "Canned Responses",
"INTEGRATIONS": "Integrations",
"DATA": "Data",
"PROFILE_SETTINGS": "Profile Settings",
"ACCOUNT_SETTINGS": "Account Settings",
"APPLICATIONS": "Applications",
@@ -380,7 +378,6 @@
"BETA": "Beta",
"REPORTS_OVERVIEW": "Overview",
"REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages",
"WHATSAPP_MANUAL_MIGRATION": "Manual setup recommended. Reconnect this WhatsApp inbox with your own Meta app.",
"HELP_CENTER": {
"TITLE": "Help Center",
"ARTICLES": "Articles",
@@ -414,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"
@@ -4,12 +4,8 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
buildPortalArticleURL,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { rendersIdentically } from 'dashboard/helper/articleDiffHelper';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
@@ -44,60 +40,13 @@ const articleLink = computed(() => {
);
});
// On a published article, title/content edits stage into draft_* columns (kept
// off the live site). Anywhere else they save straight to the live record — and
// we drop any leftover draft (e.g. left behind when the card/bulk menu moved a
// published article to draft) so a later publish can't resurrect stale content.
const stageDraftFields = values => {
if (article.value?.status !== ARTICLE_STATUSES.PUBLISHED) {
const hasStaleDraft =
article.value?.draftTitle != null || article.value?.draftContent != null;
if (!hasStaleDraft) return values;
// The editor is showing the staged draft, so promote both fields to the live
// record (the field being autosaved wins) before dropping the drafts —
// otherwise saving one field would snap the other back to the old live value.
return {
...values,
title: values.title ?? article.value.draftTitle ?? article.value.title,
content:
values.content ?? article.value.draftContent ?? article.value.content,
draft_title: null,
draft_content: null,
};
}
const staged = { ...values };
['title', 'content'].forEach(field => {
if (field in staged) {
staged[`draft_${field}`] = staged[field];
delete staged[field];
}
});
// Clear the draft when it matches the live version (a revert, or a body edit
// the renderer ignores like a blank line) so it doesn't leave a "pending
// changes" badge with nothing to compare. The title is shown as raw escaped
// text, so compare it exactly; only the body is Markdown, so compare its render.
const liveTitle = article.value.title ?? '';
const liveContent = article.value.content ?? '';
const nextTitle = staged.draft_title ?? article.value.draftTitle ?? liveTitle;
const nextContent =
staged.draft_content ?? article.value.draftContent ?? liveContent;
if (nextTitle === liveTitle && rendersIdentically(liveContent, nextContent)) {
staged.draft_title = null;
staged.draft_content = null;
}
return staged;
};
const saveArticle = async ({ ...values }) => {
isUpdating.value = true;
try {
await store.dispatch('articles/update', {
portalSlug,
articleId: articleSlug,
...stageDraftFields(values),
...values,
});
isSaved.value = true;
} catch (error) {
@@ -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';
@@ -15,9 +15,6 @@ import {
import ChannelName from './components/ChannelName.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const getters = useStoreGetters();
const store = useStore();
@@ -29,31 +26,6 @@ const selectedInbox = ref({});
const searchQuery = ref('');
const inboxes = useMapGetter('inboxes/getInboxes');
const accountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const hasWhatsAppManualTransfer = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
);
});
const isWhatsAppManualMigrationRecommended = inbox => {
return (
hasWhatsAppManualTransfer.value &&
inbox.channel_type === 'Channel::Whatsapp' &&
inbox.provider === 'whatsapp_cloud' &&
inbox.provider_config?.source === 'embedded_signup' &&
!inbox.reauthorization_required
);
};
onActivated(() => {
store.dispatch('inboxes/get');
});
const inboxesList = computed(() => {
return inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name));
@@ -170,48 +142,12 @@ const openDelete = inbox => {
<span class="block text-heading-3 text-n-slate-12 capitalize">
{{ inbox.name }}
</span>
<div class="flex flex-wrap items-center gap-2">
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
<router-link
v-if="inbox.reauthorization_required"
:to="{
name: 'settings_inbox_show',
params: { inboxId: inbox.id },
}"
>
<Label
:label="$t('INBOX_MGMT.LIST.REAUTHORIZATION_REQUIRED')"
color="ruby"
compact
>
<template #icon>
<Icon icon="i-lucide-triangle-alert" class="size-3.5" />
</template>
</Label>
</router-link>
<router-link
v-else-if="isWhatsAppManualMigrationRecommended(inbox)"
:to="{
name: 'settings_inbox_show',
params: { inboxId: inbox.id },
}"
>
<Label
:label="$t('INBOX_MGMT.LIST.MANUAL_SETUP_RECOMMENDED')"
color="blue"
compact
>
<template #icon>
<Icon icon="i-lucide-info" class="size-3.5" />
</template>
</Label>
</router-link>
</div>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
</div>
</div>
<div class="flex gap-3 justify-end">
@@ -387,10 +387,12 @@ 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.inbox.reauthorization_required &&
!this.showWhatsAppManualMigration
);
},
whatsappRegistrationIncomplete() {
@@ -21,14 +21,14 @@ const copy = computed(() => ({
</script>
<template>
<Banner color="blue" :action-label="copy.start" @action="emit('start')">
<Banner color="amber" :action-label="copy.start" @action="emit('start')">
<div class="flex items-start gap-2">
<Icon
icon="i-lucide-info"
class="flex-shrink-0 mt-0.5 size-4 text-n-blue-11"
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-blue-12">{{ copy.title }}</span>
<span class="font-medium text-n-amber-12">{{ copy.title }}</span>
<span>
{{ copy.description }}
<a
@@ -310,9 +310,9 @@ defineExpose({ open, close });
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-blue-3 text-n-blue-11"
class="grid flex-shrink-0 rounded-lg size-8 place-content-center bg-n-amber-3 text-n-amber-11"
>
<Icon icon="i-lucide-info" class="size-4" />
<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">
@@ -1,9 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import whatsappChannel from 'dashboard/api/channel/whatsappChannel';
import inboxMixin from 'shared/mixins/inboxMixin';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
@@ -34,8 +30,7 @@ export default {
},
},
setup() {
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
return { v$: useVuelidate(), runEmbeddedSignup };
return { v$: useVuelidate() };
},
data() {
return {
@@ -46,29 +41,15 @@ export default {
allowedDomains: '',
isUpdatingAllowedDomains: false,
isSettingDefaults: false,
isReconfiguring: false,
};
},
validations: {
whatsAppInboxAPIKey: { required },
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
showWhatsAppReconfigure() {
return (
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_RECONFIGURE
)
);
},
isForwardingEnabled() {
return !!this.inbox.forwarding_enabled;
},
@@ -179,28 +160,6 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async reconfigureWhatsApp() {
this.isReconfiguring = true;
try {
const credentials = await this.runEmbeddedSignup();
// User dismissed the Meta popup without completing signup.
if (!credentials) return;
await whatsappChannel.reauthorizeWhatsApp({
inboxId: this.inbox.id,
...credentials,
});
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_SUCCESS')
);
} catch (error) {
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_ERROR')
);
} finally {
this.isReconfiguring = false;
}
},
async syncTemplates() {
this.isSyncingTemplates = true;
try {
@@ -399,23 +358,6 @@ export default {
>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
</SettingsFieldSection>
<SettingsFieldSection
v-if="showWhatsAppReconfigure"
:label="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')
"
>
<NextButton
:is-loading="isReconfiguring"
:disabled="isReconfiguring"
@click="reconfigureWhatsApp"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</SettingsFieldSection>
</template>
<!-- Manual Setup Section -->
@@ -5,11 +5,9 @@ import { useBranding } from 'shared/composables/useBranding';
import { picoSearch } from '@scmmishra/pico-search';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
import WebhookRow from './WebhookRow.vue';
import WebhookPaywall from './WebhookPaywall.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import SettingsLayout from '../../SettingsLayout.vue';
@@ -22,7 +20,6 @@ export default {
NewWebhook,
EditWebhook,
WebhookRow,
WebhookPaywall,
},
setup() {
const { replaceInstallationName } = useBranding();
@@ -42,19 +39,7 @@ export default {
...mapGetters({
records: 'webhooks/getWebhooks',
uiFlags: 'webhooks/getUIFlags',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
apiAndWebhooksEnabled() {
return (
!this.isOnChatwootCloud ||
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.API_AND_WEBHOOKS
)
);
},
integration() {
return this.$store.getters['integrations/getIntegration']('webhook');
},
@@ -72,16 +57,9 @@ export default {
];
},
},
watch: {
apiAndWebhooksEnabled: {
immediate: true,
handler(enabled) {
if (enabled) this.$store.dispatch('webhooks/get');
},
},
},
mounted() {
this.$store.dispatch('integrations/get', 'webhook');
this.$store.dispatch('webhooks/get');
},
methods: {
openAddPopup() {
@@ -127,10 +105,10 @@ export default {
<template>
<SettingsLayout
:is-loading="apiAndWebhooksEnabled && uiFlags.fetchingList"
:is-loading="uiFlags.fetchingList"
:loading-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LOADING')"
:no-records-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LIST.404')"
:no-records-found="apiAndWebhooksEnabled && !records.length"
:no-records-found="!records.length"
>
<template #header>
<BaseSettingsHeader
@@ -140,21 +118,19 @@ export default {
:description="replaceInstallationName(integration.description)"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
:search-placeholder="
apiAndWebhooksEnabled
? $t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
: ''
$t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
<template v-if="apiAndWebhooksEnabled && records?.length" #count>
<template v-if="records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{
$t('INTEGRATION_SETTINGS.WEBHOOK.COUNT', { n: records.length })
}}
</span>
</template>
<template v-if="apiAndWebhooksEnabled" #actions>
<template #actions>
<NextButton
blue
:label="$t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT')"
@@ -165,9 +141,7 @@ export default {
</BaseSettingsHeader>
</template>
<template #body>
<WebhookPaywall v-if="!apiAndWebhooksEnabled" />
<BaseTable
v-else
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
@@ -186,19 +160,11 @@ export default {
</template>
</BaseTable>
</template>
<woot-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showAddPopup"
:on-close="hideAddPopup"
>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
</woot-modal>
<woot-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showEditPopup"
:on-close="hideEditPopup"
>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<EditWebhook
v-if="showEditPopup"
:id="selectedWebHook.id"
@@ -207,7 +173,6 @@ export default {
/>
</woot-modal>
<woot-delete-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
@@ -1,27 +0,0 @@
<script setup>
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const accountId = useMapGetter('getCurrentAccountId');
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
</script>
<template>
<div class="grid place-content-center w-full h-full max-h-[28rem] mx-auto">
<BasePaywallModal
class="mx-auto"
feature-prefix="INTEGRATION_SETTINGS.WEBHOOK"
i18n-key="PAYWALL"
is-on-chatwoot-cloud
@upgrade="openBilling"
/>
</div>
</template>
@@ -6,7 +6,6 @@ import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
const props = defineProps({
value: { type: String, default: '' },
showResetButton: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['onCopy', 'onReset']);
@@ -42,14 +41,12 @@ const onReset = () => {
}"
:type="inputType"
:model-value="value"
:disabled="disabled"
readonly
>
<template #masked>
<button
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5"
type="button"
:disabled="disabled"
@click="toggleMasked"
>
<fluent-icon :icon="maskIcon" :size="16" />
@@ -64,7 +61,6 @@ const onReset = () => {
type="button"
icon="i-lucide-copy"
class="rounded-xl"
:disabled="disabled"
@click="onClick"
/>
<ConfirmButton
@@ -77,7 +73,6 @@ const onReset = () => {
variant="outline"
icon="i-lucide-key-round"
class="rounded-xl"
:disabled="disabled"
@click="onReset"
/>
</div>
@@ -101,24 +101,7 @@ export default {
currentUser: 'getCurrentUser',
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
apiAndWebhooksEnabled() {
if (!this.isOnChatwootCloud) return true;
return this.currentUser.accounts.some(
account => account.api_and_webhooks
);
},
accessTokenDescription() {
if (!this.apiAndWebhooksEnabled) {
return this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.PAID_PLAN_NOTE');
}
return this.replaceInstallationName(
this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE')
);
},
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
@@ -208,14 +191,10 @@ export default {
useAlert(this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS'));
},
async onCopyToken(value) {
if (!this.apiAndWebhooksEnabled) return;
await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
async resetAccessToken() {
if (!this.apiAndWebhooksEnabled) return;
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
@@ -360,11 +339,12 @@ export default {
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="accessTokenDescription"
:description="
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
"
>
<AccessToken
:value="currentUser.access_token"
:disabled="!apiAndWebhooksEnabled"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
@@ -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,
@@ -25,32 +25,24 @@ const fetchMetaData = async (commit, params) => {
}
};
const debouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 5000);
const longDebouncedFetchMetaData = debounce(fetchMetaData, 7500, false, 20000);
const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 2000);
const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
const superLongDebouncedFetchMetaData = debounce(
fetchMetaData,
15000,
10000,
false,
30000
20000
);
const metaDebouncers = {
default: debouncedFetchMetaData,
long: longDebouncedFetchMetaData,
superLong: superLongDebouncedFetchMetaData,
};
// allCount is 0 until a meta request succeeds; under load it stays 0, so treat
// the unknown case as a large account and poll slowest instead of fastest.
export const getMetaDebounceKey = allCount => {
if (allCount > 2000 || allCount === 0) return 'superLong';
if (allCount > 100) return 'long';
return 'default';
};
export const actions = {
get: ({ commit, state: $state }, params) => {
metaDebouncers[getMetaDebounceKey($state.allCount)](commit, params);
get: async ({ commit, state: $state }, params) => {
if ($state.allCount > 2000) {
superLongDebouncedFetchMetaData(commit, params);
} else if ($state.allCount > 100) {
longDebouncedFetchMetaData(commit, params);
} else {
debouncedFetchMetaData(commit, params);
}
},
set({ commit }, meta) {
commit(types.SET_CONV_TAB_META, meta);
@@ -96,32 +96,6 @@ export const actions = {
}
},
// Push the draft to live and clear it, optionally changing status in the same
// update. Only edited fields are sent so an untouched live value survives.
publishDraft: ({ dispatch, state }, { portalSlug, articleId, status }) => {
const article = state.articles.byId[articleId];
const payload = {
portalSlug,
articleId,
status,
draft_title: null,
draft_content: null,
};
if (article?.draftTitle != null) payload.title = article.draftTitle;
if (article?.draftContent != null) payload.content = article.draftContent;
return dispatch('update', payload);
},
// Clear the draft (optionally changing status); live content is left untouched.
discardDraft: ({ dispatch }, { portalSlug, articleId, status }) =>
dispatch('update', {
portalSlug,
articleId,
status,
draft_title: null,
draft_content: null,
}),
updateArticleMeta: async ({ commit }, { portalSlug, locale }) => {
try {
const { data } = await articlesAPI.getArticles({
@@ -150,101 +150,6 @@ describe('#actions', () => {
});
});
describe('#publishDraft', () => {
const state = {
articles: {
byId: {
1: {
id: 1,
draftTitle: 'Draft title',
draftContent: 'Draft content',
},
},
},
};
it('dispatches update promoting the edited fields and clearing the draft', async () => {
await actions.publishDraft(
{ dispatch, state },
{ portalSlug: 'room-rental', articleId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('update', {
portalSlug: 'room-rental',
articleId: 1,
status: undefined,
draft_title: null,
draft_content: null,
title: 'Draft title',
content: 'Draft content',
});
});
it('only sends the fields that were actually edited', async () => {
const partialState = {
articles: { byId: { 1: { id: 1, draftContent: 'Only content' } } },
};
await actions.publishDraft(
{ dispatch, state: partialState },
{ portalSlug: 'room-rental', articleId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('update', {
portalSlug: 'room-rental',
articleId: 1,
status: undefined,
draft_title: null,
draft_content: null,
content: 'Only content',
});
});
it('forwards a status to change it in the same update', async () => {
await actions.publishDraft(
{ dispatch, state },
{ portalSlug: 'room-rental', articleId: 1, status: 'archived' }
);
expect(dispatch).toHaveBeenCalledWith(
'update',
expect.objectContaining({
status: 'archived',
title: 'Draft title',
content: 'Draft content',
draft_title: null,
draft_content: null,
})
);
});
});
describe('#discardDraft', () => {
it('dispatches update clearing the draft columns', async () => {
await actions.discardDraft(
{ dispatch },
{ portalSlug: 'room-rental', articleId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('update', {
portalSlug: 'room-rental',
articleId: 1,
status: undefined,
draft_title: null,
draft_content: null,
});
});
it('forwards a status to change it in the same update', async () => {
await actions.discardDraft(
{ dispatch },
{ portalSlug: 'room-rental', articleId: 1, status: 'draft' }
);
expect(dispatch).toHaveBeenCalledWith('update', {
portalSlug: 'room-rental',
articleId: 1,
status: 'draft',
draft_title: null,
draft_content: null,
});
});
});
describe('#updateArticleMeta', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
@@ -67,11 +67,8 @@ const createMarkdownInstance = (linkify = true) => {
// `<!--cw-colwidths:...-->` comment before the table. It exists only for the
// editor's markdown round-trip and must never surface as text — markdown-it runs
// with `html: false`, which would otherwise escape it into a visible comment in
// rendered/plain output (e.g. dashboard search snippets). Strip the whole marker
// line, including any blockquote prefix, so a quoted table's `>` prefixes don't
// collapse together and break table parsing.
const COLWIDTHS_MARKER_REGEX =
/^[ \t>]*<!--cw-colwidths:[\d,]+-->[ \t]*\r?\n?/gm;
// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
const COLWIDTHS_MARKER_REGEX = /<!--cw-colwidths:[\d,]+-->\r?\n?/g;
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
@@ -107,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';
@@ -153,15 +134,6 @@ After`;
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
expect(formatter.plainText).not.toContain('cw-colwidths');
});
it('strips a blockquote-prefixed marker so the quoted table still renders', () => {
const message =
'> <!--cw-colwidths:120,200-->\n> | A | B |\n> | --- | --- |\n> | 1 | 2 |';
const { formattedMessage } = new MessageFormatter(message);
expect(formattedMessage).not.toContain('cw-colwidths');
expect(formattedMessage).toContain('<blockquote>');
expect(formattedMessage).toContain('<table>');
});
});
describe('#sanitize', () => {
-4
View File
@@ -66,9 +66,6 @@ export default {
? getLanguageDirection(this.$root.$i18n.locale)
: false;
},
isUnreadOrCampaignView() {
return ['unread-messages', 'campaigns'].includes(this.$route.name);
},
},
watch: {
activeCampaign() {
@@ -377,7 +374,6 @@ export default {
'is-widget-right': isRightAligned,
'is-bubble-hidden': hideMessageBubble,
'is-flat-design': isWidgetStyleFlat,
'bg-n-slate-2 dark:bg-n-solid-1': !isUnreadOrCampaignView,
dark: prefersDarkMode,
}"
>
@@ -10,7 +10,7 @@ export default {
</script>
<template>
<div class="bg-n-solid-1 h-full">
<div class="bg-white h-full">
<IframeLoader :url="$route.query.link" />
</div>
</template>
@@ -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,8 +108,6 @@ class WebhookListener < BaseListener
end
def deliver_account_webhooks(payload, account)
return unless account.api_and_webhooks_enabled?
account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event])
-4
View File
@@ -154,10 +154,6 @@ class Account < ApplicationRecord
}
end
def api_and_webhooks_enabled?
true
end
def locale_english_name
# the locale can also be something like pt_BR, en_US, fr_FR, etc.
# the format is `<locale_code>_<country_code>`
-2
View File
@@ -5,8 +5,6 @@
# id :bigint not null, primary key
# content :text
# description :text
# draft_content :text
# draft_title :string
# locale :string default("en"), not null
# meta :jsonb
# position :integer
+4 -12
View File
@@ -101,13 +101,6 @@ class Channel::Whatsapp < ApplicationRecord
end
end
# Whether the pending (unsaved) provider_config change drops the embedded_signup
# source marker, i.e. this save is an embedded signup → manual setup transfer.
def embedded_to_manual_transfer_pending?
before, after = provider_config_change
before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup'
end
def mark_message_templates_updated
# rubocop:disable Rails/SkipsModelValidations
update_column(:message_templates_last_updated, Time.zone.now)
@@ -137,14 +130,13 @@ class Channel::Whatsapp < ApplicationRecord
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
# Logs only the embedded signup → manual migration (the save drops the
# embedded_signup source marker), so credential rotations on inboxes that are
# already manual stay silent.
# 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
return unless before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup'
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_EMBEDDED_TO_MANUAL] success account_id=#{account_id} channel_id=#{id}")
Rails.logger.info("[WHATSAPP_MANUAL_TRANSFER] success account_id=#{account_id} channel_id=#{id}")
end
def perform_webhook_setup
@@ -54,20 +54,7 @@ module ActivityMessageHandler
user_status_change_activity_content(user_name)
end
return if content.blank?
::Conversations::ActivityMessageJob.perform_later(
self,
activity_message_params(
content,
content_attributes: {
activity: {
type: 'conversation_status_changed',
status: status
}
}
)
)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def auto_resolve_message_key(minutes)
@@ -100,10 +87,8 @@ module ActivityMessageHandler
end
end
def activity_message_params(content, content_attributes: nil)
params = { account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content }
params[:content_attributes] = content_attributes if content_attributes.present?
params
def activity_message_params(content)
{ account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content }
end
def create_muted_message
+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
-21
View File
@@ -1,21 +0,0 @@
class DashboardAppPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def create?
@account_user.administrator?
end
def update?
@account_user.administrator?
end
def destroy?
@account_user.administrator?
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
@@ -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
@@ -47,7 +47,7 @@ class Whatsapp::EmbeddedSignupService
account: @account,
inbox_id: @inbox_id,
phone_number_id: @phone_number_id,
waba_id: @waba_id
business_id: @business_id
).perform(access_token, phone_info)
else
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
@@ -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)
@@ -94,12 +90,12 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
private
# Only saves dropping the embedded_signup source marker are transfer attempts; creation/rotation failures are setup errors. Returns false.
# 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.embedded_to_manual_transfer_pending?
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_EMBEDDED_TO_MANUAL] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
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
@@ -159,7 +155,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)
@@ -1,9 +1,9 @@
class Whatsapp::ReauthorizationService
def initialize(account:, inbox_id:, phone_number_id:, waba_id:)
def initialize(account:, inbox_id:, phone_number_id:, business_id:)
@account = account
@inbox_id = inbox_id
@phone_number_id = phone_number_id
@waba_id = waba_id
@business_id = business_id
end
def perform(access_token, phone_info)
@@ -33,7 +33,7 @@ class Whatsapp::ReauthorizationService
channel.provider_config = current_config.merge(
'api_key' => access_token,
'phone_number_id' => resolved_phone_number_id,
'business_account_id' => @waba_id,
'business_account_id' => @business_id,
'source' => 'embedded_signup'
)
channel.save!
@@ -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)
@@ -4,8 +4,6 @@ json.title article.title
json.content article.content
json.description article.description
json.status article.status
json.draft_title article.draft_title
json.draft_content article.draft_content
json.position article.position
json.account_id article.account_id
json.updated_at article.updated_at.to_i
@@ -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
@@ -6,6 +6,6 @@ json.outgoing_url resource.outgoing_url unless resource.system_bot?
json.bot_type resource.bot_type
json.bot_config resource.bot_config
json.account_id resource.account_id
json.access_token resource.access_token if resource.access_token.present? && Current.account_user&.administrator?
json.access_token resource.access_token if resource.access_token.present?
json.secret resource.secret if !resource.system_bot? && Current.account_user&.administrator?
json.system_bot resource.system_bot?

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