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.
@@ -0,0 +1,94 @@
1
+ module Dscf
2
+ module Core
3
+ class BusinessesController < ApplicationController
4
+ include Dscf::Core::Common
5
+ include Dscf::Core::ReviewableController
6
+
7
+ def create
8
+ super do
9
+ business = @clazz.new(model_params)
10
+ business.user = current_user
11
+ business.reviews.build(
12
+ status: "draft",
13
+ context: "default"
14
+ )
15
+
16
+ if business.save && params[:business][:business_license].present?
17
+ document = business.documents.create!(
18
+ document_type: :business_license
19
+ )
20
+ document.files.attach(params[:business][:business_license])
21
+ document.save!
22
+ end
23
+
24
+ business
25
+ end
26
+ end
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
+
39
+ def my_business
40
+ index do
41
+ current_user.businesses
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # Enable authentication for this controller
48
+ def authentication_required?
49
+ true
50
+ end
51
+
52
+ def model_params
53
+ params.require(:business).permit(
54
+ :name,
55
+ :description,
56
+ :contact_email,
57
+ :contact_phone,
58
+ :tin_number,
59
+ :business_type_id,
60
+ business_license: :file
61
+ )
62
+ end
63
+
64
+ def eager_loaded_associations
65
+ [
66
+ :business_type, :user, :documents,
67
+ reviews: {reviewed_by: :user_profile}
68
+ ]
69
+ end
70
+
71
+ def allowed_order_columns
72
+ %w[id name description contact_email contact_phone tin_number created_at updated_at]
73
+ end
74
+
75
+ def default_serializer_includes
76
+ {
77
+ index: [
78
+ :business_type, :user, :documents,
79
+ reviews: {reviewed_by: :user_profile}
80
+ ],
81
+ show: [
82
+ :business_type, :user, :documents,
83
+ reviews: {reviewed_by: :user_profile}
84
+ ],
85
+ create: %i[business_type user documents reviews],
86
+ update: [
87
+ :business_type, :user, :documents,
88
+ reviews: {reviewed_by: :user_profile}
89
+ ]
90
+ }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,256 @@
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
+ filtered = filter_changes(main_changes, config[:only], config[:except])
72
+ if filtered.present?
73
+ structured_changes[:main][:changes] ||= {}
74
+ structured_changes[:main][:changes].merge!(filtered)
75
+ end
76
+ end
77
+
78
+ # 2. Associated record changes
79
+ config[:associated].each do |assoc_name|
80
+ assoc_key = assoc_name.to_sym
81
+ before_assoc = before_associated[assoc_key] || []
82
+ after_assoc = after_associated[assoc_key] || []
83
+
84
+ assoc_changes = diff_associated_records(before_assoc, after_assoc, assoc_name)
85
+ next unless assoc_changes.present?
86
+
87
+ # Apply filtering to associated changes
88
+ filtered_assoc_changes = filter_associated_changes(assoc_changes, config[:only], config[:except])
89
+ next unless filtered_assoc_changes.present?
90
+
91
+ # Structure: { "reviews.123" => { action: "created", changes: {...} } }
92
+ # Convert to: { reviews: [{ id: 123, action: "created", model: "Review", changes: {...} }] }
93
+ structured_changes[:associated][assoc_name.to_s] ||= []
94
+
95
+ filtered_assoc_changes.each do |key, value|
96
+ # Extract record ID from key like "reviews.123"
97
+ record_id = key.to_s.split(".").last
98
+ model_name = after_assoc.find { |r| r[:id].to_s == record_id }&.dig(:type) ||
99
+ before_assoc.find { |r| r[:id].to_s == record_id }&.dig(:type) ||
100
+ assoc_name.to_s.singularize.camelize
101
+
102
+ structured_changes[:associated][assoc_name.to_s] << {
103
+ id: record_id.to_i,
104
+ action: value[:action] || value["action"],
105
+ model: model_name,
106
+ changes: value[:changes] || value["changes"] || {}
107
+ }
108
+ end
109
+ end
110
+ end
111
+
112
+ # Return flat hash if no changes (for compatibility)
113
+ return {} if structured_changes[:main].empty? && structured_changes[:associated].empty?
114
+
115
+ structured_changes
116
+ end
117
+
118
+ # Diff two record states (before/after)
119
+ def diff_records(before, after)
120
+ return {} if after.blank?
121
+
122
+ # For create actions, before is nil - treat all attributes as new
123
+ if before.blank?
124
+ changes = {}
125
+ after_attrs = after[:attributes] || {}
126
+ after_attrs.each do |key, new_val|
127
+ # Skip timestamps and IDs for create
128
+ next if %w[id created_at updated_at].include?(key.to_s)
129
+ # Skip nil valbues
130
+ next if new_val.nil?
131
+
132
+ changes[key] = {old: nil, new: new_val}
133
+ end
134
+ return changes
135
+ end
136
+
137
+ return {} if before[:id] != after[:id]
138
+
139
+ changes = {}
140
+ before_attrs = before[:attributes] || {}
141
+ after_attrs = after[:attributes] || {}
142
+
143
+ # Find changed attributes
144
+ all_keys = (before_attrs.keys + after_attrs.keys).uniq
145
+ all_keys.each do |key|
146
+ old_val = before_attrs[key]
147
+ new_val = after_attrs[key]
148
+
149
+ # Skip if unchanged
150
+ next if old_val == new_val
151
+
152
+ changes[key] = {old: old_val, new: new_val}
153
+ end
154
+
155
+ changes
156
+ end
157
+
158
+ # Diff associated records (handle create/update/delete)
159
+ def diff_associated_records(before_list, after_list, assoc_name)
160
+ changes = {}
161
+
162
+ # Build ID maps
163
+ before_map = before_list.each_with_object({}) { |rec, h| h[rec[:id]] = rec if rec[:id] }
164
+ after_map = after_list.each_with_object({}) { |rec, h| h[rec[:id]] = rec if rec[:id] }
165
+
166
+ # Detect created records (in after but not in before)
167
+ after_map.each do |id, after_rec|
168
+ next if before_map[id]
169
+
170
+ changes["#{assoc_name}.#{id}"] = {
171
+ action: "created",
172
+ attributes: after_rec[:attributes]
173
+ }
174
+ end
175
+
176
+ # Detect updated records (in both, but with differences)
177
+ before_map.each do |id, before_rec|
178
+ after_rec = after_map[id]
179
+ next unless after_rec
180
+
181
+ record_changes = diff_records(before_rec, after_rec)
182
+ next unless record_changes.present?
183
+
184
+ changes["#{assoc_name}.#{id}"] = {
185
+ action: "updated",
186
+ changes: record_changes
187
+ }
188
+
189
+ # Detect deleted records (in before but not in after)
190
+ next if after_map[id]
191
+
192
+ changes["#{assoc_name}.#{id}"] = {
193
+ action: "deleted",
194
+ attributes: before_rec[:attributes]
195
+ }
196
+ end
197
+
198
+ changes
199
+ end
200
+
201
+ # Filter changes based on only/except rules
202
+ def filter_changes(changes, only, except)
203
+ return changes if only.blank? && except.blank?
204
+
205
+ filtered = changes.dup
206
+ filtered = filtered.slice(*only.map(&:to_s)) if only.present?
207
+ filtered = filtered.except(*except.map(&:to_s)) if except.present?
208
+ filtered
209
+ end
210
+
211
+ # Filter associated changes
212
+ # Input: { "reviews.46" => { action: "updated", changes: {...} } }
213
+ # Output: Same structure but with filtered changes
214
+ def filter_associated_changes(assoc_changes, only, except)
215
+ return assoc_changes if only.blank? && except.blank?
216
+
217
+ filtered = {}
218
+
219
+ assoc_changes.each do |key, record_data|
220
+ # Filter attributes (for created/deleted records)
221
+ if record_data[:attributes].present?
222
+ filtered_attrs = filter_changes(record_data[:attributes], only, except)
223
+ next if filtered_attrs.blank?
224
+
225
+ filtered[key] = record_data.merge(attributes: filtered_attrs)
226
+ # Filter changes (for updated records)
227
+ elsif record_data[:changes].present?
228
+ filtered_changes = filter_changes(record_data[:changes], only, except)
229
+ next if filtered_changes.blank?
230
+
231
+ filtered[key] = record_data.merge(changes: filtered_changes)
232
+ else
233
+ filtered[key] = record_data
234
+ end
235
+ end
236
+
237
+ filtered
238
+ end
239
+
240
+ # Resolve human-readable action label
241
+ def resolve_action_label(action, configs)
242
+ config = configs.find { |c| c[:action].present? }
243
+ return action.to_s.humanize unless config
244
+
245
+ case config[:action]
246
+ when String
247
+ config[:action]
248
+ when Hash
249
+ config[:action][action.to_sym] || config[:action][action.to_s] || action.to_s.humanize
250
+ else
251
+ action.to_s.humanize
252
+ end
253
+ end
254
+ end
255
+ end
256
+ 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
@@ -5,14 +5,15 @@ module Dscf
5
5
  belongs_to :verified_by, polymorphic: true, optional: true
