Skip to content

Sync Schools and Roles to Salesforce on Create and Update - #677

Merged
fspeirs merged 12 commits into
mainfrom
fs-sync-schools-to-salesforce
Mar 25, 2026
Merged

Sync Schools and Roles to Salesforce on Create and Update#677
fspeirs merged 12 commits into
mainfrom
fs-sync-schools-to-salesforce

Conversation

@fspeirs

@fspeirs fspeirs commented Feb 11, 2026

Copy link
Copy Markdown
Contributor

Status

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

  • Schoolafter_commit on create/update enqueues both SchoolSyncJob and ContactSyncJob.
  • Roleafter_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

@cla-bot cla-bot Bot added the cla-signed label Feb 11, 2026
@fspeirs
fspeirs force-pushed the fs-sync-schools-to-salesforce branch from d6ab230 to 4837a1c Compare February 12, 2026 11:23
@fspeirs
fspeirs force-pushed the fs-sync-schools-to-salesforce branch from 2e6f173 to fb20637 Compare February 23, 2026 13:26
@github-actions

github-actions Bot commented Mar 13, 2026

Copy link
Copy Markdown

Test coverage

90.18% line coverage reported by SimpleCov.
Run: https://github.com/RaspberryPiFoundation/editor-api/actions/runs/23539068448

@fspeirs fspeirs changed the title Sync Schools to Salesforce on Create and Update Sync Schools and Roles to Salesforce on Create and Update Mar 18, 2026
@fspeirs
fspeirs force-pushed the fs-sync-schools-to-salesforce branch 6 times, most recently from e084550 to 8241770 Compare March 20, 2026 15:04
@fspeirs
fspeirs marked this pull request as ready for review March 23, 2026 11:29
Copilot AI review requested due to automatic review settings March 23, 2026 11:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Salesforce (Heroku Connect) synchronization for Schools and Roles by introducing a dedicated salesforce_connect database connection, Salesforce-backed ActiveRecord models, and GoodJob jobs/queues to sync on create/update (plus backfill rake tasks).

Changes:

  • Add multi-database config (salesforce_connect) and local/CI Docker services to run Heroku Connect Postgres.
  • Add Salesforce AR models (Salesforce::Base, Salesforce::School/Role/Contact) and sync jobs (SalesforceSyncJob + per-model jobs).
  • Enqueue sync jobs from School/Role lifecycle callbacks and add specs/factories/rake tasks to validate & backfill.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
spec/models/school_spec.rb Adds expectations that Salesforce sync jobs are enqueued on School create/update.
spec/models/role_spec.rb Adds expectations that Salesforce sync job is enqueued on Role create/update.
spec/jobs/salesforce/school_sync_job_spec.rb Verifies SchoolSyncJob field mappings, truncation, and feature-flag discard behavior.
spec/jobs/salesforce/role_sync_job_spec.rb Verifies RoleSyncJob field mappings, student skip behavior, and feature-flag discard behavior.
spec/jobs/salesforce/contact_sync_job_spec.rb Verifies ContactSyncJob updates the opt-in field and retries when contact missing.
spec/factories/salesforce/school.rb Factory for Salesforce school records.
spec/factories/salesforce/role.rb Factory for Salesforce role records.
spec/factories/salesforce/contact.rb Factory for Salesforce contact records.
lib/tasks/salesforce_sync.rake Adds rake tasks to enqueue backfill sync jobs for all Schools/Roles/Contacts.
docker-compose.yml Adds salesforce_connect service and wires api dependency/env for local dev.
config/initializers/good_job.rb Adds a dedicated salesforce_sync queue with concurrency.
config/database.yml Adds salesforce_connect DB configuration for all envs (tasks disabled).
app/models/school.rb Enqueues Salesforce School + Contact sync jobs on create/update commit.
app/models/role.rb Enqueues Salesforce Role sync job on create/update commit.
app/models/salesforce/base.rb Establishes Salesforce model base class connected to salesforce_connect.
app/models/salesforce/school.rb Maps Salesforce school table and primary key.
app/models/salesforce/role.rb Maps Salesforce role/affiliation table and primary key.
app/models/salesforce/contact.rb Maps Salesforce contact table and primary key.
app/jobs/salesforce/salesforce_sync_job.rb Adds shared job behaviors: concurrency keying, retries, discard flag, truncation helper.
app/jobs/salesforce/school_sync_job.rb Implements School-to-Salesforce attribute mapping and save.
app/jobs/salesforce/role_sync_job.rb Implements Role-to-Salesforce attribute mapping (skips student roles) and save.
app/jobs/salesforce/contact_sync_job.rb Syncs school creator UX-contact opt-in to Salesforce contact.
.github/workflows/ci.yml Adds GHCR permissions and Heroku Connect service container for tests.
.env.example Documents Salesforce connect env vars for local development.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread spec/models/role_spec.rb
Comment thread app/models/school.rb
Comment thread app/models/role.rb
Comment thread lib/tasks/salesforce_sync.rake Outdated
Comment thread .env.example Outdated
Comment thread docker-compose.yml
Comment thread config/database.yml Outdated
Comment thread .github/workflows/ci.yml
Comment thread spec/factories/salesforce/role.rb Outdated
Comment thread spec/models/school_spec.rb
@fspeirs
fspeirs force-pushed the fs-sync-schools-to-salesforce branch from 239ff00 to 63a7445 Compare March 23, 2026 12:51
@fspeirs
fspeirs requested a review from Copilot March 23, 2026 12:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread .github/workflows/ci.yml Outdated
Comment thread app/jobs/salesforce/salesforce_sync_job.rb
Comment thread app/jobs/salesforce/salesforce_sync_job.rb
@fspeirs
fspeirs force-pushed the fs-sync-schools-to-salesforce branch 4 times, most recently from f5813b2 to 7afc8bb Compare March 23, 2026 13:50
@fspeirs
fspeirs requested a review from Copilot March 23, 2026 13:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/models/role.rb
Comment thread app/jobs/salesforce/school_sync_job.rb Outdated
Comment thread app/jobs/salesforce/role_sync_job.rb Outdated
Comment thread app/jobs/salesforce/contact_sync_job.rb Outdated
@fspeirs
fspeirs force-pushed the fs-sync-schools-to-salesforce branch 3 times, most recently from f1eaa91 to b824e69 Compare March 23, 2026 15:23
Comment thread app/jobs/salesforce/contact_sync_job.rb Outdated

