From 524de69ebb92303c7df9d955ec32cf35a00e341b Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 11 Feb 2026 14:21:47 +0000 Subject: [PATCH 01/12] Set up multi-database connection to salesforce_connnect. This commit adds a databse called salesforce_connect which will end up talking to the Heroku app `rpf-heroku-connect` datastore. --- config/database.yml | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/config/database.yml b/config/database.yml index c4ddd41f6..3884d4b8a 100644 --- a/config/database.yml +++ b/config/database.yml @@ -7,14 +7,35 @@ default: &default password: <%= ENV.fetch('POSTGRES_PASSWORD', '') %> pool: <%= ENV.fetch('RAILS_MAX_THREADS', 5) %> +salesforce_connect: &salesforce_connect + adapter: postgresql + encoding: unicode + host: <%= ENV.fetch('SALESFORCE_CONNECT_HOST', 'localhost') %> + username: <%= ENV.fetch('SALESFORCE_CONNECT_USER', '') %> + password: <%= ENV.fetch('SALESFORCE_CONNECT_PASSWORD', '') %> + pool: <%= ENV.fetch('RAILS_MAX_THREADS', 5) %> + database_tasks: false + development: - <<: *default - database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_development') %> + default: + <<: *default + database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_development') %> + salesforce_connect: + <<: *salesforce_connect + database: <%= ENV.fetch('SALESFORCE_CONNECT_DB', 'salesforce_development') %> test: - <<: *default - database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_test') %> + default: + <<: *default + database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_test') %> + salesforce_connect: + <<: *salesforce_connect + database: <%= ENV.fetch('SALESFORCE_CONNECT_DB', 'salesforce_development') %> production: - <<: *default - url: <%= ENV['DATABASE_URL'] %> + default: + <<: *default + url: <%= ENV['DATABASE_URL'] %> + salesforce_connect: + <<: *salesforce_connect + url: <%= ENV.fetch('SALESFORCE_CONNECT_URL', "") %> From 31ad66111d280bc58cea649b82fa2720dc5e07a7 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 11 Feb 2026 14:24:49 +0000 Subject: [PATCH 02/12] Create Salesforce sync model and jobs This commit creates a Salesforce module, a Salesforce::School object which inherits from a Saleforce::Base class. It also introduces a Salesforce::SyncJob base class of background job for writing to Salesforce and a specific Salesforce::SchoolSyncJob to sync a School record with Salesforce. --- app/jobs/salesforce/salesforce_sync_job.rb | 33 ++++++++++++++++++++++ app/jobs/salesforce/school_sync_job.rb | 21 ++++++++++++++ app/models/salesforce/base.rb | 9 ++++++ app/models/salesforce/school.rb | 9 ++++++ 4 files changed, 72 insertions(+) create mode 100644 app/jobs/salesforce/salesforce_sync_job.rb create mode 100644 app/jobs/salesforce/school_sync_job.rb create mode 100644 app/models/salesforce/base.rb create mode 100644 app/models/salesforce/school.rb diff --git a/app/jobs/salesforce/salesforce_sync_job.rb b/app/jobs/salesforce/salesforce_sync_job.rb new file mode 100644 index 000000000..acf3d6cc3 --- /dev/null +++ b/app/jobs/salesforce/salesforce_sync_job.rb @@ -0,0 +1,33 @@ +# # frozen_string_literal: true + +module Salesforce + class SalesforceSyncJob < ApplicationJob + include GoodJob::ActiveJobExtensions::Concurrency + + good_job_control_concurrency_with( + perform_throttle: [2, 1.second] + ) + + SalesforceRecordNotFound = Class.new(StandardError) + SkipBecauseSalesforceIsDisabled = Class.new(StandardError) + + include ActionView::Helpers::SanitizeHelper + + queue_as :salesforce_sync + + discard_on SkipBecauseSalesforceIsDisabled + + before_perform do |_job| + unless ENV.fetch('SALESFORCE_ENABLED', 'true') == 'true' + raise SkipBecauseSalesforceIsDisabled, 'SALESFORCE_ENABLED is not true.' + end + end + + def perform(*) + raise NotImplementedError, 'Subclasses must implement perform' + end + + # TODO Consider implementing private utilities here, e.g. truncate_value + end +end + diff --git a/app/jobs/salesforce/school_sync_job.rb b/app/jobs/salesforce/school_sync_job.rb new file mode 100644 index 000000000..9326b780e --- /dev/null +++ b/app/jobs/salesforce/school_sync_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Salesforce + class SchoolSyncJob < SalesforceSyncJob + MODEL_CLASS = Salesforce::School + + FIELD_MAPPINGS = {}.freeze + + STATUS_MAPPINGS = {}.freeze + + + def perform(school_id:) + @school = School.find(id: school_id) + sf_school = Salesforce::School.find_or_initialize_by(school_id__c: school_id) + + # Make the sf_school match @school. + + sf_school.save! + end + end +end diff --git a/app/models/salesforce/base.rb b/app/models/salesforce/base.rb new file mode 100644 index 000000000..4229edba7 --- /dev/null +++ b/app/models/salesforce/base.rb @@ -0,0 +1,9 @@ +# # frozen_string_literal: true + +module Salesforce + class Base < ApplicationRecord + self.abstract_class = true + + connects_to database: { writing: :salesforce_connect } + end +end diff --git a/app/models/salesforce/school.rb b/app/models/salesforce/school.rb new file mode 100644 index 000000000..db63167fc --- /dev/null +++ b/app/models/salesforce/school.rb @@ -0,0 +1,9 @@ +# # frozen_string_literal: true + +module Salesforce + class School < Salesforce::Base + self.table_name = 'salesforce.school__c' # TODO Confirm this - placeholder + self.primary_key = :school_id__c + + end +end From cc6fe3fe2356b1a74000f6f986d97a1f241b1732 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 11 Feb 2026 14:27:15 +0000 Subject: [PATCH 03/12] Adapt School to sync to salesfoce after :create and :update This uses an `after_commit` hook on the above two events to enqueue a sync to Salesforce. --- app/models/school.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/models/school.rb b/app/models/school.rb index 24531d48a..db0363fd6 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -51,6 +51,9 @@ class School < ApplicationRecord # TODO: Remove the conditional once the feature flag is retired after_create :generate_code!, if: -> { FeatureFlags.immediate_school_onboarding? } + + # After creation, sync the School to Salesforce. + after_commit :do_salesforce_sync, on: [:create, :update] def self.find_for_user!(user) school = Role.find_by(user_id: user.id)&.school || find_by(creator_id: user.id) @@ -169,4 +172,8 @@ def format_uk_postal_code # ensures UK postcodes are always formatted correctly (as the inward code is always 3 chars long) self.postal_code = "#{cleaned_postal_code[0..-4]} #{cleaned_postal_code[-3..]}" end + + def do_salesforce_sync + Salesforce::SchoolSyncJob.perform_later(school_id: id) + end end From 8d1e39262c9a8f953cff0913fa8f16e939f9217c Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 12 Feb 2026 11:20:06 +0000 Subject: [PATCH 04/12] Add heroku-connect to Docker compose file This commit adds the heroku-connect container as documented in: https://github.com/RaspberryPiFoundation/heroku-connect This means that building the project now requires authenticating with `ghcr.io` by doing: $ echo $GITHUB_TOKEN | docker login ghcr.io -u \ --password-stdin Where $GITHUB_TOKEN is a PAT with `packages:read` permissions (same as you use for NPM). --- docker-compose.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 99541f0fd..9753bd327 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,7 +53,29 @@ services: platform: linux/amd64 command: -u $SMEE_TUNNEL -t http://api:3009/github_webhooks + salesforce_connect: + image: ghcr.io/raspberrypifoundation/heroku-connect + volumes: + - salesforce_connect_data:/var/lib/postgres/data/ + environment: + - POSTGRES_DB=salesforce_development + - POSTGRES_CLONE_DB=salesforce_test + - POSTGRES_PASSWORD=password + - POSTGRES_USER=postgres + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}", + ] + interval: 5s + timeout: 5s + retries: 10 + ports: + - "4101:5432" + volumes: postgres-data: bundle-data-v2: node_modules: + salesforce_connect_data: From 45df6459d49a2290a33dadc7799c4e030c8a0312 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 12 Feb 2026 15:45:16 +0000 Subject: [PATCH 05/12] Add salesforce_sync queue to GoodJob This commit adds a new queue to GoodJob to run SalesforceSyncJobs on. --- config/initializers/good_job.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 600f199eb..38399b54a 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -18,5 +18,5 @@ def authenticate_admin # The create_students_job queue is a serial queue that allows only one job at a time. # DO NOT change the value of create_students_job:1 without understanding the implications # of processing more than one user creation job at once. - config.good_job.queues = 'create_students_job:1;import_schools_job:1;default:5' + config.good_job.queues = 'create_students_job:1;import_schools_job:1;salesforce_sync:1,default:5' end From 31210b4c9eddde53683396a958ff2a8b9b698969 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 12 Feb 2026 16:23:11 +0000 Subject: [PATCH 06/12] Add Salesforce Connect information to .env.example These are the settings neede to talk to the `salesforce_connect` container. --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 7d94cf136..cfbaebc47 100644 --- a/.env.example +++ b/.env.example @@ -57,3 +57,9 @@ RESEED_API_KEY=changeme # Enable immediate onboarding for schools ENABLE_IMMEDIATE_SCHOOL_ONBOARDING=true + +# Salesforce Connect +SALESFORCE_ENABLED=true +SALESFORCE_CONNECT_HOST=salesforce_connect +SALESFORCE_CONNECT_PASSWORD=password +SALESFORCE_CONNECT_USER=postgres From fb206375870bba3d2caf0ef62d264b39e5214cba Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Mon, 23 Feb 2026 13:25:04 +0000 Subject: [PATCH 07/12] Add a Rake task to sync existing schools to SF While the code in this branch will update sales force on create or update, there is some work required to back-fill existing data into Salesforce. This commit provides a Rake task, patterened after that in `clubs-api` that will do this. Inspired by: https://github.com/RaspberryPiFoundation/clubs-api/blob/main/lib/tasks/salesforce_sync.rake --- lib/tasks/salesforce_sync.rake | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 lib/tasks/salesforce_sync.rake diff --git a/lib/tasks/salesforce_sync.rake b/lib/tasks/salesforce_sync.rake new file mode 100644 index 000000000..a304bf955 --- /dev/null +++ b/lib/tasks/salesforce_sync.rake @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +namespace :salesforce_sync do + desc 'Sync all Schools to Salesforce' + task school: :environment do + School.pluck(:id).each do |school_id| + Salesforce::SchoolSyncJob.perform_later(school_id:) + end + end +end From 008ce2c32c72b12361daa3ab9b6d9a9d44b3f6f8 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Fri, 13 Mar 2026 14:12:31 +0000 Subject: [PATCH 08/12] Add salesforce object mapping to FIELD_MAPPINGS for schools --- app/jobs/salesforce/salesforce_sync_job.rb | 9 +- app/jobs/salesforce/school_sync_job.rb | 44 ++++++++- docker-compose.yml | 6 ++ spec/jobs/salesforce/school_sync_job_spec.rb | 94 ++++++++++++++++++++ 4 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 spec/jobs/salesforce/school_sync_job_spec.rb diff --git a/app/jobs/salesforce/salesforce_sync_job.rb b/app/jobs/salesforce/salesforce_sync_job.rb index acf3d6cc3..2b738c6c0 100644 --- a/app/jobs/salesforce/salesforce_sync_job.rb +++ b/app/jobs/salesforce/salesforce_sync_job.rb @@ -27,7 +27,14 @@ def perform(*) raise NotImplementedError, 'Subclasses must implement perform' end - # TODO Consider implementing private utilities here, e.g. truncate_value + private + + def truncate_value(sf_field:, value:) + column = self.class::MODEL_CLASS.column_for_attribute(sf_field) + return value if column.limit.nil? + + value.truncate(column.limit, omission: '…') + end end end diff --git a/app/jobs/salesforce/school_sync_job.rb b/app/jobs/salesforce/school_sync_job.rb index 9326b780e..0ab4fdb72 100644 --- a/app/jobs/salesforce/school_sync_job.rb +++ b/app/jobs/salesforce/school_sync_job.rb @@ -4,18 +4,54 @@ module Salesforce class SchoolSyncJob < SalesforceSyncJob MODEL_CLASS = Salesforce::School - FIELD_MAPPINGS = {}.freeze + FIELD_MAPPINGS = { + EditorUUID__c: :id, + Name: :name, + EditorReference__c: :reference, + AddressLine1__c: :address_line_1, + AddressLine2__c: :address_line_2, + EditorMunicipality__c: :municipality, + EditorAdministrativeArea__c: :administrative_area, + Postcode__c: :postal_code, + CountryCode__c: :country_code, + VerifiedAt__c: :verified_at, + CreatedAt__c: :created_at, + UpdatedAt__c: :updated_at, + RejectedAt__c: :rejected_at, + Website__c: :website, + ExperienceCSAgreeToUXContact__c: :creator_agree_to_ux_contact, + UserOrigin__c: :user_origin, + DistrictNameSupplied__c: :district_name, + NCESID__c: :district_nces_id, + SchoolRollNumber__c: :school_roll_number, + }.freeze STATUS_MAPPINGS = {}.freeze def perform(school_id:) - @school = School.find(id: school_id) - sf_school = Salesforce::School.find_or_initialize_by(school_id__c: school_id) + school = ::School.find(school_id) - # Make the sf_school match @school. + sf_school = Salesforce::School.find_or_initialize_by(school_id__c: school_id) + sf_school.attributes = sf_school_attributes(school:) sf_school.save! end + + private + + def sf_school_attributes(school:) + mapped_attributes(school:).to_h do |sf_field, value| + value = truncate_value(sf_field:, value:) if value.is_a?(String) + + [sf_field, value] + end + end + + def mapped_attributes(school:) + FIELD_MAPPINGS.transform_values do |school_field| + school.send(school_field) + end + end end end diff --git a/docker-compose.yml b/docker-compose.yml index 9753bd327..4117cb24d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,8 @@ services: depends_on: db: condition: service_healthy + salesforce_connect: + condition: service_healthy volumes: - .:/app - bundle-data-v2:/usr/local/bundle @@ -46,6 +48,10 @@ services: - POSTGRES_DB - POSTGRES_PASSWORD - POSTGRES_USER + - SALESFORCE_CONNECT_HOST=salesforce_connect + - SALESFORCE_CONNECT_USER=postgres + - SALESFORCE_CONNECT_PASSWORD=password + - SALESFORCE_CONNECT_DB=salesforce_development extra_hosts: - "host.docker.internal:host-gateway" smee: diff --git a/spec/jobs/salesforce/school_sync_job_spec.rb b/spec/jobs/salesforce/school_sync_job_spec.rb new file mode 100644 index 000000000..c44f76b46 --- /dev/null +++ b/spec/jobs/salesforce/school_sync_job_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Salesforce::SchoolSyncJob do + subject(:perform_job) { described_class.new.perform(school_id: school.id) } + + context 'when the job has run' do + let(:school) { create(:school) } + let(:sf_school) { Salesforce::School.find(school.id) } + + before { perform_job } + + it 'syncs the standard mappings' do # rubocop:disable RSpec/ExampleLength + described_class::FIELD_MAPPINGS.each do |sf_field, field| + sf_value = sf_school.send(sf_field) + school_value = school.send(field) + + expect(sf_value).to eq(school_value), "Expected #{sf_field} to be #{school_value.inspect} but was #{sf_value.inspect}" + end + end + + context 'when address fields are very long' do + let(:max_field_size) { Salesforce::School.column_for_attribute(:AddressLine1__c).limit } + let(:school) { create(:school, address_line_1: '❌' * (max_field_size + 10)) } + + it 'truncates AddressLine1__c to the max field length' do + expect(sf_school.AddressLine1__c.size).to be <= max_field_size + end + end + + context 'when the school is verified' do + let(:school) { create(:verified_school) } + + it 'syncs VerifiedAt__c' do + expect(sf_school.VerifiedAt__c).to eq(school.verified_at) + end + end + + context 'when the school is rejected' do + let(:school) do + s = create(:school) + s.update_column(:rejected_at, Time.current) + s + end + + it 'syncs RejectedAt__c' do + expect(sf_school.RejectedAt__c).to eq(school.rejected_at) + end + end + + context 'when creator_agree_to_ux_contact is true' do + let(:school) { create(:school, creator_agree_to_ux_contact: true) } + + it 'syncs ExperienceCSAgreeToUXContact__c as true' do + expect(sf_school.ExperienceCSAgreeToUXContact__c).to be true + end + end + + context 'when creator_agree_to_ux_contact is false' do + let(:school) { create(:school, creator_agree_to_ux_contact: false) } + + it 'syncs ExperienceCSAgreeToUXContact__c as false' do + expect(sf_school.ExperienceCSAgreeToUXContact__c).to be false + end + end + end + + context 'when the Salesforce school fails to save' do + let(:school) { create(:school) } + let(:sf_school) { instance_double(Salesforce::School) } + + before do + allow(Salesforce::School).to receive(:find_or_initialize_by).with(school_id__c: school.id).and_return(sf_school) + allow(sf_school).to receive(:attributes=) + allow(sf_school).to receive(:save!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'raises an error' do + expect { perform_job }.to raise_error ActiveRecord::RecordInvalid + end + end + + context 'when SALESFORCE_ENABLED is false' do + let(:school) { create(:school) } + + before { stub_const('ENV', ENV.to_h.merge('SALESFORCE_ENABLED' => 'false')) } + + it 'discards the job without syncing' do + expect(Salesforce::School).not_to receive(:find_or_initialize_by) + expect { perform_job }.to raise_error(Salesforce::SalesforceSyncJob::SkipBecauseSalesforceIsDisabled) + end + end +end From 8387fc5f9225ac82992a497001e433efb27461a4 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Tue, 17 Mar 2026 09:28:21 +0000 Subject: [PATCH 09/12] Add Salesforce structures for Role sync This commit adds: - Hooks in Role to sync to Salesforce - Salesforce model object for Role - Sync job and tests for Role sync --- app/jobs/salesforce/role_sync_job.rb | 43 +++++++++++++ app/models/role.rb | 6 ++ app/models/salesforce/role.rb | 8 +++ spec/jobs/salesforce/role_sync_job_spec.rb | 74 ++++++++++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 app/jobs/salesforce/role_sync_job.rb create mode 100644 app/models/salesforce/role.rb create mode 100644 spec/jobs/salesforce/role_sync_job_spec.rb diff --git a/app/jobs/salesforce/role_sync_job.rb b/app/jobs/salesforce/role_sync_job.rb new file mode 100644 index 000000000..f54e4028d --- /dev/null +++ b/app/jobs/salesforce/role_sync_job.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Salesforce + class RoleSyncJob < SalesforceSyncJob + MODEL_CLASS = Salesforce::Role + + FIELD_MAPPINGS = { + EditorRoleId__c: :id, + EditorUserId__c: :user_id, + EditorSchoolId__c: :school_id, + RoleName__c: :role, + CreatedAt__c: :created_at, + UpdatedAt__c: :updated_at + }.freeze + + STATUS_MAPPINGS = {}.freeze + + def perform(role_id:) + role = ::Role.find(role_id) + + sf_role = Salesforce::Role.find_or_initialize_by(role_id__c: role_id) + sf_role.attributes = sf_role_attributes(role:) + + sf_role.save! + end + + private + + def sf_role_attributes(role:) + mapped_attributes(role:).to_h do |sf_field, value| + value = truncate_value(sf_field:, value:) if value.is_a?(String) + + [sf_field, value] + end + end + + def mapped_attributes(role:) + FIELD_MAPPINGS.transform_values do |role_field| + role.send(role_field) + end + end + end +end diff --git a/app/models/role.rb b/app/models/role.rb index 46d30bf91..e7da0e617 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -16,6 +16,8 @@ class Role < ApplicationRecord } ) + after_commit :do_salesforce_sync, on: %i[create update] + private def students_cannot_have_additional_roles @@ -38,4 +40,8 @@ def users_can_only_have_roles_in_one_school errors.add(:base, 'Cannot create role as this user already has a role in a different school') end + + def do_salesforce_sync + Salesforce::RoleSyncJob.perform_later(role_id: id) + end end diff --git a/app/models/salesforce/role.rb b/app/models/salesforce/role.rb new file mode 100644 index 000000000..0d8a49199 --- /dev/null +++ b/app/models/salesforce/role.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Salesforce + class Role < Salesforce::Base + self.table_name = 'salesforce.role__c' # TODO: Confirm this - placeholder + self.primary_key = :role_id__c + end +end diff --git a/spec/jobs/salesforce/role_sync_job_spec.rb b/spec/jobs/salesforce/role_sync_job_spec.rb new file mode 100644 index 000000000..b2812cc25 --- /dev/null +++ b/spec/jobs/salesforce/role_sync_job_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Salesforce::RoleSyncJob do + subject(:perform_job) { described_class.new.perform(role_id: role.id) } + + context 'when the job has run' do + let(:role) { create(:role) } + let(:sf_role) { Salesforce::Role.find(role.id) } + + before { perform_job } + + it 'syncs the standard mappings' do + described_class::FIELD_MAPPINGS.each do |sf_field, field| + sf_value = sf_role.send(sf_field) + role_value = role.send(field) + + expect(sf_value).to eq(role_value), "Expected #{sf_field} to be #{role_value.inspect} but was #{sf_value.inspect}" + end + end + + context 'when the role is a student' do + let(:role) { create(:student_role) } + + it 'syncs RoleName__c as student' do + expect(sf_role.RoleName__c).to eq('student') + end + end + + context 'when the role is a teacher' do + let(:role) { create(:teacher_role) } + + it 'syncs RoleName__c as teacher' do + expect(sf_role.RoleName__c).to eq('teacher') + end + end + + context 'when the role is an owner' do + let(:role) { create(:owner_role) } + + it 'syncs RoleName__c as owner' do + expect(sf_role.RoleName__c).to eq('owner') + end + end + end + + context 'when the Salesforce role fails to save' do + let(:role) { create(:role) } + let(:sf_role) { instance_double(Salesforce::Role) } + + before do + allow(Salesforce::Role).to receive(:find_or_initialize_by).with(role_id__c: role.id).and_return(sf_role) + allow(sf_role).to receive(:attributes=) + allow(sf_role).to receive(:save!).and_raise(ActiveRecord::RecordInvalid) + end + + it 'raises an error' do + expect { perform_job }.to raise_error ActiveRecord::RecordInvalid + end + end + + context 'when SALESFORCE_ENABLED is false' do + let(:role) { create(:role) } + + before { stub_const('ENV', ENV.to_h.merge('SALESFORCE_ENABLED' => 'false')) } + + it 'discards the job without syncing' do + allow(Salesforce::Role).to receive(:find_or_initialize_by) + expect { perform_job }.to raise_error(Salesforce::SalesforceSyncJob::SkipBecauseSalesforceIsDisabled) + expect(Salesforce::Role).not_to have_received(:find_or_initialize_by) + end + end +end From 22702fc96fa09842357956bc8d5952699e0003e4 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Tue, 17 Mar 2026 10:57:48 +0000 Subject: [PATCH 10/12] Update all column names to match database schema This commit changes the column names to match the agreed mapping values in Heroku Connect. --- app/jobs/salesforce/role_sync_job.rb | 14 ++++----- app/jobs/salesforce/school_sync_job.rb | 43 +++++++++++++------------- app/models/salesforce/role.rb | 4 +-- app/models/salesforce/school.rb | 7 ++--- lib/tasks/salesforce_sync.rake | 7 +++++ 5 files changed, 41 insertions(+), 34 deletions(-) diff --git a/app/jobs/salesforce/role_sync_job.rb b/app/jobs/salesforce/role_sync_job.rb index f54e4028d..d29ff3129 100644 --- a/app/jobs/salesforce/role_sync_job.rb +++ b/app/jobs/salesforce/role_sync_job.rb @@ -5,12 +5,12 @@ class RoleSyncJob < SalesforceSyncJob MODEL_CLASS = Salesforce::Role FIELD_MAPPINGS = { - EditorRoleId__c: :id, - EditorUserId__c: :user_id, - EditorSchoolId__c: :school_id, - RoleName__c: :role, - CreatedAt__c: :created_at, - UpdatedAt__c: :updated_at + editorroleid__c: :id, + editoruserid__c: :user_id, + editorschoolid__c: :school_id, + rolename__c: :role, + createdat__c: :created_at, + updatedat__c: :updated_at }.freeze STATUS_MAPPINGS = {}.freeze @@ -18,7 +18,7 @@ class RoleSyncJob < SalesforceSyncJob def perform(role_id:) role = ::Role.find(role_id) - sf_role = Salesforce::Role.find_or_initialize_by(role_id__c: role_id) + sf_role = Salesforce::Role.find_or_initialize_by(editorroleid__c: role_id) sf_role.attributes = sf_role_attributes(role:) sf_role.save! diff --git a/app/jobs/salesforce/school_sync_job.rb b/app/jobs/salesforce/school_sync_job.rb index 0ab4fdb72..37c96bb31 100644 --- a/app/jobs/salesforce/school_sync_job.rb +++ b/app/jobs/salesforce/school_sync_job.rb @@ -5,36 +5,37 @@ class SchoolSyncJob < SalesforceSyncJob MODEL_CLASS = Salesforce::School FIELD_MAPPINGS = { - EditorUUID__c: :id, - Name: :name, - EditorReference__c: :reference, - AddressLine1__c: :address_line_1, - AddressLine2__c: :address_line_2, - EditorMunicipality__c: :municipality, - EditorAdministrativeArea__c: :administrative_area, - Postcode__c: :postal_code, - CountryCode__c: :country_code, - VerifiedAt__c: :verified_at, - CreatedAt__c: :created_at, - UpdatedAt__c: :updated_at, - RejectedAt__c: :rejected_at, - Website__c: :website, - ExperienceCSAgreeToUXContact__c: :creator_agree_to_ux_contact, - UserOrigin__c: :user_origin, - DistrictNameSupplied__c: :district_name, - NCESID__c: :district_nces_id, - SchoolRollNumber__c: :school_roll_number, + editoruuid__c: :id, + name: :name, + editorreference__c: :reference, + addressline1__c: :address_line_1, + addressline2__c: :address_line_2, + editormunicipality__c: :municipality, + editoradministrativearea__c: :administrative_area, + postcode__c: :postal_code, + countrycode__c: :country_code, + verifiedat__c: :verified_at, + createdat__c: :created_at, + updatedat__c: :updated_at, + rejectedat__c: :rejected_at, + website__c: :website, + userorigin__c: :user_origin, + districtnamesupplied__c: :district_name, + ncesid__c: :district_nces_id, + schoolrollnumber__c: :school_roll_number }.freeze STATUS_MAPPINGS = {}.freeze - def perform(school_id:) school = ::School.find(school_id) - sf_school = Salesforce::School.find_or_initialize_by(school_id__c: school_id) + sf_school = Salesforce::School.find_or_initialize_by(editoruuid__c: school_id) sf_school.attributes = sf_school_attributes(school:) + # Somewhere in here we have to get the Contact object from Salesforce and + # set its ExperienceCSAgreeToUXContact__c field to the value of :creator_agree_to_ux_contact + sf_school.save! end diff --git a/app/models/salesforce/role.rb b/app/models/salesforce/role.rb index 0d8a49199..9924ba435 100644 --- a/app/models/salesforce/role.rb +++ b/app/models/salesforce/role.rb @@ -2,7 +2,7 @@ module Salesforce class Role < Salesforce::Base - self.table_name = 'salesforce.role__c' # TODO: Confirm this - placeholder - self.primary_key = :role_id__c + self.table_name = 'salesforce.contact_editor_affiliation__c' + self.primary_key = :Affiliation_ID__c end end diff --git a/app/models/salesforce/school.rb b/app/models/salesforce/school.rb index db63167fc..cfacfcb73 100644 --- a/app/models/salesforce/school.rb +++ b/app/models/salesforce/school.rb @@ -1,9 +1,8 @@ -# # frozen_string_literal: true +# frozen_string_literal: true module Salesforce class School < Salesforce::Base - self.table_name = 'salesforce.school__c' # TODO Confirm this - placeholder - self.primary_key = :school_id__c - + self.table_name = 'salesforce.editor__c' + self.primary_key = :EditorUUID__c end end diff --git a/lib/tasks/salesforce_sync.rake b/lib/tasks/salesforce_sync.rake index a304bf955..8673c26e3 100644 --- a/lib/tasks/salesforce_sync.rake +++ b/lib/tasks/salesforce_sync.rake @@ -7,4 +7,11 @@ namespace :salesforce_sync do Salesforce::SchoolSyncJob.perform_later(school_id:) end end + + desc 'Sync all Roles to Salesforce' + task role: :environment do + Role.pluck(:id).each do |role_id| + Salesforce::RoleSyncJob.perform_later(role_id:) + end + end end From 1716dc3b4a1c7ef487e826e3fe66a74d119fba9c Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Tue, 17 Mar 2026 15:58:26 +0000 Subject: [PATCH 11/12] Make Salesforce sync jobs more concurrent This enforces a concurrency control key per object primary key, but allows a larger number of concurrent actions. --- app/jobs/salesforce/salesforce_sync_job.rb | 20 ++++++++++++-------- config/initializers/good_job.rb | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/jobs/salesforce/salesforce_sync_job.rb b/app/jobs/salesforce/salesforce_sync_job.rb index 2b738c6c0..0a5e290e1 100644 --- a/app/jobs/salesforce/salesforce_sync_job.rb +++ b/app/jobs/salesforce/salesforce_sync_job.rb @@ -1,26 +1,31 @@ -# # frozen_string_literal: true +# frozen_string_literal: true module Salesforce class SalesforceSyncJob < ApplicationJob include GoodJob::ActiveJobExtensions::Concurrency + # Serialise concurrent performs for the same record (same class + record ID) + # to prevent TOCTOU races on find_or_initialize_by + save!, while allowing + # jobs for different records to run fully in parallel. good_job_control_concurrency_with( - perform_throttle: [2, 1.second] + perform_limit: 1, + key: -> { "#{self.class.name}/#{arguments.first.values.first}" } ) SalesforceRecordNotFound = Class.new(StandardError) SkipBecauseSalesforceIsDisabled = Class.new(StandardError) + discard_on SkipBecauseSalesforceIsDisabled + + retry_on SalesforceRecordNotFound, wait: :polynomially_longer, attempts: 10 + include ActionView::Helpers::SanitizeHelper queue_as :salesforce_sync - discard_on SkipBecauseSalesforceIsDisabled - before_perform do |_job| - unless ENV.fetch('SALESFORCE_ENABLED', 'true') == 'true' - raise SkipBecauseSalesforceIsDisabled, 'SALESFORCE_ENABLED is not true.' - end + salesforce_enabled = ENV.fetch('SALESFORCE_ENABLED', 'true') == 'true' + raise SkipBecauseSalesforceIsDisabled, 'SALESFORCE_ENABLED is not true.' unless salesforce_enabled end def perform(*) @@ -37,4 +42,3 @@ def truncate_value(sf_field:, value:) end end end - diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 38399b54a..ccc9aa17d 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -18,5 +18,5 @@ def authenticate_admin # The create_students_job queue is a serial queue that allows only one job at a time. # DO NOT change the value of create_students_job:1 without understanding the implications # of processing more than one user creation job at once. - config.good_job.queues = 'create_students_job:1;import_schools_job:1;salesforce_sync:1,default:5' + config.good_job.queues = 'create_students_job:1;import_schools_job:1;salesforce_sync:10,default:5' end From 0d3890aa49a2f4b060a4fb0a59e62cf8193da4a1 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Tue, 17 Mar 2026 15:59:37 +0000 Subject: [PATCH 12/12] Update database keys for Salesforce::Role --- app/jobs/salesforce/role_sync_job.rb | 14 +++++++++----- app/models/salesforce/role.rb | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/jobs/salesforce/role_sync_job.rb b/app/jobs/salesforce/role_sync_job.rb index d29ff3129..eeca7ec38 100644 --- a/app/jobs/salesforce/role_sync_job.rb +++ b/app/jobs/salesforce/role_sync_job.rb @@ -5,10 +5,10 @@ class RoleSyncJob < SalesforceSyncJob MODEL_CLASS = Salesforce::Role FIELD_MAPPINGS = { - editorroleid__c: :id, - editoruserid__c: :user_id, - editorschoolid__c: :school_id, - rolename__c: :role, + affiliation_id__c: :id, + contact__c: :user_id, + editor__c: :school_id, + role__c: :role, createdat__c: :created_at, updatedat__c: :updated_at }.freeze @@ -18,7 +18,11 @@ class RoleSyncJob < SalesforceSyncJob def perform(role_id:) role = ::Role.find(role_id) - sf_role = Salesforce::Role.find_or_initialize_by(editorroleid__c: role_id) + # We don't sync student roles - Unsure if this is working. + return if role.student? + + sf_role = Salesforce::Role.find_or_initialize_by(affiliation_id__c: role_id) + sf_role.attributes = sf_role_attributes(role:) sf_role.save! diff --git a/app/models/salesforce/role.rb b/app/models/salesforce/role.rb index 9924ba435..5e0d4c768 100644 --- a/app/models/salesforce/role.rb +++ b/app/models/salesforce/role.rb @@ -3,6 +3,6 @@ module Salesforce class Role < Salesforce::Base self.table_name = 'salesforce.contact_editor_affiliation__c' - self.primary_key = :Affiliation_ID__c + self.primary_key = :affiliation_id__c end end