Files
unsupervised-scheduler/src/Booking/AdminBooking.php
T
thatguygriffandClaude Opus 5 5ce42f0003
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
Let the studio register the students who cannot register themselves
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
2026-08-24 18:42:59 -03:00

290 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
/**
* Booking a private lesson **for** a student, from wp-admin — the studio's
* counterpart to the group class's "Add students directly". The front desk takes
* a phone call, an instructor slots in a make-up lesson; neither of them can log
* in as the student, and only a guardian may book through the student-facing
* 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 four
* ways, each deliberate:
*
* 1. **No intake questions or policy acceptances are recorded.** They are the
* student's to answer and agree to; a staff member ticking boxes on their
* behalf would be an audit trail that says something untrue. The lesson's
* detail page simply shows none.
* 2. **It is not bounded by what the student could book themselves.** Any open
* 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 {
/**
* How far ahead the form's list of open times reaches. Long enough to book a
* term ahead, short enough that the select stays a select.
*/
private const HORIZON_DAYS = 56;
public function __construct(
private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private LessonBooker $booker,
) {}
/**
* Book a lesson on a student's behalf, returning the notice to show. When
* `$onlyInstructorId` is non-zero the slot must belong to that instructor —
* how an instructor's own **My Lessons** page is kept to their own schedule,
* where the studio **Scheduler** passes 0 and may book any instructor's time.
*
* @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 {
// 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' ) );
}
$slot = $slotId > 0 ? $this->availability->findById( $slotId ) : null;
if ( null === $slot || ( $onlyInstructorId > 0 && $slot->instructorId !== $onlyInstructorId ) ) {
return new \WP_Error( 'invalid_slot', __( 'Choose a time to book.', 'unsupervised-schedular' ) );
}
if ( $slot->isBooked ) {
return new \WP_Error( 'slot_taken', __( 'That time has already been booked.', 'unsupervised-schedular' ) );
}
$offering = $this->booker->resolveOffering( $slot, $offeringId );
if ( $offering instanceof \WP_Error ) {
return $offering;
}
// A weekly reservation needs a weekly time to reserve. The student-facing
// flow quietly books a single lesson when the slot does not repeat; here the
// staff member asked for a term and must be told they are not getting one,
// rather than discovering it later on the roster.
$weekly = Lesson::RECURRENCE_WEEKLY === $recurrence;
if ( $weekly && null === $slot->recurrenceGroup ) {
return new \WP_Error( 'not_weekly', __( 'That time does not repeat weekly, so it cannot be reserved for the term.', 'unsupervised-schedular' ) );
}
$reservation = $this->booker->reserve(
$slot,
$offering,
$studentId,
$weekly ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
$notes,
// Stamped on the lesson so it can be told apart later: only a lesson the
// studio booked may have its intake recorded after the fact.
get_current_user_id()
);
if ( $reservation instanceof \WP_Error ) {
return $reservation;
}
$settlement = $this->booker->settle(
$reservation['ids'],
$reservation['anchor_id'],
$slot,
$offering,
$studentId,
$noCharge
);
return $this->notice( $studentId, $offering, $slot, count( $reservation['ids'] ), $settlement['status'] );
}
/**
* What was booked and what it left owing, so the notice answers the two things
* the person who booked it needs to know.
*/
private function notice( int $studentId, Offering $offering, AvailabilitySlot $slot, int $count, string $status ): string {
$who = $this->studentName( $studentId );
$what = $count > 1
? sprintf(
/* translators: 1: student name, 2: lesson type, 3: number of weekly occurrences, 4: first lesson date and time. */
__( 'Booked %1$s into %2$s — %3$d weekly lessons from %4$s.', 'unsupervised-schedular' ),
$who,
$offering->title,
$count,
Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
)
: sprintf(
/* translators: 1: student name, 2: lesson type, 3: lesson date and time. */
__( 'Booked %1$s into %2$s on %3$s.', 'unsupervised-schedular' ),
$who,
$offering->title,
Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
);
$owing = Lesson::STATUS_CONFIRMED === $status
? __( 'Nothing is owed, so it is confirmed.', 'unsupervised-schedular' )
: __( 'A pending payment has been raised; the lesson is confirmed once it settles.', 'unsupervised-schedular' );
return $what . ' ' . $owing;
}
/**
* Everything the form's three selects need. `$onlyInstructorId` scopes both the
* open times and the lesson types to one instructor's, the same way `book()`
* scopes what may be booked.
*
* @return array{students: list<array{id: int, name: string}>, offerings: list<array{id: int, label: string}>, slots: list<array{id: int, label: string, weekly: bool}>}
*/
public function formData( int $onlyInstructorId = 0 ): array {
$slots = $this->openSlots( $onlyInstructorId );
$offerings = array_values(
array_filter(
$this->offerings->findAll( $onlyInstructorId, Offering::KIND_PRIVATE_LESSON, true ),
static fn( Offering $o ): bool => null !== $o->id
)
);
// Whose lesson type / whose time only needs saying when the page spans more
// than one instructor — on an instructor's own page it is noise.
$named = 0 === $onlyInstructorId;
return [
'students' => $this->studentOptions(),
'offerings' => array_map(
fn( Offering $o ): array => [
'id' => (int) $o->id,
'label' => $this->offeringLabel( $o, $named ),
],
$offerings
),
'slots' => array_map(
fn( AvailabilitySlot $s ): array => [
'id' => (int) $s->id,
'label' => $this->slotLabel( $s, $named ),
'weekly' => null !== $s->recurrenceGroup,
],
$slots
),
];
}
/**
* Open slots inside the booking horizon, newest last.
*
* @return list<AvailabilitySlot>
*/
private function openSlots( int $instructorId ): array {
$until = ( new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) ) )
->modify( '+' . self::HORIZON_DAYS . ' days' )
->format( 'Y-m-d H:i:s' );
return array_values(
array_filter(
$this->availability->findAvailable( $instructorId, 0, 0, '', $until ),
static fn( AvailabilitySlot $s ): bool => null !== $s->id
)
);
}
/**
* A lesson type as "60 min piano (60 min) — Jane Doe", the instructor named
* only when the list spans several.
*/
private function offeringLabel( Offering $offering, bool $withInstructor ): string {
$label = $offering->title;
if ( null !== $offering->durationMinutes ) {
/* translators: %d: lesson length in minutes. */
$label .= ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $offering->durationMinutes ) . ')';
}
return $withInstructor ? $label . ' — ' . $this->instructorName( $offering->instructorId ) : $label;
}
/**
* An open time as "Mon Sep 2, 4:00 PM (30 min) — Jane Doe — 30 min piano —
* repeats weekly": when it is, how long, whose, and what it is already tied to,
* since all four decide whether a given student can be booked into it.
*/
private function slotLabel( AvailabilitySlot $slot, bool $withInstructor ): string {
/* translators: %d: lesson length in minutes. */
$label = Val::string( mysql2date( 'D M j, Y g:i A', $slot->startDt ) ) . ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $slot->durationMinutes ) . ')';
if ( $withInstructor ) {
$label .= ' — ' . $this->instructorName( $slot->instructorId );
}
$tied = null !== $slot->offeringId ? $this->offerings->findById( $slot->offeringId ) : null;
if ( null !== $tied ) {
$label .= ' — ' . $tied->title;
}
if ( null !== $slot->recurrenceGroup ) {
$label .= ' — ' . __( 'repeats weekly', 'unsupervised-schedular' );
}
return $label;
}
private function instructorName( int $instructorId ): string {
return $this->studentName( $instructorId );
}
/** A person's display name, however little the account has on file. */
private function studentName( int $userId ): string {
$user = get_userdata( $userId );
return UserName::format( $user instanceof \WP_User ? $user : null, $userId );
}
/**
* 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}>
*/
private function studentOptions(): array {
$users = array_filter(
get_users(
[
'role' => RoleManager::STUDENT,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $u ): bool => $u instanceof \WP_User
);
return array_values(
array_map(
static fn( \WP_User $u ): array => [
'id' => (int) $u->ID,
'name' => UserName::format( $u, (int) $u->ID ),
],
$users
)
);
}
}