`) 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 $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 $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, invited: list} */ 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 */ 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 $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 $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 */ 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 */ 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( '/' ) ); } }