6
6
  validates :document_type, presence: true
7
7
 
8
- has_one_attached :file
9
-
8
+ has_many_attached :files
10
9
  enum :document_type, {business_license: 0, drivers_license: 1, claim: 2}
11
10
 
12
- def file_url
13
- return nil unless file.attached?
11
+ def file_urls
12
+ return [] unless files.attached?
14
13
 
15
- Rails.application.routes.url_helpers.rails_blob_url(file, only_path: true)
14
+ files.map do |file|
15
+ Rails.application.routes.url_helpers.rails_blob_url(file, only_path: true)
16
+ end
16
17
  end
17
18
  end
18
19
  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
@@ -6,6 +6,7 @@ module Dscf
6
6
  belongs_to :user, serializer: Dscf::Core::UserSerializer
7
7
  belongs_to :business_type, serializer: Dscf::Core::BusinessTypeSerializer
8
8
  has_many :reviews, serializer: Dscf::Core::ReviewSerializer
9
+ has_many :documents, serializer: Dscf::Core::DocumentSerializer
9
10
  end
10
11
  end
11
12
  end
@@ -0,0 +1,17 @@
1
+ module Dscf
2
+ module Core
3
+ class DocumentSerializer < ActiveModel::Serializer
4
+ attributes :id, :document_type, :file_urls, :is_verified, :verified_at, :created_at, :updated_at
5
+ include Rails.application.routes.url_helpers
6
+
7
+ belongs_to :documentable, polymorphic: true
8
+ belongs_to :verified_by, polymorphic: true, optional: true
9
+
10
+ def file_urls
11
+ return [] unless object.files.attached?
12
+
13
+ object.files.map { |file| url_for(file) }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -33,3 +33,27 @@ en:
33
33
  show: "Failed to retrieve business type details"
