## Description Adds Freshdesk as an integration import source so administrators can validate a Freshdesk domain and API key, then import contacts, tickets, public replies, customer replies, and private notes while tracking progress from Data Imports. The integration has now been validated against a live Freshdesk trial tenant with contacts, Web Chat and phone tickets, public replies, customer replies, a private note, pagination, requester expansion, and attachment metadata. That validation found and fixed the current Web Chat source mapping and prevented the ticket description from duplicating the initial Web Chat message. Related: #15116 ## Closes Closes [CW-7639](https://linear.app/chatwoot/issue/CW-7639/freshdesk-freshworks-migration) ## Type of change - [x] New feature (non-breaking change which adds functionality) ## What changed - Added a shared source adapter, importer, job, retry, restart, creation, and placeholder inbox contract used by Intercom and Freshdesk. - Added Freshdesk API authentication, contact and ticket pagination, requester expansion, conversation retrieval, normalization, channel grouping, and error handling. - Added current Freshdesk source identifiers through SMS, including Web Chat source `15`, and grouped equivalent sources into placeholder inboxes. - Used Web Chat conversation events as the complete message history so the generated ticket description does not duplicate the initial customer message. - Preserved Freshdesk ticket subjects in source metadata and added a sanitized live-derived Web Chat fixture with structured bodies and attachment metadata. - Added Freshdesk selection, domain and API key validation, and provider-neutral import status handling in the Data Imports UI. ## How to test 1. Enable the data_import feature for an account and open Settings > Data > New import. 2. Select Freshdesk and enter a Freshdesk domain and API key. 3. Select contacts and/or conversations, validate the credentials, and start the import. 4. Confirm progress is displayed and imported tickets appear as resolved conversations in Freshdesk placeholder inboxes with public replies and private notes preserved. 5. Verify Web Chat tickets appear in the Chat placeholder inbox and the initial customer message is imported once. 6. Verify an abandoned import can be restarted and a stalled import can be retried. ## Current scope - **Product decision:** Attachment binaries are intentionally not imported in the current migration scope. Attachment metadata is preserved and messages include a skipped-attachment marker. - Adaptive Retry-After scheduling and handling the 30,000-ticket listing ceiling are covered by stacked follow-up PRs. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
150 lines
4.8 KiB
Ruby
150 lines
4.8 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: data_imports
|
|
#
|
|
# id :bigint not null, primary key
|
|
# abandoned_at :datetime
|
|
# access_token :text
|
|
# completed_at :datetime
|
|
# cursor :jsonb not null
|
|
# data_type :string not null
|
|
# import_types :jsonb not null
|
|
# last_error_at :datetime
|
|
# name :string
|
|
# processed_records :integer
|
|
# processing_errors :text
|
|
# source_metadata :jsonb not null
|
|
# source_provider :string
|
|
# source_type :string
|
|
# started_at :datetime
|
|
# stats :jsonb not null
|
|
# status :integer default("pending"), not null
|
|
# total_records :integer
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# account_id :bigint not null
|
|
# initiated_by_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_data_imports_on_account_id (account_id)
|
|
# index_data_imports_on_initiated_by_id (initiated_by_id)
|
|
# index_data_imports_on_source_provider (source_provider)
|
|
#
|
|
class DataImport < ApplicationRecord
|
|
ACTIVE_IMPORT_RUN_ID_KEY = 'active_import_run_id'.freeze
|
|
ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY = 'active_intercom_import_run_id'.freeze
|
|
IMPORT_STALLED_AFTER = 15.minutes
|
|
INTERCOM_STALLED_AFTER = IMPORT_STALLED_AFTER
|
|
LEGACY_DATA_TYPES = ['contacts'].freeze
|
|
INTEGRATION_DATA_TYPES = %w[freshdesk intercom].freeze
|
|
IMPORT_TYPES = %w[contacts conversations].freeze
|
|
|
|
belongs_to :account
|
|
belongs_to :initiated_by, class_name: 'User', optional: true
|
|
|
|
encrypts :access_token if Chatwoot.encryption_configured?
|
|
|
|
has_many :items, class_name: 'DataImportItem', dependent: :destroy_async
|
|
has_many :mappings, class_name: 'DataImportMapping', dependent: :destroy_async
|
|
has_many :import_errors, class_name: 'DataImportError', dependent: :destroy_async
|
|
|
|
validates :data_type, inclusion: { in: LEGACY_DATA_TYPES + INTEGRATION_DATA_TYPES, message: I18n.t('errors.data_import.data_type.invalid') }
|
|
validates :access_token, presence: true, on: :create, if: :integration_import?
|
|
validate :validate_import_types
|
|
validate :validate_integration_provider
|
|
|
|
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]) }
|
|
scope :active_integrations, lambda {
|
|
where(data_type: INTEGRATION_DATA_TYPES, status: [:pending, :processing]).where('source_provider = data_type')
|
|
}
|
|
|
|
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 freshdesk_import?
|
|
data_type == 'freshdesk' && source_provider == 'freshdesk'
|
|
end
|
|
|
|
def integration_import?
|
|
INTEGRATION_DATA_TYPES.include?(data_type) && data_type == source_provider
|
|
end
|
|
|
|
def restartable?
|
|
failed? || abandoned?
|
|
end
|
|
|
|
def stalled?
|
|
integration_import? && (pending? || processing?) && updated_at <= IMPORT_STALLED_AFTER.ago
|
|
end
|
|
|
|
def abandonable?
|
|
integration_import? && (pending? || processing?)
|
|
end
|
|
|
|
def abandon!
|
|
self.class.transaction do
|
|
active_imports = self.class.lock.where(id: id, data_type: INTEGRATION_DATA_TYPES, status: [:pending, :processing])
|
|
abandonable_import = active_imports.where('source_provider = data_type').first
|
|
abandonable_import&.update!(status: :abandoned, abandoned_at: Time.current)
|
|
end
|
|
reload
|
|
end
|
|
|
|
def active_intercom_import_run_id
|
|
active_import_run_id
|
|
end
|
|
|
|
def assign_active_intercom_import_run_id
|
|
assign_active_import_run_id
|
|
end
|
|
|
|
def active_import_run_id
|
|
source_metadata.to_h[ACTIVE_IMPORT_RUN_ID_KEY] || source_metadata.to_h[ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY]
|
|
end
|
|
|
|
def assign_active_import_run_id
|
|
run_id = SecureRandom.uuid
|
|
self.source_metadata = source_metadata.to_h.merge(ACTIVE_IMPORT_RUN_ID_KEY => run_id)
|
|
source_metadata[ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY] = run_id if 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
|
|
|
|
def validate_integration_provider
|
|
return unless INTEGRATION_DATA_TYPES.include?(data_type)
|
|
return if source_provider == data_type
|
|
|
|
errors.add(:source_provider, 'must match the integration data type')
|
|
end
|
|
end
|