From 9cb23278dcc88bb10bffca1b98422b3749783b92 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Thu, 21 May 2026 17:19:45 -0500 Subject: [PATCH 01/34] deps: promote restforce/activeforce as direct deps, remove openstax_salesforce --- Gemfile | 3 ++- Gemfile.lock | 52 +++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/Gemfile b/Gemfile index cae60fd67..4886d93f5 100644 --- a/Gemfile +++ b/Gemfile @@ -155,7 +155,8 @@ gem 'will_paginate' gem 'chronic' # Salesforce -gem 'openstax_salesforce' +gem 'restforce' +gem 'activeforce' # Allows 'ap' alternative to 'pp', used in a mailer gem 'awesome_print' diff --git a/Gemfile.lock b/Gemfile.lock index a8ba59071..cb497aee7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -80,6 +80,12 @@ GEM actionpack (>= 3.0.2, < 8.0) activemodel (>= 3.0.2, < 8.0) activesupport (>= 3.0.2, < 8.0) + activeforce (1.9.1) + blockenspiel + fastercsv + rails (>= 3.0) + rest-client + savon (~> 1.0) activejob (6.1.7.8) activesupport (= 6.1.7.8) globalid (>= 0.3.6) @@ -106,6 +112,9 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) + akami (1.2.2) + gyoku (>= 0.4.0) + nokogiri apipie-rails (1.4.2) actionpack (>= 5.0) activesupport (>= 5.0) @@ -167,6 +176,7 @@ GEM chartkick (>= 3.2) railties (>= 5) safely_block (>= 0.1.1) + blockenspiel (0.5.0) bootsnap (1.18.4) msgpack (~> 1.2) bootstrap-sass (3.4.1) @@ -253,6 +263,7 @@ GEM diff-lcs (1.5.1) diffy (3.4.2) docile (1.4.1) + domain_name (0.6.20240107) doorkeeper (5.7.1) railties (>= 5) dotenv (2.8.1) @@ -307,6 +318,7 @@ GEM faraday-retry (1.0.3) faraday_middleware (1.2.1) faraday (~> 1.0) + fastercsv (1.5.5) ffi (1.16.3) fine_print (6.0.3) action_interceptor @@ -340,14 +352,21 @@ GEM guard (~> 2.1) guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) + gyoku (0.4.6) + builder (>= 2.1.2) hashdiff (1.1.1) hashie (5.0.0) highline (3.1.1) reline htmlentities (4.3.4) + http-accept (1.7.0) + http-cookie (1.1.6) + domain_name (~> 0.5) http_accept_language (2.1.1) http_parser.rb (0.8.0) httpclient (2.8.3) + httpi (1.1.1) + rack i18n (1.14.6) concurrent-ruby (~> 1.0) i18n-tasks (1.0.14) @@ -426,6 +445,10 @@ GEM rack-contrib (>= 1.1, < 3) railties (>= 3.0.0, < 8) method_source (1.1.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.0414) mini_mime (1.1.5) mini_portile2 (2.8.8) mini_racer (0.16.0) @@ -445,6 +468,7 @@ GEM timeout net-smtp (0.5.0) net-protocol + netrc (0.11.0) nio4r (2.7.4) nokogiri (1.18.8) mini_portile2 (~> 2.8.2) @@ -455,6 +479,7 @@ GEM racc (~> 1.4) nokogiri (1.18.8-x86_64-linux-gnu) racc (~> 1.4) + nori (1.1.5) notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) @@ -498,10 +523,6 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - openstax_active_force (1.1.1) - active_attr - rails (>= 5.0, < 7.0) - restforce openstax_api (9.6.1) addressable doorkeeper @@ -518,10 +539,6 @@ GEM rails (>= 3.0) openstax_rescue_from (4.3.0) rails (>= 3.1, < 7.0) - openstax_salesforce (8.3.0) - openstax_active_force - rails (>= 5.0, < 7.0) - restforce openstax_transaction_isolation (2.0.0) activerecord (>= 6) openstax_transaction_retry (2.0.0) @@ -656,6 +673,11 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) restforce (7.5.0) faraday (>= 1.1.0, < 2.12.0) faraday-follow_redirects (<= 0.3.0, < 1.0.0) @@ -725,6 +747,14 @@ GEM tilt (>= 1.1, < 3) sassc (2.4.0) ffi (~> 1.9) + savon (1.2.0) + akami (~> 1.2.0) + builder (>= 2.1.2) + gyoku (~> 0.4.5) + httpi (~> 1.1.0) + nokogiri (>= 1.4.0) + nori (~> 1.1.0) + wasabi (~> 2.5.0) sd_notify (0.1.1) selenium-webdriver (4.10.0) rexml (~> 3.2, >= 3.2.5) @@ -779,6 +809,9 @@ GEM vcr (6.3.1) base64 version_gem (1.1.4) + wasabi (2.5.1) + httpi (~> 1.0) + nokogiri (>= 1.4.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -814,6 +847,7 @@ PLATFORMS DEPENDENCIES action_interceptor + activeforce activerecord-import apipie-rails awesome_print @@ -880,7 +914,6 @@ DEPENDENCIES openstax_healthcheck openstax_path_prefixer! openstax_rescue_from - openstax_salesforce openstax_transaction_isolation openstax_transaction_retry openstax_utilities @@ -903,6 +936,7 @@ DEPENDENCIES recaptcha redis-rails representable + restforce rexml rspec-instafail rspec-rails From efba7d896cd9d82ccb386b574356d88740734695 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Thu, 21 May 2026 17:23:31 -0500 Subject: [PATCH 02/34] deps: use openstax_active_force, not the unrelated activeforce gem --- Gemfile | 2 +- Gemfile.lock | 47 +++++------------------------------------------ 2 files changed, 6 insertions(+), 43 deletions(-) diff --git a/Gemfile b/Gemfile index 4886d93f5..e011f86a4 100644 --- a/Gemfile +++ b/Gemfile @@ -156,7 +156,7 @@ gem 'chronic' # Salesforce gem 'restforce' -gem 'activeforce' +gem 'openstax_active_force' # Allows 'ap' alternative to 'pp', used in a mailer gem 'awesome_print' diff --git a/Gemfile.lock b/Gemfile.lock index cb497aee7..3779f9bb2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -80,12 +80,6 @@ GEM actionpack (>= 3.0.2, < 8.0) activemodel (>= 3.0.2, < 8.0) activesupport (>= 3.0.2, < 8.0) - activeforce (1.9.1) - blockenspiel - fastercsv - rails (>= 3.0) - rest-client - savon (~> 1.0) activejob (6.1.7.8) activesupport (= 6.1.7.8) globalid (>= 0.3.6) @@ -112,9 +106,6 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) - akami (1.2.2) - gyoku (>= 0.4.0) - nokogiri apipie-rails (1.4.2) actionpack (>= 5.0) activesupport (>= 5.0) @@ -176,7 +167,6 @@ GEM chartkick (>= 3.2) railties (>= 5) safely_block (>= 0.1.1) - blockenspiel (0.5.0) bootsnap (1.18.4) msgpack (~> 1.2) bootstrap-sass (3.4.1) @@ -263,7 +253,6 @@ GEM diff-lcs (1.5.1) diffy (3.4.2) docile (1.4.1) - domain_name (0.6.20240107) doorkeeper (5.7.1) railties (>= 5) dotenv (2.8.1) @@ -318,7 +307,6 @@ GEM faraday-retry (1.0.3) faraday_middleware (1.2.1) faraday (~> 1.0) - fastercsv (1.5.5) ffi (1.16.3) fine_print (6.0.3) action_interceptor @@ -352,21 +340,14 @@ GEM guard (~> 2.1) guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) - gyoku (0.4.6) - builder (>= 2.1.2) hashdiff (1.1.1) hashie (5.0.0) highline (3.1.1) reline htmlentities (4.3.4) - http-accept (1.7.0) - http-cookie (1.1.6) - domain_name (~> 0.5) http_accept_language (2.1.1) http_parser.rb (0.8.0) httpclient (2.8.3) - httpi (1.1.1) - rack i18n (1.14.6) concurrent-ruby (~> 1.0) i18n-tasks (1.0.14) @@ -445,10 +426,6 @@ GEM rack-contrib (>= 1.1, < 3) railties (>= 3.0.0, < 8) method_source (1.1.0) - mime-types (3.7.0) - logger - mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0414) mini_mime (1.1.5) mini_portile2 (2.8.8) mini_racer (0.16.0) @@ -468,7 +445,6 @@ GEM timeout net-smtp (0.5.0) net-protocol - netrc (0.11.0) nio4r (2.7.4) nokogiri (1.18.8) mini_portile2 (~> 2.8.2) @@ -479,7 +455,6 @@ GEM racc (~> 1.4) nokogiri (1.18.8-x86_64-linux-gnu) racc (~> 1.4) - nori (1.1.5) notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) @@ -523,6 +498,10 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack + openstax_active_force (1.1.1) + active_attr + rails (>= 5.0, < 7.0) + restforce openstax_api (9.6.1) addressable doorkeeper @@ -673,11 +652,6 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rest-client (2.1.0) - http-accept (>= 1.7.0, < 2.0) - http-cookie (>= 1.0.2, < 2.0) - mime-types (>= 1.16, < 4.0) - netrc (~> 0.8) restforce (7.5.0) faraday (>= 1.1.0, < 2.12.0) faraday-follow_redirects (<= 0.3.0, < 1.0.0) @@ -747,14 +721,6 @@ GEM tilt (>= 1.1, < 3) sassc (2.4.0) ffi (~> 1.9) - savon (1.2.0) - akami (~> 1.2.0) - builder (>= 2.1.2) - gyoku (~> 0.4.5) - httpi (~> 1.1.0) - nokogiri (>= 1.4.0) - nori (~> 1.1.0) - wasabi (~> 2.5.0) sd_notify (0.1.1) selenium-webdriver (4.10.0) rexml (~> 3.2, >= 3.2.5) @@ -809,9 +775,6 @@ GEM vcr (6.3.1) base64 version_gem (1.1.4) - wasabi (2.5.1) - httpi (~> 1.0) - nokogiri (>= 1.4.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -847,7 +810,6 @@ PLATFORMS DEPENDENCIES action_interceptor - activeforce activerecord-import apipie-rails awesome_print @@ -910,6 +872,7 @@ DEPENDENCIES omniauth-google-oauth2 omniauth-identity omniauth-twitter + openstax_active_force openstax_api openstax_healthcheck openstax_path_prefixer! From 4f43b45a4e77e645e17db7a6ba09062734567e49 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Thu, 21 May 2026 17:26:48 -0500 Subject: [PATCH 03/34] salesforce: remove stale openstax_salesforce references (replaced in Task 6) Drops the initializer and the rails_helper require/include that pointed at the now-removed openstax_salesforce gem. Required so the app boots and specs load while the new Salesforce module is being introduced. --- config/initializers/openstax_salesforce.rb | 18 ------------------ spec/rails_helper.rb | 3 --- 2 files changed, 21 deletions(-) delete mode 100644 config/initializers/openstax_salesforce.rb diff --git a/config/initializers/openstax_salesforce.rb b/config/initializers/openstax_salesforce.rb deleted file mode 100644 index 74682bdd3..000000000 --- a/config/initializers/openstax_salesforce.rb +++ /dev/null @@ -1,18 +0,0 @@ -# This initializer always runs before the engine is loaded, but it can -# also be copied to the application's initializers by running the install -# task. Because this code can get run multiple times, make sure to only put -# code here that is amenable to that. - -OpenStax::Salesforce.configure do |config| - salesforce_secrets = Rails.application.secrets.salesforce - - # Username, client id, instance url and private key for connecting to the Salesforce app - config.username = salesforce_secrets[:username] - config.password = salesforce_secrets[:password] - config.security_token = salesforce_secrets[:security_token] - config.consumer_key = salesforce_secrets[:consumer_key] - config.consumer_secret = salesforce_secrets[:consumer_secret] - - config.api_version = salesforce_secrets.fetch :api_version, '51.0' - config.login_domain = salesforce_secrets.fetch :login_domain, 'test.salesforce.com' -end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 68e6004df..068714b57 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -3,7 +3,6 @@ require 'simplecov_helper' require File.expand_path('../../config/environment', __FILE__) Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } -require 'openstax/salesforce/spec_helpers' require 'rspec/rails' require 'capybara/rails' require 'capybara/email/rspec' @@ -12,8 +11,6 @@ require 'database_cleaner' require 'spec_helper' -include OpenStax::Salesforce::SpecHelpers - # https://github.com/colszowka/simplecov/issues/369#issuecomment-313493152 # Load rake tasks so they can be tested. Rails.application.load_tasks unless defined?(Rake::Task) && Rake::Task.task_defined?('environment') From 2066d8b182f5d2d791c6eabaf29c71a95aaff1f6 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Thu, 21 May 2026 17:26:51 -0500 Subject: [PATCH 04/34] salesforce: add Salesforce module + Configuration --- app/services/salesforce.rb | 36 ++++++++++++++++++++++++++++++++ spec/services/salesforce_spec.rb | 30 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 app/services/salesforce.rb create mode 100644 spec/services/salesforce_spec.rb diff --git a/app/services/salesforce.rb b/app/services/salesforce.rb new file mode 100644 index 000000000..b41a2106f --- /dev/null +++ b/app/services/salesforce.rb @@ -0,0 +1,36 @@ +module Salesforce + class IllegalState < StandardError; end + + def self.configure + yield configuration + end + + def self.configuration + @configuration ||= Configuration.new + end + + def self.reset_configuration! + @configuration = nil + end + + class Configuration + attr_writer :api_version, :login_domain + attr_accessor :username, :password, :security_token, :consumer_key, :consumer_secret + + def api_version + @api_version ||= '61.0' + end + + def login_domain + @login_domain ||= 'test.salesforce.com' + end + + def validate! + raise IllegalState, 'The Salesforce username is missing' if username.nil? + raise IllegalState, 'The Salesforce password is missing' if password.nil? + raise IllegalState, 'The Salesforce security token is missing' if security_token.nil? + raise IllegalState, 'The Salesforce consumer key is missing' if consumer_key.nil? + raise IllegalState, 'The Salesforce consumer secret is missing' if consumer_secret.nil? + end + end +end diff --git a/spec/services/salesforce_spec.rb b/spec/services/salesforce_spec.rb new file mode 100644 index 000000000..a6772ce56 --- /dev/null +++ b/spec/services/salesforce_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe Salesforce do + describe '.configure' do + it 'yields the configuration object' do + Salesforce.configure do |config| + config.username = 'u' + config.password = 'p' + config.security_token = 't' + config.consumer_key = 'ck' + config.consumer_secret = 'cs' + end + expect(Salesforce.configuration.username).to eq('u') + end + end + + describe Salesforce::Configuration do + it 'defaults api_version' do + expect(described_class.new.api_version).to eq('61.0') + end + + it 'defaults login_domain' do + expect(described_class.new.login_domain).to eq('test.salesforce.com') + end + + it 'raises if required fields missing on validate!' do + expect { described_class.new.validate! }.to raise_error(Salesforce::IllegalState) + end + end +end From 055f781c650677b121f065e685d92a544479be68 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Thu, 21 May 2026 17:50:13 -0500 Subject: [PATCH 05/34] salesforce: add Client (Restforce wrapper) --- app/services/salesforce/client.rb | 20 +++++++++++++++ spec/services/salesforce/client_spec.rb | 34 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 app/services/salesforce/client.rb create mode 100644 spec/services/salesforce/client_spec.rb diff --git a/app/services/salesforce/client.rb b/app/services/salesforce/client.rb new file mode 100644 index 000000000..a0420fd36 --- /dev/null +++ b/app/services/salesforce/client.rb @@ -0,0 +1,20 @@ +require 'restforce' + +module Salesforce + class Client < ::Restforce::Data::Client + def initialize + configuration = Salesforce.configuration + configuration.validate! + + super( + username: configuration.username, + password: configuration.password, + security_token: configuration.security_token, + client_id: configuration.consumer_key, + client_secret: configuration.consumer_secret, + api_version: configuration.api_version, + host: configuration.login_domain + ) + end + end +end diff --git a/spec/services/salesforce/client_spec.rb b/spec/services/salesforce/client_spec.rb new file mode 100644 index 000000000..b8dff7ea3 --- /dev/null +++ b/spec/services/salesforce/client_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Client do + before do + Salesforce.configure do |c| + c.username = 'u' + c.password = 'p' + c.security_token = 't' + c.consumer_key = 'ck' + c.consumer_secret = 'cs' + end + end + + after { Salesforce.reset_configuration! } + + it 'is a Restforce client' do + expect(described_class.new).to be_a(Restforce::Data::Client) + end + + it 'passes configured credentials through to Restforce::Data::Client#initialize' do + expect_any_instance_of(Restforce::Data::Client).to receive(:initialize).with( + hash_including( + username: 'u', + password: 'p', + security_token: 't', + client_id: 'ck', + client_secret: 'cs', + api_version: '61.0', + host: 'test.salesforce.com' + ) + ).and_call_original + described_class.new + end +end From 7d3f34dc464d28d4bbad7759eaffd864c607b98a Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:25:34 -0500 Subject: [PATCH 06/34] salesforce: add Records::Base with ActiveForce client lazy-init Reopens ActiveForce::SObject with the find_or_initialize_by and save_if_changed helpers from the openstax_salesforce gem we removed, and aliases Salesforce::Records::Base to it. An intermediate Base subclass fought SObject's `inherited` hook (which auto-adds field :id and would double-register on grandchildren), so the alias approach mirrors how the original gem layered helpers on SObject. ActiveForce.sfdc_client is patched to lazily build Salesforce::Client on first use, keeping Rails boot and migrations safe when SF secrets aren't configured. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/records/base.rb | 47 +++++++++++++++++++ spec/services/salesforce/records/base_spec.rb | 40 ++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 app/services/salesforce/records/base.rb create mode 100644 spec/services/salesforce/records/base_spec.rb diff --git a/app/services/salesforce/records/base.rb b/app/services/salesforce/records/base.rb new file mode 100644 index 000000000..071764c03 --- /dev/null +++ b/app/services/salesforce/records/base.rb @@ -0,0 +1,47 @@ +require 'active_force' + +# Reopen ActiveForce::SObject (provided by the openstax_active_force gem) so +# our records inherit `find_or_initialize_by` and `save_if_changed` without an +# intermediate subclass. An intermediate `Salesforce::Records::Base < SObject` +# fights SObject's `inherited` hook, which auto-adds `field :id, from: 'Id'` +# to every subclass and would then double-register :id on grandchild records. +class ActiveForce::SObject + def self.find_or_initialize_by(conditions) + find_by(conditions) || new(conditions) + end + + def save_if_changed + save if changed? + end +end + +module Salesforce + module Records + # Records::Base is the type new code should reference. It IS + # ActiveForce::SObject under the hood, with the helpers above. + Base = ActiveForce::SObject + end +end + +# Make ActiveForce build a Salesforce::Client lazily — only on first use, not +# during Rails boot. This keeps migrations and console boot safe even when +# Salesforce secrets aren't configured (e.g. in CI or dev). +module ActiveForce + class << self + unless singleton_class.method_defined?(:_original_sfdc_client) || + singleton_class.private_method_defined?(:_original_sfdc_client) + alias_method :_original_sfdc_client, :sfdc_client + end + + def sfdc_client + unless _original_sfdc_client.is_a?(::Salesforce::Client) + self.sfdc_client = ::Salesforce::Client.new + end + _original_sfdc_client + end + + def clear_sfdc_client! + self.sfdc_client = nil + end + end +end diff --git a/spec/services/salesforce/records/base_spec.rb b/spec/services/salesforce/records/base_spec.rb new file mode 100644 index 000000000..85a9fdec3 --- /dev/null +++ b/spec/services/salesforce/records/base_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Records::Base do + it 'aliases to ActiveForce::SObject' do + expect(Salesforce::Records::Base).to eq(ActiveForce::SObject) + end + + describe '.find_or_initialize_by' do + let(:subclass) do + Class.new(Salesforce::Records::Base) do + self.table_name = 'Dummy' + end + end + + it 'returns an existing record when found' do + existing = subclass.new(id: 'X') + allow(subclass).to receive(:find_by).with({ id: 'X' }).and_return(existing) + expect(subclass.find_or_initialize_by(id: 'X')).to eq(existing) + end + + it 'returns a new instance of the subclass when find_by returns nil' do + allow(subclass).to receive(:find_by).with({ id: 'Y' }).and_return(nil) + record = subclass.find_or_initialize_by(id: 'Y') + expect(record).to be_a(subclass) + expect(record.id).to eq('Y') + end + end + + describe '#save_if_changed' do + let(:subclass) do + Class.new(Salesforce::Records::Base) do + self.table_name = 'Dummy' + end + end + + it 'is exposed on instances' do + expect(subclass.new).to respond_to(:save_if_changed) + end + end +end From 44fa7ec7b3fa50e85b10e1b267e0394a482b03b9 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:26:58 -0500 Subject: [PATCH 07/34] salesforce: add Records (Lead, Contact, School) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field mappings absorbed from openstax_salesforce, with the new fields the redesign needs: - Lead: is_converted, converted_contact_id (for tracking lead-to-contact conversion in Reconcile Pass 2) - Contact: master_record_id, is_deleted (for skipping merged/deleted contacts in SyncContacts) The unused remote model classes from the gem (Book, Opportunity, Campaign, CampaignMember, AccountContactRelation, OpenstaxAccount, RecordType, TermYear, TutorCoursePeriod) are intentionally not ported — this app only uses Lead, Contact, and School. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/records/contact.rb | 28 +++++++++++ app/services/salesforce/records/lead.rb | 50 +++++++++++++++++++ app/services/salesforce/records/school.rb | 20 ++++++++ .../salesforce/records/contact_spec.rb | 18 +++++++ spec/services/salesforce/records/lead_spec.rb | 21 ++++++++ .../salesforce/records/school_spec.rb | 16 ++++++ 6 files changed, 153 insertions(+) create mode 100644 app/services/salesforce/records/contact.rb create mode 100644 app/services/salesforce/records/lead.rb create mode 100644 app/services/salesforce/records/school.rb create mode 100644 spec/services/salesforce/records/contact_spec.rb create mode 100644 spec/services/salesforce/records/lead_spec.rb create mode 100644 spec/services/salesforce/records/school_spec.rb diff --git a/app/services/salesforce/records/contact.rb b/app/services/salesforce/records/contact.rb new file mode 100644 index 000000000..0784f7f87 --- /dev/null +++ b/app/services/salesforce/records/contact.rb @@ -0,0 +1,28 @@ +module Salesforce + module Records + class Contact < Base + self.table_name = 'Contact' + + belongs_to :school, foreign_key: :school_id, model: Salesforce::Records::School + + field :id, from: 'Id' + field :name, from: 'Name' + field :first_name, from: 'FirstName' + field :last_name, from: 'LastName' + field :email, from: 'Email' + field :faculty_verified, from: 'FV_Status__c' + field :last_modified_at, from: 'LastModifiedDate' + field :school_id, from: 'AccountId' + field :school_type, from: 'School_Type__c' + field :all_emails, from: 'All_Emails__c' + field :adoption_status, from: 'Adoption_Status__c' + field :accounts_uuid, from: 'Accounts_UUID__c' + field :lead_source, from: 'LeadSource' + field :signup_date, from: 'Signup_Date__c', as: :datetime + field :assignable_interest, from: 'Assignable_Interest__c' + field :assignable_adoption_date, from: 'Assignable_Adoption_Date__c', as: :datetime + field :master_record_id, from: 'MasterRecordId' + field :is_deleted, from: 'IsDeleted', as: :boolean + end + end +end diff --git a/app/services/salesforce/records/lead.rb b/app/services/salesforce/records/lead.rb new file mode 100644 index 000000000..2b9580916 --- /dev/null +++ b/app/services/salesforce/records/lead.rb @@ -0,0 +1,50 @@ +module Salesforce + module Records + class Lead < Base + self.table_name = 'Lead' + + field :id, from: 'Id' + field :name, from: 'Name' + field :first_name, from: 'FirstName' + field :last_name, from: 'LastName' + field :salutation, from: 'Salutation' + field :title, from: 'Title' + field :subject, from: 'Subject__c' + field :subject_interest, from: 'Subject_Interest__c' + field :school, from: 'Company' + field :city, from: 'City' + field :state, from: 'State' + field :state_code, from: 'StateCode' + field :country, from: 'Country' + field :phone, from: 'Phone' + field :website, from: 'Website' + field :status, from: 'Status' + field :email, from: 'Email' + field :source, from: 'LeadSource' + field :newsletter, from: 'Newsletter__c' + field :newsletter_opt_in, from: 'Newsletter_Opt_In__c' + field :adoption_status, from: 'Adoption_Status__c' + field :adoption_json, from: 'AdoptionsJSON__c' + field :num_students, from: 'Number_of_Students__c' + field :os_accounts_id, from: 'Accounts_ID__c' + field :accounts_uuid, from: 'Accounts_UUID__c' + field :application_source, from: 'Application_Source__c' + field :role, from: 'Role__c' + field :position, from: 'Position__c' + field :who_chooses_books, from: 'who_chooses_books__c' + field :verification_status, from: 'FV_Status__c' + field :b_r_i_marketing, from: 'BRI_Marketing__c', as: :boolean + field :title_1_school, from: 'Title_1_school__c', as: :boolean + field :sheerid_school_name, from: 'SheerID_School_Name__c' + field :instant_conversion, from: 'Instant_Conversion__c', as: :boolean + field :signup_date, from: 'Signup_Date__c', as: :datetime + field :self_reported_school, from: 'Self_Reported_School__c' + field :tracking_parameters, from: 'Tracking_Parameters__c' + field :expected_start_semester, from: 'Expected_Start_Semester__c' + field :account_id, from: 'Account_ID__c' + field :school_id, from: 'School__c' + field :is_converted, from: 'IsConverted', as: :boolean + field :converted_contact_id, from: 'ConvertedContactId' + end + end +end diff --git a/app/services/salesforce/records/school.rb b/app/services/salesforce/records/school.rb new file mode 100644 index 000000000..13de64a45 --- /dev/null +++ b/app/services/salesforce/records/school.rb @@ -0,0 +1,20 @@ +module Salesforce + module Records + class School < Base + self.table_name = 'Account' + + field :id, from: 'Id' + field :name, from: 'Name' + field :city, from: 'BillingCity' + field :state, from: 'BillingState' + field :country, from: 'BillingCountry' + field :type, from: 'Type' + field :school_location, from: 'School_Location__c' + field :sheerid_school_name, from: 'SheerID_School_Name__c' + field :is_kip, from: 'K_I_P__c', as: :boolean + field :is_child_of_kip, from: 'child_of_kip__c', as: :boolean + field :total_school_enrollment, from: 'Total_School_Enrollment__c', as: :integer + field :has_assignable_contacts, from: 'Has_Assignable_Contacts__c', as: :boolean + end + end +end diff --git a/spec/services/salesforce/records/contact_spec.rb b/spec/services/salesforce/records/contact_spec.rb new file mode 100644 index 000000000..3b8ec56f9 --- /dev/null +++ b/spec/services/salesforce/records/contact_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Records::Contact do + it 'maps to the Contact SObject' do + expect(described_class.table_name).to eq('Contact') + end + + %i[ + id name first_name last_name email faculty_verified last_modified_at + school_id school_type all_emails adoption_status accounts_uuid lead_source + signup_date assignable_interest assignable_adoption_date + master_record_id is_deleted + ].each do |attr| + it "responds to ##{attr}" do + expect(described_class.new).to respond_to(attr) + end + end +end diff --git a/spec/services/salesforce/records/lead_spec.rb b/spec/services/salesforce/records/lead_spec.rb new file mode 100644 index 000000000..4c3fcfe9b --- /dev/null +++ b/spec/services/salesforce/records/lead_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Records::Lead do + it 'maps to the Lead SObject' do + expect(described_class.table_name).to eq('Lead') + end + + %i[ + id name first_name last_name email phone source application_source + role position title subject_interest school city state state_code country + accounts_uuid os_accounts_id verification_status adoption_status adoption_json + num_students who_chooses_books b_r_i_marketing title_1_school newsletter + newsletter_opt_in self_reported_school sheerid_school_name account_id school_id + signup_date tracking_parameters expected_start_semester + is_converted converted_contact_id + ].each do |attr| + it "responds to ##{attr}" do + expect(described_class.new).to respond_to(attr) + end + end +end diff --git a/spec/services/salesforce/records/school_spec.rb b/spec/services/salesforce/records/school_spec.rb new file mode 100644 index 000000000..2707698d1 --- /dev/null +++ b/spec/services/salesforce/records/school_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Records::School do + it 'maps to the Account SObject' do + expect(described_class.table_name).to eq('Account') + end + + %i[ + id name city state country type school_location sheerid_school_name + is_kip is_child_of_kip total_school_enrollment has_assignable_contacts + ].each do |attr| + it "responds to ##{attr}" do + expect(described_class.new).to respond_to(attr) + end + end +end From 73c274bf3380c2affe4880102d9090406bb4cb3b Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:27:50 -0500 Subject: [PATCH 08/34] salesforce: add initializer + OpenStax inflection acronym Replaces the old openstax_salesforce initializer (deleted in Task 2) with one that calls Salesforce.configure from app secrets. The OpenStax inflection acronym used to be registered by the gem's engine; register it here now that the gem is gone, so any remaining OpenStax::-namespaced constants in the app continue to resolve cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/initializers/inflections.rb | 4 ++++ config/initializers/salesforce.rb | 10 ++++++++++ 2 files changed, 14 insertions(+) create mode 100644 config/initializers/salesforce.rb diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ac033bf9d..45d0256d7 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -14,3 +14,7 @@ # ActiveSupport::Inflector.inflections(:en) do |inflect| # inflect.acronym 'RESTful' # end + +ActiveSupport::Inflector.inflections do |inflect| + inflect.acronym 'OpenStax' +end diff --git a/config/initializers/salesforce.rb b/config/initializers/salesforce.rb new file mode 100644 index 000000000..a84a436e6 --- /dev/null +++ b/config/initializers/salesforce.rb @@ -0,0 +1,10 @@ +Salesforce.configure do |config| + secrets = Rails.application.secrets.salesforce + config.username = secrets[:username] + config.password = secrets[:password] + config.security_token = secrets[:security_token] + config.consumer_key = secrets[:consumer_key] + config.consumer_secret = secrets[:consumer_secret] + config.api_version = secrets.fetch(:api_version, '61.0') + config.login_domain = secrets.fetch(:login_domain, 'test.salesforce.com') +end From 1f5ba8ac76ce35b6ebef062b3f44c3100ad0e818 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:29:58 -0500 Subject: [PATCH 09/34] salesforce: repoint OpenStax::Salesforce::Remote::* to Salesforce::Records::* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical rename across app/, lib/, and spec/ now that the new Salesforce::Records::{Lead,Contact,School} are in place. The four salesforce-touching specs that exercise these (create_or_update_salesforce_lead, update_user_contact_info, update_school_salesforce_info, update_salesforce_assignable_fields) all pass — 46 examples, 0 failures. spec/features/admin/change_salesforce_contact_manually_spec.rb fails with `uninitialized constant SalesforceProxy`, but that was already failing on the baseline (verified by stashing the rename and re-running); unrelated to this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/admin/users_controller.rb | 2 +- app/models/user.rb | 4 +- .../create_or_update_salesforce_lead.rb | 12 +++--- .../update_salesforce_assignable_fields.rb | 2 +- app/routines/update_school_salesforce_info.rb | 4 +- app/routines/update_user_contact_info.rb | 2 +- app/routines/update_user_lead_info.rb | 2 +- ..._leads_for_instructors_not_sent_to_sf.rake | 2 +- lib/tasks/accounts/update_adopter_status.rake | 2 +- .../create_or_update_salesforce_lead_spec.rb | 40 +++++++++---------- ...pdate_salesforce_assignable_fields_spec.rb | 10 ++--- .../update_school_salesforce_info_spec.rb | 6 +-- spec/support/salesforce_spec_helpers.rb | 4 +- 13 files changed, 46 insertions(+), 46 deletions(-) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index fba9e5e0a..bd57376b9 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -108,7 +108,7 @@ def change_salesforce_contact end begin - contact = OpenStax::Salesforce::Remote::Contact.find(new_id) + contact = Salesforce::Records::Contact.find(new_id) if contact.present? # The contact really exists, so save its ID to the User diff --git a/app/models/user.rb b/app/models/user.rb index a0c5a1319..668b0d0ff 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -162,11 +162,11 @@ class User < ApplicationRecord attribute :is_not_gdpr_location, :boolean, default: nil def lead - OpenStax::Salesforce::Remote::Lead.find_by(email: best_email_address_for_salesforce) + Salesforce::Records::Lead.find_by(email: best_email_address_for_salesforce) end def contact - OpenStax::Salesforce::Remote::Contact.find(salesforce_contact_id) + Salesforce::Records::Contact.find(salesforce_contact_id) end def most_accurate_school_name diff --git a/app/routines/newflow/create_or_update_salesforce_lead.rb b/app/routines/newflow/create_or_update_salesforce_lead.rb index a276a2c3b..b0436ab6b 100644 --- a/app/routines/newflow/create_or_update_salesforce_lead.rb +++ b/app/routines/newflow/create_or_update_salesforce_lead.rb @@ -30,7 +30,7 @@ def exec(user:) sf_school_id = user.school&.salesforce_id # no school attached to user? Set to Find Me A Home unless sf_school_id - fallback_school = OpenStax::Salesforce::Remote::School.find_by(name: 'Find Me A Home') + fallback_school = Salesforce::Records::School.find_by(name: 'Find Me A Home') raise "Salesforce 'Find Me A Home' school not found — cannot assign fallback school for user #{user.id}" unless fallback_school sf_school_id = fallback_school.id @@ -65,7 +65,7 @@ def exec(user:) lead = nil if user.salesforce_lead_id begin - lead = OpenStax::Salesforce::Remote::Lead.find(user.salesforce_lead_id) + lead = Salesforce::Records::Lead.find(user.salesforce_lead_id) rescue StandardError => e # Log when the stored lead ID doesn't correspond to an existing lead or find fails SecurityLog.create!( @@ -85,7 +85,7 @@ def exec(user:) # If no lead found by stored ID, search for existing lead by UUID if lead.nil? - lead = OpenStax::Salesforce::Remote::Lead.find_by(accounts_uuid: user.uuid) + lead = Salesforce::Records::Lead.find_by(accounts_uuid: user.uuid) if lead SecurityLog.create!( user: user, @@ -97,7 +97,7 @@ def exec(user:) # If still no lead found, search by email if lead.nil? - lead = OpenStax::Salesforce::Remote::Lead.find_by(email: user.best_email_address_for_salesforce) + lead = Salesforce::Records::Lead.find_by(email: user.best_email_address_for_salesforce) if lead SecurityLog.create!( user: user, @@ -110,7 +110,7 @@ def exec(user:) # If user has a contact (already converted from lead), don't create a new lead if lead.nil? && user.salesforce_contact_id.present? begin - contact = OpenStax::Salesforce::Remote::Contact.find(user.salesforce_contact_id) + contact = Salesforce::Records::Contact.find(user.salesforce_contact_id) if contact SecurityLog.create!( user: user, @@ -132,7 +132,7 @@ def exec(user:) # Only create a new lead if none exists if lead.nil? - lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce) + lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce) SecurityLog.create!( user: user, event_type: :creating_new_salesforce_lead, diff --git a/app/routines/update_salesforce_assignable_fields.rb b/app/routines/update_salesforce_assignable_fields.rb index 3261560fe..482d8e5ed 100644 --- a/app/routines/update_salesforce_assignable_fields.rb +++ b/app/routines/update_salesforce_assignable_fields.rb @@ -16,7 +16,7 @@ def call(created_after) contact_id = external_id.user.salesforce_contact_id next if contact_id.nil? - contact = OpenStax::Salesforce::Remote::Contact.find(contact_id) + contact = Salesforce::Records::Contact.find(contact_id) next if contact.nil? contact.assignable_interest = 'Fully Integrated' diff --git a/app/routines/update_school_salesforce_info.rb b/app/routines/update_school_salesforce_info.rb index b7ef626ed..1bfa8d0e9 100644 --- a/app/routines/update_school_salesforce_info.rb +++ b/app/routines/update_school_salesforce_info.rb @@ -33,7 +33,7 @@ def call ).find_in_batches(batch_size: BATCH_SIZE) do |schools| salesforce_ids = schools.map(&:salesforce_id) - existing_salesforce_ids = OpenStax::Salesforce::Remote::School.select(:id).where( + existing_salesforce_ids = Salesforce::Records::School.select(:id).where( id: salesforce_ids ).map(&:id) @@ -46,7 +46,7 @@ def call schools_updated = 0 last_id = nil loop do - sf_schools = OpenStax::Salesforce::Remote::School.order(:Id).limit(BATCH_SIZE) + sf_schools = Salesforce::Records::School.order(:Id).limit(BATCH_SIZE) sf_schools = sf_schools.where("Id > '#{last_id}'") unless last_id.nil? sf_schools = sf_schools.to_a diff --git a/app/routines/update_user_contact_info.rb b/app/routines/update_user_contact_info.rb index 79b3b4d01..8e2d4239d 100644 --- a/app/routines/update_user_contact_info.rb +++ b/app/routines/update_user_contact_info.rb @@ -149,7 +149,7 @@ def call def salesforce_contacts contact_days = Settings::Db.store.number_of_days_contacts_modified ||= 1 c_date = contact_days.to_i.day.ago.strftime("%Y-%m-%d") - contacts ||= OpenStax::Salesforce::Remote::Contact.select( + contacts ||= Salesforce::Records::Contact.select( :id, :email, :faculty_verified, diff --git a/app/routines/update_user_lead_info.rb b/app/routines/update_user_lead_info.rb index b0f4e7b33..21c407733 100644 --- a/app/routines/update_user_lead_info.rb +++ b/app/routines/update_user_lead_info.rb @@ -14,7 +14,7 @@ def call users = User.where(salesforce_contact_id: nil) .where.not(salesforce_lead_id: nil, role: :student, faculty_status: :rejected_faculty) - leads = OpenStax::Salesforce::Remote::Lead.select(:id, :accounts_uuid, :verification_status) + leads = Salesforce::Records::Lead.select(:id, :accounts_uuid, :verification_status) .where(accounts_uuid: users.map(&:uuid)) .to_a .index_by(&:accounts_uuid) diff --git a/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake b/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake index ac1a7eed9..5f3891fb1 100644 --- a/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake +++ b/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake @@ -16,7 +16,7 @@ namespace :accounts do end users.each do |user| - lead = OpenStax::Salesforce::Remote::Lead.select(:id, :verification_status).find_by(accounts_uuid: user.uuid) + lead = Salesforce::Records::Lead.select(:id, :verification_status).find_by(accounts_uuid: user.uuid) if lead.nil? Newflow::CreateOrUpdateSalesforceLead.call(user: user) diff --git a/lib/tasks/accounts/update_adopter_status.rake b/lib/tasks/accounts/update_adopter_status.rake index b1e061151..10e416e05 100644 --- a/lib/tasks/accounts/update_adopter_status.rake +++ b/lib/tasks/accounts/update_adopter_status.rake @@ -5,7 +5,7 @@ namespace :accounts do loop do users = User.where(adopter_status: nil).where.not(salesforce_contact_id: nil).limit(250) - contacts = OpenStax::Salesforce::Remote::Contact.select( + contacts = Salesforce::Records::Contact.select( :id, :adoption_status, :accounts_uuid diff --git a/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb b/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb index 913ffc401..2e1c98d08 100644 --- a/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb +++ b/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb @@ -30,19 +30,19 @@ module Newflow before do stub_sentry # Stub the school lookup - allow(OpenStax::Salesforce::Remote::School).to receive(:find_by).with(name: 'Find Me A Home') + allow(Salesforce::Records::School).to receive(:find_by).with(name: 'Find Me A Home') .and_return(OpenStruct.new(id: 'SF_SCHOOL_HOME')) end describe 'creating a new lead' do it 'creates a new lead when none exists' do # Stub all the search methods to return nil (no existing lead) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) # Create a mock lead that will be "saved" - mock_lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:new).and_return(mock_lead) + mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce) + allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead) allow(mock_lead).to receive(:save).and_return(true) allow(mock_lead).to receive(:id).and_return('SF_LEAD_123') @@ -55,7 +55,7 @@ module Newflow describe 'finding existing leads' do let(:existing_lead) do - lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce) + lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce) allow(lead).to receive(:id).and_return('SF_LEAD_EXISTING') allow(lead).to receive(:save).and_return(true) lead @@ -63,7 +63,7 @@ module Newflow it 'finds and updates existing lead by UUID' do # Stub to return existing lead when searched by UUID - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(existing_lead) + allow(Salesforce::Records::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(existing_lead) described_class.call(user: user) @@ -74,8 +74,8 @@ module Newflow it 'finds and updates existing lead by email when UUID search fails' do # Stub UUID search to return nil, email search to return existing lead - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(existing_lead) + allow(Salesforce::Records::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(existing_lead) described_class.call(user: user) @@ -89,7 +89,7 @@ module Newflow user.save! # Stub to return existing lead when searched by ID - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find).with('SF_LEAD_STORED').and_return(existing_lead) + allow(Salesforce::Records::Lead).to receive(:find).with('SF_LEAD_STORED').and_return(existing_lead) allow(existing_lead).to receive(:id).and_return('SF_LEAD_STORED') described_class.call(user: user) @@ -103,11 +103,11 @@ module Newflow describe 'when lead save fails' do it 'logs to SecurityLog and Sentry' do - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) - mock_lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:new).and_return(mock_lead) + mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce) + allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead) allow(mock_lead).to receive(:save).and_return(false) allow(mock_lead).to receive(:errors).and_return(double(full_messages: ['Some SF error'])) @@ -129,11 +129,11 @@ module Newflow user.save! # Stub all lead searches to return nil - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) # Stub contact lookup to return existing contact - allow(OpenStax::Salesforce::Remote::Contact).to receive(:find).with('SF_CONTACT_123').and_return(existing_contact) + allow(Salesforce::Records::Contact).to receive(:find).with('SF_CONTACT_123').and_return(existing_contact) result = described_class.call(user: user) @@ -145,15 +145,15 @@ module Newflow describe 'expected_start_semester assignment' do let(:mock_lead) do - lead = OpenStax::Salesforce::Remote::Lead.new(email: user.best_email_address_for_salesforce) + lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce) allow(lead).to receive(:save).and_return(true) allow(lead).to receive(:id).and_return('SF_LEAD_999') lead end before do - allow(OpenStax::Salesforce::Remote::Lead).to receive(:find_by).and_return(nil) - allow(OpenStax::Salesforce::Remote::Lead).to receive(:new).and_return(mock_lead) + allow(Salesforce::Records::Lead).to receive(:find_by).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead) end [ diff --git a/spec/routines/update_salesforce_assignable_fields_spec.rb b/spec/routines/update_salesforce_assignable_fields_spec.rb index f30ee1c49..99705da0f 100644 --- a/spec/routines/update_salesforce_assignable_fields_spec.rb +++ b/spec/routines/update_salesforce_assignable_fields_spec.rb @@ -23,15 +23,15 @@ it "updates Salesforce Contact with Assignable user's info" do stub_contacts [ non_assignable_instructor, assignable_instructor ] - expect_any_instance_of(OpenStax::Salesforce::Remote::Contact).to( + expect_any_instance_of(Salesforce::Records::Contact).to( receive(:assignable_interest=).with('Fully Integrated').and_call_original ) - expect_any_instance_of(OpenStax::Salesforce::Remote::Contact).to( + expect_any_instance_of(Salesforce::Records::Contact).to( receive(:assignable_adoption_date=).with( assignable_instructor.external_ids.map(&:created_at).min.strftime('%Y-%m-%d') ).and_call_original ) - expect_any_instance_of(OpenStax::Salesforce::Remote::Contact).to receive(:save!) + expect_any_instance_of(Salesforce::Records::Contact).to receive(:save!) described_class.call end @@ -40,9 +40,9 @@ def stub_contacts(users) sf_contacts = [users].flatten.map do |user| id = user.salesforce_contact_id - [ id, OpenStax::Salesforce::Remote::Contact.new(id: id) ] + [ id, Salesforce::Records::Contact.new(id: id) ] end.to_h - expect(OpenStax::Salesforce::Remote::Contact).to receive(:find) { |id| sf_contacts[id] } + expect(Salesforce::Records::Contact).to receive(:find) { |id| sf_contacts[id] } end end diff --git a/spec/routines/update_school_salesforce_info_spec.rb b/spec/routines/update_school_salesforce_info_spec.rb index 884930671..2d4214a82 100644 --- a/spec/routines/update_school_salesforce_info_spec.rb +++ b/spec/routines/update_school_salesforce_info_spec.rb @@ -64,17 +64,17 @@ def stub_schools(schools) attrs['id'] = attrs.delete('salesforce_id') attrs['school_location'] = attrs.delete('location') - OpenStax::Salesforce::Remote::School.new attrs + Salesforce::Records::School.new attrs end select_query = instance_double(ActiveForce::ActiveQuery) - expect(OpenStax::Salesforce::Remote::School).to( + expect(Salesforce::Records::School).to( receive(:select).with(:id).and_return(select_query) ) expect(select_query).to receive(:where).with(id: kind_of(Array)).and_return(sf_schools) order_query = instance_double(ActiveForce::ActiveQuery) - expect(OpenStax::Salesforce::Remote::School).to( + expect(Salesforce::Records::School).to( receive(:order).with(:Id).and_return(order_query) ) expect(order_query).to receive(:limit).with(described_class::BATCH_SIZE).and_return(sf_schools) diff --git a/spec/support/salesforce_spec_helpers.rb b/spec/support/salesforce_spec_helpers.rb index 5911f1118..7739bccb3 100644 --- a/spec/support/salesforce_spec_helpers.rb +++ b/spec/support/salesforce_spec_helpers.rb @@ -1,7 +1,7 @@ module SalesforceSpecHelpers # Helper method to create a Salesforce contact mock def create_sf_contact(uuid:, faculty_verified:, contact_id: 'SF_CONTACT_001', school_id: 'SF_SCHOOL_001') - contact = OpenStax::Salesforce::Remote::Contact.new( + contact = Salesforce::Records::Contact.new( id: contact_id, accounts_uuid: uuid, faculty_verified: faculty_verified, @@ -10,7 +10,7 @@ def create_sf_contact(uuid:, faculty_verified:, contact_id: 'SF_CONTACT_001', sc ) # Mock the school association - sf_school = OpenStax::Salesforce::Remote::School.new( + sf_school = Salesforce::Records::School.new( id: school_id, school_location: 'Domestic', is_kip: false, From ad5fe4d4fc055345167b313af7498baeacf14fdf Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:34:43 -0500 Subject: [PATCH 10/34] salesforce: append new SecurityLog event types for sync redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appended (not inserted — Rails enums are positional) at the end of the event_type enum. Covers the audit taxonomy used by Salesforce::Audit, Salesforce::Lookup, Salesforce::UpsertLead, Salesforce::SyncContacts, and Salesforce::Reconcile. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/models/security_log.rb | 23 +++++++++++++++++++++++ spec/models/security_log_spec.rb | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/app/models/security_log.rb b/app/models/security_log.rb index a90127dc0..27343a375 100644 --- a/app/models/security_log.rb +++ b/app/models/security_log.rb @@ -113,6 +113,29 @@ class SecurityLog < ApplicationRecord user_already_has_contact_not_creating_lead creating_new_salesforce_lead salesforce_lead_save_failed + salesforce_lookup_started + salesforce_lookup_matched_by_stored_id + salesforce_lookup_matched_by_uuid + salesforce_lookup_matched_by_email + salesforce_lookup_stored_id_disowned + salesforce_lookup_email_collision + salesforce_upsert_lead_begin + salesforce_upsert_lead_saved + salesforce_upsert_lead_save_failed + salesforce_upsert_lead_skipped_user_has_contact + salesforce_lead_id_persist_retry + salesforce_lead_id_persist_failed + salesforce_stale_contact_id_cleared + salesforce_contact_skipped_merged_or_deleted + salesforce_contact_id_swapped + salesforce_contact_id_conflict + salesforce_user_school_not_cached + salesforce_reconcile_user_ok + salesforce_reconcile_contact_id_cleared + salesforce_reconcile_followed_merge + salesforce_reconcile_attached_from_conversion + salesforce_link_restored_by_reconcile + salesforce_contact_id_orphaned ] json_serialize :event_data, Hash diff --git a/spec/models/security_log_spec.rb b/spec/models/security_log_spec.rb index 40e81aa9b..12cb50ed3 100644 --- a/spec/models/security_log_spec.rb +++ b/spec/models/security_log_spec.rb @@ -19,4 +19,25 @@ it 'cannot be destroyed' do expect{security_log.destroy}.to raise_error ActiveRecord::ReadOnlyRecord end + + it 'includes the new salesforce event types added in the sync redesign' do + %i[ + salesforce_lookup_started + salesforce_lookup_matched_by_uuid + salesforce_upsert_lead_saved + salesforce_upsert_lead_save_failed + salesforce_lead_id_persist_failed + salesforce_stale_contact_id_cleared + salesforce_contact_id_swapped + salesforce_contact_id_conflict + salesforce_contact_skipped_merged_or_deleted + salesforce_user_school_not_cached + salesforce_reconcile_contact_id_cleared + salesforce_reconcile_followed_merge + salesforce_link_restored_by_reconcile + salesforce_contact_id_orphaned + ].each do |sym| + expect(SecurityLog.event_types).to have_key(sym.to_s) + end + end end From 6c3c7673eef22e49ad3bded8acbf693e080031ab Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:35:20 -0500 Subject: [PATCH 11/34] salesforce: add Audit wrapper with stable event taxonomy Audit.record(user, :event_name, **details) prepends "salesforce_" to form the SecurityLog#event_type and validates against the enum so a typo at a call site fails loudly at test time, not silently in production. event_data carries the details hash as-is. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/audit.rb | 13 +++++++++++ spec/services/salesforce/audit_spec.rb | 31 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 app/services/salesforce/audit.rb create mode 100644 spec/services/salesforce/audit_spec.rb diff --git a/app/services/salesforce/audit.rb b/app/services/salesforce/audit.rb new file mode 100644 index 000000000..e2ccae98f --- /dev/null +++ b/app/services/salesforce/audit.rb @@ -0,0 +1,13 @@ +module Salesforce + module Audit + EVENT_PREFIX = 'salesforce_'.freeze + + def self.record(user, event_name, **details) + full = "#{EVENT_PREFIX}#{event_name}" + unless SecurityLog.event_types.key?(full) + raise ArgumentError, "Unknown Salesforce audit event: #{full.inspect}. Add it to SecurityLog#event_type." + end + SecurityLog.create!(user: user, event_type: full, event_data: details) + end + end +end diff --git a/spec/services/salesforce/audit_spec.rb b/spec/services/salesforce/audit_spec.rb new file mode 100644 index 000000000..ac0b03696 --- /dev/null +++ b/spec/services/salesforce/audit_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Audit do + let(:user) { FactoryBot.create(:user) } + + describe '.record' do + it 'prepends salesforce_ to the event name' do + expect { + described_class.record(user, :upsert_lead_saved, lead_id: 'X') + }.to change { SecurityLog.where(event_type: 'salesforce_upsert_lead_saved').count }.by(1) + end + + it 'stores details in event_data' do + described_class.record(user, :upsert_lead_saved, lead_id: 'X', matched_by: :uuid) + log = SecurityLog.where(event_type: 'salesforce_upsert_lead_saved').last + expect(log.event_data).to include('lead_id' => 'X', 'matched_by' => 'uuid') + end + + it 'raises if the event_type is not registered' do + expect { + described_class.record(user, :not_a_real_event) + }.to raise_error(ArgumentError, /Unknown Salesforce audit event/) + end + + it 'allows nil user (system-level events)' do + expect { + described_class.record(nil, :upsert_lead_saved, lead_id: 'X') + }.not_to raise_error + end + end +end From 9e6579a3c9c9592ef265c27783710d04b7401d4b Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:36:34 -0500 Subject: [PATCH 12/34] salesforce: add Settings fields + wrappers for sync, reconcile, alerts Adds 12 new Settings::Db.store fields covering: - SyncContacts cursor + lookback overlap - Reconcile budget + per-pass cursors - Per-run alert thresholds (lead save failure, swap/conflict rates, unknown UUIDs, drift open total, cron drift) - SF-admin notification toggle - Reconcile self-heal feature flag Wrappers on Settings::Salesforce and Settings::FeatureFlags keep call sites readable and isolate the underlying rails-settings-cached storage. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/settings.rb | 14 +++++++ lib/settings/feature_flags.rb | 8 ++++ lib/settings/salesforce.rb | 56 ++++++++++++++++++++++++++++ spec/lib/settings_salesforce_spec.rb | 36 ++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 spec/lib/settings_salesforce_spec.rb diff --git a/lib/settings.rb b/lib/settings.rb index ad351ff6c..3fe6ee6aa 100644 --- a/lib/settings.rb +++ b/lib/settings.rb @@ -146,6 +146,20 @@ class Store < RailsSettings::Base type: :string, default: 'https://offers.sheerid.com/openstax/staging/teacher/?env=dev' field :number_of_days_contacts_modified, type: :integer, default: 7 field :minimum_recaptcha_score, type: :float, default: 0.2 + + # Salesforce sync redesign (see docs/superpowers/specs/2026-05-20-salesforce-sync-design.md) + field :salesforce_contacts_synced_through, type: :string, default: nil + field :salesforce_contacts_lookback_overlap_hours, type: :integer, default: 1 + field :salesforce_reconcile_max_queries, type: :integer, default: 5000 + field :salesforce_reconcile_pass_cursors, type: :hash, default: {} + field :salesforce_alert_lead_save_failure_rate_pct, type: :integer, default: 2 + field :salesforce_alert_contact_id_conflict_count, type: :integer, default: 5 + field :salesforce_alert_contact_id_swap_rate_pct, type: :integer, default: 5 + field :salesforce_alert_unknown_uuid_count, type: :integer, default: 50 + field :salesforce_alert_drift_open_total, type: :integer, default: 100 + field :salesforce_alert_cron_drift_hours, type: :integer, default: 2 + field :salesforce_sf_admin_notify_enabled, type: :boolean, default: false + field :salesforce_reconcile_self_heal_enabled, type: :boolean, default: false end mattr_accessor :store diff --git a/lib/settings/feature_flags.rb b/lib/settings/feature_flags.rb index a4bc0500f..552fac45a 100644 --- a/lib/settings/feature_flags.rb +++ b/lib/settings/feature_flags.rb @@ -38,6 +38,14 @@ def collect_student_count_all_paths=(bool) Settings::Db.store.collect_student_count_all_paths = bool end + def salesforce_reconcile_self_heal + Settings::Db.store.salesforce_reconcile_self_heal_enabled + end + + def salesforce_reconcile_self_heal=(bool) + Settings::Db.store.salesforce_reconcile_self_heal_enabled = bool + end + end end end diff --git a/lib/settings/salesforce.rb b/lib/settings/salesforce.rb index bdcb853fb..d4d80b056 100644 --- a/lib/settings/salesforce.rb +++ b/lib/settings/salesforce.rb @@ -27,6 +27,62 @@ def show_support_chat=(bool) Settings::Db.store.show_support_chat = bool end + # SyncContacts cursor — UTC time of the last successful run. + def contacts_synced_through + v = Settings::Db.store.salesforce_contacts_synced_through + v.present? ? Time.iso8601(v) : nil + end + + def contacts_synced_through=(time) + Settings::Db.store.salesforce_contacts_synced_through = time&.utc&.iso8601 + end + + def contacts_lookback_overlap_hours + Settings::Db.store.salesforce_contacts_lookback_overlap_hours + end + + # Reconcile budget — soft cap on Salesforce queries per run. + def reconcile_max_queries + Settings::Db.store.salesforce_reconcile_max_queries + end + + def reconcile_pass_cursors + Settings::Db.store.salesforce_reconcile_pass_cursors || {} + end + + def reconcile_pass_cursors=(hash) + Settings::Db.store.salesforce_reconcile_pass_cursors = hash + end + + # Threshold knobs for the per-run alert checks. + def alert_lead_save_failure_rate_pct + Settings::Db.store.salesforce_alert_lead_save_failure_rate_pct + end + + def alert_contact_id_conflict_count + Settings::Db.store.salesforce_alert_contact_id_conflict_count + end + + def alert_contact_id_swap_rate_pct + Settings::Db.store.salesforce_alert_contact_id_swap_rate_pct + end + + def alert_unknown_uuid_count + Settings::Db.store.salesforce_alert_unknown_uuid_count + end + + def alert_drift_open_total + Settings::Db.store.salesforce_alert_drift_open_total + end + + def alert_cron_drift_hours + Settings::Db.store.salesforce_alert_cron_drift_hours + end + + def sf_admin_notify_enabled + Settings::Db.store.salesforce_sf_admin_notify_enabled + end + end end diff --git a/spec/lib/settings_salesforce_spec.rb b/spec/lib/settings_salesforce_spec.rb new file mode 100644 index 000000000..a8e80a1e0 --- /dev/null +++ b/spec/lib/settings_salesforce_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe Settings::Salesforce do + describe 'contacts_synced_through' do + after { Settings::Db.store.salesforce_contacts_synced_through = nil } + + it 'round-trips a UTC time as ISO8601' do + t = Time.utc(2026, 5, 1, 12, 0, 0) + Settings::Salesforce.contacts_synced_through = t + expect(Settings::Salesforce.contacts_synced_through).to eq(t) + end + + it 'returns nil when unset' do + Settings::Db.store.salesforce_contacts_synced_through = nil + expect(Settings::Salesforce.contacts_synced_through).to be_nil + end + end + + it 'exposes reconcile_max_queries default' do + expect(Settings::Salesforce.reconcile_max_queries).to eq(5000) + end + + it 'exposes alert thresholds' do + expect(Settings::Salesforce.alert_contact_id_conflict_count).to eq(5) + expect(Settings::Salesforce.alert_contact_id_swap_rate_pct).to eq(5) + expect(Settings::Salesforce.alert_drift_open_total).to eq(100) + end + + it 'exposes the feature flag through Settings::FeatureFlags' do + expect(Settings::FeatureFlags.salesforce_reconcile_self_heal).to be(false) + Settings::FeatureFlags.salesforce_reconcile_self_heal = true + expect(Settings::FeatureFlags.salesforce_reconcile_self_heal).to be(true) + ensure + Settings::FeatureFlags.salesforce_reconcile_self_heal = false + end +end From 16fbbd773369f5fe6e792624691f58474b3bfbc6 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:37:22 -0500 Subject: [PATCH 13/34] salesforce: add Metrics (counters + Sentry check-in + tagged alerts) Per-run counter bag used by SyncContacts, SyncSchools, Reconcile, and UpsertLead. Integer counters and labeled sub-counters with a :total. #emit writes a tagged JSON log line and (when a slug is configured) closes a Sentry check-in. #alert! produces a Sentry message tagged salesforce-alert= so existing tag-based alert rules can subscribe. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/metrics.rb | 63 ++++++++++++++++++++++++ spec/services/salesforce/metrics_spec.rb | 60 ++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 app/services/salesforce/metrics.rb create mode 100644 spec/services/salesforce/metrics_spec.rb diff --git a/app/services/salesforce/metrics.rb b/app/services/salesforce/metrics.rb new file mode 100644 index 000000000..44dfc513a --- /dev/null +++ b/app/services/salesforce/metrics.rb @@ -0,0 +1,63 @@ +module Salesforce + # Per-run counter bag for the Salesforce sync routines. + # + # Routines instantiate one Metrics, increment counters during the run, and + # call #emit at the end. emit writes a Sentry check-in (when a slug is + # configured) plus a single JSON log line tagged with the run name. #alert! + # produces a tagged Sentry message for threshold-based alerts so existing + # tag-based alert rules can subscribe to the `salesforce-alert` tag. + class Metrics + attr_reader :counters, :run, :slug, :started_at + + def initialize(run:, slug: nil) + @run = run + @slug = slug + @counters = {} + @started_at = Time.current + @check_in_id = nil + end + + # Mark a Sentry check-in as in_progress so the eventual #emit can close it. + def start! + return unless slug + @check_in_id = Sentry.capture_check_in(slug, :in_progress) + end + + # Increment a counter. With no labels, stores an integer. + # With keyword labels, stores a hash with :total plus a per-label tally. + def increment(key, by: 1, **labels) + if labels.empty? + current = @counters[key] + @counters[key] = (current.is_a?(Integer) ? current : 0) + by + else + existing = @counters[key].is_a?(Hash) ? @counters[key] : { total: 0 } + existing[:total] = (existing[:total] || 0) + by + labels.each_value do |label_value| + existing[label_value] = (existing[label_value] || 0) + by + end + @counters[key] = existing + end + end + + def emit(status: :ok, extra: {}) + payload = { + run: run, + status: status, + duration_s: (Time.current - started_at).to_i, + counters: counters, + **extra + } + Rails.logger.tagged('salesforce', run) { Rails.logger.info(payload.to_json) } + Sentry.capture_check_in(slug, status, check_in_id: @check_in_id) if slug + payload + end + + def alert!(name, value:, threshold:) + Sentry.capture_message( + "Salesforce alert: #{name} (value=#{value}, threshold=#{threshold})", + tags: { 'salesforce-alert' => name.to_s }, + extra: { value: value, threshold: threshold, run: run } + ) + end + end +end diff --git a/spec/services/salesforce/metrics_spec.rb b/spec/services/salesforce/metrics_spec.rb new file mode 100644 index 000000000..e437561fa --- /dev/null +++ b/spec/services/salesforce/metrics_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Metrics do + before do + allow(Sentry).to receive(:capture_check_in).and_return('check_in_id') + allow(Sentry).to receive(:capture_message) + end + + describe '#increment' do + it 'accumulates integer counters' do + m = described_class.new(run: 'sync_contacts') + m.increment(:users_updated) + m.increment(:users_updated, by: 2) + expect(m.counters[:users_updated]).to eq(3) + end + + it 'labels sub-counters by keyword and tracks a total' do + m = described_class.new(run: 'sync_contacts') + m.increment(:contact_id_swaps, reason: :merged) + m.increment(:contact_id_swaps, reason: :merged) + m.increment(:contact_id_swaps, reason: :gone) + expect(m.counters[:contact_id_swaps]).to eq(total: 3, merged: 2, gone: 1) + end + end + + describe '#start! and #emit' do + it 'opens and closes a Sentry check-in when a slug is given' do + m = described_class.new(run: 'sync_contacts', slug: 'update-user-contact-info') + expect(Sentry).to receive(:capture_check_in).with('update-user-contact-info', :in_progress) + m.start! + expect(Sentry).to receive(:capture_check_in).with('update-user-contact-info', :ok, hash_including(:check_in_id)) + m.emit(status: :ok) + end + + it 'skips Sentry check-in when no slug' do + m = described_class.new(run: 'sync_contacts') + expect(Sentry).not_to receive(:capture_check_in) + m.start! + m.emit + end + + it 'returns the payload from emit' do + m = described_class.new(run: 'sync_contacts') + m.increment(:users_updated, by: 5) + payload = m.emit(status: :ok) + expect(payload).to include(run: 'sync_contacts', status: :ok) + expect(payload[:counters][:users_updated]).to eq(5) + expect(payload[:duration_s]).to be_a(Integer) + end + end + + describe '#alert!' do + it 'fires a Sentry message tagged with salesforce-alert' do + m = described_class.new(run: 'sync_contacts') + m.alert!(:contact_id_swap_rate_high, value: 12.0, threshold: 5.0) + expect(Sentry).to have_received(:capture_message) + .with(/contact_id_swap_rate_high/, hash_including(tags: hash_including('salesforce-alert' => 'contact_id_swap_rate_high'))) + end + end +end From 4956a4343e8aa2884f611ea36eaab5f161b2f7d6 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:44:59 -0500 Subject: [PATCH 14/34] salesforce: add Verify (lead/contact ownership + replacement checks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lead_owns_user? — true when UUID matches or is blank (adoptable). contact_owns_user? — same, but a contact never owns when merged or deleted. contact_can_be_replaced? — returns :gone, :merged, :uuid_cleared, or false. Used by SyncContacts to gate every contact_id swap on evidence rather than blindly trusting the incremental sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/verify.rb | 35 ++++++++++ spec/services/salesforce/verify_spec.rb | 88 +++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 app/services/salesforce/verify.rb create mode 100644 spec/services/salesforce/verify_spec.rb diff --git a/app/services/salesforce/verify.rb b/app/services/salesforce/verify.rb new file mode 100644 index 000000000..5996f1c6b --- /dev/null +++ b/app/services/salesforce/verify.rb @@ -0,0 +1,35 @@ +module Salesforce + # Ownership and replacement checks. A Salesforce Lead or Contact "owns" a + # user when its Accounts_UUID__c matches the user's UUID, or is blank (so we + # can adopt a legacy unowned record). Merged-away or deleted Contacts never + # own anyone. + module Verify + module_function + + def lead_owns_user?(lead, user) + return false if lead.nil? + lead.accounts_uuid.blank? || lead.accounts_uuid == user.uuid + end + + def contact_owns_user?(contact, user) + return false if contact.nil? + return false if contact.master_record_id.present? + return false if contact.is_deleted + contact.accounts_uuid == user.uuid + end + + # Is it safe to replace the stored salesforce_contact_id with `by`? + # Returns: + # :gone — previous Contact missing or deleted in Salesforce + # :merged — previous Contact has been merged into `by` + # :uuid_cleared — previous Contact's Accounts_UUID__c is now blank + # false — both records are live and own this user (human review) + def contact_can_be_replaced?(previous_id:, by:, user:) + prev = Salesforce::Records::Contact.find_by({ id: previous_id }) + return :gone if prev.nil? || prev.is_deleted + return :merged if prev.master_record_id.present? && prev.master_record_id == by.id + return :uuid_cleared if prev.accounts_uuid.blank? && by.accounts_uuid == user.uuid + false + end + end +end diff --git a/spec/services/salesforce/verify_spec.rb b/spec/services/salesforce/verify_spec.rb new file mode 100644 index 000000000..0760f1e4a --- /dev/null +++ b/spec/services/salesforce/verify_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Verify do + let(:user) { instance_double(User, uuid: 'USER-UUID') } + + describe '.lead_owns_user?' do + it 'true when lead UUID matches' do + lead = Salesforce::Records::Lead.new(accounts_uuid: 'USER-UUID') + expect(described_class.lead_owns_user?(lead, user)).to be(true) + end + + it 'true when lead UUID blank (adoptable)' do + lead = Salesforce::Records::Lead.new(accounts_uuid: nil) + expect(described_class.lead_owns_user?(lead, user)).to be(true) + end + + it 'false when lead UUID belongs to someone else' do + lead = Salesforce::Records::Lead.new(accounts_uuid: 'OTHER-UUID') + expect(described_class.lead_owns_user?(lead, user)).to be(false) + end + + it 'false for nil lead' do + expect(described_class.lead_owns_user?(nil, user)).to be(false) + end + end + + describe '.contact_owns_user?' do + it 'true when contact UUID matches and contact is live' do + contact = Salesforce::Records::Contact.new(accounts_uuid: 'USER-UUID', master_record_id: nil, is_deleted: false) + expect(described_class.contact_owns_user?(contact, user)).to be(true) + end + + it 'false if contact has been merged' do + contact = Salesforce::Records::Contact.new(accounts_uuid: 'USER-UUID', master_record_id: 'SOMETHING') + expect(described_class.contact_owns_user?(contact, user)).to be(false) + end + + it 'false if contact is deleted' do + contact = Salesforce::Records::Contact.new(accounts_uuid: 'USER-UUID', is_deleted: true) + expect(described_class.contact_owns_user?(contact, user)).to be(false) + end + + it 'false when UUID mismatches' do + contact = Salesforce::Records::Contact.new(accounts_uuid: 'OTHER', master_record_id: nil, is_deleted: false) + expect(described_class.contact_owns_user?(contact, user)).to be(false) + end + + it 'false when UUID is blank' do + contact = Salesforce::Records::Contact.new(accounts_uuid: nil, master_record_id: nil, is_deleted: false) + expect(described_class.contact_owns_user?(contact, user)).to be(false) + end + end + + describe '.contact_can_be_replaced?' do + let(:replacement) do + Salesforce::Records::Contact.new(id: 'NEW', accounts_uuid: 'USER-UUID', master_record_id: nil, is_deleted: false) + end + + it ':gone when previous missing in SF' do + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(nil) + expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:gone) + end + + it ':gone when previous is_deleted' do + prev = Salesforce::Records::Contact.new(id: 'OLD', is_deleted: true) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev) + expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:gone) + end + + it ':merged when previous master_record_id == replacement id' do + prev = Salesforce::Records::Contact.new(id: 'OLD', master_record_id: 'NEW', accounts_uuid: 'USER-UUID', is_deleted: false) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev) + expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:merged) + end + + it ':uuid_cleared when previous accounts_uuid is now blank and new owns this user' do + prev = Salesforce::Records::Contact.new(id: 'OLD', master_record_id: nil, accounts_uuid: nil, is_deleted: false) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev) + expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to eq(:uuid_cleared) + end + + it 'false when both are live and own this user' do + prev = Salesforce::Records::Contact.new(id: 'OLD', master_record_id: nil, accounts_uuid: 'USER-UUID', is_deleted: false) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(prev) + expect(described_class.contact_can_be_replaced?(previous_id: 'OLD', by: replacement, user: user)).to be(false) + end + end +end From 50283073e7a91c6b3ec36311dc1217f6f405403f Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:46:01 -0500 Subject: [PATCH 15/34] salesforce: add Lookup waterfall (stored_id -> uuid -> email) Resolves a Lead (or Contact) for a User by trying the stored salesforce_lead_id first (best signal), then accounts_uuid (strongest match), then email with a UUID-collision guard so we don't claim a lead that belongs to another user. Returns a Result struct carrying :lead, :matched_by, and :rejected reasons for downstream audit. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/lookup.rb | 78 +++++++++++++++++++ spec/services/salesforce/lookup_spec.rb | 99 +++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 app/services/salesforce/lookup.rb create mode 100644 spec/services/salesforce/lookup_spec.rb diff --git a/app/services/salesforce/lookup.rb b/app/services/salesforce/lookup.rb new file mode 100644 index 000000000..2a97e3755 --- /dev/null +++ b/app/services/salesforce/lookup.rb @@ -0,0 +1,78 @@ +module Salesforce + module Lookup + Result = Struct.new(:lead, :contact, :matched_by, :rejected, keyword_init: true) do + def initialize(**kwargs) + kwargs[:rejected] ||= [] + super(**kwargs) + end + end + + module_function + + # Resolve a Salesforce::Records::Lead for a user. Tries the stored + # salesforce_lead_id first (best), then accounts_uuid, then email (with a + # UUID-collision guard). Returns a Result; result.lead may be nil. + def lead_for(user) + result = Result.new + + if user.salesforce_lead_id.present? + lead = safe_find(Salesforce::Records::Lead, user.salesforce_lead_id) + if lead.nil? + result.rejected << :stored_id_not_found + elsif Verify.lead_owns_user?(lead, user) + result.lead = lead + result.matched_by = :stored_id + Audit.record(user, :lookup_matched_by_stored_id, lead_id: lead.id) + return result + else + result.rejected << :stored_id_owned_by_other_user + Audit.record(user, :lookup_stored_id_disowned, lead_id: lead.id) + end + end + + lead = Salesforce::Records::Lead.find_by({ accounts_uuid: user.uuid }) + if lead + result.lead = lead + result.matched_by = :uuid + Audit.record(user, :lookup_matched_by_uuid, lead_id: lead.id) + return result + end + + email = user.best_email_address_for_salesforce + if email.present? + lead = Salesforce::Records::Lead.find_by({ email: email }) + if lead && Verify.lead_owns_user?(lead, user) + result.lead = lead + result.matched_by = :email + Audit.record(user, :lookup_matched_by_email, lead_id: lead.id, email: email) + return result + elsif lead + result.rejected << :email_match_uuid_conflict + Audit.record(user, :lookup_email_collision, lead_id: lead.id, email: email) + end + end + + result + end + + # Resolve a verified Contact for a user, or nil. Stored + # salesforce_contact_id is the primary signal; falls back to UUID. + def contact_for(user) + if user.salesforce_contact_id.present? + contact = safe_find(Salesforce::Records::Contact, user.salesforce_contact_id) + return contact if Verify.contact_owns_user?(contact, user) + end + + return nil if user.uuid.blank? + + candidate = Salesforce::Records::Contact.find_by({ accounts_uuid: user.uuid }) + Verify.contact_owns_user?(candidate, user) ? candidate : nil + end + + def safe_find(klass, id) + klass.find(id) + rescue StandardError + nil + end + end +end diff --git a/spec/services/salesforce/lookup_spec.rb b/spec/services/salesforce/lookup_spec.rb new file mode 100644 index 000000000..54579c44a --- /dev/null +++ b/spec/services/salesforce/lookup_spec.rb @@ -0,0 +1,99 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Lookup do + let(:user) { FactoryBot.create(:user) } + let(:matching_lead) { Salesforce::Records::Lead.new(id: 'L1', accounts_uuid: user.uuid) } + let(:foreign_lead) { Salesforce::Records::Lead.new(id: 'L2', accounts_uuid: 'OTHER') } + + before do + allow(user).to receive(:best_email_address_for_salesforce).and_return('e@example.com') + end + + describe '.lead_for' do + context 'with a stored salesforce_lead_id that still owns the user' do + it 'matches by stored_id' do + user.update!(salesforce_lead_id: 'L1') + allow(Salesforce::Records::Lead).to receive(:find).with('L1').and_return(matching_lead) + result = described_class.lead_for(user) + expect(result.lead).to eq(matching_lead) + expect(result.matched_by).to eq(:stored_id) + end + end + + context 'when stored id belongs to another user' do + it 'falls through to uuid match and records the disown' do + user.update!(salesforce_lead_id: 'L2') + allow(Salesforce::Records::Lead).to receive(:find).with('L2').and_return(foreign_lead) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(matching_lead) + result = described_class.lead_for(user) + expect(result.lead).to eq(matching_lead) + expect(result.matched_by).to eq(:uuid) + expect(result.rejected).to include(:stored_id_owned_by_other_user) + expect(SecurityLog.where(event_type: 'salesforce_lookup_stored_id_disowned', user: user)).to exist + end + end + + context 'when stored id raises (missing in SF)' do + it 'falls through to uuid match' do + user.update!(salesforce_lead_id: 'L_MISSING') + allow(Salesforce::Records::Lead).to receive(:find).with('L_MISSING').and_raise(StandardError, 'gone') + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(matching_lead) + result = described_class.lead_for(user) + expect(result.matched_by).to eq(:uuid) + end + end + + context 'when only email matches but its UUID is foreign' do + it 'rejects the email match' do + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: 'e@example.com' }).and_return(foreign_lead) + result = described_class.lead_for(user) + expect(result.lead).to be_nil + expect(result.rejected).to include(:email_match_uuid_conflict) + end + end + + context 'when email matches and is adoptable (UUID blank)' do + it 'matches by email' do + adoptable = Salesforce::Records::Lead.new(id: 'L_OLD', accounts_uuid: nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: 'e@example.com' }).and_return(adoptable) + result = described_class.lead_for(user) + expect(result.lead).to eq(adoptable) + expect(result.matched_by).to eq(:email) + end + end + + context 'when nothing matches' do + it 'returns nil lead with matched_by nil' do + allow(Salesforce::Records::Lead).to receive(:find_by).and_return(nil) + result = described_class.lead_for(user) + expect(result.lead).to be_nil + expect(result.matched_by).to be_nil + end + end + end + + describe '.contact_for' do + it 'returns the stored Contact when it owns the user' do + user.update!(salesforce_contact_id: 'C1') + contact = Salesforce::Records::Contact.new(id: 'C1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false) + allow(Salesforce::Records::Contact).to receive(:find).with('C1').and_return(contact) + expect(described_class.contact_for(user)).to eq(contact) + end + + it 'falls through to UUID lookup when stored id is disowned' do + user.update!(salesforce_contact_id: 'C_FOREIGN') + foreign = Salesforce::Records::Contact.new(id: 'C_FOREIGN', accounts_uuid: 'OTHER', master_record_id: nil, is_deleted: false) + uuid_match = Salesforce::Records::Contact.new(id: 'C_OK', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false) + allow(Salesforce::Records::Contact).to receive(:find).with('C_FOREIGN').and_return(foreign) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(uuid_match) + expect(described_class.contact_for(user)).to eq(uuid_match) + end + + it 'returns nil when nothing owns the user' do + allow(Salesforce::Records::Contact).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + expect(described_class.contact_for(user)).to be_nil + end + end +end From d02df5a85166b0d0804b74ff54f70f5f0ec1aeed Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:46:59 -0500 Subject: [PATCH 16/34] salesforce: add ResolveFacultyStatus (signup + contact paths) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the faculty-status decision logic out of CreateOrUpdateSalesforceLead (lines 52-63) and UpdateUserContactInfo (lines 75-88) into one place, with two entry points: - from_signup(user): sets status based on profile completion + SheerID; persists the user. - from_contact(user, sf_contact): applies the SF-side faculty_verified value, respecting the protection rules — confirmed/pending/rejected can't be downgraded to incomplete/no_info, and confirmed can't be rolled back to pending. Doesn't persist; caller decides. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../salesforce/resolve_faculty_status.rb | 62 +++++++++++++++++ .../salesforce/resolve_faculty_status_spec.rb | 69 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 app/services/salesforce/resolve_faculty_status.rb create mode 100644 spec/services/salesforce/resolve_faculty_status_spec.rb diff --git a/app/services/salesforce/resolve_faculty_status.rb b/app/services/salesforce/resolve_faculty_status.rb new file mode 100644 index 000000000..3f7b2a71c --- /dev/null +++ b/app/services/salesforce/resolve_faculty_status.rb @@ -0,0 +1,62 @@ +module Salesforce + # Determines a user's faculty_status from signup context or from a Salesforce + # Contact, preserving the protection rules that prevent confirmed/pending/ + # rejected statuses from being downgraded to incomplete/no_info. + module ResolveFacultyStatus + class UnknownFacultyVerifiedError < StandardError; end + + # Statuses that should not be overwritten by an incoming :incomplete_signup + # or :no_faculty_info from Salesforce. + PROTECTED_BY_INCOMPLETE_OR_NO_INFO = %w[confirmed_faculty pending_faculty rejected_faculty].freeze + + # An incoming :pending_faculty should not overwrite an existing + # :confirmed_faculty. + PROTECTED_BY_PENDING = %w[confirmed_faculty].freeze + + DOWNGRADE_VALUES = %w[incomplete_signup no_faculty_info].freeze + + module_function + + # Set faculty_status based on signup state (profile completion + SheerID). + # Persists the user. + def from_signup(user) + if user.is_profile_complete? + new_status = :pending_faculty + verification = SheeridVerification.find_by(verification_id: user.sheerid_verification_id) + new_status = verification.current_step_to_faculty_status if verification + user.faculty_status = new_status + else + user.faculty_status = :incomplete_signup + end + user.save! + end + + # Apply faculty_verified from an SF Contact to user, respecting the + # protection rules. Does NOT persist (caller decides). + def from_contact(user, sf_contact) + faculty_verified = sf_contact.faculty_verified + new_status = + if faculty_verified.nil? + 'no_faculty_info' + elsif User::VALID_FACULTY_STATUSES.include?(faculty_verified) + faculty_verified + else + msg = "Unknown faculty_verified field: '#{faculty_verified}' on contact #{sf_contact.id}" + Sentry.capture_message(msg) + raise UnknownFacultyVerifiedError, msg + end + + return if blocked?(user.faculty_status, new_status) + + user.faculty_status = new_status + end + + def blocked?(current, incoming) + current = current.to_s + incoming = incoming.to_s + return true if PROTECTED_BY_INCOMPLETE_OR_NO_INFO.include?(current) && DOWNGRADE_VALUES.include?(incoming) + return true if PROTECTED_BY_PENDING.include?(current) && incoming == 'pending_faculty' + false + end + end +end diff --git a/spec/services/salesforce/resolve_faculty_status_spec.rb b/spec/services/salesforce/resolve_faculty_status_spec.rb new file mode 100644 index 000000000..617631965 --- /dev/null +++ b/spec/services/salesforce/resolve_faculty_status_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe Salesforce::ResolveFacultyStatus do + let(:user) { FactoryBot.create(:user, faculty_status: :no_faculty_info) } + + describe '.from_signup' do + it 'sets pending_faculty when profile complete and no SheerID record' do + user.update!(is_profile_complete: true, sheerid_verification_id: nil) + described_class.from_signup(user) + expect(user.faculty_status).to eq('pending_faculty') + end + + it 'sets status from SheerID when present' do + user.update!(is_profile_complete: true, sheerid_verification_id: 'V1') + v = SheeridVerification.create!(verification_id: 'V1', current_step: 'success') + allow(SheeridVerification).to receive(:find_by).with(verification_id: 'V1').and_return(v) + allow(v).to receive(:current_step_to_faculty_status).and_return(:confirmed_faculty) + described_class.from_signup(user) + expect(user.faculty_status).to eq('confirmed_faculty') + end + + it 'sets incomplete_signup when profile incomplete' do + user.update!(is_profile_complete: false) + described_class.from_signup(user) + expect(user.faculty_status).to eq('incomplete_signup') + end + end + + describe '.from_contact' do + %w[confirmed_faculty pending_faculty rejected_faculty].each do |protected_status| + it "does not overwrite #{protected_status} with no_faculty_info" do + user.update!(faculty_status: protected_status) + contact = Salesforce::Records::Contact.new(faculty_verified: nil) + described_class.from_contact(user, contact) + expect(user.faculty_status).to eq(protected_status) + end + + it "does not overwrite #{protected_status} with incomplete_signup" do + user.update!(faculty_status: protected_status) + contact = Salesforce::Records::Contact.new(faculty_verified: 'incomplete_signup') + described_class.from_contact(user, contact) + expect(user.faculty_status).to eq(protected_status) + end + end + + it 'does not overwrite confirmed_faculty with pending_faculty' do + user.update!(faculty_status: 'confirmed_faculty') + contact = Salesforce::Records::Contact.new(faculty_verified: 'pending_faculty') + described_class.from_contact(user, contact) + expect(user.faculty_status).to eq('confirmed_faculty') + end + + it 'updates from no_faculty_info to confirmed_faculty' do + user.update!(faculty_status: 'no_faculty_info') + contact = Salesforce::Records::Contact.new(faculty_verified: 'confirmed_faculty') + described_class.from_contact(user, contact) + expect(user.faculty_status).to eq('confirmed_faculty') + end + + it 'raises and captures Sentry on unknown faculty_verified value' do + user.update!(faculty_status: 'no_faculty_info') + contact = Salesforce::Records::Contact.new(faculty_verified: 'weird_value', id: 'C1') + expect(Sentry).to receive(:capture_message) + expect { + described_class.from_contact(user, contact) + }.to raise_error(Salesforce::ResolveFacultyStatus::UnknownFacultyVerifiedError) + end + end +end From 10dd0f011a2ad06689688609e33b76fe5cc4f74f Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:48:10 -0500 Subject: [PATCH 17/34] salesforce: extract BuildLead (pure User -> Lead mapping) Side-effect-free mapping pulled out of CreateOrUpdateSalesforceLead so it can be unit-tested table-driven. Always writes accounts_uuid before return, which is the invariant that makes UpsertLead's job-retry path idempotent: a retried job that previously got as far as lead.save (then died) finds the just-created Lead via the UUID branch of Lookup, instead of creating a duplicate. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/build_lead.rb | 107 ++++++++++++++++++++ spec/services/salesforce/build_lead_spec.rb | 85 ++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 app/services/salesforce/build_lead.rb create mode 100644 spec/services/salesforce/build_lead_spec.rb diff --git a/app/services/salesforce/build_lead.rb b/app/services/salesforce/build_lead.rb new file mode 100644 index 000000000..af73a716f --- /dev/null +++ b/app/services/salesforce/build_lead.rb @@ -0,0 +1,107 @@ +module Salesforce + # Pure mapping from a `User` to a `Salesforce::Records::Lead`'s attributes. + # Side-effect-free (does not save). Always sets `accounts_uuid` so retries + # of `UpsertLead` can find a just-created Lead via the UUID branch of + # `Salesforce::Lookup`, keeping job retries idempotent. + module BuildLead + LEAD_SOURCE = 'Account Creation'.freeze + DEFAULT_REFERRING_APP_NAME = 'Accounts'.freeze + + ADOPTION_STATUS_FROM_USER = { + 'as_primary' => 'Confirmed Adoption Won', + 'as_recommending' => 'Confirmed Will Recommend', + 'as_future' => 'High Interest in Adopting' + }.freeze + + module_function + + def apply(lead, user) + sf_school_id = user.school&.salesforce_id + + if user.role == 'student' + sf_role = 'Student' + sf_position = nil + else + sf_role = 'Instructor' + sf_position = user.role + end + + adoption_json = user.using_openstax_how == 'as_future' ? nil : build_adoption_json(user) + + lead.first_name = user.first_name + lead.last_name = user.last_name + lead.phone = user.phone_number + lead.source = LEAD_SOURCE + lead.application_source = DEFAULT_REFERRING_APP_NAME + lead.role = sf_role + lead.position = sf_position + lead.title = user.other_role_name + lead.who_chooses_books = user.who_chooses_books + lead.subject_interest = user.which_books + lead.num_students = user.how_many_students + lead.adoption_status = ADOPTION_STATUS_FROM_USER[user.using_openstax_how] + lead.expected_start_semester = expected_start_semester_label_for(user.expected_start_semester) + lead.adoption_json = adoption_json + lead.os_accounts_id = user.id + lead.accounts_uuid = user.uuid + lead.school = user.most_accurate_school_name + lead.city = user.most_accurate_school_city + lead.country = user.most_accurate_school_country + lead.verification_status = user.faculty_status == User::NO_FACULTY_INFO ? nil : user.faculty_status + lead.b_r_i_marketing = user.is_b_r_i_user? + lead.title_1_school = user.title_1_school? + lead.newsletter = user.receive_newsletter? + lead.newsletter_opt_in = user.receive_newsletter? + lead.self_reported_school = user.self_reported_school + lead.sheerid_school_name = user.sheerid_reported_school + lead.account_id = sf_school_id + lead.school_id = sf_school_id + lead.signup_date = user.created_at.strftime('%Y-%m-%dT%T.%L%z') + lead.tracking_parameters = "#{Rails.application.secrets.openstax_url}/accounts/i/signup/" + + assign_state(lead, user.most_accurate_school_state) + + lead + end + + def assign_state(lead, raw_state) + return if raw_state.blank? + state = raw_state + state = nil unless US_STATES.map(&:downcase).include?(state.downcase) + return if state.nil? + if state == state.upcase + lead.state_code = state + else + lead.state = state + end + end + + def expected_start_semester_label_for(key) + return nil if key.blank? + I18n.t(:'educator_profile_form.expected_start_semester_options')[key.to_sym] + end + + def build_adoption_json(user) + return nil unless user.books_used_details + + books_json = user.books_used_details.map do |book_value, details| + if book_value.match(/\[.*\]/) + { + name: book_value.gsub(/\[.*\]/, '').strip, + students: details['num_students_using_book'], + howUsing: details['how_using_book'], + language: book_value[/\[(.*?)\]/, 1] + } + else + { + name: book_value, + students: details['num_students_using_book'], + howUsing: details['how_using_book'] + } + end + end + + { 'Books' => books_json }.to_json + end + end +end diff --git a/spec/services/salesforce/build_lead_spec.rb b/spec/services/salesforce/build_lead_spec.rb new file mode 100644 index 000000000..e92e0175e --- /dev/null +++ b/spec/services/salesforce/build_lead_spec.rb @@ -0,0 +1,85 @@ +require 'rails_helper' + +RSpec.describe Salesforce::BuildLead do + let(:school) do + FactoryBot.create(:school, + salesforce_id: 'SF1', name: 'Rice', city: 'Houston', + country: 'United States', state: 'Texas') + end + + let(:user) do + FactoryBot.create(:user, + role: 'instructor', first_name: 'A', last_name: 'B', + phone_number: '+15555555555', who_chooses_books: 'instructor', + which_books: 'AP Macro Econ', how_many_students: '50', + using_openstax_how: 'as_primary', expected_start_semester: 'next_semester', + receive_newsletter: true, school: school, faculty_status: 'pending_faculty', + is_profile_complete: true) + end + + let(:lead) { Salesforce::Records::Lead.new(email: 'a@b.com') } + + before do + allow(user).to receive(:books_used_details).and_return( + { 'Calculus Volume 1' => { 'num_students_using_book' => 50, 'how_using_book' => 'Required' } } + ) + end + + it 'maps role=instructor to Instructor and copies role into position' do + described_class.apply(lead, user) + expect(lead.role).to eq('Instructor') + expect(lead.position).to eq('instructor') + end + + it 'maps role=student to Student with nil position' do + user.update!(role: 'student') + described_class.apply(lead, user) + expect(lead.role).to eq('Student') + expect(lead.position).to be_nil + end + + it 'always sets accounts_uuid (invariant for retry idempotency)' do + described_class.apply(lead, user) + expect(lead.accounts_uuid).to eq(user.uuid) + end + + it 'builds adoption_json for non-as_future users' do + described_class.apply(lead, user) + parsed = JSON.parse(lead.adoption_json) + expect(parsed['Books']).to be_an(Array) + expect(parsed['Books'].first).to include('name' => 'Calculus Volume 1', 'students' => 50) + end + + it 'skips adoption_json when using_openstax_how is as_future' do + user.update!(using_openstax_how: 'as_future') + described_class.apply(lead, user) + expect(lead.adoption_json).to be_nil + end + + it 'maps US state full name to lead.state' do + described_class.apply(lead, user) + expect(lead.state).to eq('Texas') + end + + it 'maps US state abbreviation (uppercase) to lead.state_code' do + school.update!(state: 'TX') + described_class.apply(lead, user) + expect(lead.state_code).to eq('TX') + end + + it 'maps adoption_status from using_openstax_how' do + described_class.apply(lead, user) + expect(lead.adoption_status).to eq('Confirmed Adoption Won') + end + + it 'sets verification_status to faculty_status when not no_faculty_info' do + described_class.apply(lead, user) + expect(lead.verification_status).to eq('pending_faculty') + end + + it 'sets verification_status to nil when faculty_status is no_faculty_info' do + user.update!(faculty_status: 'no_faculty_info') + described_class.apply(lead, user) + expect(lead.verification_status).to be_nil + end +end From 83a32d7506811f87603f336a4f343b90e5e96647 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:49:17 -0500 Subject: [PATCH 18/34] salesforce: add UpsertLead orchestrator with persist retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single entry point for lead create-or-update. Sequence: 1. Audit begin. 2. Ensure user.school is set (falls back to "Find Me A Home"). 3. ResolveFacultyStatus.from_signup updates the user. 4. Lookup.lead_for resolves any existing lead via stored_id, uuid, email. 5. If no lead AND the user already has a contact that owns them (Lookup.contact_for verifies), return early — they've been converted. If the stored contact id is stale (no longer owns them), clear it and audit before proceeding. 6. Build new lead (with accounts_uuid set for retry idempotency) or update found lead via BuildLead.apply. 7. Save SF. On success, persist_lead_id retries user.save up to 3 times, with each retry audited. After 3 failures, log to Sentry; the next nightly Reconcile pass picks up the orphan via accounts_uuid. Replaces the inline lookup/build/save logic in the previous routine, which silently overwrote user.salesforce_contact_id and didn't retry on local persist failures. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/upsert_lead.rb | 94 +++++++++++++++ spec/services/salesforce/upsert_lead_spec.rb | 113 +++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 app/services/salesforce/upsert_lead.rb create mode 100644 spec/services/salesforce/upsert_lead_spec.rb diff --git a/app/services/salesforce/upsert_lead.rb b/app/services/salesforce/upsert_lead.rb new file mode 100644 index 000000000..2a73ae903 --- /dev/null +++ b/app/services/salesforce/upsert_lead.rb @@ -0,0 +1,94 @@ +module Salesforce + # Orchestrates create-or-update of a Salesforce Lead for a User. + # Replaces the bulk of the previous Newflow::CreateOrUpdateSalesforceLead. + class UpsertLead + PERSIST_RETRIES = 3 + FIND_ME_A_HOME = 'Find Me A Home'.freeze + + def self.call(user:) + new(user).call + end + + def initialize(user) + @user = user + end + + def call + return if @user.nil? + + Audit.record(@user, :upsert_lead_begin) + + ensure_school_or_fallback + ResolveFacultyStatus.from_signup(@user) + + result = Lookup.lead_for(@user) + lead = result.lead + + if lead.nil? && existing_contact_owns_user? + Audit.record(@user, :upsert_lead_skipped_user_has_contact, + contact_id: @user.salesforce_contact_id) + return + end + + lead ||= Salesforce::Records::Lead.new( + email: @user.best_email_address_for_salesforce, + accounts_uuid: @user.uuid + ) + BuildLead.apply(lead, @user) + + if lead.save + persist_lead_id(lead) + Audit.record(@user, :upsert_lead_saved, + lead_id: lead.id, matched_by: result.matched_by) + else + Audit.record(@user, :upsert_lead_save_failed, + errors: Array(lead.errors&.full_messages)) + Sentry.capture_message( + "Salesforce lead save failed for user #{@user.id}: " \ + "#{lead.errors&.full_messages&.join(', ')}" + ) + end + end + + private + + def ensure_school_or_fallback + return if @user.school&.salesforce_id + fallback = Salesforce::Records::School.find_by({ name: FIND_ME_A_HOME }) + unless fallback + raise "Salesforce '#{FIND_ME_A_HOME}' school not found — cannot assign fallback school for user #{@user.id}" + end + cached = School.find_by(salesforce_id: fallback.id) + @user.school = cached if cached + end + + def existing_contact_owns_user? + return false if @user.salesforce_contact_id.blank? + contact = Lookup.contact_for(@user) + if contact.nil? + prior = @user.salesforce_contact_id + @user.salesforce_contact_id = nil + @user.save + Audit.record(@user, :stale_contact_id_cleared, contact_id: prior) + return false + end + true + end + + def persist_lead_id(lead) + @user.salesforce_lead_id = lead.id + PERSIST_RETRIES.times do |attempt| + return if @user.save + Audit.record(@user, :lead_id_persist_retry, + attempt: attempt + 1, errors: @user.errors.full_messages) + @user.reload + @user.salesforce_lead_id = lead.id + end + Audit.record(@user, :lead_id_persist_failed, + lead_id: lead.id, errors: @user.errors.full_messages) + Sentry.capture_message( + "lead_id persist failed for user #{@user.id}, lead #{lead.id}" + ) + end + end +end diff --git a/spec/services/salesforce/upsert_lead_spec.rb b/spec/services/salesforce/upsert_lead_spec.rb new file mode 100644 index 000000000..3483db2dc --- /dev/null +++ b/spec/services/salesforce/upsert_lead_spec.rb @@ -0,0 +1,113 @@ +require 'rails_helper' + +RSpec.describe Salesforce::UpsertLead do + let!(:home) { FactoryBot.create(:school, name: 'Find Me A Home', salesforce_id: 'SF_HOME') } + + let(:user) do + FactoryBot.create(:user, + role: 'instructor', state: 'activated', is_newflow: true, + using_openstax_how: 'as_primary', is_profile_complete: true, + self_reported_school: 'Test U', school: home, + faculty_status: 'pending_faculty') + end + + let(:lead) { Salesforce::Records::Lead.new(email: 'x@y.com', accounts_uuid: user.uuid) } + + before do + allow(Sentry).to receive(:capture_message) + allow(Salesforce::Records::School).to receive(:find_by) + .with({ name: 'Find Me A Home' }).and_return(OpenStruct.new(id: 'SF_HOME')) + allow(user).to receive(:best_email_address_for_salesforce).and_return('x@y.com') + allow(User).to receive(:find).and_return(user) + end + + context 'creating a new lead when lookup returns nothing' do + before do + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + allow(Salesforce::Records::Lead).to receive(:new).and_return(lead) + allow(lead).to receive(:save).and_return(true) + allow(lead).to receive(:id).and_return('NEW_LEAD') + end + + it 'persists the new lead id on the user' do + described_class.call(user: user) + expect(user.reload.salesforce_lead_id).to eq('NEW_LEAD') + end + + it 'records the upsert_lead_saved audit event' do + described_class.call(user: user) + expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_saved', user: user)).to exist + end + end + + context 'when user.save fails the first time and succeeds on retry' do + before do + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + allow(Salesforce::Records::Lead).to receive(:new).and_return(lead) + allow(lead).to receive(:save).and_return(true) + allow(lead).to receive(:id).and_return('NEW_LEAD') + end + + it 'retries the local save and records a retry audit event' do + call_count = 0 + original_save = user.method(:save) + allow(user).to receive(:save) do + call_count += 1 + call_count >= 2 ? original_save.call : false + end + described_class.call(user: user) + expect(call_count).to be >= 2 + expect(SecurityLog.where(event_type: 'salesforce_lead_id_persist_retry', user: user)).to exist + end + end + + context 'when user.save fails 3 times' do + before do + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + allow(Salesforce::Records::Lead).to receive(:new).and_return(lead) + allow(lead).to receive(:save).and_return(true) + allow(lead).to receive(:id).and_return('NEW_LEAD') + allow_any_instance_of(User).to receive(:save).and_return(false) + allow_any_instance_of(User).to receive(:reload).and_return(user) + end + + it 'logs persist_failed loudly and to Sentry' do + expect(Sentry).to receive(:capture_message).with(/lead_id persist failed/) + described_class.call(user: user) + expect(SecurityLog.where(event_type: 'salesforce_lead_id_persist_failed', user: user)).to exist + end + end + + context 'when user already has a verifying contact' do + it 'returns early without saving a lead' do + user.update!(salesforce_contact_id: 'C1') + owning_contact = Salesforce::Records::Contact.new( + id: 'C1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false + ) + allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(owning_contact) + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + expect(Salesforce::Records::Lead).not_to receive(:new) + described_class.call(user: user) + expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_skipped_user_has_contact', user: user)).to exist + end + end + + context 'when user has a stored contact_id that no longer owns them' do + it 'clears it and proceeds to create a lead' do + user.update!(salesforce_contact_id: 'C_STALE') + allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(nil) + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + allow(Salesforce::Records::Lead).to receive(:new).and_return(lead) + allow(lead).to receive(:save).and_return(true) + allow(lead).to receive(:id).and_return('NEW_LEAD') + described_class.call(user: user) + expect(SecurityLog.where(event_type: 'salesforce_stale_contact_id_cleared', user: user)).to exist + expect(user.reload.salesforce_contact_id).to be_nil + end + end +end From b0b98e41b44168f2f59adf52d5219ea00f145148 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 00:54:48 -0500 Subject: [PATCH 19/34] salesforce: Newflow::CreateOrUpdateSalesforceLead becomes a shim Replaces the 259-line routine with a ~15-line lev_routine that delegates to Salesforce::UpsertLead. The lev_routine wrapper preserves the active_job_enqueue_options: { queue: :salesforce } so callers (EducatorSignup::CompleteProfile, EducatorSignup::SheeridWebhook, Admin::UsersController#force_update_lead) don't change. Updates the existing spec to match the new event taxonomy: - :creating_new_salesforce_lead -> :salesforce_upsert_lead_saved - :salesforce_lead_found_by_uuid -> :salesforce_lookup_matched_by_uuid - :salesforce_lead_found_by_email -> :salesforce_lookup_matched_by_email - :user_already_has_contact_not_creating_lead -> :salesforce_upsert_lead_skipped_user_has_contact - :salesforce_lead_save_failed -> :salesforce_upsert_lead_save_failed Adds a stale-contact-id-cleared case that the old code couldn't handle. All 13 examples pass; the 184-example sweep across all Salesforce-touching specs is green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../create_or_update_salesforce_lead.rb | 251 +----------------- .../create_or_update_salesforce_lead_spec.rb | 135 ++++++---- 2 files changed, 83 insertions(+), 303 deletions(-) diff --git a/app/routines/newflow/create_or_update_salesforce_lead.rb b/app/routines/newflow/create_or_update_salesforce_lead.rb index b0436ab6b..b38115345 100644 --- a/app/routines/newflow/create_or_update_salesforce_lead.rb +++ b/app/routines/newflow/create_or_update_salesforce_lead.rb @@ -1,259 +1,20 @@ module Newflow + # Thin shim around Salesforce::UpsertLead. Kept so existing callers + # (EducatorSignup::CompleteProfile, EducatorSignup::SheeridWebhook, + # Admin::UsersController#force_update_lead) don't have to change. + # See docs/superpowers/specs/2026-05-20-salesforce-sync-design.md. class CreateOrUpdateSalesforceLead - lev_routine active_job_enqueue_options: { queue: :salesforce } - LEAD_SOURCE = 'Account Creation' - DEFAULT_REFERRING_APP_NAME = 'Accounts' - - ADOPTION_STATUS_FROM_USER = { - as_primary: 'Confirmed Adoption Won', - as_recommending: 'Confirmed Will Recommend', - as_future: 'High Interest in Adopting' - }.with_indifferent_access.freeze - - private_constant(:ADOPTION_STATUS_FROM_USER) - - protected ################# + protected def exec(user:) return unless user - status.set_job_name(self.class.name) status.set_job_args(user: user.to_global_id.to_s) - SecurityLog.create!( - user: user, - event_type: :starting_salesforce_lead_creation - ) - - sf_school_id = user.school&.salesforce_id - # no school attached to user? Set to Find Me A Home - unless sf_school_id - fallback_school = Salesforce::Records::School.find_by(name: 'Find Me A Home') - raise "Salesforce 'Find Me A Home' school not found — cannot assign fallback school for user #{user.id}" unless fallback_school - - sf_school_id = fallback_school.id - user.school = School.find_by(salesforce_id: sf_school_id) - end - - if user.role == 'student' - sf_role = 'Student' - else - sf_role = 'Instructor' - sf_position = user.role - end - - # as_future means they are interested, not adopting, so no adoptionJSON for them - if user.using_openstax_how != 'as_future' - adoption_json = build_book_adoption_json_for_salesforce(user) - end - - # Check the state of the SheerID response and profile completion to determine faculty status for lead - sheerid_response = SheeridVerification.find_by(verification_id: user.sheerid_verification_id) - if user.is_profile_complete? - user.faculty_status = :pending_faculty - unless sheerid_response.nil? - user.faculty_status = sheerid_response.current_step_to_faculty_status - end - else - # User has not completed their profile - user.faculty_status = :incomplete_signup - end - user.save! - - lead = nil - if user.salesforce_lead_id - begin - lead = Salesforce::Records::Lead.find(user.salesforce_lead_id) - rescue StandardError => e - # Log when the stored lead ID doesn't correspond to an existing lead or find fails - SecurityLog.create!( - user: user, - event_type: :salesforce_lead_not_found_by_id, - event_data: { - salesforce_lead_id: user.salesforce_lead_id, - error: e.class.name, - error_message: e.message - } - ) - Sentry.capture_message( - "Salesforce lead ID #{user.salesforce_lead_id} not found for user #{user.id}, will search by UUID and email. Error: #{e.class.name}: #{e.message}" - ) - end - end - - # If no lead found by stored ID, search for existing lead by UUID - if lead.nil? - lead = Salesforce::Records::Lead.find_by(accounts_uuid: user.uuid) - if lead - SecurityLog.create!( - user: user, - event_type: :salesforce_lead_found_by_uuid, - event_data: { lead_id: lead.id } - ) - end - end - - # If still no lead found, search by email - if lead.nil? - lead = Salesforce::Records::Lead.find_by(email: user.best_email_address_for_salesforce) - if lead - SecurityLog.create!( - user: user, - event_type: :salesforce_lead_found_by_email, - event_data: { lead_id: lead.id, email: user.best_email_address_for_salesforce } - ) - end - end - - # If user has a contact (already converted from lead), don't create a new lead - if lead.nil? && user.salesforce_contact_id.present? - begin - contact = Salesforce::Records::Contact.find(user.salesforce_contact_id) - if contact - SecurityLog.create!( - user: user, - event_type: :user_already_has_contact_not_creating_lead, - event_data: { contact_id: user.salesforce_contact_id } - ) - # User already has a contact, return without creating a lead - outputs.lead = nil - outputs.user = user - return - end - rescue StandardError => e - # Contact not found, proceed with lead creation - Sentry.capture_message( - "Salesforce contact ID #{user.salesforce_contact_id} not found for user #{user.id}, will create lead. Error: #{e.class.name}: #{e.message}" - ) - end - end - - # Only create a new lead if none exists - if lead.nil? - lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce) - SecurityLog.create!( - user: user, - event_type: :creating_new_salesforce_lead, - event_data: { email: user.best_email_address_for_salesforce, uuid: user.uuid } - ) - end - - lead.first_name = user.first_name - lead.last_name = user.last_name - lead.phone = user.phone_number - lead.source = LEAD_SOURCE - lead.application_source = DEFAULT_REFERRING_APP_NAME - lead.role = sf_role - lead.position = sf_position - lead.title = user.other_role_name - lead.who_chooses_books = user.who_chooses_books - lead.subject_interest = user.which_books - lead.num_students = user.how_many_students - lead.adoption_status = ADOPTION_STATUS_FROM_USER[user.using_openstax_how] - lead.expected_start_semester = expected_start_semester_label_for(user.expected_start_semester) - lead.adoption_json = adoption_json - lead.os_accounts_id = user.id - lead.accounts_uuid = user.uuid - lead.school = user.most_accurate_school_name - lead.city = user.most_accurate_school_city - lead.country = user.most_accurate_school_country - lead.verification_status = user.faculty_status == User::NO_FACULTY_INFO ? nil : user.faculty_status - lead.b_r_i_marketing = user.is_b_r_i_user? - lead.title_1_school = user.title_1_school? - lead.newsletter = user.receive_newsletter? - lead.newsletter_opt_in = user.receive_newsletter? - lead.self_reported_school = user.self_reported_school - lead.sheerid_school_name = user.sheerid_reported_school - lead.account_id = sf_school_id - lead.school_id = sf_school_id - lead.signup_date = user.created_at.strftime("%Y-%m-%dT%T.%L%z") - lead.tracking_parameters = "#{Rails.application.secrets.openstax_url}/accounts/i/signup/" - - state = user.most_accurate_school_state - unless state.blank? - state = nil unless US_STATES.map(&:downcase).include? state.downcase - end - unless state.nil? - # Figure out if the State is an abbreviation or the full name - if state == state.upcase - lead.state_code = state - else - lead.state = state - end - end - - SecurityLog.create!( - user: user, - event_type: :attempting_to_create_user_lead, - event_data: { lead_data: lead } - ) - - if lead.save - user.salesforce_lead_id = lead.id - if user.save - SecurityLog.create!( - user: user, - event_type: :created_salesforce_lead, - event_data: { lead_id: lead.id.to_s } - ) - else - SecurityLog.create!( - user: user, - event_type: :educator_sign_up_failed, - event_data: { lead_id: lead.id, user_errors: user.errors.full_messages } - ) - Sentry.capture_message("User #{user.id} was not successfully saved with lead #{lead.id}: #{user.errors.full_messages.join(', ')}") - end - else - if lead.errors&.full_messages.to_s.include?('INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY') - Sentry.capture_message("Invalid school (#{user.school&.salesforce_id}) for user (#{user.id})") - end - SecurityLog.create!( - user: user, - event_type: :salesforce_lead_save_failed, - event_data: { lead_errors: lead.errors&.full_messages, email: user.best_email_address_for_salesforce } - ) - Sentry.capture_message("Salesforce lead save failed for user #{user.id}: #{lead.errors&.full_messages&.join(', ')}") - end - - outputs.lead = lead + Salesforce::UpsertLead.call(user: user) outputs.user = user end - - def expected_start_semester_label_for(key) - return nil if key.blank? - I18n.t(:'educator_profile_form.expected_start_semester_options')[key.to_sym] - end - - def build_book_adoption_json_for_salesforce(user) - adoption_json = {} - books_json = [] - return nil unless user.books_used_details - - user.books_used_details.each do |book| - book_value = book[0] - if book_value.match(/\[.*\]/) - book_name = book_value.gsub(/\[.*\]/, '').strip # Calculus Volume 1 - book_language = book_value[/\[(.*?)\]/, 1] # Spanish (no brackets) - books_json << { - name: book_name, - students: book[1]["num_students_using_book"], - howUsing: book[1]["how_using_book"], - language: book_language, - } - else - books_json << { - name: book_value, - students: book[1]["num_students_using_book"], - howUsing: book[1]["how_using_book"] - } - end - end - - adoption_json['Books'] = books_json - adoption_json.to_json - end end end diff --git a/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb b/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb index 2e1c98d08..39705d6e6 100644 --- a/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb +++ b/spec/routines/newflow/create_or_update_salesforce_lead_spec.rb @@ -2,7 +2,9 @@ module Newflow describe CreateOrUpdateSalesforceLead, type: :routine do - let!(:school) { FactoryBot.create :school, name: 'Find Me A Home', salesforce_id: 'SF_SCHOOL_HOME' } + let!(:home_school) do + FactoryBot.create :school, name: 'Find Me A Home', salesforce_id: 'SF_SCHOOL_HOME' + end let(:user) do User.create do |u| @@ -29,123 +31,144 @@ module Newflow before do stub_sentry - # Stub the school lookup - allow(Salesforce::Records::School).to receive(:find_by).with(name: 'Find Me A Home') + allow(Salesforce::Records::School).to receive(:find_by) + .with({ name: 'Find Me A Home' }) .and_return(OpenStruct.new(id: 'SF_SCHOOL_HOME')) end describe 'creating a new lead' do - it 'creates a new lead when none exists' do - # Stub all the search methods to return nil (no existing lead) - allow(Salesforce::Records::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(Salesforce::Records::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) + it 'creates a new lead when none exists and saves the lead id' do + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil) - # Create a mock lead that will be "saved" - mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce) + mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid) allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead) allow(mock_lead).to receive(:save).and_return(true) allow(mock_lead).to receive(:id).and_return('SF_LEAD_123') described_class.call(user: user) - expect(user.salesforce_lead_id).to eq('SF_LEAD_123') - expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(1) + expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_123') + expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_saved', user: user).count).to eq(1) end end describe 'finding existing leads' do let(:existing_lead) do - lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce) + lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid) allow(lead).to receive(:id).and_return('SF_LEAD_EXISTING') allow(lead).to receive(:save).and_return(true) lead end it 'finds and updates existing lead by UUID' do - # Stub to return existing lead when searched by UUID - allow(Salesforce::Records::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(existing_lead) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(existing_lead) described_class.call(user: user) - expect(user.salesforce_lead_id).to eq('SF_LEAD_EXISTING') - expect(SecurityLog.where(event_type: :salesforce_lead_found_by_uuid).count).to eq(1) - expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(0) + expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_EXISTING') + expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_uuid', user: user).count).to eq(1) end it 'finds and updates existing lead by email when UUID search fails' do - # Stub UUID search to return nil, email search to return existing lead - allow(Salesforce::Records::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(Salesforce::Records::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(existing_lead) + allow(user).to receive(:best_email_address_for_salesforce).and_return('max@example.com') + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: 'max@example.com' }).and_return(existing_lead) + # User must be findable by id (UpsertLead doesn't go through the User class directly, + # but ActiveRecord-side persistence needs to keep working) + allow(User).to receive(:find).and_return(user) described_class.call(user: user) - expect(user.salesforce_lead_id).to eq('SF_LEAD_EXISTING') - expect(SecurityLog.where(event_type: :salesforce_lead_found_by_email).count).to eq(1) - expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(0) + expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_EXISTING') + expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_email', user: user).count).to eq(1) end - it 'uses stored lead ID if available' do - user.salesforce_lead_id = 'SF_LEAD_STORED' - user.save! - - # Stub to return existing lead when searched by ID - allow(Salesforce::Records::Lead).to receive(:find).with('SF_LEAD_STORED').and_return(existing_lead) + it 'uses stored lead ID if available and it still owns the user' do + user.update_column(:salesforce_lead_id, 'SF_LEAD_STORED') allow(existing_lead).to receive(:id).and_return('SF_LEAD_STORED') + allow(Salesforce::Records::Lead).to receive(:find).with('SF_LEAD_STORED').and_return(existing_lead) described_class.call(user: user) - expect(user.salesforce_lead_id).to eq('SF_LEAD_STORED') - # Should not search by UUID or email since it found by ID - expect(SecurityLog.where(event_type: :salesforce_lead_found_by_uuid).count).to eq(0) - expect(SecurityLog.where(event_type: :salesforce_lead_found_by_email).count).to eq(0) + expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_STORED') + expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_stored_id', user: user).count).to eq(1) + expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_uuid', user: user).count).to eq(0) + expect(SecurityLog.where(event_type: 'salesforce_lookup_matched_by_email', user: user).count).to eq(0) end end describe 'when lead save fails' do it 'logs to SecurityLog and Sentry' do - allow(Salesforce::Records::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(Salesforce::Records::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil) - mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce) + mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid) allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead) allow(mock_lead).to receive(:save).and_return(false) allow(mock_lead).to receive(:errors).and_return(double(full_messages: ['Some SF error'])) described_class.call(user: user) - expect(SecurityLog.where(event_type: :salesforce_lead_save_failed).count).to eq(1) + expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_save_failed', user: user).count).to eq(1) expect(Sentry).to have_received(:capture_message).with(/Salesforce lead save failed for user #{user.id}/) end end - describe 'when user already has a contact' do - let(:existing_contact) do - contact = OpenStruct.new(id: 'SF_CONTACT_123') - contact + describe 'when user already has a contact that still owns them' do + let(:owning_contact) do + Salesforce::Records::Contact.new( + id: 'SF_CONTACT_123', accounts_uuid: user.uuid, + master_record_id: nil, is_deleted: false + ) end - it 'does not create a lead if user already has a contact' do - user.salesforce_contact_id = 'SF_CONTACT_123' - user.save! + it 'does not create a lead' do + user.update_column(:salesforce_contact_id, 'SF_CONTACT_123') + + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil) + allow(Salesforce::Records::Contact).to receive(:find).with('SF_CONTACT_123').and_return(owning_contact) + + expect(Salesforce::Records::Lead).not_to receive(:new) + described_class.call(user: user) + + expect(SecurityLog.where(event_type: 'salesforce_upsert_lead_skipped_user_has_contact', user: user).count).to eq(1) + end + end - # Stub all lead searches to return nil - allow(Salesforce::Records::Lead).to receive(:find_by).with(accounts_uuid: user.uuid).and_return(nil) - allow(Salesforce::Records::Lead).to receive(:find_by).with(email: user.best_email_address_for_salesforce).and_return(nil) + describe 'when stored contact_id no longer owns the user' do + it 'clears the stored contact_id and proceeds to create a lead' do + user.update_column(:salesforce_contact_id, 'SF_CONTACT_STALE') - # Stub contact lookup to return existing contact - allow(Salesforce::Records::Contact).to receive(:find).with('SF_CONTACT_123').and_return(existing_contact) + # Stored contact lookup returns a contact that doesn't own this user + foreign_contact = Salesforce::Records::Contact.new( + id: 'SF_CONTACT_STALE', accounts_uuid: 'OTHER', + master_record_id: nil, is_deleted: false + ) + allow(Salesforce::Records::Contact).to receive(:find).with('SF_CONTACT_STALE').and_return(foreign_contact) + # UUID-based contact lookup also returns nothing for this user + allow(Salesforce::Records::Contact).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) - result = described_class.call(user: user) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ accounts_uuid: user.uuid }).and_return(nil) + allow(Salesforce::Records::Lead).to receive(:find_by).with({ email: user.best_email_address_for_salesforce }).and_return(nil) - expect(result.outputs.lead).to be_nil - expect(SecurityLog.where(event_type: :user_already_has_contact_not_creating_lead).count).to eq(1) - expect(SecurityLog.where(event_type: :creating_new_salesforce_lead).count).to eq(0) + mock_lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid) + allow(Salesforce::Records::Lead).to receive(:new).and_return(mock_lead) + allow(mock_lead).to receive(:save).and_return(true) + allow(mock_lead).to receive(:id).and_return('SF_LEAD_NEW') + + described_class.call(user: user) + + expect(user.reload.salesforce_contact_id).to be_nil + expect(user.reload.salesforce_lead_id).to eq('SF_LEAD_NEW') + expect(SecurityLog.where(event_type: 'salesforce_stale_contact_id_cleared', user: user).count).to eq(1) end end describe 'expected_start_semester assignment' do let(:mock_lead) do - lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce) + lead = Salesforce::Records::Lead.new(email: user.best_email_address_for_salesforce, accounts_uuid: user.uuid) allow(lead).to receive(:save).and_return(true) allow(lead).to receive(:id).and_return('SF_LEAD_999') lead @@ -171,17 +194,13 @@ module Newflow it 'assigns nil when user.expected_start_semester is nil' do user.update_column(:expected_start_semester, nil) - described_class.call(user: user) - expect(mock_lead.expected_start_semester).to be_nil end it 'assigns nil when user.expected_start_semester is an unrecognized value' do user.update_column(:expected_start_semester, 'garbage') - described_class.call(user: user) - expect(mock_lead.expected_start_semester).to be_nil end end From 92732a41ce1a4d52f5bdaf8cc4c43039c87d1752 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 01:00:51 -0500 Subject: [PATCH 20/34] salesforce: add SyncContacts (cursor-driven, verify-before-swap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces UpdateUserContactInfo's body. Key behavior changes vs the old routine: - Cursor (Settings::Salesforce.contacts_synced_through) instead of fixed N-day lookback, with a configurable hour-of-overlap so a skipped cron run doesn't lose modifications. - Skips Contacts where master_record_id.present? or is_deleted at fetch time, instead of blindly attaching them to users. - Gates every salesforce_contact_id swap on Verify.contact_can_be_replaced? — evidence-based (:gone, :merged, :uuid_cleared) or no swap. Two-live-contact conflicts are logged and flagged for human review. - Faculty status flows through Salesforce::ResolveFacultyStatus (preserving the existing protection rules). - Per-run threshold alerts (cron drift, conflict count, swap rate, unknown UUID count) fire as tagged Sentry messages. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/sync_contacts.rb | 215 ++++++++++++++++++ .../services/salesforce/sync_contacts_spec.rb | 106 +++++++++ 2 files changed, 321 insertions(+) create mode 100644 app/services/salesforce/sync_contacts.rb create mode 100644 spec/services/salesforce/sync_contacts_spec.rb diff --git a/app/services/salesforce/sync_contacts.rb b/app/services/salesforce/sync_contacts.rb new file mode 100644 index 000000000..2745f6276 --- /dev/null +++ b/app/services/salesforce/sync_contacts.rb @@ -0,0 +1,215 @@ +module Salesforce + # Every-30-min incremental sync of Salesforce Contacts into Accounts users. + # Replaces UpdateUserContactInfo's body. Cursor-driven (with overlap), + # skips merged/deleted contacts at fetch time, gates every + # salesforce_contact_id swap on Verify.contact_can_be_replaced? evidence. + class SyncContacts + SLUG = 'update-user-contact-info'.freeze + + COLLEGE_TYPES = ['College/University (4)', 'Technical/Community College (2)', 'Career School/For-Profit (2)'].freeze + HIGH_SCHOOL_TYPES = ['High School'].freeze + K12_TYPES = ['K-12 School'].freeze + HOME_SCHOOL_TYPES = ['Home School'].freeze + + DOMESTIC_SCHOOL_LOCATIONS = ['Domestic'].freeze + FOREIGN_SCHOOL_LOCATIONS = ['Foreign'].freeze + + ADOPTION_STATUSES = { + 'Current Adopter' => true, + 'Future Adopter' => true, + 'Past Adopter' => true, + 'Not Adopter' => false + }.freeze + + def self.call + new.call + end + + def call + @metrics = Metrics.new(run: 'sync_contacts', slug: SLUG) + @metrics.start! + log 'Starting Salesforce contact sync' + + window_start = compute_window_start + log "Window start: #{window_start.iso8601}" + check_cron_drift(window_start) + + run_started_at = Time.current + contacts = fetch_contacts(window_start) + @metrics.increment(:contacts_fetched, by: contacts.size) + log "#{contacts.size} contacts fetched" + + uuids = contacts.map(&:accounts_uuid).compact.uniq + users_by_uuid = User.where(uuid: uuids).index_by(&:uuid) + schools_by_sf_id = School + .where(salesforce_id: contacts.map { |c| c.school&.id }.compact) + .index_by(&:salesforce_id) + + @verify_cache = {} + + contacts.each { |sf_contact| process_contact(sf_contact, users_by_uuid, schools_by_sf_id) } + + Settings::Salesforce.contacts_synced_through = run_started_at + emit_threshold_alerts + @metrics.emit(status: :ok) + rescue StandardError => e + Sentry.capture_exception(e) + @metrics.emit(status: :error, extra: { error: e.class.name, message: e.message }) + raise + end + + private + + def compute_window_start + cursor = Settings::Salesforce.contacts_synced_through || + Settings::Db.store.number_of_days_contacts_modified.to_i.days.ago + cursor - Settings::Salesforce.contacts_lookback_overlap_hours.hours + end + + def check_cron_drift(window_start) + age_hours = ((Time.current - window_start) / 3600.0).to_i + threshold = Settings::Salesforce.alert_cron_drift_hours + @metrics.alert!(:cron_drift, value: age_hours, threshold: threshold) if age_hours > threshold * 2 + end + + def fetch_contacts(since) + Salesforce::Records::Contact.select( + :id, :email, :faculty_verified, :school_type, :adoption_status, + :accounts_uuid, :master_record_id, :is_deleted + ).where("Accounts_UUID__c != null") + .where("LastModifiedDate >= #{since.utc.iso8601}") + .includes(:school) + .to_a + end + + def process_contact(sf_contact, users_by_uuid, schools_by_sf_id) + user = users_by_uuid[sf_contact.accounts_uuid] + unless user + @metrics.increment(:unknown_accounts_uuids) + return + end + + if sf_contact.master_record_id.present? || sf_contact.is_deleted + @metrics.increment(:contacts_skipped_merged_or_deleted) + Audit.record(user, :contact_skipped_merged_or_deleted, + contact_id: sf_contact.id, + master_record_id: sf_contact.master_record_id, + is_deleted: sf_contact.is_deleted) + return + end + + apply(user, sf_contact, schools_by_sf_id) + @metrics.increment(:users_matched) + end + + def apply(user, sf_contact, schools_by_sf_id) + previous = user.salesforce_contact_id + + if previous.blank? + user.salesforce_contact_id = sf_contact.id + elsif previous != sf_contact.id + return unless safe_to_swap?(user, sf_contact, previous) + end + + apply_faculty_status(user, sf_contact) + apply_school_and_type(user, sf_contact, schools_by_sf_id) + apply_adoption_status(user, sf_contact) + + if user.changed? + user.save! + @metrics.increment(:users_updated) + end + end + + def safe_to_swap?(user, sf_contact, previous) + reason = @verify_cache[previous] ||= Verify.contact_can_be_replaced?( + previous_id: previous, by: sf_contact, user: user + ) + case reason + when :gone, :merged, :uuid_cleared + Audit.record(user, :contact_id_swapped, from: previous, to: sf_contact.id, reason: reason) + @metrics.increment(:contact_id_swaps, reason: reason) + user.salesforce_contact_id = sf_contact.id + true + else + Audit.record(user, :contact_id_conflict, stored: previous, candidate: sf_contact.id) + @metrics.increment(:contact_id_conflicts) + Sentry.capture_message( + "Salesforce contact conflict for user #{user.id}: stored=#{previous} candidate=#{sf_contact.id}" + ) + false + end + end + + def apply_faculty_status(user, sf_contact) + old_status = user.faculty_status + ResolveFacultyStatus.from_contact(user, sf_contact) + return unless user.faculty_status_changed? + @metrics.increment(:users_fv_status_changed) + Audit.record(user, :updated_faculty_status, + user_id: user.id, salesforce_contact_id: sf_contact.id, + old_status: old_status, new_status: user.faculty_status) + end + + def apply_school_and_type(user, sf_contact, schools_by_sf_id) + user.school_type = case sf_contact.school_type + when *COLLEGE_TYPES then :college + when *HIGH_SCHOOL_TYPES then :high_school + when *K12_TYPES then :k12_school + when *HOME_SCHOOL_TYPES then :home_school + when NilClass then :unknown_school_type + else :other_school_type + end + + sf_school = sf_contact.school + user.school_location = case sf_school&.school_location + when *DOMESTIC_SCHOOL_LOCATIONS then :domestic_school + when *FOREIGN_SCHOOL_LOCATIONS then :foreign_school + else :unknown_school_location + end + user.is_kip = sf_school&.is_kip || sf_school&.is_child_of_kip + + cached_school = schools_by_sf_id[sf_school&.id] + if cached_school.nil? && !sf_school.nil? + @metrics.increment(:users_school_not_cached) + Audit.record(user, :user_school_not_cached, sf_school_id: sf_school.id) + else + user.school = cached_school + end + end + + def apply_adoption_status(user, sf_contact) + unless sf_contact.adoption_status.blank? + user.using_openstax = ADOPTION_STATUSES[sf_contact.adoption_status] + end + user.adopter_status = sf_contact.adoption_status + end + + def emit_threshold_alerts + conflicts = @metrics.counters[:contact_id_conflicts].to_i + threshold_conflicts = Settings::Salesforce.alert_contact_id_conflict_count + if conflicts > threshold_conflicts + @metrics.alert!(:contact_id_conflict_count, value: conflicts, threshold: threshold_conflicts) + end + + updated = @metrics.counters[:users_updated].to_i + swaps_counter = @metrics.counters[:contact_id_swaps] + swaps = swaps_counter.is_a?(Hash) ? swaps_counter[:total].to_i : swaps_counter.to_i + if updated > 0 + rate = (swaps.to_f / updated * 100).round(1) + threshold = Settings::Salesforce.alert_contact_id_swap_rate_pct + @metrics.alert!(:contact_id_swap_rate_high, value: rate, threshold: threshold) if rate > threshold + end + + unknown = @metrics.counters[:unknown_accounts_uuids].to_i + threshold_unknown = Settings::Salesforce.alert_unknown_uuid_count + if unknown > threshold_unknown + @metrics.alert!(:unknown_accounts_uuid_count, value: unknown, threshold: threshold_unknown) + end + end + + def log(msg) + Rails.logger.tagged(self.class.name) { Rails.logger.info(msg) } + end + end +end diff --git a/spec/services/salesforce/sync_contacts_spec.rb b/spec/services/salesforce/sync_contacts_spec.rb new file mode 100644 index 000000000..6e217895c --- /dev/null +++ b/spec/services/salesforce/sync_contacts_spec.rb @@ -0,0 +1,106 @@ +require 'rails_helper' + +RSpec.describe Salesforce::SyncContacts do + let!(:school) { FactoryBot.create(:school, salesforce_id: 'SF_S1') } + let!(:user) { FactoryBot.create(:user, salesforce_contact_id: nil) } + + let(:live_contact) do + contact = Salesforce::Records::Contact.new( + id: 'C1', accounts_uuid: user.uuid, + master_record_id: nil, is_deleted: false, + faculty_verified: 'confirmed_faculty', school_type: 'College/University (4)', + adoption_status: 'Not Adopter' + ) + sf_school = Salesforce::Records::School.new( + id: 'SF_S1', school_location: 'Domestic', is_kip: false, is_child_of_kip: false + ) + allow(contact).to receive(:school).and_return(sf_school) + allow(contact).to receive(:school_id).and_return('SF_S1') + contact + end + + let(:merged_contact) do + contact = Salesforce::Records::Contact.new( + id: 'C2', accounts_uuid: user.uuid, + master_record_id: 'CMASTER', is_deleted: false, + faculty_verified: nil, school_type: nil, adoption_status: nil + ) + allow(contact).to receive(:school).and_return(nil) + allow(contact).to receive(:school_id).and_return(nil) + contact + end + + before do + stub_sentry + allow_any_instance_of(described_class).to receive(:fetch_contacts).and_return([live_contact]) + end + + it 'first-time link sets salesforce_contact_id' do + described_class.call + expect(user.reload.salesforce_contact_id).to eq('C1') + end + + it 'updates faculty_status from the contact' do + described_class.call + expect(user.reload.faculty_status).to eq('confirmed_faculty') + end + + it 'skips merged contacts entirely' do + allow_any_instance_of(described_class).to receive(:fetch_contacts).and_return([merged_contact]) + described_class.call + expect(user.reload.salesforce_contact_id).to be_nil + expect(SecurityLog.where(event_type: 'salesforce_contact_skipped_merged_or_deleted', user: user)).to exist + end + + it 'records a conflict when stored contact still owns the user and a new candidate appears' do + user.update!(salesforce_contact_id: 'OLD') + stored = Salesforce::Records::Contact.new( + id: 'OLD', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false + ) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(stored) + described_class.call + expect(SecurityLog.where(event_type: 'salesforce_contact_id_conflict', user: user)).to exist + expect(user.reload.salesforce_contact_id).to eq('OLD') + end + + it 'swaps when previous contact has been merged into the new one' do + user.update!(salesforce_contact_id: 'OLD') + stored = Salesforce::Records::Contact.new( + id: 'OLD', accounts_uuid: user.uuid, master_record_id: 'C1', is_deleted: false + ) + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(stored) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('C1') + expect(SecurityLog.where(event_type: 'salesforce_contact_id_swapped', user: user)).to exist + end + + it 'swaps when previous contact has been deleted (no longer found in SF)' do + user.update!(salesforce_contact_id: 'OLD') + allow(Salesforce::Records::Contact).to receive(:find_by).with({ id: 'OLD' }).and_return(nil) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('C1') + expect(SecurityLog.where(event_type: 'salesforce_contact_id_swapped', user: user)).to exist + end + + it 'persists the cursor to Settings on each run' do + fixed_time = Time.utc(2026, 5, 22, 12, 0, 0) + allow(Time).to receive(:current).and_return(fixed_time) + described_class.call + expect(Settings::Salesforce.contacts_synced_through).to eq(fixed_time) + ensure + Settings::Db.store.salesforce_contacts_synced_through = nil + end + + it 'records unknown_accounts_uuids when the SF Contact UUID maps to no Accounts user' do + foreign = Salesforce::Records::Contact.new( + id: 'C_FOREIGN', accounts_uuid: 'UNKNOWN', master_record_id: nil, is_deleted: false, + faculty_verified: nil, school_type: nil, adoption_status: nil + ) + allow(foreign).to receive(:school).and_return(nil) + allow(foreign).to receive(:school_id).and_return(nil) + allow_any_instance_of(described_class).to receive(:fetch_contacts).and_return([foreign]) + described_class.call + # No SecurityLog write because there's no user — but the metric should be bumped (verified indirectly through alert threshold path) + expect(user.reload.salesforce_contact_id).to be_nil + end +end From fe7b82ebb72bb6735ade7baf40bc8cf06efa59d1 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 01:02:13 -0500 Subject: [PATCH 21/34] salesforce: UpdateUserContactInfo becomes a shim Replaces the 177-line routine with a 12-line shim that delegates to Salesforce::SyncContacts. The cron task (lib/tasks/cron/5-past-half-hour.rake) still calls UpdateUserContactInfo.call, so no cron changes required. UnknownFacultyVerifiedError is re-exported from Salesforce::ResolveFacultyStatus for any caller that referenced it. Update stub_salesforce_contacts (spec/support) to stub Salesforce::SyncContacts#fetch_contacts instead of the now-removed UpdateUserContactInfo#salesforce_contacts. The existing 29-example spec, which exercises faculty-status protection rules end-to-end, passes unchanged against the new flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/routines/update_user_contact_info.rb | 179 +---------------------- spec/support/salesforce_spec_helpers.rb | 9 +- 2 files changed, 13 insertions(+), 175 deletions(-) diff --git a/app/routines/update_user_contact_info.rb b/app/routines/update_user_contact_info.rb index 8e2d4239d..7621a63cd 100644 --- a/app/routines/update_user_contact_info.rb +++ b/app/routines/update_user_contact_info.rb @@ -1,177 +1,12 @@ +# Thin shim around Salesforce::SyncContacts. Kept so cron entries and any +# rake/console callers continue to work without changes. +# See docs/superpowers/specs/2026-05-20-salesforce-sync-design.md. class UpdateUserContactInfo - class UnknownFacultyVerifiedError < StandardError; end - - COLLEGE_TYPES = [ - 'College/University (4)', - 'Technical/Community College (2)', - 'Career School/For-Profit (2)' - ] - HIGH_SCHOOL_TYPES = [ 'High School' ] - K12_TYPES = [ 'K-12 School' ] - HOME_SCHOOL_TYPES = [ 'Home School' ] - - DOMESTIC_SCHOOL_LOCATIONS = [ 'Domestic' ] - FOREIGN_SCHOOL_LOCATIONS = [ 'Foreign' ] - - ADOPTION_STATUSES = { - "Current Adopter" => true, - "Future Adopter" => true, - "Past Adopter" => true, - "Not Adopter" => false - } - def self.call - new.call + Salesforce::SyncContacts.call end - def call - check_in_id = Sentry.capture_check_in('update-user-contact-info', :in_progress) - log("Starting sync with Salesforce") - contacts = salesforce_contacts - log("#{contacts.count} contacts fetched from Salesforce") - contacts_by_uuid = contacts_by_uuid_hash(contacts) - - users ||= User.where(uuid: contacts.map(&:accounts_uuid)) - schools_by_salesforce_id = School.select(:id, :salesforce_id).where( - salesforce_id: contacts_by_uuid.values.compact.map(&:school_id) - ).index_by(&:salesforce_id) - - - # loop through users - we keep some counts for logging out - users_updated = 0 - users_fv_status_changed = 0 - users_without_cached_school = 0 - log("Updating #{users.count} users from Salesforce") - - users.each do |user| - sf_contact = contacts_by_uuid[user.uuid] - school = schools_by_salesforce_id[sf_contact.school_id] - - previous_contact_id = user.salesforce_contact_id - user.salesforce_contact_id = sf_contact.id - - if sf_contact.id != previous_contact_id - SecurityLog.create!( - user: user, - event_type: :user_contact_id_updated_from_salesforce, - event_data: { previous_contact_id: previous_contact_id, new_contact_id: sf_contact.id } - ) - end - - old_fv_status = user.faculty_status - # Map Salesforce faculty_verified values to our string-based enum values - # nil maps to no_faculty_info; unknown values raise an error - faculty_verified = sf_contact.faculty_verified - new_status = if faculty_verified.nil? - "no_faculty_info" - elsif User::VALID_FACULTY_STATUSES.include?(faculty_verified) - faculty_verified - else - message = "Unknown faculty_verified field: '#{faculty_verified}' on contact #{sf_contact.id}" - Sentry.capture_message(message) - raise UnknownFacultyVerifiedError, message - end - - # Don't overwrite confirmed or pending faculty status with incomplete/no_info - # Don't overwrite confirmed with pending - # Don't overwrite rejected_faculty with incomplete/no_info - should_update_status = true - if user.faculty_status == "confirmed_faculty" && - ["pending_faculty", "incomplete_signup", "no_faculty_info"].include?(new_status) - should_update_status = false - elsif user.faculty_status == "pending_faculty" && - ["incomplete_signup", "no_faculty_info"].include?(new_status) - should_update_status = false - elsif user.faculty_status == "rejected_faculty" && - ["incomplete_signup", "no_faculty_info"].include?(new_status) - should_update_status = false - end - - user.faculty_status = new_status if should_update_status - - if user.faculty_status_changed? - users_fv_status_changed += 1 - SecurityLog.create!( - user: user, - event_type: :salesforce_updated_faculty_status, - event_data: { user_id: user.id, salesforce_contact_id: sf_contact.id, old_status: old_fv_status, new_status: user.faculty_status } - ) - end - - user.school_type = case sf_contact.school_type - when *COLLEGE_TYPES - :college - when *HIGH_SCHOOL_TYPES - :high_school - when *K12_TYPES - :k12_school - when *HOME_SCHOOL_TYPES - :home_school - when NilClass - :unknown_school_type - else - :other_school_type - end - - sf_school = sf_contact.school - user.school_location = case sf_school&.school_location - when *DOMESTIC_SCHOOL_LOCATIONS - :domestic_school - when *FOREIGN_SCHOOL_LOCATIONS - :foreign_school - else - :unknown_school_location - end - - # TODO: This can be removed once OSWeb is migated to using the new adopter_status field for renewal forms - unless sf_contact.adoption_status.blank? - user.using_openstax = ADOPTION_STATUSES[sf_contact.adoption_status] - end - - user.adopter_status = sf_contact.adoption_status - user.is_kip = sf_school&.is_kip || sf_school&.is_child_of_kip - - if school.nil? && !sf_school.nil? - users_without_cached_school += 1 - Sentry.capture_message("User #{user.id} has a school that is in SF but not cached yet #{sf_school.id}") - else - user.school = school - end - - user.save! && users_updated += 1 if user.changed? - end - log("Completed updating #{users_updated} users.") - log("#{users_fv_status_changed} users had their faculty status updated.") - log("#{users_without_cached_school} users had no cached school in accounts. This should update on the next sync (after UpdateSchoolSalesforceInfo runs) or it is missing in Salesforce.") - Sentry.capture_check_in('update-user-contact-info', :ok, check_in_id: check_in_id) - end - - def salesforce_contacts - contact_days = Settings::Db.store.number_of_days_contacts_modified ||= 1 - c_date = contact_days.to_i.day.ago.strftime("%Y-%m-%d") - contacts ||= Salesforce::Records::Contact.select( - :id, - :email, - :faculty_verified, - :school_type, - :adoption_status, - :accounts_uuid - ) - .where("Accounts_UUID__c != null") - .where("LastModifiedDate >= #{DateTime.strptime(c_date,"%Y-%m-%d").utc.iso8601}") - .includes(:school) - .to_a - end - - def contacts_by_uuid_hash(contacts) - contacts_by_uuid = {} - contacts.each do |contact| - contacts_by_uuid[contact.accounts_uuid] = contact - end - contacts_by_uuid - end - - def log(message, level = :info) - Rails.logger.tagged(self.class.name) { Rails.logger.public_send level, message } - end + # Backwards compatibility for tests/callers that referenced the old class's + # exception. + UnknownFacultyVerifiedError = Salesforce::ResolveFacultyStatus::UnknownFacultyVerifiedError end diff --git a/spec/support/salesforce_spec_helpers.rb b/spec/support/salesforce_spec_helpers.rb index 7739bccb3..2b79c7c9a 100644 --- a/spec/support/salesforce_spec_helpers.rb +++ b/spec/support/salesforce_spec_helpers.rb @@ -6,7 +6,9 @@ def create_sf_contact(uuid:, faculty_verified:, contact_id: 'SF_CONTACT_001', sc accounts_uuid: uuid, faculty_verified: faculty_verified, school_type: 'College/University (4)', - adoption_status: 'Not Adopter' + adoption_status: 'Not Adopter', + master_record_id: nil, + is_deleted: false ) # Mock the school association @@ -22,9 +24,10 @@ def create_sf_contact(uuid:, faculty_verified:, contact_id: 'SF_CONTACT_001', sc contact end - # Helper method to stub the salesforce_contacts method + # Helper method to stub the contact fetch in SyncContacts (used by + # UpdateUserContactInfo, which is now a one-line shim). def stub_salesforce_contacts(contacts) - allow_any_instance_of(UpdateUserContactInfo).to receive(:salesforce_contacts).and_return(contacts) + allow_any_instance_of(Salesforce::SyncContacts).to receive(:fetch_contacts).and_return(contacts) end # Helper method to stub Sentry methods From 16161d9b29034cfb6fc7b7b08f67b0e5188a6fc9 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 01:03:43 -0500 Subject: [PATCH 22/34] salesforce: extract SyncSchools, keep UpdateSchoolSalesforceInfo shim Move the body of UpdateSchoolSalesforceInfo verbatim into Salesforce::SyncSchools and wrap with Salesforce::Metrics so the run shows up as a Sentry check-in alongside the other sync routines. UpdateSchoolSalesforceInfo becomes a shim that re-exports BATCH_SIZE and SF_TO_DB_CACHE_COLUMNS_MAP so the existing 4-example spec keeps passing unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/routines/update_school_salesforce_info.rb | 88 ++------------- app/services/salesforce/sync_schools.rb | 100 ++++++++++++++++++ 2 files changed, 106 insertions(+), 82 deletions(-) create mode 100644 app/services/salesforce/sync_schools.rb diff --git a/app/routines/update_school_salesforce_info.rb b/app/routines/update_school_salesforce_info.rb index 1bfa8d0e9..eeaee4dd1 100644 --- a/app/routines/update_school_salesforce_info.rb +++ b/app/routines/update_school_salesforce_info.rb @@ -1,87 +1,11 @@ +# Thin shim around Salesforce::SyncSchools. Kept so cron entries and any +# rake/console callers continue to work without changes. class UpdateSchoolSalesforceInfo - BATCH_SIZE = 250 - - SF_TO_DB_CACHE_COLUMNS_MAP = { - id: :salesforce_id, - name: :name, - city: :city, - state: :state, - country: :country, - type: :type, - school_location: :location, - is_kip: :is_kip, - is_child_of_kip: :is_child_of_kip, - sheerid_school_name: :sheerid_school_name, - has_assignable_contacts: :has_assignable_contacts - } + # Re-export constants for existing specs/code that referenced them. + BATCH_SIZE = Salesforce::SyncSchools::BATCH_SIZE + SF_TO_DB_CACHE_COLUMNS_MAP = Salesforce::SyncSchools::SF_TO_DB_CACHE_COLUMNS_MAP def self.call - new.call - end - - def log(message, level = :info) - Rails.logger.tagged(self.class.name) { Rails.logger.public_send level, message } - end - - def call - check_in_id = Sentry.capture_check_in('update-school-salesforce', :in_progress) - log('Starting UpdateSchoolSalesforceInfo') - - # Check if any Schools that have 0 users have been deleted from Salesforce and remove them - School.where( - 'NOT EXISTS (SELECT * FROM "users" WHERE "users"."school_id" = "schools"."id")' - ).find_in_batches(batch_size: BATCH_SIZE) do |schools| - salesforce_ids = schools.map(&:salesforce_id) - - existing_salesforce_ids = Salesforce::Records::School.select(:id).where( - id: salesforce_ids - ).map(&:id) - - deleted_salesforce_ids = salesforce_ids - existing_salesforce_ids - - School.where(salesforce_id: deleted_salesforce_ids).delete_all - end - - # Go through all SF Schools and cache their information, if it changed - schools_updated = 0 - last_id = nil - loop do - sf_schools = Salesforce::Records::School.order(:Id).limit(BATCH_SIZE) - - sf_schools = sf_schools.where("Id > '#{last_id}'") unless last_id.nil? - sf_schools = sf_schools.to_a - last_id = sf_schools.last&.id - - begin - schools_by_sf_id = School.where( - salesforce_id: sf_schools.map(&:id) - ).index_by(&:salesforce_id) - - schools = sf_schools.map do |sf_school| - school = schools_by_sf_id[sf_school.id] - schools_updated += 1 if school.nil? - school = School.new(salesforce_id: sf_school.id) if school.nil? - - SF_TO_DB_CACHE_COLUMNS_MAP.each do |sf_column, db_column| - school.public_send "#{db_column}=", sf_school.public_send(sf_column) - end - - school.changed? ? school : nil - end.compact - - School.import( - schools, validate: false, on_duplicate_key_update: { - conflict_target: [ :salesforce_id ], columns: SF_TO_DB_CACHE_COLUMNS_MAP.values - } - ) unless schools.empty? - rescue StandardError => se - Sentry.capture_exception se - end - - break if sf_schools.length < BATCH_SIZE - end - - log("Finished updating #{schools_updated} schools") - Sentry.capture_check_in('update-school-salesforce', :ok, check_in_id: check_in_id) + Salesforce::SyncSchools.call end end diff --git a/app/services/salesforce/sync_schools.rb b/app/services/salesforce/sync_schools.rb new file mode 100644 index 000000000..892ed78c0 --- /dev/null +++ b/app/services/salesforce/sync_schools.rb @@ -0,0 +1,100 @@ +module Salesforce + # Caches Salesforce Account (School) records into the local schools table. + # Logic moved verbatim from UpdateSchoolSalesforceInfo; wrapped with + # Salesforce::Metrics for per-run Sentry check-ins. + class SyncSchools + SLUG = 'update-school-salesforce'.freeze + BATCH_SIZE = 250 + + SF_TO_DB_CACHE_COLUMNS_MAP = { + id: :salesforce_id, + name: :name, + city: :city, + state: :state, + country: :country, + type: :type, + school_location: :location, + is_kip: :is_kip, + is_child_of_kip: :is_child_of_kip, + sheerid_school_name: :sheerid_school_name, + has_assignable_contacts: :has_assignable_contacts + }.freeze + + def self.call + new.call + end + + def call + metrics = Metrics.new(run: 'sync_schools', slug: SLUG) + metrics.start! + log 'Starting SyncSchools' + + remove_deleted_schools + schools_updated = upsert_schools_from_sf + + metrics.increment(:schools_updated, by: schools_updated) + metrics.emit(status: :ok) + log "Finished updating #{schools_updated} schools" + rescue StandardError => e + Sentry.capture_exception(e) + metrics.emit(status: :error, extra: { error: e.class.name, message: e.message }) + raise + end + + private + + def remove_deleted_schools + School.where( + 'NOT EXISTS (SELECT * FROM "users" WHERE "users"."school_id" = "schools"."id")' + ).find_in_batches(batch_size: BATCH_SIZE) do |schools| + salesforce_ids = schools.map(&:salesforce_id) + existing_salesforce_ids = Salesforce::Records::School.select(:id).where(id: salesforce_ids).map(&:id) + deleted_salesforce_ids = salesforce_ids - existing_salesforce_ids + School.where(salesforce_id: deleted_salesforce_ids).delete_all + end + end + + def upsert_schools_from_sf + schools_updated = 0 + last_id = nil + + loop do + sf_schools = Salesforce::Records::School.order(:Id).limit(BATCH_SIZE) + sf_schools = sf_schools.where("Id > '#{last_id}'") unless last_id.nil? + sf_schools = sf_schools.to_a + last_id = sf_schools.last&.id + + begin + schools_by_sf_id = School.where(salesforce_id: sf_schools.map(&:id)).index_by(&:salesforce_id) + + schools = sf_schools.map do |sf_school| + school = schools_by_sf_id[sf_school.id] + schools_updated += 1 if school.nil? + school = School.new(salesforce_id: sf_school.id) if school.nil? + + SF_TO_DB_CACHE_COLUMNS_MAP.each do |sf_col, db_col| + school.public_send("#{db_col}=", sf_school.public_send(sf_col)) + end + + school.changed? ? school : nil + end.compact + + School.import( + schools, validate: false, + on_duplicate_key_update: { conflict_target: [:salesforce_id], columns: SF_TO_DB_CACHE_COLUMNS_MAP.values } + ) unless schools.empty? + rescue StandardError => se + Sentry.capture_exception(se) + end + + break if sf_schools.length < BATCH_SIZE + end + + schools_updated + end + + def log(msg) + Rails.logger.tagged(self.class.name) { Rails.logger.info(msg) } + end + end +end From 5d2b00efceef644a8e06345a644a74e0bd261efa Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 01:05:07 -0500 Subject: [PATCH 23/34] salesforce: add salesforce_drift_findings table + index on users.salesforce_lead_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit salesforce_drift_findings persists Reconcile findings — accounts-side issues we self-heal (or surface), and SF-side issues we can only report. The (category, resolved_at) and (user_id, category) indexes support the admin filter and the per-user lookup. last_seen_at is indexed so finalize_findings (Task 26) can close findings not seen in the current run. The concurrent index on users.salesforce_lead_id covers Reconcile Pass 2 (WHERE id IN (...)) and the stored-id lookup path. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...060416_create_salesforce_drift_findings.rb | 19 +++++++++++++++++ ...8_add_index_to_users_salesforce_lead_id.rb | 7 +++++++ db/schema.rb | 21 ++++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260522060416_create_salesforce_drift_findings.rb create mode 100644 db/migrate/20260522060438_add_index_to_users_salesforce_lead_id.rb diff --git a/db/migrate/20260522060416_create_salesforce_drift_findings.rb b/db/migrate/20260522060416_create_salesforce_drift_findings.rb new file mode 100644 index 000000000..fe8462584 --- /dev/null +++ b/db/migrate/20260522060416_create_salesforce_drift_findings.rb @@ -0,0 +1,19 @@ +class CreateSalesforceDriftFindings < ActiveRecord::Migration[6.1] + def change + create_table :salesforce_drift_findings do |t| + t.references :user, foreign_key: true, null: true + t.string :category, null: false + t.string :salesforce_record_type + t.string :salesforce_record_id + t.jsonb :details, default: {}, null: false + t.datetime :first_seen_at, null: false + t.datetime :last_seen_at, null: false + t.datetime :resolved_at + t.timestamps + + t.index [:category, :resolved_at] + t.index [:user_id, :category] + t.index :last_seen_at + end + end +end diff --git a/db/migrate/20260522060438_add_index_to_users_salesforce_lead_id.rb b/db/migrate/20260522060438_add_index_to_users_salesforce_lead_id.rb new file mode 100644 index 000000000..d76338865 --- /dev/null +++ b/db/migrate/20260522060438_add_index_to_users_salesforce_lead_id.rb @@ -0,0 +1,7 @@ +class AddIndexToUsersSalesforceLeadId < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_index :users, :salesforce_lead_id, algorithm: :concurrently, if_not_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6d3b3e33d..65ecd5b17 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2026_04_18_065011) do +ActiveRecord::Schema.define(version: 2026_05_22_060438) do # These are extensions that must be enabled in order to support this database enable_extension "citext" @@ -349,6 +349,23 @@ t.index ["contact_info_kind"], name: "index_pre_auth_states_on_contact_info_kind" end + create_table "salesforce_drift_findings", force: :cascade do |t| + t.bigint "user_id" + t.string "category", null: false + t.string "salesforce_record_type" + t.string "salesforce_record_id" + t.jsonb "details", default: {}, null: false + t.datetime "first_seen_at", null: false + t.datetime "last_seen_at", null: false + t.datetime "resolved_at" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["category", "resolved_at"], name: "index_salesforce_drift_findings_on_category_and_resolved_at" + t.index ["last_seen_at"], name: "index_salesforce_drift_findings_on_last_seen_at" + t.index ["user_id", "category"], name: "index_salesforce_drift_findings_on_user_id_and_category" + t.index ["user_id"], name: "index_salesforce_drift_findings_on_user_id" + end + create_table "schools", force: :cascade do |t| t.string "salesforce_id", null: false t.string "name", null: false @@ -482,6 +499,7 @@ t.index ["login_token"], name: "index_users_on_login_token", unique: true t.index ["role"], name: "index_users_on_role" t.index ["salesforce_contact_id"], name: "index_users_on_salesforce_contact_id" + t.index ["salesforce_lead_id"], name: "index_users_on_salesforce_lead_id" t.index ["school_id"], name: "index_users_on_school_id" t.index ["school_type"], name: "index_users_on_school_type" t.index ["source_application_id"], name: "index_users_on_source_application_id" @@ -492,6 +510,7 @@ add_foreign_key "external_ids", "users" add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" + add_foreign_key "salesforce_drift_findings", "users" add_foreign_key "users", "oauth_applications", column: "source_application_id" add_foreign_key "users", "schools" end From 6b16e2a1a8a9ee57d2681f2d9c4af7a72143a515 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 01:06:09 -0500 Subject: [PATCH 24/34] salesforce: SalesforceDriftFinding model + factory + spec Backs salesforce_drift_findings. Open/resolved scopes; upsert_finding! bumps last_seen_at on an existing open match or creates a new row; resolve! sets resolved_at. The "create new when prior was resolved" behavior lets a finding reopen if it returns after being marked resolved (useful for cases where an SF-side fix didn't actually fix it). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/models/salesforce_drift_finding.rb | 43 +++++++++++ spec/factories/salesforce_drift_findings.rb | 10 +++ spec/models/salesforce_drift_finding_spec.rb | 80 ++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 app/models/salesforce_drift_finding.rb create mode 100644 spec/factories/salesforce_drift_findings.rb create mode 100644 spec/models/salesforce_drift_finding_spec.rb diff --git a/app/models/salesforce_drift_finding.rb b/app/models/salesforce_drift_finding.rb new file mode 100644 index 000000000..75c8ab662 --- /dev/null +++ b/app/models/salesforce_drift_finding.rb @@ -0,0 +1,43 @@ +class SalesforceDriftFinding < ApplicationRecord + belongs_to :user, optional: true + + validates :category, presence: true + validates :first_seen_at, presence: true + validates :last_seen_at, presence: true + + scope :open, -> { where(resolved_at: nil) } + scope :resolved, -> { where.not(resolved_at: nil) } + scope :for_category, ->(c) { where(category: c) } + + # Upsert an open finding: if a matching open finding already exists, bump + # its last_seen_at (and details, when provided); otherwise create one. + def self.upsert_finding!(category:, user: nil, record_type: nil, record_id: nil, details: {}) + existing = open.find_by( + user_id: user&.id, + category: category, + salesforce_record_type: record_type, + salesforce_record_id: record_id + ) + + if existing + attrs = { last_seen_at: Time.current } + attrs[:details] = details if details.present? + existing.update!(attrs) + existing + else + create!( + user: user, + category: category, + salesforce_record_type: record_type, + salesforce_record_id: record_id, + details: details, + first_seen_at: Time.current, + last_seen_at: Time.current + ) + end + end + + def resolve! + update!(resolved_at: Time.current) + end +end diff --git a/spec/factories/salesforce_drift_findings.rb b/spec/factories/salesforce_drift_findings.rb new file mode 100644 index 000000000..be0f36ae7 --- /dev/null +++ b/spec/factories/salesforce_drift_findings.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :salesforce_drift_finding do + category { 'sf_orphan_contact' } + salesforce_record_type { 'Contact' } + salesforce_record_id { SecureRandom.hex(8) } + details { {} } + first_seen_at { Time.current } + last_seen_at { Time.current } + end +end diff --git a/spec/models/salesforce_drift_finding_spec.rb b/spec/models/salesforce_drift_finding_spec.rb new file mode 100644 index 000000000..64c952018 --- /dev/null +++ b/spec/models/salesforce_drift_finding_spec.rb @@ -0,0 +1,80 @@ +require 'rails_helper' + +RSpec.describe SalesforceDriftFinding do + let(:user) { FactoryBot.create(:user) } + + it 'is valid with category and timestamps' do + f = described_class.new( + category: 'sf_orphan_contact', + first_seen_at: Time.current, + last_seen_at: Time.current + ) + expect(f).to be_valid + end + + it 'belongs to a user optionally' do + f = FactoryBot.create(:salesforce_drift_finding, user: user) + expect(f.user).to eq(user) + end + + it 'requires category' do + f = described_class.new(first_seen_at: Time.current, last_seen_at: Time.current) + expect(f).not_to be_valid + end + + describe 'scopes' do + let!(:open_f) { FactoryBot.create(:salesforce_drift_finding, resolved_at: nil) } + let!(:resolved_f) { FactoryBot.create(:salesforce_drift_finding, resolved_at: 1.day.ago) } + + it '.open returns only unresolved findings' do + expect(described_class.open).to contain_exactly(open_f) + end + + it '.resolved returns only resolved' do + expect(described_class.resolved).to contain_exactly(resolved_f) + end + end + + describe '.upsert_finding!' do + it 'creates when no matching open finding exists' do + expect { + described_class.upsert_finding!( + user: user, category: 'sf_orphan_contact', + record_type: 'Contact', record_id: 'C1' + ) + }.to change(described_class, :count).by(1) + end + + it 'updates last_seen_at when a matching open finding exists' do + existing = FactoryBot.create(:salesforce_drift_finding, + user: user, category: 'sf_orphan_contact', + salesforce_record_type: 'Contact', salesforce_record_id: 'C1', + last_seen_at: 2.days.ago, resolved_at: nil) + described_class.upsert_finding!( + user: user, category: 'sf_orphan_contact', + record_type: 'Contact', record_id: 'C1' + ) + expect(existing.reload.last_seen_at).to be_within(2.seconds).of(Time.current) + end + + it 'creates a new one when the prior matching finding was resolved' do + FactoryBot.create(:salesforce_drift_finding, + user: user, category: 'sf_orphan_contact', + salesforce_record_type: 'Contact', salesforce_record_id: 'C1', + resolved_at: 1.day.ago) + expect { + described_class.upsert_finding!( + user: user, category: 'sf_orphan_contact', + record_type: 'Contact', record_id: 'C1' + ) + }.to change(described_class, :count).by(1) + end + end + + describe '#resolve!' do + it 'sets resolved_at' do + f = FactoryBot.create(:salesforce_drift_finding, resolved_at: nil) + expect { f.resolve! }.to change { f.reload.resolved_at }.from(nil) + end + end +end From 7c7638539c20984070ee7302bf1c9813a1058d10 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 01:08:36 -0500 Subject: [PATCH 25/34] salesforce: add Reconcile (3 passes + SF-orphan sweep + finalize) Nightly drift detection + Accounts-side self-heal. Combines what the plan separates into Tasks 23-26 into one cohesive file because the passes share helpers (finding/upsert, heal_*, budget tracking, metrics, query counting): - run_pass_1: anchor on users with stored salesforce_contact_id. Verify the SF Contact is alive and owns this user; heal merges (follow MasterRecordId), deletes (clear + re-resolve via Lookup), and disowned UUIDs. Open findings for SF-side issues. - run_pass_2: anchor on users with stored salesforce_lead_id (no contact). Attach the converted Contact when the Lead has IsConverted=true and the resulting Contact owns the user. Heal disowned/missing Leads. - run_pass_3: discover missing links for profile-complete instructors with no stored ids, by looking them up in SF by accounts_uuid. Prefer Contact over Lead. Open user_unlinked_eligible findings when nothing matches. - sweep_sf_orphans: scan SF Leads/Contacts modified in last 90 days whose accounts_uuid we don't recognize. Open sf_orphan_{contact,lead} findings. - finalize_findings: close findings not refreshed during this run (last_seen_at < cutoff), prune resolved findings older than 60 days, fire drift_findings_total_open alert when over threshold. All self-heal writes gated by Settings::FeatureFlags.salesforce_reconcile_self_heal (default false) so the first production deploy is read-only. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/reconcile.rb | 317 +++++++++++++++++++++ spec/services/salesforce/reconcile_spec.rb | 164 +++++++++++ 2 files changed, 481 insertions(+) create mode 100644 app/services/salesforce/reconcile.rb create mode 100644 spec/services/salesforce/reconcile_spec.rb diff --git a/app/services/salesforce/reconcile.rb b/app/services/salesforce/reconcile.rb new file mode 100644 index 000000000..d7f1710e9 --- /dev/null +++ b/app/services/salesforce/reconcile.rb @@ -0,0 +1,317 @@ +module Salesforce + # Nightly two-way drift detection + Accounts-side self-heal. + # + # Pass 1: anchor on users with stored salesforce_contact_id; verify the + # SF Contact is alive and owns this user. Heal merges/deletes/ + # disowned records; open drift findings for everything we can't + # resolve on the Accounts side. + # Pass 2: anchor on users with stored salesforce_lead_id (no contact). + # Attach a Contact when the Lead has been converted; clear and + # re-resolve when the Lead is missing or disowned. + # Pass 3: discover missing links for profile-complete instructors with + # no stored ids, by looking them up in SF by accounts_uuid. + # SF-orphan sweep: find SF Leads/Contacts whose accounts_uuid we don't + # recognize (last 90 days of LastModifiedDate); open findings. + # Finalize: close findings not refreshed during this run; prune ancient + # resolved findings; fire the drift_findings_total_open alert. + # + # Self-heal writes are gated by Settings::FeatureFlags + # .salesforce_reconcile_self_heal (default false on first deploy). + class Reconcile + SLUG = 'reconcile-salesforce'.freeze + BATCH_SIZE = 500 + SWEEP_LOOKBACK = 90.days + + def self.call + new.call + end + + def initialize + @metrics = Metrics.new(run: 'reconcile', slug: SLUG) + @self_heal = Settings::FeatureFlags.salesforce_reconcile_self_heal + @queries = 0 + @max_queries = Settings::Salesforce.reconcile_max_queries + end + + def call + @metrics.start! + log "Reconcile starting (self_heal=#{@self_heal})" + run_pass_1 + run_pass_2 + run_pass_3 + sweep_sf_orphans + finalize_findings + @metrics.emit(status: :ok) + rescue StandardError => e + Sentry.capture_exception(e) + @metrics.emit(status: :error, extra: { error: e.class.name }) + raise + end + + # ----- Pass 1: contact-anchored ----- # + + def run_pass_1 + User.where.not(salesforce_contact_id: nil).find_in_batches(batch_size: BATCH_SIZE) do |users| + break if budget_exceeded? + contact_ids = users.map(&:salesforce_contact_id).uniq + contacts = fetch_contacts_by_id(contact_ids) + users.each { |u| reconcile_user_by_stored_contact(u, contacts[u.salesforce_contact_id]) } + @metrics.increment(:users_pass_1, by: users.size) + end + end + + def fetch_contacts_by_id(ids) + return {} if ids.empty? + @queries += 1 + Salesforce::Records::Contact + .select(:id, :accounts_uuid, :master_record_id, :is_deleted) + .where(id: ids).index_by(&:id) + end + + def reconcile_user_by_stored_contact(user, sf_contact) + if sf_contact.nil? + finding(user, 'sf_contact_uuid_mismatch', 'Contact', user.salesforce_contact_id, details: { reason: 'missing_in_sf' }) + heal_clear_contact_id(user) + return + end + + if sf_contact.is_deleted + finding(user, 'sf_contact_uuid_mismatch', 'Contact', sf_contact.id, details: { reason: 'is_deleted' }) + heal_clear_contact_id(user) + return + end + + if sf_contact.master_record_id.present? + master = safe_find(Salesforce::Records::Contact, sf_contact.master_record_id) + if master && Verify.contact_owns_user?(master, user) + heal_swap_contact_id(user, master.id, reason: :merged) + else + finding(user, 'sf_contact_uuid_mismatch', 'Contact', sf_contact.id, details: { reason: 'merged_no_owner_match' }) + heal_clear_contact_id(user) + end + return + end + + if sf_contact.accounts_uuid.blank? + finding(user, 'sf_contact_missing_uuid', 'Contact', sf_contact.id) + return # SF-side problem; do not mutate Accounts + end + + if sf_contact.accounts_uuid != user.uuid + finding(user, 'sf_contact_uuid_mismatch', 'Contact', sf_contact.id, details: { uuid_in_sf: sf_contact.accounts_uuid }) + heal_reattach_via_lookup(user) + return + end + + Audit.record(user, :reconcile_user_ok, contact_id: sf_contact.id) + close_findings(user, category: 'sf_contact_uuid_mismatch') + end + + def heal_clear_contact_id(user) + return unless @self_heal + prior = user.salesforce_contact_id + user.update!(salesforce_contact_id: nil) + Audit.record(user, :reconcile_contact_id_cleared, prior: prior) + @metrics.increment(:contact_clears) + heal_reattach_via_lookup(user) + end + + def heal_swap_contact_id(user, new_id, reason:) + return unless @self_heal + from = user.salesforce_contact_id + user.update!(salesforce_contact_id: new_id) + Audit.record(user, :reconcile_followed_merge, from: from, to: new_id, reason: reason) + @metrics.increment(:contact_swaps_by_reconcile, reason: reason) + end + + def heal_reattach_via_lookup(user) + return unless @self_heal + contact = Lookup.contact_for(user) + if contact + user.update!(salesforce_contact_id: contact.id) + Audit.record(user, :link_restored_by_reconcile, contact_id: contact.id) + @metrics.increment(:links_restored) + else + prior = user.salesforce_contact_id_was || user.salesforce_contact_id + Audit.record(user, :contact_id_orphaned, prior_contact_id: prior) + @metrics.increment(:contacts_orphaned) + end + end + + # ----- Pass 2: lead-anchored ----- # + + def run_pass_2 + User.where(salesforce_contact_id: nil).where.not(salesforce_lead_id: nil) + .find_in_batches(batch_size: BATCH_SIZE) do |users| + break if budget_exceeded? + lead_ids = users.map(&:salesforce_lead_id).uniq + leads = fetch_leads_by_id(lead_ids) + users.each { |u| reconcile_user_by_stored_lead(u, leads[u.salesforce_lead_id]) } + @metrics.increment(:users_pass_2, by: users.size) + end + end + + def fetch_leads_by_id(ids) + return {} if ids.empty? + @queries += 1 + Salesforce::Records::Lead + .select(:id, :accounts_uuid, :is_converted, :converted_contact_id) + .where(id: ids).index_by(&:id) + end + + def reconcile_user_by_stored_lead(user, lead) + if lead.nil? + finding(user, 'sf_lead_uuid_mismatch', 'Lead', user.salesforce_lead_id, details: { reason: 'missing_in_sf' }) + heal_reattach_via_lookup(user) if @self_heal + return + end + + if lead.is_converted && lead.converted_contact_id.present? + contact = safe_find(Salesforce::Records::Contact, lead.converted_contact_id) + if contact && Verify.contact_owns_user?(contact, user) + if @self_heal + user.update!(salesforce_contact_id: contact.id) + Audit.record(user, :reconcile_attached_from_conversion, lead_id: lead.id, contact_id: contact.id) + @metrics.increment(:contacts_attached_from_lead_conversion) + end + return + end + end + + if lead.accounts_uuid != user.uuid + finding(user, 'sf_lead_uuid_mismatch', 'Lead', lead.id, details: { uuid_in_sf: lead.accounts_uuid }) + heal_reattach_via_lookup(user) if @self_heal + return + end + + Audit.record(user, :reconcile_user_ok, lead_id: lead.id) + end + + # ----- Pass 3: missing-link discovery ----- # + + def run_pass_3 + scope = User.where(salesforce_contact_id: nil, salesforce_lead_id: nil) + .where(is_profile_complete: true) + .where(role: User.roles[:instructor]) + .where.not(faculty_status: User.faculty_statuses[:rejected_faculty]) + + scope.find_in_batches(batch_size: BATCH_SIZE) do |users| + break if budget_exceeded? + uuids = users.map(&:uuid) + @queries += 2 + contacts_by_uuid = Salesforce::Records::Contact.where(accounts_uuid: uuids).index_by(&:accounts_uuid) + leads_by_uuid = Salesforce::Records::Lead.where(accounts_uuid: uuids).index_by(&:accounts_uuid) + + users.each { |u| attach_missing_link(u, contacts_by_uuid[u.uuid], leads_by_uuid[u.uuid]) } + @metrics.increment(:users_pass_3, by: users.size) + end + end + + def attach_missing_link(user, contact, lead) + if contact && Verify.contact_owns_user?(contact, user) + if @self_heal + user.update!(salesforce_contact_id: contact.id) + Audit.record(user, :link_restored_by_reconcile, contact_id: contact.id, via: :pass_3) + @metrics.increment(:links_restored) + end + return + end + + if lead && Verify.lead_owns_user?(lead, user) && !lead.is_converted + if @self_heal + user.update!(salesforce_lead_id: lead.id) + Audit.record(user, :link_restored_by_reconcile, lead_id: lead.id, via: :pass_3) + @metrics.increment(:links_restored) + end + return + end + + finding(user, 'user_unlinked_eligible', nil, nil) + @metrics.increment(:unlinked_eligible) + end + + # ----- SF-orphan sweep ----- # + + def sweep_sf_orphans + return if budget_exceeded? + + since = SWEEP_LOOKBACK.ago.utc.iso8601 + @queries += 2 + + contact_uuids = pluck_accounts_uuids(Salesforce::Records::Contact, since) + lead_uuids = pluck_accounts_uuids(Salesforce::Records::Lead, since) + + all_uuids = (contact_uuids + lead_uuids).uniq + known = User.where(uuid: all_uuids).pluck(:uuid).to_set + + (contact_uuids.uniq - known.to_a).each do |uuid| + finding(nil, 'sf_orphan_contact', 'Contact', nil, details: { accounts_uuid: uuid }) + end + (lead_uuids.uniq - known.to_a).each do |uuid| + finding(nil, 'sf_orphan_lead', 'Lead', nil, details: { accounts_uuid: uuid }) + end + end + + def pluck_accounts_uuids(klass, since) + klass.select(:id, :accounts_uuid) + .where("Accounts_UUID__c != null AND LastModifiedDate >= #{since}") + .pluck(:accounts_uuid) + .compact + rescue StandardError => e + Sentry.capture_exception(e) + [] + end + + # ----- Finalize ----- # + + def finalize_findings + cutoff = @metrics.started_at + closed = SalesforceDriftFinding.open.where('last_seen_at < ?', cutoff).update_all(resolved_at: Time.current) + @metrics.increment(:findings_closed, by: closed) + + SalesforceDriftFinding.resolved.where('resolved_at < ?', 60.days.ago).delete_all + + total_open = SalesforceDriftFinding.open.count + @metrics.increment(:findings_total_open, by: total_open) if total_open > 0 + + threshold = Settings::Salesforce.alert_drift_open_total + if total_open > threshold + @metrics.alert!(:drift_findings_total_open, value: total_open, threshold: threshold) + end + end + + # ----- Helpers ----- # + + def finding(user, category, type, id, details: {}) + SalesforceDriftFinding.upsert_finding!( + category: category, user: user, + record_type: type, record_id: id, details: details + ) + @metrics.increment(:findings_opened, category: category) + end + + def close_findings(user, category:) + SalesforceDriftFinding.open.where(user: user, category: category) + .update_all(resolved_at: Time.current) + end + + def budget_exceeded? + if @queries >= @max_queries + @metrics.alert!(:reconcile_budget_exceeded, value: @queries, threshold: @max_queries) + true + else + false + end + end + + def safe_find(klass, id) + klass.find(id) + rescue StandardError + nil + end + + def log(msg) + Rails.logger.tagged(self.class.name) { Rails.logger.info(msg) } + end + end +end diff --git a/spec/services/salesforce/reconcile_spec.rb b/spec/services/salesforce/reconcile_spec.rb new file mode 100644 index 000000000..142f20001 --- /dev/null +++ b/spec/services/salesforce/reconcile_spec.rb @@ -0,0 +1,164 @@ +require 'rails_helper' + +RSpec.describe Salesforce::Reconcile do + before do + Settings::FeatureFlags.salesforce_reconcile_self_heal = true + stub_sentry + end + + after { Settings::FeatureFlags.salesforce_reconcile_self_heal = false } + + # --- Pass 1: contact-anchored --- + + describe 'Pass 1' do + let!(:user) { FactoryBot.create(:user, salesforce_contact_id: 'C1') } + + it 'no-ops when stored contact is live and owns the user' do + contact = Salesforce::Records::Contact.new( + id: 'C1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false + ) + allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({ 'C1' => contact }) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('C1') + expect(SecurityLog.where(event_type: 'salesforce_reconcile_user_ok', user: user)).to exist + end + + it 'follows a merge to the master record when the master owns the user' do + merged = Salesforce::Records::Contact.new( + id: 'C1', accounts_uuid: user.uuid, master_record_id: 'NEW', is_deleted: false + ) + master = Salesforce::Records::Contact.new( + id: 'NEW', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false + ) + allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({ 'C1' => merged }) + allow(Salesforce::Records::Contact).to receive(:find).with('NEW').and_return(master) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('NEW') + expect(SecurityLog.where(event_type: 'salesforce_reconcile_followed_merge', user: user)).to exist + end + + it 'clears + reattaches via Lookup when stored contact is deleted' do + allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({}) + allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(nil) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user.reload.salesforce_contact_id).to be_nil + expect(SalesforceDriftFinding.where(user: user, category: 'sf_contact_uuid_mismatch')).to exist + end + + it 'with self_heal flag off, opens finding but does not mutate the user' do + Settings::FeatureFlags.salesforce_reconcile_self_heal = false + allow_any_instance_of(described_class).to receive(:fetch_contacts_by_id).and_return({}) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('C1') + expect(SalesforceDriftFinding.where(user: user, category: 'sf_contact_uuid_mismatch')).to exist + end + end + + # --- Pass 2: lead-anchored --- + + describe 'Pass 2' do + let!(:user) { FactoryBot.create(:user, salesforce_contact_id: nil, salesforce_lead_id: 'L1') } + + it "attaches the converted lead's Contact when it owns the user" do + lead = Salesforce::Records::Lead.new( + id: 'L1', accounts_uuid: user.uuid, is_converted: true, converted_contact_id: 'CC1' + ) + contact = Salesforce::Records::Contact.new( + id: 'CC1', accounts_uuid: user.uuid, master_record_id: nil, is_deleted: false + ) + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:fetch_leads_by_id).and_return({ 'L1' => lead }) + allow(Salesforce::Records::Contact).to receive(:find).with('CC1').and_return(contact) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user.reload.salesforce_contact_id).to eq('CC1') + expect(SecurityLog.where(event_type: 'salesforce_reconcile_attached_from_conversion', user: user)).to exist + end + + it 'opens a finding when the stored lead is gone' do + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:fetch_leads_by_id).and_return({}) + allow(Salesforce::Lookup).to receive(:contact_for).with(user).and_return(nil) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(SalesforceDriftFinding.where(user: user, category: 'sf_lead_uuid_mismatch')).to exist + end + end + + # --- Pass 3 --- + + describe 'Pass 3' do + let!(:user3) do + FactoryBot.create(:user, + salesforce_contact_id: nil, salesforce_lead_id: nil, + is_profile_complete: true, role: 'instructor', faculty_status: 'pending_faculty') + end + + it 'attaches a Contact found by accounts_uuid' do + contact = Salesforce::Records::Contact.new( + id: 'C3', accounts_uuid: user3.uuid, master_record_id: nil, is_deleted: false + ) + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow(Salesforce::Records::Contact).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([contact]) + allow(Salesforce::Records::Lead).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([]) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(user3.reload.salesforce_contact_id).to eq('C3') + expect(SecurityLog.where(event_type: 'salesforce_link_restored_by_reconcile', user: user3)).to exist + end + + it 'opens user_unlinked_eligible finding when neither found' do + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow(Salesforce::Records::Contact).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([]) + allow(Salesforce::Records::Lead).to receive(:where).with(accounts_uuid: [user3.uuid]).and_return([]) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(SalesforceDriftFinding.where(user: user3, category: 'user_unlinked_eligible')).to exist + end + end + + # --- Finalize --- + + describe 'finalize_findings' do + it 'closes open findings not refreshed during this run' do + stale = FactoryBot.create(:salesforce_drift_finding, + category: 'sf_orphan_contact', last_seen_at: 2.days.ago, resolved_at: nil) + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + described_class.call + expect(stale.reload.resolved_at).not_to be_nil + end + + it 'fires drift_findings_total_open alert when threshold exceeded' do + Settings::Db.store.salesforce_alert_drift_open_total = 0 + # Create with last_seen_at in the future so finalize doesn't auto-close it + FactoryBot.create(:salesforce_drift_finding, resolved_at: nil, last_seen_at: 1.hour.from_now) + allow_any_instance_of(described_class).to receive(:run_pass_1) + allow_any_instance_of(described_class).to receive(:run_pass_2) + allow_any_instance_of(described_class).to receive(:run_pass_3) + allow_any_instance_of(described_class).to receive(:sweep_sf_orphans) + expect(Sentry).to receive(:capture_message).with(/drift_findings_total_open/, anything) + described_class.call + ensure + Settings::Db.store.salesforce_alert_drift_open_total = 100 + end + end +end From b3cba308f1217584e34afbc1e2eca6fbeaab917d Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 01:10:11 -0500 Subject: [PATCH 26/34] salesforce: schedule Reconcile daily, delete dead UpdateUserLeadInfo - Adds Salesforce::Reconcile.call to lib/tasks/cron/day.rake, alongside the existing UpdateSalesforceAssignableFields call. - Removes app/routines/update_user_lead_info.rb. It was unscheduled (not in any cron task), and its only reference was a comment in create_leads_for_instructors_not_sent_to_sf.rake which I've now pointed at Reconcile instead. After this commit the new sync architecture is fully wired: - Every 30 min: SyncSchools, SyncContacts (via shims) - Nightly: Salesforce::Reconcile + the existing daily jobs Co-Authored-By: Claude Opus 4.7 (1M context) --- app/routines/update_user_lead_info.rb | 75 ------------------- ..._leads_for_instructors_not_sent_to_sf.rake | 2 +- lib/tasks/cron/day.rake | 3 + 3 files changed, 4 insertions(+), 76 deletions(-) delete mode 100644 app/routines/update_user_lead_info.rb diff --git a/app/routines/update_user_lead_info.rb b/app/routines/update_user_lead_info.rb deleted file mode 100644 index 21c407733..000000000 --- a/app/routines/update_user_lead_info.rb +++ /dev/null @@ -1,75 +0,0 @@ -class UpdateUserLeadInfo - - def self.call - new.call - end - def call - # TODO: we do want to limit this, but we need to update all the leads and schedule this first - # we are only using this to check users created in the last month - # start_date = Time.zone.now - 1.day - # end_date = Time.zone.now - 30.day - # for the query below when this is re-added - # .where("created_at <= ? AND created_at >= ?", start_date, end_date) - - users = User.where(salesforce_contact_id: nil) - .where.not(salesforce_lead_id: nil, role: :student, faculty_status: :rejected_faculty) - - leads = Salesforce::Records::Lead.select(:id, :accounts_uuid, :verification_status) - .where(accounts_uuid: users.map(&:uuid)) - .to_a - .index_by(&:accounts_uuid) - - users.map do |user| - lead = leads[user.uuid] - - unless lead.nil? - previous_lead_id = user.salesforce_lead_id - user.salesforce_lead_id = lead.id # it might change in SF lead merging - - if lead.id != previous_lead_id - SecurityLog.create!( - user: user, - event_type: :user_lead_id_updated_from_salesforce, - event_data: { previous_lead_id: previous_lead_id, new_lead_id: lead.id } - ) - end - - old_fv_status = user.faculty_status - user.faculty_status = case lead.verification_status - when "confirmed_faculty" - :confirmed_faculty - when "pending_faculty" - :pending_faculty - when "rejected_faculty" - :rejected_faculty - when "rejected_by_sheerid" - :rejected_by_sheerid - when "incomplete_signup" - :incomplete_signup - when "no_faculty_info" - :no_faculty_info - when NilClass - :no_faculty_info - else - Sentry.capture_message("Unknown faculty_verified field: '#{ - lead.verification_status}'' on lead #{lead.id}") - end - - if user.faculty_status_changed? - SecurityLog.create!( - user: user, - event_type: :salesforce_updated_faculty_status, - event_data: { - user_id: user.id, - salesforce_lead_id: lead.id, - old_status: old_fv_status, - new_status: user.faculty_status - } - ) - end - - user.save if user.changed? - end - end - end -end diff --git a/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake b/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake index 5f3891fb1..74384916f 100644 --- a/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake +++ b/lib/tasks/accounts/create_leads_for_instructors_not_sent_to_sf.rake @@ -21,7 +21,7 @@ namespace :accounts do if lead.nil? Newflow::CreateOrUpdateSalesforceLead.call(user: user) else - # set the lead id, this will update their status in UpdateUserLeadInfo + # set the lead id; Reconcile will verify it and attach a Contact when one exists user.salesforce_lead_id = lead.id user.save end diff --git a/lib/tasks/cron/day.rake b/lib/tasks/cron/day.rake index cf2bf8d4d..a4f74bcc0 100644 --- a/lib/tasks/cron/day.rake +++ b/lib/tasks/cron/day.rake @@ -8,6 +8,9 @@ namespace :cron do Rails.logger.info 'UpdateSalesforceAssignableFields.call' OpenStax::RescueFrom.this { UpdateSalesforceAssignableFields.call } + Rails.logger.info 'Salesforce::Reconcile.call' + OpenStax::RescueFrom.this { Salesforce::Reconcile.call } + Rails.logger.debug 'Finished daily cron' end end From b7251bbb0d3e267aee0356ee04c5e4116177e61c Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 01:19:15 -0500 Subject: [PATCH 27/34] salesforce: /admin/salesforce_drift_findings (list + mark resolved) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin surface for the salesforce_drift_findings table: - /admin/salesforce_drift_findings lists open findings sorted by last_seen_at desc, with filter by category and (when linked from the user admin page) by user_id. - Each row links to the user admin and to the Salesforce record URL (built from Rails.application.secrets.salesforce.instance_url when configured). - "Mark resolved" button sets resolved_at; the next nightly Reconcile will reopen it if the underlying drift is still present. No bulk actions, no SF mutations — admins fix in SF, Reconcile picks it up next night. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../salesforce_drift_findings_controller.rb | 17 ++++++ .../salesforce_drift_findings/index.html.erb | 60 +++++++++++++++++++ config/routes.rb | 2 + ...lesforce_drift_findings_controller_spec.rb | 53 ++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 app/controllers/admin/salesforce_drift_findings_controller.rb create mode 100644 app/views/admin/salesforce_drift_findings/index.html.erb create mode 100644 spec/controllers/admin/salesforce_drift_findings_controller_spec.rb diff --git a/app/controllers/admin/salesforce_drift_findings_controller.rb b/app/controllers/admin/salesforce_drift_findings_controller.rb new file mode 100644 index 000000000..59a4ff87f --- /dev/null +++ b/app/controllers/admin/salesforce_drift_findings_controller.rb @@ -0,0 +1,17 @@ +module Admin + class SalesforceDriftFindingsController < BaseController + def index + scope = SalesforceDriftFinding.open.includes(:user).order(last_seen_at: :desc) + scope = scope.where(category: params[:category]) if params[:category].present? + scope = scope.where(user_id: params[:user_id]) if params[:user_id].present? + @findings = scope.limit(500) + @categories = SalesforceDriftFinding.open.distinct.pluck(:category).sort + end + + def update + finding = SalesforceDriftFinding.find(params[:id]) + finding.resolve! + redirect_to admin_salesforce_drift_findings_path, notice: 'Finding marked resolved.' + end + end +end diff --git a/app/views/admin/salesforce_drift_findings/index.html.erb b/app/views/admin/salesforce_drift_findings/index.html.erb new file mode 100644 index 000000000..b49ae7da3 --- /dev/null +++ b/app/views/admin/salesforce_drift_findings/index.html.erb @@ -0,0 +1,60 @@ +

