# 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