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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,11 @@ RESEED_API_KEY=changeme

# Enable immediate onboarding for schools
ENABLE_IMMEDIATE_SCHOOL_ONBOARDING=true

# Salesforce Connect
SALESFORCE_ENABLED=true
SALESFORCE_CONNECT_HOST=salesforce_connect
SALESFORCE_CONNECT_PORT=4101
SALESFORCE_CONNECT_DB=salesforce_development
SALESFORCE_CONNECT_PASSWORD=password
SALESFORCE_CONNECT_USER=postgres
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ jobs:
contents: read
issues: write
pull-requests: write
packages: read
env:
RAILS_ENV: test
POSTGRES_DB: choco_cake_test
Expand All @@ -56,6 +57,11 @@ jobs:
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: deterministic-key
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: derivation-salt
EDITOR_ENCRYPTION_KEY: a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef0
SALESFORCE_CONNECT_HOST: 127.0.0.1
SALESFORCE_CONNECT_PORT: '4101'
SALESFORCE_CONNECT_USER: postgres
SALESFORCE_CONNECT_PASSWORD: password
SALESFORCE_CONNECT_DB: salesforce_test
services:
postgres:
image: postgres:12
Expand All @@ -74,6 +80,22 @@ jobs:
image: redis:6.2-alpine
ports:
- 6379:6379
salesforce_connect:
image: 'ghcr.io/raspberrypifoundation/heroku-connect'
credentials:
username: ${{ github.actor }}
password: ${{ secrets.github_token }}
env:
POSTGRES_DB: salesforce_test
POSTGRES_PASSWORD: password
POSTGRES_USER: postgres
ports:
- 4101:5432
options: >-
--health-cmd="pg_isready -h 127.0.0.1 -U postgres"
--health-interval=5s
--health-timeout=5s
--health-retries=5

steps:
- uses: actions/checkout@v4
Expand Down
17 changes: 17 additions & 0 deletions app/jobs/salesforce/contact_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module Salesforce
class ContactSyncJob < SalesforceSyncJob
MODEL_CLASS = Salesforce::Contact

def perform(school_id:)
school = ::School.find(school_id)

sf_contact = Salesforce::Contact.find_by(pi_accounts_unique_id__c: school.creator_id)
raise SalesforceRecordNotFound, "Contact not found for creator_id: #{school.creator_id}" unless sf_contact

sf_contact.experiencecsagreetouxcontact__c = school.creator_agree_to_ux_contact
sf_contact.save!
end
end
end
42 changes: 42 additions & 0 deletions app/jobs/salesforce/role_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

module Salesforce
class RoleSyncJob < SalesforceSyncJob
MODEL_CLASS = Salesforce::Role

FIELD_MAPPINGS = {
affiliation_id__c: :id,
contact__r__pi_accounts_unique_id__c: :user_id,
editor__r__editoruuid__c: :school_id,
roletype__c: :role,
createdat__c: :created_at,
updatedat__c: :updated_at
}.freeze

def perform(role_id:)
role = ::Role.find(role_id)

return if role.student?

sf_role = Salesforce::Role.find_or_initialize_by(affiliation_id__c: role_id)
sf_role.attributes = sf_role_attributes(role:)
sf_role.save!
end

private

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)

[sf_field, value]
end
end

def mapped_attributes(role:)
FIELD_MAPPINGS.transform_values do |role_field|
role.send(role_field)
end
end
end
end
47 changes: 47 additions & 0 deletions app/jobs/salesforce/salesforce_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

module Salesforce
class SalesforceSyncJob < ApplicationJob
include GoodJob::ActiveJobExtensions::Concurrency

# Serialise concurrent performs for the same record (same class + record ID)
# to prevent TOCTOU races on find_or_initialize_by + save!, while allowing
# jobs for different records to run fully in parallel.
good_job_control_concurrency_with(
perform_limit: 1,
key: -> { "#{self.class.name}/#{arguments.first.values.first}" }
)

