dscf-core 0.2.4 → 0.2.6

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