Maintenance::DeleteAuNzCompanyDataTask

Source code
# frozen_string_literal: true

# Purges AU/NZ company profiles ahead of the reviewed data load (ADO #78910).
#
# Runs in TESTING MODE by default: leave the `commit` checkbox unticked and the
# task destroys nothing, it only emails the CSV of everything it WOULD destroy.
# Tick `commit` to perform the deletion.
#
# The report answers "is anything outside scope at risk?" explicitly. Every
# affected profile gets its own row, including the descendants that
# `has_ancestry`'s default `:destroy` orphan strategy would remove silently —
# those are the `delete_cascade` rows, and they are exactly what a hand-written
# `DELETE ... WHERE service_country_id IN (...)` would never show you. Retained
# profiles are listed too, each with the reason it was spared, which is the
# confirmation acceptance criterion #5 asks for.
#
# In commit mode the cascade rows are built from the LIVE subtree rather than
# the snapshot, and every row is written only after the destroy has succeeded,
# so the CSV records what actually happened rather than what was predicted.
#
# Scope resolution, including why protection has to travel upwards through
# ancestors, lives in CompanyProfiles::AuNzPurgeScope.
#
# NOTE: this task removes company profiles and whatever hangs off them through
# `dependent: :destroy` (GDS profiles, MBO and reason-group join rows). Shared
# records reachable only through many-to-many joins — mbo_profiles,
# custom_fields, mappers, conditions, validation_options — are NOT swept here,
# because one MBO profile or custom field can still be referenced by an
# out-of-scope company, and this schema already holds orphans predating the
# purge. Sweeping them safely means capturing candidates before the purge and
# deleting only those left orphaned afterwards, which is deferred to a
# follow-up rather than bolted on here.
class Maintenance::DeleteAuNzCompanyDataTask < MaintenanceTasks::Task
  include ArrayHelper

  # Raised when a purge root turns out to shield a profile that must survive.
  # The scope is resolved once up front, so a concurrent write could move a
  # protected profile under a root afterwards; this aborts rather than letting
  # the cascade reach it.
  class ScopeViolationError < StandardError; end

  attribute :emails, :string
  validates :emails, presence: true, fcm_email_format: true

  # Unticked (the default) means emulate and email the preview only.
  attribute :commit, :boolean, default: false

  REPORT_SENDER = 'Delete AU NZ Company Data'

  DECISIONS = {
    root: 'delete_root',
    cascade: 'delete_cascade',
    retained: 'retained',
    failed: 'destroy_failed',
    violation: 'skipped_scope_violation'
  }.freeze

  MODES = { dry_run: 'dry_run', commit: 'commit' }.freeze

  after_start :begin_report
  after_complete :send_report
  after_error :send_report

  # An ActiveRecord::Relation, so job-iteration cursors by id rather than by
  # position in a rebuilt Array. A positional cursor applied to a list that
  # shrinks as roots are destroyed skips exactly the profiles it has already
  # shortened past, silently. No `order` here: the cursor rejects a relation
  # that carries one and reorders by primary key itself. `includes` is fine —
  # it only rejects ORDER BY and LIMIT — and it keeps `live_row` from issuing a
  # country lookup per root.
  def collection
    CompanyProfile.where(id: scope.purge_roots.map(&:id)).includes(:service_country)
  end

  def process(profile)
    return preview(profile) unless commit

    purge(profile)
  end

  private

  def report
    @report ||= CompanyProfiles::PurgeReport.new(
      mode: commit ? MODES.fetch(:commit) : MODES.fetch(:dry_run),
      decisions: DECISIONS,
      task_name: self.class.name
    )
  end

  def scope
    @scope ||= CompanyProfiles::AuNzPurgeScope.new
  end

  def begin_report
    report.start(scope.retained_rows)
  end

  # Dry run: nothing is destroyed, so the snapshot is the only truth available.
  def preview(profile)
    report.record(DECISIONS.fetch(:root), live_row(profile))
    scope.cascade_victims_for(profile.id).each do |victim|
      report.record(DECISIONS.fetch(:cascade), victim, { purge_root_id: profile.id })
    end
  end

  # No staleness guard is needed: a purge root is never a descendant of another
  # purge root, so an earlier cascade in this run cannot have removed it.
  def purge(profile)
    victims = live_victims(profile)
    shielded = victims.reject { |victim| safe_to_destroy?(victim) }
    return refuse(profile, shielded) if shielded.any?

    destroy_subtree(profile)
    report_purged(profile, victims)
  end

  def report_purged(profile, victims)
    report.record(DECISIONS.fetch(:root), live_row(profile))
    victims.each { |victim| report.record(DECISIONS.fetch(:cascade), victim, { purge_root_id: profile.id }) }
  end

  # Live, and captured before the destroy makes them unreadable. These are the
  # profiles the cascade actually takes, which the snapshot cannot know.
  def live_victims(profile)
    profile.descendants.includes(:service_country).map { |child| live_row(child) }
  end

  # Rows are written only once the destroy has succeeded, so a failed run cannot
  # mail a report claiming profiles were deleted when they were not.
  def destroy_subtree(profile)
    profile.destroy!
  rescue StandardError => e
    report.record(DECISIONS.fetch(:failed), live_row(profile), { reason: e.message })
    raise
  end

  def refuse(profile, shielded)
    ids = shielded.map(&:id).join(', ')
    report.record(DECISIONS.fetch(:violation), live_row(profile), { reason: "shields #{ids}" })
    raise ScopeViolationError, "Refusing to destroy #{profile.id}: it shields #{ids}"
  end

  def safe_to_destroy?(row)
    scope.in_scope?(row) && !scope.excluded?(row)
  end

  def live_row(profile)
    CompanyProfiles::AuNzPurgeScope::Row.new(
      id: profile.id,
      ancestry: profile.ancestry,
      company_guid: profile.company_guid,
      name: profile.name,
      country_code: profile.service_country&.code
    )
  end

  def send_report
    return unless report.any?

    path = report.to_file
    CsvReportMailer.send_report(
      recipients: emails_array(emails),
      file_path: path,
      report_sender: REPORT_SENDER
    ).deliver_now
  ensure
    File.delete(path) if path && File.exist?(path)
  end
end

Previous Runs

Succeeded

Processed 39 out of 39 items (100%).

Ran for less than 5 seconds, finished .

Arguments:
emails
[FILTERED]
commit
0

Metadata:
user_id
-1
Succeeded

Processed 39 out of 39 items (100%).

Ran for less than 5 seconds, finished .

Arguments:
emails
[FILTERED]
commit
0

Metadata:
user_id
-1