Skip to content
Merged
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ docker compose up
- Single spec: `docker compose run --rm api rspec spec/path/to/spec.rb`
- Lint: `docker compose run --rm api bundle exec rubocop`
- CI: GitHub Actions with Ruby 4, Postgres 12, Redis.
- Salesforce sync specs need `SALESFORCE_CONNECT_DB` set and matching Heroku Connect tables (schema comes from the published `heroku-connect` image after Salesforce mapping is exported).

## Salesforce / Heroku Connect
- Sync writes to the `salesforce_connect` DB (not a Salesforce API). Pattern from editor-api PR #677.
- Feature flag: `SALESFORCE_ENABLED=true`.
- After deploy, backfill: `rails salesforce_sync:school`, `salesforce_sync:role`, `salesforce_sync:contact`, `salesforce_sync:school_class`, `salesforce_sync:class_teacher`, `salesforce_sync:lesson`.
- **Parent-sync race guard (required for any job using `__r__` external-ID lookups).** Heroku Connect rejects an INSERT permanently with `Foreign key external ID … not found` if the parent record isn't yet in Salesforce — the mirror row stays `FAILED` forever (no auto-retry). Call `ensure_parent_synced!(model, external_id_field, external_id, label)` on `Salesforce::SalesforceSyncJob` (the base class) before saving a child record; it checks the parent has a non-nil `sfid` in its Heroku Connect mirror and raises `SalesforceRecordNotFound` if not. The base job declares `retry_on SalesforceRecordNotFound, wait: :polynomially_longer, attempts: 10` so the job self-heals once parents land. See `Salesforce::RoleSyncJob` and `Salesforce::ClassTeacherSyncJob` for call-site examples.

## Where to Look First
- Routes: `config/routes.rb`. Auth: `config/initializers/omniauth.rb`, `app/helpers/authentication_helper.rb`, `app/controllers/concerns/identifiable.rb`.
Expand Down
47 changes: 47 additions & 0 deletions app/jobs/salesforce/class_teacher_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

module Salesforce
class ClassTeacherSyncJob < SalesforceSyncJob
MODEL_CLASS = Salesforce::ClassTeacher

FIELD_MAPPINGS = {
contactclassroomaffiliationuuid__c: :id,
classroom__r__classroomuuid__c: :school_class_id,
contact_teacher__r__pi_accounts_unique_id__c: :teacher_id,
createdat__c: :created_at,
updatedat__c: :updated_at
}.freeze

def perform(class_teacher_id:)
class_teacher = ::ClassTeacher.find(class_teacher_id)

ensure_parent_synced!(Salesforce::SchoolClass, :classroomuuid__c, class_teacher.school_class_id, 'Classroom__c')
ensure_parent_synced!(Salesforce::Contact, :pi_accounts_unique_id__c, class_teacher.teacher_id, 'Contact')

sf_class_teacher = Salesforce::ClassTeacher.find_or_initialize_by(
contactclassroomaffiliationuuid__c: class_teacher_id
)
sf_class_teacher.attributes = sf_class_teacher_attributes(class_teacher:)

sf_class_teacher.save!
end

private

def sf_class_teacher_attributes(class_teacher:)
mapped_attributes(class_teacher:).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(class_teacher:)
FIELD_MAPPINGS.transform_values do |class_teacher_field|
class_teacher.send(class_teacher_field)
end
end

def concurrency_key_id = arguments.first.with_indifferent_access[:class_teacher_id]
end
end
62 changes: 62 additions & 0 deletions app/jobs/salesforce/lesson_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

module Salesforce
class LessonSyncJob < SalesforceSyncJob
MODEL_CLASS = Salesforce::Lesson

FIELD_MAPPINGS = {
lesson_uuid__c: :id,
classroom__r__classroomuuid__c: :school_class_id,
lessontitle__c: :name,
createdat__c: :created_at,
updatedat__c: :updated_at
}.freeze

def perform(lesson_id:)
lesson = ::Lesson.find(lesson_id)
return if lesson.school_class_id.blank?

ensure_parent_synced!(Salesforce::SchoolClass, :classroomuuid__c, lesson.school_class_id, 'Classroom__c')

sf_lesson = Salesforce::Lesson.find_or_initialize_by(lesson_uuid__c: lesson_id)
sf_lesson.attributes = sf_lesson_attributes(lesson:)

sf_lesson.save!
end

private

def sf_lesson_attributes(lesson:)
mapped_attributes(lesson:).merge(
teacherprojecttitle__c: lesson.project&.name,
teacherprojecttype__c: lesson.project&.project_type,
numberofassignedprojects__c: assigned_projects_count(lesson),
# Sum of the two completion paths: state-machine `:submitted` (Code Editor flow)
# and `school_projects.finished` (Experience CS flow). They are mutually exclusive
# per project, so the sum is safe.
numberofcompletedprojects__c: lesson.submitted_projects_count + lesson.finished_projects_count,
lastsyncdate__c: Time.current
).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(lesson:)
FIELD_MAPPINGS.transform_values do |lesson_field|
lesson.send(lesson_field)
end
end

