dscf-core 0.1.7 → 0.1.8

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: 921baabb605b4c33c9d64f8e0839d0aebb9558e6980553f03fc35acf12286a84
4
- data.tar.gz: 762b3316a670197ab8b24baff0140658550869a25b12424953795d0d28f972ae
3
+ metadata.gz: 2c8bc7f7428e10e98816b30968fa31e0609cba87e3292c8ad8d248f38067e578
4
+ data.tar.gz: db88e3856be17b5a02d0350d28d303d3ec59abf09cf2641bae204b990bf6704c
5
5
  SHA512:
6
- metadata.gz: a6505e11aafa30ca100d6e371997f68407930c2f6759be12db6d63f8fc5cc6f9fb7050c206e93ba06d877bc56670ecbcb3077c5e02309a4f86bdaf600d7686cb
7
- data.tar.gz: b2e5f301c038fed2e084d3a4aa7592a902d6c97fcbc8de51fd4a47baf8be25dbf512bd281a2584186b86fd433af89db18f3592042453d4848edc41ebff3d3c58
6
+ metadata.gz: 1f436f1f1d10c5c8e43c6683d8d104175d480d15dab6005e7b198ef4237e9c6fee26c15ea9a17d8fba4ad0b0f14a50aecbceb735157f4103b549e1ae4cc62a06
7
+ data.tar.gz: 8a4dda4a6efa99ebc8469fa9bf5ba69af0edbf3065f5c5ee288f591d2d65ee844a8bcf8d723a91bcec7840edc673c8db61584c752de63c0d1b96546b9f6af7a3
@@ -8,7 +8,18 @@ module Dscf
8
8
  when ActiveRecord::Base, ActiveRecord::Relation
9
9
  ActiveModelSerializers::SerializableResource.new(data, options)
10
10
  when Hash
11
- data.transform_values { |value| serialize(value, options) }
11
+ # Handle hash data with potential serializer options
12
+ serialized_hash = {}
13
+ data.each do |key, value|
14
+ if options[key] && value.is_a?(ActiveRecord::Base)
15
+ # Use specific serializer for this key if provided
16
+ serializer_opts = options[key].is_a?(Hash) ? options[key] : {}
17
+ serialized_hash[key] = ActiveModelSerializers::SerializableResource.new(value, serializer_opts)
18
+ else
19
+ serialized_hash[key] = serialize(value, options)
20
+ end
21
+ end
22
+ serialized_hash
12
23
  when Array
13
24
  data.map { |value| serialize(value, options) }
14
25
  else