@zetter-rpf zetter-rpf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it important to handle deletions of schools and roles?
I think schools are unlikely to be deleted, but roles may be if a school requests it.

This might be something you want to consider in a seperate PR.

@zetter-rpf

zetter-rpf commented Mar 24, 2026

Copy link
Copy Markdown
Contributor

Is is possible to configure this so that the salesforce database isn't needed to run the app and tests?

When we spoke before I said it would be useful if docker wasn't required to run the app as we don't all use it in the team. I can currently boot the server and the console on this branch without the salesforce database running, but see errors when I run the tests:

Maybe this is because it's trying to run the sync job inline during the test run?

 bundle exec rspec spec/models/school_spec.rb:31
Run options: include {locations: {"./spec/models/school_spec.rb" => [31]}}

Randomized with seed 45890

School
  associations
    has many roles (FAILED - 1)

Failures:

  1) School associations has many roles
     Failure/Error: connects_to database: { writing: :salesforce_connect }
     
     ActiveRecord::NoDatabaseError:
       We could not find your database: salesforce_development. Available database configurations can be found in config/database.yml.
     
       To resolve this error:
     
       - Did you not create the database, or did you delete it? To create the database, run:
     
           bin/rails db:create
     
       - Has the database name changed? Verify that config/database.yml contains the correct database name.
     # ./app/models/salesforce/base.rb:7:in '<class:Base>'
     # ./app/models/salesforce/base.rb:4:in '<module:Salesforce>'
     # ./app/models/salesforce/base.rb:3:in '<top (required)>'
     # ./app/models/salesforce/school.rb:4:in '<module:Salesforce>'
     # ./app/models/salesforce/school.rb:3:in '<top (required)>'
     # ./app/jobs/salesforce/school_sync_job.rb:5:in '<class:SchoolSyncJob>'
     # ./app/jobs/salesforce/school_sync_job.rb:4:in '<module:Salesforce>'
     # ./app/jobs/salesforce/school_sync_job.rb:3:in '<top (required)>'
     # ./app/models/school.rb:177:in 'School#do_salesforce_sync'
     # ./spec/models/school_spec.rb:9:in 'block (2 levels) in <top (required)>'
     # ------------------
     # --- Caused by: ---
     # PG::ConnectionBad:
     #   connection to server at "::1", port 5432 failed: FATAL:  database "salesforce_development" does not exist
     #   ./app/models/salesforce/base.rb:7:in '<class:Base>'

