Merge branch 'feature/cw-7513' into feature/cw-7513-specs
This commit is contained in:
@@ -77,7 +77,8 @@ jobs:
|
||||
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '10.2.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
@@ -118,7 +119,8 @@ jobs:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '10.2.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
@@ -149,7 +151,8 @@ jobs:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '10.2.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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]
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
@@ -0,0 +1,39 @@
|
||||
/* 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();
|
||||
@@ -66,6 +66,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
responsesCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -112,10 +116,10 @@ const showSyncStatus = computed(() => !isPdf.value);
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
{
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
|
||||
value: 'viewRelatedQuestions',
|
||||
action: 'viewRelatedQuestions',
|
||||
icon: 'i-ph-tree-view-duotone',
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'),
|
||||
value: 'viewDetails',
|
||||
action: 'viewDetails',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -143,6 +147,9 @@ 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
|
||||
@@ -158,6 +165,10 @@ 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 });
|
||||
};
|
||||
@@ -177,9 +188,13 @@ const handleRetry = () => {
|
||||
<Checkbox v-model="modelValue" />
|
||||
</div>
|
||||
<div class="flex gap-1 justify-between w-full">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||
<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"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
@@ -228,6 +243,9 @@ 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"
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
<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>
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ResponseCard from '../../assistant/ResponseCard.vue';
|
||||
const props = defineProps({
|
||||
captainDocument: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const meta = useMapGetter('captainResponses/getMeta');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const totalCount = computed(() => meta.value.totalCount || 0);
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainResponses/get', {
|
||||
assistantId: props.captainDocument.assistant.id,
|
||||
documentId: props.captainDocument.id,
|
||||
});
|
||||
});
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
|
||||
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
overflow-y-auto
|
||||
width="3xl"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-3 min-h-48">
|
||||
<ResponseCard
|
||||
v-for="response in responses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:status="response.status"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -85,6 +85,13 @@ const hasFilteredUnreadCounts = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasDataImport = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.DATA_IMPORT
|
||||
);
|
||||
});
|
||||
|
||||
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||
if (!currentAccountId) return;
|
||||
|
||||
@@ -856,6 +863,16 @@ 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, required: true },
|
||||
onClose: { type: Function, default: null },
|
||||
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 = () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const FEATURE_FLAGS = {
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
DATA_IMPORT: 'data_import',
|
||||
DELAYED_AUTOMATIONS: 'delayed_automations',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
INTEGRATIONS: 'integrations',
|
||||
|
||||
@@ -811,6 +811,7 @@
|
||||
"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})",
|
||||
@@ -870,7 +871,27 @@
|
||||
},
|
||||
"RELATED_RESPONSES": {
|
||||
"TITLE": "Related FAQs",
|
||||
"DESCRIPTION": "These FAQs are generated directly from the document."
|
||||
"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."
|
||||
},
|
||||
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
|
||||
"CREATE": {
|
||||
@@ -911,7 +932,7 @@
|
||||
},
|
||||
|
||||
"OPTIONS": {
|
||||
"VIEW_RELATED_RESPONSES": "View Related Responses",
|
||||
"VIEW_DETAILS": "View details",
|
||||
"SYNC_NOW": "Refresh now",
|
||||
"RETRY_SYNC": "Retry refresh",
|
||||
"DELETE_DOCUMENT": "Delete Document"
|
||||
|
||||
@@ -340,6 +340,7 @@
|
||||
"NOTIFICATIONS": "Notifications",
|
||||
"CANNED_RESPONSES": "Canned Responses",
|
||||
"INTEGRATIONS": "Integrations",
|
||||
"DATA": "Data",
|
||||
"PROFILE_SETTINGS": "Profile Settings",
|
||||
"ACCOUNT_SETTINGS": "Account Settings",
|
||||
"APPLICATIONS": "Applications",
|
||||
@@ -411,6 +412,104 @@
|
||||
"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 RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
|
||||
import DocumentDetails from 'dashboard/components-next/captain/pageComponents/document/DocumentDetails.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 showRelatedResponses = ref(false);
|
||||
const showDocumentDetails = ref(false);
|
||||
const showCreateDialog = ref(false);
|
||||
const createDocumentDialog = ref(null);
|
||||
const relationQuestionDialog = ref(null);
|
||||
const documentDetailsDialog = ref(null);
|
||||
|
||||
const handleShowRelatedDocument = () => {
|
||||
showRelatedResponses.value = true;
|
||||
nextTick(() => relationQuestionDialog.value.dialogRef.open());
|
||||
const handleShowDocumentDetails = () => {
|
||||
showDocumentDetails.value = true;
|
||||
nextTick(() => documentDetailsDialog.value.dialogRef.open());
|
||||
};
|
||||
const handleCreateDocument = () => {
|
||||
showCreateDialog.value = true;
|
||||
nextTick(() => createDocumentDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleRelatedResponseClose = () => {
|
||||
showRelatedResponses.value = false;
|
||||
const handleDocumentDetailsClose = () => {
|
||||
showDocumentDetails.value = false;
|
||||
};
|
||||
|
||||
const handleCreateDialogClose = () => {
|
||||
@@ -235,8 +235,8 @@ const handleAction = ({ action, id }) => {
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
} else if (action === 'viewRelatedQuestions') {
|
||||
handleShowRelatedDocument();
|
||||
} else if (action === 'viewDetails') {
|
||||
handleShowDocumentDetails();
|
||||
} else if (action === 'sync') {
|
||||
handleSync(id);
|
||||
}
|
||||
@@ -416,6 +416,7 @@ 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)"
|
||||
@@ -427,11 +428,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<RelatedResponses
|
||||
v-if="showRelatedResponses"
|
||||
ref="relationQuestionDialog"
|
||||
<DocumentDetails
|
||||
v-if="showDocumentDetails"
|
||||
ref="documentDetailsDialog"
|
||||
:captain-document="selectedDocument"
|
||||
@close="handleRelatedResponseClose"
|
||||
@close="handleDocumentDetailsClose"
|
||||
/>
|
||||
<CreateDocumentDialog
|
||||
v-if="showCreateDialog"
|
||||
|
||||
+5
-3
@@ -52,9 +52,11 @@ const helpURL = getHelpUrlForFeature(props.featureName);
|
||||
v-if="title"
|
||||
class="flex items-center justify-between w-full gap-4 min-h-8 mb-2"
|
||||
>
|
||||
<h1 class="text-heading-1 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<slot name="title">
|
||||
<h1 class="text-heading-1 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="description || $slots.description || linkText || helpURL"
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
<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>
|
||||
@@ -0,0 +1,210 @@
|
||||
<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>
|
||||
@@ -0,0 +1,238 @@
|
||||
<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>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
<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>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<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>
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<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>
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<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>
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
<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>
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<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>
|
||||
@@ -0,0 +1,34 @@
|
||||
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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
@@ -0,0 +1,84 @@
|
||||
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';
|
||||
@@ -0,0 +1,92 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
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, ref } from 'vue';
|
||||
import { computed, onActivated, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
@@ -27,6 +27,10 @@ const searchQuery = ref('');
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
onActivated(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
});
|
||||
|
||||
const inboxesList = computed(() => {
|
||||
return inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ 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: [
|
||||
@@ -57,6 +58,7 @@ export default {
|
||||
...canned.routes,
|
||||
...inbox.routes,
|
||||
...integrations.routes,
|
||||
...data.routes,
|
||||
...labels.routes,
|
||||
...macros.routes,
|
||||
...reports.routes,
|
||||
|
||||
@@ -104,6 +104,11 @@ 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,6 +68,25 @@ 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';
|
||||
|
||||
@@ -8,8 +8,6 @@ class AutomationRules::ProcessPendingExecutionJob < ApplicationJob
|
||||
# Atomic claim: a duplicate enqueue (overlapping sweep or stale reclaim) loses here and returns.
|
||||
return unless pending_execution.claim!
|
||||
|
||||
return pending_execution.update!(status: :skipped, skip_reason: 'expired') if expired?(pending_execution)
|
||||
|
||||
skip_reason = skip_reason_for(pending_execution)
|
||||
return pending_execution.update!(status: :skipped, skip_reason: skip_reason) if skip_reason
|
||||
|
||||
@@ -21,15 +19,22 @@ class AutomationRules::ProcessPendingExecutionJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def expired?(pending_execution)
|
||||
pending_execution.due_at < AutomationRulePendingExecution::DUE_WINDOW.ago
|
||||
def skip_reason_for(pending_execution)
|
||||
return 'expired' if pending_execution.due_at < AutomationRulePendingExecution::DUE_WINDOW.ago
|
||||
|
||||
structural_skip_reason(pending_execution) || behavioral_skip_reason(pending_execution)
|
||||
end
|
||||
|
||||
def skip_reason_for(pending_execution)
|
||||
def structural_skip_reason(pending_execution)
|
||||
rule = pending_execution.automation_rule
|
||||
return 'rule_inactive' if rule.nil? || !rule.active?
|
||||
return 'flag_disabled' unless pending_execution.account.feature_enabled?('delayed_automations')
|
||||
return 'conversation_gone' if pending_execution.conversation.nil?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def behavioral_skip_reason(pending_execution)
|
||||
return 'episode_moved' unless pending_execution.episode_current?
|
||||
return 'conditions_changed' unless conditions_still_match?(pending_execution)
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
@@ -0,0 +1,24 @@
|
||||
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
|
||||
@@ -35,8 +35,11 @@ class AutomationRule < ApplicationRecord
|
||||
validates :account_id, presence: true
|
||||
validates :execution_delay, numericality: { only_integer: true, in: EXECUTION_DELAY_RANGE }, allow_nil: true
|
||||
validate :execution_delay_supported_conditions
|
||||
validate :execution_delay_supported_event
|
||||
|
||||
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
|
||||
# Rows already armed under the old delay must not fire on a config the rule no longer has.
|
||||
after_update :cancel_stale_pending_executions, if: -> { saved_change_to_execution_delay? }
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
@@ -110,6 +113,22 @@ class AutomationRule < ApplicationRecord
|
||||
errors.add(:execution_delay, 'cannot be used with attribute_changed conditions.')
|
||||
end
|
||||
|
||||
# Conversation-level events (anything but message_created) key their episode on
|
||||
# status_changed_at alone. A delayed condition on any other attribute (assignee, team,
|
||||
# priority, ...) would collapse distinct qualifying periods into one episode and could
|
||||
# fire on a stale window, so only status conditions are supported until episodes track
|
||||
# per-attribute change times.
|
||||
def execution_delay_supported_event
|
||||
return if execution_delay.blank? || conditions.blank? || event_name == 'message_created'
|
||||
return if conditions.all? { |obj| obj['attribute_key'] == 'status' }
|
||||
|
||||
errors.add(:execution_delay, 'only supports status conditions for conversation-level events.')
|
||||
end
|
||||
|
||||
def cancel_stale_pending_executions
|
||||
pending_executions.pending.find_each { |execution| execution.update!(status: :skipped, skip_reason: 'rule_edited') }
|
||||
end
|
||||
|
||||
def validate_single_condition(condition)
|
||||
query_operator = condition['query_operator']
|
||||
|
||||
|
||||
@@ -3,33 +3,115 @@
|
||||
# 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_account_id (account_id)
|
||||
# index_data_imports_on_initiated_by_id (initiated_by_id)
|
||||
# index_data_imports_on_source_provider (source_provider)
|
||||
#
|
||||
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
|
||||
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 }
|
||||
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]) }
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# == 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
|
||||
@@ -0,0 +1,35 @@
|
||||
# == 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
|
||||
@@ -0,0 +1,33 @@
|
||||
# == 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
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
@@ -0,0 +1,84 @@
|
||||
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
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
@@ -0,0 +1,67 @@
|
||||
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
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
@@ -0,0 +1,991 @@
|
||||
# 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
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
@@ -0,0 +1,55 @@
|
||||
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
|
||||
@@ -0,0 +1,23 @@
|
||||
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,7 +38,8 @@ class Imap::BaseFetchEmailService
|
||||
end
|
||||
|
||||
def email_already_present?(channel, message_id)
|
||||
channel.inbox.messages.find_by(source_id: message_id).present? || deleted_message_tracker.deleted?(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)
|
||||
end
|
||||
|
||||
def deleted_message_tracker
|
||||
|
||||
@@ -40,7 +40,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
|
||||
def fetch_whatsapp_templates(url)
|
||||
response = HTTParty.get(url)
|
||||
return [] unless response.success?
|
||||
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
|
||||
|
||||
next_url = next_url(response)
|
||||
|
||||
@@ -155,7 +159,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')
|
||||
response.parsed_response.dig('error', 'message') if response.parsed_response.is_a?(Hash)
|
||||
end
|
||||
|
||||
def voice_message?(type, attachment)
|
||||
|
||||
@@ -23,7 +23,6 @@ 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
|
||||
@@ -38,8 +37,11 @@ class Whatsapp::WebhookTeardownService
|
||||
Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}"
|
||||
end
|
||||
|
||||
# The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one.
|
||||
# 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.
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
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
|
||||
@@ -0,0 +1,31 @@
|
||||
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?
|
||||
json.access_token resource.access_token if resource.access_token.present? && Current.account_user&.administrator?
|
||||
json.secret resource.secret if !resource.system_bot? && Current.account_user&.administrator?
|
||||
json.system_bot resource.system_bot?
|
||||
|
||||
@@ -253,7 +253,16 @@
|
||||
display_name: WhatsApp Manual Transfer
|
||||
enabled: false
|
||||
column: feature_flags_ext_1
|
||||
- name: data_import
|
||||
display_name: Data Import
|
||||
enabled: false
|
||||
column: feature_flags_ext_1
|
||||
- name: api_and_webhooks
|
||||
display_name: API and Webhooks
|
||||
enabled: true
|
||||
column: feature_flags_ext_1
|
||||
- name: delayed_automations
|
||||
display_name: Delayed Automations
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
column: feature_flags_ext_1
|
||||
|
||||
@@ -47,6 +47,28 @@ en:
|
||||
saml_not_available: SAML authentication is not available in this installation.
|
||||
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
|
||||
|
||||
data_imports:
|
||||
intercom:
|
||||
activities:
|
||||
assignment: '%{actor} assigned the conversation'
|
||||
assignment_with_target: '%{actor} assigned the conversation to %{target}'
|
||||
assign_and_reopen: '%{actor} assigned and reopened the conversation'
|
||||
assign_and_reopen_with_target: '%{actor} assigned the conversation to %{target} and reopened it'
|
||||
open: '%{actor} opened the conversation'
|
||||
close: '%{actor} closed the conversation'
|
||||
snoozed: '%{actor} snoozed the conversation'
|
||||
participant_added: '%{actor} added a participant'
|
||||
participant_added_with_target: '%{actor} added %{target} as a participant'
|
||||
participant_removed: '%{actor} removed a participant'
|
||||
participant_removed_with_target: '%{actor} removed %{target} as a participant'
|
||||
conversation_attribute_updated: '%{actor} updated conversation attributes'
|
||||
ticket_attribute_updated: '%{actor} updated ticket attributes'
|
||||
ticket_state_updated: '%{actor} updated the ticket state'
|
||||
custom_action_started: '%{actor} started a custom action'
|
||||
custom_action_finished: '%{actor} finished a custom action'
|
||||
quick_reply: '%{actor} used a quick reply'
|
||||
generic: '%{actor} recorded %{event}'
|
||||
|
||||
profile_settings:
|
||||
sessions:
|
||||
cannot_revoke_current: You cannot revoke the current session.
|
||||
|
||||
@@ -222,6 +222,17 @@ Rails.application.routes.draw do
|
||||
post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?
|
||||
end
|
||||
end
|
||||
resources :data_imports, only: [:index, :show, :create] do
|
||||
collection do
|
||||
post :validate_source
|
||||
end
|
||||
member do
|
||||
post :start
|
||||
post :abandon
|
||||
get :error_logs
|
||||
get :skip_logs
|
||||
end
|
||||
end
|
||||
resources :csat_survey_responses, only: [:index] do
|
||||
collection do
|
||||
get :metrics
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
class ExpandDataImportsForIntercomImports < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_data_import_columns
|
||||
add_data_import_indexes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_data_import_columns
|
||||
change_table :data_imports, bulk: true do |t|
|
||||
t.string :name
|
||||
t.string :source_type
|
||||
t.string :source_provider
|
||||
t.jsonb :import_types, default: [], null: false
|
||||
t.integer :initiated_by_id
|
||||
t.text :access_token
|
||||
t.jsonb :source_metadata, default: {}, null: false
|
||||
t.jsonb :stats, default: {}, null: false
|
||||
t.jsonb :cursor, default: {}, null: false
|
||||
t.datetime :started_at
|
||||
t.datetime :completed_at
|
||||
t.datetime :abandoned_at
|
||||
t.datetime :last_error_at
|
||||
end
|
||||
end
|
||||
|
||||
def add_data_import_indexes
|
||||
add_index :data_imports, :initiated_by_id
|
||||
add_index :data_imports, :source_provider
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
class CreateDataImportItems < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_data_import_items
|
||||
add_data_import_item_indexes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_data_import_items
|
||||
create_table :data_import_items do |t|
|
||||
t.references :data_import, null: false, index: true
|
||||
t.string :source_provider, null: false
|
||||
t.string :source_object_type, null: false
|
||||
t.string :source_object_id, null: false
|
||||
t.integer :status, default: 0, null: false
|
||||
t.string :chatwoot_record_type
|
||||
t.bigint :chatwoot_record_id
|
||||
t.integer :attempt_count, default: 0, null: false
|
||||
t.string :last_error_code
|
||||
t.text :last_error_message
|
||||
t.jsonb :metadata, default: {}, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
|
||||
def add_data_import_item_indexes
|
||||
add_index :data_import_items,
|
||||
[:data_import_id, :source_object_type, :source_object_id],
|
||||
unique: true,
|
||||
name: 'idx_data_import_items_on_import_and_source'
|
||||
add_index :data_import_items, [:chatwoot_record_type, :chatwoot_record_id], name: 'idx_data_import_items_on_record'
|
||||
add_index :data_import_items, [:source_provider, :source_object_type, :source_object_id], name: 'idx_data_import_items_on_source'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
class CreateDataImportMappings < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :data_import_mappings do |t|
|
||||
t.integer :account_id, null: false
|
||||
t.references :data_import, null: false, index: true
|
||||
t.string :source_provider, null: false
|
||||
t.string :source_object_type, null: false
|
||||
t.string :source_object_id, null: false
|
||||
t.string :chatwoot_record_type, null: false
|
||||
t.bigint :chatwoot_record_id, null: false
|
||||
t.jsonb :metadata, default: {}, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :data_import_mappings,
|
||||
[:account_id, :source_provider, :source_object_type, :source_object_id],
|
||||
unique: true,
|
||||
name: 'idx_data_import_mappings_on_account_and_source'
|
||||
add_index :data_import_mappings, [:chatwoot_record_type, :chatwoot_record_id], name: 'idx_data_import_mappings_on_record'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class CreateDataImportErrors < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :data_import_errors do |t|
|
||||
t.references :data_import, null: false, index: true
|
||||
t.references :data_import_item, null: true, index: true
|
||||
t.string :source_object_type
|
||||
t.string :source_object_id
|
||||
t.string :error_code, null: false
|
||||
t.text :message
|
||||
t.jsonb :details, default: {}, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :data_import_errors, [:source_object_type, :source_object_id], name: 'idx_data_import_errors_on_source'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
class CreateAgentSessions < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :agent_sessions do |t|
|
||||
t.integer :session_type, null: false
|
||||
t.references :subject, polymorphic: true, null: false, index: false
|
||||
t.references :result, polymorphic: true, index: false
|
||||
t.references :account, null: false, index: true
|
||||
t.references :assistant, null: false, index: true
|
||||
t.references :user, index: true
|
||||
t.string :llm_model
|
||||
t.float :credits_consumed
|
||||
t.jsonb :faq_ids, default: []
|
||||
t.jsonb :document_ids, default: []
|
||||
t.jsonb :scenario_ids, default: []
|
||||
t.jsonb :run_context, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :agent_sessions, [:account_id, :session_type, :created_at]
|
||||
add_index :agent_sessions, [:account_id, :subject_type, :subject_id]
|
||||
add_index :agent_sessions, [:account_id, :result_type, :result_id]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
class CreateCaptainFaqSuggestions < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_faq_suggestions
|
||||
create_faq_observations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_faq_suggestions
|
||||
create_table :captain_faq_suggestions do |t|
|
||||
t.string :question, null: false
|
||||
t.text :answer, null: false
|
||||
t.vector :embedding, limit: 1536
|
||||
t.references :assistant, null: false, index: true
|
||||
t.references :account, null: false, index: true
|
||||
t.string :language, null: false, default: 'en'
|
||||
t.integer :source_count, null: false, default: 0
|
||||
t.integer :status, null: false, default: 0
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :captain_faq_suggestions, [:account_id, :assistant_id, :status, :language],
|
||||
name: 'idx_cap_faq_suggestions_on_account_assistant_status_language'
|
||||
add_index :captain_faq_suggestions, :embedding, using: :ivfflat,
|
||||
name: 'vector_idx_captain_faq_suggestions_embedding',
|
||||
opclass: :vector_cosine_ops
|
||||
end
|
||||
|
||||
def create_faq_observations
|
||||
create_table :captain_faq_observations do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.references :conversation, null: false, index: true
|
||||
t.references :faq_suggestion, index: true
|
||||
t.string :generated_question, null: false
|
||||
t.text :generated_answer, null: false
|
||||
t.string :language, null: false, default: 'en'
|
||||
t.integer :status, null: false, default: 0
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :captain_faq_observations, [:conversation_id, :faq_suggestion_id],
|
||||
unique: true,
|
||||
where: 'faq_suggestion_id IS NOT NULL',
|
||||
name: 'idx_captain_faq_observations_on_conversation_and_suggestion'
|
||||
end
|
||||
end
|
||||
+125
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -146,6 +146,31 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id"
|
||||
end
|
||||
|
||||
create_table "agent_sessions", force: :cascade do |t|
|
||||
t.integer "session_type", null: false
|
||||
t.string "subject_type", null: false
|
||||
t.bigint "subject_id", null: false
|
||||
t.string "result_type"
|
||||
t.bigint "result_id"
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "assistant_id", null: false
|
||||
t.bigint "user_id"
|
||||
t.string "llm_model"
|
||||
t.float "credits_consumed"
|
||||
t.jsonb "faq_ids", default: []
|
||||
t.jsonb "document_ids", default: []
|
||||
t.jsonb "scenario_ids", default: []
|
||||
t.jsonb "run_context", default: {}
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "result_type", "result_id"], name: "idx_on_account_id_result_type_result_id_ca66c00cd7"
|
||||
t.index ["account_id", "session_type", "created_at"], name: "idx_on_account_id_session_type_created_at_c20a14bd4e"
|
||||
t.index ["account_id", "subject_type", "subject_id"], name: "idx_on_account_id_subject_type_subject_id_6d60963b3d"
|
||||
t.index ["account_id"], name: "index_agent_sessions_on_account_id"
|
||||
t.index ["assistant_id"], name: "index_agent_sessions_on_assistant_id"
|
||||
t.index ["user_id"], name: "index_agent_sessions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "applied_slas", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "sla_policy_id", null: false
|
||||
@@ -411,6 +436,39 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
t.index ["status"], name: "index_captain_documents_on_status"
|
||||
end
|
||||
|
||||
create_table "captain_faq_observations", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "faq_suggestion_id"
|
||||
t.string "generated_question", null: false
|
||||
t.text "generated_answer", null: false
|
||||
t.string "language", default: "en", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_captain_faq_observations_on_account_id"
|
||||
t.index ["conversation_id", "faq_suggestion_id"], name: "idx_captain_faq_observations_on_conversation_and_suggestion", unique: true, where: "(faq_suggestion_id IS NOT NULL)"
|
||||
t.index ["conversation_id"], name: "index_captain_faq_observations_on_conversation_id"
|
||||
t.index ["faq_suggestion_id"], name: "index_captain_faq_observations_on_faq_suggestion_id"
|
||||
end
|
||||
|
||||
create_table "captain_faq_suggestions", force: :cascade do |t|
|
||||
t.string "question", null: false
|
||||
t.text "answer", null: false
|
||||
t.vector "embedding", limit: 1536
|
||||
t.bigint "assistant_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.string "language", default: "en", null: false
|
||||
t.integer "source_count", default: 0, null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_captain_faq_suggestions_on_account_id"
|
||||
t.index ["account_id", "assistant_id", "status", "language"], name: "idx_cap_faq_suggestions_on_account_assistant_status_language"
|
||||
t.index ["assistant_id"], name: "index_captain_faq_suggestions_on_assistant_id"
|
||||
t.index ["embedding"], name: "vector_idx_captain_faq_suggestions_embedding", opclass: :vector_cosine_ops, using: :ivfflat
|
||||
end
|
||||
|
||||
create_table "captain_inboxes", force: :cascade do |t|
|
||||
t.bigint "captain_assistant_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
@@ -861,6 +919,57 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
t.index ["user_id"], name: "index_dashboard_apps_on_user_id"
|
||||
end
|
||||
|
||||
create_table "data_import_errors", force: :cascade do |t|
|
||||
t.bigint "data_import_id", null: false
|
||||
t.bigint "data_import_item_id"
|
||||
t.string "source_object_type"
|
||||
t.string "source_object_id"
|
||||
t.string "error_code", null: false
|
||||
t.text "message"
|
||||
t.jsonb "details", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["data_import_id"], name: "index_data_import_errors_on_data_import_id"
|
||||
t.index ["data_import_item_id"], name: "index_data_import_errors_on_data_import_item_id"
|
||||
t.index ["source_object_type", "source_object_id"], name: "idx_data_import_errors_on_source"
|
||||
end
|
||||
|
||||
create_table "data_import_items", force: :cascade do |t|
|
||||
t.bigint "data_import_id", null: false
|
||||
t.string "source_provider", null: false
|
||||
t.string "source_object_type", null: false
|
||||
t.string "source_object_id", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.string "chatwoot_record_type"
|
||||
t.bigint "chatwoot_record_id"
|
||||
t.integer "attempt_count", default: 0, null: false
|
||||
t.string "last_error_code"
|
||||
t.text "last_error_message"
|
||||
t.jsonb "metadata", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["chatwoot_record_type", "chatwoot_record_id"], name: "idx_data_import_items_on_record"
|
||||
t.index ["data_import_id", "source_object_type", "source_object_id"], name: "idx_data_import_items_on_import_and_source", unique: true
|
||||
t.index ["data_import_id"], name: "index_data_import_items_on_data_import_id"
|
||||
t.index ["source_provider", "source_object_type", "source_object_id"], name: "idx_data_import_items_on_source"
|
||||
end
|
||||
|
||||
create_table "data_import_mappings", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.bigint "data_import_id", null: false
|
||||
t.string "source_provider", null: false
|
||||
t.string "source_object_type", null: false
|
||||
t.string "source_object_id", null: false
|
||||
t.string "chatwoot_record_type", null: false
|
||||
t.bigint "chatwoot_record_id", null: false
|
||||
t.jsonb "metadata", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "source_provider", "source_object_type", "source_object_id"], name: "idx_data_import_mappings_on_account_and_source", unique: true
|
||||
t.index ["chatwoot_record_type", "chatwoot_record_id"], name: "idx_data_import_mappings_on_record"
|
||||
t.index ["data_import_id"], name: "index_data_import_mappings_on_data_import_id"
|
||||
end
|
||||
|
||||
create_table "data_imports", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "data_type", null: false
|
||||
@@ -870,7 +979,22 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
t.integer "processed_records"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "name"
|
||||
t.string "source_type"
|
||||
t.string "source_provider"
|
||||
t.jsonb "import_types", default: [], null: false
|
||||
t.integer "initiated_by_id"
|
||||
t.text "access_token"
|
||||
t.jsonb "source_metadata", default: {}, null: false
|
||||
t.jsonb "stats", default: {}, null: false
|
||||
t.jsonb "cursor", default: {}, null: false
|
||||
t.datetime "started_at"
|
||||
t.datetime "completed_at"
|
||||
t.datetime "abandoned_at"
|
||||
t.datetime "last_error_at"
|
||||
t.index ["account_id"], name: "index_data_imports_on_account_id"
|
||||
t.index ["initiated_by_id"], name: "index_data_imports_on_initiated_by_id"
|
||||
t.index ["source_provider"], name: "index_data_imports_on_source_provider"
|
||||
end
|
||||
|
||||
create_table "email_templates", force: :cascade do |t|
|
||||
|
||||
@@ -9,16 +9,10 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
RESULTS_PER_PAGE = 25
|
||||
|
||||
def index
|
||||
base_query = @documents
|
||||
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
|
||||
base_query = apply_source_filter(base_query, permitted_params[:source])
|
||||
base_query = apply_filter(base_query, permitted_params[:filter])
|
||||
base_query = apply_search(base_query, permitted_params[:search_key])
|
||||
base_query = apply_sort(base_query, permitted_params[:sort])
|
||||
|
||||
@documents_count = base_query.count
|
||||
@documents = filtered_documents
|
||||
@documents_count = @documents.count
|
||||
@sync_interval_hours = current_sync_interval&.in_hours&.to_i
|
||||
@documents = base_query.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
@documents = with_responses_count(@documents).page(@current_page).per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -59,6 +53,21 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
@documents = Current.account.captain_documents.with_attached_pdf_file.includes(:assistant)
|
||||
end
|
||||
|
||||
def filtered_documents
|
||||
documents = @documents
|
||||
documents = documents.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
|
||||
documents = apply_source_filter(documents, permitted_params[:source])
|
||||
documents = apply_filter(documents, permitted_params[:filter])
|
||||
documents = apply_search(documents, permitted_params[:search_key])
|
||||
apply_sort(documents, permitted_params[:sort])
|
||||
end
|
||||
|
||||
def with_responses_count(scope)
|
||||
scope.left_joins(:responses)
|
||||
.select('captain_documents.*, COUNT(captain_assistant_responses.id) AS responses_count')
|
||||
.group('captain_documents.id')
|
||||
end
|
||||
|
||||
def set_document
|
||||
@document = @documents.find(permitted_params[:id])
|
||||
end
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: agent_sessions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# credits_consumed :float
|
||||
# document_ids :jsonb
|
||||
# faq_ids :jsonb
|
||||
# llm_model :string
|
||||
# result_type :string
|
||||
# run_context :jsonb
|
||||
# scenario_ids :jsonb
|
||||
# session_type :integer not null
|
||||
# subject_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
# result_id :bigint
|
||||
# subject_id :bigint not null
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_on_account_id_result_type_result_id_ca66c00cd7 (account_id,result_type,result_id)
|
||||
# idx_on_account_id_session_type_created_at_c20a14bd4e (account_id,session_type,created_at)
|
||||
# idx_on_account_id_subject_type_subject_id_6d60963b3d (account_id,subject_type,subject_id)
|
||||
# index_agent_sessions_on_account_id (account_id)
|
||||
# index_agent_sessions_on_assistant_id (assistant_id)
|
||||
# index_agent_sessions_on_user_id (user_id)
|
||||
#
|
||||
class Captain::AgentSession < ApplicationRecord
|
||||
self.table_name = 'agent_sessions'
|
||||
|
||||
SUBJECT_TYPES = { 'assistant' => 'Conversation', 'copilot' => 'CopilotThread' }.freeze
|
||||
RESULT_TYPES = { 'assistant' => 'Message', 'copilot' => 'CopilotMessage' }.freeze
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :subject, ->(session) { where(account_id: session.account_id) }, polymorphic: true
|
||||
belongs_to :result, ->(session) { where(account_id: session.account_id) }, polymorphic: true, optional: true
|
||||
|
||||
enum :session_type, { assistant: 0, copilot: 1 }, prefix: :session
|
||||
|
||||
before_validation :ensure_account
|
||||
|
||||
validate :subject_type_matches_session_type
|
||||
validate :result_type_matches_session_type, if: -> { result_type.present? }
|
||||
validate :subject_belongs_to_account
|
||||
validate :result_belongs_to_account, if: -> { result_id.present? }
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = assistant&.account
|
||||
end
|
||||
|
||||
def subject_type_matches_session_type
|
||||
expected_type = SUBJECT_TYPES[session_type]
|
||||
return if subject_type == expected_type
|
||||
|
||||
errors.add(:subject_type, "must be #{expected_type} for #{session_type} sessions")
|
||||
end
|
||||
|
||||
def result_type_matches_session_type
|
||||
expected_type = RESULT_TYPES[session_type]
|
||||
return if result_type == expected_type
|
||||
|
||||
errors.add(:result_type, "must be #{expected_type} for #{session_type} sessions")
|
||||
end
|
||||
|
||||
def subject_belongs_to_account
|
||||
return if subject.nil? || subject.account_id == account_id
|
||||
|
||||
errors.add(:subject, 'must belong to the session account')
|
||||
end
|
||||
|
||||
def result_belongs_to_account
|
||||
target_class = result_type.safe_constantize
|
||||
actual_account_id = target_class && target_class.unscoped.where(id: result_id).pick(:account_id)
|
||||
return if actual_account_id == account_id
|
||||
|
||||
errors.add(:result, 'must belong to the session account')
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async
|
||||
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async
|
||||
has_many :faq_suggestions, class_name: 'Captain::FaqSuggestion', dependent: :destroy_async
|
||||
has_many :captain_inboxes,
|
||||
class_name: 'CaptainInbox',
|
||||
foreign_key: :captain_assistant_id,
|
||||
@@ -37,6 +38,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
|
||||
|
||||
@@ -96,7 +98,8 @@ class Captain::Assistant < ApplicationRecord
|
||||
def agent_tools
|
||||
[
|
||||
self.class.resolve_tool_class('faq_lookup').new(self),
|
||||
self.class.resolve_tool_class('handoff').new(self)
|
||||
self.class.resolve_tool_class('handoff').new(self),
|
||||
*account.captain_custom_tools.enabled.map { |custom_tool| custom_tool.tool(self) }
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_faq_observations
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# generated_answer :text not null
|
||||
# generated_question :string not null
|
||||
# language :string default("en"), not null
|
||||
# status :integer default("attached"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# faq_suggestion_id :bigint
|
||||
#
|
||||
class Captain::FaqObservation < ApplicationRecord
|
||||
self.table_name = 'captain_faq_observations'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :conversation, class_name: '::Conversation'
|
||||
belongs_to :faq_suggestion, class_name: 'Captain::FaqSuggestion', optional: true, inverse_of: :observations
|
||||
|
||||
enum status: { attached: 0, discarded: 1 }
|
||||
|
||||
validates :generated_question, :generated_answer, :language, presence: true
|
||||
validates :faq_suggestion, presence: true, if: :attached?
|
||||
validate :faq_suggestion_belongs_to_account
|
||||
|
||||
before_validation :ensure_account
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = conversation&.account
|
||||
end
|
||||
|
||||
def faq_suggestion_belongs_to_account
|
||||
return if faq_suggestion.blank? || faq_suggestion.account_id == account_id
|
||||
|
||||
errors.add(:faq_suggestion, :invalid)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_faq_suggestions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# answer :text not null
|
||||
# embedding :vector(1536)
|
||||
# language :string default("en"), not null
|
||||
# question :string not null
|
||||
# source_count :integer default(0), not null
|
||||
# status :integer default("open"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
#
|
||||
class Captain::FaqSuggestion < ApplicationRecord
|
||||
self.table_name = 'captain_faq_suggestions'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :account
|
||||
has_many :observations,
|
||||
class_name: 'Captain::FaqObservation',
|
||||
dependent: :delete_all,
|
||||
inverse_of: :faq_suggestion
|
||||
has_neighbors :embedding, normalize: true
|
||||
|
||||
enum status: { open: 0, approved: 1, dismissed: 2 }
|
||||
|
||||
validates :question, :answer, :language, presence: true
|
||||
|
||||
before_validation :ensure_account
|
||||
after_commit :update_embedding, on: [:create, :update]
|
||||
|
||||
scope :ordered, -> { order(source_count: :desc, updated_at: :desc) }
|
||||
scope :by_language, ->(language) { where(language: language) }
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = assistant&.account
|
||||
end
|
||||
|
||||
def update_embedding
|
||||
return unless open?
|
||||
return unless saved_change_to_question? || saved_change_to_answer? || embedding.nil?
|
||||
return if previously_new_record? && embedding.present?
|
||||
|
||||
Captain::Llm::UpdateEmbeddingJob.perform_later(self, "#{question}: #{answer}")
|
||||
end
|
||||
end
|
||||
@@ -11,8 +11,11 @@ module Enterprise::Concerns::Account
|
||||
|
||||
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
has_many :captain_faq_observations, dependent: :destroy_async, class_name: 'Captain::FaqObservation'
|
||||
has_many :captain_faq_suggestions, dependent: :destroy_async, class_name: 'Captain::FaqSuggestion'
|
||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
|
||||
@@ -7,6 +7,7 @@ module Enterprise::Concerns::Conversation
|
||||
has_many :sla_events, dependent: :destroy_async
|
||||
has_many :calls, dependent: :destroy_async
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
has_many :captain_faq_observations, class_name: 'Captain::FaqObservation', dependent: :delete_all
|
||||
scope :with_sla_applicable_contact, -> { left_joins(:contact).where(contacts: { blocked: [false, nil] }) }
|
||||
|
||||
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
class Captain::AssistantMigration::DraftApplier
|
||||
ASSISTANT_DESCRIPTION_LIMIT = 500
|
||||
CONFIG_KEY = 'assistant_migration'.freeze
|
||||
SCENARIO_DESCRIPTION_LIMIT = 500
|
||||
ORIGINAL_VALUES_KEY = 'original_values'.freeze
|
||||
|
||||
pattr_initialize [:assistant!, :draft!, { dry_run: true }]
|
||||
|
||||
def perform
|
||||
changes = build_changes
|
||||
apply_changes(changes) unless dry_run
|
||||
|
||||
{
|
||||
assistant_id: assistant.id,
|
||||
dry_run: dry_run,
|
||||
changes: changes
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_changes
|
||||
{
|
||||
description: description_change,
|
||||
response_guidelines: array_change(:response_guidelines, response_guidelines),
|
||||
guardrails: array_change(:guardrails, guardrails),
|
||||
config: config_change
|
||||
}.compact
|
||||
end
|
||||
|
||||
def apply_changes(changes)
|
||||
assistant.transaction do
|
||||
assistant.update!(assistant_update_attributes(changes)) if assistant_update_attributes(changes).present?
|
||||
end
|
||||
end
|
||||
|
||||
def assistant_update_attributes(changes)
|
||||
{}.tap do |attributes|
|
||||
attributes[:description] = changes.dig(:description, :to) if changes[:description].present?
|
||||
attributes[:response_guidelines] = changes.dig(:response_guidelines, :to) if changes[:response_guidelines].present?
|
||||
attributes[:guardrails] = changes.dig(:guardrails, :to) if changes[:guardrails].present?
|
||||
attributes[:config] = changes.dig(:config, :to) if changes[:config].present?
|
||||
end
|
||||
end
|
||||
|
||||
def description_change
|
||||
value = assistant_description_value
|
||||
return if value.blank? || value == assistant.description
|
||||
|
||||
{ from: assistant.description, to: value }
|
||||
end
|
||||
|
||||
def assistant_description_value
|
||||
value = item_values(:business_product_context).join(' ').presence
|
||||
return if value.blank?
|
||||
|
||||
raise ArgumentError, "Assistant description exceeds #{ASSISTANT_DESCRIPTION_LIMIT} characters" if value.length > ASSISTANT_DESCRIPTION_LIMIT
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
def response_guidelines
|
||||
(item_values(:response_guidelines) + scenario_response_guidelines).uniq
|
||||
end
|
||||
|
||||
def guardrails
|
||||
item_values(:guardrails)
|
||||
end
|
||||
|
||||
def array_change(field, values)
|
||||
return if values.blank?
|
||||
|
||||
current = Array(assistant.public_send(field)).map(&:to_s)
|
||||
return if current == values
|
||||
|
||||
{ from: current, to: values }
|
||||
end
|
||||
|
||||
def config_change
|
||||
updated_config = assistant.config.deep_dup
|
||||
conversation_messages.each do |key, value|
|
||||
next if value.blank?
|
||||
next if updated_config[key].present?
|
||||
|
||||
updated_config[key] = value
|
||||
end
|
||||
updated_config[CONFIG_KEY] = migration_config
|
||||
|
||||
return if updated_config == assistant.config
|
||||
|
||||
{ from: assistant.config, to: updated_config }
|
||||
end
|
||||
|
||||
def migration_config
|
||||
existing_migration_config.merge(
|
||||
ORIGINAL_VALUES_KEY => existing_original_values,
|
||||
'scenario_candidates' => staged_scenario_candidates,
|
||||
'faq_document_candidates' => normalized_faq_document_candidates,
|
||||
'needs_review' => normalized_instruction_items(:needs_review)
|
||||
)
|
||||
end
|
||||
|
||||
def existing_migration_config
|
||||
config = assistant.config[CONFIG_KEY]
|
||||
config.is_a?(Hash) ? config : {}
|
||||
end
|
||||
|
||||
def existing_original_values
|
||||
existing_migration_config[ORIGINAL_VALUES_KEY].presence || original_values
|
||||
end
|
||||
|
||||
def original_values
|
||||
{
|
||||
'name' => assistant.name,
|
||||
'description' => assistant.description,
|
||||
'config' => original_config,
|
||||
'response_guidelines' => Array(assistant.response_guidelines),
|
||||
'guardrails' => Array(assistant.guardrails)
|
||||
}
|
||||
end
|
||||
|
||||
def original_config
|
||||
assistant.config.except(CONFIG_KEY)
|
||||
end
|
||||
|
||||
def conversation_messages
|
||||
messages = draft_hash.fetch(:conversation_messages, {})
|
||||
messages = messages.deep_stringify_keys
|
||||
|
||||
{
|
||||
'welcome_message' => messages['welcome_message'].to_s.strip,
|
||||
'handoff_message' => messages['handoff_message'].to_s.strip,
|
||||
'resolution_message' => messages['resolution_message'].to_s.strip
|
||||
}
|
||||
end
|
||||
|
||||
def staged_scenario_candidates
|
||||
scenario_candidates.map do |candidate|
|
||||
candidate.transform_keys(&:to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def scenario_response_guidelines
|
||||
scenario_candidates.filter_map { |candidate| candidate[:response_guideline].presence }
|
||||
end
|
||||
|
||||
def scenario_tool_ids(tool_ids)
|
||||
Array(tool_ids).filter_map { |tool_id| tool_id.to_s.squish.presence }.uniq
|
||||
end
|
||||
|
||||
def scenario_candidates
|
||||
Array(draft_hash[:scenario_candidates]).filter_map do |candidate|
|
||||
normalized_scenario_candidate(candidate)
|
||||
end
|
||||
end
|
||||
|
||||
def normalized_scenario_candidate(candidate)
|
||||
return unless candidate.is_a?(Hash)
|
||||
|
||||
candidate = candidate.deep_symbolize_keys
|
||||
normalized_candidate = {
|
||||
title: candidate[:title].to_s.squish,
|
||||
description: candidate[:description].to_s.squish.truncate(SCENARIO_DESCRIPTION_LIMIT),
|
||||
instruction: candidate[:instruction].to_s.squish,
|
||||
response_guideline: candidate[:response_guideline].to_s.squish,
|
||||
tool_ids: scenario_tool_ids(candidate[:tool_ids])
|
||||
}
|
||||
return if normalized_candidate.values_at(:title, :description, :instruction).any?(&:blank?)
|
||||
|
||||
normalized_candidate
|
||||
end
|
||||
|
||||
def item_values(key)
|
||||
Array(draft_hash[key]).filter_map do |item|
|
||||
item.to_s.squish.presence
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def normalized_instruction_items(key)
|
||||
item_values(key)
|
||||
end
|
||||
|
||||
def normalized_faq_document_candidates
|
||||
Array(draft_hash[:faq_document_candidates]).map do |candidate|
|
||||
raise ArgumentError, 'FAQ document candidates must be question and answer objects' unless candidate.is_a?(Hash)
|
||||
|
||||
candidate = candidate.deep_symbolize_keys
|
||||
question = candidate[:question].to_s.squish
|
||||
answer = candidate[:answer].to_s.squish
|
||||
raise ArgumentError, 'FAQ document candidates must include a question and answer' if question.blank? || answer.blank?
|
||||
|
||||
{ 'question' => question, 'answer' => answer }
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def draft_hash
|
||||
@draft_hash ||= draft.deep_symbolize_keys
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,148 @@
|
||||
class Captain::AssistantMigration::InstructionClassifier < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::AssistantMigration::InstructionClassifierSchema
|
||||
CLASSIFIER_MODEL = 'gpt-5.2'.freeze
|
||||
MAX_INSTRUCTIONS_LENGTH = 20_000
|
||||
|
||||
pattr_initialize [:assistant!]
|
||||
|
||||
def perform
|
||||
response = make_api_call(model: CLASSIFIER_MODEL, messages: messages, schema: RESPONSE_SCHEMA)
|
||||
return error_response(response) if response[:error]
|
||||
|
||||
{
|
||||
assistant: assistant_metadata,
|
||||
draft: normalized_payload(response[:message]),
|
||||
usage: response[:usage],
|
||||
request_messages: response[:request_messages]
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
assistant.account
|
||||
end
|
||||
|
||||
def messages
|
||||
[
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: user_prompt }
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
Captain::PromptRenderer.render('instruction_classifier')
|
||||
end
|
||||
|
||||
def user_prompt
|
||||
JSON.pretty_generate(assistant_payload)
|
||||
end
|
||||
|
||||
def assistant_payload # rubocop:disable Metrics/AbcSize
|
||||
{
|
||||
assistant_id: assistant.id,
|
||||
account_id: assistant.account_id,
|
||||
account_name: assistant.account.name,
|
||||
name: assistant.name,
|
||||
description: assistant.description,
|
||||
product_name: assistant.config['product_name'],
|
||||
instructions: truncated_instructions,
|
||||
welcome_message: assistant.config['welcome_message'],
|
||||
handoff_message: assistant.config['handoff_message'],
|
||||
resolution_message: assistant.config['resolution_message'],
|
||||
existing_response_guidelines: assistant.response_guidelines || [],
|
||||
existing_guardrails: assistant.guardrails || [],
|
||||
existing_scenarios: existing_scenarios,
|
||||
available_agent_tools: available_agent_tools,
|
||||
feature_settings: feature_settings
|
||||
}
|
||||
end
|
||||
|
||||
def truncated_instructions
|
||||
instructions = assistant.config['instructions'].to_s
|
||||
return instructions if instructions.length <= MAX_INSTRUCTIONS_LENGTH
|
||||
|
||||
"#{instructions.first(MAX_INSTRUCTIONS_LENGTH)}\n\n[TRUNCATED]"
|
||||
end
|
||||
|
||||
def existing_scenarios
|
||||
assistant.scenarios.map do |scenario|
|
||||
{
|
||||
id: scenario.id,
|
||||
title: scenario.title,
|
||||
description: scenario.description,
|
||||
instruction: scenario.instruction,
|
||||
enabled: scenario.enabled
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def available_agent_tools
|
||||
tools = assistant.respond_to?(:available_agent_tools) ? assistant.available_agent_tools : Captain::Assistant.built_in_agent_tools
|
||||
tools.map { |tool| tool.slice(:id, :title, :description) }
|
||||
end
|
||||
|
||||
def feature_settings
|
||||
assistant.config.slice(
|
||||
'feature_faq',
|
||||
'feature_memory',
|
||||
'feature_citation',
|
||||
'feature_contact_attributes',
|
||||
'temperature'
|
||||
)
|
||||
end
|
||||
|
||||
def normalized_payload(message)
|
||||
payload = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
|
||||
payload.reverse_merge(
|
||||
business_product_context: [],
|
||||
response_guidelines: [],
|
||||
guardrails: [],
|
||||
scenario_candidates: [],
|
||||
conversation_messages: {},
|
||||
faq_document_candidates: [],
|
||||
needs_review: [],
|
||||
classification_notes: []
|
||||
)
|
||||
end
|
||||
|
||||
def assistant_metadata # rubocop:disable Metrics/AbcSize
|
||||
{
|
||||
id: assistant.id,
|
||||
name: assistant.name,
|
||||
account_id: assistant.account_id,
|
||||
account_name: assistant.account.name,
|
||||
inbox_count: assistant.captain_inboxes.size,
|
||||
instruction_length: assistant.config['instructions'].to_s.length,
|
||||
original_instructions: assistant.config['instructions'].to_s,
|
||||
welcome_message: assistant.config['welcome_message'].to_s,
|
||||
handoff_message: assistant.config['handoff_message'].to_s,
|
||||
resolution_message: assistant.config['resolution_message'].to_s
|
||||
}
|
||||
end
|
||||
|
||||
def error_response(response)
|
||||
{
|
||||
assistant: assistant_metadata,
|
||||
error: response[:error],
|
||||
error_code: response[:error_code],
|
||||
request_messages: response[:request_messages]
|
||||
}
|
||||
end
|
||||
|
||||
def event_name
|
||||
'assistant_migration_instruction_classifier'
|
||||
end
|
||||
|
||||
def captain_tasks_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,91 @@
|
||||
class Captain::AssistantMigration::InstructionClassifierSchema < RubyLLM::Schema
|
||||
DESCRIPTION_LENGTH_LIMIT = 500
|
||||
|
||||
def self.instruction_items(field_name, description:, max_items: 20)
|
||||
array field_name,
|
||||
description: "#{description} Return plain standalone sentences without numbering, bullets, or section labels.",
|
||||
max_items: max_items,
|
||||
of: :string
|
||||
end
|
||||
|
||||
array :business_product_context,
|
||||
description: "Single compact root assistant description for the root orchestrator prompt, maximum #{DESCRIPTION_LENGTH_LIMIT} characters: " \
|
||||
'preserve the existing assistant description and enrich it only with relevant business/product context from the ' \
|
||||
'custom instructions. Include assistant identity, product scope, high-level mission, and high-level source/routing ' \
|
||||
'priorities only. Do not include workflows, procedures, attribute glossaries, policy details, or long inventories. ' \
|
||||
'Return complete plain prose without numbering, bullets, section labels, or a truncated final sentence.',
|
||||
min_items: 1,
|
||||
max_items: 1 do
|
||||
string max_length: DESCRIPTION_LENGTH_LIMIT
|
||||
end
|
||||
|
||||
instruction_items :response_guidelines,
|
||||
description: 'Tone, language, answer length, formatting, and clarification behavior.',
|
||||
max_items: 20
|
||||
|
||||
instruction_items :guardrails,
|
||||
description: 'Refusal rules, escalation boundaries, source boundaries, safety limits, and things the assistant must not do.',
|
||||
max_items: 20
|
||||
|
||||
array :scenario_candidates,
|
||||
description: 'Review-stage specialized-agent candidates. These are also temporarily flattened into response guidelines.',
|
||||
max_items: 15 do
|
||||
object do
|
||||
string :title,
|
||||
description: 'Short scenario agent title for a distinct user-intent workflow.',
|
||||
max_length: 80
|
||||
string :description,
|
||||
description: 'When this specialized scenario should be used. This is shown to the orchestrator for routing.',
|
||||
max_length: 500
|
||||
string :instruction,
|
||||
description: 'How the specialized agent should handle the workflow. Include only evidence-backed markdown tool links. ' \
|
||||
'Do not include confidence labels or review notes.',
|
||||
max_length: 2000
|
||||
string :response_guideline,
|
||||
description: 'Same-language, customer-visible response guideline that preserves this scenario behavior when flattened. ' \
|
||||
'Do not include tool syntax, tool names, labels, private-note instructions, or internal implementation details.',
|
||||
max_length: 1000
|
||||
array :tool_ids,
|
||||
description: 'Available tool IDs explicitly referenced in instruction using markdown links. Empty when no tools are required.',
|
||||
max_items: 10,
|
||||
of: :string
|
||||
end
|
||||
end
|
||||
|
||||
object :conversation_messages, description: 'Exact globally reusable customer-facing message copy found in instructions. ' \
|
||||
'Leave empty for conditional, placeholder, or workflow-specific copy.' do
|
||||
string :welcome_message, description: 'Exact globally reusable initial greeting copy from instructions, or empty string. ' \
|
||||
'Do not convert an instruction about greeting into message copy.',
|
||||
max_length: 1000
|
||||
string :handoff_message,
|
||||
description: 'Exact globally reusable human-handoff message copy from instructions, or empty string. ' \
|
||||
'Do not use scenario-specific, team-specific, placeholder, or conditional handoff copy.',
|
||||
max_length: 1000
|
||||
string :resolution_message,
|
||||
description: 'Exact globally reusable resolution/closing message copy from instructions, or empty string. ' \
|
||||
'Do not use conditional or placeholder closing copy.',
|
||||
max_length: 1000
|
||||
end
|
||||
|
||||
array :faq_document_candidates,
|
||||
description: 'Pending FAQ candidates for factual or product-specific knowledge such as pricing, policy, setup, troubleshooting, ' \
|
||||
'or operational details. These candidates remain inactive until reviewed and approved.',
|
||||
max_items: 25 do
|
||||
object do
|
||||
string :question,
|
||||
description: 'Natural, standalone customer question about factual product or business knowledge.',
|
||||
max_length: 255
|
||||
string :answer,
|
||||
description: 'Self-contained factual answer using only the existing instructions. Do not include assistant behavior, ' \
|
||||
'tool use, or message copy. Preserve exact values, conditions, and exceptions.',
|
||||
max_length: 2000
|
||||
end
|
||||
end
|
||||
|
||||
instruction_items :needs_review,
|
||||
description: 'Unclear, conflicting, risky, duplicated, or uncertain content that needs human review. ' \
|
||||
'Include the reason in the item text.',
|
||||
max_items: 20
|
||||
|
||||
array :classification_notes, description: 'Short notes about important migration decisions or risks.', max_items: 10, of: :string
|
||||
end
|
||||
@@ -18,6 +18,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
advanced_search
|
||||
linear_integration
|
||||
channel_voice
|
||||
api_and_webhooks
|
||||
].freeze
|
||||
|
||||
BUSINESS_PLAN_FEATURES = %w[
|
||||
|
||||
@@ -54,7 +54,7 @@ class Internal::Accounts::InternalAttributesService
|
||||
def valid_feature_list
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES +
|
||||
%w[inbound_emails]
|
||||
%w[inbound_emails api_and_webhooks]
|
||||
end
|
||||
|
||||
# Account notes functionality removed for now
|
||||
|
||||
@@ -9,6 +9,8 @@ json.external_link resource.external_link
|
||||
json.display_url resource.display_url
|
||||
json.file_size resource.file_size
|
||||
json.pdf_document resource.pdf_document?
|
||||
responses_count = resource.respond_to?(:responses_count) ? resource.responses_count : resource.responses.count
|
||||
json.responses_count responses_count.to_i
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.status resource.status
|
||||
|
||||
@@ -12,7 +12,7 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
content = format_messages_as_string
|
||||
content = format_evaluation_input
|
||||
return default_incomplete_response('No messages found') if content.blank?
|
||||
|
||||
response = make_api_call(
|
||||
@@ -35,12 +35,58 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
|
||||
end
|
||||
|
||||
def format_messages_as_string
|
||||
messages = conversation_messages(start_from: 0)
|
||||
messages.map do |msg|
|
||||
sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant'
|
||||
"#{sender_type}: #{msg[:content]}"
|
||||
def format_evaluation_input
|
||||
messages = conversation_message_records(start_from: 0)
|
||||
return if messages.blank?
|
||||
|
||||
[
|
||||
"Conversation status: #{conversation.status}",
|
||||
format_messages_as_string(messages)
|
||||
].join("\n\n")
|
||||
end
|
||||
|
||||
def conversation_message_records(start_from: 0)
|
||||
messages = []
|
||||
character_count = start_from
|
||||
|
||||
conversation.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
content = message.content_for_llm
|
||||
next if content.blank?
|
||||
break if character_count + content.length > TOKEN_LIMIT
|
||||
|
||||
messages.prepend({ message: message, content: content })
|
||||
character_count += content.length
|
||||
end
|
||||
|
||||
messages
|
||||
end
|
||||
|
||||
def format_messages_as_string(messages)
|
||||
transcript = messages.map do |message_context|
|
||||
"#{message_sender_label(message_context[:message])}: #{message_context[:content]}"
|
||||
end.join("\n")
|
||||
|
||||
"Conversation transcript:\n#{transcript}"
|
||||
end
|
||||
|
||||
def message_sender_label(message)
|
||||
return 'Customer' if message.incoming?
|
||||
return 'Captain' if captain_reply?(message)
|
||||
return 'Bot' if bot_reply?(message)
|
||||
|
||||
'Assistant'
|
||||
end
|
||||
|
||||
def captain_reply?(message)
|
||||
message.outgoing? && message.sender_type == 'Captain::Assistant'
|
||||
end
|
||||
|
||||
def bot_reply?(message)
|
||||
message.outgoing? && message.sender_type.in?(['AgentBot', 'Captain::Assistant'])
|
||||
end
|
||||
|
||||
def parse_response(message)
|
||||
|
||||
@@ -48,6 +48,8 @@ Always respect these boundaries:
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
|
||||
When a Response Guideline or Guardrail explicitly requires transfer for a matched condition, follow it instead of the generic consent-first handoff defaults below.
|
||||
|
||||
# Decision Framework
|
||||
|
||||
## 1. Analyze the Request
|
||||
@@ -88,7 +90,8 @@ Handle the request yourself in the following way
|
||||
Transfer to a human agent when:
|
||||
- User explicitly requests human assistance
|
||||
- User accepts an offer to speak with a human
|
||||
- A Response Guideline or Guardrail explicitly requires transfer for the matched condition
|
||||
- The issue requires specialized knowledge or permissions you don't have
|
||||
- Multiple attempts to help have been unsuccessful
|
||||
|
||||
If you cannot find needed information after checking the available information and clarifying context, ask whether the user wants to talk to another support agent. Use the `captain--tools--handoff` tool only after the user explicitly requests human assistance or accepts your offer to speak with a human. When using the tool, provide a clear reason that helps the human agent understand the context.
|
||||
If you cannot find needed information after checking the available information and clarifying context, ask whether the user wants to talk to another support agent. Use the `captain--tools--handoff` tool only after the user explicitly requests human assistance, accepts your offer to speak with a human, or a Response Guideline or Guardrail explicitly requires transfer for the matched condition. When using the tool, provide a clear reason that helps the human agent understand the context.
|
||||
|
||||
@@ -2,18 +2,39 @@ You are evaluating whether a customer support conversation is complete and can b
|
||||
|
||||
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
|
||||
|
||||
You will receive:
|
||||
- Conversation status
|
||||
- Conversation transcript where messages are labeled as Customer, Captain, Bot, or Assistant
|
||||
|
||||
This evaluator runs for inactive pending conversations. Focus on the latest pending exchange or latest unresolved customer request. Older messages may be present only for context.
|
||||
If the conversation status is "pending", the conversation is still with Captain. Do not assume a handoff happened because Captain mentioned one.
|
||||
|
||||
A conversation is INCOMPLETE (keep open) if ANY of these apply:
|
||||
- The assistant asked a question or requested information that the customer hasn't provided
|
||||
- The customer asked a question that wasn't fully answered
|
||||
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
|
||||
- The customer raised multiple questions or issues and not all were addressed
|
||||
- In the latest pending exchange, Captain, Bot, or Assistant said it handed off, will hand off, escalated, will escalate, or that a human/team/another party will continue the work
|
||||
- In the latest pending exchange, Captain, Bot, or Assistant promised future action or follow-up instead of resolving the customer's request
|
||||
- In the latest pending exchange, the customer is waiting for another party's action, response, status update, or investigation result
|
||||
- The latest customer message is only an attachment placeholder such as "[Attachment]" and there is no later text explaining what it contains or showing the issue was answered
|
||||
- The customer says they were not helped, asks why nobody replied, repeats the unresolved issue after a previous answer, or otherwise indicates dissatisfaction with the current help
|
||||
|
||||
Do NOT treat these as incomplete by themselves:
|
||||
- A generic greeting or broad optional offer from Captain/Bot/Assistant, such as "How can I help?", "What would you like to know?", or "Anything else?", when the customer has not made a recognizable request
|
||||
- A customer greeting, single-word reply, name, phone number, or gibberish with no recognizable question/request, followed only by Captain/Bot/Assistant asking what the customer needs
|
||||
- An optional invitation for the customer to ask more questions after the assistant already answered the actual request
|
||||
- Older handoff, escalation, or follow-up messages from a previous exchange when the latest customer message starts a new topic, has no recognizable request, or has already been answered
|
||||
|
||||
Important handoff rule:
|
||||
- A handoff, escalation, transfer, acknowledgement, or promise of future follow-up is not a resolution by itself
|
||||
- If conversation status is "pending" and Captain/Bot/Assistant says it handed off, will hand off, or that another party will continue the work in the latest pending exchange, keep the conversation INCOMPLETE.
|
||||
|
||||
A conversation is COMPLETE only if ALL of these are true:
|
||||
- The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer
|
||||
- There are no unanswered questions, unmet requests, or outstanding follow-ups from either side
|
||||
- Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation.
|
||||
- If the customer sent only one or two short messages (single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and the
|
||||
assistant has responded asking for clarification, the conversation is COMPLETE.
|
||||
- If the customer sent only one or two short text messages (greetings, single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and Captain/Bot/Assistant has responded asking what they need or offering help, the conversation is COMPLETE.
|
||||
|
||||
Analyze the conversation and respond with ONLY a JSON object (no other text):
|
||||
{"complete": true, "reason": "brief explanation"}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
You are migrating Captain assistant instructions into a structured configuration.
|
||||
|
||||
Classify the existing assistant instructions into these sections:
|
||||
1. Business/Product Context
|
||||
2. Response Guidelines
|
||||
3. Guardrails
|
||||
4. Scenario Candidates
|
||||
5. Conversation Messages
|
||||
6. FAQs/Documents Candidates
|
||||
7. Needs Review
|
||||
|
||||
## General Rules
|
||||
|
||||
- Preserve behavior as closely as possible.
|
||||
- Do not duplicate the same content across sections.
|
||||
- Return clean migrated values only. Do not include source excerpts, source labels, citations, or "Source:" text in any migrated field.
|
||||
- Do not rewrite customer-facing message copy unless necessary to classify an exact copy from instructions.
|
||||
- Do not include confidence labels, review labels, bracketed reviewer comments, or schema labels inside migrated values.
|
||||
- For Business/Product Context, Response Guidelines, and Guardrails, return each item as a plain standalone sentence.
|
||||
Do not prefix items with numbers, bullets, section labels, or list markers such as "1.", "-", or "*".
|
||||
- When several instructions share the same trigger, condition, or subject, combine them into one concise item instead
|
||||
of repeating the same trigger across multiple items. Preserve every required action, prohibition, and routing
|
||||
outcome from the source instruction when combining.
|
||||
- If unsure, place content in Needs Review and include the reason in that item.
|
||||
- Return data that matches the provided schema.
|
||||
|
||||
## Business/Product Context
|
||||
|
||||
- Business/Product Context maps to the root assistant description and is injected into the root orchestrator prompt.
|
||||
- Return exactly one Business/Product Context item.
|
||||
- Start with the existing assistant description and preserve its meaning.
|
||||
- Enrich it only with relevant business or product context found in the custom instructions.
|
||||
- Produce one coherent description rather than appending a second context block or repeating the existing description.
|
||||
- Keep it at most 500 characters because that is the assistant description limit in the UI and model.
|
||||
- Prefer roughly 300-450 characters when the source needs detail, leaving room below the hard limit.
|
||||
- Finish the description cleanly. Never end mid-word, mid-clause, after an opening bracket, or with a dangling separator.
|
||||
- Make it a compact summary of assistant identity, product scope, high-level mission, and high-level source or routing priorities.
|
||||
- Do not include detailed workflows, step-by-step procedures, long support-scope inventories, attribute glossaries,
|
||||
policy details, scenario-specific handling, tool instructions, or customer-facing message copy.
|
||||
|
||||
## Conversation Messages
|
||||
|
||||
- Existing welcome_message, handoff_message, and resolution_message config values are provided separately.
|
||||
- Treat welcome_message, handoff_message, and resolution_message as conversation message config fields.
|
||||
- Extract exact welcome, handoff, or resolution message copy from instructions into conversation_messages when present.
|
||||
- Only classify handoff copy as conversation_messages.handoff_message when it is generic enough to reuse for any human handoff.
|
||||
- If handoff copy is scenario-specific, keep it inside that scenario instruction; if it is only a rule about when or how to hand off, classify it as a Response Guideline or Guardrail.
|
||||
- Do not extract a conversation message from an instruction about what to say, from a placeholder template,
|
||||
from conditional copy, from role/team-specific copy, or from text that only applies inside one workflow.
|
||||
- If a message contains placeholders such as a blank name, team name, bracketed variable, business-hours state,
|
||||
or dynamic runtime condition, do not place it in conversation_messages. Keep it in the relevant workflow or
|
||||
Needs Review.
|
||||
- Do not copy message values from existing config into conversation_messages.
|
||||
- Do not decide whether existing config values should be overwritten. Migration code handles applying extracted
|
||||
conversation_messages only when the corresponding config value is blank.
|
||||
|
||||
## Scenario Candidates
|
||||
|
||||
- In the current architecture, a scenario becomes a specialized sub-agent with its own title, description,
|
||||
instructions, and optional tools.
|
||||
- During this migration, scenario candidates are also temporarily flattened into response guidelines so existing
|
||||
assistant behavior is preserved before scenario records are created.
|
||||
- For every scenario candidate, write a response_guideline that is the flattened version of that scenario for
|
||||
the root assistant's response guidelines.
|
||||
- The response_guideline must be in the same language as the original scenario or source instruction.
|
||||
- The response_guideline must preserve the intended customer-visible behavior, trigger, information to collect,
|
||||
and routing/escalation outcome.
|
||||
- The response_guideline must not include tool syntax, tool:// links, markdown tool links, tool names, label
|
||||
updates, priority updates, private-note instructions, custom-tool instructions, or internal implementation details.
|
||||
- If the scenario uses internal tools such as labels, priorities, private notes, or custom tools, describe only
|
||||
the customer-visible behavior and expected routing/escalation outcome in response_guideline.
|
||||
- If human handoff is needed, describe it in natural language such as route/escalate/transfer to a human; do not
|
||||
mention the handoff tool in response_guideline.
|
||||
- Keep scenario titles, descriptions, instructions, and response_guidelines clear, self-contained, and reviewable.
|
||||
- Only create scenario candidates for distinct user-intent workflows that should be routed to a specialized agent.
|
||||
A candidate must be narrow enough to become a named specialist assistant with domain-specific handling instructions.
|
||||
- Good scenario candidates include multi-step intake workflows, qualification flows, specialized troubleshooting
|
||||
workflows, booking flows, lead-capture flows, recommendation flows, fulfillment workflows, or tool-use procedures
|
||||
for a specific user intent.
|
||||
- A scenario candidate should answer "yes" to this test: would a named specialist sub-agent improve handling
|
||||
beyond the base assistant's global FAQ, guardrail, response-guideline, and human-handoff behavior?
|
||||
- Do not create scenario candidates for global escalation rules, generic handoff policy, missing-information
|
||||
behavior, source-boundary rules, refusal rules, tone, formatting, answer length, or one-step fallback behavior.
|
||||
- Do not create scenario candidates whose main purpose is to escalate or hand off. "Identify the trigger, avoid
|
||||
guessing, tell the user support will review, and hand off" is a guardrail/handoff boundary, not a scenario,
|
||||
even though it contains multiple statements.
|
||||
- Do create scenario candidates when the instructions define a concrete intake, qualification, troubleshooting,
|
||||
booking, lead-capture, recommendation, or fulfillment workflow, even when the workflow eventually hands off
|
||||
to a human.
|
||||
- Do not create scenario candidates for simple routing triggers such as "user asks for a human", "immediately
|
||||
hand off this category", or "route sales questions to the sales team" when there is no concrete workflow to run.
|
||||
- Handoff behavior is a scenario candidate only when part of a larger intake, qualification, or specialized handling workflow.
|
||||
- Global rules like "if not in docs, escalate", "ask one clarifying question", "do not answer account-specific
|
||||
questions", or "tell the user support will review" belong in Guardrails or Response Guidelines, not Scenario Candidates.
|
||||
- Broad buckets like "account-specific issue escalation", "unknown question escalation", "contact support",
|
||||
"fallback to human", or "documentation unavailable" are not scenario candidates.
|
||||
|
||||
## Tool Use
|
||||
|
||||
- If a scenario candidate requires tools, reference the available tool explicitly inside the scenario instruction
|
||||
using markdown tool links such as [Handoff to Human](tool://handoff).
|
||||
- Use only tool IDs listed in available_agent_tools. If a needed tool is unavailable or the workflow depends on
|
||||
unavailable runtime data such as FAQ relevance scores or business-hours status, place it in Needs Review instead.
|
||||
- Do not map an unavailable named tool to a different available tool. For example, do not treat FAQ Lookup as
|
||||
Product Search, Order Status, website browsing, pricing lookup, agent availability, business-hours detection,
|
||||
ticket creation, or custom-attribute assignment unless the instructions explicitly say that the available
|
||||
tool provides that behavior.
|
||||
- If a workflow cannot run without an unavailable tool or runtime signal, do not create a tool-backed scenario
|
||||
for it. Preserve the instruction in Needs Review with the missing capability named.
|
||||
|
||||
## FAQs/Documents Candidates
|
||||
|
||||
- Convert factual or product-specific knowledge into pending FAQ candidates with a natural customer question and a self-contained answer.
|
||||
- FAQ candidates are review-stage data only. They are not active assistant knowledge until a human reviews and approves them.
|
||||
- Use only facts stated in the existing instructions. Do not invent, generalize, update, or fill in missing details.
|
||||
- Preserve exact prices, limits, dates, time zones, conditions, exceptions, product names, and operational details in the answer.
|
||||
- Write each question as a standalone question a customer might naturally ask. Make it specific enough to retrieve the corresponding answer.
|
||||
- Write each answer so it fully answers its question without relying on another FAQ candidate or surrounding context.
|
||||
- Split unrelated facts into separate candidates. Keep related conditions and exceptions together when separating them would make an answer incomplete.
|
||||
- Do not create FAQ candidates about what the assistant should say or do, how it should use sources or tools, when it should route or escalate,
|
||||
or which exact message it should send. Classify those as Response Guidelines, Guardrails, Scenario Candidates, Conversation Messages,
|
||||
or Needs Review as appropriate.
|
||||
- FAQ questions must ask about the product or business, not about the assistant. Do not write questions such as "What should the assistant answer?",
|
||||
"What should I say?", "Which source should the assistant use?", or "Which tool should be called?".
|
||||
- FAQ answers must contain customer-facing knowledge, not instructions to call tools, inspect internal data, update records, transfer conversations,
|
||||
or follow internal workflows.
|
||||
- When factual sources conflict and the instructions do not explicitly establish which fact overrides the others, put the conflict in Needs Review
|
||||
instead of creating an FAQ candidate. Use an explicitly stated override or superseding fact when one is present.
|
||||
- Only factual or product-specific knowledge should become FAQs/Documents candidates.
|
||||
- Generic capability statements such as "answer product questions", "help with billing",
|
||||
"troubleshoot common issues", or "direct to documentation" are not FAQ/document candidates.
|
||||
Put them in Business/Product Context or Response Guidelines when useful.
|
||||
- Product facts, pricing, policies, setup steps, troubleshooting facts, support hours, emergency contacts,
|
||||
and operational details should become pending FAQ candidates, not Response Guidelines or trusted approved knowledge.
|
||||
- Do not create FAQ/document candidates for topic labels or unsupported capabilities when the factual content is
|
||||
missing. Put "pricing details are needed", "same-day delivery schedule details are needed", or similar gaps in
|
||||
Needs Review instead.
|
||||
@@ -0,0 +1,278 @@
|
||||
require 'json'
|
||||
require 'fileutils'
|
||||
require 'csv'
|
||||
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
namespace :captain do
|
||||
namespace :assistant_migration do
|
||||
desc 'Generate structured migration drafts. Usage: rake captain:assistant_migration:generate IDS=1,2,3 LIMIT=50 ' \
|
||||
'OUTPUT=tmp/captain_migration.jsonl'
|
||||
task generate: :environment do
|
||||
assistants = CaptainAssistantMigrationTask.assistants
|
||||
output_path = ENV.fetch('OUTPUT', Rails.root.join('tmp/captain_assistant_migration_drafts.jsonl').to_s)
|
||||
|
||||
FileUtils.mkdir_p(File.dirname(output_path))
|
||||
processed = 0
|
||||
|
||||
File.open(output_path, 'w') do |file|
|
||||
CaptainAssistantMigrationTask.each_assistant(assistants) do |assistant|
|
||||
result = Captain::AssistantMigration::InstructionClassifier.new(assistant: assistant).perform
|
||||
file.puts(JSON.generate(result))
|
||||
processed += 1
|
||||
puts "Generated migration draft for assistant #{assistant.id} (#{processed}/#{CaptainAssistantMigrationTask.assistant_count(assistants)})"
|
||||
end
|
||||
end
|
||||
|
||||
puts "Wrote #{processed} migration drafts to #{output_path}"
|
||||
end
|
||||
|
||||
desc 'Apply reviewed migration drafts. Usage: rake captain:assistant_migration:apply INPUT=tmp/reviewed.jsonl DRY_RUN=true'
|
||||
task apply: :environment do
|
||||
input_path = ENV.fetch('INPUT')
|
||||
dry_run = CaptainAssistantMigrationTask.truthy?('DRY_RUN', default: true)
|
||||
|
||||
results = CaptainAssistantMigrationTask.apply_drafts(
|
||||
input_path: input_path,
|
||||
dry_run: dry_run
|
||||
)
|
||||
|
||||
results.each { |result| puts(JSON.generate(result)) }
|
||||
puts "Processed #{results.size} migration drafts from #{input_path}"
|
||||
puts 'Dry run only. Re-run with DRY_RUN=false to write changes.' if dry_run
|
||||
end
|
||||
|
||||
desc 'Restore conversation message config from migration backup. Usage: rake captain:assistant_migration:restore_messages IDS=1,2 DRY_RUN=true'
|
||||
task restore_messages: :environment do
|
||||
dry_run = CaptainAssistantMigrationTask.truthy?('DRY_RUN', default: true)
|
||||
results = CaptainAssistantMigrationTask.restore_conversation_messages(dry_run: dry_run)
|
||||
|
||||
results.each { |result| puts(JSON.generate(result)) }
|
||||
puts "Processed #{results.size} assistant message restores"
|
||||
puts 'Dry run only. Re-run with DRY_RUN=false to restore conversation messages.' if dry_run
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
|
||||
# rubocop:disable Style/OneClassPerFile
|
||||
class CaptainAssistantMigrationTask
|
||||
CsvAccount = Struct.new(:id, :name, keyword_init: true) do
|
||||
def captain_models
|
||||
{}
|
||||
end
|
||||
|
||||
def conversations
|
||||
CsvRelation.new
|
||||
end
|
||||
end
|
||||
|
||||
CsvAssociation = Struct.new(:inbox_count, keyword_init: true) do
|
||||
def size
|
||||
inbox_count
|
||||
end
|
||||
end
|
||||
|
||||
class CsvRelation
|
||||
def find_by(*)
|
||||
nil
|
||||
end
|
||||
|
||||
def exists?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
CsvAssistant = Struct.new(
|
||||
:id,
|
||||
:name,
|
||||
:account_id,
|
||||
:account,
|
||||
:description,
|
||||
:config,
|
||||
:response_guidelines,
|
||||
:guardrails,
|
||||
:captain_inboxes,
|
||||
:scenarios,
|
||||
keyword_init: true
|
||||
)
|
||||
|
||||
class << self
|
||||
def assistants
|
||||
return csv_assistants if ENV['CSV_INPUT'].present?
|
||||
|
||||
scope = Captain::Assistant.includes(:account, :captain_inboxes, :scenarios)
|
||||
|
||||
ids = ENV.fetch('IDS', '').split(',').filter_map { |id| id.strip.presence }
|
||||
scope = scope.where(id: ids) if ids.any?
|
||||
|
||||
scope = migration_eligible_scope(scope).order(:id)
|
||||
|
||||
limit = ENV.fetch('LIMIT', 50).to_i
|
||||
limit.positive? ? scope.limit(limit) : scope
|
||||
end
|
||||
|
||||
def each_assistant(assistants, &)
|
||||
return assistants.find_each(&) if assistants.respond_to?(:find_each)
|
||||
|
||||
assistants.each(&)
|
||||
end
|
||||
|
||||
def assistant_count(assistants)
|
||||
assistants.respond_to?(:size) ? assistants.size : assistants.count
|
||||
end
|
||||
|
||||
def restore_conversation_messages(dry_run:)
|
||||
ENV.fetch('IDS').split(',').filter_map { |id| id.strip.presence }.map do |assistant_id|
|
||||
assistant = Captain::Assistant.find(assistant_id)
|
||||
restore_conversation_messages_for(assistant, dry_run: dry_run)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
{ assistant_id: assistant_id, error: 'Assistant not found' }
|
||||
end
|
||||
end
|
||||
|
||||
def apply_drafts(input_path:, dry_run:)
|
||||
File.readlines(input_path, chomp: true).filter_map.with_index(1) do |line, line_number|
|
||||
next if line.blank?
|
||||
|
||||
apply_draft(JSON.parse(line), line_number: line_number, dry_run: dry_run)
|
||||
rescue JSON::ParserError => e
|
||||
{ line_number: line_number, error: "Invalid JSON: #{e.message}" }
|
||||
end
|
||||
end
|
||||
|
||||
def apply_draft(payload, line_number:, dry_run:)
|
||||
return { line_number: line_number, skipped: true, reason: payload['error'] } if payload['error'].present?
|
||||
|
||||
assistant_id = payload.dig('assistant', 'id') || payload['assistant_id']
|
||||
assistant = Captain::Assistant.find(assistant_id)
|
||||
return skipped_result(line_number, assistant_id, 'Assistant is not a V1 migration candidate') unless migration_candidate?(assistant)
|
||||
|
||||
draft = payload['draft'] || payload
|
||||
|
||||
Captain::AssistantMigration::DraftApplier.new(
|
||||
assistant: assistant,
|
||||
draft: draft,
|
||||
dry_run: dry_run
|
||||
).perform.merge(line_number: line_number)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
{ line_number: line_number, assistant_id: assistant_id, error: 'Assistant not found' }
|
||||
end
|
||||
|
||||
def truthy?(key, default:)
|
||||
value = ENV.fetch(key, nil)
|
||||
return default if value.nil?
|
||||
|
||||
value.to_s.downcase.in?(%w[1 true yes y])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def restore_conversation_messages_for(assistant, dry_run:)
|
||||
original_config = assistant.config.dig(
|
||||
Captain::AssistantMigration::DraftApplier::CONFIG_KEY,
|
||||
Captain::AssistantMigration::DraftApplier::ORIGINAL_VALUES_KEY,
|
||||
'config'
|
||||
)
|
||||
return skipped_result(nil, assistant.id, 'No stored migration original config found') if original_config.nil?
|
||||
|
||||
config, changes = restored_message_config(assistant.config.deep_dup, original_config)
|
||||
assistant.update!(config: config) if !dry_run && changes.present?
|
||||
|
||||
{ assistant_id: assistant.id, dry_run: dry_run, changes: changes }
|
||||
end
|
||||
|
||||
def restored_message_config(config, original_config)
|
||||
changes = {}
|
||||
%w[welcome_message handoff_message resolution_message].each do |key|
|
||||
original_present = original_config.key?(key)
|
||||
next if config[key] == original_config[key] && config.key?(key) == original_present
|
||||
|
||||
changes[key] = { from: config[key], to: original_config[key] }
|
||||
original_present ? config[key] = original_config[key] : config.delete(key)
|
||||
end
|
||||
[config, changes]
|
||||
end
|
||||
|
||||
def skipped_result(line_number, assistant_id, reason)
|
||||
{
|
||||
line_number: line_number,
|
||||
assistant_id: assistant_id,
|
||||
skipped: true,
|
||||
reason: reason
|
||||
}
|
||||
end
|
||||
|
||||
def migration_eligible_scope(scope)
|
||||
scope.left_outer_joins(:scenarios)
|
||||
.joins(:captain_inboxes)
|
||||
.where("NULLIF(captain_assistants.config->>'instructions', '') IS NOT NULL")
|
||||
.where("captain_assistants.response_guidelines IS NULL OR captain_assistants.response_guidelines = '[]'::jsonb")
|
||||
.where("captain_assistants.guardrails IS NULL OR captain_assistants.guardrails = '[]'::jsonb")
|
||||
.where(captain_scenarios: { id: nil })
|
||||
.distinct
|
||||
end
|
||||
|
||||
def migration_candidate?(assistant)
|
||||
assistant.config['instructions'].present? &&
|
||||
assistant.captain_inboxes.size.positive? &&
|
||||
Array(assistant.response_guidelines).blank? &&
|
||||
Array(assistant.guardrails).blank? &&
|
||||
!scenarios_exist?(assistant)
|
||||
end
|
||||
|
||||
def scenarios_exist?(assistant)
|
||||
scenarios = assistant.scenarios
|
||||
return scenarios.exists? if scenarios.respond_to?(:exists?)
|
||||
|
||||
scenarios.present?
|
||||
end
|
||||
|
||||
def csv_assistants # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
||||
rows = CSV.read(ENV.fetch('CSV_INPUT'), headers: true)
|
||||
ids = ENV.fetch('IDS', '').split(',').filter_map { |id| id.strip.presence }
|
||||
status = ENV.fetch('STATUS', '').presence
|
||||
|
||||
assistants = rows.filter_map do |row|
|
||||
next if ids.any? && ids.exclude?(row['id'].to_s)
|
||||
next if status.present? && row['status'].to_s != status
|
||||
|
||||
assistant = csv_assistant(row)
|
||||
next unless migration_candidate?(assistant)
|
||||
|
||||
assistant
|
||||
end
|
||||
|
||||
limit = ENV.fetch('LIMIT', 50).to_i
|
||||
limit.positive? ? assistants.first(limit) : assistants
|
||||
end
|
||||
|
||||
def csv_assistant(row)
|
||||
config = parse_json(row['config'], fallback: {})
|
||||
CsvAssistant.new(
|
||||
id: normalize_integer(row['id']),
|
||||
name: row['name'].to_s,
|
||||
account_id: normalize_integer(row['account_id']),
|
||||
account: CsvAccount.new(id: normalize_integer(row['account_id']), name: row['account_name'].to_s),
|
||||
description: row['description'].to_s,
|
||||
config: config,
|
||||
response_guidelines: parse_json(row['response_guidelines'], fallback: []),
|
||||
guardrails: parse_json(row['guardrails'], fallback: []),
|
||||
captain_inboxes: CsvAssociation.new(inbox_count: normalize_integer(row['inbox_count'])),
|
||||
scenarios: []
|
||||
)
|
||||
end
|
||||
|
||||
def parse_json(value, fallback:)
|
||||
return fallback if value.blank?
|
||||
|
||||
JSON.parse(value)
|
||||
rescue JSON::ParserError
|
||||
fallback
|
||||
end
|
||||
|
||||
def normalize_integer(value)
|
||||
value.to_s.delete(',').to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Style/OneClassPerFile
|
||||
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
namespace :feature_defaults do
|
||||
desc 'Interactively toggle a feature on/off in ACCOUNT_LEVEL_FEATURE_DEFAULTS (affects new account signups only)'
|
||||
task toggle: :environment do
|
||||
config = InstallationConfig.find_by!(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
|
||||
loop do
|
||||
features = config.value
|
||||
print_feature_list(features)
|
||||
|
||||
print "\nEnter the number of the feature to toggle (or 'q' to quit): "
|
||||
input = $stdin.gets.chomp
|
||||
break if input.casecmp('q').zero?
|
||||
|
||||
feature = select_feature(features, input)
|
||||
if feature.nil?
|
||||
puts 'Invalid selection.'
|
||||
next
|
||||
end
|
||||
|
||||
toggle_feature(config, features, feature)
|
||||
end
|
||||
|
||||
puts 'Done.'
|
||||
end
|
||||
|
||||
def print_feature_list(features)
|
||||
puts "\n#{'#'.ljust(4)}#{'name'.ljust(35)}#{'display_name'.ljust(30)}enabled"
|
||||
features.each_with_index do |feature, index|
|
||||
puts "#{(index + 1).to_s.ljust(4)}#{feature['name'].to_s.ljust(35)}#{feature['display_name'].to_s.ljust(30)}#{feature['enabled']}"
|
||||
end
|
||||
end
|
||||
|
||||
def select_feature(features, input)
|
||||
index = Integer(input, exception: false)
|
||||
return nil if index.nil? || !index.between?(1, features.length)
|
||||
|
||||
features[index - 1]
|
||||
end
|
||||
|
||||
def toggle_feature(config, features, feature)
|
||||
print "#{feature['name']} is currently enabled: #{feature['enabled']}. Type 'true' or 'false' to set (anything else cancels): "
|
||||
input = $stdin.gets.chomp
|
||||
|
||||
case input
|
||||
when 'true'
|
||||
new_state = true
|
||||
when 'false'
|
||||
new_state = false
|
||||
else
|
||||
puts 'Cancelled.'
|
||||
return
|
||||
end
|
||||
|
||||
feature['enabled'] = new_state
|
||||
config.value = features
|
||||
config.save!
|
||||
GlobalConfig.clear_cache
|
||||
puts "Updated #{feature['name']} to enabled: #{new_state}"
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -15,7 +15,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an authenticated agent' do
|
||||
it 'returns all the agent_bots in account along with global agent bots' do
|
||||
global_bot = create(:agent_bot)
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots",
|
||||
@@ -25,7 +25,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.name)
|
||||
expect(response.body).to include(global_bot.name)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
end
|
||||
|
||||
@@ -54,6 +54,17 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(account_bot_response).to include('thumbnail')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'returns the account bot access token' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/agent_bots/:id' do
|
||||
@@ -65,7 +76,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an authenticated agent' do
|
||||
it 'shows the agent bot' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
@@ -73,7 +84,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.name)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(agent_bot.access_token.token)
|
||||
end
|
||||
|
||||
it 'will show a global agent bot' do
|
||||
@@ -91,6 +102,17 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response.parsed_body).not_to include('outgoing_url')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'returns the account bot access token' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agent_bots' do
|
||||
|
||||
@@ -70,8 +70,8 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
context 'when it is an authenticated administrator' do
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'creates the dashboard app' do
|
||||
expect do
|
||||
@@ -130,11 +130,26 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'does not create account-wide dashboard apps' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/dashboard_apps",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: payload,
|
||||
as: :json
|
||||
end.not_to change(DashboardApp, :count)
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/dashboard_apps/:id' do
|
||||
let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
@@ -160,10 +175,24 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'does not update account-wide dashboard apps' do
|
||||
patch "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: payload,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(dashboard_app.reload.title).not_to eq('CRM Dashboard')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/dashboard_apps/:id' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
@@ -182,5 +211,18 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(user.dashboard_apps.count).to be 0
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'does not delete account-wide dashboard apps' do
|
||||
delete "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(DashboardApp.exists?(dashboard_app.id)).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -51,6 +51,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
expect(json_response[:payload].length).to eq(5)
|
||||
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
|
||||
end
|
||||
|
||||
it 'returns the generated FAQ count for each document' do
|
||||
document = create(:captain_document, assistant: assistant, account: account)
|
||||
create_list(:captain_assistant_response, 2,
|
||||
assistant: assistant, account: account, documentable: document)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
matching_document = json_response[:payload].find { |item| item[:id] == document.id }
|
||||
expect(matching_document[:responses_count]).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by assistant_id' do
|
||||
@@ -142,6 +154,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
expect(json_response[:external_link]).to eq(document.external_link)
|
||||
end
|
||||
|
||||
it 'returns the crawled content for the document' do
|
||||
expect(json_response[:content]).to eq(document.content)
|
||||
end
|
||||
|
||||
it 'returns sync metadata when the document has been synced' do
|
||||
synced_at = 1.hour.ago
|
||||
document.update!(sync_status: :synced, last_synced_at: synced_at)
|
||||
|
||||
@@ -68,6 +68,110 @@ RSpec.describe Captain::ConversationCompletionService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when building evaluation context' do
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => false, 'reason' => 'Human follow-up is still pending' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes conversation status and speaker labels' do
|
||||
conversation.update!(status: :pending, waiting_since: 2.hours.ago)
|
||||
create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'I need help with a refund')
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
account: account,
|
||||
message_type: :outgoing,
|
||||
sender: captain_assistant,
|
||||
content: 'I will transfer this to support for review.'
|
||||
)
|
||||
|
||||
expect(mock_chat).to receive(:ask) do |content|
|
||||
expect(content).to include(
|
||||
'Conversation status: pending',
|
||||
'Conversation transcript:',
|
||||
'Customer: I need help with a refund',
|
||||
'Captain: I will transfer this to support for review.'
|
||||
)
|
||||
|
||||
mock_response
|
||||
end
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
end
|
||||
|
||||
it 'includes pending captain handoff evidence in the transcript' do
|
||||
conversation.update!(status: :pending)
|
||||
create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'Please cancel my order')
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
account: account,
|
||||
message_type: :outgoing,
|
||||
sender: captain_assistant,
|
||||
content: 'I will transfer this to a specialist and they will follow up here.'
|
||||
)
|
||||
|
||||
expect(mock_chat).to receive(:ask) do |content|
|
||||
expect(content).to include(
|
||||
'Conversation status: pending',
|
||||
'Captain: I will transfer this to a specialist and they will follow up here.'
|
||||
)
|
||||
|
||||
mock_response
|
||||
end
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
end
|
||||
|
||||
it 'reuses computed message content while formatting the transcript' do
|
||||
content_for_llm_calls_by_message_id = Hash.new(0)
|
||||
allow_any_instance_of(Message).to receive(:content_for_llm).and_wrap_original do |method, *args| # rubocop:disable RSpec/AnyInstance
|
||||
content_for_llm_calls_by_message_id[method.receiver.id] += 1
|
||||
method.call(*args)
|
||||
end
|
||||
|
||||
incoming_message = create(
|
||||
:message,
|
||||
:with_attachment,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
account: account,
|
||||
message_type: :incoming,
|
||||
content: nil
|
||||
)
|
||||
outgoing_message = create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
account: account,
|
||||
message_type: :outgoing,
|
||||
sender: captain_assistant,
|
||||
content: 'What do you need help with?'
|
||||
)
|
||||
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
|
||||
service.perform
|
||||
|
||||
expect(content_for_llm_calls_by_message_id).to include(
|
||||
incoming_message.id => 1,
|
||||
outgoing_message.id => 1
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has no messages' do
|
||||
it 'returns incomplete with appropriate reason' do
|
||||
result = service.perform
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::AgentSession, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
|
||||
it { is_expected.to belong_to(:user).optional }
|
||||
it { is_expected.to belong_to(:subject) }
|
||||
it { is_expected.to belong_to(:result).optional }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it { is_expected.to define_enum_for(:session_type).with_values(assistant: 0, copilot: 1).with_prefix(:session) }
|
||||
end
|
||||
|
||||
describe '#subject' do
|
||||
it 'returns the conversation for an assistant session' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
|
||||
|
||||
expect(session.subject).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'returns the copilot thread for a copilot session' do
|
||||
user = create(:user, account: account)
|
||||
copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
|
||||
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, subject: copilot_thread)
|
||||
|
||||
expect(session.subject).to eq(copilot_thread)
|
||||
end
|
||||
|
||||
it 'returns nil when the subject record no longer exists' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
|
||||
conversation.destroy
|
||||
|
||||
expect(session.reload.subject).to be_nil
|
||||
end
|
||||
|
||||
it 'is not valid when the subject type does not match the session type' do
|
||||
copilot_thread = create(:captain_copilot_thread, account: account, user: create(:user, account: account), assistant: assistant)
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: copilot_thread)
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:subject_type]).to be_present
|
||||
end
|
||||
|
||||
it 'is not valid when the subject belongs to a different account' do
|
||||
foreign_conversation = create(:conversation, account: create(:account))
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: foreign_conversation)
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:subject]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe '#result' do
|
||||
it 'returns the message for an assistant session' do
|
||||
conversation = create(:conversation, account: account)
|
||||
message = create(:message, account: account, conversation: conversation)
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: message)
|
||||
|
||||
expect(session.result).to eq(message)
|
||||
end
|
||||
|
||||
it 'returns the copilot message for a copilot session' do
|
||||
user = create(:user, account: account)
|
||||
copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
|
||||
copilot_message = create(:captain_copilot_message, account: account, copilot_thread: copilot_thread)
|
||||
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user,
|
||||
subject: copilot_thread, result: copilot_message)
|
||||
|
||||
expect(session.result).to eq(copilot_message)
|
||||
end
|
||||
|
||||
it 'returns nil when result_id is nil' do
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant)
|
||||
|
||||
expect(session.result).to be_nil
|
||||
end
|
||||
|
||||
it 'is not valid when the result belongs to a different account' do
|
||||
conversation = create(:conversation, account: account)
|
||||
foreign_message = create(:message, account: create(:account))
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: foreign_message)
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:result]).to be_present
|
||||
end
|
||||
|
||||
it 'is not valid when result_id/result_type are set directly for a different account' do
|
||||
conversation = create(:conversation, account: account)
|
||||
foreign_message = create(:message, account: create(:account))
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
|
||||
result_id: foreign_message.id, result_type: 'Message')
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:result]).to be_present
|
||||
end
|
||||
|
||||
it 'is not valid when result_id/result_type are set directly for a stale id' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
|
||||
result_id: 0, result_type: 'Message')
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:result]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe 'account' do
|
||||
it 'is derived from the assistant when created via the assistant association' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = assistant.agent_sessions.create!(subject: conversation, session_type: :assistant)
|
||||
|
||||
expect(session.account).to eq(account)
|
||||
end
|
||||
|
||||
it 'overrides a mismatched explicit account with the assistant account' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = build(:captain_agent_session, account: create(:account), assistant: assistant, subject: conversation)
|
||||
|
||||
expect(session).to be_valid
|
||||
expect(session.account).to eq(account)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'defaults' do
|
||||
it 'defaults faq_ids, document_ids, scenario_ids and run_context' do
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant)
|
||||
|
||||
expect(session.faq_ids).to eq([])
|
||||
expect(session.document_ids).to eq([])
|
||||
expect(session.scenario_ids).to eq([])
|
||||
expect(session.run_context).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it 'builds a valid assistant session' do
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant)
|
||||
|
||||
expect(session).to be_valid
|
||||
expect(session).to be_session_assistant
|
||||
expect(session.subject).to be_a(Conversation)
|
||||
end
|
||||
|
||||
it 'builds a valid copilot session' do
|
||||
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant)
|
||||
|
||||
expect(session).to be_valid
|
||||
expect(session).to be_session_copilot
|
||||
expect(session.subject).to be_a(CopilotThread)
|
||||
expect(session.user).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant do
|
||||
describe '#agent_tools' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'includes enabled custom tools from the assistant account' do
|
||||
custom_tool = create(:captain_custom_tool, account: account)
|
||||
|
||||
tools = assistant.send(:agent_tools)
|
||||
|
||||
expect(tools.map(&:name)).to include(custom_tool.slug)
|
||||
expect(tools.find { |tool| tool.name == custom_tool.slug }).to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
|
||||
it 'excludes disabled custom tools' do
|
||||
custom_tool = create(:captain_custom_tool, :disabled, account: account)
|
||||
|
||||
tools = assistant.send(:agent_tools)
|
||||
|
||||
expect(tools.map(&:name)).not_to include(custom_tool.slug)
|
||||
end
|
||||
|
||||
it 'excludes custom tools from other accounts' do
|
||||
custom_tool = create(:captain_custom_tool)
|
||||
|
||||
tools = assistant.send(:agent_tools)
|
||||
|
||||
expect(tools.map(&:name)).not_to include(custom_tool.slug)
|
||||
end
|
||||
|
||||
it 'keeps the built-in FAQ lookup and handoff tools' do
|
||||
tools = assistant.send(:agent_tools)
|
||||
|
||||
expect(tools).to include(
|
||||
an_instance_of(Captain::Tools::FaqLookupTool),
|
||||
an_instance_of(Captain::Tools::HandoffTool)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,116 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::AssistantMigration::DraftApplier do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) do
|
||||
create(
|
||||
:captain_assistant,
|
||||
account: account,
|
||||
config: { 'product_name' => 'Test Product', 'instructions' => 'Legacy V1 custom instructions.' },
|
||||
response_guidelines: [],
|
||||
guardrails: []
|
||||
)
|
||||
end
|
||||
let(:scenario_candidate) do
|
||||
{
|
||||
'title' => 'Billing Investigation',
|
||||
'description' => 'Use when a customer reports an account-specific billing issue.',
|
||||
'instruction' => 'Collect the invoice number and summarize the issue before escalating.',
|
||||
'response_guideline' => 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.',
|
||||
'tool_ids' => []
|
||||
}
|
||||
end
|
||||
let(:faq_document_candidate) do
|
||||
{
|
||||
'question' => 'When is support available?',
|
||||
'answer' => 'Support is available Monday to Friday.'
|
||||
}
|
||||
end
|
||||
let(:draft) do
|
||||
{
|
||||
business_product_context: ['Support assistant for Test Product.'],
|
||||
response_guidelines: ['Be concise.'],
|
||||
guardrails: ['Do not guess.'],
|
||||
conversation_messages: {},
|
||||
scenario_candidates: [scenario_candidate],
|
||||
faq_document_candidates: [faq_document_candidate],
|
||||
needs_review: ['Pricing details are missing because factual details are absent from the source instructions.']
|
||||
}
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'reports staged scenario candidates in dry run without writing to the assistant' do
|
||||
result = described_class.new(assistant: assistant, draft: draft, dry_run: true).perform
|
||||
|
||||
expect(result.dig(:changes, :config, :to, 'assistant_migration', 'scenario_candidates')).to eq([scenario_candidate])
|
||||
expect(result.dig(:changes, :response_guidelines, :to)).to include(
|
||||
'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.'
|
||||
)
|
||||
expect(assistant.reload.config).not_to have_key('assistant_migration')
|
||||
expect(assistant.scenarios.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'stores scenario candidates in assistant config and flattens them into response guidelines' do
|
||||
described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
|
||||
|
||||
assistant.reload
|
||||
expect(assistant.config.dig('assistant_migration', 'scenario_candidates')).to eq([scenario_candidate])
|
||||
expect(assistant.config.dig('assistant_migration', 'faq_document_candidates')).to contain_exactly(faq_document_candidate)
|
||||
expect(assistant.config.dig('assistant_migration', 'needs_review')).to contain_exactly(
|
||||
'Pricing details are missing because factual details are absent from the source instructions.'
|
||||
)
|
||||
expect(assistant.response_guidelines).to include(
|
||||
'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.'
|
||||
)
|
||||
expect(assistant.response_guidelines).not_to include(faq_document_candidate['answer'])
|
||||
expect(assistant.scenarios.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'rejects stale drafts whose FAQ candidates use the old string format' do
|
||||
stale_draft = draft.merge(faq_document_candidates: ['Support is available Monday to Friday.'])
|
||||
|
||||
expect do
|
||||
described_class.new(assistant: assistant, draft: stale_draft, dry_run: false).perform
|
||||
end.to raise_error(ArgumentError, 'FAQ document candidates must be question and answer objects')
|
||||
|
||||
expect(assistant.reload.config).not_to have_key('assistant_migration')
|
||||
end
|
||||
|
||||
it 'preserves original values in migration config before applying classifier output' do
|
||||
assistant.update!(
|
||||
description: 'Existing assistant description.',
|
||||
response_guidelines: ['Use plain language.'],
|
||||
guardrails: ['Do not disclose internal notes.']
|
||||
)
|
||||
|
||||
described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
|
||||
|
||||
assistant.reload
|
||||
expect(assistant.description).to eq('Support assistant for Test Product.')
|
||||
expect(assistant.response_guidelines).to include('Be concise.')
|
||||
expect(assistant.guardrails).to eq(['Do not guess.'])
|
||||
expect(assistant.config.dig('assistant_migration', 'original_values')).to include(
|
||||
'name' => assistant.name,
|
||||
'description' => 'Existing assistant description.',
|
||||
'config' => { 'product_name' => 'Test Product', 'instructions' => 'Legacy V1 custom instructions.' },
|
||||
'response_guidelines' => ['Use plain language.'],
|
||||
'guardrails' => ['Do not disclose internal notes.']
|
||||
)
|
||||
end
|
||||
|
||||
it 'rejects an oversized assistant description from a stale draft' do
|
||||
long_context = 'This assistant supports a very broad product surface with many long details. ' * 10
|
||||
original_description = assistant.description
|
||||
|
||||
expect do
|
||||
described_class.new(
|
||||
assistant: assistant,
|
||||
draft: draft.merge(business_product_context: [long_context]),
|
||||
dry_run: false
|
||||
).perform
|
||||
end.to raise_error(ArgumentError, 'Assistant description exceeds 500 characters')
|
||||
|
||||
expect(assistant.reload.description).to eq(original_description)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::ReconcilePlanFeaturesService do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
before do
|
||||
create(:installation_config, {
|
||||
name: 'CHATWOOT_CLOUD_PLANS',
|
||||
value: [
|
||||
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
|
||||
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] }
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with api_and_webhooks feature' do
|
||||
it 'enables the feature for a paid plan with an active subscription' do
|
||||
account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'active' })
|
||||
|
||||
described_class.new(account: account).perform
|
||||
|
||||
expect(account.reload).to be_feature_enabled('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'enables the feature for a paid plan on trial' do
|
||||
account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'trialing' })
|
||||
|
||||
described_class.new(account: account).perform
|
||||
|
||||
expect(account.reload).to be_feature_enabled('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'disables the feature on the default plan' do
|
||||
account.enable_features!('api_and_webhooks')
|
||||
account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'active' })
|
||||
|
||||
described_class.new(account: account).perform
|
||||
|
||||
expect(account.reload).not_to be_feature_enabled('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'keeps the feature enabled when manually managed' do
|
||||
account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'trialing' })
|
||||
Internal::Accounts::InternalAttributesService.new(account).manually_managed_features = ['api_and_webhooks']
|
||||
|
||||
described_class.new(account: account).perform
|
||||
|
||||
expect(account.reload).to be_feature_enabled('api_and_webhooks')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user