Files
unsupervised-scheduler/src/Booking/LessonController.php
T
thatguygriffandClaude Opus 5 8c21a3fa9d
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped
Let the studio book lessons and record intake collected elsewhere
Two related gaps, closed together because the second is created by the first.

A private lesson could only be booked by the student or their guardian, so a
booking taken over the phone had no way in — where group classes have had "Add
students directly" all along. "Book a lesson for a student" is now a panel on
Scheduler and My Lessons: student, open time, lesson type, with weekly term
reservations and a no-charge option for make-up lessons. The booking core is
extracted to Booking\LessonBooker and shared with POST /bookings, so the two
paths cannot drift on offering rules, slot claiming, or billing.

That leaves a registration with no intake answers and no policy acceptances,
because nobody was at a keyboard to give them — already true of every directly
added group-class student. Ticking the boxes on a student's behalf would be an
audit trail that says something untrue, so instead the answers are collected
another way and recorded afterwards, from a lesson's or an enrolment's detail
page. Every recording must say how it was collected, which is stamped on each
row along with who typed it and shown in a new "How it was given" column: a
policy ticked online and one transcribed from paper must never look alike.

Only staff-made registrations qualify (us_lessons.booked_by,
us_group_enrollments.enrolled_by) — one the student made already holds their
own answers. Only what is still missing can be recorded, re-checked at write
time, so a stale or double-posted form cannot duplicate or overwrite. No IP is
stored for a transcription, and accepted_by stays the student while recorded_by
names the staff member.

Intake is now generic over Registration\IntakeSubject, which Lesson and
Enrollment both implement; LessonDetail became Registration\IntakeAudit and is
shared by both detail views rather than duplicated.

Closes #182

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK
2026-08-24 14:06:16 -03:00

327 lines
14 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 and $error are 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 );
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 {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$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'] ?? '' ) ) ),
$onlyInstructorId
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
return $result instanceof \WP_Error
? [ '', $result->get_error_message() ]
: [ $result, '' ];
}
/**
* 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 ) );
}
}