soql_dashboard 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2886f7f3363f2da271570b530d9349ac25577919f2f742ec70e24e28f49942f
4
- data.tar.gz: 92a902a0931a1a35ce3b315d53bb66b151d058ad90003e476ec2dc60c7e61602
3
+ metadata.gz: 0c11073ceb067d5a92c3edfe38f79307729b37fcb1680becb195f14ae7d029c6
4
+ data.tar.gz: 1eb341095e2b0ceb594852e0caad8c837c988420119a08317dcf9db977243671
5
5
  SHA512:
6
- metadata.gz: c8f614ed26311e86522dbfa2c281a6f5bb5e837fdd4b0c4a4f0f56e6e8fe9bc60e4c4cfd17c0b4c8305537c2b5d4dacacda44b2e7d92fe37ee571dc7f9085d14
7
- data.tar.gz: 5b0f2c782224ad527f43fd88ac94c0de9bcb69e86a1b9283458645a0352af75c41d343ac95743bf70f42326e82314e73deb78b56178cfdb5547e79bd7e7ccd11
6
+ metadata.gz: af39a082bfd872c230a86caa143929292a960e879e329989c70c30513fd31106e826048f2529a38406f70f49734548ed78fa4123ed090eacdce07214e25d0dbb
7
+ data.tar.gz: b67f4ed627cca207f84aee102b754c18e818ee8a877dba812914eeaf695f815a7da004ba7bc4b3a34976ed37f2376df20b94566ab5f505f5634d9ae8904c8871
data/README.md CHANGED
@@ -6,6 +6,7 @@ A Rails engine that provides a dashboard interface for querying Salesforce using
6
6
 
7
7
  - Query Salesforce objects using SOQL
8
8
  - View results in a user-friendly dashboard
9
+ - Audit trail for all SOQL queries with user tracking
9
10
  - Easily mountable in any Rails application
10
11
 
11
12
  ## Installation
@@ -20,8 +21,14 @@ Then run:
20
21
 
21
22
  ```sh
22
23
  bundle install
24
+ rails generate soql_dashboard:install
25
+ rails soql_dashboard:install:migrations
26
+ rails db:migrate
23
27
  ```
24
28
 
29
+ This will create the necessary database table:
30
+ - `soql_dashboard_audits` - tracks all query executions for audit purposes
31
+
25
32
  ## Mounting the Engine
26
33
 
27
34
  In your application's `config/routes.rb`:
@@ -4,9 +4,12 @@ module SoqlDashboard
4
4
  class ReportsController < ApplicationController
5
5
  before_action :set_integration, only: %i[index execute_query]
6
6
 
7
+ FALLBACK_OBJECTS = %w[Account Contact Opportunity Lead Case User].freeze
8
+
7
9
  def index
8
10
  @integrations = available_integrations
9
11
  @selected_integration = @integration
12
+ @objects = integration_objects
10
13
  @query_result = nil
11
14
  end
12
15
 
@@ -15,6 +18,7 @@ module SoqlDashboard
15
18
 
16
19
  @integrations = available_integrations
17
20
  @selected_integration = @integration
21
+ @objects = integration_objects
18
22
  @soql_query = params[:soql_query]
19
23
  query_execution if @soql_query.present?
20
24
  render :index
@@ -29,11 +33,20 @@ module SoqlDashboard
29
33
  end
30
34
 
31
35
  def query_execution
32
- result = execute_soql_query(@soql_query)
36
+ service = SoqlDashboard::RunStatement.new
37
+ result = service.call(@soql_query, {
38
+ user: current_user_for_audit,
39
+ salesforce_integration: @integration,
40
+ })
41
+
33
42
  if result[:error]
34
43
  @error = result[:error]
35
44
  else
36
- @query_result = result
45
+ @query_result = {
46
+ results: result[:result][:records] || [],
47
+ total_size: result[:result][:totalSize] || 0,
48
+ duration: result[:duration],
49
+ }
37
50
  end
38
51
  rescue StandardError => e
39
52
  @error = e.message
@@ -70,5 +83,31 @@ module SoqlDashboard
70
83
  total_size: result[:totalSize] || 0,
71
84
  }
72
85
  end
