dscf-core 0.2.8 → 0.2.9

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/dscf/core/auditable_controller.rb +17 -17
  3. data/app/controllers/concerns/dscf/core/authenticatable.rb +3 -8
  4. data/app/controllers/concerns/dscf/core/authorizable.rb +66 -0
  5. data/app/controllers/concerns/dscf/core/common.rb +49 -19
  6. data/app/controllers/concerns/dscf/core/file_uploadable.rb +261 -0
  7. data/app/controllers/concerns/dscf/core/reviewable_controller.rb +31 -1
  8. data/app/controllers/dscf/core/addresses_controller.rb +0 -5
  9. data/app/controllers/dscf/core/application_controller.rb +3 -5
  10. data/app/controllers/dscf/core/auth_controller.rb +16 -11
  11. data/app/controllers/dscf/core/businesses_controller.rb +0 -5
  12. data/app/controllers/dscf/core/files_controller.rb +38 -0
  13. data/app/controllers/dscf/core/permissions_controller.rb +20 -0
  14. data/app/controllers/dscf/core/role_permissions_controller.rb +54 -0
  15. data/app/controllers/dscf/core/roles_controller.rb +30 -0
  16. data/app/controllers/dscf/core/user_roles_controller.rb +56 -0
  17. data/app/errors/dscf/core/file_upload_error.rb +9 -0
  18. data/app/jobs/dscf/core/audit_logger_job.rb +1 -3
  19. data/app/models/concerns/dscf/core/attachable.rb +403 -0
  20. data/app/models/dscf/core/file_attachment.rb +205 -0
  21. data/app/models/dscf/core/permission.rb +27 -0
  22. data/app/models/dscf/core/role.rb +8 -3
  23. data/app/models/dscf/core/role_permission.rb +18 -0
  24. data/app/models/dscf/core/user.rb +56 -0
  25. data/app/models/dscf/core/user_role.rb +23 -3
  26. data/app/policies/dscf/core/application_policy.rb +62 -0
  27. data/app/policies/dscf/core/business_policy.rb +29 -0
  28. data/app/policies/dscf/core/business_type_policy.rb +6 -0
  29. data/app/policies/dscf/core/permission_policy.rb +6 -0
  30. data/app/policies/dscf/core/role_policy.rb +25 -0
  31. data/app/serializers/dscf/core/attachment_serializer.rb +30 -0
  32. data/app/serializers/dscf/core/permission_serializer.rb +7 -0
  33. data/app/serializers/dscf/core/role_light_serializer.rb +7 -0
  34. data/app/serializers/dscf/core/role_permission_serializer.rb +9 -0
  35. data/app/serializers/dscf/core/role_serializer.rb +2 -2
  36. data/app/serializers/dscf/core/user_auth_serializer.rb +6 -2
  37. data/app/serializers/dscf/core/user_role_serializer.rb +2 -3
  38. data/app/services/dscf/core/file_storage/client.rb +210 -0
  39. data/app/services/dscf/core/file_storage/uploader.rb +127 -0
  40. data/app/services/dscf/core/file_storage.rb +44 -0
  41. data/app/services/dscf/core/token_service.rb +2 -1
  42. data/config/locales/en.yml +35 -2
  43. data/config/routes.rb +15 -0
  44. data/db/migrate/20250821185708_create_dscf_core_roles.rb +3 -1
  45. data/db/migrate/20250822054547_create_dscf_core_user_roles.rb +3 -1
  46. data/db/migrate/20260128000000_create_dscf_core_file_attachments.rb +48 -0
  47. data/db/migrate/20260304000001_create_dscf_core_permissions.rb +19 -0
  48. data/db/migrate/20260304000002_create_dscf_core_role_permissions.rb +11 -0
  49. data/lib/dscf/core/permission_registry.rb +58 -0
  50. data/lib/dscf/core/version.rb +1 -1
  51. data/lib/dscf/core.rb +12 -1
  52. data/spec/factories/dscf/core/permissions.rb +14 -0
  53. data/spec/factories/dscf/core/reviews.rb +0 -1
  54. data/spec/factories/dscf/core/role_permissions.rb +6 -0
  55. data/spec/factories/dscf/core/roles.rb +42 -2
  56. data/spec/factories/dscf/core/user_roles.rb +4 -2
  57. metadata +33 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e10c176a47d549a92a06991d19c0191936870f267882996887f8a4926495ec11
