Module: Fosm::DataRetention

Defined in:
lib/fosm/data_retention.rb

Overview

Service for discovering and managing FOSM objects under a data retention policy.

Eligibility criteria

A model is “archival-eligible” when it satisfies BOTH of the following:

1. It has at least one *terminal* state whose name contains "archiv"
   (case-insensitive — covers :archived, :archival, :archiviert, etc.)
2. Its database table has an `archived_at` datetime/timestamp column.

Retention window

Driven by Fosm.config.data_retention_days (default 3650 = 10 years). Records with a non-nil archived_at older than the cutoff are “eligible for purge”.

Audit-safety guarantee

Purging a business record NEVER touches fosm_transition_logs. The audit trail is intentionally kept forever for compliance purposes. Only the source record (e.g. the Invoice or FaasAccount row) is deleted.

Class Method Summary collapse

Class Method Details

.all_eligible_for_purge(model_class) ⇒ ActiveRecord::Relation

An unbounded scope of all purge-eligible records for use in batch jobs. Callers should use find_each to avoid loading all rows into memory.

Parameters:

  • model_class (Class)

Returns:

  • (ActiveRecord::Relation)


121
122
123
# File 'lib/fosm/data_retention.rb', line 121

def all_eligible_for_purge(model_class)
  eligible_scope(model_class)
end

.archival_eligible?(model_class) ⇒ Boolean

Returns true when the model has an archival terminal state AND an ‘archived_at` column.

Parameters:

  • model_class (Class)

Returns:

  • (Boolean)


36
37
38
# File 'lib/fosm/data_retention.rb', line 36

def archival_eligible?(model_class)
  has_archival_terminal_state?(model_class) && has_archived_at_column?(model_class)
end

.archival_eligible_modelsArray<Class>

All registered FOSM model classes that meet both eligibility criteria.

Returns:

  • (Array<Class>)


27
28
29
# File 'lib/fosm/data_retention.rb', line 27

def archival_eligible_models
  Fosm::Registry.model_classes.select { |mc| archival_eligible?(mc) }
end

.archival_states_for(model_class) ⇒ Array<String>

All archival terminal state names for the model (as strings).

Parameters:

  • model_class (Class)

Returns:

  • (Array<String>)


66
67
68
69
70
71
72
# File 'lib/fosm/data_retention.rb', line 66

def archival_states_for(model_class)
  lifecycle = model_class.try(:fosm_lifecycle)
  return [] unless lifecycle
  lifecycle.states
    .select { |s| s.terminal? && s.name.to_s.downcase.include?("archiv") }
    .map { |s| s.name.to_s }
end

.has_archival_terminal_state?(model_class) ⇒ Boolean

Returns true when at least one terminal state name includes “archiv”.

Parameters:

  • model_class (Class)

Returns:

  • (Boolean)


44
45
46
47
48
# File 'lib/fosm/data_retention.rb', line 44

def has_archival_terminal_state?(model_class)
  lifecycle = model_class.try(:fosm_lifecycle)
  return false unless lifecycle
  lifecycle.states.any? { |s| s.terminal? && s.name.to_s.downcase.include?("archiv") }
end

.has_archived_at_column?(model_class) ⇒ Boolean

Returns true when the model’s table has an ‘archived_at` column.

Rescues gracefully if the table doesn’t exist yet (e.g. during migrations).

Parameters:

  • model_class (Class)

Returns:

  • (Boolean)


56
57
58
59
60
# File 'lib/fosm/data_retention.rb', line 56

def has_archived_at_column?(model_class)
  model_class.column_names.include?("archived_at")
rescue => _e
  false
end

.records_eligible_for_purge(model_class, page: 1, per_page: 50) ⇒ ActiveRecord::Relation

Returns a paginated ActiveRecord relation of purge-eligible records, ordered oldest-first (most overdue first).

Pagination is offset-based. Pages are 1-indexed.

Parameters:

  • model_class (Class)
  • page (Integer) (defaults to: 1)

    1-based page number (clamped to >= 1)

  • per_page (Integer) (defaults to: 50)

    max records per page

Returns:

  • (ActiveRecord::Relation)


111
112
113
114
# File 'lib/fosm/data_retention.rb', line 111

def records_eligible_for_purge(model_class, page: 1, per_page: 50)
  offset = ([page.to_i, 1].max - 1) * per_page
  eligible_scope(model_class).offset(offset).limit(per_page)
end

.retention_cutoff_dateActiveSupport::TimeWithZone

The cutoff timestamp: records with ‘archived_at` before this moment are eligible for purge.

Returns:

  • (ActiveSupport::TimeWithZone)


78
79
80
# File 'lib/fosm/data_retention.rb', line 78

def retention_cutoff_date
  Fosm.config.data_retention_days.days.ago
end

.safe_to_purge?(record) ⇒ Boolean

Returns true when a single record is safe to purge:

- Its class has an `archived_at` column (defensive belt-and-suspenders).
- +archived_at+ is not nil.
- +archived_at+ is strictly older than the retention cutoff.
- The record's current state is an archival terminal state.

This is the authoritative pre-purge check. Always call this immediately before destroying, even when the controller already checked — the retention window or record state may have changed since the UI check.

Parameters:

  • record (ActiveRecord::Base)

Returns:

  • (Boolean)


138
139
140
141
142
143
144
# File 'lib/fosm/data_retention.rb', line 138

def safe_to_purge?(record)
  return false unless record.class.column_names.include?("archived_at")
  archived_at = record.archived_at
  return false if archived_at.nil?
  return false if archived_at >= retention_cutoff_date
  archival_states_for(record.class).include?(record.state.to_s)
end

.total_eligible_for_purge(model_class) ⇒ Integer

Total records eligible for purge: in an archival state, with a non-nil ‘archived_at` older than the retention cutoff.

Parameters:

  • model_class (Class)

Returns:

  • (Integer)


98
99
100
# File 'lib/fosm/data_retention.rb', line 98

def total_eligible_for_purge(model_class)
  eligible_scope(model_class).count
end

.total_in_archival_state(model_class) ⇒ Integer

Total records currently in any archival terminal state, regardless of whether they have passed the retention window.

Parameters:

  • model_class (Class)

Returns:

  • (Integer)


87
88
89
90
91
# File 'lib/fosm/data_retention.rb', line 87

def total_in_archival_state(model_class)
  states = archival_states_for(model_class)
  return 0 if states.empty?
  model_class.where(state: states).count
end