Files
unsupervised-scheduler/src/Booking/LessonController.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

368 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Availability\WeekCalendar;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Registration\IntakeAudit;
use Unsupervised\Schedular\Registration\IntakeProvenance;
use Unsupervised\Schedular\Registration\IntakeRecording;
use Unsupervised\Schedular\Val;
class LessonController {
public function __construct(
private BookingRepository $repository,
private PaymentRepository $payments,
private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private IntakeAudit $detail,
private AdminBooking $adminBooking,
private IntakeRecording $intake,
) {}
public function renderAdminDashboard(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) );
}
if ( $this->maybeRenderDetail( 'us-scheduler', false ) ) {
return;
}
[ $notice, $error ] = $this->handleFormAction( false, 0 );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
$this->renderLessonsPage( $rows, 'us-scheduler', 0, $notice, $error );
}
public function renderInstructorLessons(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) );
}
if ( $this->maybeRenderDetail( 'us-my-lessons', true ) ) {
return;
}
$instructorId = get_current_user_id();
[ $notice, $error ] = $this->handleFormAction( true, $instructorId );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( $instructorId ) );
$this->renderLessonsPage( $rows, 'us-my-lessons', $instructorId, $notice, $error );
}
/**
* When the request targets a single lesson (`?lesson_id=`), render its detail
* view and report that the page has been handled. Instructors may only open
* their own lessons; the studio dashboard ($onlyOwn = false) may open any.
*/
private function maybeRenderDetail( string $pageSlug, bool $onlyOwn ): bool {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only lesson selector.
$lessonId = absint( Val::int( $_GET['lesson_id'] ?? 0 ) );
if ( $lessonId <= 0 ) {
return false;
}
$lesson = $this->repository->findById( $lessonId );
$backUrl = admin_url( 'admin.php?page=' . $pageSlug );
$notice = '';
$error = '';
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
$row = null;
$answers = [];
$accepts = [];
$intake = $this->emptyIntake();
} else {
// Recorded before the tables are read, so what was just entered appears
// on the page that reports it.
[ $notice, $error ] = $this->recordIntake( $lesson );
$row = $this->row( $lesson );
$answers = $this->detail->answers( $lesson );
$accepts = $this->detail->acceptances( $lesson );
$intake = $this->intakeForm( $lesson );
}
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
return true;
}
/**
* Handle a submitted "record intake collected elsewhere" form.
*
* @return array{string, string} Success notice and error message.
*/
private function recordIntake( Lesson $lesson ): array {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
return [ '', '' ];
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
return [ '', '' ];
}
$answers = [];
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below.
foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) {
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion.
$rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] );
$versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) );
$result = $this->intake->record(
$lesson,
$answers,
$versionIds,
sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ),
sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ),
get_current_user_id()
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
return $result instanceof \WP_Error
? [ '', $result->get_error_message() ]
: [ $result, '' ];
}
/**
* What the detail template needs to offer the recording form: whether this
* lesson qualifies at all, what is still missing, and the collection methods
* to choose between.
*
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
*/
private function intakeForm( Lesson $lesson ): array {
if ( ! $lesson->isStaffRegistered() ) {
return $this->emptyIntake();
}
return [ 'recordable' => true ] + $this->intake->pending( $lesson ) + [ 'methods' => IntakeProvenance::choices() ];
}
/**
* The form data for a lesson that cannot be recorded against — one the student
* booked, or one that could not be opened at all.
*
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
*/
private function emptyIntake(): array {
return [
'recordable' => false,
'questions' => [],
'policies' => [],
'methods' => [],
];
}
/**
* Render the lessons template with its calendar view state: week (default)
* or list, plus which week the week view shows, and the choices the
* book-for-a-student form offers — scoped to one instructor's own schedule on
* **My Lessons**, studio-wide (0) on the **Scheduler**.
*
* @param list<array<string, mixed>> $rows
*/
// 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.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$view = 'list' === sanitize_key( Val::string( wp_unslash( $_GET['usc_view'] ?? '' ) ) ) ? 'list' : 'week';
$requestedWeek = sanitize_text_field( Val::string( wp_unslash( $_GET['usc_week'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$weekStart = WeekCalendar::weekStart( $requestedWeek, Val::int( get_option( 'start_of_week', 1 ) ), current_time( 'Y-m-d' ) );
$weekDays = WeekCalendar::bucket( $weekStart, $rows, static fn( array $row ): string => Val::string( $row['day'] ) );
$prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' );
$nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' );
$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';
}
/**
* Run the submitted action and report what happened: a per-lesson payment
* override (e-transfer email or HST rate), or a lesson booked for a student.
* When $onlyOwn, the payment or slot must belong to the current instructor.
*
* @return array{string, string} Success notice and error message; each is
* empty when it does not apply.
*/
private function handleFormAction( bool $onlyOwn, int $instructorId ): array {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
return [ '', '' ];
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
if ( 'book_for_student' === $action ) {
return $this->bookForStudent( $onlyOwn ? $instructorId : 0 );
}
$this->updatePayment( $action, $onlyOwn );
return [ '', '' ];
}
/**
* Book a lesson on a student's behalf from the submitted form. The slot is
* scoped to the instructor's own schedule on **My Lessons** ($onlyInstructorId
* non-zero) and studio-wide on the **Scheduler**.
*
* @return array{string, string}
*/
private function bookForStudent( int $onlyInstructorId ): array {
$submitted = $this->submittedBooking();
$result = $this->adminBooking->book(
$submitted['student_id'],
$submitted['slot_id'],
$submitted['offering_id'],
$submitted['weekly'] ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
$submitted['no_charge'],
$submitted['notes'],
$onlyInstructorId
);
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.
*/
private function updatePayment( string $action, bool $onlyOwn ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion.
$taxRate = isset( $_POST['tax_rate'] ) ? max( 0.0, Val::float( $_POST['tax_rate'] ) ) : 0.0;
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( $paymentId <= 0 || ! in_array( $action, [ 'set_etransfer', 'set_tax' ], true ) ) {
return;
}
$payment = $this->payments->findById( $paymentId );
if ( null === $payment || ( $onlyOwn && get_current_user_id() !== $payment->instructorId ) ) {
return;
}
if ( 'set_tax' === $action ) {
$this->payments->updateTax( $paymentId, $taxRate );
return;
}
$this->payments->updateEtransferEmail( $paymentId, '' !== $email ? $email : null );
}
/**
* Build a display row for a lesson, including its e-transfer and HST payment
* overrides.
*
* @return array<string, mixed>
*/
private function row( Lesson $lesson ): array {
$student = get_userdata( $lesson->studentId );
$instructor = get_userdata( $lesson->instructorId );
$payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null;
$slot = $this->availability->findById( $lesson->slotId );
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
return [
'lesson_id' => (int) $lesson->id,
'student' => $student ? $student->display_name : (string) $lesson->studentId,
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
'offering' => $offering ? $offering->title : '—',
'duration' => null !== $offering && null !== $offering->durationMinutes ? $offering->durationMinutes : 0,
'recurrence' => $lesson->recurrence,
'time' => $slot ? $this->formatSlotTime( $slot ) : '—',
'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '',
'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—',
'status' => $lesson->status,
'notes' => $lesson->notes ?? '',
'payment_id' => $payment ? (int) $payment->id : 0,
'currency' => $payment ? (string) $payment->currency : '',
'amount' => $payment ? (float) $payment->amount : 0.0,
'tax_rate' => $payment ? (float) $payment->taxRate : 0.0,
'tax_amount' => $payment ? (float) $payment->taxAmount : 0.0,
'total' => $payment ? $payment->total() : 0.0,
'etransfer_email' => $payment ? (string) $payment->etransferEmail : '',
'etransfer_editable' => null !== $payment && Payment::METHOD_ETRANSFER === $payment->method && ! $payment->isPaid(),
'tax_editable' => null !== $payment && ! $payment->isPaid(),
];
}
/**
* Format a slot's window as e.g. "Jul 6, 2026 9:00 AM10:00 AM", repeating the
* date on the end time only when the slot crosses midnight.
*/
private function formatSlotTime( AvailabilitySlot $slot ): string {
$sameDay = substr( $slot->startDt, 0, 10 ) === substr( $slot->endDt, 0, 10 );
return Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
. ''
. Val::string( mysql2date( $sameDay ? 'g:i A' : 'M j, Y g:i A', $slot->endDt ) );
}
}