Finished in 0.20151 seconds (files took 2.06 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/models/school_spec.rb:31 # School associations has many roles

Randomized with seed 45890

@fspeirs
fspeirs force-pushed the fs-sync-schools-to-salesforce branch from 07cab8f to 1badbdc Compare March 24, 2026 14:35
@fspeirs
fspeirs temporarily deployed to editor-api-p-fs-sync-sc-unsngq March 24, 2026 14:35 Inactive
@fspeirs
fspeirs force-pushed the fs-sync-schools-to-salesforce branch 2 times, most recently from ba50e3a to b6bd57b Compare March 24, 2026 15:11
@fspeirs

fspeirs commented Mar 24, 2026

Copy link
Copy Markdown
Contributor Author

@zetter-rpf I've fully split out the Salesforce tests from the code.

If you run:

POSTGRES_HOST=localhost HOSTNAME=$(hostname) POSTGRES_USER=postgres POSTGRES_DB=choco_cake_test bundle exec rspec

You can successfully run the test suite locally without docker and without the Salesforce integration. In CI it will run inside docker and test the Salesforce integration directly.

Comment thread lib/feature_flags.rb Outdated
Comment thread .env.example Outdated
@fspeirs
fspeirs force-pushed the fs-sync-schools-to-salesforce branch from 1eeb786 to 5cf84d8 Compare March 25, 2026 11:06
fspeirs added 12 commits March 25, 2026 11:38
- Replace pluck(:id).each with find_each in all three sync tasks to
  avoid loading all IDs into memory at once
- Scope role backfill to non-student roles to avoid enqueuing no-op
  jobs for student roles (which RoleSyncJob immediately discards)
…B is unset

In development and test environments, if SALESFORCE_CONNECT_DB is not
set (i.e. the Heroku Connect Docker service is not running), the
salesforce_connect AR connection now falls back to the main database.
This prevents ActiveRecord::NoDatabaseError when developers run the
test suite locally without Docker.

When SALESFORCE_CONNECT_DB is set (Docker / CI), the connection is
directed to the real Heroku Connect datastore as before.
Step 1:

Tag all three Salesforce job specs with requires_salesforce_db: true.
Add a filter_run_excluding rule in rails_helper so these specs are
automatically skipped when SALESFORCE_CONNECT_DB is not set (i.e. the
developer is running tests locally without the heroku-connect Docker
service).

CI and Docker environments, which set SALESFORCE_CONNECT_DB, continue
to run the full Salesforce spec suite unchanged.

Step 2:

Make SALESFORCE_ENABLED opt-in (default false)

Previously salesforce_sync? returned true unless SALESFORCE_ENABLED was
explicitly set to 'false'. This caused GoodJob (which runs inline in
test mode) to execute SchoolSyncJob and RoleSyncJob immediately whenever
a school or role was created in any test — hitting Salesforce tables that
don't exist in the test database.

Change the check to opt-in: salesforce_sync? now returns true only when
SALESFORCE_ENABLED == 'true'. Production and Docker setups already set
this explicitly, so behaviour there is unchanged.

Update the model specs to set SALESFORCE_ENABLED=true for the describe
block that asserts jobs are enqueued, and update the feature flags spec
to reflect the new default-false behaviour.

Step 3:

Guard after_commit callbacks with feature flag condition

Move the FeatureFlags.salesforce_sync? check (and the student? check
for Role) from inside do_salesforce_sync into the after_commit :if
condition. This prevents the callback from firing at all when Salesforce
sync is disabled, avoiding any interaction with the queue adapter or
Salesforce models in tests that don't enable the feature.
This PR reflects a renaming of the `Contact` field in Salesforce from
`ExperienceCSAgreeToUXContact__c` to `EditorAgreeToUXContact__c` to
reflect the fact that it's generic to schools using either product and
not Experience-CS specific.
@fspeirs
fspeirs force-pushed the fs-sync-schools-to-salesforce branch from 5cf84d8 to f53a903 Compare March 25, 2026 11:38
@fspeirs

fspeirs commented Mar 25, 2026

Copy link
Copy Markdown
Contributor Author

@zetter-rpf I got a reply from the Salesforce team about deletions: it may be something they want to do in the future but the lack of syncing deletions is not a blocker for this work.

@fspeirs
fspeirs merged commit 9d9d042 into main Mar 25, 2026
6 checks passed
@fspeirs
fspeirs deleted the fs-sync-schools-to-salesforce branch March 25, 2026 13:19
@fspeirs fspeirs self-assigned this Mar 31, 2026
fspeirs added a commit that referenced this pull request Jun 9, 2026
Related to
https://github.com/RaspberryPiFoundation/experience-cs/issues/2142

## Summary

Extends the Heroku Connect Salesforce sync pattern from #677 to three
more Salesforce objects:

| editor-api model | Salesforce object | Fields synced |
| --- | --- | --- |
| `SchoolClass` | `Classroom__c` | identifiers, name, member count,
parent `Editor__c` lookup |
| `ClassTeacher` | `Contact_Classroom_Affiliation__c` | identifiers,
parent `Classroom__c` and `Contact` lookups |
| `Lesson` (classroom-bound) | `Lesson__c` | identifiers, name,
teacher-project metadata, assigned/completed counts, parent
`Classroom__c` lookup |

It also generalises the parent-sync race guard that #850 introduced for
`RoleSyncJob` so every sync job using `__r__<external_id>` lookups gets
it for free, and documents the operational shape of the sync layer in
`AGENTS.md`.

## Behaviour

**Triggers.** `after_commit` on each source model enqueues a per-record
GoodJob (`Salesforce::{SchoolClass,Lesson,ClassTeacher}SyncJob`), gated
on `FeatureFlags.salesforce_sync?`. Per-record concurrency on the base
job collapses duplicate enqueues from same-transaction commits (e.g. new
class + initial teacher).

**Lesson completion is two flows.**
`Lesson__c.numberofcompletedprojects__c` is the sum of:
- `Lesson#submitted_projects_count` — Statesman-driven, used by the Code
Editor (Python/HTML) flow when a `SchoolProject` transitions to
`:submitted`. Unchanged from main.
- `Lesson#finished_projects_count` — live read of
`school_projects.where(finished: true).count`, used by Experience CS for
Scratch projects (which flip `finished` directly and never enter the
state machine).

The two flows are mutually exclusive in practice, so summing is safe.
`SchoolProject` gets a new `after_commit` on `saved_change_to_finished?`
to enqueue `LessonSyncJob` when Experience CS flips the flag.

**Assignment count is event-driven.**
`Lesson__c.numberofassignedprojects__c` is `lesson.remixes.count`, which
only changes when a remix `Project` is created. The enqueue lives on
`Project after_commit :create` (`remixed_from_id.present?`), not on
roster changes — so adding a student to a class no longer scans every
lesson in the class.

**Parent-sync race guard.** `ensure_parent_synced!(model,
external_id_field, external_id, label)` on
`Salesforce::SalesforceSyncJob` checks the parent has a non-nil `sfid`
in its Heroku Connect mirror and raises `SalesforceRecordNotFound` if
not. The base job retries with polynomial backoff, so jobs self-heal
once the parent lands instead of leaving the child row permanently
`FAILED` in Heroku Connect. This is a generalisation of the mechanism
introduced in #850.

## Backfill

After deploying with `SALESFORCE_ENABLED=true`, run the backfill rake
tasks **in this order** so each child's parent already has an `sfid` in
its mirror by the time the child enqueues:

```bash
rails salesforce_sync:school
rails salesforce_sync:role
rails salesforce_sync:contact
rails salesforce_sync:school_class
rails salesforce_sync:class_teacher
rails salesforce_sync:lesson
```

Each task is `Model.find_each { perform_later }` — non-blocking; the
worker drains the `salesforce_sync` queue at concurrency 10. Watch
progress at `/admin/good_job`.

Strict ordering isn't load-bearing: `ensure_parent_synced!` defers any
child whose parent hasn't landed via `retry_on SalesforceRecordNotFound,
wait: :polynomially_longer, attempts: 10`, so out-of-order runs
self-heal. Running parents first just cuts retry churn.

Re-running is idempotent — each job uses `find_or_initialize_by` on the
external-ID key, so repeats update in place rather than duplicating
rows.

## Out of scope

- **Destroy semantics for mirror rows.** This PR doesn't delete or
soft-delete mirror rows when the source record is destroyed (existing
behaviour). Worth deciding before GA.
- **Observability on race-guard exhaustion.** No Sentry alert when
`retry_on SalesforceRecordNotFound` exhausts its attempts.
- **`salesforce_sync:all` convenience task** to run the canonical
backfill order in one go.

## Test plan

- [ ] `docker compose run --rm api rspec spec/jobs/salesforce
spec/models/{lesson,school_class,school_project_salesforce_sync,class_teacher_salesforce_sync,class_student_salesforce_sync,project_salesforce_sync}_spec.rb`
- [ ] `docker compose run --rm api bundle exec rubocop`
- [ ] After deploy with `SALESFORCE_ENABLED=true`, run the backfill
tasks per the Backfill section above.
- [ ] In `salesforce_connect`, confirm every parent row has a non-nil
`sfid` before child writes and that any transient `_hc_err` rows
self-heal via the retry.


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants