active_record-undo 0.2.0 → 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.
@@ -4,6 +4,7 @@
4
4
  require_relative 'cascade_handler'
5
5
  require_relative 'model_extension/attribution_helper'
6
6
  require_relative 'model_extension/tenant_verification'
7
+ require_relative 'model_extension/async_methods'
7
8
 
8
9
  module ActiveRecord
9
10
  module Undo
@@ -53,6 +54,8 @@ module ActiveRecord
53
54
  module InstanceMethods
54
55
  include AttributionHelper
55
56
  include TenantVerification
57
+ include AsyncMethods
58
+ include StatusPredicates
56
59
 
57
60
  def soft_deleted?
58
61
  public_send(self.class.undoable_column).present?
@@ -71,10 +74,10 @@ module ActiveRecord
71
74
  def undoable?
72
75
  return false unless persisted? && soft_deleted?
73
76
 
74
- ActiveRecord::Undo::UndoLogItem.joins(:undo_log).exists?(
75
- item_type: self.class.name,
76
- item_id: id
77
- )
77
+ log = undo_log
78
+ return false unless log
79
+
80
+ log.respond_to?(:completed?) ? log.completed? : true
78
81
  end
79
82
 
80
83
  def undo_log
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ module StatusPredicates
6
+ def soft_delete_pending?
7
+ soft_delete_status == 'pending'
8
+ end
9
+
10
+ def soft_delete_processing?
11
+ soft_delete_status == 'processing'
12
+ end
13
+
14
+ def soft_delete_completed?
15
+ soft_delete_status == 'completed'
16
+ end
17
+
18
+ def soft_delete_failed?
19
+ soft_delete_status == 'failed'
20
+ end
21
+
22
+ def restore_pending?
23
+ restore_status == 'pending'
24
+ end
25
+
26
+ def restore_processing?
27
+ restore_status == 'processing'
28
+ end
29
+
30
+ def restore_completed?
31
+ restore_status == 'completed'
32
+ end
33
+
34
+ def restore_failed?
35
+ restore_status == 'failed'
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/message_verifier'
4
+
5
+ module ActiveRecord
6
+ module Undo
7
+ class UndoLog < ActiveRecord::Base
8
+ module TokenVerifier
9
+ extend ActiveSupport::Concern
10
+
11
+ class_methods do
12
+ def find_by_signed_token(token, purpose: :restore)
13
+ return nil unless token.is_a?(String) && token.present?
14
+
15
+ verified_id = verify_signed_token(token, purpose: purpose)
16
+ return nil unless verified_id
17
+
18
+ find_by(id: verified_id)
19
+ rescue StandardError
20
+ nil
21
+ end
22
+
23
+ def verify_signed_token(token, purpose: :restore)
24
+ return nil unless token.is_a?(String) && token.present?
25
+
26
+ token_verifier.verified(token, purpose: purpose)
27
+ rescue StandardError
28
+ nil
29
+ end
30
+
31
+ def token_verifier
32
+ if defined?(Rails.application) && Rails.application.respond_to?(:message_verifier)
33
+ Rails.application.message_verifier(:active_record_undo)
34
+ else
35
+ key = ActiveRecord::Undo.config.token_secret_key || 'active_record_undo_default_secret_key_32_bytes'
36
+ @token_verifier ||= ActiveSupport::MessageVerifier.new(key, digest: 'SHA256')
37
+ end
38
+ end
39
+ end
40
+
41
+ def signed_token(expires_in: ActiveRecord::Undo.config.token_expires_in, purpose: :restore)
42
+ self.class.token_verifier.generate(id, purpose: purpose, expires_in: expires_in)
43
+ end
44
+ alias to_signed_token signed_token
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/message_verifier'
3
+ require_relative 'undo_log/token_verifier'
4
4
 
5
5
  module ActiveRecord
6
6
  module Undo
@@ -9,12 +9,17 @@ module ActiveRecord
9
9
 
10
10
  include ModelExtension::AttributionHelper
11
11
  include ModelExtension::TenantVerification
12
+ include TokenVerifier
13
+ include StatusPredicates
12
14
 
13
15
  has_many :undo_log_items, class_name: 'ActiveRecord::Undo::UndoLogItem', dependent: :destroy
14
16
 
15
17
  belongs_to :whodunnit, polymorphic: true, optional: true
16
18
  belongs_to :tenant, polymorphic: true, optional: true
17
19
 
20
+ enum :status, { pending: 0, processing: 1, completed: 2, failed: 3 }, default: :completed
21
+ enum :action, { soft_delete: 0, restore: 1 }, default: :soft_delete
22
+
18
23
  scope :for_whodunnit, lambda { |user|
19
24
  if user.is_a?(ActiveRecord::Base)
20
25
  where(whodunnit: user)
@@ -40,50 +45,39 @@ module ActiveRecord
40
45
  end
41
46
  }
