Maintenance::Atom::ImportCustomFieldsByCompanyGuidTask

Source code
# frozen_string_literal: true

# Second pass of the Atom custom-field import, for the companies the first pass missed.
#
# Maintenance::Atom::ImportCustomFieldsDedupedTask resolves a row's company with
# CompanyProfile.find_by(id: parent_id), which only works for the profiles the subcompany
# import created with the atom id as their primary key. A company that already existed
# in company-api keeps its own id and merely carries the atom company_guid, so its
# parent_id matched nothing and its custom fields were never attached — the gap this
# task closes.
#
# Each row is therefore resolved by company_guid instead, and a resolved profile whose
# id equals the row's parent_id is skipped: the first pass already attached that one,
# and re-attaching would be pointless work on every re-run.
#
# Grouping, CustomField upsert and validation-option/mapper reconciliation are shared
# with the first pass via Maintenance::Atom::CustomFieldGroupImportable, whose
# content-based lookup adopts the CustomField the first pass already created rather
# than duplicating it; only the missing MboProfile attachments are added.
#
# Expected CSV headers (the deduped export plus company_guid):
#   name, validation_required, validation_regex,
#   inclusion_list_validation_json, code_labels_json, auto_import_options,
#   legacy_scope, legacy_agent_port, mapper_key,
#   parent_id, company_guid, created_at, updated_at
#
# company_guid is the guid of the atom subcompany the row's parent_id points at. Rows
# with an unresolvable company_guid are logged and skipped rather than raising, so one
# unmigrated company does not abort the group; rows with a blank company_guid carry
# nothing to resolve and are dropped, also with a warning.

class Maintenance::Atom::ImportCustomFieldsByCompanyGuidTask < MaintenanceTasks::Task
  include DatadogTrace
  include Maintenance::Atom::CustomFieldGroupImportable

  csv_collection
  report_on(StandardError)

  MAX_LOGGED_PARENT_IDS = 10

  private

  # A dedup group spans many rows, each with its own (parent_id, company_guid) pair, so
  # the two are kept paired rather than collected into separate lists. Blank guids are
  # dropped (nothing to resolve) and duplicate pairs collapsed.
  def parent_attributes(group_rows)
    { 'parents' => collect_parents(group_rows) }
  end

  def collect_parents(group_rows)
    resolvable, blank = group_rows.partition { |group_row| group_row['company_guid'].to_s.strip.present? }
    log_blank_company_guids(group_rows.first['name'], blank) if blank.any?

    resolvable.map { |group_row| parent_pair(group_row) }.uniq
  end

  def parent_pair(group_row)
    {
      'parent_id' => group_row['parent_id'].to_s.strip.presence,
      'company_guid' => group_row['company_guid'].to_s.strip
    }
  end

  # A blank company_guid loses that company its custom field for good — this task is the
  # second and last pass — so the drop is reported rather than silent: it means the export
  # shipped rows without a guid, which is a problem with the CSV, not with the data. Logged
  # once per dedup group at collection time, with the parent_ids for tracing the rows back.
  def log_blank_company_guids(name, blank_rows)
    parent_ids = blank_rows.filter_map { |group_row| group_row['parent_id'].to_s.strip.presence }
    Rails.logger.warn(
      "CustomField '#{name}': #{blank_rows.size} row(s) with a blank company_guid, " \
      "skipping mbo_profile attachment (parent_ids: #{format_parent_ids(parent_ids)})"
    )
  end

  # Bounded: a group can span thousands of rows and the point of the line is the count
  # plus a sample to grep the CSV with, not the full list.
  def format_parent_ids(parent_ids)
    return 'none' if parent_ids.empty?

    listed = parent_ids.first(MAX_LOGGED_PARENT_IDS).join(', ')
    remaining = parent_ids.size - MAX_LOGGED_PARENT_IDS
    remaining.positive? ? "#{listed}, and #{remaining} more" : listed
  end

  def attach_parents(custom_field, group)
    group['parents'].each { |parent| attach_by_company_guid(custom_field, parent) }
  end

  def attach_by_company_guid(custom_field, parent)
    company_guid = parent['company_guid']
    # find_by_company_guid normalizes case and legacy ATOM- guids, and returns nil for a
    # value that cannot be a uuid instead of matching rows whose company_guid IS NULL.
    company_profile = CompanyProfile.find_by_company_guid(company_guid)
    return log_unresolved(custom_field, company_guid) unless company_profile
    return if already_imported?(company_profile, parent['parent_id'])

    attach_to_mbo_profiles(custom_field, company_profile)
  end

  # The profile's id IS the atom parent_id, so the id-based first pass already attached
  # this field to it. Compared case-insensitively: the CSV carries the atom id verbatim
  # while the uuid column stores it lowercased.
  def already_imported?(company_profile, parent_id)
    parent_id.present? && company_profile.id.casecmp?(parent_id.to_s.strip)
  end

  def log_unresolved(custom_field, company_guid)
    Rails.logger.warn(
      "CustomField #{custom_field.id}: no CompanyProfile with company_guid #{company_guid}, " \
      'skipping mbo_profile attachment'
    )
    nil
  end
end