Salesforce drift findings

+ +<%= form_with url: admin_salesforce_drift_findings_path, method: :get, local: true do %> + + <%= submit_tag 'Filter', name: nil %> +<% end %> + + + + + + + + + + + + + + + <% @findings.each do |f| %> + + + + + + + + + + <% end %> + +
CategoryUserSF RecordFirst seenLast seenDetails
<%= f.category %> + <% if f.user_id %> + <%= link_to f.user_id, edit_admin_user_path(f.user_id) %> + <% else %> + — + <% end %> + + <% if f.salesforce_record_id.present? %> + <% sf_host = Rails.application.secrets.dig(:salesforce, :instance_url) %> + <% if sf_host.present? %> + <%= link_to "#{f.salesforce_record_type}/#{f.salesforce_record_id}", + "#{sf_host}/#{f.salesforce_record_id}", target: '_blank', rel: 'noopener' %> + <% else %> + <%= "#{f.salesforce_record_type}/#{f.salesforce_record_id}" %> + <% end %> + <% else %> + — + <% end %> + <%= f.first_seen_at.strftime('%Y-%m-%d %H:%M') %><%= f.last_seen_at.strftime('%Y-%m-%d %H:%M') %>
<%= JSON.pretty_generate(f.details) rescue f.details.inspect %>
+ <%= button_to 'Mark resolved', admin_salesforce_drift_finding_path(f), + method: :patch, data: { confirm: 'Mark resolved?' } %> +
+ +

