dscf-core 0.3.7 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 140fe172991cc8a918430e1dfe062b715d930c0a6914820d16eec4bc4c7a9a9f
4
- data.tar.gz: b6bd385bde47d38da59639170d16843c6000db6fc59410a086eb885c7b5e11b1
3
+ metadata.gz: c7a5e4e4e0fa072a549085c2e512fb502b59d3b7547d9265bbc5e969ff3b5225
4
+ data.tar.gz: 246be4bbcbb6660743154695649c2320998b85c890814d5629cc78899a3b3e30
5
5
  SHA512:
6
- metadata.gz: f2f6606363b0d92186f267d703e29cc608f08f9be092500d94435e83b9ca2b44cc129d8898ceab3cea2933323b8c5a534542d1c75ecbab55ccf16ae3a34dd05e
7
- data.tar.gz: e26442c83f27198db1e1eb2967838ef518e799d1926e76bb286030da06215e68fde220bd3d3f841bf38be2705a4061639be3b7e1b9faaf88ff8f29c56cbd3fc2
6
+ metadata.gz: c60bf6536fecbfff1826fa883c26abf8d5535d5e9ad8a8df96e2aaad29158b9f4d219737932398bfb23e6ec71c02fe511a9b4ffa981d2a4ef4c171da47e42af5
7
+ data.tar.gz: 45400ff88afccceafcf353327a53c787d1a7bb5ddeee7ab101f63e9f48cc6ce573b00a6232dedfca7b0860508c710cd4facbf18397eb842b9238c1f59b7f5695
@@ -13,6 +13,9 @@ module Dscf
13
13
  end
14
14
 
15
15
  def authorize_action!
16
+ # Only perform automatic before_action authorization for standard REST actions
17
+ return skip_authorization unless %w[index show create update destroy].include?(action_name)
18
+
16
19
  policy_target = resolve_policy_target
17
20
  return skip_authorization unless policy_target
18
21
 
@@ -10,10 +10,12 @@ module Dscf
10
10
  included do
11
11
  before_action :set_clazz
12
12
  before_action :set_object, only: %i[show update]
13
+ skip_before_action :authorize_action!, raise: false
14
+ before_action :authorize_action!
13
15
  end
14
16
 
15
17
  def index
16
- authorize @clazz.new, :index?
18
+ authorize @clazz.new, "#{action_name}?"
17
19
 
18
20
  data = nil
19
21
  options = {}
@@ -154,13 +154,32 @@ module Dscf
154
154
  def create_retailer_from_signup!(user)
155
155
  return unless defined?(Dscf::Marketplace::Retailer)
156
156
 
