Skip to content

Cancel ownership transfer - #1032

Open
DNR500 wants to merge 4 commits into
mainfrom
cancel-ownership-transfer
Open

DNR500 wants to merge 4 commits into
mainfrom
cancel-ownership-transfer

Conversation

@DNR500

@DNR500 DNR500 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Backend for #1771 lets a school owner cancel a pending ownership transfer.

What this does

  • Adds a way for the owner to cancel a transfer that's still waiting on the invited teacher to respond
  • The invited teacher gets an email letting them know it's been cancelled
  • Once cancelled, the owner is free to start a new transfer with someone else right away
  • Only an owner of the school can cancel a transfer — this is checked on the server, not just hidden in the app, so it can't be done by anyone else even by calling the API directly
  • A couple of small reliability fixes so the notification emails don't break if a teacher's details are briefly unavailable

Why

Covers the case where an owner changes their mind, or nominates the wrong person by mistake, without needing to wait for the original invite to be responded to.

Any current owner can cancel a pending transfer, not just the one
who started it - cancel is just a third status transition alongside
accept/decline, so it slots into the existing resolve! helper with
no new resource-loading logic, which now wraps the read-and-update
in a transaction so its row lock is held across both steps (stopping
a concurrent accept/decline/cancel from also finding the transfer
pending) and responds only after commit, so a commit-time callback
failure surfaces as a clean 500 instead of racing an already-rendered
response.

The read ability for a pending transfer is likewise open to any
owner of the school, not just the one who started it, since any of
them may need to see it in order to decide whether to cancel it -
the status endpoint now identifies the viewer by their owner role
rather than by whether they happened to be the requester.
Works like the existing request email - sent from a callback that
only fires when a transfer actually moves from pending to cancelled,
so fixing up an old transfer to cancelled some other way won't
trigger it. Since any owner can now cancel a transfer, not just the
one who started it, the email attributes the cancellation to "the
school owner" generically rather than naming a specific person - it
has no reliable way to know which owner actually clicked cancel.
If the user-info API ever returns an empty body, the user-lookup
code would crash instead of treating it as "no users found". The
fix lives in the client itself, not just the caller that happened to
hit it, so every caller is protected - including the job that sends
the new cancellation email.
request_ownership_transfer interpolated the nominee's and requested
owner's names directly with no fallback, so a missing name from the
user-info lookup would render as a blank greeting or a blank sentence
instead of degrading gracefully. Brings it in line with the same
hardening the cancellation email already has: a generic label when a
name isn't available, never a blank.
@DNR500 DNR500 self-assigned this Sep 23, 2026
Copilot AI lite review requested due to automatic review settings September 23, 2026 13:57
@cla-bot cla-bot Bot added the cla-signed label Sep 23, 2026
@github-actions

Copy link
Copy Markdown

Test coverage

93.7% line coverage reported by SimpleCov.
Run: https://github.com/RaspberryPiFoundation/editor-api/actions/runs/35870688641

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The reviewed changes have no unresolved blocking issues.

Review effort: Lite
Findings: None

Comment on lines +30 to +32
# head/render happen after the transaction returns, not inside it, so a
# commit-time callback failure (e.g. enqueuing a notification) surfaces as
# a clean 500 instead of racing an already-performed response.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really understand this, maybe because it's quite jargony. What is a clean 500? What's it racing - isn't it single threaded?

Comment on lines 54 to 56
def pending_ownership_transfer
@school.ownership_transfers.lock.pending.first
end

@zetter-rpf zetter-rpf Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A small suggestion to simplify this, if you use first! rather than first then a ActiveRecord::RecordNotFound will be raised if there are no transfers found.

ActiveRecord::RecordNotFound is already caught and turned into 404 responses by the api controllers so you can simplify resolve! by assuming the transfer always exists (removing the transfer.nil? check and the next if loaded.blank?)

r.body = { userIds: user_ids }
end
return if response.body.blank?
return [] if response.body.blank?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what requests does the User Info response return an empty body?

I just wanted to check it was equivalent to no users found, and not a different error state we should handle.

@zetter-rpf zetter-rpf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, I've added a few comments but nothing major

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants