diff --git a/.env.example b/.env.example index 7d94cf136..cfbaebc47 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/jobs/salesforce/salesforce_sync_job.rb b/app/jobs/salesforce/salesforce_sync_job.rb new file mode 100644 index 000000000..2b738c6c0 --- /dev/null +++ b/app/jobs/salesforce/salesforce_sync_job.rb @@ -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 + diff --git a/app/jobs/salesforce/school_sync_job.rb b/app/jobs/salesforce/school_sync_job.rb new file mode 100644 index 000000000..0ab4fdb72 --- /dev/null +++ b/app/jobs/salesforce/school_sync_job.rb @@ -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 diff --git a/app/models/salesforce/base.rb b/app/models/salesforce/base.rb new file mode 100644 index 000000000..4229edba7 --- /dev/null +++ b/app/models/salesforce/base.rb @@ -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 diff --git a/app/models/salesforce/school.rb b/app/models/salesforce/school.rb new file mode 100644 index 000000000..db63167fc --- /dev/null +++ b/app/models/salesforce/school.rb @@ -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 diff --git a/app/models/school.rb b/app/models/school.rb index 24531d48a..db0363fd6 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -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) @@ -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 diff --git a/config/database.yml b/config/database.yml index c4ddd41f6..3884d4b8a 100644 --- a/config/database.yml +++ b/config/database.yml @@ -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', "") %> diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 600f199eb..38399b54a 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 99541f0fd..4117cb24d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,8 @@ services: depends_on: db: condition: service_healthy + salesforce_connect: + condition: service_healthy volumes: - .:/app - bundle-data-v2:/usr/local/bundle @@ -46,6 +48,10 @@ 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: @@ -53,7 +59,29 @@ services: 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: diff --git a/lib/tasks/salesforce_sync.rake b/lib/tasks/salesforce_sync.rake new file mode 100644 index 000000000..a304bf955 --- /dev/null +++ b/lib/tasks/salesforce_sync.rake @@ -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 diff --git a/spec/jobs/salesforce/school_sync_job_spec.rb b/spec/jobs/salesforce/school_sync_job_spec.rb new file mode 100644 index 000000000..c44f76b46 --- /dev/null +++ b/spec/jobs/salesforce/school_sync_job_spec.rb @@ -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