active_record-undo 0.1.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -2
  3. data/DOCUMENTATION.md +340 -133
  4. data/README.md +473 -190
  5. data/app/controllers/active_record/undo/application_controller.rb +100 -0
  6. data/app/controllers/active_record/undo/logs_controller/async_handler.rb +50 -0
  7. data/app/controllers/active_record/undo/logs_controller.rb +116 -0
  8. data/app/controllers/active_record/undo/restores_controller.rb +11 -0
  9. data/app/jobs/active_record/undo/restore_job.rb +72 -0
  10. data/app/jobs/active_record/undo/soft_delete_job.rb +75 -0
  11. data/config/routes.rb +13 -0
  12. data/db/migrate/20260920000000_add_status_and_error_message_to_undo_logs.rb +15 -0
  13. data/lib/active_record/undo/cascade_handler.rb +66 -0
  14. data/lib/active_record/undo/configuration.rb +9 -1
  15. data/lib/active_record/undo/engine.rb +14 -1
  16. data/lib/active_record/undo/model_extension/async_methods.rb +68 -0
  17. data/lib/active_record/undo/model_extension/attribution_helper.rb +8 -0
  18. data/lib/active_record/undo/model_extension/tenant_verification.rb +1 -1
  19. data/lib/active_record/undo/model_extension.rb +22 -6
  20. data/lib/active_record/undo/safe_redirect.rb +60 -0
  21. data/lib/active_record/undo/status_predicates.rb +39 -0
  22. data/lib/active_record/undo/undo_log/token_verifier.rb +48 -0
  23. data/lib/active_record/undo/undo_log.rb +77 -2
  24. data/lib/active_record/undo/version.rb +1 -1
  25. data/lib/active_record/undo/view_helpers/status_badge_helper.rb +84 -0
  26. data/lib/active_record/undo/view_helpers.rb +105 -0
  27. data/lib/active_record/undo.rb +37 -1
  28. data/sig/active_record/undo.rbs +50 -1
  29. metadata +29 -1
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module ActiveRecord
6
+ module Undo
7
+ class ApplicationController < ActiveRecord::Undo.base_controller_class
8
+ include SafeRedirect
9
+
10
+ protect_from_forgery with: :exception, unless: -> { request.format.json? }
11
+
12
+ rescue_from ActiveRecord::Undo::Error, with: :handle_undo_error
13
+ rescue_from ActiveRecord::Undo::SecurityError, with: :handle_security_error
14
+
15
+ private
16
+
17
+ def resolve_whodunnit_user
18
+ if respond_to?(:current_user, true) && send(:current_user).present?
19
+ send(:current_user)
20
+ elsif ActiveRecord::Undo.config.current_user_method.respond_to?(:call)
21
+ ActiveRecord::Undo.config.current_user_method.call
22
+ else
23
+ ActiveRecord::Undo.whodunnit
24
+ end
25
+ end
26
+
27
+ def resolve_current_tenant
28
+ if respond_to?(:current_tenant, true) && send(:current_tenant).present?
29
+ send(:current_tenant)
30
+ elsif ActiveRecord::Undo.config.current_tenant_method.respond_to?(:call)
31
+ ActiveRecord::Undo.config.current_tenant_method.call
32
+ else
33
+ ActiveRecord::Undo.current_tenant
34
+ end
35
+ end
36
+
37
+ def handle_security_error(error)
38
+ respond_to do |format|
39
+ format.html { handle_html_error(error.message, :forbidden) }
40
+ format.turbo_stream { render_turbo_stream_error(error.message, :forbidden) }
41
+ format.json { render json: { error: error.message }, status: :forbidden }
42
+ end
43
+ end
44
+
45
+ def handle_undo_error(error)
46
+ respond_to do |format|
47
+ format.html { handle_html_error(error.message, :unprocessable_entity) }
48
+ format.turbo_stream { render_turbo_stream_error(error.message, :unprocessable_entity) }
49
+ format.json { render json: { error: error.message }, status: :unprocessable_entity }
50
+ end
51
+ end
52
+
53
+ def handle_not_found
54
+ msg = 'Undo log not found or already restored.'
55
+ respond_to do |format|
56
+ format.html { handle_html_error(msg, :not_found) }
57
+ format.turbo_stream { render_turbo_stream_error(msg, :not_found) }
58
+ format.json { render json: { error: msg }, status: :not_found }
59
+ end
60
+ end
61
+
62
+ def handle_expired
63
+ msg = 'Undo action has expired.'
64
+ respond_to do |format|
65
+ format.html { handle_html_error(msg, :unprocessable_entity) }
66
+ format.turbo_stream { render_turbo_stream_error(msg, :unprocessable_entity) }
67
+ format.json { render json: { error: msg }, status: :unprocessable_entity }
68
+ end
69
+ end
70
+
71
+ def handle_html_error(message, status)
72
+ if should_redirect_on_error?
73
+ redirect_to determine_redirect_path, alert: message, status: :see_other
74
+ else
75
+ render plain: message, status: status
76
+ end
77
+ end
78
+
79
+ def should_redirect_on_error?
80
+ return true if ActiveRecord::Undo.config.error_handling == :redirect
81
+ return false if ActiveRecord::Undo.config.error_handling == :render
82
+
83
+ safe_redirect_path(params[:redirect_to]).present? || safe_redirect_path(request.referer).present?
84
+ end
85
+
86
+ def render_turbo_stream_error(message, status)
87
+ render inline: turbo_stream_template('alert', message),
88
+ content_type: 'text/vnd.turbo-stream.html',
89
+ status: status
90
+ end
91
+
92
+ def turbo_stream_template(type, message)
93
+ escaped = ERB::Util.html_escape(message)
94
+ '<turbo-stream action="append" target="notifications"><template>' \
95
+ "<div class=\"undo-notification undo-#{type}\">#{escaped}</div>" \
96
+ '</template></turbo-stream>'
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class LogsController < ApplicationController
6
+ module AsyncHandler
7
+ private
8
+
9
+ def async_request?
10
+ params[:async].to_s == 'true' || request.headers['X-Undo-Async'].to_s == 'true'
11
+ end
12
+
13
+ def execute_async_restore_action
14
+ tenant = resolve_current_tenant
15
+ with_tenant_context(tenant) do
16
+ @undo_log.restore_later!(whodunnit: resolve_whodunnit_user)
17
+ end
18
+ end
19
+
20
+ def respond_with_async_restore_scheduled
21
+ notice = 'Restoration has been scheduled and is processing.'
22
+ respond_to do |format|
23
+ format.html { redirect_to determine_redirect_path, notice: notice, status: :see_other }
24
+ format.turbo_stream { render_turbo_stream_notice(notice, :accepted) }
25
+ format.json { render json: { success: true, status: 'pending', log_id: @undo_log.id }, status: :accepted }
26
+ end
27
+ end
28
+
29
+ def render_turbo_stream_notice(notice, status)
30
+ render inline: turbo_stream_template('notice', notice),
31
+ content_type: 'text/vnd.turbo-stream.html',
32
+ status: status
33
+ end
34
+
35
+ def log_status_payload
36
+ {
37
+ id: @undo_log.id,
38
+ action: @undo_log.action,
39
+ status: @undo_log.status,
40
+ soft_delete_status: @undo_log.soft_delete_status,
41
+ restore_status: @undo_log.restore_status,
42
+ error_message: @undo_log.error_message,
43
+ completed: @undo_log.completed?,
44
+ failed: @undo_log.failed?
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logs_controller/async_handler'
4
+
5
+ module ActiveRecord
6
+ module Undo
7
+ class LogsController < ApplicationController
8
+ include AsyncHandler
9
+
10
+ before_action :set_undo_log
11
+ before_action :check_not_found
12
+ before_action :check_expired
13
+ before_action :check_tenant_access
14
+
15
+ def restore
16
+ return unless @undo_log
17
+
18
+ if async_request?
19
+ execute_async_restore_action
20
+ respond_with_async_restore_scheduled
21
+ else
22
+ count = @undo_log.undo_log_items.count
23
+ execute_restore_action
24
+ respond_with_restored_log(count)
25
+ end
26
+ end
27
+
28
+ def status
29
+ return unless @undo_log
30
+
31
+ respond_to do |format|
32
+ format.json { render json: log_status_payload, status: :ok }
33
+ format.html { render plain: @undo_log.status, status: :ok }
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def set_undo_log
40
+ @undo_log = find_undo_log
41
+ end
42
+
43
+ def check_not_found
44
+ handle_not_found if @undo_log.nil?
45
+ end
46
+
47
+ def check_expired
48
+ handle_expired if @undo_log&.expired?
49
+ end
50
+
51
+ def check_tenant_access
52
+ tenant = resolve_current_tenant
53
+ with_tenant_context(tenant) do
54
+ ActiveRecord::Undo.verify_configured_context!
55
+ return unless @undo_log && (@undo_log.tenant_id.present? || @undo_log.tenant_type.present?)
56
+
57
+ @undo_log.send(:verify_tenant_match!, @undo_log)
58
+ end
59
+ end
60
+
61
+ def find_undo_log
62
+ if params[:token].present?
63
+ ActiveRecord::Undo::UndoLog.find_by_signed_token(params[:token])
64
+ elsif params[:id].present?
65
+ find_by_id_or_token(params[:id])
66
+ end
67
+ end
68
+
69
+ def find_by_id_or_token(identifier)
70
+ str = identifier.to_s
71
+ return nil if str.blank?
72
+
73
+ if str.match?(/\A\d+\z/) || uuid_identifier?(str)
74
+ ActiveRecord::Undo::UndoLog.find_by(id: identifier)
75
+ else
76
+ ActiveRecord::Undo::UndoLog.find_by_signed_token(str)
77
+ end
78
+ end
79
+
80
+ def uuid_identifier?(str)
81
+ str.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
82
+ end
83
+
84
+ def execute_restore_action
85
+ tenant = resolve_current_tenant
86
+ with_tenant_context(tenant) do
87
+ @undo_log.restore!(whodunnit: resolve_whodunnit_user)
88
+ end
89
+ end
90
+
91
+ def with_tenant_context(tenant)
92
+ orig = ActiveRecord::Undo.current_tenant
93
+ ActiveRecord::Undo.current_tenant = tenant if tenant.present?
94
+ yield
95
+ ensure
96
+ ActiveRecord::Undo.current_tenant = orig
97
+ end
98
+
99
+ def respond_with_restored_log(count)
100
+ respond_to do |format|
101
+ format.html do
102
+ redirect_to determine_redirect_path, notice: 'Record successfully restored.', status: :see_other
103
+ end
104
+ format.turbo_stream { render_turbo_stream_success }
105
+ format.json { render json: { success: true, restored_items_count: count }, status: :ok }
106
+ end
107
+ end
108
+
109
+ def render_turbo_stream_success
110
+ render inline: turbo_stream_template('notice', 'Record successfully restored.'),
111
+ content_type: 'text/vnd.turbo-stream.html',
112
+ status: :ok
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class RestoresController < LogsController
6
+ def create
7
+ restore
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class RestoreJob < ActiveRecord::Undo.base_job_class
6
+ queue_as :default
7
+
8
+ def perform(undo_log_id, whodunnit: nil)
9
+ undo_log = find_undo_log(undo_log_id)
10
+ return unless undo_log
11
+
12
+ acquired = lock_and_start_processing!(undo_log)
13
+ return unless acquired
14
+
15
+ execute_restore_batches(undo_log, whodunnit)
16
+ rescue StandardError => e
17
+ handle_failure(undo_log, e)
18
+ raise e
19
+ end
20
+
21
+ private
22
+
23
+ def find_undo_log(undo_log_id)
24
+ ActiveRecord::Undo::UndoLog.find_by(id: undo_log_id)
25
+ end
26
+
27
+ def lock_and_start_processing!(undo_log)
28
+ undo_log.with_lock do
29
+ return false if undo_log.processing?
30
+ return false if undo_log.completed?
31
+
32
+ undo_log.update!(status: :processing)
33
+ true
34
+ end
35
+ end
36
+
37
+ def execute_restore_batches(undo_log, whodunnit)
38
+ actor = undo_log.send(:resolve_whodunnit, whodunnit)
39
+ undo_log.send(:with_whodunnit_context, actor) do
40
+ with_tenant_context(undo_log) do
41
+ restore_all_items_in_batches(undo_log)
42
+ cleanup_log!(undo_log)
43
+ end
44
+ end
45
+ end
46
+
47
+ def with_tenant_context(undo_log)
48
+ ten = undo_log.tenant || undo_log.tenant_id
49
+ orig = ActiveRecord::Undo.current_tenant
50
+ ActiveRecord::Undo.current_tenant = ten if ten.present?
51
+ yield
52
+ ensure
53
+ ActiveRecord::Undo.current_tenant = orig
54
+ end
55
+
56
+ def restore_all_items_in_batches(undo_log)
57
+ undo_log.undo_log_items.find_each(batch_size: 1000, order: :desc, &:restore_item!)
58
+ end
59
+
60
+ def cleanup_log!(undo_log)
61
+ undo_log.undo_log_items.in_batches(of: 1000).delete_all
62
+ undo_log.destroy!
63
+ end
64
+
65
+ def handle_failure(undo_log, exception)
66
+ return unless undo_log && ActiveRecord::Undo::UndoLog.exists?(undo_log.id)
67
+
68
+ undo_log.update!(status: :failed, error_message: exception.message)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class SoftDeleteJob < ActiveRecord::Undo.base_job_class
6
+ queue_as :default
7
+
8
+ def perform(record_class_name, record_id, undo_log_id, whodunnit: nil, tenant: nil)
9
+ undo_log = find_undo_log(undo_log_id)
10
+ return unless undo_log
11
+
12
+ acquired = lock_and_start_processing!(undo_log)
13
+ return unless acquired
14
+
15
+ execute_soft_delete_cascade(record_class_name, record_id, undo_log, whodunnit, tenant)
16
+ rescue StandardError => e
17
+ handle_failure(undo_log, e)
18
+ raise e
19
+ end
20
+
21
+ private
22
+
23
+ def find_undo_log(undo_log_id)
24
+ ActiveRecord::Undo::UndoLog.find_by(id: undo_log_id)
25
+ end
26
+
27
+ def lock_and_start_processing!(undo_log)
28
+ undo_log.with_lock do
29
+ return false if undo_log.processing?
30
+ return false if undo_log.completed?
31
+
32
+ undo_log.update!(status: :processing)
33
+ true
34
+ end
35
+ end
36
+
37
+ def execute_soft_delete_cascade(class_name, record_id, undo_log, whodunnit, tenant)
38
+ record = class_name.constantize.unscoped.find(record_id)
39
+ timestamp = record_timestamp(record, undo_log)
40
+
41
+ run_cascade_with_context(record, timestamp, undo_log, whodunnit, tenant)
42
+ undo_log.update!(status: :completed)
43
+ end
44
+
45
+ def record_timestamp(record, undo_log)
46
+ col = record.class.respond_to?(:undoable_column) ? record.class.undoable_column : :deleted_at
47
+ record.public_send(col) || undo_log.created_at || Time.current
48
+ end
49
+
50
+ def run_cascade_with_context(record, timestamp, undo_log, whodunnit, tenant)
51
+ with_execution_context(record, whodunnit, tenant) do
52
+ CascadeHandler.new(record).soft_delete_associations_batched!(timestamp, undo_log)
53
+ end
54
+ end
55
+
56
+ def with_execution_context(record, whodunnit, tenant)
57
+ actor = whodunnit || record.send(:resolve_whodunnit, nil)
58
+ ten = tenant || record.send(:resolve_tenant, nil)
59
+ record.send(:with_whodunnit_context, actor) do
60
+ orig = ActiveRecord::Undo.current_tenant
61
+ ActiveRecord::Undo.current_tenant = ten if ten.present?
62
+ yield
63
+ ensure
64
+ ActiveRecord::Undo.current_tenant = orig
65
+ end
66
+ end
67
+
68
+ def handle_failure(undo_log, exception)
69
+ return unless undo_log && ActiveRecord::Undo::UndoLog.exists?(undo_log.id)
70
+
71
+ undo_log.update!(status: :failed, error_message: exception.message)
72
+ end
73
+ end
74
+ end
75
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveRecord::Undo::Engine.routes.draw do
4
+ resources :logs, only: [] do
5
+ member do
6
+ post :restore
7
+ get :status
8
+ end
9
+ end
10
+
11
+ post '/restore/:token', to: 'restores#create', as: :signed_restore
12
+ get '/status/:token', to: 'restores#status', as: :signed_status
13
+ end
@@ -0,0 +1,15 @@
1
+ # db/migrate/20260920000000_add_status_and_error_message_to_undo_logs.rb
2
+ # frozen_string_literal: true
3
+
4
+ class AddStatusAndErrorMessageToUndoLogs < ActiveRecord::Migration[7.0]
5
+ def change
6
+ change_table :undo_logs, bulk: true do |t|
7
+ t.integer :status, default: 2, null: false unless column_exists?(:undo_logs, :status)
8
+ t.integer :action, default: 0, null: false unless column_exists?(:undo_logs, :action)
9
+ t.text :error_message unless column_exists?(:undo_logs, :error_message)
10
+ end
11
+
12
+ add_index :undo_logs, :status unless index_exists?(:undo_logs, :status)
13
+ add_index :undo_logs, :action unless index_exists?(:undo_logs, :action)
14
+ end
15
+ end
@@ -20,8 +20,74 @@ module ActiveRecord
20
20
  undo_log.undo_log_items.build(item: @record)
21
21
  end
22
22
 
23
+ def soft_delete_associations_batched!(timestamp, undo_log, batch_size: 1000)
24
+ associations_to_cascade.each do |reflection|
25
+ cascade_association_batched!(reflection, timestamp, undo_log, batch_size)
26
+ end
27
+ end
28
+
23
29
  private
24
30
 
31
+ def cascade_association_batched!(reflection, timestamp, undo_log, batch_size)
32
+ target = @record.public_send(reflection.name)
33
+ return if target.nil?
34
+
35
+ if reflection.collection? || target.is_a?(ActiveRecord::Relation)
36
+ cascade_collection_batched!(target, reflection, timestamp, undo_log, batch_size)
37
+ else
38
+ cascade_record_batched!(target, reflection, timestamp, undo_log, batch_size)
39
+ end
40
+ end
41
+
42
+ def cascade_collection_batched!(target, reflection, timestamp, undo_log, batch_size)
43
+ each_collection_batch(target, batch_size) do |batch|
44
+ batch.each do |associated|
45
+ cascade_record_batched!(associated, reflection, timestamp, undo_log, batch_size)
46
+ end
47
+ end
48
+ end
49
+
50
+ def each_collection_batch(target, batch_size, &)
51
+ if target.respond_to?(:in_batches)
52
+ target.in_batches(of: batch_size, &)
53
+ else
54
+ target.each_slice(batch_size, &)
55
+ end
56
+ end
57
+
58
+ def cascade_record_batched!(associated, reflection, timestamp, undo_log, batch_size)
59
+ return if skip_cascade?(associated)
60
+
61
+ if undoable_record?(associated)
62
+ process_undoable_child!(associated, timestamp, undo_log, batch_size)
63
+ elsif reflection.options[:dependent] == :destroy
64
+ associated.destroy!
65
+ end
66
+ end
67
+
68
+ def skip_cascade?(associated)
69
+ return true if associated.nil?
70
+ return true if associated.respond_to?(:soft_deleted?) && associated.soft_deleted?
71
+
72
+ if associated.respond_to?(:soft_deleted?) && associated.persisted?
73
+ associated.reload
74
+ return true if associated.soft_deleted?
75
+ end
76
+
77
+ false
78
+ end
79
+
80
+ def undoable_record?(associated)
81
+ associated.respond_to?(:acts_as_undoable) || associated.class.respond_to?(:undoable_column)
82
+ end
83
+
84
+ def process_undoable_child!(associated, timestamp, undo_log, batch_size)
85
+ self.class.new(associated).soft_delete_associations_batched!(timestamp, undo_log, batch_size: batch_size)
86
+ column_name = associated.class.respond_to?(:undoable_column) ? associated.class.undoable_column : :deleted_at
87
+ associated.update_columns(column_name => timestamp, updated_at: timestamp)
88
+ ActiveRecord::Undo::UndoLogItem.create!(undo_log_id: undo_log.id, item: associated)
89
+ end
90
+
25
91
  def cascade_to_associations!(timestamp, undo_log)
26
92
  associations_to_cascade.each do |reflection|
27
93
  associated_records_for(reflection).each do |associated|
@@ -3,12 +3,20 @@
3
3
  module ActiveRecord
4
4
  module Undo
5
5
  class Configuration
6
- attr_accessor :retention_period, :current_user_method, :current_tenant_method
6
+ attr_accessor :retention_period, :current_user_method, :current_tenant_method,
7
+ :base_controller, :base_job, :default_redirect_path, :token_secret_key,
8
+ :token_expires_in, :error_handling
7
9
 
8
10
  def initialize
9
11
  @retention_period = 30.days
10
12
  @current_user_method = nil
11
13
  @current_tenant_method = nil
14
+ @base_controller = '::ApplicationController'
15
+ @base_job = '::ActiveJob::Base'
16
+ @default_redirect_path = ->(main_app) { main_app.respond_to?(:root_path) ? main_app.root_path : '/' }
17
+ @token_secret_key = nil
18
+ @token_expires_in = 24.hours
19
+ @error_handling = :auto
12
20
  end
13
21
  end
14
22
  end
@@ -1,6 +1,7 @@
1
- # lib/active_record/undo/engine.rb
2
1
  # frozen_string_literal: true
3
2
 
3
+ require_relative 'view_helpers'
4
+
4
5
  module ActiveRecord
5
6
  module Undo
6
7
  class Engine < ::Rails::Engine
@@ -15,6 +16,18 @@ module ActiveRecord
15
16
  end
16
17
  end
17
18
 
19
+ initializer 'active_record_undo.view_helpers' do
20
+ ActiveSupport.on_load(:action_view) do
21
+ include ActiveRecord::Undo::ViewHelpers
22
+ end
23
+ end
24
+
25
+ initializer 'active_record_undo.turbo_stream_mime_type' do
26
+ if defined?(Mime::Type) && (!defined?(Mime[:turbo_stream]) || Mime[:turbo_stream].nil?)
27
+ Mime::Type.register 'text/vnd.turbo-stream.html', :turbo_stream
28
+ end
29
+ end
30
+
18
31
  rake_tasks do
19
32
  path = root.join('lib', 'tasks', 'active_record_undo_tasks.rake')
20
33
  load path if File.exist?(path)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ module ModelExtension
6
+ module AsyncMethods
7
+ def soft_delete_status
8
+ return nil unless soft_deleted?
9
+ return 'completed' unless undo_log
10
+
11
+ undo_log.soft_delete_status
12
+ end
13
+
14
+ def restore_status
15
+ return nil unless undo_log
16
+
17
+ undo_log.restore_status
18
+ end
19
+
20
+ def soft_delete_later!(whodunnit: nil, tenant: nil, queue: nil)
21
+ ensure_undoable_column_exists!
22
+ return false if soft_deleted?
23
+
24
+ ActiveRecord::Undo.verify_configured_context!
25
+ attrs = build_undo_log_attributes(whodunnit, tenant)
26
+ undo_log = execute_initial_soft_delete_later!(attrs)
27
+ enqueue_soft_delete_job!(undo_log, whodunnit, tenant, queue)
28
+ undo_log
29
+ end
30
+
31
+ def restore_later!(whodunnit: nil, queue: nil)
32
+ ensure_undoable_column_exists!
33
+ return false unless soft_deleted?
34
+
35
+ ActiveRecord::Undo.verify_configured_context!
36
+ log = undo_log
37
+ raise ActiveRecord::Undo::Error, 'No undo log found to restore.' unless log
38
+
39
+ log.restore_later!(whodunnit: whodunnit, queue: queue)
40
+ log
41
+ end
42
+
43
+ private
44
+
45
+ def execute_initial_soft_delete_later!(attrs)
46
+ transaction do
47
+ timestamp = Time.current
48
+ undo_log = ActiveRecord::Undo::UndoLog.create!(attrs.merge(action: :soft_delete, status: :pending))
49
+ update_soft_delete_timestamps!(timestamp)
50
+ ActiveRecord::Undo::UndoLogItem.create!(undo_log_id: undo_log.id, item: self)
51
+ undo_log
52
+ end
53
+ end
54
+
55
+ def update_soft_delete_timestamps!(timestamp)
56
+ column_name = self.class.undoable_column
57
+ update_columns(column_name => timestamp, updated_at: timestamp)
58
+ end
59
+
60
+ def enqueue_soft_delete_job!(undo_log, whodunnit, tenant, queue)
61
+ job_class = ActiveRecord::Undo::SoftDeleteJob
62
+ job = queue.present? ? job_class.set(queue: queue) : job_class
63
+ job.perform_later(self.class.name, id, undo_log.id, whodunnit: whodunnit, tenant: tenant)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -40,6 +40,14 @@ module ActiveRecord
40
40
  attrs[:tenant_id] = ten
41
41
  end
42
42
  end
43
+
44
+ def with_whodunnit_context(actor)
45
+ orig = ActiveRecord::Undo.whodunnit
46
+ ActiveRecord::Undo.whodunnit = actor
47
+ yield
48
+ ensure
49
+ ActiveRecord::Undo.whodunnit = orig
50
+ end
43
51
  end
44
52
  end
45
53
  end
@@ -8,7 +8,7 @@ module ActiveRecord
8
8
 
9
9
  def verify_tenant_match!(undo_log)
10
10
  ctx = current_tenant_context
11
- raise_tenant_mismatch!(undo_log, 'nil') if ctx.nil?
11
+ raise_tenant_mismatch!(undo_log, 'nil') if ctx.blank?
12
12
 
13
13
  return if tenant_matches?(undo_log, ctx)
14
14