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
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]>
193 lines
8.0 KiB
PHP
193 lines
8.0 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\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 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 ) );
|
||
}
|
||
}
|