4
- data.tar.gz: bd7f34bfb4ce9629e165e4ae2f5e7e5b2262c5a3749856cf9ff3bf2d69cb8194
3
+ metadata.gz: 575dd50f589e60290a2aefab52de0f5d1dc1eda3c55b04631faa0c2ff7809ec6
4
+ data.tar.gz: 49b4d98a1b1cbb3d9493c53aae40bb17a7233feb03a2b87359b036362099cc01
5
5
  SHA512:
6
- metadata.gz: '039a1630b2eac7c862f96e9224de0d1c73dd61629fcb3d62c0f7e09d12daa52d6a818ff7920155ef52f8d30de22865937efa29b8601f45a7d76facae43b66e6d'
7
- data.tar.gz: d166f00188bfd6304533b07492bb44b82a0e5f16e4f5bea17280917239a4e23cc2a9cc3301d2596321bbf8871eefd0a168c7ac22376b04fd36531cd08ce674ce
6
+ metadata.gz: 1de6428f9e48f4d1caf0414cb4b9fd568e60486430e83f6ab5dcb2d39b4891fb52c97fc09d0c310c102ca3c79c5a714e256d2856f5038adf8e6c9af01f82ac28
7
+ data.tar.gz: 165304a18afc35d61ccc5127f2e35eb3a235275452342ffb5c4d26078b8a45a165a128931c6570ad3cc12914f1cde7e8351d3c254bf152a3a0412017df237e4e
@@ -47,23 +47,6 @@ module Dscf
47
47
  self._audit_configs += [config]
48
48
  end
49
49
 
50
- # Normalize associated configuration to hash format
51
- # Input: [:reviews, :comments] or { reviews: { only: [:status] }, comments: {} }
52
- # Output: { reviews: { only: [...], except: [...] }, comments: { only: nil, except: nil } }
53
- private def normalize_associated_for_config(associated)
54
- return {} if associated.blank?
55
-
56
- if associated.is_a?(Array)
57
- # Old format: [:reviews, :comments] -> convert to hash
58
- associated.each_with_object({}) { |name, h| h[name] = {} }
59
- elsif associated.is_a?(Hash)
60
- # New format: already a hash
61
- associated
62
- else
63
- {}
64
- end
65
- end
66
-
67
50
  # Validate audit configuration at definition time
68
51
  def validate_audit_config!(associated:, only:, except:, on:, action:)
69
52
  # Validate associated format
@@ -164,6 +147,23 @@ module Dscf
164
147
  def auditable_record_resolvers
165
148
  @_auditable_record_resolvers || {}
166
149
  end
150
+
151
+ private
152
+
153
+ # Normalize associated configuration to hash format
154
+ # Input: [:reviews, :comments] or { reviews: { only: [:status] }, comments: {} }
155
+ # Output: { reviews: { only: [...], except: [...] }, comments: { only: nil, except: nil } }
156
+ def normalize_associated_for_config(associated)
157
+ return {} if associated.blank?
158
+
159
+ if associated.is_a?(Array)
160
+ associated.each_with_object({}) { |name, h| h[name] = {} }
161
+ elsif associated.is_a?(Hash)
162
+ associated
163
+ else
164
+ {}
165
+ end
166
+ end
167
167
  end
168
168
 
169
169
  private
@@ -4,7 +4,6 @@ module Dscf
4
4
  extend ActiveSupport::Concern
5
5
 
6
6
  included do
7
- before_action :authenticate_user, if: :authentication_required?
8
7
  rescue_from AuthenticationError, with: :handle_authentication_error
9
8
  end
10
9
 
@@ -12,12 +11,12 @@ module Dscf
12
11
  @current_user ||= authenticate_from_token
13
12
  end
14
13
 
15
- def authenticate_user!
14
+ def authenticate_user
16
15
  raise AuthenticationError, "Authentication required" unless current_user
