From 6a13cf49748af8710560b896e5aff49db3e349bc Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 11 Feb 2026 14:21:47 +0000 Subject: [PATCH 1/6] 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 fa3ccae0020d966e65ba56f83f74c3904d17c72e Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 11 Feb 2026 14:24:49 +0000 Subject: [PATCH 2/6] 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 217f3e427031b8d46008f5b7466072f4d0db2727 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 11 Feb 2026 14:27:15 +0000 Subject: [PATCH 3/6] 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 4837a1c5f78633b75d8e77b5c4875d831743e089 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 12 Feb 2026 11:20:06 +0000 Subject: [PATCH 4/6] 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 96d326615..0bc0aded3 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: node_modules: + salesforce_connect_data: From df91823c4e4cdb6696ada833c1639266b1546f76 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 12 Feb 2026 15:45:16 +0000 Subject: [PATCH 5/6] 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 2e6f17385ca466dcc69b169a04ac0fa1de463620 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 12 Feb 2026 16:23:11 +0000 Subject: [PATCH 6/6] 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