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
368 lines
15 KiB
PHP
368 lines
15 KiB
PHP
<?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 AM–10: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 ) );
|
||
}
|
||
}
|