17
16
  end
18
17
 
19
- def authenticate_user
20
- authenticate_user! if authentication_required?
18
+ def authenticate_user!
19
+ raise AuthenticationError, "Authentication required" unless current_user
21
20
  end
22
21
 
23
22
  def sign_in(user, request)
@@ -69,10 +68,6 @@ module Dscf
69
68
  params[:refresh_token]
70
69
  end
71
70
 
72
- def authentication_required?
73
- true # Override in specific controllers if needed
74
- end
75
-
76
71
  def handle_authentication_error(error)
77
72
  render json: error.to_hash, status: error.status_code
78
73
  end
@@ -0,0 +1,66 @@
1
+ require "pundit"
2
+
3
+ module Dscf
4
+ module Core
5
+ module Authorizable
6
+ extend ActiveSupport::Concern
7
+
8
+ include ::Pundit::Authorization
9
+
10
+ included do
11
+ after_action :verify_authorized
12
+ rescue_from ::Pundit::NotAuthorizedError, with: :handle_not_authorized
13
+ end
14
+
15
+ def authorize_action!
16
+ policy_target = resolve_policy_target
17
+ return skip_authorization unless policy_target
18
+
19
+ authorize policy_target
20
+ end
21
+
22
+ def pundit_user
23
+ current_user
24
+ end
25
+
26
+ # Override authorize to fall back to ApplicationPolicy when no specific policy is defined.
27
+ # In Pundit 2.5, authorize delegates to Pundit::Context which bypasses the instance policy method.
28
+ def authorize(record, query = nil, policy_class: nil)
29
+ super
30
+ rescue ::Pundit::NotDefinedError
31
+ fallback_policy = Dscf::Core::ApplicationPolicy.new(pundit_user, record)
32
+ effective_query = query || "#{action_name}?"
33
+ # Guard against undefined query methods — fail closed (deny) rather than raise NoMethodError
34
+ unless fallback_policy.respond_to?(effective_query, true) && fallback_policy.public_send(effective_query)
35
+ raise ::Pundit::NotAuthorizedError, policy: fallback_policy, query: effective_query, record: record
36
+ end
37
+
38
+ record
39
+ end
40
+
41
+ # Override policy_scope to fall back to ApplicationPolicy::Scope when no specific scope is defined.
42
+ def policy_scope(scope, policy_scope_class: nil)
43
+ super
44
+ rescue ::Pundit::NotDefinedError
45
+ Dscf::Core::ApplicationPolicy::Scope.new(pundit_user, scope).resolve
46
+ end
47
+
48
+ private
49
+
50
+ def resolve_policy_target
51
+ # Try @obj first (set by Common's set_object for show/update/destroy)
52
+ return @obj if @obj.present?
53
+
54
+ # Try @clazz (set by Common's set_clazz for index/create)
55
+ return @clazz.new if @clazz.present?
56
+
57
+ # Cannot determine policy target — skip authorization (public/special action)
58
+ nil
59
+ end
60
+
61
+ def handle_not_authorized(_exception)
62
+ render_error("errors.unauthorized", status: :forbidden)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -12,6 +12,8 @@ module Dscf
12
12
  end
13
13
 
14
14
  def index
15
+ authorize @clazz.new, :index?
16
+
15
17
  data = nil
16
18
  options = {}
17
19
  if block_given?
@@ -24,7 +26,7 @@ module Dscf
24
26
  data = incoming
25
27
  end
26
28
  else
27
- data = @clazz.all
29
+ data = policy_scope(@clazz)
28
30
  end
29
31
 
30
32
  # Apply eager loading if defined
@@ -61,6 +63,8 @@ module Dscf
61
63
  end
62
64
 
63
65
  def show
66
+ authorize @obj
67
+
64
68
  data = nil
65
69
  options = {}
66
70
  if block_given?
@@ -86,6 +90,8 @@ module Dscf
86
90
  end
87
91
 
88
92
  def create
93
+ authorize @clazz.new, :create?
94
+
89
95
  obj = nil
90
96
  options = {}
91
97
  if block_given?
