Skip to content

create and read ownership transfer - #1017

Open
DNR500 wants to merge 19 commits into
mainfrom
create-and-get-ownership-transfer
Open

DNR500 wants to merge 19 commits into
mainfrom
create-and-get-ownership-transfer

Conversation

@DNR500

@DNR500 DNR500 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary WIP

This PR adds the two endpoints editor-standalone needs for the owner-initiates / nominee-reviews flow: starting a transfer, and checking its status.

What's changed?

  • POST /api/schools/:school_id/ownership_transfer — the school owner nominates another owner or teacher at the school to take over. Sends the nomination email on success; rejects the nominee if they don't hold an owner/teacher role at the school, or if the school already has a pending transfer
  • GET /api/schools/:school_id/ownership_transfer — returns the school's most recent transfer, whatever its status, and who the current user is relative to it (owner/nominee); anyone not involved gets a 404, same as if no transfer had ever existed
  • A partial unique index on ownership_transfers.school_id (scoped to status = 'pending') plus a matching model validation, so two concurrent create requests can't both succeed — closes a race condition Update ownership transfer and mailer #1019 flagged as not yet covered

Adds the endpoint editor-standalone needs to show ownership transfer status to both the owner and the nominee.

Examples

Creating a transfer

POST /api/schools/9d9c1e3e-1e4b-4b0a-9a2e-6a2b7e6f1a10/ownership_transfer
Authorization: Bearer <owner's token>
Content-Type: application/json

{ "ownership_transfer": { "nominated_user_id": "3f2b1c4a-...-a1b2c3d4e5f6" } }

As the school owner, nominating a teacher or another owner at the school:

201 Created

(empty body — the nominee gets an email; the requester and nominee then use the GET endpoint below to see it)

If the nominated user doesn't hold an owner/teacher role at the school:

// 422 Unprocessable Content
{
  "error": {
    "nominated_user_id": ["'3f2b1c4a-...' does not have the 'owner' or 'teacher' role for school '9d9c1e3e-...'"]
  }
}

If the school already has a pending transfer (whether from this request racing another, or a genuinely separate attempt):

// 422 Unprocessable Content
{
  "error": {
    "school_id": ["already has a pending ownership transfer"]
  }
}

Other responses: 401 (no token), 403 (caller isn't an owner of that school — teachers and students can view a transfer but not start one).

Checking transfer status

GET /api/schools/9d9c1e3e-1e4b-4b0a-9a2e-6a2b7e6f1a10/ownership_transfer
Authorization: Bearer <token>

As the owner who requested it, while it's pending:

// 200 OK
{
  "status": "pending",
  "you_are": "owner",
  "nominee_name": "Priya Shah"
}

As the nominee, while it's pending:

// 200 OK
{
  "status": "pending",
  "you_are": "nominee"
}

The transfer stays visible to the requester and nominee after it's resolved — status reflects whatever actually happened, rather than the response disappearing once it's no longer pending:

// 200 OK — the requester, after the nominee accepted
{
  "status": "completed",
  "you_are": "owner",
  "nominee_name": "Priya Shah"
}
// 200 OK — the nominee, after declining it themselves
{
  "status": "rejected",
  "you_are": "nominee"
}

As another teacher or owner at the school who wasn't involved (or if no transfer has ever existed for the school) — deliberately indistinguishable, so the response doesn't leak whether one exists or what happened to it:

404 Not Found

(empty body)

Other responses: 401 (no token), 403 (caller is a school-student — a category that's never eligible to view or act on an ownership transfer at all, so it's rejected outright rather than folded into the 404 case above).

@cla-bot cla-bot Bot added the cla-signed label Sep 14, 2026
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Test coverage

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

@DNR500
DNR500 force-pushed the create-and-get-ownership-transfer branch from 5db3b80 to 3acb88c Compare September 15, 2026 14:24
Comment thread lib/concepts/ownership_transfer/create.rb Fixed
@DNR500 DNR500 changed the title Create and get ownership transfer get/read ownership transfer Sep 15, 2026
@DNR500
DNR500 force-pushed the create-and-get-ownership-transfer branch from 3acb88c to 44c8217 Compare September 15, 2026 15:46
@DNR500
DNR500 changed the base branch from main to 1766-create-ownership-transfer September 15, 2026 15:48
@DNR500
DNR500 force-pushed the create-and-get-ownership-transfer branch from 44c8217 to 69a8eed Compare September 15, 2026 17:09
cocomarine added a commit that referenced this pull request Sep 18, 2026
## Status

- Part of
RaspberryPiFoundation/digital-editor-issues#1766
- Follow up on
#1011

## What's changed?

- Added `nominated_user_id`, `requested_by_user_id`, and a `status` enum
to `ownership_transfers` table
- Validating the nominee holds the owner or teacher role for the school
- Update `SchoolOwnershipMailer` to include the nominee's and
requester's names in the email body, with model, mailer, and preview
specs updated to match

## Not covered here:
- Edge cases due to multiple transfer requests at any one time
- Covered by
#1017
Base automatically changed from 1766-create-ownership-transfer to main September 18, 2026 08:46
…eate

The rescue clause is attached to the whole method body (no explicit
begin), so if an exception were raised before `response =
OperationResponse.new` finished executing, response would still be nil
inside the rescue block, turning response[:error] = ... into a second,
uncaught NoMethodError instead of a graceful error response.
The DB's partial unique index exists specifically to catch the race the
application-level uniqueness validation can't (two requests creating a
pending transfer for the same school at once). When it fires, .save
raises ActiveRecord::RecordNotUnique, which the previous blanket rescue
StandardError caught and surfaced as a raw exception string - including
the Postgres constraint name - instead of the same "already has a
pending ownership transfer" message a non-concurrent duplicate gets.