42
47
 
43
- def self.find_by_signed_token(token, purpose: :restore)
44
- return nil unless token.is_a?(String) && token.present?
45
-
46
- verified_id = verify_signed_token(token, purpose: purpose)
47
- return nil unless verified_id
48
+ def expired?
49
+ period = ActiveRecord::Undo.config.retention_period
50
+ return false unless period && created_at
48
51
 
49
- find_by(id: verified_id)
50
- rescue StandardError
51
- nil
52
+ created_at < Time.current - period
52
53
  end
53
54
 
54
- def self.verify_signed_token(token, purpose: :restore)
55
- return nil unless token.is_a?(String) && token.present?
56
-
57
- token_verifier.verified(token, purpose: purpose)
58
- rescue StandardError
59
- nil
60
- end
55
+ def soft_delete_status
56
+ return status if action == 'soft_delete' || action.nil?
61
57
 
62
- def self.token_verifier
63
- if defined?(Rails.application) && Rails.application.respond_to?(:message_verifier)
64
- Rails.application.message_verifier(:active_record_undo)
65
- else
66
- key = ActiveRecord::Undo.config.token_secret_key || 'active_record_undo_default_secret_key_32_bytes'
67
- @token_verifier ||= ActiveSupport::MessageVerifier.new(key, digest: 'SHA256')
68
- end
58
+ 'completed'
69
59
  end
70
60
 
71
- def expired?
72
- period = ActiveRecord::Undo.config.retention_period
73
- return false unless period && created_at
61
+ def restore_status
62
+ return status if action == 'restore'
74
63
 
75
- created_at < Time.current - period
64
+ nil
76
65
  end
77
66
 
78
- def signed_token(expires_in: ActiveRecord::Undo.config.token_expires_in, purpose: :restore)
79
- self.class.token_verifier.generate(id, purpose: purpose, expires_in: expires_in)
67
+ def restore_later!(whodunnit: nil, queue: nil)
68
+ verify_restore_preconditions!
69
+ with_lock do
70
+ validate_restore_status_eligibility!
71
+ update!(action: :restore, status: :pending)
72
+ end
73
+ enqueue_restore_job!(whodunnit, queue)
74
+ self
80
75
  end
81
- alias to_signed_token signed_token
82
76
 
83
77
  # Restores all records associated with this deletion batch
84
78
  def restore!(whodunnit: nil)
85
- ActiveRecord::Undo.verify_configured_context!
86
- verify_tenant_match!(self) if tenant_present?
79
+ verify_restore_preconditions!
80
+ validate_restore_status_eligibility!
87
81
 
88
82
  actor = resolve_whodunnit(whodunnit)
89
83
  with_whodunnit_context(actor) do
@@ -93,6 +87,29 @@ module ActiveRecord
93
87
 
94
88
  private
95
89
 
90
+ def verify_restore_preconditions!
91
+ ActiveRecord::Undo.verify_configured_context!
92
+ verify_tenant_match!(self) if tenant_present?
93
+ raise ActiveRecord::Undo::Error, 'Undo action has expired.' if expired?
94
+ end
95
+
96
+ def validate_restore_status_eligibility!
97
+ raise ActiveRecord::Undo::Error, 'Cannot restore: UndoLog is currently processing.' if processing?
98
+
99
+ if pending? && action == 'restore'
100
+ raise ActiveRecord::Undo::Error, 'Cannot restore: UndoLog is already scheduled for restore.'
101
+ end
102
+ return if completed? || (action == 'restore' && failed?)
103
+
104
+ raise ActiveRecord::Undo::Error, "Cannot restore: UndoLog is not completed (current status: #{status})."
105
+ end
106
+
107
+ def enqueue_restore_job!(whodunnit, queue)
108
+ job_class = ActiveRecord::Undo::RestoreJob
109
+ job = queue.present? ? job_class.set(queue: queue) : job_class
110
+ job.perform_later(id, whodunnit: whodunnit)
111
+ end
112
+
96
113
  def tenant_present?
97
114
  tenant_id.present? || tenant_type.present?
98
115
  end
@@ -3,6 +3,6 @@
3
3
 
4
4
  module ActiveRecord
5
5
  module Undo
6
- VERSION = '0.2.0'
6
+ VERSION = '0.3.0'
7
7
  end