@@ -102,25 +108,38 @@ module Dscf
102
108
  obj = @clazz.new(model_params)
103
109
  end
104
110
 
105
- if obj.save
106
- # Store in instance variable for auditing and other concerns
107
- @obj = obj
111
+ # Wrap in transaction for atomic file upload support
112
+ ActiveRecord::Base.transaction do
113
+ if obj.save
114
+ # Store in instance variable for auditing and other concerns
115
+ @obj = obj
108
116
 
109
- obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
110
- @obj = obj # Update with reloaded version
117
+ # Hook for optional post-save operations (e.g., file attachments)
118
+ # If file upload fails in strict mode, this raises FileUploadError
119
+ after_save_hook(obj) if respond_to?(:after_save_hook, true)
111
120
 
112
- includes = serializer_includes_for_action(:create)
113
- options[:include] = includes if includes.present?
121
+ obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
122
+ @obj = obj # Update with reloaded version
114
123
 
115
- render_success(data: obj, serializer_options: options, status: :created)
116
- else
117
- render_error(errors: obj.errors.full_messages[0], status: :unprocessable_entity)
124
+ includes = serializer_includes_for_action(:create)
125
+ options[:include] = includes if includes.present?
126
+
127
+ render_success(data: obj, serializer_options: options, status: :created)
128
+ else
129
+ render_error(errors: obj.errors.full_messages[0], status: :unprocessable_entity)
130
+ end
118
131
  end
132
+ rescue ::Pundit::NotAuthorizedError
133
+ raise
134
+ rescue Dscf::Core::FileUploadError => e
135
+ render_error(errors: e.message, status: :unprocessable_entity)
119
136
  rescue StandardError => e
120
137
  render_error(error: e.message)
121
138
  end
122
139
 
123
140
  def update
141
+ authorize @obj
142
+
124
143
  obj = nil
125
144
  options = {}
126
145
  if block_given?
@@ -137,17 +156,28 @@ module Dscf
137
156
  obj = set_object
138
157
  end
139
158
 
140
- if obj.update(model_params)
141
- obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
142
- @obj = obj # Update with reloaded version for auditing
159
+ # Wrap in transaction for atomic file upload support
160
+ ActiveRecord::Base.transaction do
161
+ if obj.update(model_params)
162
+ # Hook for optional post-update operations (e.g., file attachments)
163
+ # If file upload fails in strict mode, this raises FileUploadError
164
+ after_save_hook(obj) if respond_to?(:after_save_hook, true)
143
165
 
144
- includes = serializer_includes_for_action(:update)
145
- options[:include] = includes if includes.present?
166
+ obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
167
+ @obj = obj # Update with reloaded version for auditing
146
168
 
147
- render_success(data: obj, serializer_options: options)
148
- else
149
- render_error(errors: obj.errors.full_messages[0], status: :unprocessable_entity)
169
+ includes = serializer_includes_for_action(:update)
170
+ options[:include] = includes if includes.present?
171
+
172
+ render_success(data: obj, serializer_options: options)
173
+ else
174
+ render_error(errors: obj.errors.full_messages[0], status: :unprocessable_entity)
175
+ end
150
176
  end
177
+ rescue ::Pundit::NotAuthorizedError
178
+ raise
179
+ rescue Dscf::Core::FileUploadError => e
180
+ render_error(errors: e.message, status: :unprocessable_entity)
151
181
  rescue StandardError => e
152
182
  render_error(error: e.message)
153
183
  end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dscf
