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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,9 @@ 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_PASSWORD=password
SALESFORCE_CONNECT_USER=postgres
40 changes: 40 additions & 0 deletions app/jobs/salesforce/salesforce_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# # frozen_string_literal: true

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

good_job_control_concurrency_with(
perform_throttle: [2, 1.second]
)

SalesforceRecordNotFound = Class.new(StandardError)
SkipBecauseSalesforceIsDisabled = Class.new(StandardError)

include ActionView::Helpers::SanitizeHelper

queue_as :salesforce_sync

discard_on SkipBecauseSalesforceIsDisabled

before_perform do |_job|
unless ENV.fetch('SALESFORCE_ENABLED', 'true') == 'true'
raise SkipBecauseSalesforceIsDisabled, 'SALESFORCE_ENABLED is not true.'
end
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

57 changes: 57 additions & 0 deletions app/jobs/salesforce/school_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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,
ExperienceCSAgreeToUXContact__c: :creator_agree_to_ux_contact,
UserOrigin__c: :user_origin,
DistrictNameSupplied__c: :district_name,
NCESID__c: :district_nces_id,
SchoolRollNumber__c: :school_roll_number,
}.freeze

STATUS_MAPPINGS = {}.freeze


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

sf_school = Salesforce::School.find_or_initialize_by(school_id__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 = 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
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
9 changes: 9 additions & 0 deletions app/models/salesforce/school.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# # frozen_string_literal: true

module Salesforce
class School < Salesforce::Base
self.table_name = 'salesforce.school__c' # TODO Confirm this - placeholder
self.primary_key = :school_id__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 @@ -51,6 +51,9 @@ class School < ApplicationRecord

# TODO: Remove the conditional once the feature flag is retired
after_create :generate_code!, if: -> { FeatureFlags.immediate_school_onboarding? }

# After creation, sync the School to Salesforce.
after_commit :do_salesforce_sync, on: [:create, :update]

def self.find_for_user!(user)
school = Role.find_by(user_id: user.id)&.school || find_by(creator_id: user.id)
Expand Down Expand Up @@ -169,4 +172,8 @@ 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)
end
end
33 changes: 27 additions & 6 deletions config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,35 @@ 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') %>
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_development') %>

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:1,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:
10 changes: 10 additions & 0 deletions lib/tasks/salesforce_sync.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

namespace :salesforce_sync do
desc 'Sync all Schools to Salesforce'
task school: :environment do
School.pluck(:id).each do |school_id|
Salesforce::SchoolSyncJob.perform_later(school_id:)
end
end
end
94 changes: 94 additions & 0 deletions spec/jobs/salesforce/school_sync_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Salesforce::SchoolSyncJob do
subject(:perform_job) { described_class.new.perform(school_id: school.id) }

context 'when the job has run' do
let(:school) { create(:school) }
let(:sf_school) { Salesforce::School.find(school.id) }

before { perform_job }

it 'syncs the standard mappings' do # rubocop:disable RSpec/ExampleLength
described_class::FIELD_MAPPINGS.each do |sf_field, field|
sf_value = sf_school.send(sf_field)
school_value = school.send(field)

expect(sf_value).to eq(school_value), "Expected #{sf_field} to be #{school_value.inspect} but was #{sf_value.inspect}"
end
end

context 'when address fields are very long' do
let(:max_field_size) { Salesforce::School.column_for_attribute(:AddressLine1__c).limit }
let(:school) { create(:school, address_line_1: '❌' * (max_field_size + 10)) }

it 'truncates AddressLine1__c to the max field length' do
expect(sf_school.AddressLine1__c.size).to be <= max_field_size
end
end

context 'when the school is verified' do
let(:school) { create(:verified_school) }

it 'syncs VerifiedAt__c' do
expect(sf_school.VerifiedAt__c).to eq(school.verified_at)
end
end

context 'when the school is rejected' do
let(:school) do
s = create(:school)
s.update_column(:rejected_at, Time.current)
s
end

it 'syncs RejectedAt__c' do
expect(sf_school.RejectedAt__c).to eq(school.rejected_at)
end
end

context 'when creator_agree_to_ux_contact is true' do
let(:school) { create(:school, creator_agree_to_ux_contact: true) }

it 'syncs ExperienceCSAgreeToUXContact__c as true' do
expect(sf_school.ExperienceCSAgreeToUXContact__c).to be true
end
end

context 'when creator_agree_to_ux_contact is false' do
let(:school) { create(:school, creator_agree_to_ux_contact: false) }

it 'syncs ExperienceCSAgreeToUXContact__c as false' do
expect(sf_school.ExperienceCSAgreeToUXContact__c).to be false
end
end
end

context 'when the Salesforce school fails to save' do
let(:school) { create(:school) }
let(:sf_school) { instance_double(Salesforce::School) }

before do
allow(Salesforce::School).to receive(:find_or_initialize_by).with(school_id__c: school.id).and_return(sf_school)
allow(sf_school).to receive(:attributes=)
allow(sf_school).to receive(:save!).and_raise(ActiveRecord::RecordInvalid)
end

it 'raises an error' do
expect { perform_job }.to raise_error ActiveRecord::RecordInvalid
end
end

context 'when SALESFORCE_ENABLED is false' do
let(:school) { create(:school) }

before { stub_const('ENV', ENV.to_h.merge('SALESFORCE_ENABLED' => 'false')) }

it 'discards the job without syncing' do
expect(Salesforce::School).not_to receive(:find_or_initialize_by)
expect { perform_job }.to raise_error(Salesforce::SalesforceSyncJob::SkipBecauseSalesforceIsDisabled)
end
end
end
Loading