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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76a29d2a9907fa50b70a858a3e0a01824f2f6168b54733d5bb22428552add9d2
4
- data.tar.gz: e6cc6b64f3eaa57d5b29bed770f9ddeec43d462dd2cd23fa3f4bc8cde97bb1ef
3
+ metadata.gz: 26fe3945c60e59f5abf2ccff51c02da617f1c063cf19c903c5505567ceed13ca
4
+ data.tar.gz: 57d0f65ee3a45cb0a8e5560fa90cf99ac1ec6f09ef7c161900eb3357c945ac89
5
5
  SHA512:
6
- metadata.gz: 23411e23576b4f5fb2a0a32a505b5d21025535b511e63430c1fac639e4a2e5f1417f99caf09a45d9a2c49917d4462faebacbbf17963db7b91daab7ec37cc8ad9
7
- data.tar.gz: e368003213a383cb9afcacf105c33c0d1fadd2acb1dd08a3ccc3039d28140cadb328c67cc5637758de604b9b9753bb3f5e96bb0926a75eb6a2ba890b05011e98
6
+ metadata.gz: a75424cbfffeca3f5205660320c9a6cd8a424680d76368f611feef1e578e7b55f90bcc6a0b44eec22d3d09a0c9621f0eb3fffbb149cc64965fa47a2fefea06a6
7
+ data.tar.gz: 393d2aac7b201c4e84e66fecba0e54ec510977f5b4996bd9b0371e458f3a991408e9cd0ef6f339421fca5d96b303f4a9bee304b93818ad88ac637fdcf9b3c00e
@@ -0,0 +1,597 @@
1
+ module Dscf
2
+ module Core
3
+ # Custom exception for missing auditable record
4
+ class AuditableRecordNotFoundError < StandardError; end
5
+
6
+ module AuditableController
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ class_attribute :_audit_configs, default: []
11
+ class_attribute :_explicit_auditable_model, default: nil
12
+
13
+ before_action :capture_audit_snapshot, if: :audit_needed?
14
+ after_action :enqueue_audit, if: :audit_needed?
15
+ end
16
+
17
+ class_methods do
18
+ # DSL: Define what to audit
19
+ #
20
+ # Simple format (backwards compatible):
21
+ # auditable associated: [:reviews], only: [:status], on: [:approve, :reject]
22
+ # auditable only: [:name, :email], on: [:create, :update]
23
+ #
24
+ # Advanced format with per-association filters:
25
+ # auditable on: [:create, :update],
26
+ # only: [:status, :approved_at], # filter for main record
27
+ # except: [:internal_notes], # exclude for main record
28
+ # associated: {
29
+ # reviews: { only: [:status, :approved_at] }, # filter for reviews
30
+ # comments: { except: [:private_flag] } # exclude for comments
31
+ # }
32
+ def auditable(associated: [], only: [], except: [], on: [], action: nil, transactional: true)
33
+ # Validate configuration
34
+ validate_audit_config!(associated: associated, only: only, except: except, on: on, action: action)
35
+
36
+ # Normalize associated to always be a hash internally
37
+ normalized_associated = normalize_associated_for_config(associated)
38
+
39
+ config = {
40
+ associated: normalized_associated,
41
+ only: Array(only),
42
+ except: Array(except),
43
+ on: Array(on).map(&:to_sym),
44
+ action: action,
45
+ transactional: transactional
46
+ }
47
+ self._audit_configs += [config]
48
+ end
49
+
50
+ # Normalize associated configuration to hash format
51
+ # Input: [:reviews, :comments] or { reviews: { only: [:status] }, comments: {} }
52
+ # Output: { reviews: { only: [...], except: [...] }, comments: { only: nil, except: nil } }
53
+ private def normalize_associated_for_config(associated)
54
+ return {} if associated.blank?
55
+
56
+ if associated.is_a?(Array)
57
+ # Old format: [:reviews, :comments] -> convert to hash
58
+ associated.each_with_object({}) { |name, h| h[name] = {} }
59
+ elsif associated.is_a?(Hash)
60
+ # New format: already a hash
61
+ associated
62
+ else
63
+ {}
64
+ end
65
+ end
66
+
67
+ # Validate audit configuration at definition time
68
+ def validate_audit_config!(associated:, only:, except:, on:, action:)
69
+ # Validate associated format
70
+ if associated.is_a?(Array)
71
+ Array(associated).each do |assoc|
72
+ unless assoc.is_a?(Symbol)
73
+ raise ArgumentError, "auditable associated: must be an array of symbols, got #{assoc.class} for #{assoc.inspect}"
74
+ end
75
+ end
76
+ elsif associated.is_a?(Hash)
77
+ associated.each do |assoc_name, assoc_config|
78
+ unless assoc_name.is_a?(Symbol)
79
+ raise ArgumentError, "auditable associated: hash keys must be symbols, got #{assoc_name.class}"
80
+ end
81
+
82
+ if assoc_config.present? && !assoc_config.is_a?(Hash)
83
+ raise ArgumentError, "auditable associated: hash values must be hashes with :only/:except, got #{assoc_config.class}"
84
+ end
85
+
86
+ # Validate only/except in association config
87
+ next unless assoc_config.is_a?(Hash)
88
+
89
+ assoc_only = Array(assoc_config[:only])
90
+ assoc_except = Array(assoc_config[:except])
91
+ overlap = assoc_only & assoc_except
92
+ if overlap.any?
93
+ Rails.logger.warn "⚠️ Auditable config warning: 'only' and 'except' overlap for #{assoc_name}: " \
94
+ "#{overlap.join(', ')}. 'except' will take precedence."
95
+ end
96
+ end
97
+ end
98
+
99
+ # Validate only/except don't overlap for main record
100
+ only_arr = Array(only).map(&:to_sym)
101
+ except_arr = Array(except).map(&:to_sym)
102
+ overlap = only_arr & except_arr
103
+ if overlap.any?
104
+ Rails.logger.warn "⚠️ Auditable config warning: 'only' and 'except' overlap for fields: " \
105
+ "#{overlap.join(', ')}. 'except' will take precedence."
106
+ end
107
+
108
+ # Validate action format
109
+ if action.present?
110
+ unless action.is_a?(String) || action.is_a?(Hash)
111
+ raise ArgumentError, "auditable action: must be a String or Hash, got #{action.class}"
112
+ end
113
+
114
+ if action.is_a?(Hash)
115
+ action.each do |key, value|
116
+ unless key.is_a?(Symbol) && value.is_a?(String)
117
+ raise ArgumentError, "auditable action: Hash keys must be Symbols and values must be Strings"
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ # Validate on actions
124
+ on_arr = Array(on).map(&:to_sym)
125
+ valid_actions = %i[index show create update destroy approve reject request_modification resubmit submit]
126
+ invalid_actions = on_arr - valid_actions
127
+ return unless invalid_actions.any? && !Rails.env.production?
128
+
129
+ Rails.logger.warn "⚠️ Auditable config warning: Unknown actions detected: #{invalid_actions.join(', ')}. " \
130
+ "Make sure these are valid controller actions."
131
+ end
132
+
133
+ # DSL: Explicitly set the model class (optional)
134
+ def auditable_model(klass)
135
+ self._explicit_auditable_model = klass
136
+ end
137
+
138
+ # DSL: Define action-specific record resolution (optional)
139
+ # Override auto-discovery for specific actions where needed
140
+ #
141
+ # Example:
142
+ # auditable_record_for :approve, -> { @reviewable_resource }
143
+ # auditable_record_for :reject, -> { @reviewable_resource }
144
+ #
145
+ # Or using a map:
146
+ # def auditable_record_map
147
+ # {
148
+ # approve: -> { @reviewable_resource },
149
+ # reject: -> { @reviewable_resource },
150
+ # custom_action: -> { @my_custom_var }
151
+ # }
152
+ # end
153
+ def auditable_record_for(action_name, resolver = nil, &block)
154
+ self._audit_configs ||= []
155
+ resolver_proc = block || resolver
156
+
157
+ raise ArgumentError, "auditable_record_for requires a block or lambda" unless resolver_proc
158
+
159
+ # Store in class attribute for later access
160
+ @_auditable_record_resolvers ||= {}
161
+ @_auditable_record_resolvers[action_name.to_sym] = resolver_proc
162
+ end
163
+
164
+ def auditable_record_resolvers
165
+ @_auditable_record_resolvers || {}
166
+ end
167
+ end
168
+
169
+ private
170
+
171
+ # Check if current action needs auditing
172
+ def audit_needed?
173
+ return false if _audit_configs.empty?
174
+
175
+ _audit_configs.any? { |c| c[:on].empty? || c[:on].include?(action_name.to_sym) }
176
+ end
177
+
178
+ # Step 1: Capture state BEFORE action executes
179
+ def capture_audit_snapshot
180
+ @audit_snapshot = {}
181
+
182
+ # For create actions, we'll capture after the record is created
183
+ if action_name.to_sym == :create
184
+ @audit_snapshot[:is_create] = true
185
+ @audit_snapshot[:before_main] = nil
186
+ @audit_snapshot[:before_associated] = {}
187
+ Rails.logger.debug "Audit: Create action detected, will capture state after creation"
188
+ return
189
+ end
190
+
191
+ record = resolve_auditable_record
192
+ return unless record
193
+
194
+ # Store record identification
195
+ @audit_snapshot[:record_id] = record.id
196
+ @audit_snapshot[:record_type] = record.class.name
197
+
198
+ # Capture current state of main record
199
+ @audit_snapshot[:before_main] = capture_record_state(record)
200
+
201
+ # Capture current state of associations
202
+ @audit_snapshot[:before_associated] = {}
203
+ active_configs.each do |config|
204
+ # Handle both array and hash formats for associated
205
+ assoc_names = config[:associated].is_a?(Hash) ? config[:associated].keys : config[:associated]
206
+ assoc_names.each do |assoc_name|
207
+ next unless record.respond_to?(assoc_name)
208
+
209
+ begin
210
+ assoc_records = Array(record.public_send(assoc_name)).compact
211
+ @audit_snapshot[:before_associated][assoc_name] = assoc_records.map { |r| capture_record_state(r) }
212
+ rescue StandardError => e
213
+ Rails.logger.debug "Audit: Could not capture #{assoc_name}: #{e.message}"
214
+ end
215
+ end
216
+ end
217
+
218
+ # Clear memoization to allow fresh resolution in after_action
219
+ # This handles cases where controllers reload/modify the record (e.g., Common module)
220
+ remove_instance_variable(:@resolve_auditable_record) if defined?(@resolve_auditable_record)
221
+ rescue StandardError => e
222
+ Rails.logger.warn "Audit snapshot failed: #{e.message}"
223
+ @audit_snapshot = nil
224
+ end
225
+
226
+ # Step 2: Enqueue audit job AFTER action completes
227
+ def enqueue_audit
228
+ Rails.logger.debug "Audit: enqueue_audit called for #{action_name}"
229
+
230
+ unless @audit_snapshot.present?
231
+ Rails.logger.debug "Audit: No snapshot present, skipping"
232
+ return
233
+ end
234
+
235
+ # For create actions, check early if we should skip auditing
236
+ # This prevents misleading error logs for validation failures
237
+ if @audit_snapshot[:is_create]
238
+ # Try to find the record WITHOUT triggering error logging
239
+ model_klass = _explicit_auditable_model || detect_model_class
240
+ record = find_created_record_quietly(model_klass)
241
+
242
+ if record.nil? || !record.persisted?
243
+ Rails.logger.debug "Audit: Skipping failed create (record not persisted)"
244
+ return
245
+ end
246
+
247
+ # Set record info for successful create
248
+ @audit_snapshot[:record_id] = record.id
249
+ @audit_snapshot[:record_type] = record.class.name
250
+ Rails.logger.debug "Audit: Set record info for create action"
251
+ end
252
+
253
+ # Now resolve the record (for both create and other actions)
254
+ record = resolve_auditable_record
255
+
256
+ # For non-create actions, fail fast if we can't find the record
257
+ unless record
258
+ # CRITICAL: Fail fast if we can't find the record
259
+ log_resolution_failure(detect_model_class, action_name.to_sym)
260
+ return
261
+ end
262
+
263
+ Rails.logger.debug "Audit: Found record #{record.class.name}##{record.id}"
264
+
265
+ # Capture state AFTER action
266
+ after_main = capture_record_state(record)
267
+ after_associated = {}
268
+
269
+ active_configs.each do |config|
270
+ # Handle both array and hash formats for associated
271
+ assoc_names = config[:associated].is_a?(Hash) ? config[:associated].keys : config[:associated]
272
+ assoc_names.each do |assoc_name|
273
+ next unless record.respond_to?(assoc_name)
274
+
275
+ begin
276
+ # Reload association to get fresh state
277
+ record.association(assoc_name).reload if record.association(assoc_name).loaded?
278
+ assoc_records = Array(record.public_send(assoc_name)).compact
279
+ after_associated[assoc_name] = assoc_records.map { |r| capture_record_state(r) }
280
+ Rails.logger.debug "Audit: Captured #{assoc_records.count} #{assoc_name} records"
281
+ rescue StandardError => e
282
+ Rails.logger.debug "Audit: Could not capture after state for #{assoc_name}: #{e.message}"
283
+ end
284
+ end
285
+ end
286
+
287
+ Rails.logger.debug "Audit: Enqueuing job with #{active_configs.count} configs"
288
+
289
+ # Check if any config requires transactional safety
290
+ requires_transaction = active_configs.any? { |c| c[:transactional] != false }
291
+
292
+ # Enqueue job with complete snapshot
293
+ job_args = {
294
+ record_id: @audit_snapshot[:record_id],
295
+ record_type: @audit_snapshot[:record_type],
296
+ before_main: @audit_snapshot[:before_main],
297
+ after_main: after_main,
298
+ before_associated: @audit_snapshot[:before_associated] || {},
299
+ after_associated: after_associated,
300
+ actor_id: current_actor&.id,
301
+ actor_type: current_actor&.class&.name,
302
+ action_name: action_name,
303
+ controller_name: controller_name,
304
+ request_uuid: request.uuid,
305
+ ip_address: request.remote_ip,
306
+ user_agent: request.user_agent,
307
+ configs: active_configs.map { |c| c.except(:block) }
308
+ }
309
+
310
+ if requires_transaction
311
+ # For critical actions, ensure audit is enqueued synchronously within transaction
312
+ # This prevents data commits without corresponding audit trail
313
+ begin
314
+ AuditLoggerJob.perform_later(**job_args)
315
+ Rails.logger.debug "Audit: Job enqueued successfully (transactional)"
316
+ rescue StandardError => e
317
+ # Re-raise to trigger transaction rollback
318
+ Rails.logger.error "CRITICAL: Audit enqueue failed - transaction will rollback: #{e.message}"
319
+ raise Dscf::Core::AuditableRecordNotFoundError,
320
+ "Critical Audit Failure: Could not enqueue audit job. Transaction rolled back to prevent untracked changes."
321
+ end
322
+ else
323
+ # For non-critical actions, log errors but don't block the transaction
324
+ AuditLoggerJob.perform_later(**job_args)
325
+ Rails.logger.debug "Audit: Job enqueued successfully (non-transactional)"
326
+ end
327
+ rescue Dscf::Core::AuditableRecordNotFoundError
328
+ # Re-raise audit errors to trigger rollback
329
+ raise
330
+ rescue StandardError => e
331
+ # Handle other unexpected errors based on transactional requirement
332
+ Rails.logger.error "Audit enqueue failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
333
+ requires_transaction = active_configs.any? { |c| c[:transactional] != false }
334
+ if requires_transaction
335
+ raise Dscf::Core::AuditableRecordNotFoundError,
336
+ "Critical Audit Failure: Unexpected error during audit enqueue. Transaction rolled back."
337
+ end
338
+ end
339
+
340
+ # Capture complete state of a record
341
+ def capture_record_state(record)
342
+ return nil unless record&.persisted?
343
+
344
+ {
345
+ id: record.id,
346
+ type: record.class.name,
347
+ attributes: record.attributes.dup,
348
+ timestamp: Time.current.to_f
349
+ }
350
+ rescue StandardError => e
351
+ Rails.logger.debug "Could not capture record state: #{e.message}"
352
+ nil
353
+ end
354
+
355
+ # Get active audit configs for current action
356
+ def active_configs
357
+ _audit_configs.select { |c| c[:on].empty? || c[:on].include?(action_name.to_sym) }
358
+ end
359
+
360
+ # Resolve the main record being audited
361
+ def resolve_auditable_record
362
+ return @resolve_auditable_record if defined?(@resolve_auditable_record)
363
+
364
+ @resolve_auditable_record = perform_record_resolution
365
+ end
366
+
367
+ # Perform the actual record resolution logic
368
+ def perform_record_resolution
369
+ model_klass = _explicit_auditable_model || detect_model_class
370
+ return nil unless model_klass.respond_to?(:ancestors) && model_klass.ancestors.include?(ActiveRecord::Base)
371
+
372
+ current_action = action_name.to_sym
373
+
374
+ # Priority 0: Action-specific resolver (explicit configuration via DSL)
375
+ resolver = self.class.auditable_record_resolvers[current_action]
376
+ if resolver
377
+ begin
378
+ record = instance_exec(&resolver)
379
+ if record.is_a?(model_klass)
380
+ Rails.logger.debug "Audit: Found record via action-specific resolver for :#{current_action}"
381
+ return record
382
+ elsif record.present?
383
+ Rails.logger.warn "Audit: Action-specific resolver for :#{current_action} returned " \
384
+ "#{record.class.name}, expected #{model_klass.name}"
385
+ end
386
+ rescue StandardError => e
387
+ Rails.logger.error "Audit: Action-specific resolver for :#{current_action} failed: #{e.message}"
388
+ end
389
+ end
390
+
391
+ # Priority 1: Instance method map (auditable_record_map)
392
+ if respond_to?(:auditable_record_map, true)
393
+ begin
394
+ record_map = auditable_record_map
395
+ if record_map.is_a?(Hash) && record_map[current_action]
396
+ resolver_proc = record_map[current_action]
397
+ record = instance_exec(&resolver_proc)
398
+ if record.is_a?(model_klass)
399
+ Rails.logger.debug "Audit: Found record via auditable_record_map for :#{current_action}"
400
+ return record
401
+ end
402
+ end
403
+ rescue StandardError => e
404
+ Rails.logger.warn "Audit: auditable_record_map failed for :#{current_action}: #{e.message}"
405
+ end
406
+ end
407
+
408
+ # Priority 2: Generic hook method (auditable_record)
409
+ if respond_to?(:auditable_record, true)
410
+ record = auditable_record
411
+ if record.is_a?(model_klass)
412
+ Rails.logger.debug "Audit: Found record via auditable_record hook"
413
+ return record
414
+ end
415
+ end
416
+
417
+ # Priority 3: Check ALL instance variables for objects of the model class
418
+ instance_variables.each do |ivar|
419
+ # Skip internal audit instance variables
420
+ next if ivar.to_s.start_with?("@__")
421
+
422
+ obj = instance_variable_get(ivar)
423
+ if obj.is_a?(model_klass)
424
+ Rails.logger.debug "Audit: Found record via instance variable #{ivar}"
425
+ return obj
426
+ end
427
+ end
428
+
429
+ # Priority 4: Try standard Rails @model_name pattern (e.g., @business)
430
+ ivar_name = "@#{model_klass.name.demodulize.underscore}"
431
+ if instance_variable_defined?(ivar_name)
432
+ obj = instance_variable_get(ivar_name)
433
+ if obj.is_a?(model_klass)
434
+ Rails.logger.debug "Audit: Found record via naming convention #{ivar_name}"
435
+ return obj
436
+ end
437
+ end
438
+
439
+ # Priority 5: Try finding by params[:id] (for show/update/delete actions)
440
+ if params[:id].present? && current_action != :create
441
+ begin
442
+ record = model_klass.find(params[:id])
443
+ Rails.logger.debug "Audit: Found record via params[:id]"
444
+ return record
445
+ rescue ActiveRecord::RecordNotFound, ArgumentError
446
+ # Record not found or invalid ID
447
+ end
448
+ end
449
+
450
+ # Priority 6: For create actions, check if a record was just created
451
+ if current_action == :create
452
+ # Try to find the most recently created record
453
+ # This is a fallback for controllers that don't set instance variables
454
+ recent_record = model_klass.order(created_at: :desc).first
455
+ if recent_record && recent_record.created_at > 5.seconds.ago
456
+ Rails.logger.debug "Audit: Found record via recent creation"
457
+ return recent_record
458
+ end
459
+ end
460
+
461
+ # FAIL-FAST: Log helpful error message with configuration instructions
462
+ log_resolution_failure(model_klass, current_action)
463
+ nil
464
+ rescue StandardError => e
465
+ Rails.logger.error "Audit: Failed to resolve record for #{model_klass}: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
466
+ nil
467
+ end
468
+
469
+ # Handle missing auditable record with environment-aware fail-fast behavior
470
+ #
471
+ # This method implements a critical safety feature:
472
+ # - In development/test: Raises detailed error with full diagnostic info
473
+ # - In production: Raises concise error and logs full details
474
+ #
475
+ # @param model_class [Class] The model class that couldn't be resolved
476
+ # @param action [Symbol] The controller action being audited
477
+ # @raise [Dscf::Core::AuditableRecordNotFoundError] Always raises to halt transaction
478
+ def log_resolution_failure(model_class, action)
479
+ # Safely collect instance variable information
480
+ instance_vars_info = instance_variables.filter_map do |v|
481
+ obj = instance_variable_get(v)
482
+ " - #{v} (#{obj.class.name})"
483
+ rescue StandardError
484
+ nil
485
+ end.join("\n")
486
+
487
+ # Build comprehensive diagnostic message
488
+ detailed_message = <<~ERROR
489
+ ═══════════════════════════════════════════════════════════════════════════
490
+ 🚨 AUDITABLE SYSTEM ERROR: Could not resolve record for audit logging
491
+ ═══════════════════════════════════════════════════════════════════════════
492
+
493
+ Controller: #{self.class.name}
494
+ Action: #{action}
495
+ Model: #{model_class.name}
496
+
497
+ The Auditable system tried to find the #{model_class.name} record but failed.
498
+
499
+ 💡 SOLUTION: Define an action-specific resolver in your controller:
500
+
501
+ Option 1 - Using auditable_record_map (recommended for multiple actions):
502
+ ─────────────────────────────────────────────────────────────────────────
503
+ private
504
+
505
+ def auditable_record_map
506
+ {
507
+ #{action}: -> { @your_instance_variable }
508
+ }
509
+ end
510
+
511
+ Option 2 - Using class-level DSL:
512
+ ─────────────────────────────────────────────────────────────────────────
513
+ auditable_record_for :#{action}, -> { @your_instance_variable }
514
+
515
+ Option 3 - Using a generic hook (for all actions):
516
+ ─────────────────────────────────────────────────────────────────────────
517
+ private
518
+
519
+ def auditable_record
520
+ @your_instance_variable
521
+ end
522
+
523
+ 📋 Available instance variables in controller:
524
+ #{instance_vars_info.presence || ' (none found)'}
525
+
526
+ ═══════════════════════════════════════════════════════════════════════════
527
+ ERROR
528
+
529
+ # Always log the full detailed message
530
+ Rails.logger.error(detailed_message)
531
+
532
+ # Environment-specific behavior
533
+ raise Dscf::Core::AuditableRecordNotFoundError, detailed_message unless Rails.env.production?
534
+
535
+ Rails.logger.error("[CRITICAL] Audit system failed to resolve #{model_class.name} for action :#{action} in #{self.class.name}")
536
+
537
+ # Raise concise error that will halt the transaction
538
+ raise Dscf::Core::AuditableRecordNotFoundError,
539
+ "Critical Audit Failure: Could not resolve auditable record for #{model_class.name}##{action}. " \
540
+ "This transaction has been rolled back to prevent untracked data changes. " \
541
+ "Check application logs for detailed diagnostic information."
542
+ end
543
+
544
+ # Find created record without triggering error logging
545
+ # This is used for create actions to check if record was saved before attempting full resolution
546
+ # @param model_klass [Class] The model class to search for
547
+ # @return [ActiveRecord::Base, nil] The found record or nil
548
+ def find_created_record_quietly(model_klass)
549
+ return nil unless model_klass.respond_to?(:ancestors) && model_klass.ancestors.include?(ActiveRecord::Base)
550
+
551
+ # Priority 1: Check ALL instance variables for objects of the model class
552
+ instance_variables.each do |ivar|
553
+ # Skip internal instance variables
554
+ next if ivar.to_s.start_with?("@__", "@audit")
555
+
556
+ obj = instance_variable_get(ivar)
557
+ return obj if obj.is_a?(model_klass)
558
+ end
559
+
560
+ # Priority 2: Try standard Rails @model_name pattern (e.g., @business_type)
561
+ ivar_name = "@#{model_klass.name.demodulize.underscore}"
562
+ if instance_variable_defined?(ivar_name)
563
+ obj = instance_variable_get(ivar_name)
564
+ return obj if obj.is_a?(model_klass)
565
+ end
566
+
567
+ # Priority 3: Try finding the most recently created record (fallback)
568
+ recent_record = model_klass.order(created_at: :desc).first
569
+ return recent_record if recent_record && recent_record.created_at > 5.seconds.ago
570
+
571
+ nil
572
+ rescue StandardError => e
573
+ Rails.logger.debug "Audit: Could not quietly find created record: #{e.message}"
574
+ nil
575
+ end
576
+
577
+ # Auto-detect model class from controller name
578
+ def detect_model_class
579
+ model_name = controller_name.classify
580
+ namespace = self.class.name.deconstantize
581
+
582
+ if namespace.present? && namespace != "Object"
583
+ "#{namespace}::#{model_name}".safe_constantize
584
+ else
585
+ model_name.safe_constantize
586
+ end
587
+ end
588
+
589
+ # Get current actor (user performing the action)
590
+ def current_actor
591
+ return @current_actor if defined?(@current_actor)
592
+
593
+ @current_actor = current_user if respond_to?(:current_user, true)
594
+ end
595
+ end
596
+ end
597
+ end
@@ -103,7 +103,11 @@ module Dscf
103
103
  end
104
104
 
105
105
  if obj.save
106
+ # Store in instance variable for auditing and other concerns
107
+ @obj = obj
108
+
106
109
  obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
110
+ @obj = obj # Update with reloaded version
107
111
 
108
112
  includes = serializer_includes_for_action(:create)
109
113
  options[:include] = includes if includes.present?
@@ -135,6 +139,7 @@ module Dscf
135
139
 
136
140
  if obj.update(model_params)
137
141
  obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
142
+ @obj = obj # Update with reloaded version for auditing
138
143
 
139
144
  includes = serializer_includes_for_action(:update)
140
145
  options[:include] = includes if includes.present?