Skip to content
8 changes: 8 additions & 0 deletions app/mailers/school_ownership_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ class SchoolOwnershipMailer < ApplicationMailer
def request_ownership_transfer
ownership_transfer = params[:ownership_transfer]
@school = ownership_transfer.school
@nominee_name = user_name(ownership_transfer.nominated_user_id)
@requested_owner_name = user_name(ownership_transfer.requested_by_user_id)
Comment on lines +9 to +10

mail(to: ownership_transfer.email_address,
subject: "You've been nominated to be an owner of #{@school.name}",
track_opens: 'true',
message_stream: 'outbound')
end

private

def user_name(user_id)
User.from_userinfo(ids: [user_id]).first&.name
end
end
18 changes: 18 additions & 0 deletions app/models/ownership_transfer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,31 @@ class OwnershipTransfer < ApplicationRecord
delegate :name, to: :school, prefix: true

belongs_to :school

enum :status, {
pending: 'pending', completed: 'completed', rejected: 'rejected', cancelled: 'cancelled'
}, default: :pending, validate: true

validates :nominated_user_id, presence: true
validates :requested_by_user_id, presence: true
validates :email_address,
format: { with: EmailValidator.regexp, message: I18n.t('validations.invitation.email_address') }
validate :nominee_has_the_school_owner_or_school_teacher_role_for_the_school

after_create_commit :send_ownership_transfer_request_email
encrypts :email_address

private

def nominee_has_the_school_owner_or_school_teacher_role_for_the_school
return unless nominated_user_id_changed? && errors.blank? && school

return if school.roles.exists?(user_id: nominated_user_id, role: %i[owner teacher])

msg = "'#{nominated_user_id}' does not have the 'owner' or 'teacher' role for school '#{school.id}'"
errors.add(:nominated_user_id, msg)
end

def send_ownership_transfer_request_email
SchoolOwnershipMailer.with(ownership_transfer: self).request_ownership_transfer.deliver_later
end
Expand Down
1 change: 1 addition & 0 deletions app/models/school.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class School < ApplicationRecord
has_many :roles, dependent: :nullify
has_many :school_projects, dependent: :nullify
has_many :school_email_domains, dependent: :destroy
has_many :ownership_transfers, dependent: :destroy