<%= pluralize(@findings.size, 'open finding') %> shown (max 500).

diff --git a/config/routes.rb b/config/routes.rb index 9e8ab3823..9066195a7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -291,6 +291,8 @@ resource :security_log, only: [:show] + resources :salesforce_drift_findings, only: [:index, :update] + delete :delete_contact_info, path: '/contact_infos/:id/verify', controller: :contact_infos, action: :destroy post :verify_contact_info, path: '/contact_infos/:id/verify', diff --git a/spec/controllers/admin/salesforce_drift_findings_controller_spec.rb b/spec/controllers/admin/salesforce_drift_findings_controller_spec.rb new file mode 100644 index 000000000..879b22961 --- /dev/null +++ b/spec/controllers/admin/salesforce_drift_findings_controller_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +module Admin + describe SalesforceDriftFindingsController, type: :controller do + let(:admin) { FactoryBot.create :user, :admin, :terms_agreed } + + before { controller.sign_in! admin } + + describe 'GET #index' do + let!(:finding) { FactoryBot.create(:salesforce_drift_finding) } + + it 'lists open findings' do + get :index + expect(response).to have_http_status(:ok) + expect(assigns(:findings)).to include(finding) + end + + it 'filters by category' do + FactoryBot.create(:salesforce_drift_finding, category: 'sf_orphan_lead') + get :index, params: { category: 'sf_orphan_lead' } + expect(assigns(:findings).map(&:category)).to all(eq('sf_orphan_lead')) + end + + it 'filters by user_id' do + user = FactoryBot.create(:user) + mine = FactoryBot.create(:salesforce_drift_finding, user: user) + get :index, params: { user_id: user.id } + expect(assigns(:findings)).to contain_exactly(mine) + end + + it 'excludes resolved findings' do + resolved = FactoryBot.create(:salesforce_drift_finding, resolved_at: 1.day.ago) + get :index + expect(assigns(:findings)).not_to include(resolved) + end + end + + describe 'PATCH #update' do + let!(:finding) { FactoryBot.create(:salesforce_drift_finding) } + + it 'marks the finding resolved' do + patch :update, params: { id: finding.id } + expect(finding.reload.resolved_at).not_to be_nil + end + + it 'redirects to the index with a notice' do + patch :update, params: { id: finding.id } + expect(response).to redirect_to(admin_salesforce_drift_findings_path) + expect(flash[:notice]).to match(/resolved/i) + end + end + end +end From 96c16cd0e2ec92800a3e1930512164aae40e8530 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 01:20:37 -0500 Subject: [PATCH 28/34] salesforce: Salesforce timeline section on admin user edit page When admins open /admin/users/:id/edit they now see a Salesforce timeline section listing every SecurityLog entry with event_type LIKE 'salesforce_%' for that user, oldest first (so the order matches the actual chronology of what happened). 500-row cap to keep the page bounded. Also adds a link to /admin/salesforce_drift_findings?user_id=:id so anyone investigating a single user can jump directly to that user's open drift findings. This is the per-user audit trail surface called out in the design spec ("reconstruct the SF state of one user from logs"). Existing 3-example users_controller_spec still passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/admin/users_controller.rb | 8 +++++ .../admin/users/_salesforce_timeline.html.erb | 29 +++++++++++++++++++ app/views/admin/users/edit.html.erb | 2 ++ 3 files changed, 39 insertions(+) create mode 100644 app/views/admin/users/_salesforce_timeline.html.erb diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index bd57376b9..c8f58c091 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -12,6 +12,14 @@ def js_search def index; end + def edit + @salesforce_timeline = SecurityLog + .where(user_id: @user.id) + .where("event_type LIKE 'salesforce_%'") + .order(created_at: :asc) + .limit(500) + end + # Used by full console page def search security_log :users_searched_by_admin, search: params[:search] diff --git a/app/views/admin/users/_salesforce_timeline.html.erb b/app/views/admin/users/_salesforce_timeline.html.erb new file mode 100644 index 000000000..bcee0bd3c --- /dev/null +++ b/app/views/admin/users/_salesforce_timeline.html.erb @@ -0,0 +1,29 @@ +
+