Rescuing RecordNotUnique specifically lets us add the same validation
message to the record's own errors, so callers see one consistent error
shape regardless of which path caught the duplicate. Also stopped
reporting this specific, expected, already-handled outcome to Sentry -
it's not the kind of exception that needs alerting on.
school.roles.exists?(user_id:, role: %i[owner teacher]) was written
independently in both OwnershipTransfer::Create#nominee_email and the
model's own nominee_has_the_school_owner_or_school_teacher_role_for_the_school
validation. If the eligible-role rule ever changes, it's easy to update
one call site and miss the other.
Both 422 cases (invalid nominee role, already-pending transfer) only
checked the HTTP status, never the response body's shape or content.
A regression to the error body - including the exact leaked-exception
bug fixed two commits ago - would have passed CI unnoticed.
Comment thread lib/concepts/ownership_transfer/create.rb Dismissed
Comment thread lib/concepts/ownership_transfer/create.rb Dismissed
@DNR500
DNR500 marked this pull request as ready for review September 18, 2026 12:29
Copilot AI lite review requested due to automatic review settings September 18, 2026 12:29
@DNR500 DNR500 changed the title get/read ownership transfer create and read ownership transfer Sep 18, 2026

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.

🟡 Changes recommended

The current model/controller behavior can surface incorrect validation errors for invalid nominees and the authorization behavior for students appears inconsistent with the PR description.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds REST support for school ownership transfers so editor-standalone can (1) start a transfer (owner nominates) and (2) read the latest transfer’s status (requester/nominee visibility with 404 for non-involved users).

Changes:

  • Introduces POST /api/schools/:school_id/ownership_transfer and GET /api/schools/:school_id/ownership_transfer with Cancancan authorization.
  • Adds DB + model protection against concurrent pending transfers (partial unique index + validation), plus a domain operation for creation.
  • Adds request/unit/model specs and shared RSpec helpers for the new flow.
File summaries
File Description
spec/support/shared_examples/ownership_transfer_examples.rb Shared example for asserting “hidden transfer” 404 behavior.
spec/support/shared_contexts/ownership_transfer_context.rb Shared context for owner/nominee setup and auth headers.
spec/models/school_spec.rb Adds specs for School#owner_or_teacher? and updates ownership transfer association spec for new status constraints.
spec/models/ownership_transfer_spec.rb Adds specs for “only one pending transfer per school” (including DB constraint behavior).
spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb Request specs for GET status endpoint across roles and transfer statuses.
spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb Request specs for POST create endpoint, validation errors, and mail enqueue.
spec/concepts/ownership_transfer/create_spec.rb Unit specs for creation operation, including concurrency handling and error reporting.
lib/concepts/ownership_transfer/create.rb New operation object for creating a transfer and mapping race conditions to friendly errors.
db/schema.rb Captures new partial unique index in schema dump.
db/migrate/20260915100000_add_unique_pending_index_to_ownership_transfers.rb Adds partial unique index enforcing one pending transfer per school.
config/routes.rb Adds nested singular ownership_transfer resource routes under schools.
config/locales/en.yml Adds i18n message for “school already has pending transfer”.
app/models/school.rb Adds owner_or_teacher?(user_id) helper.
app/models/ownership_transfer.rb Adds pending uniqueness validation and uses School#owner_or_teacher? in nominee-role validation.
app/models/ability.rb Adds ownership transfer read permissions (object-level) and controller-level abilities (symbol).
app/controllers/api/ownership_transfers_controller.rb New API controller implementing show/create behavior and “hide from non-involved users” logic.
Review details
  • Files reviewed: 16/16 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/controllers/api/ownership_transfers_controller.rb
Comment on lines 14 to 19
validates :email_address,
format: { with: EmailValidator.regexp, message: I18n.t('validations.invitation.email_address') }
validates :school_id,
uniqueness: { conditions: -> { where(status: :pending) }, message: I18n.t('validations.ownership_transfer.school_pending') },
on: :create
validate :nominee_has_the_school_owner_or_school_teacher_role_for_the_school

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed

Comment thread lib/concepts/ownership_transfer/create.rb
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.

4 participants