From 709a40bc2aa296c211244b39436404e3418a3922 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Fri, 25 Sep 2026 22:08:09 +0200 Subject: [PATCH 1/2] feat: email the previous owner when their ownership transfer completes --- app/mailers/school_ownership_mailer.rb | 11 ++++ app/models/ownership_transfer.rb | 6 +++ ...ownership_transfer_previous_owner.text.erb | 10 ++++ .../school_ownership_mailer_preview.rb | 17 +++++- spec/mailers/school_ownership_mailer_spec.rb | 54 +++++++++++++++++++ spec/models/ownership_transfer_spec.rb | 34 ++++++++++++ 6 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 app/views/school_ownership_mailer/complete_ownership_transfer_previous_owner.text.erb diff --git a/app/mailers/school_ownership_mailer.rb b/app/mailers/school_ownership_mailer.rb index 970cfddc3..d59d70ed3 100644 --- a/app/mailers/school_ownership_mailer.rb +++ b/app/mailers/school_ownership_mailer.rb @@ -35,6 +35,17 @@ def complete_ownership_transfer message_stream: 'outbound') end + def complete_ownership_transfer_previous_owner + @school = ownership_transfer.school + previous_owner = users_by_id[ownership_transfer.requested_by_user_id] + @previous_owner_name = previous_owner&.name.presence || 'there' + + mail(to: previous_owner&.email, + subject: "Ownership of the Code Classroom account for #{@school.name} has been transferred", + track_opens: 'true', + message_stream: 'outbound') + end + private def ownership_transfer diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb index a2bb552f8..66cfe625a 100644 --- a/app/models/ownership_transfer.rb +++ b/app/models/ownership_transfer.rb @@ -23,6 +23,8 @@ class OwnershipTransfer < ApplicationRecord if: -> { saved_change_to_status?(from: 'pending', to: 'cancelled') } after_update_commit :send_ownership_transfer_completed_email, if: -> { saved_change_to_status?(from: 'pending', to: 'completed') } + after_update_commit :send_ownership_transfer_completed_email_to_previous_owner, + if: -> { saved_change_to_status?(from: 'pending', to: 'completed') } encrypts :email_address private @@ -47,4 +49,8 @@ def send_ownership_transfer_cancelled_email def send_ownership_transfer_completed_email SchoolOwnershipMailer.with(ownership_transfer: self).complete_ownership_transfer.deliver_later end + + def send_ownership_transfer_completed_email_to_previous_owner + SchoolOwnershipMailer.with(ownership_transfer: self).complete_ownership_transfer_previous_owner.deliver_later + end end diff --git a/app/views/school_ownership_mailer/complete_ownership_transfer_previous_owner.text.erb b/app/views/school_ownership_mailer/complete_ownership_transfer_previous_owner.text.erb new file mode 100644 index 000000000..800f7676d --- /dev/null +++ b/app/views/school_ownership_mailer/complete_ownership_transfer_previous_owner.text.erb @@ -0,0 +1,10 @@ +Hi <%= @previous_owner_name %>, + +Ownership of the Code Classroom account for <%= @school.name %> has been successfully transferred to the new owner. + +You are no longer the owner of this school. As part of the transfer, your role has automatically been changed to Teacher. You will still be able to create students, classes and projects within the account. + +If you would like to be removed from the school, please contact us at websupport@raspberrypi.org. + +Kind Regards, +The Code Classroom team diff --git a/spec/mailers/previews/school_ownership_mailer_preview.rb b/spec/mailers/previews/school_ownership_mailer_preview.rb index 01b858283..b70367e21 100644 --- a/spec/mailers/previews/school_ownership_mailer_preview.rb +++ b/spec/mailers/previews/school_ownership_mailer_preview.rb @@ -3,7 +3,7 @@ # 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 + REQUESTED_OWNER = { id: SecureRandom.uuid, name: 'Oaklynn Duran', email: 'oaklynn@example.com' }.freeze def request_ownership_transfer school = School.new(name: 'Elmwood Secondary School') @@ -43,6 +43,21 @@ def complete_ownership_transfer with_stubbed_user_info_api { SchoolOwnershipMailer.with(ownership_transfer:).complete_ownership_transfer.message } end + def complete_ownership_transfer_previous_owner + school = School.new(name: 'Elmwood Secondary School') + ownership_transfer = OwnershipTransfer.new( + email_address: 'teacher@example.com', + school:, + nominated_user_id: NOMINEE[:id], + requested_by_user_id: REQUESTED_OWNER[:id], + status: :completed + ) + + with_stubbed_user_info_api do + SchoolOwnershipMailer.with(ownership_transfer:).complete_ownership_transfer_previous_owner.message + end + end + private # fake the user info response, but only for the duration of diff --git a/spec/mailers/school_ownership_mailer_spec.rb b/spec/mailers/school_ownership_mailer_spec.rb index 5afa971c2..593b12e5e 100644 --- a/spec/mailers/school_ownership_mailer_spec.rb +++ b/spec/mailers/school_ownership_mailer_spec.rb @@ -174,4 +174,58 @@ end end end + + describe 'complete_ownership_transfer_previous_owner' do + subject(:email) { described_class.with(ownership_transfer:).complete_ownership_transfer_previous_owner } + + 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, + status: :completed + ) + end + + before do + stub_user_info_api_fetch_by_ids( + user_ids: [nominee.id, requested_owner.id], + users: [{ id: nominee.id, name: nominee.name }, { id: requested_owner.id, name: requested_owner.name, + email: requested_owner.email }] + ) + end + + it 'is sent to the previous owner' do + expect(email.to).to eq([requested_owner.email]) + end + + it 'includes the previous owner\'s name 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 + + it 'includes the school name in the subject' do + expect(email.subject).to include(ownership_transfer.school.name) + end + + context 'when the previous owner is missing from the user-info response' do + before do + stub_user_info_api_fetch_by_ids( + user_ids: [nominee.id, requested_owner.id], + users: [{ id: nominee.id, name: nominee.name }] + ) + end + + it 'greets them generically instead of leaving the greeting blank' do + expect(email.body.to_s).to include('Hi there,') + end + end + end end diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb index 537955bc4..7a33d24d4 100644 --- a/spec/models/ownership_transfer_spec.rb +++ b/spec/models/ownership_transfer_spec.rb @@ -241,4 +241,38 @@ end end end + + describe 'the completion email to the previous owner' do + before { ownership_transfer.save! } + + it 'is enqueued with the transfer as the mailer param when the transfer completes' do + ownership_transfer.update!(status: :completed) + + assert_enqueued_email_with( + SchoolOwnershipMailer, :complete_ownership_transfer_previous_owner, params: { ownership_transfer: } + ) + end + + it 'is not enqueued when the transfer resolves to a different status' do + assert_no_enqueued_emails do + ownership_transfer.update!(status: :rejected) + end + end + + it 'is not enqueued when an already-completed transfer is saved again unchanged' do + ownership_transfer.update!(status: :completed) + + assert_no_enqueued_emails do + ownership_transfer.update!(status: :completed) + end + end + + it 'is not enqueued when a non-pending transfer is corrected to completed' do + ownership_transfer.update!(status: :rejected) + + assert_no_enqueued_emails do + ownership_transfer.update!(status: :completed) + end + end + end end From 1dca2be4ef1e76b1e2b34216f01cc621eed08993 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Mon, 28 Sep 2026 16:40:16 +0200 Subject: [PATCH 2/2] feat: email the previous owner when the nominee declines their ownership transfer --- app/mailers/school_ownership_mailer.rb | 11 ++++ app/models/ownership_transfer.rb | 6 +++ ...ownership_transfer_previous_owner.text.erb | 10 ++++ .../school_ownership_mailer_preview.rb | 15 ++++++ spec/mailers/school_ownership_mailer_spec.rb | 54 +++++++++++++++++++ spec/models/ownership_transfer_spec.rb | 46 +++++++++++++--- 6 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 app/views/school_ownership_mailer/decline_ownership_transfer_previous_owner.text.erb diff --git a/app/mailers/school_ownership_mailer.rb b/app/mailers/school_ownership_mailer.rb index d59d70ed3..b36235a78 100644 --- a/app/mailers/school_ownership_mailer.rb +++ b/app/mailers/school_ownership_mailer.rb @@ -46,6 +46,17 @@ def complete_ownership_transfer_previous_owner message_stream: 'outbound') end + def decline_ownership_transfer_previous_owner + @school = ownership_transfer.school + previous_owner = users_by_id[ownership_transfer.requested_by_user_id] + @previous_owner_name = previous_owner&.name.presence || 'there' + + mail(to: previous_owner&.email, + subject: "New owner declined ownership of the Code Classroom account for #{@school.name}", + track_opens: 'true', + message_stream: 'outbound') + end + private def ownership_transfer diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb index 66cfe625a..05b4e00c3 100644 --- a/app/models/ownership_transfer.rb +++ b/app/models/ownership_transfer.rb @@ -25,6 +25,8 @@ class OwnershipTransfer < ApplicationRecord if: -> { saved_change_to_status?(from: 'pending', to: 'completed') } after_update_commit :send_ownership_transfer_completed_email_to_previous_owner, if: -> { saved_change_to_status?(from: 'pending', to: 'completed') } + after_update_commit :send_ownership_transfer_declined_email_to_previous_owner, + if: -> { saved_change_to_status?(from: 'pending', to: 'rejected') } encrypts :email_address private @@ -53,4 +55,8 @@ def send_ownership_transfer_completed_email def send_ownership_transfer_completed_email_to_previous_owner SchoolOwnershipMailer.with(ownership_transfer: self).complete_ownership_transfer_previous_owner.deliver_later end + + def send_ownership_transfer_declined_email_to_previous_owner + SchoolOwnershipMailer.with(ownership_transfer: self).decline_ownership_transfer_previous_owner.deliver_later + end end diff --git a/app/views/school_ownership_mailer/decline_ownership_transfer_previous_owner.text.erb b/app/views/school_ownership_mailer/decline_ownership_transfer_previous_owner.text.erb new file mode 100644 index 000000000..7d3d9af4f --- /dev/null +++ b/app/views/school_ownership_mailer/decline_ownership_transfer_previous_owner.text.erb @@ -0,0 +1,10 @@ +Hi <%= @previous_owner_name %>, + +The person you nominated as the new owner of the Code Classroom account for <%= @school.name %> has declined the ownership transfer. + +You will remain the owner, and no changes have been made to your school. + +If you'd still like to transfer ownership, you can submit a new request and nominate another person within your school. + +Kind Regards, +The Code Classroom team diff --git a/spec/mailers/previews/school_ownership_mailer_preview.rb b/spec/mailers/previews/school_ownership_mailer_preview.rb index b70367e21..37677bfb5 100644 --- a/spec/mailers/previews/school_ownership_mailer_preview.rb +++ b/spec/mailers/previews/school_ownership_mailer_preview.rb @@ -58,6 +58,21 @@ def complete_ownership_transfer_previous_owner end end + def decline_ownership_transfer_previous_owner + school = School.new(name: 'Elmwood Secondary School') + ownership_transfer = OwnershipTransfer.new( + email_address: 'teacher@example.com', + school:, + nominated_user_id: NOMINEE[:id], + requested_by_user_id: REQUESTED_OWNER[:id], + status: :rejected + ) + + with_stubbed_user_info_api do + SchoolOwnershipMailer.with(ownership_transfer:).decline_ownership_transfer_previous_owner.message + end + end + private # fake the user info response, but only for the duration of diff --git a/spec/mailers/school_ownership_mailer_spec.rb b/spec/mailers/school_ownership_mailer_spec.rb index 593b12e5e..83732e49e 100644 --- a/spec/mailers/school_ownership_mailer_spec.rb +++ b/spec/mailers/school_ownership_mailer_spec.rb @@ -228,4 +228,58 @@ end end end + + describe 'decline_ownership_transfer_previous_owner' do + subject(:email) { described_class.with(ownership_transfer:).decline_ownership_transfer_previous_owner } + + 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, + status: :rejected + ) + end + + before do + stub_user_info_api_fetch_by_ids( + user_ids: [nominee.id, requested_owner.id], + users: [{ id: nominee.id, name: nominee.name }, { id: requested_owner.id, name: requested_owner.name, + email: requested_owner.email }] + ) + end + + it 'is sent to the previous owner' do + expect(email.to).to eq([requested_owner.email]) + end + + it 'includes the previous owner\'s name 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 + + it 'includes the school name in the subject' do + expect(email.subject).to include(ownership_transfer.school.name) + end + + context 'when the previous owner is missing from the user-info response' do + before do + stub_user_info_api_fetch_by_ids( + user_ids: [nominee.id, requested_owner.id], + users: [{ id: nominee.id, name: nominee.name }] + ) + end + + it 'greets them generically instead of leaving the greeting blank' do + expect(email.body.to_s).to include('Hi there,') + end + end + end end diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb index 7a33d24d4..2cfa90860 100644 --- a/spec/models/ownership_transfer_spec.rb +++ b/spec/models/ownership_transfer_spec.rb @@ -186,9 +186,9 @@ end it 'is not enqueued when the transfer resolves to a different status' do - assert_no_enqueued_emails do + expect do ownership_transfer.update!(status: :rejected) - end + end.not_to have_enqueued_mail(SchoolOwnershipMailer, :cancel_ownership_transfer) end it 'is not enqueued when an already-cancelled transfer is saved again unchanged' do @@ -220,9 +220,9 @@ end it 'is not enqueued when the transfer resolves to a different status' do - assert_no_enqueued_emails do + expect do ownership_transfer.update!(status: :rejected) - end + end.not_to have_enqueued_mail(SchoolOwnershipMailer, :complete_ownership_transfer) end it 'is not enqueued when an already-completed transfer is saved again unchanged' do @@ -254,9 +254,9 @@ end it 'is not enqueued when the transfer resolves to a different status' do - assert_no_enqueued_emails do + expect do ownership_transfer.update!(status: :rejected) - end + end.not_to have_enqueued_mail(SchoolOwnershipMailer, :complete_ownership_transfer_previous_owner) end it 'is not enqueued when an already-completed transfer is saved again unchanged' do @@ -275,4 +275,38 @@ end end end + + describe 'the decline email to the previous owner' do + before { ownership_transfer.save! } + + it 'is enqueued with the transfer as the mailer param when the transfer is declined' do + ownership_transfer.update!(status: :rejected) + + assert_enqueued_email_with( + SchoolOwnershipMailer, :decline_ownership_transfer_previous_owner, params: { ownership_transfer: } + ) + end + + it 'is not enqueued when the transfer resolves to a different status' do + expect do + ownership_transfer.update!(status: :completed) + end.not_to have_enqueued_mail(SchoolOwnershipMailer, :decline_ownership_transfer_previous_owner) + end + + it 'is not enqueued when an already-declined transfer is saved again unchanged' do + ownership_transfer.update!(status: :rejected) + + assert_no_enqueued_emails do + ownership_transfer.update!(status: :rejected) + end + end + + it 'is not enqueued when a non-pending transfer is corrected to rejected' do + ownership_transfer.update!(status: :completed) + + assert_no_enqueued_emails do + ownership_transfer.update!(status: :rejected) + end + end + end end