dscf-core 0.2.5 → 0.2.7

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.
@@ -78,25 +78,30 @@ module Dscf
78
78
  status: :bad_request
79
79
  )
80
80
  end
81
- perform_review_action(**action_config.slice(:status, :require_feedback, :after, :update_model))
81
+ perform_review_action(**action_config.slice(:status, :require_feedback, :after, :update_model, :from))
82
82
  end
83
83
  end
84
84
 
85
85
  def default_actions
86
86
  {
87
+ submit: {status: "pending", require_submission_ready: true},
87
88
  approve: {status: "approved"},
88
89
  reject: {status: "rejected", require_feedback: true},
89
90
  request_modification: {status: "modify", require_feedback: true},
90
- resubmit: {status: "pending", update_model: true}
91
+ resubmit: {status: "pending", update_model: true, from: "modify"}
91
92
  }
92
93
  end
93
94
 
94
95
  def default_context_config
95
96
  {
96
97
  default: {
97
- statuses: %w[pending approved rejected modify],
98
- initial_status: "pending",
99
- transitions: {"pending" => %w[approved rejected modify], "modify" => ["pending"]},
98
+ statuses: %w[draft pending approved rejected modify],
99
+ initial_status: "draft",
100
+ transitions: {
101
+ "draft" => ["pending"],
102
+ "pending" => %w[approved rejected modify],
103
+ "modify" => ["pending"]
104
+ },
100
105
  actions: default_actions
101
106
  }
102
107
  }
@@ -104,10 +109,22 @@ module Dscf
104
109
 
105
110
  def validate_action_config(actions, statuses, context_name)
106
111
  actions.each do |action_name, opts|
107
- next if statuses.include?(opts[:status])
112
+ unless statuses.include?(opts[:status])
113
+ raise ArgumentError, <<~MSG
114
+ Action '#{action_name}' in context '#{context_name}' maps to invalid status '#{opts[:status]}'. Must be one of: #{statuses.join(', ')}
115
+ MSG
116
+ end
117
+
118
+ # Validate 'from' constraints if present
119
+ next unless opts[:from].present?
120
+
121
+ from_statuses = Array(opts[:from]).map(&:to_s)
122
+ invalid_statuses = from_statuses - statuses
123
+
124
+ next if invalid_statuses.empty?
108
125
 
109
126
  raise ArgumentError, <<~MSG
110
- Action '#{action_name}' in context '#{context_name}' maps to invalid status '#{opts[:status]}'. Must be one of: #{statuses.join(', ')}
127
+ Action '#{action_name}' in context '#{context_name}' has invalid 'from' statuses: #{invalid_statuses.join(', ')}. Must be one of: #{statuses.join(', ')}
111
128
  MSG
112
129
  end
113
130
  end
@@ -141,7 +158,8 @@ module Dscf
141
158
  self.class.review_action_names.include?(action_name.to_sym)
142
159
  end
143
160
 
144
- def perform_review_action(status:, require_feedback: false, after: nil, update_model: false)
161
+ def perform_review_action(status:, require_feedback: false, require_submission_ready: false, after: nil, update_model: false,
162
+ from: nil)
145
163
  begin
146
164
  context_config
147
165
  rescue ArgumentError => e
@@ -149,6 +167,28 @@ module Dscf
149
167
  end
150
168
 
151
169
  current_review = reviewable_resource.current_review_for(current_review_context)
170
+ current_status = current_review&.status || context_config[:initial_status]
171
+
172
+ # Check if action can be performed from the current status
173
+ if from.present?
174
+ allowed_from_statuses = Array(from).map(&:to_s)
175
+ unless allowed_from_statuses.include?(current_status)
176
+ error_message = "Action '#{action_name}' can only be performed from: " \
177
+ "#{allowed_from_statuses.join(', ')}. Current status is '#{current_status}'"
178
+ return render_error(
179
+ errors: [error_message],
180
+ status: :unprocessable_entity
181
+ )
182
+ end
183
+ end
184
+
185
+ # Check if submission is ready (for submit action)
186
+ if require_submission_ready && !submission_ready?(reviewable_resource)
187
+ return render_error(
188
+ errors: ["Resource must be complete and valid before submission"],
189
+ status: :unprocessable_entity
190
+ )
191
+ end
152
192
 
