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