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

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:
2026-07-23 13:51:02 -03:00
co-authored by Claude Opus 4.8
parent 25aeba9dc1
commit a281935811
33 changed files with 1598 additions and 38 deletions
+13
View File
@@ -19,6 +19,7 @@ class EnrollmentEndpoint {
private OfferingRepository $offerings,
private RegistrationGate $gate,
private PaymentService $payments,
private GroupAccessRepository $access,
) {}
/**
@@ -88,6 +89,12 @@ class EnrollmentEndpoint {
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
// Invite-only classes can only be enrolled in by students who were granted
// access (or added directly); everyone else never sees the class at all.
if ( $offering->isInviteOnly() && ! $this->access->hasGrant( $offeringId, $studentId ) ) {
return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
@@ -110,6 +117,12 @@ class EnrollmentEndpoint {
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
// Mark the access grant used so instructor rosters distinguish invited
// students from enrolled ones (a no-op for public classes).
if ( $offering->isInviteOnly() ) {
$this->access->markEnrolled( $offeringId, $studentId );
}
$payment = null;
if ( $offering->price > 0.0 ) {
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Val;
/**
* A grant of access to an invite-only group class. Registered students who have
* been "made available" a class hold an `invited` grant (`student_id` set);
* email-invited people who do not yet have an account hold a grant keyed by
* `email` and linked to a `us_invites` row, which is pointed at the new
* `student_id` once they register. A grant flips to `enrolled` when the student
* enrols through the normal flow.
*/
class GroupAccess {
public const STATUS_INVITED = 'invited';
public const STATUS_ENROLLED = 'enrolled';
public const STATUS_REVOKED = 'revoked';
/**
* All valid grant statuses.
*
* @var list<string>
*/
public const VALID_STATUSES = [ self::STATUS_INVITED, self::STATUS_ENROLLED, self::STATUS_REVOKED ];
public function __construct(
public readonly int $offeringId,
public readonly ?int $studentId = null,
public readonly string $email = '',
public readonly ?int $inviteId = null,
public readonly string $status = self::STATUS_INVITED,
public readonly ?int $invitedBy = null,
public readonly ?int $id = null,
) {}
public static function fromRow( \stdClass $row ): self {
return new self(
offeringId: Val::int( $row->offering_id ),
studentId: Val::intOrNull( $row->student_id ),
email: Val::string( $row->email ?? '' ),
inviteId: Val::intOrNull( $row->invite_id ?? null ),
status: Val::string( $row->status ),
invitedBy: Val::intOrNull( $row->invited_by ?? null ),
id: Val::int( $row->id ),
);
}
/**
* Returns a plain array representation of the grant.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'offering_id' => $this->offeringId,
'student_id' => $this->studentId,
'email' => $this->email,
'invite_id' => $this->inviteId,
'status' => $this->status,
'invited_by' => $this->invitedBy,
];
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
class GroupAccessRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_group_access';
}
public function insert( GroupAccess $access ): int {
$this->db->insert(
$this->table,
[
'offering_id' => $access->offeringId,
'student_id' => $access->studentId,
'email' => $access->email,
'invite_id' => $access->inviteId,
'status' => $access->status,
'invited_by' => $access->invitedBy,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%s', '%d', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
}
/**
* Whether a student holds a live (invited or enrolled) grant for an offering.
*/
public function hasGrant( int $offeringId, int $studentId ): bool {
$count = (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status IN ( %s, %s )',
$this->table,
$offeringId,
$studentId,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
);
return $count > 0;
}
/**
* The offering ids a student holds a live grant for — the invite-only classes
* to fold into their catalogue view.
*
* @return list<int>
*/
public function findGrantedOfferingIds( int $studentId ): array {
$rows = $this->db->get_col(
$this->db->prepare(
'SELECT DISTINCT offering_id FROM %i WHERE student_id = %d AND status IN ( %s, %s )',
$this->table,
$studentId,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
);
return array_values( array_map( \Unsupervised\Schedular\Val::int( ... ), $rows ) );
}
/**
* All grants for an offering, newest first.
*
* @return list<GroupAccess>
*/
public function findByOffering( int $offeringId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE offering_id = %d ORDER BY id DESC',
$this->table,
$offeringId
)
);
return array_map( GroupAccess::fromRow( ... ), $rows ?? [] );
}
/**
* Point email-invite grants for an address at the account created when the
* invitation was accepted, so the granted class unlocks for the new student.
* Only grants still awaiting an account (`student_id` NULL) are linked.
*/
public function linkStudentByEmail( string $email, int $studentId ): bool {
if ( '' === $email ) {
return false;
}
$sql = $this->db->prepare(
'UPDATE %i SET student_id = %d WHERE email = %s AND student_id IS NULL',
$this->table,
$studentId,
$email
);
return null !== $sql && false !== $this->db->query( $sql );
}
/**
* Flip a student's live grant for an offering to enrolled.
*/
public function markEnrolled( int $offeringId, int $studentId ): bool {
return false !== $this->db->update(
$this->table,
[ 'status' => GroupAccess::STATUS_ENROLLED ],
[
'offering_id' => $offeringId,
'student_id' => $studentId,
],
[ '%s' ],
[ '%d', '%d' ]
);
}
public function revoke( int $id ): bool {
return false !== $this->db->update(
$this->table,
[ 'status' => GroupAccess::STATUS_REVOKED ],
[ 'id' => $id ],
[ '%s' ],
[ '%d' ]
);
}
}
+289 -6
View File
@@ -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( '/' ) );
}
}