34
34
  create: "Failed to create business type"
35
35
  update: "Failed to update business type"
36
+
37
+ business:
38
+ success:
39
+ index: "Businesses retrieved successfully"
40
+ show: "Business details retrieved successfully"
41
+ create: "Business created successfully"
42
+ update: "Business updated successfully"
43
+ submit: "Business submitted successfully"
44
+ approve: "Business approved successfully"
45
+ reject: "Business rejected successfully"
46
+ request_modification: "Business modification requested successfully"
47
+ resubmit: "Business resubmitted successfully"
48
+ my_business: "Businesses retrieved successfully"
49
+ errors:
50
+ index: "Failed to retrieve businesses"
51
+ show: "Business not found"
52
+ create: "Failed to create business"
53
+ update: "Failed to update business"
54
+ submit: "Failed to submit business"
55
+ approve: "Failed to approve business"
56
+ reject: "Failed to reject business"
57
+ request_modification: "Failed to request modification for business"
58
+ resubmit: "Failed to resubmit business"
59
+ my_business: "Failed to retrieve businesses"
data/config/routes.rb CHANGED
@@ -1,4 +1,17 @@
1
1
  Dscf::Core::Engine.routes.draw do
2
+ resources :businesses do
3
+ collection do
4
+ get "my_business"
5
+ end
6
+ member do
7
+ patch "submit"
8
+ patch "approve"
9
+ patch "reject"
10
+ patch "request_modification"
11
+ patch "resubmit"
12
+ end
13
+ end
14
+
2
15
  post "auth/login", to: "auth#login"
3
16
  post "auth/logout", to: "auth#logout"
4
17
  post "auth/signup", to: "auth#signup"
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Core
3
- VERSION = "0.2.4".freeze
3
+ VERSION = "0.2.6".freeze
4
4
  end
5
5
  end
@@ -0,0 +1,11 @@
1
+ FactoryBot.define do
2
+ factory :auditable_test_model, class: "AuditableTestModel" do
3
+ name { Faker::Company.name }
4
+ description { Faker::Company.catch_phrase }
5
+ contact_email { Faker::Internet.email }
6
+ contact_phone { Faker::PhoneNumber.phone_number }
7
+ tin_number { Faker::Number.number(digits: 10).to_s }
8
+ user
9
+ business_type
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ FactoryBot.define do
2
+ factory :audit_log, class: "Dscf::Core::AuditLog" do
3
+ auditable { association :business }
4
+ action { "update" }
5
+ audited_changes { {"name" => ["Old name", "New name"]} }
6
+ metadata { {} }
7
+ actor { association :user }
8
+ request_uuid { SecureRandom.uuid }
9
+ ip_address { "127.0.0.1" }
10
+ user_agent { "RSpec" }
11
+ end
12
+ end
@@ -15,7 +15,7 @@ FactoryBot.define do
15
15
  end
16
16
 
17
17
  after(:build) do |document|
18
- document.file.attach(
18
+ document.files.attach(
19
19
  io: File.open(Rails.root.join("app", "fixtures", "images", "logo.jpg")),
20
20
  filename: "logo.jpg",
21
21
  content_type: "image/jpeg"