153
193
  feedback = if require_feedback
154
194
  feedback_data = params[:review_feedback]
@@ -159,10 +199,10 @@ module Dscf
159
199
  feedback_data
160
200
  end
161
201
 
162
- unless valid_transition?(current_review&.status, status)
202
+ unless valid_transition?(current_status, status)
163
203
  return render_error(
164
204
  errors: [
165
- "Cannot transition from '#{current_review&.status || context_config[:initial_status]}' to '#{status}'"
205
+ "Cannot transition from '#{current_status}' to '#{status}'"
166
206
  ]
167
207
  )
168
208
  end
@@ -261,6 +301,15 @@ module Dscf
261
301
  # Override for custom auth
262
302
  end
263
303
 
304
+ def submission_ready?(resource)
305
+ # Default implementation - validates the resource is ready for submission
306
+ # Override this method in your controller for custom validation logic
307
+ # Return true if ready, false otherwise
308
+ return true unless resource.respond_to?(:valid?)
309
+
310
+ resource.valid?
311
+ end
312
+
264
313
  def before_review_action(review)
265
314
  # Default implementation - can be overridden
266
315
  # Check if there's a custom hook method defined
@@ -9,7 +9,7 @@ module Dscf
9
9
  business = @clazz.new(model_params)
10
10
  business.user = current_user
11
11
  business.reviews.build(
12
- status: "pending",
12
+ status: "draft",
13
13
  context: "default"
14
14
  )
15
15
 
@@ -25,6 +25,17 @@ module Dscf
25
25
  end
26
26
  end
27
27
 
28
+ def update
29
+ unless @obj.editable?
30
+ return render_error(
31
+ errors: ["Cannot update Business after submission. Use modification request workflow instead."],
32
+ status: :unprocessable_entity
33
+ )
34
+ end
35
+
36
+ super
37
+ end
38
+
28
39
  def my_business
29
40
  index do
30
41
  current_user.businesses
@@ -0,0 +1,308 @@
1
+ module Dscf
2
+ module Core
3
+ class AuditLoggerJob < ApplicationJob
4
+ queue_as :audit
5
+ retry_on StandardError, wait: 5.seconds, attempts: 3
6
+ discard_on ActiveRecord::RecordNotFound
7
+
8
+ def perform(record_id:, record_type:, before_main:, after_main:, before_associated:, after_associated:,
9
+ action_name:, controller_name:, actor_id: nil, actor_type: nil,
10
+ request_uuid: nil, ip_address: nil, user_agent: nil, configs: [])
11
+ # Find the auditable record
12
+ record = record_type.constantize.find_by(id: record_id)
13
+ return unless record
14
+
15
+ # Find the actor if present
16
+ actor = actor_id && actor_type ? actor_type.constantize.find_by(id: actor_id) : nil
17
+
18
+ # Calculate changes
19
+ all_changes = calculate_changes(
20
+ before_main: before_main,
21
+ after_main: after_main,
22
+ before_associated: before_associated,
23
+ after_associated: after_associated,
24
+ configs: configs
25
+ )
26
+
27
+ # Skip if no changes detected
28
+ # Check for empty structured format
29
+ return if all_changes.blank? ||
30
+ (all_changes.is_a?(Hash) &&
31
+ all_changes[:main].blank? &&
32
+ all_changes[:associated].blank?)
33
+
34
+ # Resolve action label
35
+ action_label = resolve_action_label(action_name, configs)
36
+
37
+ # Create audit log
38
+ record.record_audit!(
39
+ action_label,
40
+ audited_changes: all_changes,
41
+ metadata: {
42
+ controller: controller_name,
43
+ action: action_name,
44
+ request_uuid: request_uuid
45
+ },
46
+ actor: actor,
47
+ request_uuid: request_uuid,
48
+ ip_address: ip_address,
49
+ user_agent: user_agent
50
+ )
51
+ rescue StandardError => e
52
+ Rails.logger.error "AuditLoggerJob failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
53
+ raise
54
+ end
55
+
56
+ private
57
+
58
+ # Calculate all changes by comparing before/after snapshots
59
+ # Returns structured format: { main: { changes: {...} }, associated: { assoc_name: [...] } }
60
+ def calculate_changes(before_main:, after_main:, before_associated:, after_associated:, configs:)
61
+ structured_changes = {
62
+ main: {},
63
+ associated: {}
64
+ }
65
+
66
+ # Process each config
67
+ configs.each do |config|
68
+ # 1. Main record changes
69
+ main_changes = diff_records(before_main, after_main)
70
+ if main_changes.present?
71
+ # Apply main record filtering (supports only/except at root level)
72
+ filtered = filter_changes(main_changes, config[:only], config[:except])
73
+ if filtered.present?
74
+ structured_changes[:main][:changes] ||= {}
75
+ structured_changes[:main][:changes].merge!(filtered)
76
+ end
77
+ end
78
+
79
+ # 2. Associated record changes
80
+ # Support both old format (array) and new format (hash with per-association filters)
81
+ associations_config = normalize_associated_config(config[:associated])
82
+
83
+ associations_config.each do |assoc_name, assoc_options|
84
+ assoc_key = assoc_name.to_sym
85
+ before_assoc = before_associated[assoc_key] || []
86
+ after_assoc = after_associated[assoc_key] || []
87
+
88
+ assoc_changes = diff_associated_records(before_assoc, after_assoc, assoc_name)
89
+ next unless assoc_changes.present?
90
+
91
+ # Apply per-association filtering
92
+ assoc_only = assoc_options[:only]
93
+ assoc_except = assoc_options[:except]
94
+ filtered_assoc_changes = filter_associated_changes(assoc_changes, assoc_only, assoc_except)
95
+ next unless filtered_assoc_changes.present?
96
+
97
+ # Structure: { "reviews.123" => { action: "created", changes: {...} } }
98
+ # Convert to: { reviews: [{ id: 123, action: "created", model: "Review", changes: {...} }] }
99
+ structured_changes[:associated][assoc_name.to_s] ||= []
100
+
101
+ filtered_assoc_changes.each do |key, value|
102
+ # Extract record ID from key like "reviews.123"
103
+ record_id = key.to_s.split(".").last
104
+ model_name = after_assoc.find { |r| r[:id].to_s == record_id }&.dig(:type) ||
105
+ before_assoc.find { |r| r[:id].to_s == record_id }&.dig(:type) ||
106
+ assoc_name.to_s.singularize.camelize
107
+
108
+ structured_changes[:associated][assoc_name.to_s] << {
109
+ id: record_id.to_i,
110
+ action: value[:action] || value["action"],
111
+ model: model_name,
112
+ changes: value[:changes] || value["changes"] || {}
113
+ }
114
+ end
115
+ end
116
+ end
117
+
118
+ # Return flat hash if no changes (for compatibility)
119
+ return {} if structured_changes[:main].empty? && structured_changes[:associated].empty?
120
+
121
+ structured_changes
122
+ end
123
+
124
+ # Normalize associated config to support both array and hash formats
125
+ # Input: [:reviews, :comments] or { reviews: { only: [:status] }, comments: {} }
126
+ # Output: { reviews: { only: [...], except: [...] }, comments: { only: nil, except: nil } }
127
+ def normalize_associated_config(associated)
128
+ return {} if associated.blank?
129
+
130
+ if associated.is_a?(Array)
131
+ # Old format: [:reviews, :comments] -> convert to hash with no filters
132
+ associated.each_with_object({}) { |name, h| h[name] = {only: nil, except: nil} }
133
+ elsif associated.is_a?(Hash)
134
+ # New format: already a hash, ensure each value is a hash with only/except
135
+ associated.transform_values do |value|
136
+ if value.is_a?(Hash)
137
+ {only: value[:only], except: value[:except]}
138
+ else
139
+ {only: nil, except: nil}
140
+ end
141
+ end
142
+ else
143
+ {}
144
+ end
145
+ end
146
+
147
+ # Diff two record states (before/after)
148
+ def diff_records(before, after)
149
+ return {} if after.blank?
150
+
151
+ # For create actions, before is nil - treat all attributes as new
152
+ if before.blank?
153
+ changes = {}
154
+ after_attrs = after[:attributes] || {}
155
+ after_attrs.each do |key, new_val|
156
+ # Skip timestamps and IDs for create
157
+ next if %w[id created_at updated_at].include?(key.to_s)
158
+ # Skip nil valbues
159
+ next if new_val.nil?
160
+
161
+ changes[key] = {old: nil, new: new_val}
162
+ end
163
+ return changes
164
+ end
165
+
166
+ return {} if before[:id] != after[:id]
167
+
168
+ changes = {}
169
+ before_attrs = before[:attributes] || {}
170
+ after_attrs = after[:attributes] || {}
171
+
172
+ # Find changed attributes
173
+ all_keys = (before_attrs.keys + after_attrs.keys).uniq
174
+ all_keys.each do |key|
175
+ old_val = before_attrs[key]
176
+ new_val = after_attrs[key]
177
+
178
+ # Skip if unchanged
179
+ next if old_val == new_val
180
+
181
+ changes[key] = {old: old_val, new: new_val}
182
+ end
183
+
184
+ changes
185
+ end
186
+
187
+ # Diff associated records (handle create/update/delete)
188
+ # Automatically excludes timestamp fields (created_at, updated_at) for associated records
189
+ def diff_associated_records(before_list, after_list, assoc_name)
190
+ changes = {}
191
+
192
+ # Build ID maps
193
+ before_map = before_list.each_with_object({}) { |rec, h| h[rec[:id]] = rec if rec[:id] }
194
+ after_map = after_list.each_with_object({}) { |rec, h| h[rec[:id]] = rec if rec[:id] }
195
+
196
+ # Detect created records (in after but not in before)
197
+ after_map.each do |id, after_rec|
198
+ next if before_map[id]
199
+
200
+ # Convert attributes to change format (old: nil, new: value)
201
+ # Skip timestamp fields automatically
202
+ created_changes = {}
203
+ (after_rec[:attributes] || {}).each do |key, value|
204
+ next if %w[created_at updated_at].include?(key.to_s)
205
+
206
+ created_changes[key] = {old: nil, new: value}
207
+ end
208
+
209
+ changes["#{assoc_name}.#{id}"] = {
210
+ action: "created",
211
+ changes: created_changes
212
+ }
213
+ end
214
+
215
+ # Detect updated records (in both, but with differences)
216
+ before_map.each do |id, before_rec|
217
+ after_rec = after_map[id]
218
+ next unless after_rec
219
+
220
+ record_changes = diff_records(before_rec, after_rec)
221
+ # Skip timestamp fields from updates
222
+ record_changes.reject! { |key, _| %w[created_at updated_at].include?(key.to_s) }
223
+ next unless record_changes.present?
224
+
225
+ changes["#{assoc_name}.#{id}"] = {
226
+ action: "updated",
227
+ changes: record_changes
228
+ }
229
+ end
230
+
231
+ # Detect deleted records (in before but not in after)
232
+ before_map.each do |id, before_rec|
233
+ next if after_map[id]
234
+
235
+ # Convert attributes to change format (old: value, new: nil)
236
+ # Skip timestamp fields automatically
237
+ deleted_changes = {}
238
+ (before_rec[:attributes] || {}).each do |key, value|
239
+ next if %w[created_at updated_at].include?(key.to_s)
240
+
241
+ deleted_changes[key] = {old: value, new: nil}
242
+ end
243
+
244
+ changes["#{assoc_name}.#{id}"] = {
245
+ action: "deleted",
246
+ changes: deleted_changes
247
+ }
248
+ end
249
+
250
+ changes
251
+ end
252
+
253
+ # Filter changes based on only/except rules
254
+ def filter_changes(changes, only, except)
255
+ return changes if only.blank? && except.blank?
256
+
257
+ filtered = changes.dup
258
+ filtered = filtered.slice(*only.map(&:to_s)) if only.present?
259
+ filtered = filtered.except(*except.map(&:to_s)) if except.present?
260
+ filtered
261
+ end
262
+
263
+ # Filter associated changes
264
+ # Input: { "reviews.46" => { action: "updated", changes: {...} } }
265
+ # Output: Same structure but with filtered changes
266
+ def filter_associated_changes(assoc_changes, only, except)
267
+ return assoc_changes if only.blank? && except.blank?
268
+
269
+ filtered = {}
270
+
271
+ assoc_changes.each do |key, record_data|
272
+ # Filter attributes (for created/deleted records)
273
+ if record_data[:attributes].present?
274
+ filtered_attrs = filter_changes(record_data[:attributes], only, except)
275
+ next if filtered_attrs.blank?
276
+
277
+ filtered[key] = record_data.merge(attributes: filtered_attrs)
278
+ # Filter changes (for updated records)
279
+ elsif record_data[:changes].present?
280
+ filtered_changes = filter_changes(record_data[:changes], only, except)
281
+ next if filtered_changes.blank?
282
+
283
+ filtered[key] = record_data.merge(changes: filtered_changes)
284
+ else
285
+ filtered[key] = record_data
286
+ end
287
+ end
288
+
289
+ filtered
290
+ end
291
+
292
+ # Resolve human-readable action label
293
+ def resolve_action_label(action, configs)
294
+ config = configs.find { |c| c[:action].present? }
295
+ return action.to_s.humanize unless config
296
+
297
+ case config[:action]
298
+ when String
299
+ config[:action]
300
+ when Hash
301
+ config[:action][action.to_sym] || config[:action][action.to_s] || action.to_s.humanize
302
+ else
303
+ action.to_s.humanize
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,34 @@
1
+ module Dscf
2
+ module Core
3
+ module AuditableModel
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_many :audit_logs,
8
+ as: :auditable,
9
+ class_name: "Dscf::Core::AuditLog",
10
+ dependent: :destroy
11
+ end
12
+
13
+ def record_audit!(action_name, audited_changes: {}, metadata: {}, actor: nil, request_uuid: nil, ip_address: nil, user_agent: nil)
14
+ audit_logs.create!(
15
+ action: action_name,
16
+ audited_changes: audited_changes.presence || {},
17
+ metadata: metadata.presence || {},
18
+ actor: actor,
19
+ request_uuid: request_uuid,
20
+ ip_address: ip_address,
21
+ user_agent: user_agent
22
+ )
23
+ end
24
+
25
+ def audit_history(limit: 50)
26
+ audit_logs.order(created_at: :desc).limit(limit)
27
+ end
28
+
29
+ def last_audit
30
+ audit_logs.order(created_at: :desc).first
31
+ end
32
+ end
33
+ end
34
+ end
@@ -20,12 +20,45 @@ module Dscf
20
20
  return current_review.status if current_review
