Module: OpenLoam::Undo

Defined in:
lib/open_loam/undo.rb

Overview

Undo/redo built ON the audit trail (L-704). Every change already records a OpenLoam::AuditRecord with its changeset; undoing one applies the inverse and writes a NEW audit record — so the inverse is itself undoable, and "redo" is just undoing that undo entry. No separate command log to keep in sync.

THE STACK: a field revert is refused unless the audit is the record's LATEST update/undo — undoing an old change out from under newer ones would silently clobber them. Undo therefore walks the history back one entry at a time.

BOUNDARIES (each a deliberate skip, not a gap):

* encrypted fields — the audit stores "[encrypted]", never the old value,
so there is nothing to revert to;
* the workflow column — a direct write is blocked by the transition gate
(OpenLoam::Workflow), so state is undone by performing the reverse TRANSITION,
not here;
* fields the actor's role may not write (when a policy is supplied).

Defined Under Namespace

Classes: NotUndoableError

Constant Summary collapse

IGNORE =
%w[id lock_version created_at updated_at].freeze

Class Method Summary collapse

Class Method Details

.ensure_current_tenant!(audit) ⇒ Object

Raises:



122
123
124
# File 'lib/open_loam/undo.rb', line 122

def ensure_current_tenant!(audit)
  raise NotUndoableError, "not in this tenant" unless audit.tenant_id == OpenLoam.tenant!.id
end

.ensure_latest!(audit) ⇒ Object

The audit must be the record's most recent update/undo — else undoing it would overwrite newer edits. Tenant-scoped via AuditRecord's default scope.

Raises:



94
95
96
97
98
99
100
101
# File 'lib/open_loam/undo.rb', line 94

def ensure_latest!(audit)
  latest = OpenLoam::AuditRecord
           .where(auditable_type: audit.auditable_type, auditable_id: audit.auditable_id, action: %w[update undo])
           .order(:id).last
  return if latest.nil? || latest.id == audit.id

  raise NotUndoableError, "a newer change exists — undo that first"
end

.guard_soft_delete(record) ⇒ Object



85
86
87
88
89
90
# File 'lib/open_loam/undo.rb', line 85

def guard_soft_delete(record)
  unless record.respond_to?(:soft_delete!) && record.respond_to?(:restore!)
    raise NotUndoableError, "#{record.class.name} is not soft-deletable, so this change can't be undone"
  end
  record
end

.load_record(audit) ⇒ Object

Look the record up through its OWN default scope (tenant holds), lifting only the soft-delete filter so a soft-deleted row is still reachable to restore. Never AuditRecord#auditable, which is unscoped.

Raises:



106
107
108
109
110
111
112
113
114
115
# File 'lib/open_loam/undo.rb', line 106

def load_record(audit)
  klass = audit.auditable_type.safe_constantize
  raise NotUndoableError, "unknown type #{audit.auditable_type.inspect}" unless klass.is_a?(Class) && klass < OpenLoam::TenantRecord

  scope = klass.respond_to?(:with_deleted) ? klass.with_deleted : klass.all
  record = scope.find_by(id: audit.auditable_id)
  raise NotUndoableError, "the record no longer exists" unless record

  record
end

.revert_fields(record, audit, policy) ⇒ Object

--- internals ---

Raises:



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/open_loam/undo.rb', line 54

def revert_fields(record, audit, policy)
  ensure_latest!(audit)
  workflow_column = record.class.respond_to?(:open_loam_workflow) ? record.class.open_loam_workflow&.column.to_s : nil

  updates = {}
  (audit.changeset || {}).each do |field, values|
    next unless values.is_a?(Array)                 # skip "[encrypted]" (a String)
    next if IGNORE.include?(field)
    next if field == workflow_column                # undo state via the reverse transition, not a direct write
    next unless record.class.column_names.include?(field)
    next if policy && !policy.writable?(field)

    updates[field] = values.first                   # the "before" value
  end

  raise NotUndoableError, "nothing revertable in this change" if updates.empty?

  # Relabel this save's audit as "undo" so the history reads legibly (and the
  # entry is itself undoable = redo). open_loam_audit_as is the same hook
  # SoftDeletable uses for soft_delete/restore.
  if record.respond_to?(:open_loam_audit_as, true)
    record.send(:open_loam_audit_as, "undo") { record.update!(updates) }
  else
    record.update!(updates)
  end
end

.soft_deletable?(audit) ⇒ Boolean

Returns:

  • (Boolean)


117
118
119
120
# File 'lib/open_loam/undo.rb', line 117

def soft_deletable?(audit)
  klass = audit.auditable_type.safe_constantize
  klass.is_a?(Class) && klass.instance_methods.include?(:soft_delete!)
end

.undo(audit, policy: nil) ⇒ Object

Apply the inverse of one audit record. Returns the affected record. policy: (optional) restricts a field revert to policy-writable fields.



38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/open_loam/undo.rb', line 38

def undo(audit, policy: nil)
  ensure_current_tenant!(audit)
  record = load_record(audit)

  case audit.action
  when "update", "undo" then revert_fields(record, audit, policy)
  when "create"         then undo_create(record)
  when "soft_delete"    then guard_soft_delete(record).restore!
  when "restore"        then guard_soft_delete(record).soft_delete!
  else raise NotUndoableError, "#{audit.action.inspect} cannot be undone"
  end
  record
end

.undo_create(record) ⇒ Object



81
82
83
# File 'lib/open_loam/undo.rb', line 81

def undo_create(record)
  guard_soft_delete(record).soft_delete!
end

.undoable?(audit) ⇒ Boolean

Coarse, action-level check the admin view consults to decide whether to offer an Undo button. The fine checks (something revertable, still latest) happen in undo and surface as NotUndoableError.

Returns:

  • (Boolean)


28
29
30
31
32
33
34
# File 'lib/open_loam/undo.rb', line 28

def undoable?(audit)
  case audit.action
  when "update", "undo"                    then true
  when "create", "soft_delete", "restore"  then soft_deletable?(audit)
  else false
  end
end