# A lesson is "assigned" to every student in its class iff it's visible to them
# (visibility == 'students'). Other visibilities aren't assigned to students at all.
def assigned_projects_count(lesson)
return 0 unless lesson.visibility == 'students'

lesson.school_class.students.count
end

def concurrency_key_id = arguments.first.with_indifferent_access[:lesson_id]
end
end
13 changes: 0 additions & 13 deletions app/jobs/salesforce/role_sync_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,6 @@ def perform(role_id:)

return if role.student?

# The Contact_Editor_Affiliation__c row uses Salesforce external-ID lookups to resolve
# its parent Editor__c (school) and Contact (user). If either parent has not yet been
# pushed to Salesforce, Heroku Connect rejects the INSERT permanently with a
# "Foreign key external ID ... not found" error and the row is stuck FAILED in the
# mirror. Raising SalesforceRecordNotFound here defers the affiliation write via the
# SalesforceSyncJob retry_on, giving the parent records time to land in Salesforce.
ensure_parent_synced!(Salesforce::School, :editoruuid__c, role.school_id, 'Editor__c')
ensure_parent_synced!(Salesforce::Contact, :pi_accounts_unique_id__c, role.user_id, 'Contact')

Expand All @@ -41,13 +35,6 @@ def perform(role_id:)

private

def ensure_parent_synced!(model, external_id_field, external_id, label)
return if model.where(external_id_field => external_id).where.not(sfid: nil).exists?

raise SalesforceRecordNotFound,
"#{label} not yet synced for #{external_id_field}: #{external_id}"
end

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)
Expand Down
13 changes: 13 additions & 0 deletions app/jobs/salesforce/salesforce_sync_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ def concurrency_key_id
raise NotImplementedError, "#{self.class.name} must implement concurrency_key_id"
end

# Guard a write that resolves a Salesforce parent via an external-ID lookup
# (`__r__<external_id_field>`). Heroku Connect rejects the INSERT permanently with
# "Foreign key external ID ... not found" if the parent isn't yet in Salesforce, and
# the mirror row stays FAILED forever (no auto-retry). Raising SalesforceRecordNotFound
# here defers the write via the retry_on declared on this base class, so the job
# self-heals once the parent lands.
def ensure_parent_synced!(model, external_id_field, external_id, label)
return if model.where(external_id_field => external_id).where.not(sfid: nil).exists?

raise SalesforceRecordNotFound,
"#{label} not yet synced for #{external_id_field}: #{external_id}"
end

def truncate_value(sf_field:, value:)
column = self.class::MODEL_CLASS.column_for_attribute(sf_field)
return value if column.limit.nil?
Expand Down
47 changes: 47 additions & 0 deletions app/jobs/salesforce/school_class_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

module Salesforce
class SchoolClassSyncJob < SalesforceSyncJob
MODEL_CLASS = Salesforce::SchoolClass

FIELD_MAPPINGS = {
classroomuuid__c: :id,
editor__r__editoruuid__c: :school_id,
classroomtitle__c: :name,
createdat__c: :created_at,
updatedat__c: :updated_at
}.freeze

def perform(school_class_id:)
school_class = ::SchoolClass.find(school_class_id)

ensure_parent_synced!(Salesforce::School, :editoruuid__c, school_class.school_id, 'Editor__c')

sf_school_class = Salesforce::SchoolClass.find_or_initialize_by(classroomuuid__c: school_class_id)
sf_school_class.attributes = sf_school_class_attributes(school_class:)

sf_school_class.save!
end

private

def sf_school_class_attributes(school_class:)
mapped_attributes(school_class:).merge(
numberofmembers__c: school_class.students.count,
lastsyncdate__c: Time.current
).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_class:)
FIELD_MAPPINGS.transform_values do |school_class_field|
school_class.send(school_class_field)
end
end

def concurrency_key_id = arguments.first.with_indifferent_access[:school_class_id]
end
end
12 changes: 12 additions & 0 deletions app/models/class_student.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,24 @@ class ClassStudent < ApplicationRecord
}
)

after_commit :do_salesforce_sync, on: %i[create destroy], if: -> { FeatureFlags.salesforce_sync? }

def user_id
student_id
end

private

# Re-sync the parent SchoolClass when students join or leave so its synced member
# count stays current, and fan out to every lesson in the class that's visible to
# students.
def do_salesforce_sync
Salesforce::SchoolClassSyncJob.perform_later(school_class_id: school_class_id)
Lesson.where(school_class_id: school_class_id, visibility: 'students').find_each do |lesson|
Salesforce::LessonSyncJob.perform_later(lesson_id: lesson.id)
end
end

