CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m3s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m24s
CI / Coding Standards & Static Analysis (pull_request) Successful in 8m21s
CI / Build Plugin Zip (pull_request) Skipped
The Book a lesson for a student panel built its picker from the us_student role but vetted the submission with the book_lesson capability. ChildLoginGate and RegistrationLoginGate withhold that capability from accounts that keep the role, so the panel offered every guardian-managed child and every unapproved signup and then refused them — with a message claiming no student had been chosen, and a form cleared of all five fields. Withholding book_lesson stops those accounts registering in their own name. It was never meant to stop the studio acting for them, which is what the panel is for, and for a child is the only route to a lesson besides their guardian. Guard the student role instead, via a new RoleManager::isStudent() shared with every picker and guard on the staff side so the two cannot drift apart again. Group enrolment gets the same predicate: addDirect() and grantAccess() vetted their posted ids not at all, and would enrol an instructor, an administrator, or an account deleted since the page was drawn — raising a real payment against them for a priced class. Keep a refused booking's fields as submitted, reading the form through one LessonController::submittedBooking() so what gets booked and what is shown again cannot disagree about a field name. A booking that succeeds still leaves an empty form, so the next one does not inherit it. Closes #185 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01XunBYk2sFEc1oL14sUiuBU
675 lines
25 KiB
PHP
675 lines
25 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\Registration\IntakeAudit;
|
||
use Unsupervised\Schedular\Registration\IntakeProvenance;
|
||
use Unsupervised\Schedular\Registration\IntakeRecording;
|
||
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,
|
||
private IntakeAudit $audit,
|
||
private IntakeRecording $intake,
|
||
) {}
|
||
|
||
/**
|
||
* 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' ) );
|
||
}
|
||
|
||
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
|
||
|
||
if ( $this->maybeRenderEnrollmentDetail( $baseUrl, 0 ) ) {
|
||
return;
|
||
}
|
||
|
||
$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 );
|
||
|
||
// 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();
|
||
|
||
if ( $this->maybeRenderEnrollmentDetail( admin_url( 'admin.php?page=us-my-group-classes' ), $instructorId ) ) {
|
||
return;
|
||
}
|
||
|
||
$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';
|
||
}
|
||
|
||
/**
|
||
* When the request targets a single enrolment (`?enrollment_id=`), render its
|
||
* detail view — the audit trail of what the student answered and agreed to,
|
||
* and, for an enrolment the studio made, the form to record intake collected
|
||
* elsewhere. Reports whether the page has been handled.
|
||
*
|
||
* `$onlyInstructorId` scopes it the way the pages themselves are scoped: an
|
||
* instructor may only open enrolments in their own classes, while the studio
|
||
* **Group Classes** page passes 0 and may open any.
|
||
*/
|
||
private function maybeRenderEnrollmentDetail( string $baseUrl, int $onlyInstructorId ): bool {
|
||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only enrolment selector.
|
||
$enrollmentId = absint( Val::int( $_GET['enrollment_id'] ?? 0 ) );
|
||
if ( $enrollmentId <= 0 ) {
|
||
return false;
|
||
}
|
||
|
||
$enrollment = $this->enrollments->findById( $enrollmentId );
|
||
$notice = '';
|
||
$error = '';
|
||
|
||
if ( null === $enrollment || ( $onlyInstructorId > 0 && $enrollment->instructorId !== $onlyInstructorId ) ) {
|
||
$row = null;
|
||
$answers = [];
|
||
$accepts = [];
|
||
$intake = $this->emptyIntake();
|
||
} else {
|
||
// Recorded before the tables are read, so what was just entered appears
|
||
// on the page that reports it.
|
||
[ $notice, $error ] = $this->recordIntake( $enrollment );
|
||
|
||
$row = $this->enrollmentRow( $enrollment );
|
||
$answers = $this->audit->answers( $enrollment );
|
||
$accepts = $this->audit->acceptances( $enrollment );
|
||
$intake = $this->intakeForm( $enrollment );
|
||
}
|
||
|
||
include USC_PLUGIN_DIR . 'templates/admin/enrollment-detail.php';
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Who and what one enrolment is, for the head of its detail view.
|
||
*
|
||
* @return array{enrollment_id: int, student: string, class: string, instructor: string, status: string, payment: string}
|
||
*/
|
||
private function enrollmentRow( Enrollment $enrollment ): array {
|
||
$student = get_userdata( $enrollment->studentId );
|
||
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
|
||
|
||
return [
|
||
'enrollment_id' => (int) $enrollment->id,
|
||
'student' => UserName::format( $student instanceof \WP_User ? $student : null, $enrollment->studentId ),
|
||
'class' => null !== $offering ? $offering->title : '—',
|
||
'instructor' => null !== $offering ? $this->instructorName( $offering ) : '—',
|
||
'status' => $enrollment->status,
|
||
'payment' => null !== $payment ? $payment->status : '—',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Handle a submitted "record intake collected elsewhere" form.
|
||
*
|
||
* @return array{string, string} Success notice and error message.
|
||
*/
|
||
private function recordIntake( Enrollment $enrollment ): array {
|
||
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_group_action' ) ) {
|
||
return [ '', '' ];
|
||
}
|
||
|
||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
|
||
if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
|
||
return [ '', '' ];
|
||
}
|
||
|
||
$answers = [];
|
||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below.
|
||
foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) {
|
||
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||
}
|
||
|
||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion.
|
||
$rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] );
|
||
$versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) );
|
||
|
||
$result = $this->intake->record(
|
||
$enrollment,
|
||
$answers,
|
||
$versionIds,
|
||
sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ),
|
||
sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ),
|
||
get_current_user_id()
|
||
);
|
||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||
|
||
return $result instanceof \WP_Error
|
||
? [ '', $result->get_error_message() ]
|
||
: [ $result, '' ];
|
||
}
|
||
|
||
/**
|
||
* What the detail template needs to offer the recording form: whether this
|
||
* enrolment qualifies at all, what is still missing, and the collection
|
||
* methods to choose between.
|
||
*
|
||
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
|
||
*/
|
||
private function intakeForm( Enrollment $enrollment ): array {
|
||
if ( ! $enrollment->isStaffRegistered() ) {
|
||
return $this->emptyIntake();
|
||
}
|
||
|
||
return [ 'recordable' => true ] + $this->intake->pending( $enrollment ) + [ 'methods' => IntakeProvenance::choices() ];
|
||
}
|
||
|
||
/**
|
||
* The form data for an enrolment that cannot be recorded against — one the
|
||
* student made, or one that could not be opened at all.
|
||
*
|
||
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
|
||
*/
|
||
private function emptyIntake(): array {
|
||
return [
|
||
'recordable' => false,
|
||
'questions' => [],
|
||
'policies' => [],
|
||
'methods' => [],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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{id: int, 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[] = [
|
||
'id' => (int) $enrollment->id,
|
||
'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,
|
||
// Stamped so this enrolment can be told apart later: only one the
|
||
// studio made may have its intake recorded after the fact, the
|
||
// student never having been asked the questions.
|
||
enrolledBy: get_current_user_id(),
|
||
)
|
||
);
|
||
|
||
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 student ids posted from a multi-select, keeping only ids
|
||
* that are actually students.
|
||
*
|
||
* The select is built from {@see studentOptions()}, but nothing stops a posted
|
||
* id naming an instructor, an administrator, or an account deleted since the
|
||
* page was drawn — and enrolling one would write a roster row, and bill it,
|
||
* against someone who is not in the class. Vetting here covers both actions at
|
||
* once, and against the same {@see RoleManager::isStudent()} the picker uses,
|
||
* so a child or an unapproved signup is still perfectly enrollable.
|
||
*
|
||
* @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_filter( array_unique( $ids ), RoleManager::isStudent( ... ) ) );
|
||
}
|
||
|
||
/**
|
||
* 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( '/' ) );
|
||
}
|
||
}
|