8
8
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ module ViewHelpers
6
+ module StatusBadgeHelper
7
+ def undo_status_badge(target, action: :auto, **html_options)
8
+ return nil if target.nil?
9
+
10
+ act, status = resolve_target_action_and_status(target, action)
11
+ return nil unless status
12
+
13
+ classes = build_badge_classes(act, status, html_options.delete(:class))
14
+ label = html_options.delete(:text) || status.to_s.humanize
15
+ render_badge_tag(label, classes, html_options)
16
+ end
17
+
18
+ def soft_delete_status_badge(target, **html_options)
19
+ undo_status_badge(target, action: :soft_delete, **html_options)
20
+ end
21
+
22
+ def restore_status_badge(target, **html_options)
23
+ undo_status_badge(target, action: :restore, **html_options)
24
+ end
25
+
26
+ private
27
+
28
+ def resolve_target_action_and_status(target, action)
29
+ return nil if target.nil?
30
+
31
+ case action
32
+ when :soft_delete
33
+ ['soft_delete', extract_target_status(target, :soft_delete_status)]
34
+ when :restore
35
+ ['restore', extract_target_status(target, :restore_status)]
36
+ else
37
+ resolve_auto_action_and_status(target)
38
+ end
39
+ end
40
+
41
+ def extract_target_status(target, status_method)
42
+ if target.respond_to?(status_method)
43
+ target.public_send(status_method)
44
+ elsif target.respond_to?(:status)
45
+ target.status
46
+ end
47
+ end
48
+
49
+ def resolve_auto_action_and_status(target)
50
+ return ['restore', target.restore_status] if auto_restore_status?(target)
51
+ return ['soft_delete', target.soft_delete_status] if auto_soft_delete_status?(target)
52
+ return [log_action(target), target.status] if target.respond_to?(:status)
53
+
54
+ nil
55
+ end
56
+
57
+ def auto_restore_status?(target)
58
+ target.respond_to?(:restore_status) && target.restore_status.present?
59
+ end
60
+
61
+ def auto_soft_delete_status?(target)
62
+ target.respond_to?(:soft_delete_status) && target.soft_delete_status.present?
63
+ end
64
+
65
+ def log_action(target)
66
+ target.respond_to?(:action) ? target.action : nil
67
+ end
68
+
69
+ def build_badge_classes(action, status, custom_class)
70
+ action_class = action ? "undo-#{action.to_s.tr('_', '-')}-badge" : nil
71
+ ['undo-status-badge', action_class, "undo-status-#{status}", custom_class].compact.join(' ')
72
+ end
73
+
74
+ def render_badge_tag(label, classes, html_options)
75
+ if respond_to?(:content_tag)
76
+ content_tag(:span, label, html_options.merge(class: classes))
77
+ else
78
+ "<span class=\"#{classes}\">#{label}</span>"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'view_helpers/status_badge_helper'
4
+
3
5
  module ActiveRecord
4
6
  module Undo
5
7
  module ViewHelpers
8
+ include StatusBadgeHelper
9
+
6
10
  def undo_button_to(target, text = nil, signed: false, **html_options)
7
11
  btn_text = extract_undo_text(text, html_options)
8
12
  url = resolve_undo_path(target, signed: signed)
@@ -15,11 +15,11 @@ require_relative 'undo/engine' if defined?(Rails::Engine)
15
15
  require_relative 'undo/view_helpers' if defined?(ActionView)
16
16
  require_relative 'undo/configuration'
17
17
  require_relative 'undo/safe_redirect'
18
+ require_relative 'undo/status_predicates'
18
19
  require_relative 'undo/model_extension'
19
20
  require_relative 'undo/undo_log'
20
21
  require_relative 'undo/undo_log_item'
21
22
  require_relative 'undo/purger'
22
- require_relative 'undo/purge_job'
23
23
 
24
24
  module ActiveRecord
25
25
  module Undo
@@ -98,6 +98,17 @@ module ActiveRecord
98
98
  defined?(ActionController::Base) ? ActionController::Base : Object
99
99
  end
100
100
 
101
+ def base_job_class
102
+ configured = config.base_job
103
+ return configured if configured.is_a?(Class)
104
+
105
+ (configured.presence && configured.to_s.safe_constantize) || default_base_job
106
+ end
107
+
108
+ def default_base_job
109
+ defined?(ActiveJob::Base) ? ActiveJob::Base : Object
110
+ end
111
+
101
112
  private
102
113
 
103
114
  def eager_load_rails!
@@ -111,6 +122,18 @@ module ActiveRecord
111
122
  end
112
123
  end
113
124
 
125
+ begin
126
+ require 'active_job'
127
+ rescue LoadError
128
+ # ActiveJob is optional
129
+ end
130
+
131
+ if defined?(ActiveJob::Base)
132
+ require_relative 'undo/purge_job'
133
+ require_relative '../../app/jobs/active_record/undo/soft_delete_job'
134
+ require_relative '../../app/jobs/active_record/undo/restore_job'
135
+ end
136
+
114
137
  if defined?(ActiveSupport)
115
138
  ActiveSupport.on_load(:active_record) do
116
139
  include ActiveRecord::Undo::ModelExtension
@@ -1,6 +1,55 @@
1
1
  module ActiveRecord
2
2
  module Undo
3
3
  VERSION: String
