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
+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";