86
+
87
+ def integration_objects
88
+ return [] unless @integration
89
+
90
+ client = SoqlDashboard::SalesforceApiClient.new(@integration.send(config_method_name))
91
+ result = client.list_all_queryable_objects
92
+
93
+ Rails.logger.debug { "integration_objects: #{result.to_json}" }
94
+ if result[:error]
95
+ Rails.logger.error "Failed to fetch objects: #{result[:error]}"
96
+ # Fallback to common Salesforce objects
97
+ FALLBACK_OBJECTS
98
+ else
99
+ records = result[:records]&.pluck("QualifiedApiName")
100
+ records&.any? ? records : FALLBACK_OBJECTS
101
+ end
102
+ end
103
+
104
+ def current_user_for_audit
105
+ return nil unless SoqlDashboard.user_class
106
+
107
+ method_name = SoqlDashboard.user_method
108
+ respond_to?(method_name, true) ? send(method_name) : nil
109
+ rescue StandardError
110
+ nil
111
+ end
73
112
  end
74
113
  end
@@ -6,7 +6,7 @@ require "json"
6
6
  require "timeout"
7
7
 
8
8
  module SoqlDashboard
9
- class SalesforceApiClient
9
+ class SalesforceApiClient # rubocop:disable Metrics/ClassLength
10
10
  def initialize(config)
11
11
  @config = config
12
12
  end
@@ -18,14 +18,24 @@ module SoqlDashboard
18
18
  response = http.request(request)
19
19
 
20
20
  parse_response(response)
21
- rescue Timeout::Error
22
- { error: "Request timed out. Please try again." }
23
- rescue Net::HTTPError => e
24
- { error: "Network error: #{e.message}" }
25
- rescue JSON::ParserError
26
- { error: "Invalid response from Salesforce API" }
27
21
  rescue StandardError => e
28
- { error: "Unexpected error: #{e.message}" }
22
+ handle_error(e)
23
+ end
24
+
25
+ def list_all_queryable_objects
26
+ # Use describe_global to get all available objects
27
+ describe_result = describe_global
28
+ return describe_result if describe_result[:error]
29
+
30
+ # Filter to only queryable objects
31
+ queryable_objects = describe_result[:sobjects]&.select do |sobject|
32
+ sobject["queryable"] == true
33
+ end&.pluck("name") || []
34
+
35
+ {
36
+ total_size: queryable_objects.length,
37
+ records: queryable_objects.map { |name| { "QualifiedApiName" => name } },
38
+ }
29
39
  end
30
40
 
31
41
  private
@@ -72,7 +82,7 @@ module SoqlDashboard
72
82
  result = JSON.parse(response.body)
73
83
 
74
84
  {
75
- totalSize: result["totalSize"],
85
+ total_size: result["totalSize"],
76
86
  records: result["records"] || [],
77
87
  }
78
88
  end
@@ -82,5 +92,44 @@ module SoqlDashboard
82
92
  error_message = error_data.first["message"] || "Bad Request"
83
93
  { error: "SOQL Error: #{error_message}" }
84
94
  end
95
+
96
+ def handle_error(error)
97
+ case error
98
+ when Timeout::Error
99
+ { error: "Request timed out. Please try again." }
100
+ when Net::HTTPError
101
+ { error: "Network error: #{error.message}" }
102
+ when JSON::ParserError
103
+ { error: "Invalid response from Salesforce API" }
104
+ else
105
+ { error: "Unexpected error: #{error.message}" }
106
+ end
107
+ end
108
+
109
+ def describe_global
110
+ uri = build_describe_uri
111
+ http = setup_http_connection(uri)
112
+ request = create_request(uri)
113
+ response = http.request(request)
114
+
115
+ parse_describe_response(response)
116
+ rescue StandardError => e
117
+ handle_error(e)
118
+ end
119
+
120
+ def build_describe_uri
121
+ URI("#{config['instance_url']}/services/data/v58.0/sobjects/")
122
+ end
123
+
124
+ def parse_describe_response(response)
125
+ case response.code.to_i
126
+ when 200
127
+ result = JSON.parse(response.body)
128
+ { sobjects: result["sobjects"] || [] }
129
+ else
130
+ # Reuse the error handling from parse_response for common HTTP errors
131
+ parse_response(response)
132
+ end
133
+ end
85
134
  end
