diff --git a/.circleci/config.yml b/.circleci/config.yml index 59702c139..c533e1402 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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 diff --git a/app/controllers/api/v1/accounts/data_imports_controller.rb b/app/controllers/api/v1/accounts/data_imports_controller.rb new file mode 100644 index 000000000..7f0d28e81 --- /dev/null +++ b/app/controllers/api/v1/accounts/data_imports_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 9f56c3817..bb9ca2a70 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -124,8 +124,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def reauthorize_and_update_channel(channel_attributes) - @inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!) @inbox.channel.update!(permitted_params(channel_attributes)[:channel]) + @inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!) end def update_channel_feature_flags diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 5b87e2d1a..3912e5b6e 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -59,6 +59,10 @@ class Api::V1::Widget::BaseController < ApplicationController permitted_params.dig(:contact, :phone_number) end + def contact_custom_attributes + permitted_params.dig(:contact, :custom_attributes)&.to_h + end + def browser_params { browser_name: browser.name, diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 00e718614..8f5977d54 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController def process_update_contact @contact = ContactIdentifyAction.new( contact: @contact, - params: { email: contact_email, phone_number: contact_phone_number, name: contact_name }, + params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes }, retain_original_contact_name: true, discard_invalid_attrs: true ).perform @@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def permitted_params - params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number], + params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }], message: [:content, :referer_url, :timestamp, :echo_id], custom_attributes: {}) end diff --git a/app/finders/data_import_error_finder.rb b/app/finders/data_import_error_finder.rb new file mode 100644 index 000000000..69d85b23c --- /dev/null +++ b/app/finders/data_import_error_finder.rb @@ -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 diff --git a/app/finders/data_import_skip_log_finder.rb b/app/finders/data_import_skip_log_finder.rb new file mode 100644 index 000000000..ee932f1da --- /dev/null +++ b/app/finders/data_import_skip_log_finder.rb @@ -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 diff --git a/app/javascript/dashboard/api/dataImports.js b/app/javascript/dashboard/api/dataImports.js new file mode 100644 index 000000000..b4c15b98a --- /dev/null +++ b/app/javascript/dashboard/api/dataImports.js @@ -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(); diff --git a/app/javascript/dashboard/components-next/banner/Banner.vue b/app/javascript/dashboard/components-next/banner/Banner.vue index c9b86d42b..f44a6ae5e 100644 --- a/app/javascript/dashboard/components-next/banner/Banner.vue +++ b/app/javascript/dashboard/components-next/banner/Banner.vue @@ -61,10 +61,10 @@ const triggerAction = () => {
-
+