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
+4 -3
View File
@@ -18,6 +18,7 @@ use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -53,15 +54,15 @@ class AdminMenu {
private PaymentController $paymentController;
private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments, $availability );
$this->offeringController = new OfferingController( $offerings );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
$this->instructorController = new InstructorController();
$this->settings = $settings;
+3
View File
@@ -51,6 +51,7 @@ class Invite {
public readonly ?string $createdAt = null,
public readonly string $kind = self::KIND_PERSONAL,
public readonly ?string $expiresAt = null,
public readonly ?int $offeringId = null,
public readonly ?int $id = null,
) {}
@@ -66,6 +67,7 @@ class Invite {
createdAt: Val::stringOrNull( $row->created_at ?? null ),
kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL,
expiresAt: Val::stringOrNull( $row->expires_at ?? null ),
offeringId: Val::intOrNull( $row->offering_id ?? null ),
id: Val::int( $row->id ),
);
}
@@ -132,6 +134,7 @@ class Invite {
'accepted_user_id' => $this->acceptedUserId,
'accepted_at' => $this->acceptedAt,
'expires_at' => $this->expiresAt,
'offering_id' => $this->offeringId,
];
}
}
+2 -1
View File
@@ -23,6 +23,7 @@ class InviteRepository {
'token' => $invite->token,
'role' => $invite->role,
'kind' => $invite->kind,
'offering_id' => $invite->offeringId,
'status' => $invite->status,
'invited_by' => $invite->invitedBy,
'accepted_user_id' => $invite->acceptedUserId,
@@ -30,7 +31,7 @@ class InviteRepository {
'accepted_at' => $invite->acceptedAt,
'expires_at' => $invite->expiresAt,
],
[ '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ]
[ '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s' ]
);
return false === $result ? 0 : $this->db->insert_id;
+50
View File
@@ -105,6 +105,56 @@ class RegistrationMailer {
return (bool) wp_mail( $email, $subject, $body );
}
/**
* Tell a registered student they have been given access to an invite-only
* group class and can now enrol. Returns false when there is no recipient.
*/
public function sendClassAccessGranted( \WP_User $user, string $className ): bool {
if ( '' === (string) $user->user_email ) {
return false;
}
$subject = sprintf(
/* translators: %s: class title */
__( 'You have been invited to %s', 'unsupervised-schedular' ),
$className
);
$body = sprintf(
/* translators: 1: class title, 2: site name, 3: login URL */
__( "You have been given access to the group class \"%1\$s\" at %2\$s.\n\nLog in and open the group classes page to enrol:\n%3\$s", 'unsupervised-schedular' ),
$className,
$this->siteName(),
wp_login_url()
);
return (bool) wp_mail( $user->user_email, $subject, $body );
}
/**
* Email a tokenised registration link to someone invited to a group class who
* does not yet have an account. Returns false when there is no recipient.
*/
public function sendClassInvite( string $email, string $link, string $className ): bool {
if ( '' === $email ) {
return false;
}
$subject = sprintf(
/* translators: %s: class title */
__( 'You are invited to join %s', 'unsupervised-schedular' ),
$className
);
$body = sprintf(
/* translators: 1: class title, 2: site name, 3: registration URL */
__( "You have been invited to the group class \"%1\$s\" at %2\$s.\n\nCreate your account using this link, then choose to enrol in the class:\n%3\$s", 'unsupervised-schedular' ),
$className,
$this->siteName(),
$link
);
return (bool) wp_mail( $email, $subject, $body );
}
private function siteName(): string {
$name = (string) get_bloginfo( 'name' );
+7
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy;
@@ -38,6 +39,7 @@ class RegistrationPage {
private RegistrationMailer $mailer,
private QuestionRepository $questions,
private AnswerRepository $answers,
private GroupAccessRepository $access,
) {}
/**
@@ -203,6 +205,11 @@ class RegistrationPage {
if ( $inviteValid && ! $invite->isGroup() ) {
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
// A personal invite may carry a group-class grant (invited by email);
// point any grants for this address at the new account so the class
// becomes enrollable for them.
$this->access->linkStudentByEmail( $email, (int) $userId );
wp_set_current_user( (int) $userId );
wp_set_auth_cookie( (int) $userId );
+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( '/' ) );
}
}
+24
View File
@@ -27,6 +27,19 @@ class Offering {
*/
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ];
/** Listed in the public catalogue; anyone with `book_lesson` may enrol. */
public const ACCESS_PUBLIC = 'public';
/** Hidden from the catalogue; only invited/added students may enrol (group classes). */
public const ACCESS_INVITE_ONLY = 'invite_only';
/**
* All valid access modes.
*
* @var list<string>
*/
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
public function __construct(
public readonly int $instructorId,
public readonly string $kind,
@@ -43,10 +56,19 @@ class Offering {
public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null,
public readonly string $accessMode = self::ACCESS_PUBLIC,
public readonly bool $isActive = true,
public readonly ?int $id = null,
) {}
/**
* Whether the offering is hidden from the public catalogue and reachable
* only by invited or directly-added students.
*/
public function isInviteOnly(): bool {
return self::ACCESS_INVITE_ONLY === $this->accessMode;
}
/**
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is
* not a real calendar date. Round-trips through DateTimeImmutable so
@@ -85,6 +107,7 @@ class Offering {
scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
accessMode: '' !== Val::string( $row->access_mode ?? '' ) ? Val::string( $row->access_mode ) : self::ACCESS_PUBLIC,
isActive: Val::bool( $row->is_active ),
id: Val::int( $row->id ),
);
@@ -116,6 +139,7 @@ class Offering {
'term_end' => $this->termEnd,
'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode,
'is_active' => $this->isActive,
];
+1
View File
@@ -135,6 +135,7 @@ class OfferingController {
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
cancellationCutoffHours: $cutoffHours,
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
isActive: isset( $_POST['is_active'] ),
id: $existing?->id,
);
+55 -6
View File
@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Val;
class OfferingEndpoint {
public function __construct( private OfferingRepository $repository ) {}
public function __construct(
private OfferingRepository $repository,
private GroupAccessRepository $access,
) {}
/**
* Registers this endpoint's REST routes.
@@ -62,16 +66,53 @@ class OfferingEndpoint {
}
public function index( \WP_REST_Request $request ): \WP_REST_Response {
$offerings = $this->repository->findAll(
Val::int( $request->get_param( 'instructor_id' ) ),
Val::string( $request->get_param( 'kind' ) ),
activeOnly: true,
);
$instructorId = Val::int( $request->get_param( 'instructor_id' ) );
$kind = Val::string( $request->get_param( 'kind' ) );
// The public catalogue is public offerings only; invite-only classes are
// hidden from it and surfaced separately to the students granted access.
$offerings = $this->repository->findAll( $instructorId, $kind, activeOnly: true, accessMode: Offering::ACCESS_PUBLIC );
foreach ( $this->grantedInviteOnly( $instructorId, $kind ) as $granted ) {
$offerings[] = $granted;
}
// Public listing: omit the private e-transfer destination email.
return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $offerings ), 200 );
}
/**
* The active invite-only offerings the caller has been granted access to,
* matching the same instructor/kind filters as the public catalogue.
*
* @return list<Offering>
*/
private function grantedInviteOnly( int $instructorId, string $kind ): array {
$grantedIds = $this->access->findGrantedOfferingIds( get_current_user_id() );
if ( [] === $grantedIds ) {
return [];
}
$out = [];
foreach ( $grantedIds as $offeringId ) {
$offering = $this->repository->findById( $offeringId );
if (
null === $offering
|| ! $offering->isActive
|| ! $offering->isInviteOnly()
|| ( $instructorId > 0 && $offering->instructorId !== $instructorId )
|| ( '' !== $kind && $offering->kind !== $kind )
) {
continue;
}
$out[] = $offering;
}
return $out;
}
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) );
if ( '' === $title ) {
@@ -104,6 +145,7 @@ class OfferingEndpoint {
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
);
@@ -150,6 +192,7 @@ class OfferingEndpoint {
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
id: $id,
);
@@ -212,6 +255,12 @@ class OfferingEndpoint {
return '' !== $email ? $email : null;
}
private function accessMode( mixed $value, string $fallback ): string {
$mode = Val::string( $value );
return in_array( $mode, Offering::VALID_ACCESS_MODES, true ) ? $mode : $fallback;
}
private function nullableInt( mixed $value ): ?int {
return ( null === $value || '' === $value ) ? null : Val::int( $value );
}
+12 -4
View File
@@ -15,11 +15,11 @@ class OfferingRepository {
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
* capacity, term_start, term_end, schedule_note, etransfer_email,
* cancellation_cutoff_hours, is_active).
* cancellation_cutoff_hours, access_mode, is_active).
*
* @var list<string>
*/
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%d' ];
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
public function insert( Offering $offering ): int {
$this->db->insert(
@@ -63,16 +63,19 @@ class OfferingRepository {
'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
'access_mode' => $offering->accessMode,
'is_active' => $offering->isActive ? 1 : 0,
];
}
/**
* Find offerings, optionally filtered by instructor, kind, and active state.
* Find offerings, optionally filtered by instructor, kind, active state, and
* access mode (e.g. `Offering::ACCESS_PUBLIC` to exclude invite-only classes
* from the public catalogue).
*
* @return list<Offering>
*/
public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null ): array {
public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null, ?string $accessMode = null ): array {
$where = [ '1 = 1' ];
$params = [];
@@ -91,6 +94,11 @@ class OfferingRepository {
$params[] = $activeOnly ? 1 : 0;
}
if ( null !== $accessMode ) {
$where[] = 'access_mode = %s';
$params[] = $accessMode;
}
$whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY title ASC";
+5 -3
View File
@@ -14,6 +14,7 @@ use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
@@ -58,6 +59,7 @@ class Plugin {
$acceptances = new AcceptanceRepository( $wpdb );
$invites = new InviteRepository( $wpdb );
$enrollments = new EnrollmentRepository( $wpdb );
$groupAccess = new GroupAccessRepository( $wpdb );
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
$paymentRepo = new PaymentRepository( $wpdb );
@@ -72,15 +74,15 @@ class Plugin {
$bookingPage = new BookingPage();
$loginPage = new LoginPage();
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers );
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
$groupClassPage = new GroupClassPage();
( new UpdateChecker() )->register();
( new RoleManager() )->register();
( new RegistrationLoginGate() )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
}
+4 -3
View File
@@ -9,6 +9,7 @@ use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -35,13 +36,13 @@ class RestRegistrar {
private EnrollmentEndpoint $enrollmentEndpoint;
private PaymentEndpoint $paymentEndpoint;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, PaymentService $paymentService ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) );
$this->offeringEndpoint = new OfferingEndpoint( $offerings );
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess );
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
}
+18
View File
@@ -66,6 +66,7 @@ class Schema {
schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email VARCHAR(191) DEFAULT NULL,
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
access_mode VARCHAR(20) NOT NULL DEFAULT 'public',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
@@ -190,6 +191,7 @@ class Schema {
token VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'us_student',
kind VARCHAR(10) NOT NULL DEFAULT 'personal',
offering_id BIGINT UNSIGNED DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
invited_by BIGINT UNSIGNED DEFAULT NULL,
accepted_user_id BIGINT UNSIGNED DEFAULT NULL,
@@ -201,6 +203,22 @@ class Schema {
KEY email (email),
KEY status (status)
) {$charset};",
"CREATE TABLE {$prefix}us_group_access (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
offering_id BIGINT UNSIGNED NOT NULL,
student_id BIGINT UNSIGNED DEFAULT NULL,
email VARCHAR(191) NOT NULL DEFAULT '',
invite_id BIGINT UNSIGNED DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'invited',
invited_by BIGINT UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY offering_id (offering_id),
KEY student_id (student_id),
KEY email (email),
KEY status (status)
) {$charset};",
];
}
}