Add group-class enrolment (year commitment, capacity, registration gate)
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / Coding Standards (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 1m4s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.3) (pull_request) Successful in 42s
CI / Build Plugin Zip (pull_request) Has been skipped

Implements #4: students enrol in a group_class offering via the same
registration gate as private lessons (intake questions + booking-scoped
policy acceptance). Enrolment is capacity-enforced and prevents duplicates.

- Schema: us_group_enrollments table.
- Enrollment value object + EnrollmentRepository (countActiveForOffering,
  hasActiveEnrollment, per-student/instructor/all-active queries, status).
- EnrollmentEndpoint: GET /enrollments (scoped) and POST /enrollments
  (validates group_class, capacity, no-duplicate; reuses RegistrationGate;
  records answers/acceptances type enrollment).
- GroupClassController + admin page (view_all_lessons): all active enrolments.
- Front-end: [us_group_classes] shortcode (GroupClassPage) + group-classes.js
  enrol flow (list classes -> questions + policies -> POST /enrollments).
- Wiring in Plugin, RestRegistrar, AdminMenu, ShortcodeRegistrar.

Payment is the deferred seam (#7): enrolment lands active, payment_id null.
JS left untested for parity with the repo's no-build vanilla-JS posture.

Tests: tests/Unit/GroupClass/ (Enrollment, EnrollmentRepository).
composer test (121), cs, and PHPStan level 6 all pass.

Refs #4

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:43:33 -03:00
parent 0b3832309d
commit 9cb5207dcd
16 changed files with 842 additions and 16 deletions
+16 -1
View File
@@ -10,6 +10,8 @@ use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Policy\PolicyController;
@@ -27,14 +29,16 @@ class AdminMenu {
private QuestionController $questionController;
private PolicyController $policyController;
private RegistrationController $registrationController;
private GroupClassController $groupClassController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings );
$this->offeringController = new OfferingController( $offerings );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
}
public function register(): void {
@@ -107,6 +111,17 @@ class AdminMenu {
35
);
// Studio admin: all group-class enrolments.
add_menu_page(
__( 'Group Classes', 'unsupervised-schedular' ),
__( 'Group Classes', 'unsupervised-schedular' ),
RoleManager::CAP_VIEW_ALL_LESSONS,
'us-group-classes',
[ $this->groupClassController, 'renderPage' ],
'dashicons-groups',
36
);
// Instructor: view their upcoming lessons.
add_menu_page(
__( 'My Lessons', 'unsupervised-schedular' ),
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
class Enrollment {
public const STATUS_ACTIVE = 'active';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
/**
* All valid enrolment statuses.
*
* @var list<string>
*/
public const VALID_STATUSES = [ self::STATUS_ACTIVE, self::STATUS_CANCELLED, self::STATUS_COMPLETED ];
public function __construct(
public readonly int $offeringId,
public readonly int $studentId,
public readonly int $instructorId,
public readonly string $status = self::STATUS_ACTIVE,
public readonly ?int $paymentId = null,
public readonly ?int $id = null,
) {}
public static function fromRow( object $row ): self {
return new self(
offeringId: (int) $row->offering_id,
studentId: (int) $row->student_id,
instructorId: (int) $row->instructor_id,
status: $row->status,
paymentId: null !== $row->payment_id ? (int) $row->payment_id : null,
id: (int) $row->id,
);
}
/**
* Returns a plain array representation of the enrolment.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'offering_id' => $this->offeringId,
'student_id' => $this->studentId,
'instructor_id' => $this->instructorId,
'status' => $this->status,
'payment_id' => $this->paymentId,
];
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
class EnrollmentEndpoint {
public function __construct(
private EnrollmentRepository $enrollments,
private OfferingRepository $offerings,
private RegistrationGate $gate,
) {}
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
'/enrollments',
[
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'index' ],
'permission_callback' => [ $this, 'isLoggedIn' ],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'enroll' ],
'permission_callback' => [ $this, 'canBook' ],
'args' => [
'offering_id' => [
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
],
'answers' => [
'type' => 'object',
'default' => [],
],
'accepted_policy_version_ids' => [
'type' => 'array',
'default' => [],
],
],
],
]
);
}
public function index( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$userId = get_current_user_id();
if ( current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
$enrollments = $this->enrollments->findAllActive();
} elseif ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
$enrollments = $this->enrollments->findByInstructor( $userId );
} else {
$enrollments = $this->enrollments->findByStudent( $userId );
}
return new \WP_REST_Response( array_map( fn( Enrollment $e ) => $e->toArray(), $enrollments ), 200 );
}
public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$offeringId = absint( $request->get_param( 'offering_id' ) );
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
return new \WP_Error( 'invalid_offering', __( 'Group class not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
$studentId = get_current_user_id();
if ( $this->enrollments->hasActiveEnrollment( $offeringId, $studentId ) ) {
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$answers = $this->answers( $request );
$acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) );
$gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
if ( $gateError instanceof \WP_Error ) {
return $gateError;
}
$id = $this->enrollments->insert(
new Enrollment(
offeringId: $offeringId,
studentId: $studentId,
instructorId: $offering->instructorId,
)
);
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
return new \WP_REST_Response(
[
'id' => $id,
'status' => Enrollment::STATUS_ACTIVE,
],
201
);
}
public function isLoggedIn(): bool {
return is_user_logged_in();
}
public function canBook(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
}
/**
* Extract a question_id => value map from the request.
*
* @return array<int, string>
*/
private function answers( \WP_REST_Request $request ): array {
$out = [];
foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
$out[ (int) $questionId ] = sanitize_text_field( (string) $value );
}
return $out;
}
private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
return '' !== $ip ? $ip : null;
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
class EnrollmentRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_group_enrollments';
}
public function insert( Enrollment $enrollment ): int {
$this->db->insert(
$this->table,
[
'offering_id' => $enrollment->offeringId,
'student_id' => $enrollment->studentId,
'instructor_id' => $enrollment->instructorId,
'status' => $enrollment->status,
'payment_id' => $enrollment->paymentId,
'enrolled_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%d', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
}
public function findById( int $id ): ?Enrollment {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
);
return $row ? Enrollment::fromRow( $row ) : null;
}
/**
* Count active enrolments for an offering (capacity check).
*/
public function countActiveForOffering( int $offeringId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND status = %s",
$offeringId,
Enrollment::STATUS_ACTIVE
)
);
}
/**
* Whether a student already holds an active enrolment in an offering.
*/
public function hasActiveEnrollment( int $offeringId, int $studentId ): bool {
$count = (int) $this->db->get_var(
$this->db->prepare(
"SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND student_id = %d AND status = %s",
$offeringId,
$studentId,
Enrollment::STATUS_ACTIVE
)
);
return $count > 0;
}
/**
* A student's enrolments, newest first.
*
* @return list<Enrollment>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY enrolled_at DESC",
$studentId
)
);
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
}
/**
* Enrolments in an instructor's group classes, newest first.
*
* @return list<Enrollment>
*/
public function findByInstructor( int $instructorId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY enrolled_at DESC",
$instructorId
)
);
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
}
/**
* All active enrolments across instructors (studio-admin view).
*
* @return list<Enrollment>
*/
public function findAllActive(): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE status = %s ORDER BY enrolled_at DESC",
Enrollment::STATUS_ACTIVE
)
);
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
}
public function updateStatus( int $id, string $status ): bool {
if ( ! in_array( $status, Enrollment::VALID_STATUSES, true ) ) {
return false;
}
return (bool) $this->db->update(
$this->table,
[ 'status' => $status ],
[ 'id' => $id ],
[ '%s' ],
[ '%d' ]
);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\OfferingRepository;
class GroupClassController {
public function __construct(
private EnrollmentRepository $enrollments,
private OfferingRepository $offerings,
) {}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
}
$rows = array_map(
function ( Enrollment $enrollment ): array {
$offering = $this->offerings->findById( $enrollment->offeringId );
$student = get_userdata( $enrollment->studentId );
return [
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
'status' => $enrollment->status,
];
},
$this->enrollments->findAllActive()
);
include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\RoleManager;
class GroupClassPage {
/**
* Renders the group-class enrolment shortcode output.
*
* @param array<string, string> $atts Shortcode attributes (unused — reserved for future options).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( ! is_user_logged_in() ) {
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( wp_login_url( get_permalink() ) ),
esc_html__( 'log in to enrol in a class', 'unsupervised-schedular' )
);
}
if ( ! current_user_can( RoleManager::CAP_BOOK_LESSON ) ) {
return '<p>' . esc_html__( 'This page is for students only.', 'unsupervised-schedular' ) . '</p>';
}
wp_enqueue_style( 'us-scheduler' );
wp_enqueue_script( 'us-scheduler-group' );
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
return (string) ob_get_clean();
}
}
+4 -2
View File
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyRepository;
@@ -32,11 +33,12 @@ class Plugin {
$policyService = new PolicyService( $policies, $policyVersions );
$acceptances = new AcceptanceRepository( $wpdb );
$invites = new InviteRepository( $wpdb );
$enrollments = new EnrollmentRepository( $wpdb );
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
( new RoleManager() )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments ) )->register();
( new ShortcodeRegistrar( $invites, $policies, $policyVersions, $acceptances ) )->register();
}
}
+6 -1
View File
@@ -7,6 +7,8 @@ use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Policy\PolicyEndpoint;
@@ -26,13 +28,15 @@ class RestRegistrar {
private OfferingEndpoint $offeringEndpoint;
private QuestionEndpoint $questionEndpoint;
private PolicyEndpoint $policyEndpoint;
private EnrollmentEndpoint $enrollmentEndpoint;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate ) {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate );
$this->offeringEndpoint = new OfferingEndpoint( $offerings );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate );
}
public function register(): void {
@@ -45,5 +49,6 @@ class RestRegistrar {
$this->offeringEndpoint->registerRoutes( self::NAMESPACE );
$this->questionEndpoint->registerRoutes( self::NAMESPACE );
$this->policyEndpoint->registerRoutes( self::NAMESPACE );
$this->enrollmentEndpoint->registerRoutes( self::NAMESPACE );
}
}
+15
View File
@@ -140,6 +140,21 @@ class Schema {
KEY registration (registration_type, registration_id)
) {$charset};",
"CREATE TABLE {$prefix}us_group_enrollments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
offering_id BIGINT UNSIGNED NOT NULL,
student_id BIGINT UNSIGNED NOT NULL,
instructor_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
payment_id BIGINT UNSIGNED DEFAULT NULL,
enrolled_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY offering_id (offering_id),
KEY student_id (student_id),
KEY instructor_id (instructor_id),
KEY status (status)
) {$charset};",
"CREATE TABLE {$prefix}us_invites (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(191) NOT NULL,
+14 -9
View File
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
@@ -16,6 +17,7 @@ class ShortcodeRegistrar {
private BookingPage $bookingPage;
private LoginPage $loginPage;
private RegistrationPage $registrationPage;
private GroupClassPage $groupClassPage;
public function __construct(
InviteRepository $invites,
@@ -26,27 +28,30 @@ class ShortcodeRegistrar {
$this->bookingPage = new BookingPage();
$this->loginPage = new LoginPage();
$this->registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances );
$this->groupClassPage = new GroupClassPage();
}
public function register(): void {
add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
add_shortcode( 'us_student_login', [ $this->loginPage, 'render' ] );
add_shortcode( 'us_student_register', [ $this->registrationPage, 'render' ] );
add_shortcode( 'us_group_classes', [ $this->groupClassPage, 'render' ] );
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
}
public function enqueueAssets(): void {
wp_register_style( 'us-scheduler', USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [], USC_VERSION, true );
wp_localize_script(
'us-scheduler',
'usScheduler',
[
'restUrl' => rest_url( 'us-scheduler/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
]
);
$data = [
'restUrl' => rest_url( 'us-scheduler/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
];
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [], USC_VERSION, true );
wp_localize_script( 'us-scheduler', 'usScheduler', $data );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [], USC_VERSION, true );
wp_localize_script( 'us-scheduler-group', 'usScheduler', $data );
}
}