86
135
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoqlDashboard
4
+ class Audit < ApplicationRecord
5
+ self.table_name = "soql_dashboard_audits"
6
+
7
+ belongs_to :user, optional: true, class_name: SoqlDashboard.user_class.to_s if SoqlDashboard.user_class
8
+ if SoqlDashboard.salesforce_integration_model
9
+ belongs_to :salesforce_integration, optional: true,
10
+ class_name: SoqlDashboard.salesforce_integration_model.to_s
11
+ end
12
+
13
+ validates :statement, presence: true
14
+
15
+ scope :recent, -> { order(created_at: :desc) }
16
+ scope :for_integration, ->(integration_id) { where(salesforce_integration_id: integration_id) }
17
+ end
18
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoqlDashboard
4
+ class RunStatement
5
+ def call(query_text, options = {})
6
+ audit = create_audit_record(query_text, options) if audit_enabled?
7
+
8
+ execution_result = execute_with_timing(query_text, options)
9
+ update_audit_record(audit, execution_result) if audit
10
+
11
+ build_response(execution_result)
12
+ end
13
+
14
+ private
15
+
16
+ def audit_enabled?
17
+ SoqlDashboard.audit
18
+ end
19
+
20
+ def create_audit_record(query_text, options)
21
+ audit = SoqlDashboard::Audit.new(statement: query_text)
22
+
23
+ assign_audit_associations(audit, options)
24
+ audit.save!
25
+ audit
26
+ end
27
+
28
+ def assign_audit_associations(audit, options)
29
+ audit.user = options[:user] if options[:user] && audit.respond_to?(:user=)
30
+
31
+ return unless options[:salesforce_integration] && audit.respond_to?(:salesforce_integration=)
32
+
33
+ audit.salesforce_integration = options[:salesforce_integration]
34
+ end
35
+
36
+ def execute_with_timing(query_text, options)
37
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
38
+
39
+ begin
40
+ result = execute_soql_query(query_text, options)
41
+ { success: true, result:, error: nil }
42
+ rescue StandardError => e
43
+ { success: false, result: nil, error: e.message }
44
+ ensure
45
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
46
+ @duration = duration # Keep this for build_response method
47
+ end
48
+ end
49
+
50
+ def execute_soql_query(query_text, options)
51
+ service = SoqlDashboard::SoqlExecutor.new(options[:salesforce_integration])
52
+ result = service.call(query_text)
53
+
54
+ raise result[:error] if result[:error]
55
+
56
+ result
57
+ end
58
+
59
+ def update_audit_record(audit, execution_result)
60
+ return unless audit&.persisted?
61
+
62
+ audit.duration = duration if audit.respond_to?(:duration=)
63
+ audit.error = execution_result[:error] if audit.respond_to?(:error=)
64
+
65
+ audit.save! if audit.changed?
66
+ end
67
+
68
+ def build_response(execution_result)
69
+ {
70
+ result: execution_result[:result],
71
+ error: execution_result[:error],
72
+ duration:,
73
+ }
74
+ end
75
+
76
+ def duration
77
+ @duration || 0.0
78
+ end
79
+ end
80
+ end
@@ -32,7 +32,7 @@ module SoqlDashboard
32
32
  query_lower = query.downcase
33
33
 
34
34
  forbidden_keywords.each do |keyword|
