Skip to content

Commit 9d9d042

Browse files
authored
Sync Schools and Roles to Salesforce on Create and Update (#677)
## Status - Closes RaspberryPiFoundation/experience-cs#1677 ## What's changed? ### Infrastructure - Adds a `salesforce_connect` database configuration in `database.yml` to connect to the `rpf-heroku-connect` Heroku Connect datastore (read/write via a separate PostgreSQL connection, with `database_tasks: false` so Rails migrations leave it alone). - Adds a `salesforce_connect` Docker service (image: `ghcr.io/raspberrypifoundation/heroku-connect`) to `docker-compose.yml` for local development, with a named volume and a health check. - Adds the `SALESFORCE_CONNECT_*` environment variables to `.env.example` and `docker-compose.yml`. - Updates CI (`.github/workflows/ci.yml`) to spin up the `heroku-connect` service container, pass the Salesforce connection env vars, and add `packages: read` permission so the private image can be pulled. ### Models - `Salesforce::Base` — abstract Active Record base class that routes all reads and writes to the `salesforce_connect` database. - `Salesforce::School` — maps to the `salesforce.editor__c` table (PK: `editoruuid__c`). - `Salesforce::Role` — maps to the `salesforce.contact_editor_affiliation__c` table (PK: `affiliation_id__c`). - `Salesforce::Contact` — maps to the `salesforce.contact` table (PK: `pi_accounts_unique_id__c`). ### Jobs - `Salesforce::SalesforceSyncJob` — base job class that: - Checks the `SALESFORCE_ENABLED` env var and discards the job (no retry) if it is not `true`. - Retries on `SalesforceRecordNotFound` with polynomial backoff, up to 10 attempts. - Truncates string values to respect Salesforce column limits (appending `…`). - Enqueues onto the new `salesforce_sync` queue. - `Salesforce::SchoolSyncJob` — upserts a `School` record into `salesforce.editor__c`, mapping all address, status, and metadata fields. Defaults `userorigin__c` to `'for_education'` when blank. - `Salesforce::RoleSyncJob` — upserts non-student `Role` records into `salesforce.contact_editor_affiliation__c`. Skips student roles entirely. - `Salesforce::ContactSyncJob` — looks up the Salesforce Contact by the school creator's user ID and syncs the `creator_agree_to_ux_contact` flag. Raises `SalesforceRecordNotFound` (triggering retry) if no Contact exists yet. ### Model callbacks - `School` — `after_commit` on create/update enqueues both `SchoolSyncJob` and `ContactSyncJob`. - `Role` — `after_commit` on create/update enqueues `RoleSyncJob`. ### GoodJob queue configuration - Adds the `salesforce_sync:10` queue to allow up to 10 concurrent Salesforce sync workers. ### Rake tasks - `salesforce_sync:school` — bulk-enqueues `SchoolSyncJob` for every School (for backfilling). - `salesforce_sync:role` — bulk-enqueues `RoleSyncJob` for every Role (for backfilling). - `salesforce_sync:contact` — bulk-enqueues `ContactSyncJob` for every School (for backfilling the UX contact flag). ### Tests - Full RSpec coverage for `SchoolSyncJob`, `RoleSyncJob`, and `ContactSyncJob`, including field mapping, skipping/discarding behaviour, and error cases. - Model specs for `School` and `Role` verify that the correct jobs are enqueued after create/update. ## Points for consideration - **Security**: The `salesforce_connect` connection credentials are injected via environment variables. No secrets are committed to the repo. - **Performance**: Each `after_commit` callback enqueues a background job (GoodJob), so there is no synchronous overhead on the request. Concurrency is capped per-record to avoid TOCTOU races. - **Salesforce Contact availability**: `ContactSyncJob` requires the Salesforce Contact record to already exist (keyed by `creator_id`). If it does not exist yet, the job retries up to 10 times with polynomial backoff. ## How to Test Locally To test these changes locally, you can: 1. Log in as a user that has no school. 2. Go to For Education > Create your school account 3. Complete the sign-up form Observe the GoodJob dashboard - you should see new `SchoolSyncJob` and `RoleSyncJob` jobs in the `salesforce_sync` queue. In the Rails Console, you can also inspect the number of `Salesforce::School` and `Salesforce::Role` objects that have been created - you should see them increase when you add a new school. ## Steps to perform after deploying to production After deploying, run the rake backfill tasks to sync existing data: ``` rails salesforce_sync:school rails salesforce_sync:role rails salesforce_sync:contact ```
1 parent efc9f26 commit 9d9d042

27 files changed

Lines changed: 677 additions & 7 deletions

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,10 @@ GOOGLE_CLIENT_SECRET=changeme
5555
# E2E tests can supply this to enable test utility endpoints
5656
RESEED_API_KEY=changeme
5757

58+
# Salesforce Connect
59+
SALESFORCE_ENABLED=true
60+
SALESFORCE_CONNECT_HOST=salesforce_connect
61+
SALESFORCE_CONNECT_PORT=5432
62+
SALESFORCE_CONNECT_DB=salesforce_development
63+
SALESFORCE_CONNECT_PASSWORD=password
64+
SALESFORCE_CONNECT_USER=postgres

.github/workflows/ci.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ jobs:
4343
contents: read
4444
issues: write
4545
pull-requests: write
46+
packages: read
4647
env:
4748
RAILS_ENV: test
4849
POSTGRES_DB: choco_cake_test
@@ -56,6 +57,11 @@ jobs:
5657
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: deterministic-key
5758
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: derivation-salt
5859
EDITOR_ENCRYPTION_KEY: a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef0
60+
SALESFORCE_CONNECT_HOST: 127.0.0.1
61+
SALESFORCE_CONNECT_PORT: '4101'
62+
SALESFORCE_CONNECT_USER: postgres
63+
SALESFORCE_CONNECT_PASSWORD: password
64+
SALESFORCE_CONNECT_DB: salesforce_test
5965
services:
6066
postgres:
6167
image: postgres:12
@@ -74,6 +80,22 @@ jobs:
7480
image: redis:6.2-alpine
7581
ports:
7682
- 6379:6379
83+
salesforce_connect:
84+
image: 'ghcr.io/raspberrypifoundation/heroku-connect'
85+
credentials:
86+
username: ${{ github.actor }}
87+
password: ${{ secrets.GITHUB_TOKEN }}
88+
env:
89+
POSTGRES_DB: salesforce_test
90+
POSTGRES_PASSWORD: password
91+
POSTGRES_USER: postgres
92+
ports:
93+
- 4101:5432
94+
options: >-
95+
--health-cmd="pg_isready -h 127.0.0.1 -U postgres"
96+
--health-interval=5s
97+
--health-timeout=5s
98+
--health-retries=5
7799
78100
steps:
79101
- uses: actions/checkout@v4
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module Salesforce
4+
class ContactSyncJob < SalesforceSyncJob
5+
MODEL_CLASS = Salesforce::Contact
6+
7+
def perform(school_id:)
8+
school = ::School.find(school_id)
9+
10+
sf_contact = Salesforce::Contact.find_by(pi_accounts_unique_id__c: school.creator_id)
11+
raise SalesforceRecordNotFound, "Contact not found for creator_id: #{school.creator_id}" unless sf_contact
12+
13+
sf_contact.editoragreetouxcontact__c = school.creator_agree_to_ux_contact
14+
sf_contact.save!
15+
end
16+
17+
private
18+
19+
def concurrency_key_id = arguments.first.with_indifferent_access[:school_id]
20+
end
21+
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
module Salesforce
4+
class RoleSyncJob < SalesforceSyncJob
5+
MODEL_CLASS = Salesforce::Role
6+
7+
FIELD_MAPPINGS = {
8+
affiliation_id__c: :id,
9+
contact__r__pi_accounts_unique_id__c: :user_id,
10+
editor__r__editoruuid__c: :school_id,
11+
roletype__c: :role,
12+
createdat__c: :created_at,
13+
updatedat__c: :updated_at
14+
}.freeze
15+
16+
def perform(role_id:)
17+
role = ::Role.find(role_id)
18+
19+
return if role.student?
20+
21+
sf_role = Salesforce::Role.find_or_initialize_by(affiliation_id__c: role_id)
22+
sf_role.attributes = sf_role_attributes(role:)
23+
sf_role.save!
24+
end
25+
26+
private
27+
28+
def sf_role_attributes(role:)
29+
mapped_attributes(role:).to_h do |sf_field, value|
30+
value = truncate_value(sf_field:, value:) if value.is_a?(String)
31+
32+
[sf_field, value]
33+
end
34+
end
35+
36+
def mapped_attributes(role:)
37+
FIELD_MAPPINGS.transform_values do |role_field|
38+
role.send(role_field)
39+
end
40+
end
41+
42+
def concurrency_key_id = arguments.first.with_indifferent_access[:role_id]
43+
end
44+
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
module Salesforce
4+
class SalesforceSyncJob < ApplicationJob
5+
include GoodJob::ActiveJobExtensions::Concurrency
6+
7+
# Serialise concurrent performs for the same record (same class + record ID)
8+
# to prevent TOCTOU races on find_or_initialize_by + save!, while allowing
9+
# jobs for different records to run fully in parallel.
10+
good_job_control_concurrency_with(
11+
perform_limit: 1,
12+
key: -> { "#{self.class.name}/#{concurrency_key_id}" }
13+
)
14+
15+
class SalesforceRecordNotFound < StandardError
16+
end
17+
18+
class SkipBecauseSalesforceIsDisabled < StandardError
19+
end
20+
21+
discard_on SkipBecauseSalesforceIsDisabled
22+
23+
retry_on SalesforceRecordNotFound, wait: :polynomially_longer, attempts: 10
24+
25+
queue_as :salesforce_sync
26+
27+
before_perform do |_job|
28+
salesforce_enabled = FeatureFlags.salesforce_sync?
29+
raise SkipBecauseSalesforceIsDisabled, 'SALESFORCE_ENABLED is not true.' unless salesforce_enabled
30+
end
31+
32+
def perform(*)
33+
raise NotImplementedError, 'Subclasses must implement perform'
34+
end
35+
36+
private
37+
38+
def concurrency_key_id
39+
raise NotImplementedError, "#{self.class.name} must implement concurrency_key_id"
40+
end
41+
42+
def truncate_value(sf_field:, value:)
43+
column = self.class::MODEL_CLASS.column_for_attribute(sf_field)
44+
return value if column.limit.nil?
45+
46+
value.truncate(column.limit, omission: '…')
47+
end
48+
end
49+
end
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module Salesforce
4+
class SchoolSyncJob < SalesforceSyncJob
5+
MODEL_CLASS = Salesforce::School
6+
7+
FIELD_MAPPINGS = {
8+
editoruuid__c: :id,
9+
name: :name,
10+
editorreference__c: :reference,
11+
addressline1__c: :address_line_1,
12+
addressline2__c: :address_line_2,
13+
editormunicipality__c: :municipality,
14+
editoradministrativearea__c: :administrative_area,
15+
postcode__c: :postal_code,
16+
countrycode__c: :country_code,
17+
verifiedat__c: :verified_at,
18+
createdat__c: :created_at,
19+
updatedat__c: :updated_at,
20+
rejectedat__c: :rejected_at,
21+
website__c: :website,
22+
userorigin__c: :user_origin,
23+
districtnamesupplied__c: :district_name,
24+
ncesid__c: :district_nces_id,
25+
schoolrollnumber__c: :school_roll_number
26+
}.freeze
27+
28+
def perform(school_id:)
29+
school = ::School.find(school_id)
30+
31+
sf_school = Salesforce::School.find_or_initialize_by(editoruuid__c: school_id)
32+
sf_school.attributes = sf_school_attributes(school:)
33+
sf_school.save!
34+
end
35+
36+
private
37+
38+
def sf_school_attributes(school:)
39+
mapped_attributes(school:).to_h do |sf_field, value|
40+
value = 'for_education' if sf_field == :userorigin__c && value.nil?
41+
value = truncate_value(sf_field:, value:) if value.is_a?(String)
42+
43+
[sf_field, value]
44+
end
45+
end
46+
47+
def mapped_attributes(school:)
48+
FIELD_MAPPINGS.transform_values do |school_field|
49+
school.send(school_field)
50+
end
51+
end
52+
53+
def concurrency_key_id = arguments.first.with_indifferent_access[:school_id]
54+
end
55+
end

app/models/role.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ class Role < ApplicationRecord
1616
}
1717
)
1818

19+
after_commit :do_salesforce_sync, on: %i[create update], if: -> { FeatureFlags.salesforce_sync? && !student? }
20+
1921
private
2022

2123
def students_cannot_have_additional_roles
@@ -38,4 +40,8 @@ def users_can_only_have_roles_in_one_school
3840

3941
errors.add(:base, 'Cannot create role as this user already has a role in a different school')
4042
end
43+
44+
def do_salesforce_sync
45+
Salesforce::RoleSyncJob.perform_later(role_id: id)
46+
end
4147
end

app/models/salesforce/base.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
module Salesforce
4+
class Base < ApplicationRecord
5+
self.abstract_class = true
6+
7+
connects_to database: { writing: :salesforce_connect }
8+
end
9+
end

app/models/salesforce/contact.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
module Salesforce
4+
class Contact < Salesforce::Base
5+
self.table_name = 'salesforce.contact'
6+
self.primary_key = :pi_accounts_unique_id__c
7+
end
8+
end

app/models/salesforce/role.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
module Salesforce
4+
class Role < Salesforce::Base
5+
self.table_name = 'salesforce.contact_editor_affiliation__c'
6+
self.primary_key = :affiliation_id__c
7+
end
8+
end

0 commit comments

Comments
 (0)