@@ -0,0 +1,345 @@
1
+ module Dscf
2
+ module Core
3
+ module ReviewableController
4
+ extend ActiveSupport::Concern
5
+ include Dscf::Core::JsonResponse
6
+
7
+ included do
8
+ class_attribute :review_context_configs, default: {}
9
+ class_attribute :reviewable_model_class, default: nil
10
+
11
+ # Initialize with default context config for zero-config support
12
+ self.review_context_configs = default_context_config.dup
13
+
14
+ # Auto-define default actions for zero-config
15
+ define_default_review_actions
16
+
17
+ before_action :set_reviewable_resource, if: :review_action?
18
+ before_action :authorize_review_action!, if: :review_action?
19
+ end
20
+
21
+ class_methods do
22
+ def reviewable_model(model_class)
23
+ self.reviewable_model_class = model_class
24
+ end
25
+
26
+ def reviewable_context(context_name, options = {})
27
+ statuses = options[:statuses]&.map(&:to_s) || %w[pending approved rejected modify]
28
+ initial_status = options[:initial_status]&.to_s || "pending"
29
+
30
+ # Convert linear transitions array to hash if provided
31
+ transitions = if options[:transitions].is_a?(Array)
32
+ validate_linear_transitions(options[:transitions], statuses, context_name)
33
+ build_linear_transitions(options[:transitions])
34
+ else
35
+ options[:transitions] || {}
36
+ end
37
+
38
+ defaults = {
39
+ statuses: statuses,
40
+ initial_status: initial_status,
41
+ transitions: transitions,
42
+ actions: options[:actions] || default_actions
43
+ }
44
+
45
+ validate_action_config(defaults[:actions], defaults[:statuses], context_name)
46
+ unless defaults[:statuses].include?(defaults[:initial_status])
47
+ raise ArgumentError, <<~MSG
48
+ Initial status '#{defaults[:initial_status]}' in context '#{context_name}' must be one of: #{defaults[:statuses].join(', ')}
49
+ MSG
50
+ end
51
+
52
+ # Add to existing configs
53
+ self.review_context_configs = review_context_configs.merge(context_name.to_sym => defaults)
54
+
55
+ # Define methods for all actions in this context
56
+ defaults[:actions].each_key do |action_name|
57
+ # Only define if not already defined
58
+ define_review_action_method(action_name) unless method_defined?(action_name)
59
+ end
60
+ end
61
+
62
+ def define_default_review_actions
63
+ default_context_config.each do |context_name, config|
64
+ validate_action_config(config[:actions], config[:statuses], context_name)
65
+ config[:actions].each_key do |action_name|
66
+ define_review_action_method(action_name)
67
+ end
68
+ end
69
+ end
70
+
71
+ def define_review_action_method(action_name)
72
+ define_method(action_name) do
73
+ context = current_review_context
74
+ action_config = context_config[:actions][action_name.to_sym]
75
+ unless action_config
76
+ return render_error(
77
+ errors: ["Action '#{action_name}' not defined for context '#{context}'"],
78
+ status: :bad_request
79
+ )
80
+ end
81
+ perform_review_action(**action_config.slice(:status, :require_feedback, :after, :update_model))
82
+ end
83
+ end
84
+
85
+ def default_actions
86
+ {
87
+ approve: {status: "approved"},
88
+ reject: {status: "rejected", require_feedback: true},
89
+ request_modification: {status: "modify", require_feedback: true},
90
+ resubmit: {status: "pending", update_model: true}
91
+ }
92
+ end
93
+
94
+ def default_context_config
95
+ {
96
+ default: {
97
+ statuses: %w[pending approved rejected modify],
98
+ initial_status: "pending",
99
+ transitions: {"pending" => %w[approved rejected modify], "modify" => ["pending"]},
100
+ actions: default_actions
101
+ }
102
+ }
103
+ end
104
+
105
+ def validate_action_config(actions, statuses, context_name)
106
+ actions.each do |action_name, opts|
107
+ next if statuses.include?(opts[:status])
108
+
109
+ raise ArgumentError, <<~MSG
110
+ Action '#{action_name}' in context '#{context_name}' maps to invalid status '#{opts[:status]}'. Must be one of: #{statuses.join(', ')}
111
+ MSG
112
+ end
113
+ end
114
+
115
+ def validate_linear_transitions(transitions, statuses, context_name)
116
+ transitions.each do |status|
117
+ unless statuses.include?(status.to_s)
118
+ raise ArgumentError, "Transition status '#{status}' in context '#{context_name}' must be one of: #{statuses.join(', ')}"
119
+ end
120
+ end
121
+ end
122
+
123
+ def build_linear_transitions(transitions)
124
+ transitions.each_with_object({}).with_index do |(status, hash), index|
125
+ next_status = transitions[index + 1]
126
+ hash[status.to_s] = next_status ? [next_status.to_s] : []
127
+ end
128
+ end
129
+
130
+ def review_action_names
131
+ # Get actions from initialized contexts + default fallback
132
+ all_actions = review_context_configs.values.flat_map { |config| config[:actions].keys }
133
+ all_actions += default_context_config[:default][:actions].keys
134
+ all_actions.uniq
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def review_action?
141
+ self.class.review_action_names.include?(action_name.to_sym)
142
+ end
143
+
144
+ def perform_review_action(status:, require_feedback: false, after: nil, update_model: false)
145
+ begin
146
+ context_config
147
+ rescue ArgumentError => e
148
+ return render_error(errors: [e.message], status: :bad_request)
149
+ end
150
+
151
+ current_review = reviewable_resource.current_review_for(current_review_context)
152
+
153
+ feedback = if require_feedback
154
+ feedback_data = params[:review_feedback]
155
+ feedback_data = feedback_data.to_unsafe_h if feedback_data.respond_to?(:to_unsafe_h)
156
+
157
+ return render_error(errors: ["Feedback is required for #{status}"]) if feedback_data.blank?
158
+
159
+ feedback_data
160
+ end
161
+
162
+ unless valid_transition?(current_review&.status, status)
163
+ return render_error(
164
+ errors: [
165
+ "Cannot transition from '#{current_review&.status || context_config[:initial_status]}' to '#{status}'"
166
+ ]
167
+ )
168
+ end
169
+
170
+ begin
171
+ ActiveRecord::Base.transaction do
172
+ # Update the model if required (e.g., for resubmit with changes)
173
+ if update_model
174
+ begin
175
+ model_updates = model_params
176
+ if model_updates.present? && !reviewable_resource.update(model_updates)
177
+ return render_error(errors: reviewable_resource.errors.full_messages)
178
+ end
179
+ rescue ActionController::ParameterMissing => e
180
+ return render_error(errors: [e.message])
181
+ end
182
+ end
183
+
184
+ # Update existing review or create new one if none exists
185
+ review = current_review || reviewable_resource.build_review_for(current_review_context)
186
+ review.assign_attributes(
187
+ status: status,
188
+ feedback: feedback,
189
+ reviewed_by: current_user,
190
+ reviewed_at: Time.current
191
+ )
192
+
193
+ before_review_action(review)
194
+ review.save!
195
+ execute_after_callbacks(review, after)
196
+ after_review_action(review)
197
+ end
198
+ render_success(data: reviewable_resource.reload)
199
+ rescue StandardError => e
200
+ render_error(errors: [e.message], status: :unprocessable_entity)
201
+ end
202
+ end
203
+
204
+ def execute_after_callbacks(review, after_callbacks = nil)
205
+ # Get action callbacks from the current action config or passed callbacks
206
+ action_config = context_config[:actions][action_name.to_sym]
207
+ action_callbacks = after_callbacks || action_config&.[](:after)
208
+ return unless action_callbacks
209
+
210
+ Array(action_callbacks).each do |callback|
211
+ if callback.is_a?(Symbol)
212
+ send(callback, review)
213
+ elsif callback.respond_to?(:call)
214
+ callback.call(review)
215
+ else
216
+ raise "Invalid callback: #{callback.inspect}"
217
+ end
218
+ rescue StandardError => e
219
+ Rails.logger.error "Callback failed: #{callback.inspect}, error: #{e.message}"
220
+ raise
221
+ end
222
+ end
223
+
224
+ def current_review_context
225
+ params[:context]&.to_sym || :default
226
+ end
227
+
228
+ def available_contexts
229
+ # Get all configured contexts plus default if not explicitly configured
230
+ contexts = self.class.review_context_configs.keys
231
+ contexts << :default unless contexts.include?(:default)
232
+ contexts
233
+ end
234
+
235
+ def context_config
236
+ context = current_review_context
237
+ config = self.class.review_context_configs[context]
238
+
239
+ # If requesting default context and it's not configured, use the built-in default
240
+ config = self.class.default_context_config[:default] if context == :default && config.nil?
241
+
242
+ # If context is not found and it's not the default, it's invalid
243
+ raise ArgumentError, "Invalid context '#{context}'. Available contexts: #{available_contexts.join(', ')}" if config.nil?
244
+
245
+ Rails.logger.debug "Context config for '#{context}': #{config.inspect}"
246
+ config
247
+ end
248
+
249
+ def allowed_statuses
250
+ context_config[:statuses]
251
+ end
252
+
253
+ def valid_transition?(from, to)
254
+ return true if context_config[:transitions].blank?
255
+
256
+ allowed_to = context_config[:transitions][from || context_config[:initial_status]] || []
257
+ allowed_to.include?(to)
258
+ end
259
+
260
+ def authorize_review_action!
261
+ # Override for custom auth
262
+ end
263
+
264
+ def before_review_action(review)
265
+ # Default implementation - can be overridden
266
+ # Check if there's a custom hook method defined
267
+ return unless respond_to?(:before_reviewable_action, true)
268
+
269
+ before_reviewable_action(review, reviewable_resource, current_review_context)
270
+ end
271
+
272
+ def after_review_action(review)
273
+ return unless respond_to?(:after_reviewable_action, true)
274
+
275
+ after_reviewable_action(review, reviewable_resource, current_review_context)
276
+ end
277
+
278
+ def current_user
279
+ # Default implementation that can be overridden
280
+ if respond_to?(:current_reviewer, true)
281
+ current_reviewer
282
+ else
283
+ return super if defined?(super)
284
+ return @current_user if defined?(@current_user)
285
+ return session[:user_id] if session && session[:user_id]
286
+
287
+ nil
288
+ end
289
+ end
290
+
291
+ def model_params
292
+ # Default implementation that can be overridden
293
+ resource_name = controller_name.singularize
294
+
295
+ # First try the standard Rails pattern: resource_name_params
296
+ params_method = "#{resource_name}_params"
297
+ return send(params_method) if respond_to?(params_method, true)
298
+
299
+ # Fallback to generic reviewable_params
300
+ return reviewable_params if respond_to?(:reviewable_params, true)
301
+
302
+ # If neither exists, return empty hash (no model updates)
303
+ {}
304
+ rescue ActionController::ParameterMissing => e
305
+ raise e # Re-raise parameter missing errors
306
+ rescue StandardError => e
307
+ Rails.logger.warn "Failed to get model params: #{e.message}"
308
+ {}
309
+ end
310
+
311
+ def model_class
312
+ return reviewable_model_class if reviewable_model_class
313
+
314
+ model_name = controller_name.classify
315
+
316
+ controller_namespace = self.class.name.deconstantize
317
+ if controller_namespace.present?
318
+ namespaced_model = "#{controller_namespace}::#{model_name}"
319
+ return namespaced_model.constantize if Object.const_defined?(namespaced_model)
320
+ end
321
+
322
+ return model_name.constantize if Object.const_defined?(model_name)
323
+
324
+ raise NameError, "Could not determine model class for #{self.class.name}. " \
325
+ "Please configure it explicitly using 'reviewable_model ModelClass' " \
326
+ "in your controller class."
327
+ rescue NameError => e
328
+ Rails.logger.error "Model resolution failed for #{self.class.name}: #{e.message}"
329
+ raise
330
+ end
331
+
332
+ def set_reviewable_resource
333
+ @reviewable_resource = model_class.find(params[:id])
334
+ rescue ActiveRecord::RecordNotFound => e
335
+ render_error(status: :not_found, errors: [e.message])
336
+ rescue NameError => e
337
+ render_error(status: :internal_server_error, errors: ["Configuration error: #{e.message}"])
338
+ end
339
+
340
+ def reviewable_resource
341
+ @reviewable_resource
342
+ end
343
+ end
344
+ end
345
+ end
@@ -13,9 +13,14 @@ module Dscf
13
13
  render_success(
14
14
  "auth.success.login",
15
15
  data: {
16
- user: user_with_profile(user),
16
+ user: user,
17
17
  access_token: tokens[:access_token],
18
18
  refresh_token: tokens[:refresh_token].refresh_token
19
+ },
20
+ serializer_options: {
21
+ user: {
22
+ serializer: Dscf::Core::UserAuthSerializer
23
+ }
19
24
  }
20
25
  )