157
- Dscf::Marketplace::Retailer.create!(
157
+ retailer = Dscf::Marketplace::Retailer.create!(
158
158
  name: retailer_signup_params[:name],
159
159
  phone: retailer_signup_params[:phone],
160
160
  tin_number: retailer_signup_params[:tin_number],
161
161
  location: retailer_signup_params[:location],
162
162
  user: user
163
163
  )
164
+
165
+ if retailer.location.present? && defined?(Dscf::Core::Address)
166
+ lat_str, lng_str = retailer.location.split(",")
167
+ lat = lat_str.to_f
168
+ lng = lng_str.to_f
169
+
170
+ if lat != 0.0 && lng != 0.0
171
+ Dscf::Core::Address.create!(
172
+ user: user,
173
+ address_type: :shipping,
174
+ country: "Ethiopia",
175
+ city: "Addis Ababa",
176
+ latitude: lat,
177
+ longitude: lng
178
+ )
179
+ end
180
+ end
181
+ rescue => e
182
+ logger.warn "Failed to auto-create address from retailer onboarding location: #{e.message}"
164
183
  end
165
184
 
166
185
  def agent_signup_params
@@ -0,0 +1,42 @@
1
+ module Dscf
2
+ module Core
3
+ class DocumentsController < ApplicationController
4
+ include Common
5
+
6
+ def index
7
+ super do
8
+ business = Business.find(params[:business_id])
9
+ business.documents
10
+ end
11
+ end
12
+
13
+ def create
14
+ super do
15
+ business = Business.find(params[:business_id])
16
+ doc = business.documents.new(document_type: :business_license)
17
+ if params[:document] && params[:document][:file].present?
18
+ doc.files.attach(params[:document][:file])
19
+ end
20
+ doc
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def model_params
27
+ params.require(:document).permit(:document_type, :file)
28
+ end
29
+
30
+ def eager_loaded_associations
31
+ [:files_attachments]
32
+ end
33
+
34
+ def set_object
35
+ business = Business.find(params[:business_id])
36
+ @obj = business.documents.find(params[:id])
37
+ rescue ActiveRecord::RecordNotFound
38
+ render_error("Document not found", status: :not_found)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ module Dscf
2
+ module Core
3
+ class AddressPolicy < ApplicationPolicy
4
+ def index?
5
+ user.present?
6
+ end
7
+
8
+ def show?
9
+ user.present? && (record.user_id == user.id || record.user_id.nil?)
10
+ end
11
+
12
+ def create?
13
+ user.present?
14
+ end
15
+
16
+ def update?
17
+ user.present? && (record.user_id == user.id || record.user_id.nil?)
18
+ end
19
+
20
+ def destroy?
21
+ user.present? && (record.user_id == user.id || record.user_id.nil?)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -22,7 +22,7 @@ module Dscf
22
22
  end
23
23
 
24
24
  def my_business?
25
- user.has_permission?("businesses.index")
25
+ user.present?
26
26
  end
27
27
  end
28
28
  end
@@ -0,0 +1,25 @@
1
+ module Dscf
2
+ module Core
3
+ class DocumentPolicy < ApplicationPolicy
4
+ def index?
5
+ user.present?
6
+ end
7
+
8
+ def show?
9
+ user.present? && (record.documentable.respond_to?(:user_id) ? record.documentable.user_id == user.id : true)
10
+ end
11
+
12
+ def create?
13
+ user.present?
14
+ end
15
+
16
+ def update?
17
+ user.present? && (record.documentable.respond_to?(:user_id) ? record.documentable.user_id == user.id : true)
18
+ end
19
+
20
+ def destroy?
21
+ user.present? && (record.documentable.respond_to?(:user_id) ? record.documentable.user_id == user.id : true)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,7 +1,7 @@
1
1
  module Dscf
2
2
  module Core
3
3
  class DocumentSerializer < ActiveModel::Serializer
4
- attributes :id, :document_type, :file_urls, :is_verified, :verified_at, :created_at, :updated_at
4
+ attributes :id, :document_type, :file_urls, :is_verified, :verified_at, :created_at, :updated_at, :name, :url
5
5
 
6
6
  belongs_to :documentable, polymorphic: true
7
7
  belongs_to :verified_by, polymorphic: true, optional: true
@@ -9,6 +9,14 @@ module Dscf
9
9
  def file_urls
10
10
  object.file_urls
11
11
  end
12
+
13
+ def name
14
+ object.files.attached? ? object.files.first.filename.to_s : object.document_type.to_s.titleize
15
+ end
16
+
17
+ def url
18
+ object.file_urls.first
19
+ end
12
20
  end
13
21
  end
14
22
  end
@@ -1,79 +1,106 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "aws-sdk-s3"
4
+ require "securerandom"
5
+
3
6
  module Dscf
4
7
  module Core
5
8
  module FileStorage
6
- # Low-level HTTP client for MinIO service
7
- # This is an internal class - other engines should NOT use this directly
9
+ # Low-level S3 client for object storage (Garage / MinIO / any S3-compatible service).
10
+ # This is an internal class - other engines should NOT use this directly; go through
11
+ # FileStorage, Uploader, or the Attachable concern instead.
12
+ #
13
+ # Configuration is read from the environment:
14
+ # S3_ENDPOINT_URL - e.g. http://garage:3900 (required)
15
+ # S3_BUCKET - bucket name (required)
16
+ # S3_ACCESS_KEY_ID - access key (required)
17
+ # S3_SECRET_ACCESS_KEY - secret key (required)
18
+ # S3_REGION - region (optional, defaults to "garage")
19
+ #
20
+ # File keys are flat UUIDs (no "/") so they stay valid for the
21
+ # `files/:file_key` route constraint (`/[^\/]+/`).
8
22
  class Client
9
- include HTTParty
10
-
11
23
  class UploadError < StandardError; end
12
24
  class DownloadError < StandardError; end
13
25
  class ConfigurationError < StandardError; end
14
26
 
27
+ DEFAULT_REGION = "garage"
28
+ DEFAULT_CONTENT_TYPE = "application/octet-stream"
29
+
30
+ # @param app_name [String, nil] retained for backwards compatibility (unused)
31
+ # @param record_class [Class, nil] retained for backwards compatibility (unused)
15
32
  def initialize(app_name: nil, record_class: nil)
16
- @base_uri = ENV.fetch("MINIO_SERVICE_URL", "http://localhost:3001")
17
- @record_class = record_class
18
- @api_key = resolve_api_key(app_name)
33
+ @endpoint = ENV["S3_ENDPOINT_URL"].presence
34
+ @bucket = ENV["S3_BUCKET"].presence
35
+ @access_key_id = ENV["S3_ACCESS_KEY_ID"].presence
36
+ @secret_access_key = ENV["S3_SECRET_ACCESS_KEY"].presence
37
+ @region = ENV.fetch("S3_REGION", DEFAULT_REGION)
19
38
  validate_configuration!
20
39
  end
21
40
 
22
- # Upload a file to MinIO
23
- # @param file [ActionDispatch::Http::UploadedFile, File, IO] the file to upload
41
+ # Upload a file to object storage.
42
+ # @param file [ActionDispatch::Http::UploadedFile, File, Hash] the file to upload
24
43
  # @param filename [String] optional custom filename
25
44
  # @return [Hash] { file_key:, filename:, size:, content_type: }
26
45
  def upload(file, filename: nil)
27
46
  prepared = prepare_file(file, filename)
28
-
29
- response = self.class.post(
30
- "#{@base_uri}/upload",
31
- headers: auth_headers,
32
- body: {file: prepared[:io]},
33
- multipart: true,
34
- timeout: 60
47
+ file_key = generate_file_key(prepared[:filename])
48
+ io = prepared[:io]
49
+ io.rewind if io.respond_to?(:rewind)
50
+
51
+ s3.put_object(
52
+ bucket: @bucket,
53
+ key: file_key,
54
+ body: io,
55
+ content_type: prepared[:content_type] || DEFAULT_CONTENT_TYPE,
56
+ metadata: {"filename" => prepared[:filename].to_s}
35
57
  )
36
58
 
37
- handle_upload_response(response, prepared)
59
+ {
60
+ file_key: file_key,
61
+ filename: prepared[:filename],
62
+ size: prepared[:size],
63
+ content_type: prepared[:content_type]
64
+ }
65
+ rescue Aws::Errors::ServiceError => e
66
+ raise UploadError, "Upload failed: #{e.message}"
38
67
  end
39
68
 
40
- # Download a file from MinIO
41
- # @param file_key [String] the file key/name in MinIO
69
+ # Download a file from object storage.
70
+ # @param file_key [String] the object key
42
71
  # @return [Hash] { data:, filename:, content_type: }
43
72
  def download(file_key)
44
- response = self.class.get(
45
- "#{@base_uri}/download/#{URI.encode_www_form_component(file_key)}",
46
- headers: auth_headers,
47
- timeout: 60
48
- )
73
+ response = s3.get_object(bucket: @bucket, key: file_key)
49
74
 
50
- handle_download_response(response, file_key)
75
+ {
76
+ data: response.body.read,
77
+ filename: response.metadata["filename"].presence || file_key,
78
+ content_type: response.content_type.presence || DEFAULT_CONTENT_TYPE
79
+ }
80
+ rescue Aws::S3::Errors::NoSuchKey => e
81
+ raise DownloadError, "File not found: #{file_key} (#{e.message})"
82
+ rescue Aws::Errors::ServiceError => e
83
+ raise DownloadError, "Download failed: #{e.message}"
51
84
  end
52
85
 
53
- # Check if file exists
54
- # @param file_key [String] the file key/name in MinIO
86
+ # Check if an object exists.
87
+ # @param file_key [String] the object key
55
88
  # @return [Boolean]
56
89
  def exists?(file_key)
57
- response = self.class.head(
58
- "#{@base_uri}/download/#{URI.encode_www_form_component(file_key)}",
59
- headers: auth_headers,
60
- timeout: 10
61
- )
62
- response.success?
90
+ s3.head_object(bucket: @bucket, key: file_key)
91
+ true
92
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
93
+ false
63
94
  rescue StandardError
64
95
  false
65
96
  end
66
97
 
67
- # Delete a file from MinIO
68
- # @param file_key [String] the file key/name in MinIO
98
+ # Delete an object.
99
+ # @param file_key [String] the object key
69
100
  # @return [Boolean]
70
101
  def delete(file_key)
71
- response = self.class.delete(
72
- "#{@base_uri}/delete/#{URI.encode_www_form_component(file_key)}",
73
- headers: auth_headers,
74
- timeout: 30
75
- )
76
- response.success?
102
+ s3.delete_object(bucket: @bucket, key: file_key)
103
+ true
77
104
  rescue StandardError => e
78
105
  Rails.logger.error("FileStorage::Client delete error: #{e.message}")
79
106
  false
@@ -81,60 +108,32 @@ module Dscf
81
108
 
82
109
  private
83
110
 
84
- def resolve_api_key(app_name)
85
- # Priority: explicit app name -> engine-specific -> default
86
- key_name = app_name&.upcase&.gsub("-", "_") || detect_engine_name
87
-
88
- # Check all possible env var keys
89
- possible_keys = [
90
- "MINIO_API_KEY_DSCF_#{key_name}",
91
- "MINIO_API_KEY_#{key_name}",
92
- "MINIO_API_KEY"
93
- ]
94
-
95
- api_key = possible_keys.find { |k| ENV[k].present? }.then { |k| ENV[k] }
96
-
97
- Rails.logger.debug("MinIO API Key lookup: key_name=#{key_name}, tried=#{possible_keys.inspect}, found=#{api_key.present?}")
98
- Rails.logger.debug("Available MINIO env vars: #{ENV.select { |k, _v| k.start_with?('MINIO_') }.keys.inspect}")
99
-
100
- api_key
101
- end
102
-
103
- def detect_engine_name
104
- # First try to detect from record class namespace
105
- if @record_class
106
- namespace = @record_class.name.split("::")[1] # Dscf::Credit::Bank -> Credit
107
- if namespace && namespace != "Core"
108
- Rails.logger.debug("MinIO engine detection: from record_class=#{@record_class.name}, detected=#{namespace.upcase}")
109
- return namespace.upcase
110
- end
111
- end
112
-
113
- # Fallback: Auto-detect engine based on caller (skip dscf_core since storage is in core)
114
- caller_paths = caller.select { |c| c.include?("/dscf_") && !c.include?("/dscf_core/") }
115
-
116
- caller_path = caller_paths.first
117
- return "CORE" unless caller_path
118
-
119
- match = caller_path.match(%r{/dscf_(\w+)/})
120
- detected = match ? match[1].upcase : "CORE"
121
-
122
- Rails.logger.debug("MinIO engine detection: caller_path=#{caller_path}, detected=#{detected}")
123
- detected
111
+ def s3
112
+ @s3 ||= Aws::S3::Client.new(
113
+ endpoint: @endpoint,
114
+ region: @region,
115
+ access_key_id: @access_key_id,
116
+ secret_access_key: @secret_access_key,
117
+ force_path_style: true
118
+ )
124
119
  end
125
120
 
126
121
  def validate_configuration!
127
- raise ConfigurationError, "MINIO_SERVICE_URL is not configured" if @base_uri.blank?
122
+ missing = []
123
+ missing << "S3_ENDPOINT_URL" if @endpoint.blank?
124
+ missing << "S3_BUCKET" if @bucket.blank?
125
+ missing << "S3_ACCESS_KEY_ID" if @access_key_id.blank?
126
+ missing << "S3_SECRET_ACCESS_KEY" if @secret_access_key.blank?
128
127
 
129
- return unless @api_key.blank?
128
+ return if missing.empty?
130
129
 
131
- detected = detect_engine_name
132
- raise ConfigurationError,
133
- "MINIO_API_KEY is not configured. Looked for: MINIO_API_KEY_DSCF_#{detected}, MINIO_API_KEY_#{detected}, MINIO_API_KEY"
130
+ raise ConfigurationError, "Object storage is not configured. Missing: #{missing.join(', ')}"
134
131
  end
135
132
 
136
- def auth_headers
137
- {"x-api-key" => @api_key}
133
+ # Flat, unique, slash-free key (kept compatible with the files/:file_key route).
134
+ def generate_file_key(filename)
135
+ ext = filename ? File.extname(filename.to_s).downcase : ""
136
+ "#{SecureRandom.uuid}#{ext}"
138
137
  end
139
138
 
140
139
  def prepare_file(file, custom_filename)
@@ -166,44 +165,6 @@ module Dscf
166
165
  raise ArgumentError, "Unsupported file type: #{file.class}"
167
166
  end
168
167
  end
169
-
170
- def handle_upload_response(response, prepared)
171
- unless response.success?
172
- error_message = response.parsed_response&.dig("message") || "Upload failed"
173
- raise UploadError, "#{error_message} (HTTP #{response.code})"
174
- end
175
-
176
- body = response.parsed_response
177
- {
178
- file_key: body["fileName"],
179
- filename: prepared[:filename],
180
- size: body["size"] || prepared[:size],
181
- content_type: body["mimeType"] || prepared[:content_type]
182
- }
183
- end
184
-
185
- def handle_download_response(response, file_key)
186
- unless response.success?
187
- error_message = response.parsed_response&.dig("message") || "Download failed"
188
- raise DownloadError, "#{error_message} (HTTP #{response.code})"
189
- end
190
-
191
- content_disposition = response.headers["content-disposition"]
192
- filename = extract_filename(content_disposition) || file_key
193
-
194
- {
195
- data: response.body,
196
- filename: filename,
197
- content_type: response.headers["content-type"] || "application/octet-stream"
198
- }
199
- end
200
-
201
- def extract_filename(content_disposition)
202
- return nil unless content_disposition
203
-
204
- match = content_disposition.match(/filename="?([^";\s]+)"?/)
205
- match&.[](1)
206
- end
207
168
  end
208
169
  end
209
170
  end
@@ -5,9 +5,14 @@ module Dscf
5
5
  class NotificationService
6
6
  class << self
7
7
  def deliver(notification)
8
+ phone = notification.recipient&.phone
9
+ raise ArgumentError, "Recipient phone is blank" if phone.blank?
8
10
  adapter = resolve_adapter
9
- adapter.send_sms(notification.recipient.phone, notification.body)
11
+ adapter.send_sms(phone, notification.body)
10
12
  notification.update!(status: :delivered, delivered_at: Time.current)
13
+ rescue => e
14
+ Rails.logger.error("Notification delivery failed: #{e.message}")
15
+ notification.update(status: :failed) rescue nil
11
16
  end
12
17
 
13
18
  def resolve_adapter
data/config/routes.rb CHANGED
@@ -13,6 +13,7 @@ Dscf::Core::Engine.routes.draw do
13
13
  patch "request_modification"
14
14
  patch "resubmit"
15
15
  end
16
+ resources :documents, only: %i[index create destroy], controller: "documents"
16
17
  end
17
18
 
18
19
  resources :roles do
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Core
3
- VERSION = "0.3.7".freeze
3
+ VERSION = "0.3.9".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dscf-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.7
4
+ version: 0.3.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asrat
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: 4.1.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: aws-sdk-s3
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: bcrypt
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -442,6 +456,7 @@ files:
442
456
  - app/controllers/dscf/core/auth_controller.rb
443
457
  - app/controllers/dscf/core/business_types_controller.rb
444
458
  - app/controllers/dscf/core/businesses_controller.rb
459
+ - app/controllers/dscf/core/documents_controller.rb
445
460
  - app/controllers/dscf/core/fayda_verifications_controller.rb
446
461
  - app/controllers/dscf/core/files_controller.rb
447
462
  - app/controllers/dscf/core/permissions_controller.rb
@@ -475,9 +490,11 @@ files:
475
490
  - app/models/dscf/core/user_profile.rb
476
491
  - app/models/dscf/core/user_role.rb
477
492
  - app/policies/dscf/core/account_management_policy.rb
493
+ - app/policies/dscf/core/address_policy.rb
478
494
  - app/policies/dscf/core/application_policy.rb
479
495
  - app/policies/dscf/core/business_policy.rb
480
496
  - app/policies/dscf/core/business_type_policy.rb
497
+ - app/policies/dscf/core/document_policy.rb
481
498
  - app/policies/dscf/core/permission_policy.rb
482
499
  - app/policies/dscf/core/role_policy.rb
483
500
  - app/serializers/dscf/core/address_serializer.rb