Skip to content

Expand Salesforce sync to SchoolClass, ClassTeacher, and Lesson#851

Draft
fspeirs wants to merge 6 commits into
mainfrom
fs-expand-salesforce-sync
Draft

Expand Salesforce sync to SchoolClass, ClassTeacher, and Lesson#851
fspeirs wants to merge 6 commits into
mainfrom
fs-expand-salesforce-sync

Conversation

@fspeirs
Copy link
Copy Markdown
Contributor

@fspeirs fspeirs commented Jun 5, 2026

Summary

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

  • SchoolClassClassroom__c
  • ClassTeacherContact_Classroom_Affiliation__c
  • Lesson (classroom lessons only) → Lesson__c

Adds new mirror models, sync jobs, model/state-machine callbacks, and backfill rake tasks, and generalises the parent-sync race guard introduced in #850 onto the Salesforce::SalesforceSyncJob base class so every job that uses __r__<external_id> lookups gets it for free.

What's in each commit

  1. Add Heroku Connect mirror models for SchoolClass, Lesson, ClassTeacherSalesforce::SchoolClass / Lesson / ClassTeacher (subclasses of Salesforce::Base) plus factories. No behaviour change yet.
  2. Generalize ensure_parent_synced! onto SalesforceSyncJob base — move the helper added in Guard Salesforce::RoleSyncJob against missing parent records #850 out of RoleSyncJob and onto the base class. RoleSyncJob keeps its call sites unchanged.
  3. Add SchoolClass, ClassTeacher, Lesson sync jobs + backfill rake tasks — three new jobs (each calling ensure_parent_synced! for their parent) and salesforce_sync:school_class / :class_teacher / :lesson rake tasks.
  4. Enqueue Salesforce sync jobs from SchoolClass, Lesson, ClassTeacher, SchoolProjectafter_commit hooks for the three models, plus SchoolProjectStateMachine transitions in/out of :submitted and :complete to keep Lesson__c.numberofsubmittedprojects__c fresh. Includes a shared not_have_enqueued_job matcher under spec/support/.
  5. Trigger LessonSyncJob from remix creation, not class-roster changesnumberofassignedprojects__c reflects lesson.remixes.count, which only changes when a remix Project is created. Hook the enqueue on Project after_commit :create (remixed_from_id.present?) so the sync fires at the actual change point, and remove the O(lessons-per-class) enqueue from ClassStudent.
  6. Document the Salesforce / Heroku Connect workflow in AGENTS.md — env, feature flag, backfill order, and the parent-sync race-guard pattern.

ClassTeacher callback gating

Creating a brand-new SchoolClass with initial teachers fires both the SchoolClass after_commit and the ClassTeacher after_commit in the same transaction. To avoid duplicate SchoolClassSyncJobs and unsequenced affiliation writes:

  • On destroy, ClassTeacher only refreshes the classroom (SchoolClassSyncJob).
  • On create, ClassTeacher enqueues both SchoolClassSyncJob and ClassTeacherSyncJob only if the parent class was created in a prior transaction (school_class.created_at < EXISTING_CLASS_THRESHOLD.ago).
  • Initial teacher affiliations on a brand-new class are intentionally left to the salesforce_sync:class_teacher backfill task.

Test plan

  • docker compose run --rm api rspec spec/jobs/salesforce spec/models/{school_class,lesson,class_student_salesforce_sync,class_teacher_salesforce_sync,project_salesforce_sync}_spec.rb
  • docker compose run --rm api bundle exec rubocop
  • After deploy with SALESFORCE_ENABLED=true, run backfills in order: rails salesforce_sync:school salesforce_sync:role salesforce_sync:contact salesforce_sync:school_class salesforce_sync:class_teacher salesforce_sync:lesson
  • Verify in salesforce_connect: every parent row has a non-nil sfid before child writes; failed rows should self-heal via retry_on SalesforceRecordNotFound.

Notes / follow-ups (not in this PR)

  • Destroy semantics for mirror rows. This PR does not 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; happy to add a follow-up.
  • salesforce_sync:all convenience task to run the canonical backfill order in one go.

