# 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