Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
51 changes: 51 additions & 0 deletions app/jobs/salesforce/lesson_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 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: lesson.remixes.count,
numberofcompletedprojects__c: lesson.submitted_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

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
9 changes: 9 additions & 0 deletions app/models/class_student.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,21 @@ class ClassStudent < ApplicationRecord
}
)

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

def user_id
student_id
end

private

# Roster changes only affect Classroom.numberofmembers__c — Lesson.numberofassignedprojects__c
# is driven by remix Project creation (see Project#enqueue_lesson_sync_for_remix), not by
# students joining or leaving a class.
def do_salesforce_sync
Salesforce::SchoolClassSyncJob.perform_later(school_class_id: school_class_id)
end

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

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

# When a teacher is added or removed from an existing SchoolClass, we refresh the
# classroom mirror (member counts) AND publish the affiliation. On destroy we only
# refresh the classroom; the now-deleted affiliation can't be republished.
#
# When a teacher is created as part of a brand-new SchoolClass (in the same transaction),
# neither callback fires here — the SchoolClass's own after_commit handles the classroom
# sync, and the initial teacher affiliations are not auto-published (backfill via
# `rake salesforce_sync:class_teacher` if needed). Suppressing the duplicates keeps the
# queue tidy and avoids racing the SchoolClassSyncJob.
EXISTING_CLASS_THRESHOLD = 1.second

after_commit :enqueue_school_class_sync, on: :destroy, if: -> { FeatureFlags.salesforce_sync? }
after_commit :enqueue_syncs_for_existing_class, on: :create, if: lambda {
FeatureFlags.salesforce_sync? && school_class.created_at < EXISTING_CLASS_THRESHOLD.ago
}

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_syncs_for_existing_class
enqueue_school_class_sync
Salesforce::ClassTeacherSyncJob.perform_later(class_teacher_id: id)
end

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

Expand Down
7 changes: 7 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 @@ -43,6 +46,10 @@ def recalculate_submitted_projects_count!

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
15 changes: 15 additions & 0 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ module Types
}
)

# A remix Project is created the first time a student saves work on a lesson, which is
# exactly when `lesson.remixes.count` (Salesforce numberofassignedprojects__c) changes.
# Triggering the sync here keeps that count fresh without scanning every lesson in the
# class on every roster change.
after_commit :enqueue_lesson_sync_for_remix,
on: :create,
if: -> { FeatureFlags.salesforce_sync? && remixed_from_id.present? }

def self.students(school, current_user)
SchoolStudent::List.call(school:, token: current_user.token, student_ids: pluck(:user_id).uniq)[:school_students] || []
end
Expand Down Expand Up @@ -103,6 +111,13 @@ def self_and_ancestors

private

def enqueue_lesson_sync_for_remix
lesson_id = parent&.lesson_id
return if lesson_id.blank?

Salesforce::LessonSyncJob.perform_later(lesson_id:)
end

def check_unique_not_null
self.identifier ||= PhraseIdentifier.generate
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
9 changes: 9 additions & 0 deletions app/models/school_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ def recalculate_lesson_submitted_projects_count!(_transition = nil)
lesson&.recalculate_submitted_projects_count!
end

def enqueue_salesforce_lesson_sync(_transition = nil)
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
2 changes: 2 additions & 0 deletions app/state_machines/school_project_state_machine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ class SchoolProjectStateMachine

after_transition(to: :submitted, &:recalculate_lesson_submitted_projects_count!)
after_transition(from: :submitted, &:recalculate_lesson_submitted_projects_count!)
after_transition(to: %i[submitted complete], &:enqueue_salesforce_lesson_sync)
after_transition(from: :complete, &:enqueue_salesforce_lesson_sync)
end
Loading
Loading