4
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+
5
+ class Error < StandardError
6
+ end
7
+
8
+ class SecurityError < Error
9
+ end
10
+
11
+ def self.whodunnit: () -> untyped
12
+ def self.whodunnit=: (untyped) -> untyped
13
+ def self.current_tenant: () -> untyped
14
+ def self.current_tenant=: (untyped) -> untyped
15
+ def self.config: () -> Configuration
16
+ def self.configure: () { (Configuration) -> void } -> void
17
+ def self.registered_models: () -> Set[untyped]
18
+ def self.undoable_models: () -> Array[untyped]
19
+ def self.base_controller_class: () -> untyped
20
+ def self.base_job_class: () -> untyped
21
+
22
+ class Configuration
23
+ attr_accessor retention_period: untyped
24
+ attr_accessor current_user_method: untyped
25
+ attr_accessor current_tenant_method: untyped
26
+ attr_accessor base_controller: untyped
27
+ attr_accessor base_job: untyped
28
+ attr_accessor default_redirect_path: untyped
29
+ attr_accessor token_secret_key: String?
30
+ attr_accessor token_expires_in: untyped
31
+ attr_accessor error_handling: Symbol
32
+
33
+ def initialize: () -> void
34
+ end
35
+
36
+ module StatusPredicates
37
+ def soft_delete_pending?: () -> bool
38
+ def soft_delete_processing?: () -> bool
39
+ def soft_delete_completed?: () -> bool
40
+ def soft_delete_failed?: () -> bool
41
+ def restore_pending?: () -> bool
42
+ def restore_processing?: () -> bool
43
+ def restore_completed?: () -> bool
44
+ def restore_failed?: () -> bool
45
+ end
46
+
47
+ module ViewHelpers
48
+ def undo_button_to: (untyped target, ?String? text, ?signed: bool, **untyped html_options) -> untyped
49
+ def undo_link_to: (untyped target, ?String? text, ?signed: bool, **untyped html_options) -> untyped
50
+ def undo_status_badge: (untyped target, ?action: Symbol, **untyped html_options) -> untyped
51
+ def soft_delete_status_badge: (untyped target, **untyped html_options) -> untyped
52
+ def restore_status_badge: (untyped target, **untyped html_options) -> untyped
53
+ end
5
54
  end
6
55
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_record-undo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Saurabh Sharma
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activejob
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 7.0.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 7.0.0
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: activerecord
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -138,10 +152,14 @@ files:
138
152
  - Rakefile
139
153
  - app/controllers/active_record/undo/application_controller.rb
140
154
  - app/controllers/active_record/undo/logs_controller.rb
155
+ - app/controllers/active_record/undo/logs_controller/async_handler.rb
141
156
  - app/controllers/active_record/undo/restores_controller.rb
157
+ - app/jobs/active_record/undo/restore_job.rb
158
+ - app/jobs/active_record/undo/soft_delete_job.rb
142
159
  - config/routes.rb
143
160
  - db/migrate/20260808000000_create_active_record_undo_tables.rb
144
161
  - db/migrate/20260821000000_add_tenant_and_user_attribution_to_undo_logs.rb
162
+ - db/migrate/20260920000000_add_status_and_error_message_to_undo_logs.rb
145
163
  - lib/active_record/undo.rb
146
164
  - lib/active_record/undo/cascade_handler.rb
147
165
  - lib/active_record/undo/cascade_handler/association_finder.rb
@@ -149,17 +167,21 @@ files:
149
167
  - lib/active_record/undo/configuration.rb
150
168
  - lib/active_record/undo/engine.rb
151
169
  - lib/active_record/undo/model_extension.rb
170
+ - lib/active_record/undo/model_extension/async_methods.rb
152
171
  - lib/active_record/undo/model_extension/attribution_helper.rb
153
172
  - lib/active_record/undo/model_extension/tenant_verification.rb
154
173
  - lib/active_record/undo/purge_job.rb
155
174
  - lib/active_record/undo/purger.rb
156
175
  - lib/active_record/undo/purger/reflection_helper.rb
157
176
  - lib/active_record/undo/safe_redirect.rb
177
+ - lib/active_record/undo/status_predicates.rb
158
178
  - lib/active_record/undo/tasks/purge.rake
159
179
  - lib/active_record/undo/undo_log.rb
180
+ - lib/active_record/undo/undo_log/token_verifier.rb
160
181
  - lib/active_record/undo/undo_log_item.rb
161
182
  - lib/active_record/undo/version.rb
162
183
  - lib/active_record/undo/view_helpers.rb
184
+ - lib/active_record/undo/view_helpers/status_badge_helper.rb
163
185
  - lib/tasks/active_record_undo_tasks.rake
164
186
  - sig/active_record/undo.rbs
165
187
  homepage: https://github.com/saurabh-activecode/active_record-undo