21
21
 
22
22
  # Return default initial status for zero-config
23
- "pending"
23
+ "draft"
24
24
  end
25
25
 
26
26
  def build_review_for(context = :default)
27
27
  reviews.build(context: context.to_s)
28
28
  end
29
+
30
+ # Helper methods for common status checks
31
+ def draft?(context = :default)
32
+ current_status_for(context) == "draft"
33
+ end
34
+
35
+ def pending?(context = :default)
36
+ current_status_for(context) == "pending"
37
+ end
38
+
39
+ def approved?(context = :default)
40
+ current_status_for(context) == "approved"
41
+ end
42
+
43
+ def rejected?(context = :default)
44
+ current_status_for(context) == "rejected"
45
+ end
46
+
47
+ def modification_requested?(context = :default)
48
+ current_status_for(context) == "modify"
49
+ end
50
+
51
+ # Check if resource can be edited via regular update endpoint
52
+ # Only draft records can be edited freely
53
+ # For modify status, use resubmit action with update_model: true
54
+ def editable?(context = :default)
55
+ draft?(context)
56
+ end
57
+
58
+ # Check if resource has been submitted
59
+ def submitted?(context = :default)
60
+ !draft?(context)
61
+ end
29
62
  end
30
63
  end
31
64
  end
@@ -0,0 +1,26 @@
1
+ # app/models/dscf/core/audit_log.rb
2
+ module Dscf
3
+ module Core
4
+ class AuditLog < ApplicationRecord
5
+ self.table_name = "dscf_core_audit_logs"
6
+
7
+ belongs_to :auditable, polymorphic: true
8
+ belongs_to :actor, polymorphic: true, optional: true
9
+
10
+ validates :action, presence: true
11
+
12
+ scope :for_auditable, ->(obj) { where(auditable_type: obj.class.name, auditable_id: obj.id) }
13
+ scope :by_actor, ->(actor) { where(actor_type: actor.class.name, actor_id: actor.id) }
14
+ scope :for_action, ->(name) { where(action: name.to_s) }
15
+ scope :recent, -> { order(created_at: :desc) }
16
+
17
+ def self.ransackable_attributes(_auth_object = nil)
18
+ %w[id auditable_type auditable_id action created_at updated_at]
19
+ end
20
+
21
+ def self.ransackable_associations(_auth_object = nil)
22
+ %w[auditable actor]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -22,6 +22,10 @@ module Dscf
22
22
  %w[business_type_id contact_email contact_phone created_at description id id_value name tin_number
23
23
  updated_at user_id]
