Let the studio register the students who cannot register themselves
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m3s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m24s
CI / Coding Standards & Static Analysis (pull_request) Successful in 8m21s
CI / Build Plugin Zip (pull_request) Skipped

The Book a lesson for a student panel built its picker from the us_student
role but vetted the submission with the book_lesson capability. ChildLoginGate
and RegistrationLoginGate withhold that capability from accounts that keep the
role, so the panel offered every guardian-managed child and every unapproved
signup and then refused them — with a message claiming no student had been
chosen, and a form cleared of all five fields.

Withholding book_lesson stops those accounts registering in their own name. It
was never meant to stop the studio acting for them, which is what the panel is
for, and for a child is the only route to a lesson besides their guardian.

Guard the student role instead, via a new RoleManager::isStudent() shared with
every picker and guard on the staff side so the two cannot drift apart again.
Group enrolment gets the same predicate: addDirect() and grantAccess() vetted
their posted ids not at all, and would enrol an instructor, an administrator,
or an account deleted since the page was drawn — raising a real payment against
them for a priced class.

Keep a refused booking's fields as submitted, reading the form through one
LessonController::submittedBooking() so what gets booked and what is shown
again cannot disagree about a field name. A booking that succeeds still leaves
an empty form, so the next one does not inherit it.

Closes #185

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XunBYk2sFEc1oL14sUiuBU
This commit is contained in:
2026-08-24 18:42:59 -03:00
co-authored by Claude Opus 5
parent 76530878b5
commit 5ce42f0003
12 changed files with 371 additions and 31 deletions
+21
View File
@@ -60,6 +60,27 @@ class RoleManager {
self::CAP_EXPORT_PAYMENTS,
];
/**
* Whether a user account is a student the studio may act for.
*
* Deliberately the role and not the `book_lesson` capability: that capability
* is withheld from a guardian's child ({@see \Unsupervised\Schedular\Guardian\ChildLoginGate})
* and from a self-signup still awaiting approval
* ({@see \Unsupervised\Schedular\Auth\RegistrationLoginGate}), so that neither
* can book or enrol *in their own name*. Staff booking or enrolling on their
* behalf is the case those restrictions exist to leave open — and for a child,
* whose account is never signed in to, it is the only route there is.
*
* Use this for every "may the studio register this person?" check, so the
* pickers staff choose from and the guards that vet their choice cannot drift
* into offering someone who is then refused.
*/
public static function isStudent( int $userId ): bool {
$user = $userId > 0 ? get_userdata( $userId ) : false;
return $user instanceof \WP_User && in_array( self::STUDENT, (array) $user->roles, true );
}
public function __construct( private AccessSettings $access = new AccessSettings() ) {}
public function register(): void {
+13 -4
View File
@@ -19,7 +19,7 @@ use Unsupervised\Schedular\Val;
* flow.
*
* It reuses `LessonBooker` — the same offering rules, the same atomic slot claim,
* the same billing — and differs from a student's own booking in exactly three
* the same billing — and differs from a student's own booking in exactly four
* ways, each deliberate:
*
* 1. **No intake questions or policy acceptances are recorded.** They are the
@@ -30,6 +30,10 @@ use Unsupervised\Schedular\Val;
* slot of the instructor's, including one only reachable past a deadline.
* 3. **It can be booked at no charge**, for a make-up or goodwill lesson, which
* skips the payment entirely and confirms the lesson at once.
* 4. **It can book for a student who cannot book at all** — a guardian's child,
* or someone still awaiting approval. Both hold the student role but have
* `book_lesson` withheld so that neither can book in their own name; that is
* a limit on them, never on the studio acting for them.
*/
class AdminBooking {
@@ -54,7 +58,10 @@ class AdminBooking {
* @return string|\WP_Error Success notice, or why nothing was booked.
*/
public function book( int $studentId, int $slotId, int $offeringId, string $recurrence, bool $noCharge, string $notes, int $onlyInstructorId = 0 ): string|\WP_Error {
if ( $studentId <= 0 || ! user_can( $studentId, RoleManager::CAP_BOOK_LESSON ) ) {
// The student role, not the `book_lesson` capability — see
// {@see RoleManager::isStudent()} for why a child and an unapproved signup
// must both be bookable for.
if ( ! RoleManager::isStudent( $studentId ) ) {
return new \WP_Error( 'invalid_student', __( 'Choose a student to book for.', 'unsupervised-schedular' ) );
}
@@ -250,8 +257,10 @@ class AdminBooking {
}
/**
* Everyone who can be booked for, by name — students and the children a
* guardian books for alike, since both hold `book_lesson`.
* Everyone who can be booked for, by name — every holder of the student role,
* which is exactly the set {@see book()} accepts. That deliberately includes
* the children a guardian books for and students still awaiting approval:
* neither may book in their own name, both may be booked for.
*
* @return list<array{id: int, name: string}>
*/
+50 -9
View File
@@ -177,7 +177,7 @@ class LessonController {
*
* @param list<array<string, mixed>> $rows
*/
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice and $error are read by the included template.
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice is read by the included template.
private function renderLessonsPage( array $rows, string $pageSlug, int $onlyInstructorId, string $notice, string $error ): void {
// View-state query params only (which view, which week) — nothing is
// mutated from them, so no nonce applies.
@@ -193,6 +193,11 @@ class LessonController {
$baseUrl = admin_url( 'admin.php?page=' . $pageSlug );
$bookForm = $this->adminBooking->formData( $onlyInstructorId );
// A refused booking is shown again as it was typed — losing five fields to a
// single mistake is what made the panel infuriating to correct. A successful
// one starts empty, so the next booking does not inherit the last one's.
$bookValues = '' !== $error ? $this->submittedBooking() : $this->emptyBooking();
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
}
@@ -229,23 +234,59 @@ class LessonController {
* @return array{string, string}
*/
private function bookForStudent( int $onlyInstructorId ): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$submitted = $this->submittedBooking();
$result = $this->adminBooking->book(
absint( Val::int( $_POST['student_id'] ?? 0 ) ),
absint( Val::int( $_POST['slot_id'] ?? 0 ) ),
absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
isset( $_POST['recurrence_weekly'] ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
isset( $_POST['no_charge'] ),
sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ),
$submitted['student_id'],
$submitted['slot_id'],
$submitted['offering_id'],
$submitted['weekly'] ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
$submitted['no_charge'],
$submitted['notes'],
$onlyInstructorId
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
return $result instanceof \WP_Error
? [ '', $result->get_error_message() ]
: [ $result, '' ];
}
/**
* The book-for-a-student form exactly as submitted. Read in one place so what
* gets booked and what the form shows again after a refusal cannot drift apart
* on a field name.
*
* @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string}
*/
private function submittedBooking(): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- read only after handleFormAction() has verified the nonce: to book, or to re-render (escaped) a form it refused.
return [
'student_id' => absint( Val::int( $_POST['student_id'] ?? 0 ) ),
'slot_id' => absint( Val::int( $_POST['slot_id'] ?? 0 ) ),
'offering_id' => absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
'weekly' => isset( $_POST['recurrence_weekly'] ),
'no_charge' => isset( $_POST['no_charge'] ),
'notes' => sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ),
];
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* An untouched book-for-a-student form.
*
* @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string}
*/
private function emptyBooking(): array {
return [
'student_id' => 0,
'slot_id' => 0,
'offering_id' => 0,
'weekly' => false,
'no_charge' => false,
'notes' => '',
];
}
/**
* Apply a per-lesson payment override. When $onlyOwn, the payment must belong
* to the current instructor.
+10 -2
View File
@@ -640,7 +640,15 @@ class GroupClassController {
}
/**
* The de-duplicated positive student ids posted from a multi-select.
* The de-duplicated student ids posted from a multi-select, keeping only ids
* that are actually students.
*
* The select is built from {@see studentOptions()}, but nothing stops a posted
* id naming an instructor, an administrator, or an account deleted since the
* page was drawn — and enrolling one would write a roster row, and bill it,
* against someone who is not in the class. Vetting here covers both actions at
* once, and against the same {@see RoleManager::isStudent()} the picker uses,
* so a child or an unapproved signup is still perfectly enrollable.
*
* @return list<int>
*/
@@ -650,7 +658,7 @@ class GroupClassController {
$raw = (array) ( $_POST['student_ids'] ?? [] );
$ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) );
return array_values( array_unique( $ids ) );
return array_values( array_filter( array_unique( $ids ), RoleManager::isStudent( ... ) ) );
}
/**