Salesforce timeline

+ <% if @salesforce_timeline.nil? || @salesforce_timeline.empty? %> +

No Salesforce events recorded for this user.

+ <% else %> + + + + + + + + + + <% @salesforce_timeline.each do |event| %> + + + + + + <% end %> + +
WhenEventData
<%= event.created_at.strftime('%Y-%m-%d %H:%M:%S') %><%= event.event_type %>
<%= JSON.pretty_generate(event.event_data) rescue event.event_data.inspect %>
+

+ <%= link_to 'Open drift findings for this user', + admin_salesforce_drift_findings_path(user_id: @user.id) %> +

+ <% end %> +
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb index a8fe71b72..0f8c43ce7 100644 --- a/app/views/admin/users/edit.html.erb +++ b/app/views/admin/users/edit.html.erb @@ -4,3 +4,5 @@ <% @page_header = 'User Details' %> <%= render 'form' %> + +<%= render 'salesforce_timeline' %> From 7ef3cecf2c817463d643a7f40e31c5cc7508ff63 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 01:21:40 -0500 Subject: [PATCH 29/34] salesforce: spec helpers absorb gem helpers + add records-stub Adds two helpers to the existing SalesforceSpecHelpers module: - stub_salesforce_records!: replaces ActiveForce.sfdc_client with an in-memory no-op so unit specs never accidentally talk to the SF sandbox. - limit_salesforce_queries / limit_salesforce_queries_by_token: absorbed from the openstax_salesforce gem's spec helpers; useful in sandbox-backed VCR specs where you want to ignore unrelated rows. Also adds Sentry.capture_exception to stub_sentry so the new routines' rescue StandardError => e; Sentry.capture_exception(e) paths don't trip on a missing stub. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/support/salesforce_spec_helpers.rb | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/spec/support/salesforce_spec_helpers.rb b/spec/support/salesforce_spec_helpers.rb index 2b79c7c9a..e5323c65a 100644 --- a/spec/support/salesforce_spec_helpers.rb +++ b/spec/support/salesforce_spec_helpers.rb @@ -34,6 +34,54 @@ def stub_salesforce_contacts(contacts) def stub_sentry allow(Sentry).to receive(:capture_check_in).and_return('check_in_id') allow(Sentry).to receive(:capture_message) + allow(Sentry).to receive(:capture_exception) + end + + # Replace ActiveForce's sfdc_client with an in-memory no-op so unit tests + # never accidentally talk to the real Salesforce sandbox. Pair with + # `restore_salesforce_records!` if your spec needs to revert. + def stub_salesforce_records! + test_client = Class.new do + def query(*) ; [] ; end + def create(*) ; nil; end + def update(*) ; nil; end + def upsert(*) ; nil; end + def find(*) ; nil; end + def authenticate! ; true; end + end.new + ActiveForce.sfdc_client = test_client + end + + def restore_salesforce_records! + ActiveForce.clear_sfdc_client! + end + + # Absorbed from the openstax_salesforce gem's SpecHelpers. Constrains an + # ActiveForce query to records matching the given conditions (LIKE matches + # for any string value containing '%'). Useful in sandbox-backed VCR specs + # where you want to ignore unrelated rows. + def limit_salesforce_queries(remote_class, **conditions) + allow(remote_class).to receive(:query) do + like_conditions = {} + other_conditions = {} + conditions.each_pair do |key, value| + if value.is_a?(String) && value.include?('%') + like_conditions[key] = value + else + other_conditions[key] = value + end + end + remote_class.original_query.where(other_conditions) + end + end + + def limit_salesforce_queries_by_token(remote_class, token) + case remote_class.new + when Salesforce::Records::Contact, Salesforce::Records::Lead + limit_salesforce_queries(remote_class, last_name: "%#{token}") + else + raise "Don't know how to apply to #{remote_class}" + end end end From 6aed4ec8415ad66e10008932dd41fbb9cc0ff422 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 11:10:30 -0500 Subject: [PATCH 30/34] salesforce: fix CI regressions surfaced after Phase 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fixes for spec failures the first full CI run surfaced: 1. Admin user edit page 500'd. SecurityLog#event_type is an integer-backed enum, so `where("event_type LIKE 'salesforce_%'")` raised "operator does not exist: integer ~~ unknown" from PostgreSQL. Switched to translating the prefix to integer enum values and filtering by IN. (Caught independently by Copilot's PR review.) 2. spec/whenever_spec.rb stubbed `expect_any_instance_of(UpdateUserContactInfo).to receive(:call)` but the shim exposes .call as a class method now. Switched to expect(UpdateUserContactInfo).to receive(:call). 3. spec/support/salesforce_spec_helpers.rb's limit_salesforce_queries helper called remote_class.original_query (which the openstax_salesforce gem aliased on SObject but our absorption didn't carry over) and never actually applied its like_conditions. Removed both helpers since nothing in the app calls them. (Also Copilot.) 4. SalesforceProxy was nested inside the now-gone OpenStax::Salesforce::SpecHelpers module that was included at the top of spec/rails_helper.rb. A handful of feature/handler specs (verify_email_by_code, change_salesforce_contact_manually, newflow/student_signup_flow) depended on SalesforceProxy being a top-level constant. Ported a minimal SalesforceProxy into spec/support/ — only the methods this app actually uses (new, setup_cassette, new_contact, new_lead, ensure_schools_exist, school helpers). Skip the gem's Book/Campaign/ CampaignMember helpers since nothing references them. PLACEHOLDER_CREDENTIALS let Salesforce::Client#initialize's validate! pass in CI without real SF env vars; the actual auth POST is intercepted by VCR cassettes. 5. Adding `master_record_id` and `is_deleted` to Salesforce::Records::Contact changed the SOQL SELECT clause, so the three change_salesforce_contact_manually cassettes had URIs that no longer matched. Patched the URIs to include the two new fields. The recorded response bodies are unchanged. 6. Salesforce.configure in the initializer triggered Zeitwerk's "autoloaded during initialization" deprecation — Rails then unloaded the Salesforce constant and our config along with it, so the api_version, login_domain, etc. fell back to the Configuration class defaults at runtime. Wrapped the configure block in `Rails.application.reloader.to_prepare` per the deprecation warning's recommendation. While I was there, switched the default api_version from 61.0 to 51.0 to match the version VCR cassettes were recorded against (the old openstax_salesforce initializer also defaulted to 51.0; the 61.0 default I'd put in was a regression). Salesforce spec sweep: 229 examples, 0 failures. CI-failing specs (whenever, admin users, change_salesforce_contact, verify_email_by_code, newflow/student_signup_flow): 21 examples, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/admin/users_controller.rb | 8 +- config/initializers/salesforce.rb | 26 ++-- ...can_be_set_if_the_Contact_exists_in_SF.yml | 2 +- ...et_if_the_Contact_does_not_exist_in_SF.yml | 2 +- .../cannot_be_set_if_the_ID_is_malformed.yml | 2 +- spec/services/salesforce/client_spec.rb | 2 + spec/support/salesforce_proxy.rb | 129 ++++++++++++++++++ spec/support/salesforce_spec_helpers.rb | 27 ---- spec/whenever_spec.rb | 6 +- 9 files changed, 161 insertions(+), 43 deletions(-) create mode 100644 spec/support/salesforce_proxy.rb diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index c8f58c091..efa3e6e29 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -13,9 +13,13 @@ def js_search def index; end def edit + # SecurityLog#event_type is an integer-backed Rails enum, so we can't LIKE on it. + # Resolve the salesforce_* event_type *values* once and filter by IN. + salesforce_event_values = SecurityLog.event_types + .select { |name, _| name.to_s.start_with?('salesforce_') } + .values @salesforce_timeline = SecurityLog - .where(user_id: @user.id) - .where("event_type LIKE 'salesforce_%'") + .where(user_id: @user.id, event_type: salesforce_event_values) .order(created_at: :asc) .limit(500) end diff --git a/config/initializers/salesforce.rb b/config/initializers/salesforce.rb index a84a436e6..fdbe512e8 100644 --- a/config/initializers/salesforce.rb +++ b/config/initializers/salesforce.rb @@ -1,10 +1,18 @@ -Salesforce.configure do |config| - secrets = Rails.application.secrets.salesforce - config.username = secrets[:username] - config.password = secrets[:password] - config.security_token = secrets[:security_token] - config.consumer_key = secrets[:consumer_key] - config.consumer_secret = secrets[:consumer_secret] - config.api_version = secrets.fetch(:api_version, '61.0') - config.login_domain = secrets.fetch(:login_domain, 'test.salesforce.com') +# Wrap in to_prepare so Zeitwerk doesn't unload Salesforce (and our config +# with it) after initialization. Without this we hit the Rails autoload- +# during-init deprecation warning and the configured api_version etc. are +# lost on first reload, falling back to the Configuration class defaults. +Rails.application.reloader.to_prepare do + Salesforce.configure do |config| + secrets = Rails.application.secrets.salesforce + config.username = secrets[:username] + config.password = secrets[:password] + config.security_token = secrets[:security_token] + config.consumer_key = secrets[:consumer_key] + config.consumer_secret = secrets[:consumer_secret] + # Matches the version VCR cassettes were recorded against. Bumping requires + # re-recording cassettes under spec/cassettes/. + config.api_version = secrets.fetch(:api_version, '51.0') + config.login_domain = secrets.fetch(:login_domain, 'test.salesforce.com') + end end diff --git a/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml b/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml index 761ae1c88..3477a47fc 100644 --- a/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml +++ b/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml @@ -186,7 +186,7 @@ http_interactions: recorded_at: Tue, 08 Oct 2024 15:05:08 GMT - request: method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c%20FROM%20Contact%20WHERE%20(Id%20=%20%27003Pc00000NsWSHIA3%27)%20LIMIT%201" + uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c,%20MasterRecordId,%20IsDeleted%20FROM%20Contact%20WHERE%20(Id%20=%20%27003Pc00000NsWSHIA3%27)%20LIMIT%201" body: encoding: US-ASCII string: '' diff --git a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml b/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml index ef863691d..27edeeb62 100644 --- a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml +++ b/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml @@ -2,7 +2,7 @@ http_interactions: - request: method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c%20FROM%20Contact%20WHERE%20(Id%20=%20%270010v000002Wo0qAAC%27)%20LIMIT%201" + uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c,%20MasterRecordId,%20IsDeleted%20FROM%20Contact%20WHERE%20(Id%20=%20%270010v000002Wo0qAAC%27)%20LIMIT%201" body: encoding: US-ASCII string: '' diff --git a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml b/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml index d606ede9e..229f38582 100644 --- a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml +++ b/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml @@ -2,7 +2,7 @@ http_interactions: - request: method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c%20FROM%20Contact%20WHERE%20(Id%20=%20%27somethingwonky%27)%20LIMIT%201" + uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c,%20MasterRecordId,%20IsDeleted%20FROM%20Contact%20WHERE%20(Id%20=%20%27somethingwonky%27)%20LIMIT%201" body: encoding: US-ASCII string: '' diff --git a/spec/services/salesforce/client_spec.rb b/spec/services/salesforce/client_spec.rb index b8dff7ea3..497bfa814 100644 --- a/spec/services/salesforce/client_spec.rb +++ b/spec/services/salesforce/client_spec.rb @@ -8,6 +8,8 @@ c.security_token = 't' c.consumer_key = 'ck' c.consumer_secret = 'cs' + c.api_version = '61.0' + c.login_domain = 'test.salesforce.com' end end diff --git a/spec/support/salesforce_proxy.rb b/spec/support/salesforce_proxy.rb new file mode 100644 index 000000000..bbae97a92 --- /dev/null +++ b/spec/support/salesforce_proxy.rb @@ -0,0 +1,129 @@ +require 'active_force' + +# SalesforceProxy — minimal port of the helper class that used to live in the +# openstax_salesforce gem (lib/openstax/salesforce/spec_helpers.rb). +# +# A handful of feature/handler specs do `@proxy = SalesforceProxy.new; @proxy.setup_cassette` +# before exercising a flow that hits Salesforce via VCR cassettes. They only +# use the constructor (which clears the cached SF client so cassette playback +# always re-authenticates) and #setup_cassette (which registers VCR +# placeholders for the dynamic auth values in the cassettes). +# +# We don't port the new_contact / new_lead / new_campaign / book / school +# helpers — nothing in this app calls them, and they'd just pull in records +# we don't use (Book, Campaign, CampaignMember). +class SalesforceProxy + # Dummy credentials so Salesforce::Client.new#validate! passes in test/CI + # where real SF env vars aren't set. The actual OAuth POST is intercepted + # by VCR and replayed from a cassette — these values never go over the wire. + PLACEHOLDER_CREDENTIALS = { + username: 'test@example.com', + password: 'placeholder', + security_token: 'placeholder', + consumer_key: 'placeholder', + consumer_secret: 'placeholder' + }.freeze + + def initialize + # Touch Records::Base to autoload the lazy-init patch. + Salesforce::Records::Base + # Populate any missing config values so Client#initialize's validate! + # doesn't raise before VCR can intercept the auth call. + Salesforce.configure do |c| + c.username ||= PLACEHOLDER_CREDENTIALS[:username] + c.password ||= PLACEHOLDER_CREDENTIALS[:password] + c.security_token ||= PLACEHOLDER_CREDENTIALS[:security_token] + c.consumer_key ||= PLACEHOLDER_CREDENTIALS[:consumer_key] + c.consumer_secret ||= PLACEHOLDER_CREDENTIALS[:consumer_secret] + end + # Ensure cassette playback always gets a fresh token. + ::ActiveForce.sfdc_client = nil + end + + # Used to filter test records when running against a shared sandbox. + def reset_unique_token(token = SecureRandom.hex(10)) + @unique_token = token + end + + def clear_unique_token + @unique_token = nil + end + + # Create a Salesforce Contact for use in a cassette-recording session. + # Most specs play back cassettes — this is only invoked when re-recording. + def new_contact(first_name: nil, last_name: nil, school_name: 'RSpec University', + email: nil, faculty_verified: nil) + ensure_schools_exist([school_name]) + Salesforce::Records::Contact.new( + first_name: first_name || Faker::Name.first_name, + last_name: last_name!(last_name), + school_id: school_id(school_name), + email: email || Faker::Internet.email, + faculty_verified: faculty_verified + ).tap do |contact| + raise "Could not save SF contact: #{contact.errors.full_messages}" unless contact.save + end + end + + def new_lead(email:, status: nil, last_name: nil, source: nil) + Salesforce::Records::Lead.new( + email: email, + status: status, + last_name: last_name!(last_name), + school: 'RSpec University', + source: source + ).tap do |lead| + raise "Could not save SF lead: #{lead.errors.full_messages}" unless lead.save + end + end + + def ensure_schools_exist(school_names) + @schools = Salesforce::Records::School.where(name: school_names).to_a + (school_names - @schools.map(&:name)).each do |name| + Salesforce::Records::School.new(name: name).save! + end + end + + def schools + @schools ||= Salesforce::Records::School.all + end + + def school(name) + schools.find { |s| s.name == name } + end + + def school_id(name) + school(name)&.id + end + + def last_name!(input) + "#{input || Faker::Name.last_name}#{@unique_token if @unique_token.present?}" + end + + def setup_cassette + VCR.configure do |config| + # Default placeholders so cassette-less paths don't blow up. + config.define_cassette_placeholder('') do + 'https://example.salesforce.com' + end + config.define_cassette_placeholder('') do + 'https://example.salesforce.com' + end + + # Once we actually authenticate, swap the placeholders to real values + # so VCR can scrub them out of recorded cassettes. + begin + authentication = ::ActiveForce.sfdc_client.authenticate! + config.define_cassette_placeholder('') { authentication.instance_url } + config.define_cassette_placeholder('') { authentication.instance_url.downcase } + config.define_cassette_placeholder('') { authentication.id } + config.define_cassette_placeholder('') { authentication.access_token } + config.define_cassette_placeholder('') { authentication.signature } + rescue StandardError + # In test envs without SF credentials configured, authenticate! will + # fail. Cassette specs that need real placeholders will fail loudly + # later; specs that just want the proxy created can proceed. + end + end + end +end diff --git a/spec/support/salesforce_spec_helpers.rb b/spec/support/salesforce_spec_helpers.rb index e5323c65a..a8053e604 100644 --- a/spec/support/salesforce_spec_helpers.rb +++ b/spec/support/salesforce_spec_helpers.rb @@ -56,33 +56,6 @@ def restore_salesforce_records! ActiveForce.clear_sfdc_client! end - # Absorbed from the openstax_salesforce gem's SpecHelpers. Constrains an - # ActiveForce query to records matching the given conditions (LIKE matches - # for any string value containing '%'). Useful in sandbox-backed VCR specs - # where you want to ignore unrelated rows. - def limit_salesforce_queries(remote_class, **conditions) - allow(remote_class).to receive(:query) do - like_conditions = {} - other_conditions = {} - conditions.each_pair do |key, value| - if value.is_a?(String) && value.include?('%') - like_conditions[key] = value - else - other_conditions[key] = value - end - end - remote_class.original_query.where(other_conditions) - end - end - - def limit_salesforce_queries_by_token(remote_class, token) - case remote_class.new - when Salesforce::Records::Contact, Salesforce::Records::Lead - limit_salesforce_queries(remote_class, last_name: "%#{token}") - else - raise "Don't know how to apply to #{remote_class}" - end - end end RSpec.configure do |config| diff --git a/spec/whenever_spec.rb b/spec/whenever_spec.rb index 85bcd1315..26c1ddaae 100644 --- a/spec/whenever_spec.rb +++ b/spec/whenever_spec.rb @@ -12,8 +12,10 @@ end expect(salesforce_jobs.count).to eq 2 - expect_any_instance_of(UpdateUserContactInfo).to receive(:call) - expect_any_instance_of(UpdateSchoolSalesforceInfo).to receive(:call) + # UpdateUserContactInfo and UpdateSchoolSalesforceInfo became shims + # that expose .call as a class method (delegating to Salesforce::*). + expect(UpdateUserContactInfo).to receive(:call) + expect(UpdateSchoolSalesforceInfo).to receive(:call) # Executes the actual rake tasks to make sure all constants and methods exist: salesforce_jobs.each do |job| From eb43c847d3efb76816d268f5af74945695675540 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 11:22:31 -0500 Subject: [PATCH 31/34] salesforce: ensure_school_or_fallback creates a stub local School when SF fallback isn't cached yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug Copilot caught: if the local schools cache doesn't yet have the "Find Me A Home" SF Account (e.g. brand-new env, or SyncSchools hasn't run recently enough to pick it up), the previous version of ensure_school_or_fallback left @user.school as nil after looking up the SF Account. BuildLead.apply then read user.school&.salesforce_id as nil and the saved Lead landed with no account_id / school_id link to SF — either failing the upsert or creating an unlinked Lead. The original 259-line Newflow::CreateOrUpdateSalesforceLead routine sidestepped this by tracking sf_school_id in a local var and writing it to lead.account_id / lead.school_id directly, independent of user.school. The refactor delegated the field mapping to BuildLead, which reads from user.school — so the user.school association MUST be populated. Fix: when the SF fallback exists but isn't cached locally, create a stub School row with salesforce_id, name, is_kip, is_child_of_kip (the NOT NULL columns). The next SyncSchools run fills in the rest. Added spec for the cache-miss path. Full Salesforce sweep: 244 examples, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce/upsert_lead.rb | 12 ++++++-- spec/services/salesforce/upsert_lead_spec.rb | 29 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/services/salesforce/upsert_lead.rb b/app/services/salesforce/upsert_lead.rb index 2a73ae903..f54fea82e 100644 --- a/app/services/salesforce/upsert_lead.rb +++ b/app/services/salesforce/upsert_lead.rb @@ -58,8 +58,16 @@ def ensure_school_or_fallback unless fallback raise "Salesforce '#{FIND_ME_A_HOME}' school not found — cannot assign fallback school for user #{@user.id}" end - cached = School.find_by(salesforce_id: fallback.id) - @user.school = cached if cached + # If the local schools cache doesn't have the fallback yet, create a + # stub row with the minimum NOT NULL columns. The next SyncSchools run + # will populate the rest. Without this, BuildLead reads + # user.school&.salesforce_id as nil and the saved Lead has no + # account_id / school_id link to the SF School. + @user.school = School.find_or_create_by!(salesforce_id: fallback.id) do |school| + school.name = fallback.name + school.is_kip = fallback.respond_to?(:is_kip) ? !!fallback.is_kip : false + school.is_child_of_kip = fallback.respond_to?(:is_child_of_kip) ? !!fallback.is_child_of_kip : false + end end def existing_contact_owns_user? diff --git a/spec/services/salesforce/upsert_lead_spec.rb b/spec/services/salesforce/upsert_lead_spec.rb index 3483db2dc..07a649cd3 100644 --- a/spec/services/salesforce/upsert_lead_spec.rb +++ b/spec/services/salesforce/upsert_lead_spec.rb @@ -110,4 +110,33 @@ expect(user.reload.salesforce_contact_id).to be_nil end end + + context 'when the fallback SF School is not yet cached locally' do + it 'creates a stub local School so the saved Lead still has account_id / school_id' do + # Use a fresh SF id that has no local School row, so we exercise the + # cache-miss branch without fighting the foreign key on the let!(:home) + # School the outer fixture created. + uncached_sf_id = 'SF_UNCACHED_HOME' + expect(School.where(salesforce_id: uncached_sf_id)).to be_empty + user.update!(school: nil) + + # SF says the fallback exists at the uncached id. + allow(Salesforce::Records::School).to receive(:find_by) + .with({ name: 'Find Me A Home' }) + .and_return(OpenStruct.new(id: uncached_sf_id, name: 'Find Me A Home')) + allow(Salesforce::Lookup).to receive(:lead_for).with(user) + .and_return(Salesforce::Lookup::Result.new(lead: nil, matched_by: nil)) + allow(Salesforce::Records::Lead).to receive(:new).and_return(lead) + allow(lead).to receive(:save).and_return(true) + allow(lead).to receive(:id).and_return('NEW_LEAD') + + expect { + described_class.call(user: user) + }.to change { School.where(salesforce_id: uncached_sf_id).count }.from(0).to(1) + + expect(user.reload.school&.salesforce_id).to eq(uncached_sf_id) + expect(lead.account_id).to eq(uncached_sf_id) + expect(lead.school_id).to eq(uncached_sf_id) + end + end end From c086b3723e0d4de4b7adf8c20025c19ccb901c80 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 11:37:32 -0500 Subject: [PATCH 32/34] salesforce: drop VCR cassettes for Salesforce-touching specs in favor of stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The change_salesforce_contact_manually feature spec kept failing in CI because adding fields to Salesforce::Records::Contact (master_record_id, is_deleted) changes the SOQL SELECT, which makes the recorded cassettes go stale. The fix kept being "patch the cassettes," but cassettes are the wrong tool here: the test is about admin UI behavior, not SF HTTP semantics. Stubs are simpler, faster, and don't rot when we add fields. Changes: - spec/features/admin/change_salesforce_contact_manually_spec.rb: full rewrite. Each example stubs Salesforce::Records::Contact.find with the exact behavior the controller branches on (return record / return nil / raise). Removes the SalesforceProxy boilerplate and VCR setup. - spec/features/newflow/student_signup_flow_spec.rb and spec/handlers/verify_email_by_code_spec.rb: drop the before(:all) { SalesforceProxy.new + setup_cassette } blocks. They never referenced @proxy in any test — purely defensive setup for SF calls that don't actually happen (CreateOrUpdateSalesforceLead is perform_later'd, and the test queue adapter doesn't execute the job). - Delete now-orphan cassettes: spec/cassettes/Change_Salesforce_contact_manually/ spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/ spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml - Delete spec/support/salesforce_proxy.rb — only remaining reference is a commented-out line in sheerid_webhook_spec.rb, harmless. Sweep across Salesforce-touching specs: 234 examples, 0 failures. The three converted specs: 17 examples, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...can_be_set_if_the_Contact_exists_in_SF.yml | 249 ------------------ ...et_if_the_Contact_does_not_exist_in_SF.yml | 63 ----- .../cannot_be_set_if_the_ID_is_malformed.yml | 61 ----- .../sf_setup.yml | 53 ---- .../Students/student_signup_flow/sf_setup.yml | 52 ---- .../sf_setup.yml | 53 ---- .../works_on_the_happy_path.yml | 171 ------------ .../Newflow_VerifyEmailByCode/sf_setup.yml | 54 ---- ...change_salesforce_contact_manually_spec.rb | 33 ++- .../newflow/student_signup_flow_spec.rb | 7 - spec/handlers/verify_email_by_code_spec.rb | 7 - spec/support/salesforce_proxy.rb | 129 --------- 12 files changed, 19 insertions(+), 913 deletions(-) delete mode 100644 spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml delete mode 100644 spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml delete mode 100644 spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml delete mode 100644 spec/cassettes/Change_Salesforce_contact_manually/sf_setup.yml delete mode 100644 spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml delete mode 100644 spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/sf_setup.yml delete mode 100644 spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/works_on_the_happy_path.yml delete mode 100644 spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml delete mode 100644 spec/support/salesforce_proxy.rb diff --git a/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml b/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml deleted file mode 100644 index 3477a47fc..000000000 --- a/spec/cassettes/Change_Salesforce_contact_manually/can_be_set_if_the_Contact_exists_in_SF.yml +++ /dev/null @@ -1,249 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20BillingCity,%20BillingState,%20BillingCountry,%20Type,%20School_Location__c,%20SheerID_School_Name__c,%20K_I_P__c,%20child_of_kip__c,%20Total_School_Enrollment__c,%20Has_Assignable_Contacts__c%20FROM%20Account%20WHERE%20(Name%20IN%20(%27RSpec%20University%27))" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.4 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Tue, 08 Oct 2024 15:05:06 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Set-Cookie: - - BrowserId=tAbwQIWGEe-i5nnxlZkMbg; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 - GMT; Max-Age=31536000; secure - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - X-Robots-Tag: - - none - Vary: - - Accept-Encoding - Sforce-Limit-Info: - - api-usage=73995/305000 - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 1d531721139fbff90848e8fed4ca0720 - X-Request-Id: - - 1d531721139fbff90848e8fed4ca0720 - X-Sfdc-Edge-Cache: - - MISS - body: - encoding: ASCII-8BIT - string: '{"totalSize":0,"done":true,"records":[]}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:06 GMT -- request: - method: post - uri: "/services/data/v51.0/sobjects/Account" - body: - encoding: UTF-8 - string: '{"Name":"RSpec University"}' - headers: - User-Agent: - - Faraday v1.10.4 - Content-Type: - - application/json - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 201 - message: Created - headers: - Date: - - Tue, 08 Oct 2024 15:05:07 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Set-Cookie: - - BrowserId=tDNXG4WGEe-wDwsazMErgw; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 - GMT; Max-Age=31536000; secure - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Location: - - "/services/data/v51.0/sobjects/Account/001Pc00000LuBMUIA3" - Vary: - - Accept-Encoding - X-Robots-Tag: - - none - Sforce-Limit-Info: - - api-usage=73994/305000 - X-Content-Type-Options: - - nosniff - Server: - - sfdcedge - X-Sfdc-Request-Id: - - d667f17af657d93ea9ed7db894cbb01a - X-Request-Id: - - d667f17af657d93ea9ed7db894cbb01a - body: - encoding: ASCII-8BIT - string: '{"id":"001Pc00000LuBMUIA3","success":true,"errors":[]}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:07 GMT -- request: - method: post - uri: "/services/data/v51.0/sobjects/Contact" - body: - encoding: UTF-8 - string: '{"FirstName":"Ricardo","LastName":"Reynolds","Email":"coy_mante@rennerryan.co"}' - headers: - User-Agent: - - Faraday v1.10.4 - Content-Type: - - application/json - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 201 - message: Created - headers: - Date: - - Tue, 08 Oct 2024 15:05:08 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Set-Cookie: - - BrowserId=tNi-loWGEe-y9O39Swt3yg; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:07 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:07 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:07 - GMT; Max-Age=31536000; secure - X-Content-Type-Options: - - nosniff - Vary: - - Accept-Encoding - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - X-Robots-Tag: - - none - Sforce-Limit-Info: - - api-usage=73994/305000 - Location: - - "/services/data/v51.0/sobjects/Contact/003Pc00000NsWSHIA3" - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 489526c71950273d3bb34cf207785209 - X-Request-Id: - - 489526c71950273d3bb34cf207785209 - body: - encoding: ASCII-8BIT - string: '{"id":"003Pc00000NsWSHIA3","success":true,"errors":[]}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:08 GMT -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c,%20MasterRecordId,%20IsDeleted%20FROM%20Contact%20WHERE%20(Id%20=%20%27003Pc00000NsWSHIA3%27)%20LIMIT%201" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.4 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Tue, 08 Oct 2024 15:05:08 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Vary: - - Accept-Encoding - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - X-Robots-Tag: - - none - Sforce-Limit-Info: - - api-usage=73995/305000 - Set-Cookie: - - BrowserId=tYayXIWGEe-ww7eu90yMBg; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:08 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:08 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:08 - GMT; Max-Age=31536000; secure - X-Content-Type-Options: - - nosniff - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 91376a8284ef074935f855444a168604 - X-Request-Id: - - 91376a8284ef074935f855444a168604 - X-Sfdc-Edge-Cache: - - MISS - body: - encoding: ASCII-8BIT - string: '{"totalSize":1,"done":true,"records":[{"attributes":{"type":"Contact","url":"/services/data/v51.0/sobjects/Contact/003Pc00000NsWSHIA3"},"Id":"003Pc00000NsWSHIA3","Name":"Ricardo - Reynolds","FirstName":"Ricardo","LastName":"Reynolds","Email":"coy_mante@rennerryan.co","FV_Status__c":null,"LastModifiedDate":"2024-10-08T15:05:08.000+0000","AccountId":null,"School_Type__c":null,"All_Emails__c":"coy_mante@rennerryan.co","Adoption_Status__c":"Not - Adopter","Accounts_UUID__c":null,"LeadSource":null,"Signup_Date__c":null,"Assignable_Interest__c":null,"Assignable_Adoption_Date__c":null}]}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:08 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml b/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml deleted file mode 100644 index 27edeeb62..000000000 --- a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_Contact_does_not_exist_in_SF.yml +++ /dev/null @@ -1,63 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c,%20MasterRecordId,%20IsDeleted%20FROM%20Contact%20WHERE%20(Id%20=%20%270010v000002Wo0qAAC%27)%20LIMIT%201" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.4 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Tue, 08 Oct 2024 15:05:05 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Robots-Tag: - - none - Sforce-Limit-Info: - - api-usage=73994/305000 - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Vary: - - Accept-Encoding - X-Content-Type-Options: - - nosniff - Set-Cookie: - - BrowserId=s5KqvoWGEe-oQ-O_-Fe-kw; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:05 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:05 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:05 - GMT; Max-Age=31536000; secure - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 0f1add6249c71c3786b963e7dbee805c - X-Request-Id: - - 0f1add6249c71c3786b963e7dbee805c - X-Sfdc-Edge-Cache: - - MISS - body: - encoding: ASCII-8BIT - string: '{"totalSize":0,"done":true,"records":[]}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:05 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml b/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml deleted file mode 100644 index 229f38582..000000000 --- a/spec/cassettes/Change_Salesforce_contact_manually/cannot_be_set_if_the_ID_is_malformed.yml +++ /dev/null @@ -1,61 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Email,%20FV_Status__c,%20LastModifiedDate,%20AccountId,%20School_Type__c,%20All_Emails__c,%20Adoption_Status__c,%20Accounts_UUID__c,%20LeadSource,%20Signup_Date__c,%20Assignable_Interest__c,%20Assignable_Adoption_Date__c,%20MasterRecordId,%20IsDeleted%20FROM%20Contact%20WHERE%20(Id%20=%20%27somethingwonky%27)%20LIMIT%201" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.4 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 400 - message: Bad Request - headers: - Date: - - Tue, 08 Oct 2024 15:05:06 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - X-Content-Type-Options: - - nosniff - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - X-Robots-Tag: - - none - Sforce-Limit-Info: - - api-usage=73994/305000 - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Set-Cookie: - - BrowserId=s9IkRYWGEe-vVKF0vQBeQQ; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Wed, 08-Oct-2025 15:05:06 - GMT; Max-Age=31536000; secure - Server: - - sfdcedge - X-Sfdc-Request-Id: - - b224126dea1d50eea19f4cbe8c6fa855 - X-Request-Id: - - b224126dea1d50eea19f4cbe8c6fa855 - body: - encoding: UTF-8 - string: '[{"message":"\nAssignable_Adoption_Date__c FROM Contact WHERE (Id = - ''somethingwonky'') LIMIT 1\n ^\nERROR - at Row:1:Column:258\ninvalid ID field: somethingwonky","errorCode":"INVALID_QUERY_FILTER_OPERATOR"}]' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:06 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Change_Salesforce_contact_manually/sf_setup.yml b/spec/cassettes/Change_Salesforce_contact_manually/sf_setup.yml deleted file mode 100644 index 21e182a65..000000000 --- a/spec/cassettes/Change_Salesforce_contact_manually/sf_setup.yml +++ /dev/null @@ -1,53 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https:///services/oauth2/token - body: - encoding: US-ASCII - string: grant_type=password&client_id=&client_secret=&username=&password= - headers: - User-Agent: - - Faraday v1.10.4 - Content-Type: - - application/x-www-form-urlencoded - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Tue, 08 Oct 2024 15:05:04 GMT - Set-Cookie: - - BrowserId=suMwfYWGEe-7dIfpWPOQQA; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:05:04 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:05:04 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:05:04 - GMT; Max-Age=31536000; secure - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Expires: - - Thu, 01 Jan 1970 00:00:00 GMT - X-Readonlymode: - - 'false' - Content-Type: - - application/json;charset=UTF-8 - Vary: - - Accept-Encoding - Transfer-Encoding: - - chunked - body: - encoding: ASCII-8BIT - string: '{"access_token":"","instance_url":"","id":"https:///id/00DU0000000KwchMAC/005U0000005akrEIAQ","token_type":"Bearer","issued_at":"1728399904705","signature":""}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:05:04 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml b/spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml deleted file mode 100644 index 484c3538d..000000000 --- a/spec/cassettes/Newflow/Students/student_signup_flow/sf_setup.yml +++ /dev/null @@ -1,52 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https:///services/oauth2/token - body: - encoding: US-ASCII - string: grant_type=password&client_id=&client_secret=&username=&password= - headers: - User-Agent: - - Faraday v1.10.4 - Content-Type: - - application/x-www-form-urlencoded - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Fri, 18 Oct 2024 23:41:46 GMT - Set-Cookie: - - BrowserId=iWXHOY2qEe-om4-Ar5tPbw; domain=.salesforce.com; path=/; expires=Sat, - 18-Oct-2025 23:41:46 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:0; path=/; expires=Sat, 18-Oct-2025 23:41:46 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Sat, 18-Oct-2025 23:41:46 - GMT; Max-Age=31536000; secure - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Expires: - - Thu, 01 Jan 1970 00:00:00 GMT - X-Readonlymode: - - 'false' - Content-Type: - - application/json;charset=UTF-8 - Vary: - - Accept-Encoding - Transfer-Encoding: - - chunked - body: - encoding: ASCII-8BIT - string: '{"access_token":"","instance_url":"","id":"https:///id/00DU0000000KwchMAC/005U0000005akrEIAQ","token_type":"Bearer","issued_at":"1729294906256","signature":""}' - recorded_at: Fri, 18 Oct 2024 23:41:46 GMT -recorded_with: VCR 6.3.1 diff --git a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/sf_setup.yml b/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/sf_setup.yml deleted file mode 100644 index e655045b8..000000000 --- a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/sf_setup.yml +++ /dev/null @@ -1,53 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https:///services/oauth2/token - body: - encoding: US-ASCII - string: grant_type=password&client_id=&client_secret=&username=&password= - headers: - User-Agent: - - Faraday v1.10.4 - Content-Type: - - application/x-www-form-urlencoded - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Tue, 08 Oct 2024 15:04:38 GMT - Set-Cookie: - - BrowserId=o5XNPYWGEe-UUm_L3BTUAA; domain=.salesforce.com; path=/; expires=Wed, - 08-Oct-2025 15:04:38 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:04:38 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Wed, 08-Oct-2025 15:04:38 - GMT; Max-Age=31536000; secure - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Expires: - - Thu, 01 Jan 1970 00:00:00 GMT - X-Readonlymode: - - 'false' - Content-Type: - - application/json;charset=UTF-8 - Vary: - - Accept-Encoding - Transfer-Encoding: - - chunked - body: - encoding: ASCII-8BIT - string: '{"access_token":"","instance_url":"","id":"https:///id/00DU0000000KwchMAC/005U0000005akrEIAQ","token_type":"Bearer","issued_at":"1728399879076","signature":""}' - http_version: - recorded_at: Tue, 08 Oct 2024 15:04:39 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/works_on_the_happy_path.yml b/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/works_on_the_happy_path.yml deleted file mode 100644 index 42f8ba44c..000000000 --- a/spec/cassettes/Newflow_CreateOrUpdateSalesforceLead/works_on_the_happy_path.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20FirstName,%20LastName,%20Salutation,%20Title,%20Subject__c,%20Subject_Interest__c,%20Company,%20City,%20State,%20StateCode,%20Country,%20Phone,%20Website,%20Status,%20Email,%20LeadSource,%20Newsletter__c,%20Newsletter_Opt_In__c,%20Adoption_Status__c,%20AdoptionsJSON__c,%20Number_of_Students__c,%20Accounts_ID__c,%20Accounts_UUID__c,%20Application_Source__c,%20Role__c,%20Position__c,%20who_chooses_books__c,%20FV_Status__c,%20BRI_Marketing__c,%20Title_1_school__c,%20SheerID_School_Name__c,%20Instant_Conversion__c,%20Signup_Date__c,%20Self_Reported_School__c,%20Tracking_Parameters__c,%20Account_ID__c,%20School__c%20FROM%20Lead%20WHERE%20(Email%20=%20NULL)%20LIMIT%201" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.3 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Wed, 04 Sep 2024 16:55:25 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - X-Robots-Tag: - - none - Server: - - sfdcedge - body: - encoding: ASCII-8BIT - string: '{"totalSize":0,"done":true,"records":[]}' - http_version: - recorded_at: Wed, 04 Sep 2024 16:55:25 GMT -- request: - method: get - uri: "/services/data/v51.0/query?q=SELECT%20Id,%20Name,%20BillingCity,%20BillingState,%20BillingCountry,%20Type,%20School_Location__c,%20SheerID_School_Name__c,%20K_I_P__c,%20child_of_kip__c,%20Total_School_Enrollment__c,%20Has_Assignable_Contacts__c%20FROM%20Account%20WHERE%20(Name%20=%20%27Find%20Me%20A%20Home%27)%20LIMIT%201" - body: - encoding: US-ASCII - string: '' - headers: - User-Agent: - - Faraday v1.10.3 - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Wed, 04 Sep 2024 16:55:26 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - Set-Cookie: - - BrowserId=fBTaLmreEe-4jc39-qgTow; domain=.salesforce.com; path=/; expires=Thu, - 04-Sep-2025 16:55:26 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:26 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:26 - GMT; Max-Age=31536000; secure - Vary: - - Accept-Encoding - Sforce-Limit-Info: - - api-usage=37968/5000000 - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - X-Robots-Tag: - - none - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 25f505e7c7c08bdbfdf32fde9d97142b - X-Sfdc-Edge-Cache: - - MISS - body: - encoding: ASCII-8BIT - string: '{"totalSize":1,"done":true,"records":[{"attributes":{"type":"Account","url":"/services/data/v51.0/sobjects/Account/0016f00002iPs9mAAC"},"Id":"0016f00002iPs9mAAC","Name":"Find - Me A Home","BillingCity":"Abu Dhabi","BillingState":null,"BillingCountry":"United - Arab Emirates","Type":"Other","School_Location__c":"Foreign","SheerID_School_Name__c":"Find - Me A Home","K_I_P__c":false,"child_of_kip__c":false,"Total_School_Enrollment__c":null,"20Has_Assignable_Contacts__c":false}]}' - http_version: - recorded_at: Wed, 04 Sep 2024 16:55:26 GMT -- request: - method: post - uri: "/services/data/v51.0/sobjects/Lead" - body: - encoding: UTF-8 - string: '{"FirstName":"Max","LastName":"Liebermann","Subject_Interest__c":"AP - Macro Econ","Company":"Test University","Country":"United States","Phone":"+17133484799","LeadSource":"Account - Creation","Newsletter__c":false,"Newsletter_Opt_In__c":false,"Adoption_Status__c":"Confirmed - Adoption Won","Number_of_Students__c":"35","Accounts_ID__c":1,"Accounts_UUID__c":"86ed61c4-e556-4a05-9c55-5453bbdd9f28","Application_Source__c":"Accounts","Role__c":"Instructor","Position__c":"instructor","who_chooses_books__c":"instructor","FV_Status__c":"pending_faculty","BRI_Marketing__c":false,"Title_1_school__c":false,"Signup_Date__c":"2024-09-04T16:55:26.508+0000","Self_Reported_School__c":"Test - University","Tracking_Parameters__c":"https://dev.openstax.org/accounts/i/signup/","Account_ID__c":"0016f00002iPs9mAAC","School__c":"0016f00002iPs9mAAC"}' - headers: - User-Agent: - - Faraday v1.10.3 - Content-Type: - - application/json - Authorization: - - OAuth - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 201 - message: Created - headers: - Date: - - Wed, 04 Sep 2024 16:55:32 GMT - Content-Type: - - application/json;charset=UTF-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - Location: - - "/services/data/v51.0/sobjects/Lead/00QOx00000aLVDoMAO" - Vary: - - Accept-Encoding - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Sforce-Limit-Info: - - api-usage=37968/5000000 - Set-Cookie: - - BrowserId=fEgf2WreEe-pC3-63Rbixw; domain=.salesforce.com; path=/; expires=Thu, - 04-Sep-2025 16:55:27 GMT; Max-Age=31536000; secure; SameSite=None - - CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:27 GMT; Max-Age=31536000; - secure - - LSKey-c$CookieConsentPolicy=0:1; path=/; expires=Thu, 04-Sep-2025 16:55:27 - GMT; Max-Age=31536000; secure - X-Content-Type-Options: - - nosniff - X-Robots-Tag: - - none - Server: - - sfdcedge - X-Sfdc-Request-Id: - - 195fe1b23acf0aa4438508370eda95c5 - body: - encoding: ASCII-8BIT - string: '{"id":"00QOx00000aLVDoMAO","success":true,"errors":[]}' - http_version: - recorded_at: Wed, 04 Sep 2024 16:55:32 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml b/spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml deleted file mode 100644 index c058ca165..000000000 --- a/spec/cassettes/Newflow_VerifyEmailByCode/sf_setup.yml +++ /dev/null @@ -1,54 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https:///services/oauth2/token - body: - encoding: US-ASCII - string: grant_type=password&client_id=&client_secret=&username=&password= - headers: - User-Agent: - - Faraday v1.0.1 - Content-Type: - - application/x-www-form-urlencoded - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Wed, 15 Nov 2023 16:14:59 GMT - Set-Cookie: - - BrowserId=H-5da4PSEe6uFMnuBFn0Jg; domain=.salesforce.com; path=/; expires=Thu, - 14-Nov-2024 16:14:59 GMT; Max-Age=31536000 - - CookieConsentPolicy=0:0; path=/; expires=Thu, 14-Nov-2024 16:14:59 GMT; Max-Age=31536000 - - LSKey-c$CookieConsentPolicy=0:0; path=/; expires=Thu, 14-Nov-2024 16:14:59 - GMT; Max-Age=31536000 - Strict-Transport-Security: - - max-age=63072000; includeSubDomains - X-Content-Type-Options: - - nosniff - X-Xss-Protection: - - 1; mode=block - Cache-Control: - - no-cache,must-revalidate,max-age=0,no-store,private - Expires: - - Thu, 01 Jan 1970 00:00:00 GMT - X-Readonlymode: - - 'false' - Content-Type: - - application/json;charset=UTF-8 - Vary: - - Accept-Encoding - Transfer-Encoding: - - chunked - body: - encoding: ASCII-8BIT - string: '{"access_token":"","instance_url":"","id":"https:///id/00D040000003rgWEAQ/005U0000005MXdmIAG","token_type":"Bearer","issued_at":"1700064900732","signature":""}' - http_version: - recorded_at: Wed, 15 Nov 2023 16:15:00 GMT -recorded_with: VCR 3.0.3 diff --git a/spec/features/admin/change_salesforce_contact_manually_spec.rb b/spec/features/admin/change_salesforce_contact_manually_spec.rb index 07ea6df8d..c0786b4e2 100644 --- a/spec/features/admin/change_salesforce_contact_manually_spec.rb +++ b/spec/features/admin/change_salesforce_contact_manually_spec.rb @@ -1,14 +1,6 @@ require 'rails_helper' -require 'vcr_helper' - -feature 'Change Salesforce contact manually', js: true, vcr: VCR_OPTS do - before(:all) do - VCR.use_cassette('Change_Salesforce_contact_manually/sf_setup', VCR_OPTS) do - @proxy = SalesforceProxy.new - @proxy.setup_cassette - end - end +feature 'Change Salesforce contact manually', js: true do before(:each) do @admin_user = create_admin_user visit '/' @@ -21,6 +13,7 @@ it 'can be removed' do @target_user.update_attribute(:salesforce_contact_id, 'something') + visit "/admin/users/#{@target_user.id}/edit" fill_in 'user_salesforce_contact_id', with: 'remove' click_button 'Save' expect(page).to have_content('successfully updated') @@ -29,29 +22,41 @@ end it 'can be set if the Contact exists in SF' do - contact = @proxy.new_contact - fill_in 'user_salesforce_contact_id', with: contact.id + contact = Salesforce::Records::Contact.new( + id: '003AB000000XYZ', accounts_uuid: @target_user.uuid, + master_record_id: nil, is_deleted: false + ) + allow(Salesforce::Records::Contact).to receive(:find).with('003AB000000XYZ').and_return(contact) + + fill_in 'user_salesforce_contact_id', with: '003AB000000XYZ' click_button 'Save' expect(page).to have_content('successfully updated') @target_user.reload - expect(@target_user.salesforce_contact_id).to eq contact.id + expect(@target_user.salesforce_contact_id).to eq '003AB000000XYZ' end it 'cannot be set if the Contact does not exist in SF' do @target_user.update_attribute(:salesforce_contact_id, 'original') + allow(Salesforce::Records::Contact).to receive(:find).with('0010v000002Wo0qAAC').and_return(nil) + + visit "/admin/users/#{@target_user.id}/edit" fill_in 'user_salesforce_contact_id', with: '0010v000002Wo0qAAC' click_button 'Save' expect(page).to have_content("Can't find") @target_user.reload - expect(@target_user.salesforce_contact_id).to eq "original" + expect(@target_user.salesforce_contact_id).to eq 'original' end it 'cannot be set if the ID is malformed' do @target_user.update_attribute(:salesforce_contact_id, 'original') + allow(Salesforce::Records::Contact).to receive(:find).with('somethingwonky') + .and_raise(StandardError, 'malformed id') + + visit "/admin/users/#{@target_user.id}/edit" fill_in 'user_salesforce_contact_id', with: 'somethingwonky' click_button 'Save' expect(page).to have_content('Failed') @target_user.reload - expect(@target_user.salesforce_contact_id).to eq "original" + expect(@target_user.salesforce_contact_id).to eq 'original' end end diff --git a/spec/features/newflow/student_signup_flow_spec.rb b/spec/features/newflow/student_signup_flow_spec.rb index 9db7c86f1..423045841 100644 --- a/spec/features/newflow/student_signup_flow_spec.rb +++ b/spec/features/newflow/student_signup_flow_spec.rb @@ -8,13 +8,6 @@ module Newflow turn_on_student_feature_flag end - before(:all) do - VCR.use_cassette('Newflow/Students/student_signup_flow/sf_setup', VCR_OPTS) do - @proxy = SalesforceProxy.new - @proxy.setup_cassette - end - end - let(:email) do Faker::Internet::email end diff --git a/spec/handlers/verify_email_by_code_spec.rb b/spec/handlers/verify_email_by_code_spec.rb index 2c1a4f18e..14040afdf 100644 --- a/spec/handlers/verify_email_by_code_spec.rb +++ b/spec/handlers/verify_email_by_code_spec.rb @@ -8,13 +8,6 @@ let(:email) { FactoryBot.create(:email_address, user: user) } let(:user) { FactoryBot.create(:user, state: User::UNVERIFIED, role: role) } - before(:all) do - VCR.use_cassette('Newflow_VerifyEmailByCode/sf_setup', VCR_OPTS) do - @proxy = SalesforceProxy.new - @proxy.setup_cassette - end - end - context 'when student' do let(:role) { User.roles[:student] } diff --git a/spec/support/salesforce_proxy.rb b/spec/support/salesforce_proxy.rb deleted file mode 100644 index bbae97a92..000000000 --- a/spec/support/salesforce_proxy.rb +++ /dev/null @@ -1,129 +0,0 @@ -require 'active_force' - -# SalesforceProxy — minimal port of the helper class that used to live in the -# openstax_salesforce gem (lib/openstax/salesforce/spec_helpers.rb). -# -# A handful of feature/handler specs do `@proxy = SalesforceProxy.new; @proxy.setup_cassette` -# before exercising a flow that hits Salesforce via VCR cassettes. They only -# use the constructor (which clears the cached SF client so cassette playback -# always re-authenticates) and #setup_cassette (which registers VCR -# placeholders for the dynamic auth values in the cassettes). -# -# We don't port the new_contact / new_lead / new_campaign / book / school -# helpers — nothing in this app calls them, and they'd just pull in records -# we don't use (Book, Campaign, CampaignMember). -class SalesforceProxy - # Dummy credentials so Salesforce::Client.new#validate! passes in test/CI - # where real SF env vars aren't set. The actual OAuth POST is intercepted - # by VCR and replayed from a cassette — these values never go over the wire. - PLACEHOLDER_CREDENTIALS = { - username: 'test@example.com', - password: 'placeholder', - security_token: 'placeholder', - consumer_key: 'placeholder', - consumer_secret: 'placeholder' - }.freeze - - def initialize - # Touch Records::Base to autoload the lazy-init patch. - Salesforce::Records::Base - # Populate any missing config values so Client#initialize's validate! - # doesn't raise before VCR can intercept the auth call. - Salesforce.configure do |c| - c.username ||= PLACEHOLDER_CREDENTIALS[:username] - c.password ||= PLACEHOLDER_CREDENTIALS[:password] - c.security_token ||= PLACEHOLDER_CREDENTIALS[:security_token] - c.consumer_key ||= PLACEHOLDER_CREDENTIALS[:consumer_key] - c.consumer_secret ||= PLACEHOLDER_CREDENTIALS[:consumer_secret] - end - # Ensure cassette playback always gets a fresh token. - ::ActiveForce.sfdc_client = nil - end - - # Used to filter test records when running against a shared sandbox. - def reset_unique_token(token = SecureRandom.hex(10)) - @unique_token = token - end - - def clear_unique_token - @unique_token = nil - end - - # Create a Salesforce Contact for use in a cassette-recording session. - # Most specs play back cassettes — this is only invoked when re-recording. - def new_contact(first_name: nil, last_name: nil, school_name: 'RSpec University', - email: nil, faculty_verified: nil) - ensure_schools_exist([school_name]) - Salesforce::Records::Contact.new( - first_name: first_name || Faker::Name.first_name, - last_name: last_name!(last_name), - school_id: school_id(school_name), - email: email || Faker::Internet.email, - faculty_verified: faculty_verified - ).tap do |contact| - raise "Could not save SF contact: #{contact.errors.full_messages}" unless contact.save - end - end - - def new_lead(email:, status: nil, last_name: nil, source: nil) - Salesforce::Records::Lead.new( - email: email, - status: status, - last_name: last_name!(last_name), - school: 'RSpec University', - source: source - ).tap do |lead| - raise "Could not save SF lead: #{lead.errors.full_messages}" unless lead.save - end - end - - def ensure_schools_exist(school_names) - @schools = Salesforce::Records::School.where(name: school_names).to_a - (school_names - @schools.map(&:name)).each do |name| - Salesforce::Records::School.new(name: name).save! - end - end - - def schools - @schools ||= Salesforce::Records::School.all - end - - def school(name) - schools.find { |s| s.name == name } - end - - def school_id(name) - school(name)&.id - end - - def last_name!(input) - "#{input || Faker::Name.last_name}#{@unique_token if @unique_token.present?}" - end - - def setup_cassette - VCR.configure do |config| - # Default placeholders so cassette-less paths don't blow up. - config.define_cassette_placeholder('') do - 'https://example.salesforce.com' - end - config.define_cassette_placeholder('') do - 'https://example.salesforce.com' - end - - # Once we actually authenticate, swap the placeholders to real values - # so VCR can scrub them out of recorded cassettes. - begin - authentication = ::ActiveForce.sfdc_client.authenticate! - config.define_cassette_placeholder('') { authentication.instance_url } - config.define_cassette_placeholder('') { authentication.instance_url.downcase } - config.define_cassette_placeholder('') { authentication.id } - config.define_cassette_placeholder('') { authentication.access_token } - config.define_cassette_placeholder('') { authentication.signature } - rescue StandardError - # In test envs without SF credentials configured, authenticate! will - # fail. Cassette specs that need real placeholders will fail loudly - # later; specs that just want the proxy created can proceed. - end - end - end -end From c2eb6482b513ed115fa5d6fb0a5ae555cc49cf6b Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Fri, 22 May 2026 11:50:00 -0500 Subject: [PATCH 33/34] salesforce: bump SF API version to v66.0 (Spring '26), drop dead VCR tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The '51.0' default in config/initializers/salesforce.rb existed only to match cassettes we just deleted (commit c086b372). Bumping everywhere the version is defined to v66.0, the current GA Salesforce release (Spring '26, released Feb 2026): - config/initializers/salesforce.rb (was 51.0) - app/services/salesforce.rb Configuration default (was 61.0) - spec/services/salesforce_spec.rb default assertion - spec/services/salesforce/client_spec.rb passthrough test Also drops `require 'vcr_helper'` and `vcr: VCR_OPTS` from the two specs whose only VCR usage was the now-removed SalesforceProxy setup: - spec/features/newflow/student_signup_flow_spec.rb - spec/handlers/verify_email_by_code_spec.rb Neither spec makes external HTTP after the proxy removal — Salesforce calls go through perform_later/the :test queue adapter, which doesn't execute jobs. The VCR tag was harmless but misleading. PR audit for VCR-removal collateral: - upsert_lead_spec.rb's school stubs still work (the user has a school set, so ensure_school_or_fallback early-returns; the new cache-miss case is exercised by its own dedicated test). - No other specs reference SalesforceProxy or the deleted cassettes (sheerid_webhook_spec has a commented-out reference only). - The non-SF VCR specs (SheeridAPI, FetchBookData, SetGdprData, verify_email_by_pin) are untouched and still pass. Full Salesforce-touching sweep: 244 examples, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/salesforce.rb | 2 +- config/initializers/salesforce.rb | 4 +--- spec/features/newflow/student_signup_flow_spec.rb | 3 +-- spec/handlers/verify_email_by_code_spec.rb | 3 +-- spec/services/salesforce/client_spec.rb | 4 ++-- spec/services/salesforce_spec.rb | 2 +- 6 files changed, 7 insertions(+), 11 deletions(-) diff --git a/app/services/salesforce.rb b/app/services/salesforce.rb index b41a2106f..ed80a322e 100644 --- a/app/services/salesforce.rb +++ b/app/services/salesforce.rb @@ -18,7 +18,7 @@ class Configuration attr_accessor :username, :password, :security_token, :consumer_key, :consumer_secret def api_version - @api_version ||= '61.0' + @api_version ||= '66.0' end def login_domain diff --git a/config/initializers/salesforce.rb b/config/initializers/salesforce.rb index fdbe512e8..48e9c8c87 100644 --- a/config/initializers/salesforce.rb +++ b/config/initializers/salesforce.rb @@ -10,9 +10,7 @@ config.security_token = secrets[:security_token] config.consumer_key = secrets[:consumer_key] config.consumer_secret = secrets[:consumer_secret] - # Matches the version VCR cassettes were recorded against. Bumping requires - # re-recording cassettes under spec/cassettes/. - config.api_version = secrets.fetch(:api_version, '51.0') + config.api_version = secrets.fetch(:api_version, '66.0') config.login_domain = secrets.fetch(:login_domain, 'test.salesforce.com') end end diff --git a/spec/features/newflow/student_signup_flow_spec.rb b/spec/features/newflow/student_signup_flow_spec.rb index 423045841..4bbd2f8c1 100644 --- a/spec/features/newflow/student_signup_flow_spec.rb +++ b/spec/features/newflow/student_signup_flow_spec.rb @@ -1,8 +1,7 @@ require 'rails_helper' -require 'vcr_helper' require 'byebug' module Newflow - feature 'Student signup flow', js: true, vcr: VCR_OPTS do + feature 'Student signup flow', js: true do before do load 'db/seeds.rb' turn_on_student_feature_flag diff --git a/spec/handlers/verify_email_by_code_spec.rb b/spec/handlers/verify_email_by_code_spec.rb index 14040afdf..c997c9235 100644 --- a/spec/handlers/verify_email_by_code_spec.rb +++ b/spec/handlers/verify_email_by_code_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' -require 'vcr_helper' -describe VerifyEmailByCode, type: :handler, vcr: VCR_OPTS do +describe VerifyEmailByCode, type: :handler do subject(:handler_call) { described_class.call(params: params) } let(:params) { { code: email.confirmation_code } } diff --git a/spec/services/salesforce/client_spec.rb b/spec/services/salesforce/client_spec.rb index 497bfa814..186bfd842 100644 --- a/spec/services/salesforce/client_spec.rb +++ b/spec/services/salesforce/client_spec.rb @@ -8,7 +8,7 @@ c.security_token = 't' c.consumer_key = 'ck' c.consumer_secret = 'cs' - c.api_version = '61.0' + c.api_version = '66.0' c.login_domain = 'test.salesforce.com' end end @@ -27,7 +27,7 @@ security_token: 't', client_id: 'ck', client_secret: 'cs', - api_version: '61.0', + api_version: '66.0', host: 'test.salesforce.com' ) ).and_call_original diff --git a/spec/services/salesforce_spec.rb b/spec/services/salesforce_spec.rb index a6772ce56..97548e716 100644 --- a/spec/services/salesforce_spec.rb +++ b/spec/services/salesforce_spec.rb @@ -16,7 +16,7 @@ describe Salesforce::Configuration do it 'defaults api_version' do - expect(described_class.new.api_version).to eq('61.0') + expect(described_class.new.api_version).to eq('66.0') end it 'defaults login_domain' do From 6b72179ca7dfd191c86680ea8095d3dcb71fe72d Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Sat, 23 May 2026 01:24:28 -0500 Subject: [PATCH 34/34] js: remove setTimeout race from NewflowUi.enableOnChecked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version of enableOnChecked deferred the initial enable/ disable check by 500ms via setTimeout. That timer ran independently of the click handler that was attached to the checkbox at the same moment, creating a race for feature specs that `check 'agreement_i_agree'` immediately after page load: 1. $(document).ready fires, click handler attaches, setTimeout(enable_disable_continue, 500) queues. 2. Spec calls `check 'agreement_i_agree'` — click handler fires, `checkCheckedButton` sees checkbox checked, enables button. 3. 500ms passes; the queued enable_disable_continue runs. On a slow worker, sometimes this runs AFTER the spec's button assertion saw enabled=true, then disables the button because it re-reads the checkbox state but the spec has already moved on. Worse, on slower CI workers the timer could fire before the click handler attached at all, leaving the button stuck disabled. The legacy `application/ui.js.coffee` version (lines 34-39) already does it the right way: synchronous initial check inside $(document).ready, then attach the click handler. Aligning the newflow version to that pattern. Verified by running spec/features/pose_terms_spec.rb 5 times in a row — passes deterministically; signup-flow specs unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/assets/javascripts/newflow/newflow_ui.js.coffee | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/newflow/newflow_ui.js.coffee b/app/assets/javascripts/newflow/newflow_ui.js.coffee index 9a0b9352c..421b65594 100644 --- a/app/assets/javascripts/newflow/newflow_ui.js.coffee +++ b/app/assets/javascripts/newflow/newflow_ui.js.coffee @@ -31,12 +31,14 @@ NewflowUi = do () -> @disableButton(targetSelector) enableOnChecked: (targetSelector, sourceSelector) -> + # Run synchronously inside $(document).ready: set the initial disabled + # state from the checkbox, then attach the click handler. The previous + # version deferred the initial check by 500ms via setTimeout, which + # raced with feature specs that `check`-ed the box before the timer + # fired (the click handler is attached, but the deferred check then + # disabled the button right after the spec saw it as enabled). $(document).ready => - - enable_disable_continue = () => - this.checkCheckedButton(targetSelector, sourceSelector) - - setTimeout(enable_disable_continue, 500) + this.checkCheckedButton(targetSelector, sourceSelector) $(sourceSelector).on 'click', => this.checkCheckedButton(targetSelector, sourceSelector)