followupboss_client 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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +12 -0
  3. data/.gitignore +18 -0
  4. data/.rspec +2 -0
  5. data/.ruby-gemset +1 -0
  6. data/.ruby-version +1 -0
  7. data/.tool-versions +1 -0
  8. data/.travis.yml +9 -0
  9. data/CHANGELOG.md +149 -0
  10. data/CODE_OF_CONDUCT.md +49 -0
  11. data/Gemfile +4 -0
  12. data/LICENSE.txt +22 -0
  13. data/README.md +882 -0
  14. data/Rakefile +6 -0
  15. data/bin/console +14 -0
  16. data/bin/setup +8 -0
  17. data/followupboss_client.gemspec +43 -0
  18. data/lib/followupboss_client.rb +16 -0
  19. data/lib/fub_client/action_plan.rb +5 -0
  20. data/lib/fub_client/appointment.rb +42 -0
  21. data/lib/fub_client/appointment_outcome.rb +51 -0
  22. data/lib/fub_client/appointment_type.rb +51 -0
  23. data/lib/fub_client/call.rb +4 -0
  24. data/lib/fub_client/client.rb +200 -0
  25. data/lib/fub_client/compatibility.rb +18 -0
  26. data/lib/fub_client/configuration.rb +54 -0
  27. data/lib/fub_client/cookie_client.rb +190 -0
  28. data/lib/fub_client/custom_field.rb +5 -0
  29. data/lib/fub_client/deal.rb +41 -0
  30. data/lib/fub_client/deal_attachment.rb +61 -0
  31. data/lib/fub_client/deal_custom_field.rb +47 -0
  32. data/lib/fub_client/em_event.rb +5 -0
  33. data/lib/fub_client/email_template.rb +5 -0
  34. data/lib/fub_client/event.rb +8 -0
  35. data/lib/fub_client/group.rb +58 -0
  36. data/lib/fub_client/her_patch.rb +101 -0
  37. data/lib/fub_client/identity.rb +33 -0
  38. data/lib/fub_client/message.rb +41 -0
  39. data/lib/fub_client/middleware/authentication.rb +26 -0
  40. data/lib/fub_client/middleware/cookie_authentication.rb +61 -0
  41. data/lib/fub_client/middleware/parser.rb +59 -0
  42. data/lib/fub_client/middleware.rb +8 -0
  43. data/lib/fub_client/note.rb +4 -0
  44. data/lib/fub_client/people_relationship.rb +34 -0
  45. data/lib/fub_client/person.rb +5 -0
  46. data/lib/fub_client/person_attachment.rb +50 -0
  47. data/lib/fub_client/pipeline.rb +45 -0
  48. data/lib/fub_client/property.rb +26 -0
  49. data/lib/fub_client/rails8_patch.rb +39 -0
  50. data/lib/fub_client/resource.rb +33 -0
  51. data/lib/fub_client/shared_inbox.rb +389 -0
  52. data/lib/fub_client/smart_list.rb +5 -0
  53. data/lib/fub_client/stage.rb +39 -0
  54. data/lib/fub_client/task.rb +18 -0
  55. data/lib/fub_client/team.rb +65 -0
  56. data/lib/fub_client/team_inbox.rb +65 -0
  57. data/lib/fub_client/text_message.rb +46 -0
  58. data/lib/fub_client/text_message_template.rb +49 -0
  59. data/lib/fub_client/user.rb +4 -0
  60. data/lib/fub_client/version.rb +3 -0
  61. data/lib/fub_client/webhook.rb +47 -0
  62. data/lib/fub_client.rb +61 -0
  63. data/scripts/test_api.rb +110 -0
  64. data/scripts/test_shared_inbox.rb +90 -0
  65. metadata +335 -0
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'fub_client'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require 'irb'
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,43 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'fub_client/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'followupboss_client'
7
+ spec.version = FubClient::VERSION
8
+ spec.authors = ['Connor Gallopo', 'Kyoto Kopz']
9
+ spec.email = ['connor.gallopo@me.com']
10
+
11
+ spec.summary = 'Enhanced Ruby client for Follow Up Boss API with Rails 8 compatibility'
12
+ spec.description = 'A comprehensive Ruby client for the Follow Up Boss API with Rails 8 compatibility, secure cookie authentication for SharedInbox, and enhanced features for real estate CRM integration.'
13
+ spec.homepage = 'https://github.com/connorgallopo/followupboss_client'
14
+ spec.license = 'MIT'
15
+
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
17
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
18
+ raise 'RubyGems 2.0 or newer is required to protect against public gem pushes.' unless spec.respond_to?(:metadata)
19
+
20
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
21
+
22
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
23
+ spec.bindir = 'exe'
24
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
+ spec.require_paths = ['lib']
26
+
27
+ spec.add_dependency 'activemodel', '>= 7.1.0', '< 9.0'
28
+ spec.add_dependency 'activesupport', '>= 7.1.0', '< 9.0'
29
+ spec.add_dependency 'facets', '~> 3.1.0'
30
+ spec.add_dependency 'faraday', '>= 1.10.3', '< 3.0'
31
+ spec.add_dependency 'her', '~> 1.1.1'
32
+ spec.add_dependency 'logger'
33
+ spec.add_dependency 'multi_json', '~> 1.15.0'
34
+ spec.add_dependency 'tzinfo', '~> 2.0.6'
35
+ # Development
36
+ spec.add_development_dependency 'bundler', '~> 2.4'
37
+ spec.add_development_dependency 'dotenv', '>= 2.8.1'
38
+ spec.add_development_dependency 'pry', '>= 0.14.2'
39
+ spec.add_development_dependency 'rake', '~> 13.0'
40
+ spec.add_development_dependency 'rspec', '~> 3.12'
41
+ spec.add_development_dependency 'vcr', '>= 6.1.0'
42
+ spec.add_development_dependency 'webmock', '>= 3.18.1'
43
+ end
@@ -0,0 +1,16 @@
1
+ # FollowUpBoss Client - Enhanced Ruby client for Follow Up Boss API
2
+ #
3
+ # This gem provides Rails 8 compatibility, secure cookie authentication,
4
+ # and comprehensive API coverage for the Follow Up Boss CRM.
5
+ #
6
+ # Usage:
7
+ # require 'followupboss_client'
8
+ #
9
+ # FubClient.configure do |config|
10
+ # config.api_key = 'your_api_key'
11
+ # end
12
+ #
13
+ # people = FubClient::Person.all
14
+ # deals = FubClient::Deal.active
15
+
16
+ require_relative 'fub_client'
@@ -0,0 +1,5 @@
1
+ module FubClient
2
+ class ActionPlan < Resource
3
+ collection_path 'actionPlans'
4
+ end
5
+ end
@@ -0,0 +1,42 @@
1
+ module FubClient
2
+ class Appointment < Resource
3
+ collection_path 'appointments'
4
+ root_element :appointment
5
+ include_root_in_json true
6
+
7
+ scope :between, lambda { |start_date, end_date|
8
+ where(startDate: start_date, endDate: end_date)
9
+ }
10
+ scope :for_person, ->(person_id) { where(personId: person_id) }
11
+ scope :by_user, ->(user_id) { where(userId: user_id) }
12
+ scope :by_type, ->(type_id) { where(typeId: type_id) }
13
+ scope :by_outcome, ->(outcome_id) { where(outcomeId: outcome_id) }
14
+
15
+ def self.upcoming
16
+ where(startDate: Time.now.iso8601)
17
+ end
18
+
19
+ def self.past
20
+ where(endDate: Time.now.iso8601, order: 'desc')
21
+ end
22
+
23
+ def person
24
+ return nil unless respond_to?(:person_id) && person_id
25
+
26
+ FubClient::Person.find(person_id)
27
+ end
28
+
29
+ def user
30
+ return nil unless respond_to?(:user_id) && user_id
31
+
32
+ FubClient::User.find(user_id)
33
+ end
34
+
35
+ def complete(outcome_id, notes = nil)
36
+ params = { outcomeId: outcome_id }
37
+ params[:notes] = notes if notes
38
+
39
+ self.class.put("#{id}/complete", params)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,51 @@
1
+ module FubClient
2
+ class AppointmentOutcome < Resource
3
+ collection_path 'appointmentOutcomes'
4
+ root_element :appointment_outcome
5
+ include_root_in_json true
6
+
7
+ # Convenience method to find appointment outcomes by name (partial match)
8
+ scope :by_name, ->(name) { where(q: name) }
9
+
10
+ # Convenience method to find active appointment outcomes
11
+ def self.active
12
+ where(active: true)
13
+ end
14
+
15
+ # Convenience method to find inactive appointment outcomes
16
+ def self.inactive
17
+ where(active: false)
18
+ end
19
+
20
+ # Get appointments with this outcome
21
+ def appointments
22
+ return [] unless id
23
+
24
+ FubClient::Appointment.by_outcome(id)
25
+ end
26
+
27
+ # Update an appointment outcome
28
+ def update(attributes)
29
+ return false unless id
30
+
31
+ begin
32
+ self.class.put(id.to_s, attributes)
33
+ true
34
+ rescue StandardError
35
+ false
36
+ end
37
+ end
38
+
39
+ # Delete an appointment outcome
40
+ def delete
41
+ return false unless id
42
+
43
+ begin
44
+ self.class.delete(id.to_s)
45
+ true
46
+ rescue StandardError
47
+ false
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,51 @@
1
+ module FubClient
2
+ class AppointmentType < Resource
3
+ collection_path 'appointmentTypes'
4
+ root_element :appointment_type
5
+ include_root_in_json true
6
+
7
+ # Convenience method to find appointment types by name (partial match)
8
+ scope :by_name, ->(name) { where(q: name) }
9
+
10
+ # Convenience method to find active appointment types
11
+ def self.active
12
+ where(active: true)
13
+ end
14
+
15
+ # Convenience method to find inactive appointment types
16
+ def self.inactive
17
+ where(active: false)
18
+ end
19
+
20
+ # Get appointments with this type
21
+ def appointments
22
+ return [] unless id
23
+
24
+ FubClient::Appointment.by_type(id)
25
+ end
26
+
27
+ # Update an appointment type
28
+ def update(attributes)
29
+ return false unless id
30
+
31
+ begin
32
+ self.class.put(id.to_s, attributes)
33
+ true
34
+ rescue StandardError
35
+ false
36
+ end
37
+ end
38
+
39
+ # Delete an appointment type
40
+ def delete
41
+ return false unless id
42
+
43
+ begin
44
+ self.class.delete(id.to_s)
45
+ true
46
+ rescue StandardError
47
+ false
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,4 @@
1
+ module FubClient
2
+ class Call < Resource
3
+ end
4
+ end
@@ -0,0 +1,200 @@
1
+ module FubClient
2
+ class Client
3
+ API_URL = 'api.followupboss.com'
4
+ WEBAPP_URL = 'app.followupboss.com'
5
+ API_VERSION = 'v1'
6
+
7
+ include Singleton
8
+
9
+ # Allow explicitly setting the instance (for testing)
10
+ class << self
11
+ attr_writer :instance
12
+ end
13
+
14
+ attr_writer :api_key
15
+ attr_accessor :cookies, :subdomain
16
+ attr_reader :her_api
17
+
18
+ def initialize
19
+ init_her_api
20
+ end
21
+
22
+ def api_key
23
+ @api_key ||= ENV['FUB_API_KEY']
24
+ end
25
+
26
+ def api_uri
27
+ return @api_uri if @api_uri
28
+
29
+ @api_uri = if subdomain
30
+ # Use subdomain-specific URL for cookie-based auth
31
+ URI::HTTPS.build(host: "#{subdomain}.followupboss.com", path: "/api/#{API_VERSION}")
32
+ else
33
+ # Use default API URL for API key auth
34
+ URI::HTTPS.build(host: API_URL, path: "/#{API_VERSION}")
35
+ end
36
+ end
37
+
38
+ # Login to obtain cookies
39
+ def login(email, password, remember = true)
40
+ # First get CSRF token
41
+ csrf_token = get_csrf_token
42
+
43
+ puts "CSRF Token: #{csrf_token}" if ENV['DEBUG']
44
+
45
+ if csrf_token.nil?
46
+ puts 'Failed to obtain CSRF token, cannot proceed with login' if ENV['DEBUG']
47
+ return false
48
+ end
49
+
50
+ conn = Faraday.new(url: "https://#{WEBAPP_URL}") do |f|
51
+ f.request :url_encoded
52
+ f.adapter :net_http
53
+ end
54
+
55
+ # Format request similar to the curl example
56
+ # Remove quotes from the password if it's a string with quotes (from .env file)
57
+ password_str = password.to_s.gsub(/^'(.*)'$/, '\1')
58
+
59
+ # Check if the password contains special characters that need encoding
60
+ encoded_password = URI.encode_www_form_component(password_str)
61
+
62
+ # Ensure # is properly encoded as %23
63
+ if password_str.include?('#') && !encoded_password.include?('%23')
64
+ puts "WARNING: The # character in password isn't being properly encoded! Manually fixing..." if ENV['DEBUG']
65
+ encoded_password = encoded_password.gsub(/#/, '%23')
66
+ end
67
+
68
+ # Explicitly use the exact raw data format from the curl example, ensuring all special characters are preserved
69
+ raw_data = "start_url=&subdomain=&email=#{URI.encode_www_form_component(email)}&password=#{encoded_password}&remember=&remember=#{remember ? '1' : ''}&csrf_token=#{csrf_token}"
70
+
71
+ puts "Login raw data: #{raw_data}" if ENV['DEBUG']
72
+
73
+ response = conn.post do |req|
74
+ req.url '/login/index'
75
+
76
+ # Add ALL headers exactly as in the curl example
77
+ req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
78
+ req.headers['Accept'] =
79
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'
80
+ req.headers['Accept-Language'] = 'en-US,en;q=0.9'
81
+ req.headers['Cache-Control'] = 'max-age=0'
82
+ req.headers['User-Agent'] =
83
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
84
+ req.headers['Origin'] = "https://#{WEBAPP_URL}"
85
+ req.headers['Referer'] = "https://#{WEBAPP_URL}/login"
86
+ req.headers['DNT'] = '1'
87
+ req.headers['Priority'] = 'u=0, i'
88
+ req.headers['Sec-CH-UA'] = '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"'
89
+ req.headers['Sec-CH-UA-Mobile'] = '?0'
90
+ req.headers['Sec-CH-UA-Platform'] = '"Windows"'
91
+ req.headers['Sec-Fetch-Dest'] = 'document'
92
+ req.headers['Sec-Fetch-Mode'] = 'navigate'
93
+ req.headers['Sec-Fetch-Site'] = 'same-origin'
94
+ req.headers['Sec-Fetch-User'] = '?1'
95
+ req.headers['Sec-GPC'] = '1'
96
+ req.headers['Upgrade-Insecure-Requests'] = '1'
97
+
98
+ # Add any cookies that might help
99
+ default_cookies = '_ga=GA1.1.703376757.1744985902; _ga_J70LJ0E97T=GS1.1.1744990639.2.1.1744990766.0.0.0'
100
+ req.headers['Cookie'] = default_cookies
101
+
102
+ req.body = raw_data
103
+ end
104
+
105
+ puts "Login response status: #{response.status}"
106
+ puts "Login response headers: #{response.headers.inspect}"
107
+ puts "Login response body: #{response.body}"
108
+
109
+ # First check for error messages in the response
110
+ if response.body.include?('Oops! Email address or password is not correct')
111
+ puts 'Login failed: Invalid credentials' if ENV['DEBUG']
112
+ return false
113
+ end
114
+
115
+ if [302, 200].include?(response.status)
116
+ # Extract cookies from response
117
+ cookies = response.headers['set-cookie']
118
+ if cookies
119
+ puts "Extracted cookies: #{cookies}" if ENV['DEBUG']
120
+ @cookies = cookies
121
+
122
+ # Verify we don't have error messages in the response
123
+ return true unless response.body.include?('<div class="message error">')
124
+
125
+ puts 'Login failed: Error message detected in response' if ENV['DEBUG']
126
+ return false
127
+
128
+ elsif ENV['DEBUG']
129
+ puts 'No cookies in response headers'
130
+ end
131
+ else
132
+ puts "Login failed with status: #{response.status}" if ENV['DEBUG']
133
+ puts "Response body sample: #{response.body[0..200]}" if ENV['DEBUG']
134
+ end
135
+
136
+ false
137
+ end
138
+
139
+ # Get CSRF token for login
140
+ def get_csrf_token
141
+ conn = Faraday.new(url: "https://#{WEBAPP_URL}") do |f|
142
+ f.adapter :net_http
143
+ end
144
+
145
+ response = conn.get('/login')
146
+
147
+ # Extract CSRF token from HTML - using the input field pattern found in the response
148
+ if response.body =~ /csrf_token\\" value=\\"([^\\]+)/
149
+ return ::Regexp.last_match(1)
150
+ elsif response.body =~ /name=\\"csrf_token\\" value=\\"([^"]+)/
151
+ return ::Regexp.last_match(1)
152
+ elsif response.body =~ /csrf_token=([^"&]+)/
153
+ return ::Regexp.last_match(1)
154
+ end
155
+
156
+ # For debugging
157
+ if ENV['DEBUG']
158
+ puts 'Could not find CSRF token in the response. Sample of response body:'
159
+ puts response.body[0..500]
160
+ end
161
+
162
+ nil
163
+ end
164
+
165
+ # Use cookie authentication?
166
+ def use_cookies?
167
+ !@cookies.nil? && !@cookies.empty?
168
+ end
169
+
170
+ # Reset the HER API connection with current settings
171
+ def reset_her_api
172
+ @api_uri = nil # Clear cached URI to rebuild with current settings
173
+ init_her_api
174
+ end
175
+
176
+ private
177
+
178
+ def init_her_api
179
+ @her_api = Her::API.new
180
+ @her_api.setup url: api_uri.to_s do |c|
181
+ # Request - use appropriate authentication middleware
182
+ if use_cookies?
183
+ # Let the CookieAuthentication middleware handle all headers
184
+ # to ensure they're consistent with the cookie format
185
+ c.use FubClient::Middleware::CookieAuthentication
186
+ else
187
+ c.use FubClient::Middleware::Authentication
188
+ end
189
+
190
+ c.request :url_encoded
191
+
192
+ # Response
193
+ c.use FubClient::Middleware::Parser
194
+
195
+ # Adapter
196
+ c.adapter :net_http
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,18 @@
1
+ # Compatibility fixes for Rails 8
2
+ module FubClient
3
+ module Compatibility
4
+ # Fix for Her gem's ActiveSupport::BasicObject usage in Rails 8+
5
+ # ActiveSupport::BasicObject was removed in Rails 8, but Her gem still references it
6
+ def self.patch_her_gem_rails8_compatibility!
7
+ # Only patch if ActiveSupport::BasicObject is not defined (Rails 8+)
8
+ return if ActiveSupport.const_defined?('BasicObject')
9
+
10
+ # Define BasicObject as an alias to ProxyObject for backward compatibility
11
+ ActiveSupport.const_set('BasicObject', ActiveSupport::ProxyObject)
12
+ end
13
+ end
14
+ end
15
+
16
+ # Apply the patch immediately when this file is loaded
17
+ # This must happen before Her gem is loaded
18
+ FubClient::Compatibility.patch_her_gem_rails8_compatibility!
@@ -0,0 +1,54 @@
1
+ module FubClient
2
+ class Configuration
3
+ attr_accessor :api_key, :subdomain, :gist_url, :encryption_key, :cookie
4
+
5
+ def initialize
6
+ @api_key = ENV['FUB_API_KEY']
7
+ @subdomain = ENV['FUB_SUBDOMAIN']
8
+ @gist_url = ENV['FUB_GIST_URL']
9
+ @encryption_key = ENV['FUB_ENCRYPTION_KEY']
10
+ @cookie = ENV['FUB_COOKIE']
11
+ end
12
+
13
+ def valid?
14
+ has_api_key_auth? || has_cookie_auth?
15
+ end
16
+
17
+ def has_api_key_auth?
18
+ !@api_key.nil? && !@api_key.empty?
19
+ end
20
+
21
+ def has_cookie_auth?
22
+ (!@cookie.nil? && !@cookie.empty?) ||
23
+ (!@gist_url.nil? && !@gist_url.empty? && !@encryption_key.nil? && !@encryption_key.empty?)
24
+ end
25
+
26
+ def auth_summary
27
+ summary = []
28
+ summary << "API Key: #{has_api_key_auth? ? 'configured' : 'not configured'}"
29
+ summary << "Cookie Auth: #{has_cookie_auth? ? 'configured' : 'not configured'}"
30
+ if has_cookie_auth?
31
+ if @cookie
32
+ summary << " - Direct cookie: #{@cookie.length} chars"
33
+ elsif @gist_url && @encryption_key
34
+ summary << " - GIST URL: #{@gist_url}"
35
+ summary << ' - Encryption key: configured'
36
+ end
37
+ summary << " - Subdomain: #{@subdomain || 'not set'}"
38
+ end
39
+ summary.join("\n")
40
+ end
41
+ end
42
+
43
+ def self.configuration
44
+ @configuration ||= Configuration.new
45
+ end
46
+
47
+ def self.configure
48
+ yield(configuration) if block_given?
49
+ end
50
+
51
+ def self.reset_configuration!
52
+ @configuration = Configuration.new
53
+ end
54
+ end