Maintenance::RecoverOrphanedCustomFieldsTask

Source code
# frozen_string_literal: true

# Recovers orphaned custom fields (no rows in custom_field_to_mbo_profiles):
# - If legacy tree_node_id is present, links the field to all mbo_profiles in that tree node.
# - If the tree node has no mbo_profiles, creates a default one and links it.
# - If legacy tree_node_id is NULL, deletes the custom field as non-recoverable.
#
# Safe to re-run:
# - Collection includes only currently orphaned fields.
# - Associations use find_or_create_by! to avoid duplicates.
class Maintenance::RecoverOrphanedCustomFieldsTask < MaintenanceTasks::Task
  include ArrayHelper

  collection_batch_size(1_000)
  attribute :emails, :string
  validates :emails, presence: true, fcm_email_format: true

  REPORT_COLUMNS = %w[
    custom_field_id
    custom_field_name
    legacy_tree_node_id
    tree_node_status
    recoverability
    action
    mbo_profile_ids
    status
    errors
  ].freeze

  after_start :prepare_csv_path
  after_complete :send_report
  after_error :send_report

  def collection
    return CustomField.none unless CustomField.connection.column_exists?(:custom_fields, :tree_node_id)

    CustomField.where.missing(:custom_field_to_mbo_profiles)
  end

  def process(custom_field)
    tree_node_id_loaded = false
    tree_node_id = legacy_tree_node_id_for(custom_field.id)
    tree_node_id_loaded = true
    row = process_custom_field(custom_field, tree_node_id)
    append_report(row.merge(custom_field_id: custom_field.id, custom_field_name: custom_field.name))
  rescue StandardError => e
    append_report(
      custom_field_id: custom_field.id,
      custom_field_name: custom_field.name,
      tree_node_id: tree_node_id,
      tree_node_status: tree_node_id_loaded ? tree_node_status_for(tree_node_id) : 'UNKNOWN',
      recoverability: tree_node_id_loaded ? recoverability_for(tree_node_id) : 'UNKNOWN',
      action: 'FAILED',
      mbo_profile_ids: [],
      status: 'FAILED',
      errors: e.message
    )
    raise
  end

  private

  def csv_path
    @csv_path ||= Rails.root.join('tmp', "recover_orphaned_custom_fields_#{Time.now.to_i}.csv").to_s
  end

  def prepare_csv_path
    File.write(csv_path, REPORT_COLUMNS.to_csv)
  end

  def send_report
    return unless csv_path && File.exist?(csv_path)

    CsvReportMailer.send_report(
      recipients: emails_array(emails),
      file_path: csv_path,
      report_sender: 'Recover Orphaned Custom Fields'
    ).deliver_now
  ensure
    File.delete(csv_path) if csv_path && File.exist?(csv_path)
  end

  def process_custom_field(custom_field, tree_node_id)
    if tree_node_id.blank?
      delete_non_recoverable_custom_field(custom_field)
      return {
        tree_node_id: nil,
        tree_node_status: 'NOT_APPLICABLE',
        recoverability: 'NON_RECOVERABLE',
        action: 'DELETED',
        mbo_profile_ids: [],
        status: 'SUCCESS',
        errors: nil
      }
    end

    process_recoverable(custom_field, tree_node_id)
  end

  def process_recoverable(custom_field, tree_node_id)
    tree_node = TreeNode.find_by(id: tree_node_id)
    unless tree_node
      return {
        tree_node_id: tree_node_id,
        tree_node_status: 'NOT_FOUND',
        recoverability: 'RECOVERABLE',
        action: 'SKIPPED_TREE_NODE_NOT_FOUND',
        mbo_profile_ids: [],
        status: 'FAILED',
        errors: 'TreeNode not found'
      }
    end

    mbo_profiles, action = mbo_profiles_for(tree_node)
    associate_custom_field(custom_field, mbo_profiles)

    {
      tree_node_id: tree_node_id,
      tree_node_status: 'FOUND',
      recoverability: 'RECOVERABLE',
      action: action,
      mbo_profile_ids: mbo_profiles.map(&:id),
      status: 'SUCCESS',
      errors: nil
    }
  end

  def mbo_profiles_for(tree_node)
    mbo_profiles = tree_node.mbo_profiles.to_a
    return [mbo_profiles, 'LINKED_EXISTING_MBO_PROFILES'] if mbo_profiles.any?

    [[create_default_mbo_profile(tree_node)], 'CREATED_DEFAULT_MBO_PROFILE_AND_LINKED']
  end

  def associate_custom_field(custom_field, mbo_profiles)
    mbo_profiles.each do |mbo_profile|
      CustomFieldToMboProfile.find_or_create_by!(
        custom_field_id: custom_field.id,
        mbo_profile_id: mbo_profile.id
      )
    end
  end

  def append_report(row)
    File.open(csv_path, 'a') do |file|
      file.puts(
        [
          row.fetch(:custom_field_id),
          row.fetch(:custom_field_name),
          row.fetch(:tree_node_id),
          row.fetch(:tree_node_status),
          row.fetch(:recoverability),
          row.fetch(:action),
          row.fetch(:mbo_profile_ids).join('|'),
          row.fetch(:status),
          row.fetch(:errors)
        ].to_csv
      )
    end
  end

  def recoverability_for(tree_node_id)
    tree_node_id.present? ? 'RECOVERABLE' : 'NON_RECOVERABLE'
  end

  def tree_node_status_for(tree_node_id)
    return 'NOT_APPLICABLE' if tree_node_id.blank?

    TreeNode.exists?(id: tree_node_id) ? 'FOUND' : 'NOT_FOUND'
  end

  def legacy_tree_node_id_for(custom_field_id)
    return nil unless CustomField.connection.column_exists?(:custom_fields, :tree_node_id)

    CustomField.connection.select_value(
      CustomField.sanitize_sql_array(
        ['SELECT tree_node_id FROM custom_fields WHERE id = ? LIMIT 1', custom_field_id]
      )
    )
  end

  def delete_non_recoverable_custom_field(custom_field)
    ActiveRecord::Base.transaction do
      # Ensure the governing link does not block deletion.
      Condition.where(governing_custom_field_id: custom_field.id).find_each do |condition|
        condition.update!(governing_custom_field_id: nil)
      end
      custom_field.destroy!
    end
  end

  def create_default_mbo_profile(tree_node)
    MboProfiles::CreateDefaultMboProfileService.new(tree_node: tree_node).call
  end
end