Files
unsupervised-scheduler/src/Booking/LessonController.php
T
thatguygriffandClaude Opus 5 df3462a8b3
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Coding Standards (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
Show a series' policy acceptances on every occurrence
A weekly booking reserves a series of lessons, but the student answers
the intake and ticks the policy boxes once — so BookingEndpoint records
both against the anchor lesson alone. The admin detail view looked them
up by whichever lesson id was being viewed, so every occurrence after
the first showed no answers and no acceptances at all.

LessonDetail now takes the Lesson rather than a bare id and resolves the
registration to `series_id ?? id`, so each occurrence reads the anchor's
records. This is the same seam PaymentService already uses to find a
series lesson's payment on the anchor.

Nothing was ever missing from the database, so existing bookings read
correctly with no migration and no schema change.

Closes #167

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 16:35:26 -03:00

193 lines
8.0 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\Val;
class LessonController {
public function __construct(
private BookingRepository $repository,
private PaymentRepository $payments,
private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private LessonDetail $detail,
) {}
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;
}
$this->handleEtransferUpdate( false );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
$this->renderLessonsPage( $rows, 'us-scheduler' );
}
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;
}
$this->handleEtransferUpdate( true );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
$this->renderLessonsPage( $rows, 'us-my-lessons' );
}
/**
* 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 );
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
$row = null;
$answers = [];
$accepts = [];
} else {
$row = $this->row( $lesson );
$answers = $this->detail->answers( $lesson );
$accepts = $this->detail->acceptances( $lesson );
}
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
return true;
}
/**
* Render the lessons template with its calendar view state: week (default)
* or list, plus which week the week view shows.
*
* @param list<array<string, mixed>> $rows
*/
private function renderLessonsPage( array $rows, string $pageSlug ): 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 );
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
}
/**
* Handle a per-lesson payment override (e-transfer email or HST rate). When
* $onlyOwn, the payment must belong to the current instructor.
*/
private function handleEtransferUpdate( bool $onlyOwn ): void {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
$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 ) );
}
}