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:
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user