4
+ module Core
5
+ # Include this concern in controllers that need to handle file uploads
6
+ # Works seamlessly with Dscf::Core::Common for automatic file handling in CRUD operations
7
+ #
8
+ # File validation happens BEFORE save, and if it fails, the entire operation is rolled back.
9
+ # This ensures atomic behavior - either everything succeeds or nothing is saved.
10
+ #
11
+ # @example With Common module (recommended - zero config!)
12
+ # class ProductsController < ApplicationController
13
+ # include Dscf::Core::Common
14
+ # include Dscf::Core::FileUploadable # Add after Common
15
+ #
16
+ # # That's it! Files are auto-attached from params on create/update
17
+ # # Just make sure your model has: has_one_file :avatar or has_many_files :images
18
+ # end
19
+ #
20
+ # @example With custom file param names
21
+ # class ProductsController < ApplicationController
22
+ # include Dscf::Core::Common
23
+ # include Dscf::Core::FileUploadable
24
+ #
25
+ # # Override to specify which params are file uploads
26
+ # def file_upload_params
27
+ # { avatar: params[:product][:avatar], images: params[:product][:images] }
28
+ # end
29
+ # end
30
+ #
31
+ # @example Soft failure mode (don't fail on upload errors)
32
+ # class ProductsController < ApplicationController
33
+ # include Dscf::Core::Common
34
+ # include Dscf::Core::FileUploadable
35
+ #
36
+ # def file_upload_strict_mode?
37
+ # false # Record will be saved even if file upload fails
38
+ # end
39
+ # end
40
+ #
41
+ module FileUploadable
42
+ extend ActiveSupport::Concern
43
+
44
+ included do
45
+ attr_reader :file_upload_errors
46
+ end
47
+
48
+ # Hook called by Common module after create/update
49
+ # Automatically attaches files from params with strict validation
50
+ def after_save_hook(record)
51
+ super if defined?(super)
52
+
53
+ success = auto_attach_files(record)
54
+
55
+ # In strict mode, raise error to trigger rollback if upload failed
56
+ return unless !success && file_upload_strict_mode?
57
+
58
+ error_message = @file_upload_errors&.first || "File upload failed"
59
+ raise FileUploadError, error_message
60
+ end
61
+
62
+ # Whether to fail the entire operation if file upload fails
63
+ # Override in controller to change behavior
64
+ # @return [Boolean] true = fail on upload error, false = save record anyway
65
+ def file_upload_strict_mode?
66
+ true
67
+ end
68
+
69
+ # Attach files to a model's attachment field
70
+ # @param record [ActiveRecord::Base] the record to attach files to
71
+ # @param attachment_name [Symbol] the attachment field name
72
+ # @param files [ActionDispatch::Http::UploadedFile, Array] the file(s) to attach
73
+ # @param options [Hash] additional options
74
+ # @return [Boolean] success status
75
+ def attach_files(record, attachment_name, files, **options)
76
+ return true if files.blank?
77
+ return true unless record.respond_to?(attachment_name)
78
+
79
+ attachment = record.send(attachment_name)
80
+ return true unless attachment.respond_to?(:attach)
81
+
82
+ # Auto-set uploaded_by if available
83
+ attachment = attachment.uploaded_by(current_user) if respond_to?(:current_user) && current_user
84
+
85
+ result = attachment.attach(files, **options)
86
+
87
+ unless result
88
+ @file_upload_errors ||= []
89
+ @file_upload_errors.concat(Array(attachment.errors))
90
+ end
91
+
92
+ result
93
+ end
94
+
95
+ # Automatically attach file uploads from params to a record
96
+ # Detects file params and attaches them to matching model attachments
97
+ # @param record [ActiveRecord::Base] the record to attach files to
98
+ # @param namespace [Symbol] the params namespace (default: model name underscored)
99
+ # @return [Boolean] success status (false if any attachment failed)
100
+ def auto_attach_files(record, namespace: nil)
101
+ @file_upload_errors = []
102
+
103
+ # Use custom file params if defined
104
+ files_to_attach = if respond_to?(:file_upload_params, true)
105
+ file_upload_params
106
+ else
107
+ extract_file_params(record, namespace)
108
+ end
109
+
110
+ all_success = true
111
+ files_to_attach.each do |attachment_name, file_value|
112
+ success = attach_files(record, attachment_name.to_sym, file_value)
113
+ all_success = false unless success
114
+ end
115
+
116
+ all_success
117
+ end
118
+
119
+ # Upload a file directly (without attaching to a model)
120
+ # Useful for standalone upload endpoints
121
+ # @param file [ActionDispatch::Http::UploadedFile] the file to upload
122
+ # @param options [Hash] uploader options
123
+ # @return [Dscf::Core::FileStorage::Attachment, nil]
124
+ def upload_file(file, **options)
125
+ @file_upload_errors = []
126
+
127
+ if file.blank?
128
+ @file_upload_errors << "No file provided"
129
+ return nil
130
+ end
131
+
132
+ uploader = FileStorage::Uploader.new(options)
133
+ result = uploader.upload(file)
134
+
135
+ unless result
136
+ @file_upload_errors = uploader.errors
137
+ return nil
138
+ end
139
+
140
+ result
141
+ end
142
+
143
+ # Upload multiple files directly
144
+ # @param files [Array<ActionDispatch::Http::UploadedFile>] the files to upload
145
+ # @param options [Hash] uploader options
146
+ # @return [Array<Hash>] array of uploaded file hashes
147
+ def upload_files(files, **options)
148
+ @file_upload_errors = []
149
+
150
+ if files.blank?
151
+ @file_upload_errors << "No files provided"
152
+ return []
153
+ end
154
+
155
+ uploader = FileStorage::Uploader.new(options)
156
+ results = uploader.upload_many(files)
157
+
158
+ @file_upload_errors = uploader.errors unless uploader.success?
159
+
160
+ results
161
+ end
162
+
163
+ # Download a file and send it as response
164
+ # @param file_key [String] the file key in storage
165
+ # @param options [Hash] download options
166
+ def send_stored_file(file_key, **options)
167
+ client = FileStorage::Client.new
168
+ result = client.download(file_key)
169
+
170
+ send_data(
171
+ result[:data],
172
+ filename: options[:filename] || result[:filename],
173
+ type: options[:content_type] || result[:content_type],
174
+ disposition: options[:disposition] || "inline"
175
+ )
176
+ rescue FileStorage::Client::DownloadError => e
177
+ render json: {error: e.message}, status: :not_found
178
+ end
179
+
180
+ # Download a file from an attachment
181
+ # @param attachment [Dscf::Core::FileAttachment] the attachment record
182
+ # @param options [Hash] download options
183
+ def send_attachment(attachment, **options)
184
+ unless attachment&.attached?
185
+ render json: {error: "File not found"}, status: :not_found
186
+ return
187
+ end
188
+
189
+ send_stored_file(
190
+ attachment.file_key,
191
+ filename: options[:filename] || attachment.filename,
192
+ content_type: options[:content_type] || attachment.content_type,
193
+ disposition: options[:disposition] || "inline"
194
+ )
195
+ end
196
+
197
+ # Process base64 encoded file
198
+ # @param base64_data [String] base64 encoded file data
199
+ # @param filename [String] desired filename
200
+ # @param content_type [String] MIME type
201
+ # @return [Hash] file hash suitable for uploading
202
+ def process_base64_file(base64_data, filename:, content_type:)
203
+ return nil if base64_data.blank?
204
+
205
+ # Handle data URL format: "data:image/png;base64,..."
206
+ if base64_data.include?(",")
207
+ content_type_match = base64_data.match(/data:([^;]+);base64/)
208
+ content_type = content_type_match[1] if content_type_match
209
+ base64_data = base64_data.split(",").last
210
+ end
211
+
212
+ decoded = Base64.decode64(base64_data)
213
+ io = StringIO.new(decoded)
214
+
215
+ {
216
+ io: io,
217
+ filename: filename,
218
+ content_type: content_type
219
+ }
220
+ end
221
+
222
+ # Upload a base64 encoded file
223
+ # @param base64_data [String] base64 encoded file data
224
+ # @param filename [String] desired filename
225
+ # @param content_type [String] MIME type
226
+ # @param options [Hash] uploader options
227
+ # @return [Hash, nil] uploaded file hash
228
+ def upload_base64_file(base64_data, filename:, content_type:, **options)
229
+ file_hash = process_base64_file(base64_data, filename: filename, content_type: content_type)
230
+ return nil unless file_hash
231
+
232
+ upload_file(file_hash, **options)
233
+ end
234
+
235
+ private
236
+
237
+ # Extract file upload params from request params
238
+ # Looks for UploadedFile objects in the model's param namespace
239
+ # @param record [ActiveRecord::Base] the record to find files for
240
+ # @param namespace [Symbol, nil] optional param namespace override
241
+ # @return [Hash] hash of attachment_name => file pairs
242
+ def extract_file_params(record, namespace = nil)
243
+ namespace ||= record.class.name.demodulize.underscore.to_sym
244
+ model_params = params[namespace] || {}
245
+
246
+ files = {}
247
+ model_params.each do |key, value|
248
+ # Handle single file uploads
249
+ if value.is_a?(ActionDispatch::Http::UploadedFile)
250
+ files[key] = value
251
+ # Handle array of files
252
+ elsif value.is_a?(Array) && value.first.is_a?(ActionDispatch::Http::UploadedFile)
253
+ files[key] = value
254
+ end
255
+ end
256
+
257
+ files
258
+ end
259
+ end
260
+ end
261
+ end
@@ -216,6 +216,8 @@ module Dscf
216
216
  if model_updates.present? && !reviewable_resource.update(model_updates)