def student_has_the_school_student_role_for_the_school
return unless student_id_changed? && errors.blank? && student.present?

Expand Down
14 changes: 14 additions & 0 deletions app/models/class_teacher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,26 @@ class ClassTeacher < ApplicationRecord
}
)

# Re-sync the parent SchoolClass on add/remove so its synced teacher/member counts
# stay current. Only :create syncs the ClassTeacher itself — a destroyed record has
# nothing to publish.
after_commit :enqueue_school_class_sync, on: %i[create destroy], if: -> { FeatureFlags.salesforce_sync? }
after_commit :enqueue_class_teacher_sync, on: :create, if: -> { FeatureFlags.salesforce_sync? }

def user_id
teacher_id
end

private

def enqueue_school_class_sync
Salesforce::SchoolClassSyncJob.perform_later(school_class_id: school_class_id)
end

def enqueue_class_teacher_sync
Salesforce::ClassTeacherSyncJob.perform_later(class_teacher_id: id)
end
Comment thread
zetter-rpf marked this conversation as resolved.

def teacher_has_the_school_teacher_role_for_the_school
return unless teacher_id_changed? && errors.blank? && teacher.present?

Expand Down
11 changes: 11 additions & 0 deletions app/models/lesson.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ class Lesson < ApplicationRecord
validate :user_has_the_school_owner_or_school_teacher_role_for_the_school
validate :user_is_the_school_teacher_for_the_school_class

after_commit :do_salesforce_sync, on: %i[create update],
if: -> { FeatureFlags.salesforce_sync? && school_class_id.present? }

def self.users
User.from_userinfo(ids: pluck(:user_id))
end
Expand All @@ -41,8 +44,16 @@ def recalculate_submitted_projects_count!
end
end

def finished_projects_count
school_projects.where(finished: true).count
end

private

def do_salesforce_sync
Salesforce::LessonSyncJob.perform_later(lesson_id: id)
end

def assign_school_from_school_class
self.school ||= school_class&.school
end
Expand Down
8 changes: 8 additions & 0 deletions app/models/salesforce/class_teacher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module Salesforce
class ClassTeacher < Salesforce::Base
self.table_name = 'salesforce.contact_classroom_affiliation__c'
self.primary_key = :contactclassroomaffiliationuuid__c
end
end
8 changes: 8 additions & 0 deletions app/models/salesforce/lesson.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module Salesforce
class Lesson < Salesforce::Base
self.table_name = 'salesforce.lesson__c'
self.primary_key = :lesson_uuid__c
end
end
8 changes: 8 additions & 0 deletions app/models/salesforce/school_class.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module Salesforce
class SchoolClass < Salesforce::Base
self.table_name = 'salesforce.classroom__c'
self.primary_key = :classroomuuid__c
end
end
6 changes: 6 additions & 0 deletions app/models/school_class.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class SchoolClass < ApplicationRecord
}
)

after_commit :do_salesforce_sync, on: %i[create update], if: -> { FeatureFlags.salesforce_sync? }

def self.teachers
teacher_ids = all.map(&:teacher_ids).flatten.uniq
User.from_userinfo(ids: teacher_ids)
Expand Down Expand Up @@ -87,6 +89,10 @@ def submitted_projects_count

private

def do_salesforce_sync
Salesforce::SchoolClassSyncJob.perform_later(school_class_id: id)
end

def school_class_has_at_least_one_teacher
return if teachers.present?

Expand Down
15 changes: 15 additions & 0 deletions app/models/school_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ class SchoolProject < ApplicationRecord
initial_state: :unsubmitted
]

# Experience CS marks Scratch projects complete by flipping school_projects.finished
# directly (bypassing the state machine), so the parent lesson needs an explicit
# re-sync when this column changes. State-machine transitions are picked up via
# Lesson#recalculate_submitted_projects_count! → Lesson#after_commit, not here.
after_commit :enqueue_salesforce_lesson_sync, on: :update, if: :saved_change_to_finished?

def lesson
project.lesson || project.parent&.lesson
end
Expand All @@ -31,6 +37,15 @@ def recalculate_lesson_submitted_projects_count!(_transition = nil)
lesson&.recalculate_submitted_projects_count!
end

def enqueue_salesforce_lesson_sync
return unless FeatureFlags.salesforce_sync?

lesson_id = lesson&.id
return if lesson_id.blank?

Salesforce::LessonSyncJob.perform_later(lesson_id:)
end

# Add convenience methods for each state
def unsubmitted?
state_machine.in_state?(:unsubmitted)
Expand Down
Loading
Loading