vert-core 1.0.0
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 +7 -0
- data/CHANGELOG.md +14 -0
- data/README.md +126 -0
- data/lib/vert/authorization/controller_methods.rb +84 -0
- data/lib/vert/authorization/dynamic_policy.rb +156 -0
- data/lib/vert/authorization/permission_resolver.rb +253 -0
- data/lib/vert/authorization/policy_finder.rb +72 -0
- data/lib/vert/clients/document_service_client.rb +104 -0
- data/lib/vert/concerns/auditable.rb +24 -0
- data/lib/vert/concerns/company_scoped.rb +48 -0
- data/lib/vert/concerns/current_attributes.rb +53 -0
- data/lib/vert/concerns/document_storeable.rb +180 -0
- data/lib/vert/concerns/multi_tenant.rb +45 -0
- data/lib/vert/concerns/soft_deletable.rb +46 -0
- data/lib/vert/concerns/uuid_primary_key.rb +42 -0
- data/lib/vert/configuration.rb +65 -0
- data/lib/vert/generators/install_generator.rb +66 -0
- data/lib/vert/generators/rls_migration_generator.rb +57 -0
- data/lib/vert/generators/templates/application_record.rb.tt +8 -0
- data/lib/vert/generators/templates/create_outbox_events.rb.tt +24 -0
- data/lib/vert/generators/templates/create_rls_functions.rb.tt +27 -0
- data/lib/vert/generators/templates/current.rb.tt +10 -0
- data/lib/vert/generators/templates/enable_rls_on_tables.rb.tt +39 -0
- data/lib/vert/generators/templates/health_controller.rb.tt +5 -0
- data/lib/vert/generators/templates/initializer.rb.tt +39 -0
- data/lib/vert/generators/templates/outbox_event.rb.tt +11 -0
- data/lib/vert/health/checker.rb +119 -0
- data/lib/vert/health/routes.rb +44 -0
- data/lib/vert/outbox/event.rb +68 -0
- data/lib/vert/outbox/publisher.rb +105 -0
- data/lib/vert/outbox/publisher_job.rb +30 -0
- data/lib/vert/railtie.rb +54 -0
- data/lib/vert/rls/connection_handler.rb +56 -0
- data/lib/vert/rls/consumer_context.rb +31 -0
- data/lib/vert/rls/context_middleware.rb +37 -0
- data/lib/vert/rls/job_context.rb +56 -0
- data/lib/vert/version.rb +5 -0
- data/lib/vert.rb +58 -0
- data/vert.gemspec +43 -0
- metadata +223 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Authorization
|
|
5
|
+
class PolicyFinder
|
|
6
|
+
attr_reader :object
|
|
7
|
+
|
|
8
|
+
def initialize(object)
|
|
9
|
+
@object = object
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def policy
|
|
13
|
+
find_policy || DynamicPolicy
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def policy!
|
|
17
|
+
policy || raise(Pundit::NotDefinedError, "Unable to find policy for #{object}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def scope
|
|
21
|
+
find_scope || DynamicPolicy::Scope
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def scope!
|
|
25
|
+
scope || raise(Pundit::NotDefinedError, "Unable to find scope for #{object}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def find_policy
|
|
31
|
+
specific_policy = "#{model_name}Policy"
|
|
32
|
+
return Object.const_get(specific_policy) if Object.const_defined?(specific_policy)
|
|
33
|
+
|
|
34
|
+
namespaced_policy = "#{model_namespace}::#{model_class_name}Policy"
|
|
35
|
+
return Object.const_get(namespaced_policy) if Object.const_defined?(namespaced_policy)
|
|
36
|
+
|
|
37
|
+
service_policy = "#{service_name}::DynamicPolicy"
|
|
38
|
+
return Object.const_get(service_policy) if Object.const_defined?(service_policy)
|
|
39
|
+
|
|
40
|
+
nil
|
|
41
|
+
rescue NameError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_scope
|
|
46
|
+
policy_class = find_policy
|
|
47
|
+
return nil unless policy_class
|
|
48
|
+
policy_class.const_defined?(:Scope) ? policy_class::Scope : nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def model_name
|
|
52
|
+
case object
|
|
53
|
+
when Class then object.name
|
|
54
|
+
when Symbol, String then object.to_s.camelize
|
|
55
|
+
else object.class.name
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def model_class_name
|
|
60
|
+
model_name.demodulize
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def model_namespace
|
|
64
|
+
model_name.deconstantize.presence || "App"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def service_name
|
|
68
|
+
model_namespace.underscore.split("/").first.camelize
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "base64"
|
|
7
|
+
|
|
8
|
+
module Vert
|
|
9
|
+
module Clients
|
|
10
|
+
class DocumentServiceClient
|
|
11
|
+
attr_reader :base_url, :timeout
|
|
12
|
+
|
|
13
|
+
def initialize(base_url: nil, timeout: 30)
|
|
14
|
+
@base_url = base_url || Vert.config.document_service_url
|
|
15
|
+
@timeout = timeout
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def upload(resource:, filename:, content:, content_type:, metadata: {})
|
|
19
|
+
uri = URI.parse("#{base_url}/api/v1/objects")
|
|
20
|
+
request = Net::HTTP::Post.new(uri)
|
|
21
|
+
request["Content-Type"] = "application/json"
|
|
22
|
+
add_auth_headers(request)
|
|
23
|
+
request.body = {
|
|
24
|
+
object: {
|
|
25
|
+
resource: resource,
|
|
26
|
+
original_filename: filename,
|
|
27
|
+
content_type: content_type,
|
|
28
|
+
content_base64: Base64.strict_encode64(content.to_s),
|
|
29
|
+
metadata: metadata
|
|
30
|
+
}
|
|
31
|
+
}.to_json
|
|
32
|
+
execute_request(uri, request)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def download_url(object_id:, disposition: "inline", expires_in: 3600)
|
|
36
|
+
uri = URI.parse("#{base_url}/api/v1/objects/#{object_id}/download_url")
|
|
37
|
+
uri.query = URI.encode_www_form(disposition: disposition, expires_in: expires_in)
|
|
38
|
+
request = Net::HTTP::Get.new(uri)
|
|
39
|
+
add_auth_headers(request)
|
|
40
|
+
execute_request(uri, request)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def download(object_id:)
|
|
44
|
+
result = download_url(object_id: object_id)
|
|
45
|
+
return nil unless result[:success]
|
|
46
|
+
url = result[:data][:url]
|
|
47
|
+
response = Net::HTTP.get_response(URI.parse(url))
|
|
48
|
+
response.body if response.is_a?(Net::HTTPSuccess)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def object(object_id)
|
|
52
|
+
uri = URI.parse("#{base_url}/api/v1/objects/#{object_id}")
|
|
53
|
+
request = Net::HTTP::Get.new(uri)
|
|
54
|
+
add_auth_headers(request)
|
|
55
|
+
execute_request(uri, request)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def delete_object(object_id)
|
|
59
|
+
uri = URI.parse("#{base_url}/api/v1/objects/#{object_id}")
|
|
60
|
+
request = Net::HTTP::Delete.new(uri)
|
|
61
|
+
add_auth_headers(request)
|
|
62
|
+
execute_request(uri, request)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def list_objects(resource: nil, page: 1, per_page: 20)
|
|
66
|
+
uri = URI.parse("#{base_url}/api/v1/objects")
|
|
67
|
+
params = { page: page, per_page: per_page }
|
|
68
|
+
params[:resource] = resource if resource
|
|
69
|
+
uri.query = URI.encode_www_form(params)
|
|
70
|
+
request = Net::HTTP::Get.new(uri)
|
|
71
|
+
add_auth_headers(request)
|
|
72
|
+
execute_request(uri, request)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def execute_request(uri, request)
|
|
78
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
79
|
+
http.use_ssl = uri.scheme == "https"
|
|
80
|
+
http.open_timeout = timeout
|
|
81
|
+
http.read_timeout = timeout
|
|
82
|
+
response = http.request(request)
|
|
83
|
+
case response
|
|
84
|
+
when Net::HTTPSuccess
|
|
85
|
+
body = JSON.parse(response.body, symbolize_names: true)
|
|
86
|
+
{ success: true, data: body[:data] || body }
|
|
87
|
+
else
|
|
88
|
+
body = (JSON.parse(response.body, symbolize_names: true) rescue {})
|
|
89
|
+
{ success: false, error: body[:error] || response.message, status: response.code.to_i }
|
|
90
|
+
end
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
{ success: false, error: e.message }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def add_auth_headers(request)
|
|
96
|
+
request["Accept"] = "application/json"
|
|
97
|
+
request["X-Tenant-ID"] = Vert::Current.tenant_id.to_s if Vert::Current.tenant_id
|
|
98
|
+
request["X-Company-ID"] = Vert::Current.company_id.to_s if Vert::Current.company_id
|
|
99
|
+
request["X-User-ID"] = Vert::Current.user_id.to_s if Vert::Current.user_id
|
|
100
|
+
request["X-Request-ID"] = Vert::Current.request_id.to_s if Vert::Current.request_id
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Concerns
|
|
5
|
+
module Auditable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
before_create :set_created_by
|
|
10
|
+
before_update :set_updated_by
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def set_created_by
|
|
16
|
+
self.created_by ||= Vert::Current.user_id if has_attribute?(:created_by)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set_updated_by
|
|
20
|
+
self.updated_by = Vert::Current.user_id if has_attribute?(:updated_by)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Concerns
|
|
5
|
+
module CompanyScoped
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
validates :company_id, presence: true
|
|
10
|
+
validate :company_belongs_to_tenant, if: -> { tenant_id.present? && company_id.present? }
|
|
11
|
+
default_scope do
|
|
12
|
+
if Vert::Current.company_id.present?
|
|
13
|
+
where(company_id: Vert::Current.company_id)
|
|
14
|
+
else
|
|
15
|
+
all
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
before_validation :set_company_id, on: :create
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
def for_company(company_id)
|
|
23
|
+
unscope(where: :company_id).where(company_id: company_id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def all_companies
|
|
27
|
+
unscope(where: :company_id)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def belongs_to_current_company?(id)
|
|
31
|
+
exists?(id: id)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def set_company_id
|
|
38
|
+
self.company_id ||= Vert::Current.company_id
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def company_belongs_to_tenant
|
|
42
|
+
return unless defined?(Company)
|
|
43
|
+
return if Company.unscoped.exists?(id: company_id, tenant_id: tenant_id)
|
|
44
|
+
errors.add(:company_id, "does not belong to the current tenant")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Concerns
|
|
5
|
+
# CurrentAttributes - Thread-safe request context
|
|
6
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
7
|
+
attribute :tenant_id, :company_id, :user_id, :request_id, :rls_configured
|
|
8
|
+
|
|
9
|
+
def self.reset_all
|
|
10
|
+
reset
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.set_context(tenant_id:, user_id: nil, company_id: nil, request_id: nil)
|
|
14
|
+
self.tenant_id = tenant_id
|
|
15
|
+
self.user_id = user_id
|
|
16
|
+
self.company_id = company_id
|
|
17
|
+
self.request_id = request_id || SecureRandom.uuid
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.serialize
|
|
21
|
+
{ tenant_id: tenant_id, user_id: user_id, company_id: company_id, request_id: request_id }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.deserialize(hash)
|
|
25
|
+
return unless hash.is_a?(Hash)
|
|
26
|
+
set_context(
|
|
27
|
+
tenant_id: hash[:tenant_id] || hash["tenant_id"],
|
|
28
|
+
user_id: hash[:user_id] || hash["user_id"],
|
|
29
|
+
company_id: hash[:company_id] || hash["company_id"],
|
|
30
|
+
request_id: hash[:request_id] || hash["request_id"]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.tenant_set?
|
|
35
|
+
tenant_id.present?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.company_set?
|
|
39
|
+
company_id.present?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.require_tenant!
|
|
43
|
+
raise Vert::TenantNotSetError, "Tenant context not set" unless tenant_set?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.require_company!
|
|
47
|
+
raise Vert::CompanyNotSetError, "Company context not set" unless company_set?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Current = Concerns::Current
|
|
53
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Concerns
|
|
5
|
+
module DocumentStoreable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
class_attribute :document_attachments, default: {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def has_document(name, resource:, content_type: nil)
|
|
14
|
+
document_attachments[name] = { resource: resource, content_type: content_type }
|
|
15
|
+
|
|
16
|
+
define_method(name) do
|
|
17
|
+
object_id = send("#{name}_object_id")
|
|
18
|
+
return nil unless object_id.present?
|
|
19
|
+
@document_cache ||= {}
|
|
20
|
+
@document_cache[name] ||= Vert::Concerns::DocumentStoreable::DocumentAttachment.new(
|
|
21
|
+
object_id: object_id,
|
|
22
|
+
resource: document_attachments[name][:resource],
|
|
23
|
+
owner: self
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
define_method("#{name}=") do |value|
|
|
28
|
+
return if value.nil?
|
|
29
|
+
result = attach_document(name, value)
|
|
30
|
+
if result[:success]
|
|
31
|
+
send("#{name}_object_id=", result[:data][:id])
|
|
32
|
+
@document_cache&.delete(name)
|
|
33
|
+
else
|
|
34
|
+
errors.add(name, "upload failed: #{result[:error]}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
define_method("attach_#{name}") do |content:, filename:, content_type: nil|
|
|
39
|
+
config = self.class.document_attachments[name]
|
|
40
|
+
ct = content_type || config[:content_type] || detect_content_type(filename)
|
|
41
|
+
result = document_service_client.upload(
|
|
42
|
+
resource: config[:resource],
|
|
43
|
+
filename: filename,
|
|
44
|
+
content: content,
|
|
45
|
+
content_type: ct,
|
|
46
|
+
metadata: document_metadata(name)
|
|
47
|
+
)
|
|
48
|
+
if result[:success]
|
|
49
|
+
send("#{name}_object_id=", result[:data][:id])
|
|
50
|
+
@document_cache&.delete(name)
|
|
51
|
+
else
|
|
52
|
+
errors.add(name, "upload failed: #{result[:error]}")
|
|
53
|
+
end
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
define_method("#{name}_attached?") { send("#{name}_object_id").present? }
|
|
58
|
+
|
|
59
|
+
define_method("purge_#{name}") do
|
|
60
|
+
object_id = send("#{name}_object_id")
|
|
61
|
+
return true unless object_id.present?
|
|
62
|
+
result = document_service_client.delete_object(object_id)
|
|
63
|
+
if result[:success]
|
|
64
|
+
send("#{name}_object_id=", nil)
|
|
65
|
+
@document_cache&.delete(name)
|
|
66
|
+
true
|
|
67
|
+
else
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def document_service_client
|
|
77
|
+
@document_service_client ||= Vert::Clients::DocumentServiceClient.new
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def attach_document(name, value)
|
|
81
|
+
config = self.class.document_attachments[name]
|
|
82
|
+
case value
|
|
83
|
+
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
|
|
84
|
+
document_service_client.upload(
|
|
85
|
+
resource: config[:resource],
|
|
86
|
+
filename: value.original_filename,
|
|
87
|
+
content: value.read,
|
|
88
|
+
content_type: value.content_type || config[:content_type],
|
|
89
|
+
metadata: document_metadata(name)
|
|
90
|
+
)
|
|
91
|
+
when Hash
|
|
92
|
+
document_service_client.upload(
|
|
93
|
+
resource: config[:resource],
|
|
94
|
+
filename: value[:filename],
|
|
95
|
+
content: value[:content],
|
|
96
|
+
content_type: value[:content_type] || config[:content_type],
|
|
97
|
+
metadata: document_metadata(name)
|
|
98
|
+
)
|
|
99
|
+
when String
|
|
100
|
+
document_service_client.upload(
|
|
101
|
+
resource: config[:resource],
|
|
102
|
+
filename: "#{name}_#{id || SecureRandom.uuid}.bin",
|
|
103
|
+
content: value,
|
|
104
|
+
content_type: config[:content_type] || "application/octet-stream",
|
|
105
|
+
metadata: document_metadata(name)
|
|
106
|
+
)
|
|
107
|
+
else
|
|
108
|
+
{ success: false, error: "Invalid value type for document" }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def document_metadata(name)
|
|
113
|
+
metadata = { owner_type: self.class.name, owner_id: id, field_name: name.to_s }
|
|
114
|
+
metadata[:tenant_id] = tenant_id if respond_to?(:tenant_id)
|
|
115
|
+
metadata[:company_id] = company_id if respond_to?(:company_id)
|
|
116
|
+
metadata
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def detect_content_type(filename)
|
|
120
|
+
ext = File.extname(filename).downcase
|
|
121
|
+
CONTENT_TYPES[ext] || "application/octet-stream"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
CONTENT_TYPES = {
|
|
125
|
+
".xml" => "application/xml", ".pdf" => "application/pdf",
|
|
126
|
+
".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".gif" => "image/gif",
|
|
127
|
+
".csv" => "text/csv", ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
128
|
+
".xls" => "application/vnd.ms-excel", ".doc" => "application/msword",
|
|
129
|
+
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
130
|
+
".zip" => "application/zip", ".txt" => "text/plain", ".json" => "application/json"
|
|
131
|
+
}.freeze
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class DocumentStoreable::DocumentAttachment
|
|
135
|
+
attr_reader :storage_object_id, :resource, :owner
|
|
136
|
+
|
|
137
|
+
def initialize(object_id:, resource:, owner:)
|
|
138
|
+
@storage_object_id = object_id
|
|
139
|
+
@resource = resource
|
|
140
|
+
@owner = owner
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def url(disposition: "inline", expires_in: 3600)
|
|
144
|
+
result = client.download_url(object_id: storage_object_id, disposition: disposition, expires_in: expires_in)
|
|
145
|
+
result[:success] ? result[:data][:url] : nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def download
|
|
149
|
+
client.download(object_id: storage_object_id)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def details
|
|
153
|
+
result = client.object(storage_object_id)
|
|
154
|
+
result[:success] ? result[:data] : nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def filename
|
|
158
|
+
details&.dig(:original_filename)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def content_type
|
|
162
|
+
details&.dig(:mime_type)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def byte_size
|
|
166
|
+
details&.dig(:size)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def attached?
|
|
170
|
+
storage_object_id.present?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def client
|
|
176
|
+
@client ||= Vert::Clients::DocumentServiceClient.new
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Concerns
|
|
5
|
+
module MultiTenant
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
validates :tenant_id, presence: true, if: :require_tenant_id?
|
|
10
|
+
default_scope do
|
|
11
|
+
if Vert::Current.tenant_id.present?
|
|
12
|
+
where(tenant_id: Vert::Current.tenant_id)
|
|
13
|
+
else
|
|
14
|
+
all
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
before_validation :set_tenant_id, on: :create
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class_methods do
|
|
21
|
+
def unscoped_for_tenant(tenant_id)
|
|
22
|
+
unscoped.where(tenant_id: tenant_id)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def all_tenants
|
|
26
|
+
unscoped
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def belongs_to_current_tenant?(id)
|
|
30
|
+
exists?(id: id)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def require_tenant_id?
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def set_tenant_id
|
|
41
|
+
self.tenant_id ||= Vert::Current.tenant_id
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "discard"
|
|
4
|
+
|
|
5
|
+
module Vert
|
|
6
|
+
module Concerns
|
|
7
|
+
module SoftDeletable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
include ::Discard::Model
|
|
12
|
+
default_scope -> { kept }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class_methods do
|
|
16
|
+
def deleted
|
|
17
|
+
discarded
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def active
|
|
21
|
+
kept
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_deleted
|
|
25
|
+
with_discarded
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def only_deleted
|
|
29
|
+
discarded
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def soft_delete
|
|
34
|
+
discard
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def restore
|
|
38
|
+
undiscard
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def deleted?
|
|
42
|
+
discarded?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Concerns
|
|
5
|
+
module UuidPrimaryKey
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
before_create :set_uuid
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def set_uuid
|
|
15
|
+
return unless has_attribute?(:id)
|
|
16
|
+
return if id.present?
|
|
17
|
+
self.id = generate_uuid_v7
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def generate_uuid_v7
|
|
21
|
+
if SecureRandom.respond_to?(:uuid_v7)
|
|
22
|
+
SecureRandom.uuid_v7
|
|
23
|
+
else
|
|
24
|
+
generate_uuid_v7_fallback
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def generate_uuid_v7_fallback
|
|
29
|
+
timestamp_ms = (Time.now.to_f * 1000).to_i
|
|
30
|
+
random_bytes = SecureRandom.random_bytes(10)
|
|
31
|
+
bytes = [
|
|
32
|
+
(timestamp_ms >> 40) & 0xFF, (timestamp_ms >> 32) & 0xFF, (timestamp_ms >> 24) & 0xFF,
|
|
33
|
+
(timestamp_ms >> 16) & 0xFF, (timestamp_ms >> 8) & 0xFF, timestamp_ms & 0xFF,
|
|
34
|
+
(0x70 | (random_bytes[0] & 0x0F)), random_bytes[1], (0x80 | (random_bytes[2] & 0x3F)),
|
|
35
|
+
random_bytes[3], random_bytes[4], random_bytes[5], random_bytes[6], random_bytes[7], random_bytes[8], random_bytes[9]
|
|
36
|
+
].pack("C*")
|
|
37
|
+
hex = bytes.unpack1("H*")
|
|
38
|
+
"#{hex[0..7]}-#{hex[8..11]}-#{hex[12..15]}-#{hex[16..19]}-#{hex[20..31]}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
# Configuração central da gem. Todos os recursos são opcionais e ativados no initializer.
|
|
5
|
+
#
|
|
6
|
+
# @example config/initializers/vert.rb
|
|
7
|
+
# Vert.configure do |config|
|
|
8
|
+
# config.enable_rls = true
|
|
9
|
+
# config.enable_outbox = true
|
|
10
|
+
# config.enable_health = true
|
|
11
|
+
# config.rabbitmq_url = ENV["RABBITMQ_URL"]
|
|
12
|
+
# end
|
|
13
|
+
class Configuration
|
|
14
|
+
# --- Flags de funcionalidade (todos opcionais) ---
|
|
15
|
+
attr_accessor :enable_rls,
|
|
16
|
+
:enable_outbox,
|
|
17
|
+
:enable_health,
|
|
18
|
+
:enable_authorization,
|
|
19
|
+
:enable_multi_tenant,
|
|
20
|
+
:enable_auditable,
|
|
21
|
+
:enable_soft_deletable,
|
|
22
|
+
:enable_uuid_primary_key,
|
|
23
|
+
:enable_company_scoped,
|
|
24
|
+
:enable_document_storeable
|
|
25
|
+
|
|
26
|
+
# --- RLS (Row Level Security) ---
|
|
27
|
+
attr_accessor :rls_user
|
|
28
|
+
|
|
29
|
+
# --- RabbitMQ / Outbox ---
|
|
30
|
+
attr_accessor :rabbitmq_url, :exchange_name
|
|
31
|
+
|
|
32
|
+
# --- Document service client ---
|
|
33
|
+
attr_accessor :document_service_url
|
|
34
|
+
|
|
35
|
+
# --- Health ---
|
|
36
|
+
attr_accessor :health_check_path, :auto_mount_health_routes,
|
|
37
|
+
:health_check_database, :health_check_redis,
|
|
38
|
+
:health_check_rabbitmq, :health_check_sidekiq
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
# Funcionalidades desativadas por padrão; ative no initializer conforme necessário
|
|
42
|
+
@enable_rls = false
|
|
43
|
+
@enable_outbox = false
|
|
44
|
+
@enable_health = true
|
|
45
|
+
@enable_authorization = false
|
|
46
|
+
@enable_multi_tenant = false
|
|
47
|
+
@enable_auditable = false
|
|
48
|
+
@enable_soft_deletable = false
|
|
49
|
+
@enable_uuid_primary_key = false
|
|
50
|
+
@enable_company_scoped = false
|
|
51
|
+
@enable_document_storeable = false
|
|
52
|
+
|
|
53
|
+
@rls_user = ENV.fetch("RLS_USER", "app_user")
|
|
54
|
+
@rabbitmq_url = ENV.fetch("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/")
|
|
55
|
+
@exchange_name = ENV.fetch("RABBITMQ_EXCHANGE", "vert.events")
|
|
56
|
+
@document_service_url = ENV.fetch("DOCUMENT_SERVICE_URL", "http://localhost:3020")
|
|
57
|
+
@health_check_path = "/health"
|
|
58
|
+
@auto_mount_health_routes = false
|
|
59
|
+
@health_check_database = true
|
|
60
|
+
@health_check_redis = false
|
|
61
|
+
@health_check_rabbitmq = false
|
|
62
|
+
@health_check_sidekiq = false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|