35
- raise ArgumentError, "#{keyword.upcase} statements are not allowed" if query_lower.include?(keyword)
35
+ raise ArgumentError, "#{keyword.upcase} statements are not allowed" if query_lower.match?(/\b#{keyword}\b/)
36
36
  end
37
37
  end
38
38
  end
@@ -38,6 +38,7 @@
38
38
  value: @soql_query,
39
39
  placeholder: "SELECT Id, Name FROM Opportunity LIMIT 10",
40
40
  rows: 5,
41
+ id: "soql_textarea",
41
42
  class: "soql-textarea" %>
42
43
  </div>
43
44
 
@@ -46,6 +47,28 @@
46
47
  </div>
47
48
  <% end %>
48
49
  </div>
50
+ <% if @objects.is_a?(Array) && @objects.any? %>
51
+ <div class="field-selector" style="margin: 12px 0; padding: 12px; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 6px;">
52
+ <div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;">
53
+ <div>
54
+ <div style="font-weight: 600; margin-bottom: 6px; color: #374151;">
55
+ Available Salesforce objects
56
+ </div>
57
+ <select
58
+ id="available-objects"
59
+ style="min-width: 360px; width: 100%; max-width: 680px;"
60
+ class="integration-dropdown"
61
+ onchange="insertSelectedObject()"
62
+ >
63
+ <option disabled selected value="">Select an object...</option>
64
+ <% (@objects || []).sort.each do |obj_name| %>
65
+ <option value="<%= obj_name %>"><%= obj_name %></option>
66
+ <% end %>
67
+ </select>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ <% end %>
49
72
 
50
73
  <% if @error %>
51
74
  <div class="error-section">
@@ -60,8 +83,9 @@
60
83
 
61
84
  <div class="results-meta">
62
85
  <p>
63
- <strong>Query:</strong> <%= @query_result[:query] %><br>
86
+ <strong>Query:</strong> <%= @soql_query %><br>
64
87
  <strong>Total Records:</strong> <%= @query_result[:total_size] %><br>
88
+ <strong>Execution Time:</strong> <%= number_with_precision(@query_result[:duration], precision: 3) %>s<br>
65
89
  </p>
66
90
  </div>
67
91
 
@@ -97,3 +121,21 @@
97
121
  </div>
98
122
  <% end %>
99
123
  </div>
124
+
125
+ <script>
126
+ function insertSelectedObject() {
127
+ try {
128
+ const select = document.getElementById('available-objects');
129
+ const value = select && select.value;
130
+ if(!value){ return; }
131
+ const textarea = document.getElementById('soql_textarea');
132
+ if(!textarea){ textarea = document.querySelector('textarea[name="soql_query"]'); }
133
+ const existing = (textarea && textarea.value) ? textarea.value : '';
134
+ const snippet = 'SELECT FIELDS(ALL) FROM ' + value + ' LIMIT 200';
135
+ textarea.value = snippet;
136
+ if (textarea) { textarea.focus(); }
137
+ } catch (e) {
138
+ console.error('Failed to insert object', e);
139
+ }
140
+ }
141
+ </script>
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSoqlDashboardAudits < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :soql_dashboard_audits do |t|
6
+ t.references :user, null: true, index: true
7
+ t.references :salesforce_integration, null: true, index: true
8
+ t.text :statement
9
+ t.float :duration
10
+ t.text :error
11
+ t.datetime :created_at
12
+ end
13
+
14
+ add_index :soql_dashboard_audits, :created_at
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record"
4
+
5
+ module SoqlDashboard
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.join(__dir__, "install", "templates")
9
+
10
+ def copy_config
11
+ template "soql_dashboard.rb", "config/initializers/soql_dashboard.rb"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SoqlDashboard
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -5,21 +5,46 @@ require "soql_dashboard/engine"
5
5
 
6
6
  module SoqlDashboard
7
7
  class << self
8
- attr_accessor :configuration
8
+ attr_accessor :configuration, :audit
9
9
  end
10
10
 
11
+ self.audit = true
12
+
11
13
  def self.configure
12
14
  self.configuration ||= Configuration.new
13
15
  yield(configuration) if block_given?
14
16
  end
15
17
 
18
+ def self.user_class
19
+ @user_class ||= configuration&.user_class || begin
20
+ User.name
21
+ rescue StandardError
22
+ nil
23
+ end
24
+ end
25
+
26
+ def self.user_method
27
+ @user_method ||= configuration&.user_method || (
28
+ if user_class
29
+ :"current_#{user_class.to_s.downcase.underscore}"
30
+ else
31
+ :current_user
32
+ end
33
+ )
34
+ end
35
+
36
+ def self.salesforce_integration_model
37
+ @salesforce_integration_model ||= configuration&.salesforce_integration_model
38
+ end
39
+
16
40
  class Configuration
17
- attr_accessor :salesforce_integration_model, :user_class, :user_method
41
+ attr_accessor :salesforce_integration_model, :user_class, :user_method, :audit
18
42
 
19
43
  def initialize
20
44
  @salesforce_integration_model = nil
21
45
  @user_class = nil
22
46
  @user_method = :current_user
47
+ @audit = true
23
48
  end
24
49
  end
25
50
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: soql_dashboard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fred Moura
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-09-12 00:00:00.000000000 Z
11
+ date: 2025-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg
@@ -58,11 +58,15 @@ files:
58
58
  - app/lib/soql_dashboard/salesforce_api_client.rb
59
59
  - app/mailers/soql_dashboard/application_mailer.rb
60
60
  - app/models/soql_dashboard/application_record.rb
61
+ - app/models/soql_dashboard/audit.rb
62
+ - app/services/soql_dashboard/run_statement.rb
61
63
  - app/services/soql_dashboard/soql_executor.rb
62
64
  - app/views/layouts/soql_dashboard/application.html.erb
63
65
  - app/views/soql_dashboard/reports/index.html.erb
64
66
  - config/routes.rb
67
+ - db/migrate/20250919113356_create_soql_dashboard_audits.rb
65
68
  - lib/generators/soql_dashboard/install/templates/soql_dashboard.rb
69
+ - lib/generators/soql_dashboard/install_generator.rb
66
70
  - lib/soql_dashboard.rb
67
71
  - lib/soql_dashboard/engine.rb
68
72
  - lib/soql_dashboard/version.rb