Add invite-only group classes
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Group classes can now be marked invite-only (us_offerings.access_mode). Invite-only classes are hidden from the public catalog and reachable only when the instructor lets someone in via one of three paths, managed from My Lessons -> My Group Classes: - Add students directly: enrols them now with a pending payment. - Make available: grants registered students access to self-enrol through the normal paid flow (multi-select, emailed a notice). - Invite by email: tokenised registration invite tied to the class for a non-account address; after they register the class becomes enrollable. Reuses an existing pending invite instead of sending a second link. New us_group_access table records grants; GET /offerings merges granted invite-only classes for the caller; enrolment requires a grant (403 invite_required) and flips it to enrolled on success. composer test (487), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -3,10 +3,17 @@ 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\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 {
|
||||
|
||||
@@ -14,6 +21,10 @@ class GroupClassController {
|
||||
private EnrollmentRepository $enrollments,
|
||||
private OfferingRepository $offerings,
|
||||
private PaymentRepository $payments,
|
||||
private GroupAccessRepository $access,
|
||||
private PaymentService $paymentService,
|
||||
private InviteRepository $invites,
|
||||
private RegistrationMailer $mailer,
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
@@ -41,7 +52,8 @@ class GroupClassController {
|
||||
/**
|
||||
* Instructor view: their own group classes with per-class rosters. Each class
|
||||
* shows its enrolment count against capacity plus a roster of enrolled
|
||||
* students with enrolment and payment status.
|
||||
* students with enrolment and payment status. Invite-only classes also carry
|
||||
* controls to add, grant access to, or email-invite students.
|
||||
*/
|
||||
public function renderInstructorPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
|
||||
@@ -49,7 +61,13 @@ class GroupClassController {
|
||||
}
|
||||
|
||||
$instructorId = get_current_user_id();
|
||||
$enrollments = $this->enrollments->findByInstructor( $instructorId );
|
||||
|
||||
$notice = '';
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
|
||||
$notice = $this->handleFormAction( $instructorId );
|
||||
}
|
||||
|
||||
$enrollments = $this->enrollments->findByInstructor( $instructorId );
|
||||
|
||||
$classes = array_map(
|
||||
function ( Offering $offering ) use ( $enrollments ): array {
|
||||
@@ -76,15 +94,280 @@ class GroupClassController {
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $offering->title,
|
||||
'capacity' => $offering->capacity,
|
||||
'enrolled' => $enrolled,
|
||||
'roster' => $roster,
|
||||
'id' => $offering->id,
|
||||
'title' => $offering->title,
|
||||
'capacity' => $offering->capacity,
|
||||
'enrolled' => $enrolled,
|
||||
'invite_only' => $offering->isInviteOnly(),
|
||||
'roster' => $roster,
|
||||
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
|
||||
];
|
||||
},
|
||||
$this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS )
|
||||
);
|
||||
|
||||
$students = $this->studentOptions();
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every action is scoped to a group class the current instructor owns.
|
||||
*/
|
||||
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;
|
||||
|
||||
if ( null === $offering || $offering->instructorId !== $instructorId || 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).
|
||||
*
|
||||
* @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( '/' ) );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user