CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Front end: the student "upcoming lessons" panel now shows each booked offering's name and length next to the time, and renders only the soonest five lessons with a "Show all" reveal. GET /bookings returns offering_title and duration_minutes so the list needs no extra request. Admin: the Scheduler and My Lessons week/list views now show the booked offering, and each lesson links to a detail view showing the policy versions the student accepted (with acceptance time and IP) and their intake answers. On My Lessons an instructor may only open their own lessons; the studio Scheduler may open any. composer test / composer lint / composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[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( $lessonId );
|
||
$accepts = $this->detail->acceptances( $lessonId );
|
||
}
|
||
|
||
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 ) );
|
||
}
|
||
}
|