class SalesforceRecordNotFound < StandardError
end

class SkipBecauseSalesforceIsDisabled < StandardError
end

discard_on SkipBecauseSalesforceIsDisabled

retry_on SalesforceRecordNotFound, wait: :polynomially_longer, attempts: 10

include ActionView::Helpers::SanitizeHelper

queue_as :salesforce_sync

before_perform do |_job|
salesforce_enabled = ENV.fetch('SALESFORCE_ENABLED', 'true') == 'true'
raise SkipBecauseSalesforceIsDisabled, 'SALESFORCE_ENABLED is not true.' unless salesforce_enabled
end

def perform(*)
raise NotImplementedError, 'Subclasses must implement perform'
end

private

def truncate_value(sf_field:, value:)
column = self.class::MODEL_CLASS.column_for_attribute(sf_field)
return value if column.limit.nil?

value.truncate(column.limit, omission: '…')
end
end
end
53 changes: 53 additions & 0 deletions app/jobs/salesforce/school_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

module Salesforce
class SchoolSyncJob < SalesforceSyncJob
MODEL_CLASS = Salesforce::School

FIELD_MAPPINGS = {
editoruuid__c: :id,
name: :name,
editorreference__c: :reference,
addressline1__c: :address_line_1,
addressline2__c: :address_line_2,
editormunicipality__c: :municipality,
editoradministrativearea__c: :administrative_area,
postcode__c: :postal_code,
countrycode__c: :country_code,
verifiedat__c: :verified_at,
createdat__c: :created_at,
updatedat__c: :updated_at,
rejectedat__c: :rejected_at,
website__c: :website,
userorigin__c: :user_origin,
districtnamesupplied__c: :district_name,
ncesid__c: :district_nces_id,
schoolrollnumber__c: :school_roll_number
}.freeze

def perform(school_id:)
school = ::School.find(school_id)

sf_school = Salesforce::School.find_or_initialize_by(editoruuid__c: school_id)
sf_school.attributes = sf_school_attributes(school:)
sf_school.save!
end

private

def sf_school_attributes(school:)
mapped_attributes(school:).to_h do |sf_field, value|
value = 'for_education' if sf_field == :userorigin__c && value.nil?
value = truncate_value(sf_field:, value:) if value.is_a?(String)

[sf_field, value]
end
end

def mapped_attributes(school:)
FIELD_MAPPINGS.transform_values do |school_field|
school.send(school_field)
end
end
end
end
6 changes: 6 additions & 0 deletions app/models/role.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class Role < ApplicationRecord
}
)

after_commit :do_salesforce_sync, on: %i[create update]

private

def students_cannot_have_additional_roles
Expand All @@ -38,4 +40,8 @@ def users_can_only_have_roles_in_one_school

errors.add(:base, 'Cannot create role as this user already has a role in a different school')
end

def do_salesforce_sync
Salesforce::RoleSyncJob.perform_later(role_id: id)
end
end
9 changes: 9 additions & 0 deletions app/models/salesforce/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module Salesforce
class Base < ApplicationRecord
self.abstract_class = true

connects_to database: { writing: :salesforce_connect }
end
end
8 changes: 8 additions & 0 deletions app/models/salesforce/contact.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

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

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

module Salesforce
class School < Salesforce::Base
self.table_name = 'salesforce.editor__c'
self.primary_key = :editoruuid__c
end
end
7 changes: 7 additions & 0 deletions app/models/school.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class School < ApplicationRecord
# TODO: Remove the conditional once the feature flag is retired
after_create :generate_code!, if: -> { FeatureFlags.immediate_school_onboarding? }

after_commit :do_salesforce_sync, on: %i[create update]

def self.find_for_user!(user)
school = Role.find_by(user_id: user.id)&.school || find_by(creator_id: user.id)
raise ActiveRecord::RecordNotFound unless school
Expand Down Expand Up @@ -169,4 +171,9 @@ def format_uk_postal_code
# ensures UK postcodes are always formatted correctly (as the inward code is always 3 chars long)
self.postal_code = "#{cleaned_postal_code[0..-4]} #{cleaned_postal_code[-3..]}"
end

