CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 3m2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
Group classes gain an instructor-set enrolment deadline (new us_offerings.enrollment_deadline column) that defaults to the first day of the class (term_start). Past the deadline students can no longer self-enrol: the enrolment endpoint rejects it (403 enrollment_closed) and the front-end class list shows "Enrolment has closed." in place of the Enrol button. Instructors keep a manual path: the "Add students directly" control on each class's details page now renders for public classes too (not just invite-only) and deliberately bypasses the deadline and capacity, so a student can be added as a late enrolment after the class has closed. Past the deadline the details page labels these as late enrolments. Bumps USC_VERSION to 1.1.3 for the schema change. Co-Authored-By: Claude Opus 4.8 <[email protected]>
516 lines
18 KiB
PHP
516 lines
18 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace Unsupervised\Schedular\GroupClass;
|
||
|
||
use Unsupervised\Schedular\Auth\Invite;
|
||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||
use Unsupervised\Schedular\Auth\RegistrationController;
|
||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||
use Unsupervised\Schedular\Auth\RoleManager;
|
||
use Unsupervised\Schedular\Auth\UserName;
|
||
use Unsupervised\Schedular\Offering\Offering;
|
||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||
use Unsupervised\Schedular\Payment\Payment;
|
||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||
use Unsupervised\Schedular\Payment\PaymentService;
|
||
use Unsupervised\Schedular\Val;
|
||
|
||
class GroupClassController {
|
||
|
||
public function __construct(
|
||
private EnrollmentRepository $enrollments,
|
||
private OfferingRepository $offerings,
|
||
private PaymentRepository $payments,
|
||
private GroupAccessRepository $access,
|
||
private PaymentService $paymentService,
|
||
private InviteRepository $invites,
|
||
private RegistrationMailer $mailer,
|
||
) {}
|
||
|
||
/**
|
||
* Studio-admin overview: every group class across instructors as a summary —
|
||
* who teaches it, when it meets, and how full it is — rather than a flat list
|
||
* of individual student enrolments. Selecting a class (`?class_id=<id>`) opens
|
||
* the same per-class details page instructors use, so a studio admin (including
|
||
* an owner-operator who also teaches) can view any class's roster and manage
|
||
* invite-only membership from here.
|
||
*/
|
||
public function renderPage(): void {
|
||
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
|
||
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
|
||
}
|
||
|
||
$notice = '';
|
||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
|
||
$notice = $this->handleFormAction( get_current_user_id() );
|
||
}
|
||
|
||
$offerings = $this->offerings->findAll( 0, Offering::KIND_GROUP_CLASS );
|
||
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
|
||
|
||
// View-state query param only (which class to drill into) — nothing is
|
||
// mutated from it, so no nonce applies.
|
||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
|
||
$current = null;
|
||
foreach ( $offerings as $offering ) {
|
||
if ( $offering->id === $classId ) {
|
||
$current = $offering;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if ( null !== $current ) {
|
||
// Enrolments are looked up by the class's own instructor; classDetail
|
||
// filters them down to this offering.
|
||
$class = $this->classDetail( $current, $this->enrollments->findByInstructor( $current->instructorId ) );
|
||
$students = $this->studentOptions();
|
||
|
||
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
|
||
|
||
return;
|
||
}
|
||
|
||
$rows = array_map(
|
||
function ( Offering $offering ): array {
|
||
return [
|
||
'id' => $offering->id,
|
||
'title' => $offering->title,
|
||
'instructor' => $this->instructorName( $offering ),
|
||
'when' => $this->whenLabel( $offering ),
|
||
'capacity' => $offering->capacity,
|
||
'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ),
|
||
'invite_only' => $offering->isInviteOnly(),
|
||
];
|
||
},
|
||
$offerings
|
||
);
|
||
|
||
include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
|
||
}
|
||
|
||
/**
|
||
* Instructor view. By default a summary of the instructor's own group classes
|
||
* — each with when it meets and how many are enrolled — rather than a dump of
|
||
* every roster. A `class_id` query param drills into one class to show its
|
||
* roster of enrolled students and, for invite-only classes, the controls to
|
||
* add, grant access to, or email-invite students.
|
||
*/
|
||
public function renderInstructorPage(): void {
|
||
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
|
||
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
|
||
}
|
||
|
||
$instructorId = get_current_user_id();
|
||
|
||
$notice = '';
|
||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
|
||
$notice = $this->handleFormAction( $instructorId );
|
||
}
|
||
|
||
$offerings = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS );
|
||
$enrollments = $this->enrollments->findByInstructor( $instructorId );
|
||
|
||
// View-state query param only (which class to drill into) — nothing is
|
||
// mutated from it, so no nonce applies.
|
||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
|
||
$current = null;
|
||
foreach ( $offerings as $offering ) {
|
||
if ( $offering->id === $classId ) {
|
||
$current = $offering;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if ( null !== $current ) {
|
||
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
|
||
$class = $this->classDetail( $current, $enrollments );
|
||
$students = $this->studentOptions();
|
||
|
||
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
|
||
|
||
return;
|
||
}
|
||
|
||
$classes = array_map(
|
||
fn( Offering $offering ): array => $this->classSummary( $offering, $enrollments ),
|
||
$offerings
|
||
);
|
||
|
||
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
|
||
|
||
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
|
||
}
|
||
|
||
/**
|
||
* Summary row for one class in the instructor overview: its identity, when it
|
||
* meets, and how many active enrolments it holds against capacity.
|
||
*
|
||
* @param list<Enrollment> $enrollments
|
||
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}
|
||
*/
|
||
private function classSummary( Offering $offering, array $enrollments ): array {
|
||
$enrolled = 0;
|
||
foreach ( $enrollments as $enrollment ) {
|
||
if ( $enrollment->offeringId === $offering->id && Enrollment::STATUS_ACTIVE === $enrollment->status ) {
|
||
++$enrolled;
|
||
}
|
||
}
|
||
|
||
return [
|
||
'id' => $offering->id,
|
||
'title' => $offering->title,
|
||
'when' => $this->whenLabel( $offering ),
|
||
'capacity' => $offering->capacity,
|
||
'enrolled' => $enrolled,
|
||
'invite_only' => $offering->isInviteOnly(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Full details for one class: the summary fields, the class's own settings
|
||
* (instructor, price, duration, description, schedule, active state), the
|
||
* roster of enrolled students (with enrolment and payment status), and — for
|
||
* invite-only classes — the list of people invited but not yet enrolled.
|
||
*
|
||
* @param list<Enrollment> $enrollments
|
||
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
|
||
*/
|
||
private function classDetail( Offering $offering, array $enrollments ): array {
|
||
$roster = [];
|
||
foreach ( $enrollments as $enrollment ) {
|
||
if ( $enrollment->offeringId !== $offering->id ) {
|
||
continue;
|
||
}
|
||
|
||
$student = get_userdata( $enrollment->studentId );
|
||
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
|
||
|
||
$roster[] = [
|
||
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
|
||
'status' => $enrollment->status,
|
||
'payment' => $payment?->status,
|
||
];
|
||
}
|
||
|
||
$deadline = $offering->effectiveEnrollmentDeadline();
|
||
|
||
return $this->classSummary( $offering, $enrollments ) + [
|
||
'instructor' => $this->instructorName( $offering ),
|
||
'price' => $offering->price,
|
||
'currency' => $offering->currency,
|
||
'duration' => $offering->durationMinutes,
|
||
'description' => $offering->description,
|
||
'schedule_note' => $offering->scheduleNote,
|
||
'deadline' => null !== $deadline ? (string) mysql2date( 'M j, Y', $deadline ) : '',
|
||
'enrollment_open' => $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ),
|
||
'active' => $offering->isActive,
|
||
'roster' => $roster,
|
||
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* The teaching instructor's display name — their real name or nickname, never
|
||
* the login. Falls back to the numeric id when the account is gone. See
|
||
* {@see UserName::format()}.
|
||
*/
|
||
private function instructorName( Offering $offering ): string {
|
||
$user = get_userdata( $offering->instructorId );
|
||
|
||
return UserName::format( $user instanceof \WP_User ? $user : null, $offering->instructorId );
|
||
}
|
||
|
||
/**
|
||
* Human-readable "when" label for a class: the class date (or weekly date
|
||
* range) and, when set, the start time. Empty when the class has no date.
|
||
*/
|
||
private function whenLabel( Offering $offering ): string {
|
||
if ( null === $offering->termStart ) {
|
||
return '';
|
||
}
|
||
|
||
$label = null === $offering->termEnd || $offering->termEnd === $offering->termStart
|
||
? (string) mysql2date( 'M j, Y', $offering->termStart )
|
||
: (string) mysql2date( 'M j, Y', $offering->termStart ) . ' – ' . (string) mysql2date( 'M j, Y', $offering->termEnd );
|
||
|
||
if ( null !== $offering->classTime ) {
|
||
$label .= ' · ' . (string) mysql2date( 'g:i a', $offering->termStart . ' ' . $offering->classTime );
|
||
}
|
||
|
||
return $label;
|
||
}
|
||
|
||
/**
|
||
* Pending (not-yet-enrolled) access grants for an invite-only class, shown so
|
||
* the instructor can see who has been invited but has not enrolled yet.
|
||
*
|
||
* @return list<array{who: string, kind: string}>
|
||
*/
|
||
private function pendingInvites( int $offeringId ): array {
|
||
$out = [];
|
||
foreach ( $this->access->findByOffering( $offeringId ) as $grant ) {
|
||
if ( GroupAccess::STATUS_INVITED !== $grant->status ) {
|
||
continue;
|
||
}
|
||
|
||
if ( null !== $grant->studentId ) {
|
||
$user = get_userdata( $grant->studentId );
|
||
$out[] = [
|
||
'who' => $user ? $user->display_name : (string) $grant->studentId,
|
||
'kind' => __( 'Granted', 'unsupervised-schedular' ),
|
||
];
|
||
} else {
|
||
$out[] = [
|
||
'who' => $grant->email,
|
||
'kind' => __( 'Email invite', 'unsupervised-schedular' ),
|
||
];
|
||
}
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Handle a posted management action, returning a status notice for display.
|
||
* The action is scoped to a group class the current instructor owns, unless
|
||
* the caller is a studio admin (`view_all_lessons`) — who may manage any
|
||
* instructor's class, since the studio-admin Group Classes page reaches the
|
||
* same controls for every class.
|
||
*/
|
||
private function handleFormAction( int $instructorId ): string {
|
||
// Nonce is verified by the caller before this method runs.
|
||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
||
$offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
|
||
|
||
$ownsOrManagesAll = null !== $offering
|
||
&& ( $offering->instructorId === $instructorId || current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) );
|
||
|
||
if ( null === $offering || ! $ownsOrManagesAll || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
|
||
return esc_html__( 'That group class was not found.', 'unsupervised-schedular' );
|
||
}
|
||
|
||
if ( 'add_direct' === $action ) {
|
||
return $this->addDirect( $offering, $this->postedStudentIds() );
|
||
}
|
||
|
||
if ( 'grant_access' === $action ) {
|
||
return $this->grantAccess( $offering, $this->postedStudentIds() );
|
||
}
|
||
|
||
if ( 'invite_email' === $action ) {
|
||
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
|
||
|
||
return $this->inviteEmail( $offering, $email );
|
||
}
|
||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Directly enrol registered students, each with a pending payment at the
|
||
* class price (comp students are settled immediately by the payment service).
|
||
*
|
||
* This is the instructor's manual enrolment path and deliberately bypasses the
|
||
* enrolment deadline and capacity, so a student can be added as a late
|
||
* enrolment after the class has closed to self-enrolment.
|
||
*
|
||
* @param list<int> $studentIds
|
||
*/
|
||
private function addDirect( Offering $offering, array $studentIds ): string {
|
||
$added = 0;
|
||
foreach ( $studentIds as $studentId ) {
|
||
if ( $this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId ) ) {
|
||
continue;
|
||
}
|
||
|
||
$enrollmentId = $this->enrollments->insert(
|
||
new Enrollment(
|
||
offeringId: (int) $offering->id,
|
||
studentId: $studentId,
|
||
instructorId: $offering->instructorId,
|
||
)
|
||
);
|
||
|
||
if ( $offering->price > 0.0 ) {
|
||
$payment = $this->paymentService->createForRegistration(
|
||
Payment::REG_ENROLLMENT,
|
||
$enrollmentId,
|
||
$studentId,
|
||
$offering->instructorId,
|
||
$offering->price,
|
||
$offering->currency,
|
||
$offering->etransferEmail
|
||
);
|
||
|
||
if ( null !== $payment && null !== $payment->id ) {
|
||
$this->enrollments->setPaymentId( $enrollmentId, $payment->id );
|
||
}
|
||
}
|
||
|
||
$this->access->markEnrolled( (int) $offering->id, $studentId );
|
||
++$added;
|
||
}
|
||
|
||
/* translators: %d: number of students added. */
|
||
return sprintf( esc_html__( '%d student(s) added to the class.', 'unsupervised-schedular' ), $added );
|
||
}
|
||
|
||
/**
|
||
* Grant registered students access to the class so it appears in their list
|
||
* for self-enrolment, notifying each by email.
|
||
*
|
||
* @param list<int> $studentIds
|
||
*/
|
||
private function grantAccess( Offering $offering, array $studentIds ): string {
|
||
$granted = 0;
|
||
foreach ( $studentIds as $studentId ) {
|
||
if (
|
||
$this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId )
|
||
|| $this->access->hasGrant( (int) $offering->id, $studentId )
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
$this->access->insert(
|
||
new GroupAccess(
|
||
offeringId: (int) $offering->id,
|
||
studentId: $studentId,
|
||
status: GroupAccess::STATUS_INVITED,
|
||
invitedBy: get_current_user_id(),
|
||
)
|
||
);
|
||
|
||
$user = get_userdata( $studentId );
|
||
if ( $user instanceof \WP_User ) {
|
||
$this->mailer->sendClassAccessGranted( $user, $offering->title );
|
||
}
|
||
|
||
++$granted;
|
||
}
|
||
|
||
/* translators: %d: number of students granted access. */
|
||
return sprintf( esc_html__( '%d student(s) granted access.', 'unsupervised-schedular' ), $granted );
|
||
}
|
||
|
||
/**
|
||
* Invite someone by email. A registered address is treated as a grant; an
|
||
* unknown address gets a tokenised registration invite tied to the class,
|
||
* reusing any pending invite already outstanding for that address (in which
|
||
* case no new link is sent).
|
||
*/
|
||
private function inviteEmail( Offering $offering, string $email ): string {
|
||
if ( ! is_email( $email ) ) {
|
||
return esc_html__( 'Enter a valid email address.', 'unsupervised-schedular' );
|
||
}
|
||
|
||
$existingUserId = email_exists( $email );
|
||
if ( false !== $existingUserId ) {
|
||
return $this->grantAccess( $offering, [ (int) $existingUserId ] );
|
||
}
|
||
|
||
// Reuse an outstanding invite rather than mailing a second link; still
|
||
// attach a class grant so enrolment unlocks once they register.
|
||
$pending = $this->invites->findPendingByEmail( $email );
|
||
if ( null !== $pending ) {
|
||
$this->access->insert(
|
||
new GroupAccess(
|
||
offeringId: (int) $offering->id,
|
||
email: $email,
|
||
inviteId: $pending->id,
|
||
status: GroupAccess::STATUS_INVITED,
|
||
invitedBy: get_current_user_id(),
|
||
)
|
||
);
|
||
|
||
return esc_html__( 'This person already has a pending invitation; the class was added to it. No new link was sent.', 'unsupervised-schedular' );
|
||
}
|
||
|
||
$rawToken = wp_generate_password( 32, false );
|
||
$inviteId = $this->invites->insert(
|
||
new Invite(
|
||
email: $email,
|
||
token: Invite::hashToken( $rawToken ),
|
||
invitedBy: get_current_user_id(),
|
||
offeringId: (int) $offering->id,
|
||
)
|
||
);
|
||
|
||
if ( $inviteId <= 0 ) {
|
||
return esc_html__( 'Could not create the invite. Deactivate and reactivate the plugin to update the database, then try again.', 'unsupervised-schedular' );
|
||
}
|
||
|
||
$this->access->insert(
|
||
new GroupAccess(
|
||
offeringId: (int) $offering->id,
|
||
email: $email,
|
||
inviteId: $inviteId,
|
||
status: GroupAccess::STATUS_INVITED,
|
||
invitedBy: get_current_user_id(),
|
||
)
|
||
);
|
||
|
||
$this->mailer->sendClassInvite( $email, $this->registrationLink( $rawToken ), $offering->title );
|
||
|
||
return esc_html__( 'Invitation sent.', 'unsupervised-schedular' );
|
||
}
|
||
|
||
/**
|
||
* Registered students to offer in the add/grant selects, by display name.
|
||
*
|
||
* @return list<array{id: int, name: string}>
|
||
*/
|
||
private function studentOptions(): array {
|
||
$users = array_filter(
|
||
get_users(
|
||
[
|
||
'role' => RoleManager::STUDENT,
|
||
'orderby' => 'display_name',
|
||
'order' => 'ASC',
|
||
]
|
||
),
|
||
static fn( mixed $u ): bool => $u instanceof \WP_User
|
||
);
|
||
|
||
return array_values(
|
||
array_map(
|
||
static fn( \WP_User $u ): array => [
|
||
'id' => (int) $u->ID,
|
||
'name' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email,
|
||
],
|
||
$users
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The de-duplicated positive student ids posted from a multi-select.
|
||
*
|
||
* @return list<int>
|
||
*/
|
||
private function postedStudentIds(): array {
|
||
// Nonce is verified by the caller before this method runs.
|
||
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int below; slashes cannot survive integer coercion.
|
||
$raw = (array) ( $_POST['student_ids'] ?? [] );
|
||
$ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) );
|
||
|
||
return array_values( array_unique( $ids ) );
|
||
}
|
||
|
||
/**
|
||
* Build the registration URL for a raw invite token, mirroring the invites
|
||
* admin page so class invites land on the same registration page.
|
||
*/
|
||
private function registrationLink( string $rawToken ): string {
|
||
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
|
||
$linkBase = $pageId > 0 ? (string) get_permalink( $pageId ) : '';
|
||
|
||
return add_query_arg( 'us_invite', rawurlencode( $rawToken ), '' !== $linkBase ? $linkBase : home_url( '/' ) );
|
||
}
|
||
}
|