Made with Cursor

fspeirs and others added 6 commits June 5, 2026 16:13
Mirrors three more Salesforce objects in the salesforce_connect schema:
Classroom__c, Lesson__c, and Contact_Classroom_Affiliation__c. Each model
inherits from Salesforce::Base and exposes the external-ID column used by
later sync jobs. Factories are added for spec coverage.

Subsequent commits wire the sync jobs and model callbacks that write to
these mirrors.

Co-authored-by: Cursor <cursoragent@cursor.com>
The parent-sync race guard introduced for RoleSyncJob in #850 is needed by
every job that resolves a Salesforce parent via an __r__<external_id_field>
lookup. Move the helper (and its comment) onto Salesforce::SalesforceSyncJob
so subsequent jobs (SchoolClass, Lesson, ClassTeacher) can call it without
duplicating the implementation. RoleSyncJob now inherits the behaviour.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds three new GoodJob-backed sync jobs that mirror each model to its
Heroku Connect counterpart:

  - Salesforce::SchoolClassSyncJob       -> Classroom__c
  - Salesforce::ClassTeacherSyncJob      -> Contact_Classroom_Affiliation__c
  - Salesforce::LessonSyncJob            -> Lesson__c

Each job inherits the parent-sync race guard from SalesforceSyncJob and
calls ensure_parent_synced! before saving to defer writes until the
parent record has landed in Salesforce. Backfill rake tasks are added
under the existing salesforce_sync namespace so operators can replay each
sync after deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
…SchoolProject

Wires the new sync jobs to the domain events that affect their mirror rows:

  - SchoolClass after_commit (create/update)   -> SchoolClassSyncJob
  - Lesson after_commit (create/update, classroom lessons only) -> LessonSyncJob
  - ClassTeacher after_commit (destroy) -> SchoolClassSyncJob (refresh members)
  - ClassTeacher after_commit (create on an existing class)
      -> SchoolClassSyncJob + ClassTeacherSyncJob
  - SchoolProject state transitions in/out of submitted/complete
      -> LessonSyncJob (refresh submitted-projects count)

The ClassTeacher create callback is gated by EXISTING_CLASS_THRESHOLD so it
fires only when the parent SchoolClass was created in a prior transaction;
brand-new classes are handled by the SchoolClass after_commit, and initial
teacher affiliations are intentionally left to the salesforce_sync:class_teacher
backfill task.

Adds spec/support/negated_matchers.rb (a shared not_have_enqueued_job
matcher used by these specs and by the class_student spec in the next commit).

Co-authored-by: Cursor <cursoragent@cursor.com>
A Lesson's numberofassignedprojects__c reflects lesson.remixes.count, which
only changes when a student creates a remix project for the lesson — not
when students join or leave the class. Hook the LessonSyncJob enqueue on
Project after_commit (on: :create, with remixed_from_id present) so the
sync fires at the actual point of change.

ClassStudent's after_commit therefore only enqueues SchoolClassSyncJob
(to refresh Classroom__c.numberofmembers__c); the per-lesson enqueue is
gone, which avoids the O(lessons-per-class) job storm on every roster
change.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a Salesforce/Heroku Connect section covering:

  - that sync targets the salesforce_connect DB (Heroku Connect mirror),
    not the Salesforce REST API
  - the SALESFORCE_ENABLED feature flag
  - the canonical post-deploy backfill order (school, role, contact,
    school_class, class_teacher, lesson)
  - the parent-sync race guard: when to call ensure_parent_synced!,
    where the helper lives, and the retry_on behaviour that makes it
    self-heal

Also records the spec env requirement (SALESFORCE_CONNECT_DB + matching
Heroku Connect tables) under Testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cla-bot cla-bot Bot added the cla-signed label Jun 5, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 5, 2026

Test coverage

SimpleCov coverage data was unavailable for this run.
Run: https://github.com/RaspberryPiFoundation/editor-api/actions/runs/27023310892

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.

1 participant