24
24
  end
25
+
26
+ def self.ransackable_associations(_auth_object = nil)
27
+ %w[user business_type documents reviews]
28
+ end
25
29
  end
26
30
  end
27
31
  end
@@ -0,0 +1,40 @@
1
+ module Dscf
2
+ module Core
3
+ class AuditLogSerializer < ActiveModel::Serializer
4
+ attributes :id, :action, :audited_changes, :metadata,
5
+ :request_uuid, :ip_address, :user_agent,
6
+ :created_at, :updated_at
7
+
8
+ attribute :actor do
9
+ if object.actor
10
+ {
11
+ id: object.actor.id,
12
+ type: object.actor_type,
13
+ name: self.class.actor_display_name(object.actor)
14
+ }
15
+ end
16
+ end
17
+
18
+ attribute :auditable do
19
+ {
20
+ id: object.auditable_id,
21
+ type: object.auditable_type
22
+ }
23
+ end
24
+
25
+ def self.actor_display_name(actor)
26
+ return nil unless actor
27
+
28
+ if actor.respond_to?(:full_name) && actor.full_name.present?
29
+ actor.full_name
30
+ elsif actor.respond_to?(:name) && actor.name.present?
31
+ actor.name
32
+ elsif actor.respond_to?(:email)
33
+ actor.email
34
+ else
35
+ "#{actor.class.name.demodulize} ##{actor.id}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -40,6 +40,7 @@ en:
40
40
  show: "Business details retrieved successfully"
41
41
  create: "Business created successfully"
42
42
  update: "Business updated successfully"
43
+ submit: "Business submitted successfully"
43
44
  approve: "Business approved successfully"
44
45
  reject: "Business rejected successfully"
45
46
  request_modification: "Business modification requested successfully"
@@ -50,6 +51,7 @@ en:
50
51
  show: "Business not found"
51
52
  create: "Failed to create business"
52
53
  update: "Failed to update business"
54
+ submit: "Failed to submit business"
53
55
  approve: "Failed to approve business"
54
56
  reject: "Failed to reject business"
55
57
  request_modification: "Failed to request modification for business"
data/config/routes.rb CHANGED
@@ -4,6 +4,7 @@ Dscf::Core::Engine.routes.draw do
4
4
  get "my_business"
5
5
  end
6
6
  member do
7
+ patch "submit"
7
8
  patch "approve"
8
9
  patch "reject"
9
10
  patch "request_modification"
@@ -3,7 +3,7 @@ class CreateDscfCoreReviews < ActiveRecord::Migration[8.0]
3
3
  create_table :dscf_core_reviews do |t|
4
4
  t.references :reviewable, polymorphic: true, null: false
5
5
  t.string :context
6
- t.string :status, default: "pending"
6
+ t.string :status, default: "draft"
7
7
  t.jsonb :feedback
8
8
  t.references :reviewed_by, polymorphic: true
9
9
  t.datetime :reviewed_at
@@ -0,0 +1,25 @@
1
+ class CreateDscfCoreAuditLogs < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :dscf_core_audit_logs do |t|
4
+ t.references :auditable, polymorphic: true, null: false
5
+
6
+ t.string :action, null: false
7
+ t.jsonb :audited_changes, null: false, default: {}
8
+ t.jsonb :metadata, default: {}
9
+
10
+ t.references :actor, polymorphic: true
11
+
12
+ t.string :request_uuid
13
+ t.string :ip_address
14
+ t.string :user_agent
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :dscf_core_audit_logs, %i[auditable_type auditable_id], name: "index_audit_logs_on_auditable"
20
+ add_index :dscf_core_audit_logs, %i[actor_type actor_id], name: "index_audit_logs_on_actor"
21
+ add_index :dscf_core_audit_logs, :request_uuid
22
+ add_index :dscf_core_audit_logs, :created_at
23
+ add_index :dscf_core_audit_logs, :action
24
+ end
25
+ end