Conversation
Test coverage93.67% line coverage reported by SimpleCov. |
…ers" This reverts commit 3b9af1b.
5db3b80 to
3acb88c
Compare
3acb88c to
44c8217
Compare
44c8217 to
69a8eed
Compare
## 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
…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.
There was a problem hiding this comment.
🟡 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_transferandGET /api/schools/:school_id/ownership_transferwith 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.
| 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 |
Summary WIP
This PR adds the two endpoints
editor-standaloneneeds 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 transferGET /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 existedownership_transfers.school_id(scoped tostatus = '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 coveredAdds the endpoint
editor-standaloneneeds to show ownership transfer status to both the owner and the nominee.Examples
Creating a transfer
As the school owner, nominating a teacher or another owner at the school:
(empty body — the nominee gets an email; the requester and nominee then use the
GETendpoint below to see it)If the nominated user doesn't hold an owner/teacher role at the school:
If the school already has a pending transfer (whether from this request racing another, or a genuinely separate attempt):
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
As the owner who requested it, while it's pending:
As the nominee, while it's pending:
The transfer stays visible to the requester and nominee after it's resolved —
statusreflects whatever actually happened, rather than the response disappearing once it's no longer pending: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:
(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).