Maintenance::BackfillAgentPortFieldsTask

Source code
# frozen_string_literal: true

# One-shot backfill of the AgentPort-sourced fields onto existing records, from the
# AgentPort export (ADO #77474): `instance` and the CompanyGroup link on the
# CompanyProfile, and `pcc_code` / `record_locator` on its GdsProfile.
#
# ONE task, one upload, one pass, one report. Every field the ticket asks for is
# keyed off the same `CompanyGUID`, so splitting it would mean reading the same
# file and resolving the same company twice, and would produce two partial reports
# instead of the single post-load confirmation acceptance criteria #3 asks for. The
# per-field logic lives in concerns so this class stays a thin orchestrator.
#
# UPDATE ONLY. No CompanyProfile and no GdsProfile is ever created (acceptance
# criteria #2); a GUID with no CompanyProfile is reported as `not_found`.
#
# `dry_run` (testing mode) defaults to TRUE, so a run started without thinking
# about it writes nothing and only produces the report, where pending changes
# appear as `would_update`.
#
# Deliberately self-contained: the concerns it includes are its own, so this
# one-shot migration cannot change behaviour because a service shared with the
# live AgentPort webhook sync was edited later.
#
# Expected CSV headers:
#   CompanyGUID, Instance, CompanyGroupName, CompanyGroupId, PCCCode,
#   CompanyRecordLocator
# CompanyName is ignored.
class Maintenance::BackfillAgentPortFieldsTask < MaintenanceTasks::Task
  include Maintenance::AgentPort::ExportRowReadable
  include Maintenance::AgentPort::CompanyFieldsBackfillable
  include Maintenance::AgentPort::GdsFieldsBackfillable

  csv_collection

  REPORT_COLUMNS = %w[company_guid result instance_result group_result gds_result gds_profile_id
                      pcc_result locator_result].freeze
  REPORT_SENDER = 'Backfill AgentPort Fields'

  attribute :emails, :string
  attribute :dry_run, :boolean, default: true
  validates :emails, presence: true, fcm_email_format: true

  after_start :prepare_csv_path
  after_complete :send_report
  after_error :send_report

  def process(row)
    company_guid = exported(row, :company_guid)
    # A blank GUID would match a profile with a NULL/empty company_guid (both
    # allowed by the schema) and stamp an unrelated record, so skip it.
    return append_report(company_guid, :blank_company_guid) if company_guid.blank?
    return append_report(company_guid, :invalid_instance) unless valid_instance?(row)

    company_profile = CompanyProfile.find_by_company_guid(company_guid)
    company_profile ? apply(company_profile, row) : append_report(company_guid, :not_found)
  end

  def csv_path
    @csv_path ||= build_csv_path
  end

  private

  # The row is all-or-nothing. `CompanyProfile` runs
  # `validates_associated :custom_fields` on any change that is not status-only, and
  # its own comment records that companies carry pre-existing invalid custom fields,
  # so `update!` here can raise on data this backfill never touched. Without the
  # transaction a raising GDS write would leave `instance` committed with no report
  # row for that company; without the rescue one bad company would abort the whole
  # pass. Both are reported per row instead, so the run continues and the failure is
  # named in the confirmation acceptance criteria #3 asks for.
  def apply(company_profile, row)
    outcome = ActiveRecord::Base.transaction do
      company = apply_company_fields(company_profile, row)
      gds = apply_gds_fields(company_profile, row)
      # Merge last-wins on `changed`, so OR the two halves explicitly rather than
      # letting the GDS half's `false` mask a company-side write.
      company.merge(gds, changed: company[:changed] || gds[:changed])
    end

    append_report(company_profile.company_guid, row_result(outcome), outcome)
  rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
    append_report(company_profile.company_guid, failure_note(e))
  end

  def row_result(outcome)
    return :unchanged unless outcome[:changed]

    dry_run ? :would_update : :updated
  end

  def failure_note(error)
    "invalid:#{error.message.gsub(/\s+/, ' ').strip[0, 200]}"
  end

  # Splits on newlines as well as commas: recipients pasted out of an email client
  # arrive one per line, and `fcm_email_format` validates the whole string before
  # the split so it would not catch the resulting malformed address.
  def recipients
    emails.to_s.split(/[\n,]+/).map(&:strip).compact_blank
  end

  def build_csv_path
    Rails.root.join('tmp', "backfill_agent_port_fields_#{Time.now.to_i}.csv").to_s
  end

  def prepare_csv_path
    @csv_path = build_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: recipients,
      file_path: csv_path,
      report_sender: REPORT_SENDER
    ).deliver_now
  ensure
    File.delete(csv_path) if csv_path && File.exist?(csv_path)
  end

  def append_report(company_guid, result, extras = {})
    File.open(csv_path, 'a') do |file|
      file.puts([company_guid, result, extras[:instance_result], extras[:group_result],
                 extras[:gds_result], extras[:gds_profile_id],
                 extras[:pcc_result], extras[:locator_result]].to_csv)
    end
  end
end

Previous Runs

Succeeded

Processed 18 out of 18 items (100%).

Ran for less than 5 seconds, finished .


Arguments:
emails
[FILTERED]
dry_run
0

Metadata:
user_id
-1
Succeeded

Processed 18 out of 18 items (100%).

Ran for less than 5 seconds, finished .


Arguments:
emails
[FILTERED]
dry_run
1

Metadata:
user_id
-1