217
217
  return render_error(errors: reviewable_resource.errors.full_messages)
218
218
  end
219
+
220
+ reviewable_resource.reload # Ensure we have latest data
219
221
  rescue ActionController::ParameterMissing => e
220
222
  return render_error(errors: [e.message])
221
223
  end
@@ -298,7 +300,35 @@ module Dscf
298
300
  end
299
301
 
300
302
  def authorize_review_action!
301
- # Override for custom auth
303
+ permission_code = "#{controller_name}.#{action_name}"
304
+
305
+ unless current_user&.has_permission?(permission_code)
306
+ skip_authorization
307
+ return render_error("errors.unauthorized", status: :forbidden)
308
+ end
309
+
310
+ # Ownership enforcement: submit/resubmit can only be performed by the record owner.
311
+ # SUPER_ADMIN bypasses this check to allow admin data correction scenarios.
312
+ submitter_actions = %w[submit resubmit]
313
+ if submitter_actions.include?(action_name) &&
314
+ !current_user&.super_admin? &&
315
+ @reviewable_resource.respond_to?(:user) &&
316
+ !@reviewable_resource.user.nil? &&
317
+ @reviewable_resource.user != current_user
318
+ skip_authorization
319
+ return render_error("errors.unauthorized", status: :forbidden)
320
+ end
321
+
322
+ # Self-review prevention: cannot approve/reject/request_modification your own submission
323
+ non_submitter_actions = %w[approve reject request_modification]
324
+ if non_submitter_actions.include?(action_name) &&
325
+ @reviewable_resource.respond_to?(:user) &&
326
+ @reviewable_resource.user == current_user
327
+ skip_authorization
328
+ return render_error("errors.unauthorized", status: :forbidden)
329
+ end
330
+
331
+ skip_authorization # This before_action handles authorization — satisfy verify_authorized
302
332
  end
303
333
 
304
334
  def submission_ready?(resource)
@@ -17,11 +17,6 @@ module Dscf
17
17
 
18
18
  private
19
19
 
20
- # Enable authentication for this controller
21
- def authentication_required?
22
- true
23
- end
24
-
25
20
  def model_params
26
21
  params.require(:address).permit(
27
22
  :address_type, :country, :city, :sub_city,
@@ -4,9 +4,11 @@ module Dscf
4
4
  include Authenticatable
5
5
  include TokenAuthenticatable
6
6
  include JsonResponse
7
+ include Authorizable
7
8
 
8
- # Handle CORS for authentication
9
9
  before_action :set_cors_headers
10
+ before_action :authenticate_user
11
+ before_action :authorize_action!
10
12
 
11
13
  private
12
14
 
@@ -16,10 +18,6 @@ module Dscf
16
18
  headers["Access-Control-Allow-Headers"] = "Origin, Content-Type, Accept, Authorization, X-Requested-With"
17
19
  headers["Access-Control-Allow-Credentials"] = "false"
18
20
  end
19
-
20
- def authentication_required?
21
- false # Override in specific controllers
22
- end
23
21
  end
24
22
  end
25
23
  end