21
26
  else
@@ -34,9 +39,14 @@ module Dscf
34
39
  render_success(
35
40
  "auth.success.signup",
36
41
  data: {
37
- user: user_with_profile(user)
42
+ user: user
38
43
  },
39
- status: :created
44
+ status: :created,
45
+ serializer_options: {
46
+ user: {
47
+ serializer: Dscf::Core::UserAuthSerializer
48
+ }
49
+ }
40
50
  )
41
51
  else
42
52
  render_error(
@@ -57,7 +67,12 @@ module Dscf
57
67
  render_success(
58
68
  "auth.success.me",
59
69
  data: {
60
- user: user_with_profile(current_user)
70
+ user: current_user
71
+ },
72
+ serializer_options: {
73
+ user: {
74
+ serializer: Dscf::Core::UserAuthSerializer
75
+ }
61
76
  }
62
77
  )
63
78
  end
@@ -101,16 +116,6 @@ module Dscf
101
116
  UserRole.create!(user: user, role: role)
102
117
  end
103
118
 
104
- def user_with_profile(user)
105
- user.as_json(
106
- only: %i[id email phone],
107
- include: {
108
- user_profile: {only: %i[first_name last_name verification_status]},
109
- roles: {only: %i[name description]}
110
- }
111
- )
112
- end
113
-
114
119
  def authentication_required?
115
120
  # Skip authentication for login, signup, and refresh endpoints
116
121
  %w[login signup refresh].exclude?(action_name)
@@ -0,0 +1,31 @@
1
+ module Dscf
2
+ module Core
3
+ module ReviewableModel
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_many :reviews, as: :reviewable, class_name: "Dscf::Core::Review", dependent: :destroy
8
+ end
9
+
10
+ def review_for(context = :default)
11
+ reviews.with_context(context).order(created_at: :desc)
12
+ end
13
+
14
+ def current_review_for(context = :default)
15
+ review_for(context).first
16
+ end
17
+
18
+ def current_status_for(context = :default)
19
+ current_review = current_review_for(context)
20
+ return current_review.status if current_review
21
+
22
+ # Return default initial status for zero-config
23
+ "pending"
24
+ end
25
+
26
+ def build_review_for(context = :default)
27
+ reviews.build(context: context.to_s)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ module Dscf
2
+ module Core
3
+ class Review < ApplicationRecord
4
+ self.table_name = "dscf_core_reviews"
5
+
6
+ belongs_to :reviewable, polymorphic: true
7
+ belongs_to :reviewed_by, polymorphic: true, optional: true
8
+
9
+ validates :context, presence: true
10
+
11
+ scope :with_context, ->(ctx) { where(context: ctx.to_s) }
12
+
13
+ before_validation :set_default_context
14
+
15
+ private
16
+
17
+ def set_default_context
18
+ self.context ||= "default"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -6,6 +6,14 @@ module Dscf
6
6
 
7
7
  has_many :user_roles, class_name: "Dscf::Core::UserRole"
8
8
  has_many :users, through: :user_roles, class_name: "Dscf::Core::User"
9
+
10
+ def self.ransackable_attributes(_auth_object = nil)
11
+ %w[id code name created_at updated_at]
12
+ end
13
+
14
+ def self.ransackable_associations(_auth_object = nil)
15
+ %w[user_roles users reviews]
16
+ end
9
17
  end
10
18
  end
11
19
  end
@@ -14,6 +14,14 @@ module Dscf
14
14
 
15
15
  before_create :set_default_temp_password
16
16
 
17
+ def self.ransackable_attributes(_auth_object = nil)
18
+ %w[id email phone verified_at created_at updated_at]
19
+ end
20
+
21
+ def self.ransackable_associations(_auth_object = nil)
22
+ %w[refresh_tokens user_roles roles businesses addresses documents user_profile]
23
+ end
24
+
17
25
  private
18
26
 
19
27
  def set_default_temp_password
@@ -0,0 +1,10 @@
1
+ module Dscf
2
+ module Core
3
+ class AddressSerializer < ActiveModel::Serializer
4
+ attributes :id, :address_type, :country, :city, :sub_city, :woreda, :kebele, :house_numbers, :po_box, :latitude, :longitude,
5
+ :created_at, :updated_at
6
+
7
+ belongs_to :user, serializer: UserSerializer
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ module Dscf
2
+ module Core
3
+ class BusinessSerializer < ActiveModel::Serializer
4
+ attributes :id, :name, :description, :contact_email, :contact_phone, :tin_number, :created_at, :updated_at
5
+
6
+ belongs_to :user, serializer: Dscf::Core::UserSerializer
7
+ belongs_to :business_type, serializer: Dscf::Core::BusinessTypeSerializer
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ module Dscf
2
+ module Core
3
+ class BusinessTypeSerializer < ActiveModel::Serializer
4
+ attributes :id, :name, :created_at, :updated_at
5
+
6
+ has_many :businesses, serializer: BusinessSerializer
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ module Dscf
2
+ module Core
3
+ class ReviewSerializer < ActiveModel::Serializer
4
+ attributes :id, :context, :status, :feedback, :reviewed_at, :created_at, :updated_at
5
+
6
+ attribute :reviewable do
7
+ {
8
+ id: object.reviewable_id,
9
+ type: object.reviewable_type
10
+ }
11
+ end
12
+
13
+ belongs_to :reviewed_by, serializer: Dscf::Core::UserAuthSerializer
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ module Dscf
2
+ module Core
3
+ class RoleSerializer < ActiveModel::Serializer
4
+ attributes :id, :code, :name, :created_at, :updated_at
5
+
6
+ has_many :user_roles, serializer: Dscf::Core::UserRoleSerializer
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ module Dscf
2
+ module Core
3
+ class UserAuthSerializer < ActiveModel::Serializer
4
+ attributes :id, :email, :phone, :verified_at
5
+
6
+ has_one :user_profile, serializer: Dscf::Core::UserProfileSerializer
7
+ has_many :roles, serializer: Dscf::Core::RoleSerializer
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ module Dscf
2
+ module Core
3
+ class UserProfileSerializer < ActiveModel::Serializer
4
+ attributes :id, :first_name, :middle_name, :last_name, :gender, :nationality, :date_of_birth,
5
+ :place_of_birth, :fayda_number, :occupation, :position_title, :employer_name,
6
+ :avg_monthly_income, :avg_annual_income, :pep_status, :risk_score, :watchlist_hit,
7
+ :verification_status, :created_at, :updated_at
8
+
9
+ belongs_to :user, serializer: Dscf::Core::UserSerializer
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ module Dscf
2
+ module Core
3
+ class UserRoleSerializer < ActiveModel::Serializer
4
+ attributes :id, :created_at, :updated_at
5
+
6
+ belongs_to :user, serializer: UserSerializer
7
+ belongs_to :role, serializer: RoleSerializer
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ module Dscf
2
+ module Core
3
+ class UserSerializer < ActiveModel::Serializer
4
+ attributes :id, :email, :phone, :verified_at, :created_at, :updated_at
5
+
6
+ has_many :user_roles, serializer: Dscf::Core::UserRoleSerializer
7
+ has_many :roles, serializer: Dscf::Core::RoleSerializer
8
+ has_many :businesses, serializer: Dscf::Core::BusinessSerializer
9
+ has_many :addresses, serializer: Dscf::Core::AddressSerializer
10
+
11
+ has_one :user_profile, serializer: Dscf::Core::UserProfileSerializer
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ class CreateDscfCoreReviews < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :dscf_core_reviews do |t|
4
+ t.references :reviewable, polymorphic: true, null: false
5
+ t.string :context
6
+ t.string :status, default: "pending"
7
+ t.jsonb :feedback
8
+ t.references :reviewed_by, polymorphic: true
9
+ t.datetime :reviewed_at
10
+
11
+ t.timestamps
12
+ end
13
+ end
14
+ end
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Core
3
- VERSION = "0.1.7".freeze
3
+ VERSION = "0.1.8".freeze
4
4
  end
5
5
  end