def do_salesforce_sync
Salesforce::SchoolSyncJob.perform_later(school_id: id)
Salesforce::ContactSyncJob.perform_later(school_id: id)
end
end
34 changes: 28 additions & 6 deletions config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,36 @@ default: &default
password: <%= ENV.fetch('POSTGRES_PASSWORD', '') %>
pool: <%= ENV.fetch('RAILS_MAX_THREADS', 5) %>

salesforce_connect: &salesforce_connect
adapter: postgresql
encoding: unicode
host: <%= ENV.fetch('SALESFORCE_CONNECT_HOST', 'localhost') %>
port: <%= ENV.fetch('SALESFORCE_CONNECT_PORT', '5432') %>
username: <%= ENV.fetch('SALESFORCE_CONNECT_USER', '') %>
password: <%= ENV.fetch('SALESFORCE_CONNECT_PASSWORD', '') %>
pool: <%= ENV.fetch('RAILS_MAX_THREADS', 5) %>
database_tasks: false

development:
<<: *default
database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_development') %>
default:
<<: *default
database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_development') %>
salesforce_connect:
<<: *salesforce_connect
database: <%= ENV.fetch('SALESFORCE_CONNECT_DB', 'salesforce_development') %>

test:
<<: *default
database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_test') %>
default:
<<: *default
database: <%= ENV.fetch('POSTGRES_DB', 'choco_cake_test') %>
salesforce_connect:
<<: *salesforce_connect
database: <%= ENV.fetch('SALESFORCE_CONNECT_DB', 'salesforce_test') %>

production:
<<: *default
url: <%= ENV['DATABASE_URL'] %>
default:
<<: *default
url: <%= ENV['DATABASE_URL'] %>
salesforce_connect:
<<: *salesforce_connect
url: <%= ENV.fetch('SALESFORCE_CONNECT_URL', "") %>
2 changes: 1 addition & 1 deletion config/initializers/good_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ def authenticate_admin
# The create_students_job queue is a serial queue that allows only one job at a time.
# DO NOT change the value of create_students_job:1 without understanding the implications
# of processing more than one user creation job at once.
config.good_job.queues = 'create_students_job:1;import_schools_job:1;default:5'
config.good_job.queues = 'create_students_job:1;import_schools_job:1;salesforce_sync:10;default:5'
end
28 changes: 28 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ services:
depends_on:
db:
condition: service_healthy
salesforce_connect:
condition: service_healthy
volumes:
- .:/app
- bundle-data-v2:/usr/local/bundle
Expand All @@ -46,14 +48,40 @@ services:
- POSTGRES_DB
- POSTGRES_PASSWORD
- POSTGRES_USER
- SALESFORCE_CONNECT_HOST=salesforce_connect
- SALESFORCE_CONNECT_USER=postgres
- SALESFORCE_CONNECT_PASSWORD=password
- SALESFORCE_CONNECT_DB=salesforce_development
extra_hosts:
- "host.docker.internal:host-gateway"
smee:
image: deltaprojects/smee-client
platform: linux/amd64
command: -u $SMEE_TUNNEL -t http://api:3009/github_webhooks

salesforce_connect:
image: ghcr.io/raspberrypifoundation/heroku-connect
volumes:
- salesforce_connect_data:/var/lib/postgres/data/
environment:
- POSTGRES_DB=salesforce_development
- POSTGRES_CLONE_DB=salesforce_test
- POSTGRES_PASSWORD=password
- POSTGRES_USER=postgres
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}",
]
interval: 5s
timeout: 5s
retries: 10
ports:
- "4101:5432"

volumes:
postgres-data:
bundle-data-v2:
node_modules:
salesforce_connect_data:
Loading
Loading