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 +4 -4
- data/app/controllers/concerns/dscf/core/auditable_controller.rb +597 -0
- data/app/controllers/concerns/dscf/core/common.rb +5 -0
- data/app/controllers/concerns/dscf/core/reviewable_controller.rb +59 -10
- data/app/controllers/dscf/core/businesses_controller.rb +12 -1
- data/app/jobs/dscf/core/audit_logger_job.rb +308 -0
- data/app/models/concerns/dscf/core/auditable_model.rb +34 -0
- data/app/models/concerns/dscf/core/reviewable_model.rb +34 -1
- data/app/models/dscf/core/audit_log.rb +26 -0
- data/app/models/dscf/core/business.rb +4 -0
- data/app/serializers/dscf/core/audit_log_serializer.rb +40 -0
- data/config/locales/en.yml +2 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20250926102025_create_dscf_core_reviews.rb +1 -1
- data/db/migrate/20251023000000_create_dscf_core_audit_logs.rb +25 -0
- data/lib/dscf/core/version.rb +1 -1
- data/spec/factories/auditable_test_model.rb +11 -0
- data/spec/factories/dscf/core/audit_logs.rb +12 -0
- metadata +10 -2
|
@@ -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: "
|
|
99
|
-
transitions: {
|
|
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
|
-
|
|
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}'
|
|
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?(
|
|
202
|
+
unless valid_transition?(current_status, status)
|
|
163
203
|
return render_error(
|
|
164
204
|
errors: [
|
|
165
|
-
"Cannot transition from '#{
|
|
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: "
|
|
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
|
-
"
|
|
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
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
@@ -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: "
|
|
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
|