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
290 lines
10 KiB
PHP
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
|
|
)
|
|
);
|
|
}
|
|
}
|