VALID_URL_REGEX = %r{\A(?:https?://)?(?:www.)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,63}(\.[a-z]{2,63})*(/.*)?\z}ix

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Hi there,
Hi <%= @nominee_name %>,

Current school owner has nominated you to become the new owner of the Code Classroom account for <%= @school.name %>.
<%= @requested_owner_name %> has nominated you to become the new owner of the Code Classroom account for <%= @school.name %>.

If you accept, you’ll take over ownership of the school account and be able to:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class AddNomineeAndStatusToOwnershipTransfers < ActiveRecord::Migration[8.1]
def change
add_column :ownership_transfers, :nominated_user_id, :uuid, null: false
add_column :ownership_transfers, :requested_by_user_id, :uuid, null: false
Comment on lines +5 to +6
add_column :ownership_transfers, :status, :string, null: false, default: 'pending'
end
end
5 changes: 4 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions spec/factories/ownership_transfer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

FactoryBot.define do
factory :ownership_transfer do
email_address { 'new-owner@example.com' }
school factory: :verified_school
school
email_address { Faker::Internet.email }
nominated_user_id { SecureRandom.uuid }
requested_by_user_id { SecureRandom.uuid }
Comment on lines +7 to +8
status { 'pending' }
end
end
19 changes: 18 additions & 1 deletion spec/mailers/previews/school_ownership_mailer_preview.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,26 @@

# Preview all emails at http://localhost:3009/rails/mailers/school_ownership_mailer
class SchoolOwnershipMailerPreview < ActionMailer::Preview
NOMINEE = { id: SecureRandom.uuid, name: 'Eliseo Ortiz' }.freeze
REQUESTED_OWNER = { id: SecureRandom.uuid, name: 'Oaklynn Duran' }.freeze

def request_ownership_transfer
school = School.new(name: 'Elmwood Secondary School')
ownership_transfer = OwnershipTransfer.new(email_address: 'teacher@example.com', school:)
stub_user_info_api

ownership_transfer = OwnershipTransfer.new(
email_address: 'teacher@example.com',
school:,
nominated_user_id: NOMINEE[:id],
requested_by_user_id: REQUESTED_OWNER[:id]
)
SchoolOwnershipMailer.with(ownership_transfer:).request_ownership_transfer
end

private

def stub_user_info_api
users = [NOMINEE, REQUESTED_OWNER]
UserInfoApiClient.define_singleton_method(:fetch_by_ids) { |ids| users.select { |u| ids.include?(u[:id]) } }
end
end
19 changes: 18 additions & 1 deletion spec/mailers/school_ownership_mailer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,29 @@
describe 'request_ownership_transfer' do
subject(:email) { described_class.with(ownership_transfer:).request_ownership_transfer }

let(:ownership_transfer) { create(:ownership_transfer) }
let(:school) { create(:verified_school) }
let(:nominee) { create(:teacher, school:) }
let(:requested_owner) { create(:owner, school:) }
let(:ownership_transfer) do
create(:ownership_transfer, school:, nominated_user_id: nominee.id, requested_by_user_id: requested_owner.id)
end

before do
stub_user_info_api_fetch_by_ids(user_ids: [nominee.id], users: [{ id: nominee.id, name: nominee.name }])
stub_user_info_api_fetch_by_ids(
user_ids: [requested_owner.id], users: [{ id: requested_owner.id, name: requested_owner.name }]
)
allow(ENV).to receive(:fetch).with('EDITOR_PUBLIC_URL').and_return('http://example.com')
end

it 'includes the nominee name in the body' do
expect(email.body.to_s).to include(nominee.name)
end

it 'includes the name of requested owner in the body' do
expect(email.body.to_s).to include(requested_owner.name)
end

it 'includes the school name in the body' do
expect(email.body.to_s).to include(ownership_transfer.school.name)
end
Expand Down
135 changes: 116 additions & 19 deletions spec/models/ownership_transfer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,134 @@
RSpec.describe OwnershipTransfer do
include ActionMailer::TestHelper

it 'has a valid factory' do
ownership_transfer = build(:ownership_transfer)
subject(:ownership_transfer) { build(:ownership_transfer, school:, nominated_user_id: nominee.id) }

expect(ownership_transfer).to be_valid
end
let(:school) { create(:verified_school) }
let(:nominee) { create(:teacher, school:) }

describe 'validations' do
it 'has a valid factory' do
expect(ownership_transfer).to be_valid
end

it 'requires a school' do
ownership_transfer.school = nil

expect(ownership_transfer).not_to be_valid
end

it 'requires a nominated_user_id' do
ownership_transfer.nominated_user_id = nil

expect(ownership_transfer).not_to be_valid
end

it 'requires a requested_by_user_id' do
ownership_transfer.requested_by_user_id = nil

it 'is invalid with an incorrectly formatted email address' do
ownership_transfer = build(:ownership_transfer, email_address: 'not-an-email-address')
expect(ownership_transfer).not_to be_valid
end

expect(ownership_transfer).not_to be_valid
it 'requires an email_address' do
ownership_transfer.email_address = nil

expect(ownership_transfer).not_to be_valid
end

it 'is invalid with an incorrectly formatted email address' do
ownership_transfer.email_address = 'not-an-email-address'

expect(ownership_transfer).not_to be_valid
end

it 'non-deterministically encrypts the email_address' do
ownership_transfer.save!

expect(described_class.find_by(email_address: ownership_transfer.email_address)).to be_nil
end
end

it 'sends an ownership transfer request email after create' do
school = create(:verified_school)
describe 'status' do
it 'defaults to pending on a new record' do
expect(ownership_transfer.status).to eq('pending')
end

it 'is valid for every declared status' do
described_class.statuses.each_key do |status|
ownership_transfer.status = status

ownership_transfer = described_class.create!(email_address: 'new-owner@example.com', school:)
expect(ownership_transfer).to be_valid
end
end

assert_enqueued_email_with SchoolOwnershipMailer, :request_ownership_transfer, params: { ownership_transfer: }
it 'is invalid when set to a status outside the enum' do
ownership_transfer.status = 'made-up-status'

expect(ownership_transfer).not_to be_valid
end

it 'exposes a predicate for the current status' do
described_class.statuses.each_key do |status|
ownership_transfer.status = status

expect(ownership_transfer.public_send("#{status}?")).to be true
end
end

it 'exposes a scope per status' do
described_class.statuses.each_key do |status|
ownership_transfer.status = status
ownership_transfer.save!

expect(described_class.public_send(status)).to include(ownership_transfer)
end
end
end

it 'delegates #school_name to School#name' do
school = build(:school, name: 'school-name')
ownership_transfer = build(:ownership_transfer, school:)
describe 'nominee role validation' do
it 'is valid when the nominee has the teacher role for the school' do
expect(ownership_transfer).to be_valid
end

it 'is valid when the nominee has the owner role for the school' do
owner = create(:owner, school:)
ownership_transfer.nominated_user_id = owner.id

expect(ownership_transfer).to be_valid
end

it 'is invalid when the nominee has only the student role for the school' do
student = create(:student, school:)
ownership_transfer.nominated_user_id = student.id

expect(ownership_transfer).not_to be_valid
end

it 'is invalid when the nominee has a teacher role for a different school' do
other_school = create(:verified_school)
other_teacher = create(:teacher, school: other_school)
ownership_transfer.nominated_user_id = other_teacher.id

expect(ownership_transfer).not_to be_valid
end

it 'adds an error naming the nominated_user_id and the school id' do
student = create(:student, school:)
ownership_transfer.nominated_user_id = student.id
ownership_transfer.valid?

expect(ownership_transfer.school_name).to eq('school-name')
expect(ownership_transfer.errors[:nominated_user_id].first).to include(student.id)
expect(ownership_transfer.errors[:nominated_user_id].first).to include(school.id)
end
end

it 'non-deterministically encrypts the email_address' do
school = create(:verified_school)
described_class.create!(email_address: 'new-owner@example.com', school:)
describe 'the request email' do
it 'is enqueued with the transfer as the mailer param' do
ownership_transfer.save!

expect(described_class.find_by(email_address: 'new-owner@example.com')).to be_nil
assert_enqueued_email_with(
SchoolOwnershipMailer, :request_ownership_transfer, params: { ownership_transfer: }
)
end
end
end
9 changes: 9 additions & 0 deletions spec/models/school_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@
expect(school.school_email_domains.size).to eq(2)
end

it 'has many ownership transfers' do
owner_one = create(:owner_role, school:)
owner_two = create(:owner_role, school:)
create(:ownership_transfer, school:, requested_by_user_id: owner_one.user_id, nominated_user_id: owner_one.user_id)
create(:ownership_transfer, school:, requested_by_user_id: owner_two.user_id, nominated_user_id: owner_two.user_id)

expect(school.ownership_transfers.size).to eq(2)
end

context 'when a school is destroyed' do
let!(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) }
let!(:lesson_1) { create(:lesson, user_id: teacher.id, school_class:) }
Expand Down
Loading