CI / Coding Standards (pull_request) Failing after 28s
CI / Tests (PHP 8.5) (pull_request) Failing after 27s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Failing after 39s
CI / Tests (PHP 8.3) (pull_request) Failing after 1m7s
CI / Tests (PHP 8.2) (pull_request) Failing after 1m8s
CI / Static Analysis (pull_request) Successful in 1m17s
CI / Build Plugin Zip (pull_request) Skipped
The assessment looked for three things: whether students can reach each other's bookings, whether payment settings can be dodged, and whether the plugin opens a way into the rest of the install. The student-isolation and payment paths held up. These are what did not. - The front-end login form told WordPress not to work out whether the site was secure, so on HTTPS every student's session cookie was issued without the Secure flag. wp_signon() only derives it from is_ssl() when the second argument is left at its default; an explicit false reads like "no preference" and is not. - The update check took whatever download URL the release API returned and handed it to core, which unpacks it over the installed plugin. The package must now be https on git.unsupervised.ca exactly, compared on the parsed host so a lookalike name cannot pass. - Uninstalling dropped 2 of 14 tables and left the Stripe secret and webhook signing key in wp_options. Removal is now a choice made in advance on Access -> Plugin removal: records are kept unless the owner opts in (with a typed confirmation), while credentials and the borrowed core registration settings go every time. - Open registration switches on the site-wide users_can_register and makes Student the default role, arming any other signup form on the site to mint students who could book and be billed immediately. The pending state is now decided once, on user_register, rather than by whichever form created the account. - Cancel and withdraw answered "not yours" differently from "does not exist", which let a signed-in student enumerate the studio's bookings. Both now give the same 404. Co-Authored-By: Claude Opus 5 <[email protected]>
290 lines
10 KiB
PHP
290 lines
10 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\GroupClass;
|
|
|
|
use Unsupervised\Schedular\Auth\RoleManager;
|
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
|
use Unsupervised\Schedular\Offering\Offering;
|
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
|
use Unsupervised\Schedular\Payment\Payment;
|
|
use Unsupervised\Schedular\Payment\PaymentService;
|
|
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
|
use Unsupervised\Schedular\Registration\RegistrationGate;
|
|
use Unsupervised\Schedular\Val;
|
|
|
|
class EnrollmentEndpoint {
|
|
|
|
public function __construct(
|
|
private EnrollmentRepository $enrollments,
|
|
private OfferingRepository $offerings,
|
|
private RegistrationGate $gate,
|
|
private PaymentService $payments,
|
|
private GroupAccessRepository $access,
|
|
private GuardianService $guardians,
|
|
) {}
|
|
|
|
/**
|
|
* Registers this endpoint's REST routes.
|
|
*
|
|
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
|
|
*/
|
|
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',
|
|
],
|
|
// Who is being enrolled. 0/absent means the caller enrols
|
|
// themselves; a child's id is honoured only for their guardian.
|
|
'student_id' => [
|
|
'type' => 'integer',
|
|
'default' => 0,
|
|
'sanitize_callback' => 'absint',
|
|
],
|
|
'answers' => [
|
|
'type' => 'object',
|
|
'default' => [],
|
|
],
|
|
'accepted_policy_version_ids' => [
|
|
'type' => 'array',
|
|
'default' => [],
|
|
],
|
|
],
|
|
],
|
|
]
|
|
);
|
|
|
|
register_rest_route(
|
|
$route_namespace,
|
|
'/enrollments/(?P<id>\d+)/withdraw',
|
|
[
|
|
[
|
|
'methods' => \WP_REST_Server::CREATABLE,
|
|
'callback' => [ $this, 'withdraw' ],
|
|
'permission_callback' => [ $this, 'isLoggedIn' ],
|
|
],
|
|
]
|
|
);
|
|
}
|
|
|
|
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 {
|
|
// A guardian sees the whole household's enrolments — their own and
|
|
// every child's — so one account covers the family.
|
|
$enrollments = [];
|
|
foreach ( $this->guardians->householdIds( $userId ) as $studentId ) {
|
|
$enrollments = array_merge( $enrollments, $this->enrollments->findByStudent( $studentId ) );
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// Who is being enrolled is settled before anything else, so an
|
|
// unauthorised student id never reaches a seat claim or a charge.
|
|
$studentId = $this->resolveStudent( $request );
|
|
if ( $studentId instanceof \WP_Error ) {
|
|
return $studentId;
|
|
}
|
|
|
|
$offeringId = absint( Val::int( $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 ] );
|
|
}
|
|
|
|
if ( $this->enrollments->hasActiveEnrollment( $offeringId, $studentId ) ) {
|
|
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 ] );
|
|
}
|
|
|
|
// Enrolment closes at the end of the deadline day — the instructor's set
|
|
// deadline, or the first class day by default.
|
|
if ( ! $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
|
|
return new \WP_Error( 'enrollment_closed', __( 'Enrolment for this class has closed.', '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 ] );
|
|
}
|
|
|
|
$answers = $this->answers( $request );
|
|
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (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,
|
|
)
|
|
);
|
|
|
|
// The acceptance binds the student but is attributed to whoever ticked the
|
|
// boxes — the guardian, when they enrolled a child.
|
|
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() );
|
|
|
|
// 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 );
|
|
}
|
|
|
|
// Scheduled billing (weekly / monthly) is generated later by the daily
|
|
// billing scan, so nothing is charged at enrolment; the enrolment is active
|
|
// regardless of payment.
|
|
$payment = null;
|
|
if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) {
|
|
$payment = $this->payments->createForRegistration(
|
|
Payment::REG_ENROLLMENT,
|
|
$id,
|
|
$studentId,
|
|
$offering->instructorId,
|
|
$offering->price,
|
|
$offering->currency,
|
|
$offering->etransferEmail,
|
|
payerId: $this->guardians->payerFor( $studentId )
|
|
);
|
|
}
|
|
|
|
// `payment: null` tells the front end to skip the payment step entirely.
|
|
return new \WP_REST_Response(
|
|
[
|
|
'id' => $id,
|
|
'status' => Enrollment::STATUS_ACTIVE,
|
|
'payment' => $payment?->toSummaryArray(),
|
|
],
|
|
201
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Withdraw the current student from a group class they enrolled in. Allowed
|
|
* only while the offering's withdrawal deadline is open (a class with no
|
|
* deadline set stays open indefinitely); once it passes, the student must
|
|
* contact the studio and an admin withdraws them by hand. A timely withdrawal
|
|
* frees the seat and voids any still-pending payment but never issues an
|
|
* account credit — that is reserved for cancelled lessons.
|
|
*/
|
|
public function withdraw( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
|
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
|
$enrollment = $this->enrollments->findById( $id );
|
|
|
|
// Someone else's enrolment is answered exactly as a nonexistent one, so the
|
|
// id space cannot be walked to count the studio's enrolments. See
|
|
// {@see \Unsupervised\Schedular\Booking\BookingEndpoint::cancel()}, which
|
|
// makes the same trade for the same reason.
|
|
if ( null === $enrollment || ! $this->guardians->canActFor( get_current_user_id(), $enrollment->studentId ) ) {
|
|
return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
|
}
|
|
|
|
if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) {
|
|
$offering = $this->offerings->findById( $enrollment->offeringId );
|
|
|
|
if ( null !== $offering && ! $offering->isWithdrawalOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
|
|
return new \WP_Error(
|
|
'withdrawal_closed',
|
|
__( 'Withdrawal for this class has closed. Please contact the studio.', 'unsupervised-schedular' ),
|
|
[ 'status' => 403 ]
|
|
);
|
|
}
|
|
|
|
$this->enrollments->updateStatus( $id, Enrollment::STATUS_CANCELLED );
|
|
$this->payments->voidPending( $enrollment->paymentId );
|
|
}
|
|
|
|
return new \WP_REST_Response(
|
|
[
|
|
'id' => $id,
|
|
'status' => Enrollment::STATUS_CANCELLED,
|
|
],
|
|
200
|
|
);
|
|
}
|
|
|
|
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 );
|
|
}
|
|
|
|
/**
|
|
* Who this enrolment is for: the caller by default, or one of their children
|
|
* when a `student_id` is supplied and they are that child's guardian. An id
|
|
* the caller may not act for is a 403, never a silent fallback to themselves.
|
|
*/
|
|
private function resolveStudent( \WP_REST_Request $request ): int|\WP_Error {
|
|
$userId = get_current_user_id();
|
|
$requested = absint( Val::int( $request->get_param( 'student_id' ) ) );
|
|
|
|
if ( $requested <= 0 || $requested === $userId ) {
|
|
return $userId;
|
|
}
|
|
|
|
if ( ! $this->guardians->canActFor( $userId, $requested ) ) {
|
|
return new \WP_Error(
|
|
'forbidden',
|
|
__( 'You cannot enrol that student.', 'unsupervised-schedular' ),
|
|
[ 'status' => 403 ]
|
|
);
|
|
}
|
|
|
|
return $requested;
|
|
}
|
|
|
|
/**
|
|
* 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( Val::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( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
|
|
|
return '' !== $ip ? $ip : null;
|
|
}
|
|
}
|