From a281935811a27c1f60759f4b3b994f313a169d17 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Thu, 23 Jul 2026 13:51:02 -0300 Subject: [PATCH] Add invite-only group classes 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 --- docs/features/account-registration.md | 3 +- docs/features/group-classes.md | 58 +++- docs/features/offerings.md | 1 + src/AdminMenu.php | 7 +- src/Auth/Invite.php | 3 + src/Auth/InviteRepository.php | 3 +- src/Auth/RegistrationMailer.php | 50 +++ src/Auth/RegistrationPage.php | 7 + src/GroupClass/EnrollmentEndpoint.php | 13 + src/GroupClass/GroupAccess.php | 67 ++++ src/GroupClass/GroupAccessRepository.php | 132 ++++++++ src/GroupClass/GroupClassController.php | 295 +++++++++++++++++- src/Offering/Offering.php | 24 ++ src/Offering/OfferingController.php | 1 + src/Offering/OfferingEndpoint.php | 61 +++- src/Offering/OfferingRepository.php | 16 +- src/Plugin.php | 8 +- src/RestRegistrar.php | 7 +- src/Schema.php | 18 ++ templates/admin/my-group-classes.php | 67 +++- templates/admin/offerings.php | 14 +- tests/Unit/Auth/InviteRepositoryTest.php | 3 +- tests/Unit/Auth/InviteTest.php | 36 ++- tests/Unit/Auth/RegistrationMailerTest.php | 38 +++ tests/Unit/Auth/RegistrationPageTest.php | 26 ++ .../GroupClass/EnrollmentEndpointTest.php | 36 +++ .../GroupClass/GroupAccessRepositoryTest.php | 158 ++++++++++ tests/Unit/GroupClass/GroupAccessTest.php | 70 +++++ .../GroupClass/GroupClassControllerTest.php | 197 +++++++++++- .../Unit/Offering/OfferingControllerTest.php | 33 ++ tests/Unit/Offering/OfferingEndpointTest.php | 102 ++++++ .../Unit/Offering/OfferingRepositoryTest.php | 38 +++ tests/Unit/Offering/OfferingTest.php | 44 ++- 33 files changed, 1598 insertions(+), 38 deletions(-) create mode 100644 src/GroupClass/GroupAccess.php create mode 100644 src/GroupClass/GroupAccessRepository.php create mode 100644 tests/Unit/GroupClass/GroupAccessRepositoryTest.php create mode 100644 tests/Unit/GroupClass/GroupAccessTest.php create mode 100644 tests/Unit/Offering/OfferingEndpointTest.php diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md index 7e95620..9464a74 100644 --- a/docs/features/account-registration.md +++ b/docs/features/account-registration.md @@ -69,6 +69,7 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h | `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) | | `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) | | `kind` | VARCHAR(10) | `personal` (single-use, per email) or `group` (multi-use link) | +| `offering_id` | BIGINT UNSIGNED | Set when a personal invite is tied to an invite-only group class (see `group-classes.md`); NULL otherwise | | `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` (group links stay `pending` until revoked/expired) | | `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited | | `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending / for group links | @@ -96,7 +97,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and 1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite. 2. The invitee opens `[us_student_register]` with the token (`?us_invite=`); the lookup hashes the submitted token and matches it against the stored hash. 3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on. -4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. +4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`. ## Flow (self-approval mode) 1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`). diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index 95ff985..225db69 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.md @@ -3,6 +3,8 @@ ## Overview Students enrol in a group class — an offering of kind `group_class` — as a commitment for the year. Enrolment is capacity-enforced and billed full-term upfront. Registration reuses the same flow as private lessons (intake questions + policy acceptance + payment). +A group class can be marked **invite-only** (`us_offerings.access_mode = invite_only`, see `offerings.md`). Invite-only classes are hidden from the public catalog — they never appear in the student booking/group-class list — and can only be enrolled in by students the instructor has let in. See **Invite-only access** below. + ## Data Model — `{prefix}us_group_enrollments` | Column | Type | Notes | @@ -53,14 +55,62 @@ payment step). `GET /enrollments` returns the caller's own enrolments, or all enrolments for the instructor's group classes if the caller has `view_own_lessons` on those offerings. +`GET /offerings` (the catalog that feeds the group-class list) returns public +offerings **plus** any invite-only offerings the caller has an access grant for, so a +granted student sees the private class alongside public ones. Ungranted students never +receive it. Enrolling in an invite-only class requires a grant: `POST /enrollments` +rejects an ungranted student with `403 invite_required`, and a successful enrolment +flips their grant from `invited` to `enrolled`. + +## Invite-only access + +Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per +person, separate from the enrolment itself. The instructor manages access from +**My Lessons → My Group Classes**, which renders three controls under each invite-only +class: + +1. **Add students directly** — the selected registered students are enrolled immediately + (`status = active`) with a **pending payment** at the class price (comp students are + settled at once by `PaymentService`). No access grant is needed — this writes straight + to `us_group_enrollments` + `us_payments`. +2. **Make available** — the selected registered students get an `invited` grant so the + class appears in their own group-class list; they then self-enrol through the normal + paid flow. Each is emailed a "you've been added" notice. +3. **Invite by email** — for an address with no account yet: a tokenised personal invite + (`us_invites`, carrying `offering_id`) is created and the registration link emailed, + alongside an `invited` grant keyed by `email` + `invite_id`. If the address already has + a **pending** invite, the grant is attached to that invite and **no second link is + sent**. An address that already has an account is treated as **Make available** instead. + +When an email-invited person completes registration, `RegistrationPage` links their new +account to the grant (`GroupAccessRepository::linkStudentByEmail`), so the invite-only +class becomes enrollable for them — they choose whether to enrol. + +### Data Model — `{prefix}us_group_access` + +| Column | Type | Notes | +|---------------|-----------------|-----------------------------------------------------------------------| +| `id` | BIGINT UNSIGNED | Primary key | +| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` (an invite-only group class) | +| `student_id` | BIGINT UNSIGNED | WordPress user ID; NULL until an email invitee registers | +| `email` | VARCHAR(191) | Email-invite grants only; used to link the account once it registers | +| `invite_id` | BIGINT UNSIGNED | FK → `us_invites.id` for email-invite grants; NULL otherwise | +| `status` | VARCHAR(20) | `invited` / `enrolled` / `revoked` | +| `invited_by` | BIGINT UNSIGNED | Instructor who granted access | +| `created_at` | DATETIME | Insertion time | + ## Admin Interface - **Group Classes** (`view_all_lessons` / studio admin): all active enrolments across instructors - **My Lessons → My Group Classes** (`view_own_lessons` / instructor): the instructor's own group classes, each showing its active-enrolment count against capacity and a - per-class roster of enrolled students with enrolment and payment status + per-class roster of enrolled students with enrolment and payment status. Invite-only + classes additionally list who has been invited but not yet enrolled and carry the + add/make-available/invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to + the owning instructor) ## Implementation - Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates) +- Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`) - Model: `Unsupervised\Schedular\GroupClass\Enrollment` - Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` — `renderPage` (studio admin, `view_all_lessons`) and `renderInstructorPage` (instructor, `view_own_lessons`) - REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint` @@ -73,7 +123,11 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin > for the card/e-transfer/comp flows. ## Tests -- `tests/Unit/GroupClass/GroupClassControllerTest.php` +- `tests/Unit/GroupClass/GroupClassControllerTest.php` (roster + add/make-available/invite actions) - `tests/Unit/GroupClass/EnrollmentTest.php` - `tests/Unit/GroupClass/EnrollmentRepositoryTest.php` +- `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (invite-only gating) +- `tests/Unit/GroupClass/GroupAccessTest.php` +- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php` - `tests/Unit/GroupClass/GroupClassPageTest.php` +- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes) diff --git a/docs/features/offerings.md b/docs/features/offerings.md index bec8a12..b76a6e8 100644 --- a/docs/features/offerings.md +++ b/docs/features/offerings.md @@ -22,6 +22,7 @@ An offering is anything a student can register for: a private-lesson type (30 or | `term_end` | DATE | Group / term offerings — last day; NULL otherwise | | `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"| | `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) | +| `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) | | `is_active` | TINYINT(1) | 0 = hidden from registration, 1 = bookable | | `created_at` | DATETIME | Insertion time | diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 6fb5e01..472999b 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -18,6 +18,7 @@ use Unsupervised\Schedular\Auth\StudentHistory; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\LessonController; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; +use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingRepository; @@ -53,15 +54,15 @@ class AdminMenu { private PaymentController $paymentController; private PaymentReportController $paymentReportController; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) { $this->availabilityController = new AvailabilityController( $availability, $offerings ); $this->lessonController = new LessonController( $bookings, $payments, $availability ); $this->offeringController = new OfferingController( $offerings ); $this->questionController = new QuestionController( $questions, $offerings ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->registrationController = new RegistrationController( $invites ); - $this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() ); - $this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments ); + $this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer ); + $this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer ); $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) ); $this->instructorController = new InstructorController(); $this->settings = $settings; diff --git a/src/Auth/Invite.php b/src/Auth/Invite.php index d3bb125..5d45205 100644 --- a/src/Auth/Invite.php +++ b/src/Auth/Invite.php @@ -51,6 +51,7 @@ class Invite { public readonly ?string $createdAt = null, public readonly string $kind = self::KIND_PERSONAL, public readonly ?string $expiresAt = null, + public readonly ?int $offeringId = null, public readonly ?int $id = null, ) {} @@ -66,6 +67,7 @@ class Invite { createdAt: Val::stringOrNull( $row->created_at ?? null ), kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL, expiresAt: Val::stringOrNull( $row->expires_at ?? null ), + offeringId: Val::intOrNull( $row->offering_id ?? null ), id: Val::int( $row->id ), ); } @@ -132,6 +134,7 @@ class Invite { 'accepted_user_id' => $this->acceptedUserId, 'accepted_at' => $this->acceptedAt, 'expires_at' => $this->expiresAt, + 'offering_id' => $this->offeringId, ]; } } diff --git a/src/Auth/InviteRepository.php b/src/Auth/InviteRepository.php index b3570f9..c8feeb8 100644 --- a/src/Auth/InviteRepository.php +++ b/src/Auth/InviteRepository.php @@ -23,6 +23,7 @@ class InviteRepository { 'token' => $invite->token, 'role' => $invite->role, 'kind' => $invite->kind, + 'offering_id' => $invite->offeringId, 'status' => $invite->status, 'invited_by' => $invite->invitedBy, 'accepted_user_id' => $invite->acceptedUserId, @@ -30,7 +31,7 @@ class InviteRepository { 'accepted_at' => $invite->acceptedAt, 'expires_at' => $invite->expiresAt, ], - [ '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ] + [ '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s' ] ); return false === $result ? 0 : $this->db->insert_id; diff --git a/src/Auth/RegistrationMailer.php b/src/Auth/RegistrationMailer.php index 8f03a08..4504dc9 100644 --- a/src/Auth/RegistrationMailer.php +++ b/src/Auth/RegistrationMailer.php @@ -105,6 +105,56 @@ class RegistrationMailer { return (bool) wp_mail( $email, $subject, $body ); } + /** + * Tell a registered student they have been given access to an invite-only + * group class and can now enrol. Returns false when there is no recipient. + */ + public function sendClassAccessGranted( \WP_User $user, string $className ): bool { + if ( '' === (string) $user->user_email ) { + return false; + } + + $subject = sprintf( + /* translators: %s: class title */ + __( 'You have been invited to %s', 'unsupervised-schedular' ), + $className + ); + $body = sprintf( + /* translators: 1: class title, 2: site name, 3: login URL */ + __( "You have been given access to the group class \"%1\$s\" at %2\$s.\n\nLog in and open the group classes page to enrol:\n%3\$s", 'unsupervised-schedular' ), + $className, + $this->siteName(), + wp_login_url() + ); + + return (bool) wp_mail( $user->user_email, $subject, $body ); + } + + /** + * Email a tokenised registration link to someone invited to a group class who + * does not yet have an account. Returns false when there is no recipient. + */ + public function sendClassInvite( string $email, string $link, string $className ): bool { + if ( '' === $email ) { + return false; + } + + $subject = sprintf( + /* translators: %s: class title */ + __( 'You are invited to join %s', 'unsupervised-schedular' ), + $className + ); + $body = sprintf( + /* translators: 1: class title, 2: site name, 3: registration URL */ + __( "You have been invited to the group class \"%1\$s\" at %2\$s.\n\nCreate your account using this link, then choose to enrol in the class:\n%3\$s", 'unsupervised-schedular' ), + $className, + $this->siteName(), + $link + ); + + return (bool) wp_mail( $email, $subject, $body ); + } + private function siteName(): string { $name = (string) get_bloginfo( 'name' ); diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index 9909a72..26c9e76 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Auth; +use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\Policy; @@ -38,6 +39,7 @@ class RegistrationPage { private RegistrationMailer $mailer, private QuestionRepository $questions, private AnswerRepository $answers, + private GroupAccessRepository $access, ) {} /** @@ -203,6 +205,11 @@ class RegistrationPage { if ( $inviteValid && ! $invite->isGroup() ) { $this->invites->markAccepted( (int) $invite->id, (int) $userId ); + // A personal invite may carry a group-class grant (invited by email); + // point any grants for this address at the new account so the class + // becomes enrollable for them. + $this->access->linkStudentByEmail( $email, (int) $userId ); + wp_set_current_user( (int) $userId ); wp_set_auth_cookie( (int) $userId ); diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php index 7fcb86d..35b2eb1 100644 --- a/src/GroupClass/EnrollmentEndpoint.php +++ b/src/GroupClass/EnrollmentEndpoint.php @@ -19,6 +19,7 @@ class EnrollmentEndpoint { private OfferingRepository $offerings, private RegistrationGate $gate, private PaymentService $payments, + private GroupAccessRepository $access, ) {} /** @@ -88,6 +89,12 @@ class EnrollmentEndpoint { 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 ] ); + } + if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) { return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } @@ -110,6 +117,12 @@ class EnrollmentEndpoint { $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); + // 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 ); + } + $payment = null; if ( $offering->price > 0.0 ) { $payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); diff --git a/src/GroupClass/GroupAccess.php b/src/GroupClass/GroupAccess.php new file mode 100644 index 0000000..630cb12 --- /dev/null +++ b/src/GroupClass/GroupAccess.php @@ -0,0 +1,67 @@ + + */ + public const VALID_STATUSES = [ self::STATUS_INVITED, self::STATUS_ENROLLED, self::STATUS_REVOKED ]; + + public function __construct( + public readonly int $offeringId, + public readonly ?int $studentId = null, + public readonly string $email = '', + public readonly ?int $inviteId = null, + public readonly string $status = self::STATUS_INVITED, + public readonly ?int $invitedBy = null, + public readonly ?int $id = null, + ) {} + + public static function fromRow( \stdClass $row ): self { + return new self( + offeringId: Val::int( $row->offering_id ), + studentId: Val::intOrNull( $row->student_id ), + email: Val::string( $row->email ?? '' ), + inviteId: Val::intOrNull( $row->invite_id ?? null ), + status: Val::string( $row->status ), + invitedBy: Val::intOrNull( $row->invited_by ?? null ), + id: Val::int( $row->id ), + ); + } + + /** + * Returns a plain array representation of the grant. + * + * @return array + */ + public function toArray(): array { + return [ + 'id' => $this->id, + 'offering_id' => $this->offeringId, + 'student_id' => $this->studentId, + 'email' => $this->email, + 'invite_id' => $this->inviteId, + 'status' => $this->status, + 'invited_by' => $this->invitedBy, + ]; + } +} diff --git a/src/GroupClass/GroupAccessRepository.php b/src/GroupClass/GroupAccessRepository.php new file mode 100644 index 0000000..1348760 --- /dev/null +++ b/src/GroupClass/GroupAccessRepository.php @@ -0,0 +1,132 @@ +table = $db->prefix . 'us_group_access'; + } + + public function insert( GroupAccess $access ): int { + $this->db->insert( + $this->table, + [ + 'offering_id' => $access->offeringId, + 'student_id' => $access->studentId, + 'email' => $access->email, + 'invite_id' => $access->inviteId, + 'status' => $access->status, + 'invited_by' => $access->invitedBy, + 'created_at' => current_time( 'mysql' ), + ], + [ '%d', '%d', '%s', '%d', '%s', '%d', '%s' ] + ); + + return $this->db->insert_id; + } + + /** + * Whether a student holds a live (invited or enrolled) grant for an offering. + */ + public function hasGrant( int $offeringId, int $studentId ): bool { + $count = (int) $this->db->get_var( + $this->db->prepare( + 'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status IN ( %s, %s )', + $this->table, + $offeringId, + $studentId, + GroupAccess::STATUS_INVITED, + GroupAccess::STATUS_ENROLLED + ) + ); + + return $count > 0; + } + + /** + * The offering ids a student holds a live grant for — the invite-only classes + * to fold into their catalogue view. + * + * @return list + */ + public function findGrantedOfferingIds( int $studentId ): array { + $rows = $this->db->get_col( + $this->db->prepare( + 'SELECT DISTINCT offering_id FROM %i WHERE student_id = %d AND status IN ( %s, %s )', + $this->table, + $studentId, + GroupAccess::STATUS_INVITED, + GroupAccess::STATUS_ENROLLED + ) + ); + + return array_values( array_map( \Unsupervised\Schedular\Val::int( ... ), $rows ) ); + } + + /** + * All grants for an offering, newest first. + * + * @return list + */ + public function findByOffering( int $offeringId ): array { + $rows = $this->db->get_results( + $this->db->prepare( + 'SELECT * FROM %i WHERE offering_id = %d ORDER BY id DESC', + $this->table, + $offeringId + ) + ); + + return array_map( GroupAccess::fromRow( ... ), $rows ?? [] ); + } + + /** + * Point email-invite grants for an address at the account created when the + * invitation was accepted, so the granted class unlocks for the new student. + * Only grants still awaiting an account (`student_id` NULL) are linked. + */ + public function linkStudentByEmail( string $email, int $studentId ): bool { + if ( '' === $email ) { + return false; + } + + $sql = $this->db->prepare( + 'UPDATE %i SET student_id = %d WHERE email = %s AND student_id IS NULL', + $this->table, + $studentId, + $email + ); + + return null !== $sql && false !== $this->db->query( $sql ); + } + + /** + * Flip a student's live grant for an offering to enrolled. + */ + public function markEnrolled( int $offeringId, int $studentId ): bool { + return false !== $this->db->update( + $this->table, + [ 'status' => GroupAccess::STATUS_ENROLLED ], + [ + 'offering_id' => $offeringId, + 'student_id' => $studentId, + ], + [ '%s' ], + [ '%d', '%d' ] + ); + } + + public function revoke( int $id ): bool { + return false !== $this->db->update( + $this->table, + [ 'status' => GroupAccess::STATUS_REVOKED ], + [ 'id' => $id ], + [ '%s' ], + [ '%d' ] + ); + } +} diff --git a/src/GroupClass/GroupClassController.php b/src/GroupClass/GroupClassController.php index 00deca1..47fce59 100644 --- a/src/GroupClass/GroupClassController.php +++ b/src/GroupClass/GroupClassController.php @@ -3,10 +3,17 @@ declare(strict_types=1); namespace Unsupervised\Schedular\GroupClass; +use Unsupervised\Schedular\Auth\Invite; +use Unsupervised\Schedular\Auth\InviteRepository; +use Unsupervised\Schedular\Auth\RegistrationController; +use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; +use Unsupervised\Schedular\Payment\PaymentService; +use Unsupervised\Schedular\Val; class GroupClassController { @@ -14,6 +21,10 @@ class GroupClassController { private EnrollmentRepository $enrollments, private OfferingRepository $offerings, private PaymentRepository $payments, + private GroupAccessRepository $access, + private PaymentService $paymentService, + private InviteRepository $invites, + private RegistrationMailer $mailer, ) {} public function renderPage(): void { @@ -41,7 +52,8 @@ class GroupClassController { /** * Instructor view: their own group classes with per-class rosters. Each class * shows its enrolment count against capacity plus a roster of enrolled - * students with enrolment and payment status. + * students with enrolment and payment status. Invite-only classes also carry + * controls to add, grant access to, or email-invite students. */ public function renderInstructorPage(): void { if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) { @@ -49,7 +61,13 @@ class GroupClassController { } $instructorId = get_current_user_id(); - $enrollments = $this->enrollments->findByInstructor( $instructorId ); + + $notice = ''; + if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) { + $notice = $this->handleFormAction( $instructorId ); + } + + $enrollments = $this->enrollments->findByInstructor( $instructorId ); $classes = array_map( function ( Offering $offering ) use ( $enrollments ): array { @@ -76,15 +94,280 @@ class GroupClassController { } return [ - 'title' => $offering->title, - 'capacity' => $offering->capacity, - 'enrolled' => $enrolled, - 'roster' => $roster, + 'id' => $offering->id, + 'title' => $offering->title, + 'capacity' => $offering->capacity, + 'enrolled' => $enrolled, + 'invite_only' => $offering->isInviteOnly(), + 'roster' => $roster, + 'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [], ]; }, $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS ) ); + $students = $this->studentOptions(); + include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php'; } + + /** + * Pending (not-yet-enrolled) access grants for an invite-only class, shown so + * the instructor can see who has been invited but has not enrolled yet. + * + * @return list + */ + private function pendingInvites( int $offeringId ): array { + $out = []; + foreach ( $this->access->findByOffering( $offeringId ) as $grant ) { + if ( GroupAccess::STATUS_INVITED !== $grant->status ) { + continue; + } + + if ( null !== $grant->studentId ) { + $user = get_userdata( $grant->studentId ); + $out[] = [ + 'who' => $user ? $user->display_name : (string) $grant->studentId, + 'kind' => __( 'Granted', 'unsupervised-schedular' ), + ]; + } else { + $out[] = [ + 'who' => $grant->email, + 'kind' => __( 'Email invite', 'unsupervised-schedular' ), + ]; + } + } + + return $out; + } + + /** + * Handle a posted management action, returning a status notice for display. + * Every action is scoped to a group class the current instructor owns. + */ + private function handleFormAction( int $instructorId ): string { + // Nonce is verified by the caller before this method runs. + // phpcs:disable WordPress.Security.NonceVerification.Missing + $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); + $offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) ); + $offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null; + + if ( null === $offering || $offering->instructorId !== $instructorId || Offering::KIND_GROUP_CLASS !== $offering->kind ) { + return esc_html__( 'That group class was not found.', 'unsupervised-schedular' ); + } + + if ( 'add_direct' === $action ) { + return $this->addDirect( $offering, $this->postedStudentIds() ); + } + + if ( 'grant_access' === $action ) { + return $this->grantAccess( $offering, $this->postedStudentIds() ); + } + + if ( 'invite_email' === $action ) { + $email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) ); + + return $this->inviteEmail( $offering, $email ); + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + + return ''; + } + + /** + * Directly enrol registered students, each with a pending payment at the + * class price (comp students are settled immediately by the payment service). + * + * @param list $studentIds + */ + private function addDirect( Offering $offering, array $studentIds ): string { + $added = 0; + foreach ( $studentIds as $studentId ) { + if ( $this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId ) ) { + continue; + } + + $enrollmentId = $this->enrollments->insert( + new Enrollment( + offeringId: (int) $offering->id, + studentId: $studentId, + instructorId: $offering->instructorId, + ) + ); + + if ( $offering->price > 0.0 ) { + $payment = $this->paymentService->createForRegistration( + Payment::REG_ENROLLMENT, + $enrollmentId, + $studentId, + $offering->instructorId, + $offering->price, + $offering->currency, + $offering->etransferEmail + ); + + if ( null !== $payment && null !== $payment->id ) { + $this->enrollments->setPaymentId( $enrollmentId, $payment->id ); + } + } + + $this->access->markEnrolled( (int) $offering->id, $studentId ); + ++$added; + } + + /* translators: %d: number of students added. */ + return sprintf( esc_html__( '%d student(s) added to the class.', 'unsupervised-schedular' ), $added ); + } + + /** + * Grant registered students access to the class so it appears in their list + * for self-enrolment, notifying each by email. + * + * @param list $studentIds + */ + private function grantAccess( Offering $offering, array $studentIds ): string { + $granted = 0; + foreach ( $studentIds as $studentId ) { + if ( + $this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId ) + || $this->access->hasGrant( (int) $offering->id, $studentId ) + ) { + continue; + } + + $this->access->insert( + new GroupAccess( + offeringId: (int) $offering->id, + studentId: $studentId, + status: GroupAccess::STATUS_INVITED, + invitedBy: get_current_user_id(), + ) + ); + + $user = get_userdata( $studentId ); + if ( $user instanceof \WP_User ) { + $this->mailer->sendClassAccessGranted( $user, $offering->title ); + } + + ++$granted; + } + + /* translators: %d: number of students granted access. */ + return sprintf( esc_html__( '%d student(s) granted access.', 'unsupervised-schedular' ), $granted ); + } + + /** + * Invite someone by email. A registered address is treated as a grant; an + * unknown address gets a tokenised registration invite tied to the class, + * reusing any pending invite already outstanding for that address (in which + * case no new link is sent). + */ + private function inviteEmail( Offering $offering, string $email ): string { + if ( ! is_email( $email ) ) { + return esc_html__( 'Enter a valid email address.', 'unsupervised-schedular' ); + } + + $existingUserId = email_exists( $email ); + if ( false !== $existingUserId ) { + return $this->grantAccess( $offering, [ (int) $existingUserId ] ); + } + + // Reuse an outstanding invite rather than mailing a second link; still + // attach a class grant so enrolment unlocks once they register. + $pending = $this->invites->findPendingByEmail( $email ); + if ( null !== $pending ) { + $this->access->insert( + new GroupAccess( + offeringId: (int) $offering->id, + email: $email, + inviteId: $pending->id, + status: GroupAccess::STATUS_INVITED, + invitedBy: get_current_user_id(), + ) + ); + + return esc_html__( 'This person already has a pending invitation; the class was added to it. No new link was sent.', 'unsupervised-schedular' ); + } + + $rawToken = wp_generate_password( 32, false ); + $inviteId = $this->invites->insert( + new Invite( + email: $email, + token: Invite::hashToken( $rawToken ), + invitedBy: get_current_user_id(), + offeringId: (int) $offering->id, + ) + ); + + if ( $inviteId <= 0 ) { + return esc_html__( 'Could not create the invite. Deactivate and reactivate the plugin to update the database, then try again.', 'unsupervised-schedular' ); + } + + $this->access->insert( + new GroupAccess( + offeringId: (int) $offering->id, + email: $email, + inviteId: $inviteId, + status: GroupAccess::STATUS_INVITED, + invitedBy: get_current_user_id(), + ) + ); + + $this->mailer->sendClassInvite( $email, $this->registrationLink( $rawToken ), $offering->title ); + + return esc_html__( 'Invitation sent.', 'unsupervised-schedular' ); + } + + /** + * Registered students to offer in the add/grant selects, by display name. + * + * @return list + */ + private function studentOptions(): array { + $users = array_filter( + get_users( + [ + 'role' => RoleManager::STUDENT, + 'orderby' => 'display_name', + 'order' => 'ASC', + ] + ), + static fn( mixed $u ): bool => $u instanceof \WP_User + ); + + return array_values( + array_map( + static fn( \WP_User $u ): array => [ + 'id' => (int) $u->ID, + 'name' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email, + ], + $users + ) + ); + } + + /** + * The de-duplicated positive student ids posted from a multi-select. + * + * @return list + */ + private function postedStudentIds(): array { + // Nonce is verified by the caller before this method runs. + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int below; slashes cannot survive integer coercion. + $raw = (array) ( $_POST['student_ids'] ?? [] ); + $ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) ); + + return array_values( array_unique( $ids ) ); + } + + /** + * Build the registration URL for a raw invite token, mirroring the invites + * admin page so class invites land on the same registration page. + */ + private function registrationLink( string $rawToken ): string { + $pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) ); + $linkBase = $pageId > 0 ? (string) get_permalink( $pageId ) : ''; + + return add_query_arg( 'us_invite', rawurlencode( $rawToken ), '' !== $linkBase ? $linkBase : home_url( '/' ) ); + } } diff --git a/src/Offering/Offering.php b/src/Offering/Offering.php index 7ea9663..5d29d7c 100644 --- a/src/Offering/Offering.php +++ b/src/Offering/Offering.php @@ -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 + */ + 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, ]; diff --git a/src/Offering/OfferingController.php b/src/Offering/OfferingController.php index 63c8d6b..2140286 100644 --- a/src/Offering/OfferingController.php +++ b/src/Offering/OfferingController.php @@ -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, ); diff --git a/src/Offering/OfferingEndpoint.php b/src/Offering/OfferingEndpoint.php index 3aaccf6..5b8908c 100644 --- a/src/Offering/OfferingEndpoint.php +++ b/src/Offering/OfferingEndpoint.php @@ -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 + */ + 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 ); } diff --git a/src/Offering/OfferingRepository.php b/src/Offering/OfferingRepository.php index 58ac8f2..f556d53 100644 --- a/src/Offering/OfferingRepository.php +++ b/src/Offering/OfferingRepository.php @@ -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 */ - 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 */ - 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"; diff --git a/src/Plugin.php b/src/Plugin.php index bd0066d..a2196cb 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -14,6 +14,7 @@ use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; +use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupClassPage; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\BillingMethodResolver; @@ -58,6 +59,7 @@ class Plugin { $acceptances = new AcceptanceRepository( $wpdb ); $invites = new InviteRepository( $wpdb ); $enrollments = new EnrollmentRepository( $wpdb ); + $groupAccess = new GroupAccessRepository( $wpdb ); $registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances ); $paymentRepo = new PaymentRepository( $wpdb ); @@ -72,15 +74,15 @@ class Plugin { $bookingPage = new BookingPage(); $loginPage = new LoginPage(); - $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers ); + $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess ); $groupClassPage = new GroupClassPage(); ( new UpdateChecker() )->register(); ( new RoleManager() )->register(); ( new RegistrationLoginGate() )->register(); ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); - ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register(); - ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register(); + ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer ) )->register(); + ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register(); ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); } diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index 916b1df..7d98ef8 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -9,6 +9,7 @@ use Unsupervised\Schedular\Booking\BookingEndpoint; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\CancellationPolicy; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; +use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingRepository; @@ -35,13 +36,13 @@ class RestRegistrar { private EnrollmentEndpoint $enrollmentEndpoint; private PaymentEndpoint $paymentEndpoint; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, PaymentService $paymentService ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) { $this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings ); $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) ); - $this->offeringEndpoint = new OfferingEndpoint( $offerings ); + $this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); $this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService ); - $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService ); + $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess ); $this->paymentEndpoint = new PaymentEndpoint( $paymentService ); } diff --git a/src/Schema.php b/src/Schema.php index 9514f48..f3e46a4 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -66,6 +66,7 @@ class Schema { schedule_note VARCHAR(191) DEFAULT NULL, etransfer_email VARCHAR(191) DEFAULT NULL, cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL, + access_mode VARCHAR(20) NOT NULL DEFAULT 'public', is_active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, PRIMARY KEY (id), @@ -190,6 +191,7 @@ class Schema { token VARCHAR(64) NOT NULL, role VARCHAR(32) NOT NULL DEFAULT 'us_student', kind VARCHAR(10) NOT NULL DEFAULT 'personal', + offering_id BIGINT UNSIGNED DEFAULT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', invited_by BIGINT UNSIGNED DEFAULT NULL, accepted_user_id BIGINT UNSIGNED DEFAULT NULL, @@ -201,6 +203,22 @@ class Schema { KEY email (email), KEY status (status) ) {$charset};", + + "CREATE TABLE {$prefix}us_group_access ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + offering_id BIGINT UNSIGNED NOT NULL, + student_id BIGINT UNSIGNED DEFAULT NULL, + email VARCHAR(191) NOT NULL DEFAULT '', + invite_id BIGINT UNSIGNED DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'invited', + invited_by BIGINT UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY offering_id (offering_id), + KEY student_id (student_id), + KEY email (email), + KEY status (status) + ) {$charset};", ]; } } diff --git a/templates/admin/my-group-classes.php b/templates/admin/my-group-classes.php index b0775e9..16effa8 100644 --- a/templates/admin/my-group-classes.php +++ b/templates/admin/my-group-classes.php @@ -5,18 +5,29 @@ if (! defined('ABSPATH')) { exit; } -/** @var list}> $classes */ +/** + * @var list, invited: list}> $classes + * @var list $students + * @var string $notice + */ ?>

+ +

+ +

+ + + + + + +

+
    + +
  • + +
+ + +
+
+ + +

+

+ +

+ +

+
+ +
+ + +

+

+ +

+ +

+
+ +
+ + +

+

+ +

+ +

+
+
+

diff --git a/templates/admin/offerings.php b/templates/admin/offerings.php index 53171ff..9f61edb 100644 --- a/templates/admin/offerings.php +++ b/templates/admin/offerings.php @@ -110,6 +110,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e

+ + + + +

+ + @@ -144,7 +151,12 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e id); ?> - title); ?> + + title); ?> + isInviteOnly()) : ?> + + + kind); ?> durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '—'; ?> price, 2) . ' ' . $offering->currency); ?> diff --git a/tests/Unit/Auth/InviteRepositoryTest.php b/tests/Unit/Auth/InviteRepositoryTest.php index 70f3be2..f269530 100644 --- a/tests/Unit/Auth/InviteRepositoryTest.php +++ b/tests/Unit/Auth/InviteRepositoryTest.php @@ -35,11 +35,12 @@ class InviteRepositoryTest extends TestCase return $d['email'] === 'a@b.test' && $d['token'] === 'tok123' && $d['kind'] === Invite::KIND_PERSONAL + && $d['offering_id'] === null && $d['status'] === Invite::STATUS_PENDING && $d['invited_by'] === 2 && $d['expires_at'] === null; }), - ['%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s'] + ['%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s'] ); $this->db->insert_id = 5; diff --git a/tests/Unit/Auth/InviteTest.php b/tests/Unit/Auth/InviteTest.php index 61eb830..de8ca0a 100644 --- a/tests/Unit/Auth/InviteTest.php +++ b/tests/Unit/Auth/InviteTest.php @@ -155,8 +155,42 @@ class InviteTest extends TestCase { $arr = (new Invite('a@b.test', 'tok', id: 1))->toArray(); - foreach (['id', 'email', 'token', 'role', 'kind', 'status', 'invited_by', 'accepted_user_id', 'accepted_at', 'expires_at'] as $key) { + foreach (['id', 'email', 'token', 'role', 'kind', 'status', 'invited_by', 'accepted_user_id', 'accepted_at', 'expires_at', 'offering_id'] as $key) { self::assertArrayHasKey($key, $arr); } } + + public function testOfferingIdRoundTrips(): void + { + $invite = Invite::fromRow((object) [ + 'id' => '5', + 'email' => 'a@b.test', + 'token' => 'tok123', + 'role' => RoleManager::STUDENT, + 'status' => Invite::STATUS_PENDING, + 'invited_by' => '2', + 'accepted_user_id' => null, + 'accepted_at' => null, + 'offering_id' => '8', + ]); + + self::assertSame(8, $invite->offeringId); + self::assertSame(8, $invite->toArray()['offering_id']); + } + + public function testOfferingIdDefaultsToNullWhenColumnMissing(): void + { + $invite = Invite::fromRow((object) [ + 'id' => '5', + 'email' => 'a@b.test', + 'token' => 'tok123', + 'role' => RoleManager::STUDENT, + 'status' => Invite::STATUS_PENDING, + 'invited_by' => null, + 'accepted_user_id' => null, + 'accepted_at' => null, + ]); + + self::assertNull($invite->offeringId); + } } diff --git a/tests/Unit/Auth/RegistrationMailerTest.php b/tests/Unit/Auth/RegistrationMailerTest.php index dfa7d5d..fe563f9 100644 --- a/tests/Unit/Auth/RegistrationMailerTest.php +++ b/tests/Unit/Auth/RegistrationMailerTest.php @@ -81,4 +81,42 @@ class RegistrationMailerTest extends TestCase { self::assertFalse((new RegistrationMailer())->sendRejected('')); } + + public function testSendClassAccessGrantedEmailsTheStudent(): void + { + Functions\expect('wp_mail') + ->once() + ->with( + 'a@b.test', + Mockery::on(static fn (string $subject): bool => str_contains($subject, 'Choir')), + Mockery::on(static fn (string $body): bool => str_contains($body, 'Choir')) + ) + ->andReturn(true); + + self::assertTrue((new RegistrationMailer())->sendClassAccessGranted($this->user('a@b.test'), 'Choir')); + } + + public function testSendClassAccessGrantedReturnsFalseWithoutRecipient(): void + { + self::assertFalse((new RegistrationMailer())->sendClassAccessGranted($this->user(''), 'Choir')); + } + + public function testSendClassInviteIncludesTheLink(): void + { + Functions\expect('wp_mail') + ->once() + ->with( + 'new@x.test', + Mockery::type('string'), + Mockery::on(static fn (string $body): bool => str_contains($body, 'http://join.test')) + ) + ->andReturn(true); + + self::assertTrue((new RegistrationMailer())->sendClassInvite('new@x.test', 'http://join.test', 'Choir')); + } + + public function testSendClassInviteReturnsFalseWithoutRecipient(): void + { + self::assertFalse((new RegistrationMailer())->sendClassInvite('', 'http://join.test', 'Choir')); + } } diff --git a/tests/Unit/Auth/RegistrationPageTest.php b/tests/Unit/Auth/RegistrationPageTest.php index bf12412..8fe5565 100644 --- a/tests/Unit/Auth/RegistrationPageTest.php +++ b/tests/Unit/Auth/RegistrationPageTest.php @@ -9,6 +9,7 @@ use Unsupervised\Schedular\Auth\Invite; use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RegistrationPage; +use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\Policy; @@ -45,11 +46,15 @@ class RegistrationPageTest extends TestCase $questions->shouldReceive('findByScope')->andReturn([])->byDefault(); $answers->shouldReceive('insert')->andReturn(1)->byDefault(); + $access = Mockery::mock(GroupAccessRepository::class); + $access->shouldReceive('linkStudentByEmail')->andReturn(true)->byDefault(); + $this->ctx = [ 'invites' => $invites, 'policies' => $policies, 'questions' => $questions, 'answers' => $answers, + 'access' => $access, 'mailer' => Mockery::mock(RegistrationMailer::class), 'settings' => Mockery::mock(StudioSettings::class), ]; @@ -63,6 +68,7 @@ class RegistrationPageTest extends TestCase $this->ctx['mailer'], $questions, $answers, + $access, ); $_POST = []; @@ -110,6 +116,26 @@ class RegistrationPageTest extends TestCase self::assertSame('invite', $this->submit($invite, false)); } + public function testInviteAcceptanceLinksClassGrantForTheEmail(): void + { + $_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ]; + + Functions\when('email_exists')->justReturn(false); + Functions\when('wp_insert_user')->justReturn(42); + Functions\when('is_wp_error')->justReturn(false); + + $this->ctx['invites']->shouldReceive('markAccepted')->once(); + Functions\when('wp_set_current_user')->justReturn(null); + Functions\when('wp_set_auth_cookie')->justReturn(null); + + // A personal invite tied to a class grant links the new account to it. + $this->ctx['access']->shouldReceive('linkStudentByEmail')->once()->with('a@b.test', 42)->andReturn(true); + + $invite = new Invite(email: 'a@b.test', token: 'hash', offeringId: 8); + + self::assertSame('invite', $this->submit($invite, false)); + } + public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void { $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; diff --git a/tests/Unit/GroupClass/EnrollmentEndpointTest.php b/tests/Unit/GroupClass/EnrollmentEndpointTest.php index 95c9408..489a785 100644 --- a/tests/Unit/GroupClass/EnrollmentEndpointTest.php +++ b/tests/Unit/GroupClass/EnrollmentEndpointTest.php @@ -7,6 +7,7 @@ use Brain\Monkey\Functions; use Mockery; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; +use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; @@ -20,6 +21,7 @@ class EnrollmentEndpointTest extends TestCase private OfferingRepository $offerings; private RegistrationGate $gate; private PaymentService $payments; + private GroupAccessRepository $access; private EnrollmentEndpoint $endpoint; protected function setUp(): void @@ -35,12 +37,14 @@ class EnrollmentEndpointTest extends TestCase $this->offerings = Mockery::mock(OfferingRepository::class); $this->gate = Mockery::mock(RegistrationGate::class); $this->payments = Mockery::mock(PaymentService::class); + $this->access = Mockery::mock(GroupAccessRepository::class); $this->endpoint = new EnrollmentEndpoint( $this->enrollments, $this->offerings, $this->gate, $this->payments, + $this->access, ); } @@ -49,6 +53,11 @@ class EnrollmentEndpointTest extends TestCase return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: $price, id: 8); } + private function inviteOnlyOffering(): Offering + { + return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Private Choir', accessMode: Offering::ACCESS_INVITE_ONLY, id: 8); + } + private function expectSuccessfulEnrollment(): void { $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); @@ -98,4 +107,31 @@ class EnrollmentEndpointTest extends TestCase $result->get_data()['payment'] ); } + + public function testInviteOnlyClassRejectsStudentWithoutGrant(): void + { + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); + $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); + $this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false); + $this->enrollments->shouldReceive('insert')->never(); + + $result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8])); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('invite_required', $result->get_error_code()); + self::assertSame(403, $result->error_data['invite_required']['status']); + } + + public function testInviteOnlyClassAllowsGrantedStudentAndMarksEnrolled(): void + { + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); + $this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(true); + $this->expectSuccessfulEnrollment(); + $this->access->shouldReceive('markEnrolled')->once()->with(8, 5); + + $result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(201, $result->get_status()); + } } diff --git a/tests/Unit/GroupClass/GroupAccessRepositoryTest.php b/tests/Unit/GroupClass/GroupAccessRepositoryTest.php new file mode 100644 index 0000000..c2c1298 --- /dev/null +++ b/tests/Unit/GroupClass/GroupAccessRepositoryTest.php @@ -0,0 +1,158 @@ +db = Mockery::mock(\wpdb::class); + $this->db->prefix = 'wp_'; + $this->repo = new GroupAccessRepository($this->db); + } + + public function testInsertReturnsId(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_group_access', + Mockery::on(static function (array $d): bool { + return $d['offering_id'] === 8 + && $d['student_id'] === 5 + && $d['status'] === GroupAccess::STATUS_INVITED; + }), + ['%d', '%d', '%s', '%d', '%s', '%d', '%s'] + ); + $this->db->insert_id = 3; + + self::assertSame(3, $this->repo->insert(new GroupAccess(offeringId: 8, studentId: 5, invitedBy: 2))); + } + + public function testHasGrantTrueWhenCountPositive(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/offering_id = %d AND student_id = %d AND status IN/'), + 'wp_us_group_access', + 8, + 5, + GroupAccess::STATUS_INVITED, + GroupAccess::STATUS_ENROLLED + ) + ->andReturn('SELECT ...'); + $this->db->shouldReceive('get_var')->andReturn('1'); + + self::assertTrue($this->repo->hasGrant(8, 5)); + } + + public function testHasGrantFalseWhenZero(): void + { + $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); + $this->db->shouldReceive('get_var')->andReturn('0'); + + self::assertFalse($this->repo->hasGrant(8, 5)); + } + + public function testFindGrantedOfferingIdsReturnsInts(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/DISTINCT offering_id.*student_id = %d/s'), + 'wp_us_group_access', + 5, + GroupAccess::STATUS_INVITED, + GroupAccess::STATUS_ENROLLED + ) + ->andReturn('SELECT ...'); + $this->db->shouldReceive('get_col')->andReturn(['8', '9']); + + self::assertSame([8, 9], $this->repo->findGrantedOfferingIds(5)); + } + + public function testFindByOfferingMapsRows(): void + { + $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); + $this->db->shouldReceive('get_results')->andReturn([ + (object) [ + 'id' => '1', + 'offering_id' => '8', + 'student_id' => '5', + 'email' => '', + 'invite_id' => null, + 'status' => GroupAccess::STATUS_INVITED, + 'invited_by' => '2', + ], + ]); + + $grants = $this->repo->findByOffering(8); + + self::assertCount(1, $grants); + self::assertSame(5, $grants[0]->studentId); + } + + public function testLinkStudentByEmailUpdatesNullStudentRows(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/UPDATE %i SET student_id = %d WHERE email = %s AND student_id IS NULL/'), + 'wp_us_group_access', + 5, + 'a@b.test' + ) + ->andReturn('UPDATE ...'); + $this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1); + + self::assertTrue($this->repo->linkStudentByEmail('a@b.test', 5)); + } + + public function testLinkStudentByEmailIgnoresEmptyEmail(): void + { + $this->db->shouldReceive('prepare')->never(); + + self::assertFalse($this->repo->linkStudentByEmail('', 5)); + } + + public function testMarkEnrolledUpdatesStatus(): void + { + $this->db->shouldReceive('update') + ->once() + ->with( + 'wp_us_group_access', + ['status' => GroupAccess::STATUS_ENROLLED], + ['offering_id' => 8, 'student_id' => 5], + ['%s'], + ['%d', '%d'] + ) + ->andReturn(1); + + self::assertTrue($this->repo->markEnrolled(8, 5)); + } + + public function testRevokeUpdatesStatus(): void + { + $this->db->shouldReceive('update') + ->once() + ->with('wp_us_group_access', ['status' => GroupAccess::STATUS_REVOKED], ['id' => 3], ['%s'], ['%d']) + ->andReturn(1); + + self::assertTrue($this->repo->revoke(3)); + } +} diff --git a/tests/Unit/GroupClass/GroupAccessTest.php b/tests/Unit/GroupClass/GroupAccessTest.php new file mode 100644 index 0000000..3850cad --- /dev/null +++ b/tests/Unit/GroupClass/GroupAccessTest.php @@ -0,0 +1,70 @@ +status); + self::assertNull($access->studentId); + self::assertSame('', $access->email); + } + + public function testFromRowMapsColumns(): void + { + $row = (object) [ + 'id' => '3', + 'offering_id' => '8', + 'student_id' => '5', + 'email' => 'a@b.test', + 'invite_id' => '9', + 'status' => GroupAccess::STATUS_ENROLLED, + 'invited_by' => '2', + ]; + + $access = GroupAccess::fromRow($row); + + self::assertSame(3, $access->id); + self::assertSame(8, $access->offeringId); + self::assertSame(5, $access->studentId); + self::assertSame('a@b.test', $access->email); + self::assertSame(9, $access->inviteId); + self::assertSame(GroupAccess::STATUS_ENROLLED, $access->status); + self::assertSame(2, $access->invitedBy); + } + + public function testFromRowCastsNullStudentAndInvite(): void + { + $row = (object) [ + 'id' => '3', + 'offering_id' => '8', + 'student_id' => null, + 'email' => 'a@b.test', + 'invite_id' => null, + 'status' => GroupAccess::STATUS_INVITED, + 'invited_by' => null, + ]; + + $access = GroupAccess::fromRow($row); + + self::assertNull($access->studentId); + self::assertNull($access->inviteId); + self::assertNull($access->invitedBy); + } + + public function testToArrayContainsExpectedKeys(): void + { + $arr = (new GroupAccess(offeringId: 8, studentId: 5, id: 3))->toArray(); + + foreach (['id', 'offering_id', 'student_id', 'email', 'invite_id', 'status', 'invited_by'] as $key) { + self::assertArrayHasKey($key, $arr); + } + } +} diff --git a/tests/Unit/GroupClass/GroupClassControllerTest.php b/tests/Unit/GroupClass/GroupClassControllerTest.php index 0c1156d..7b28ff6 100644 --- a/tests/Unit/GroupClass/GroupClassControllerTest.php +++ b/tests/Unit/GroupClass/GroupClassControllerTest.php @@ -5,13 +5,17 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass; use Brain\Monkey\Functions; use Mockery; +use Unsupervised\Schedular\Auth\InviteRepository; +use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\GroupClass\Enrollment; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; +use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; +use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Tests\Unit\TestCase; class GroupClassControllerTest extends TestCase @@ -19,19 +23,38 @@ class GroupClassControllerTest extends TestCase private EnrollmentRepository&Mockery\MockInterface $enrollments; private OfferingRepository&Mockery\MockInterface $offerings; private PaymentRepository&Mockery\MockInterface $payments; + private GroupAccessRepository&Mockery\MockInterface $access; + private PaymentService&Mockery\MockInterface $paymentService; + private InviteRepository&Mockery\MockInterface $invites; + private RegistrationMailer&Mockery\MockInterface $mailer; private GroupClassController $controller; protected function setUp(): void { parent::setUp(); - $this->enrollments = Mockery::mock(EnrollmentRepository::class); - $this->offerings = Mockery::mock(OfferingRepository::class); - $this->payments = Mockery::mock(PaymentRepository::class); - $this->controller = new GroupClassController($this->enrollments, $this->offerings, $this->payments); + $this->enrollments = Mockery::mock(EnrollmentRepository::class); + $this->offerings = Mockery::mock(OfferingRepository::class); + $this->payments = Mockery::mock(PaymentRepository::class); + $this->access = Mockery::mock(GroupAccessRepository::class); + $this->paymentService = Mockery::mock(PaymentService::class); + $this->invites = Mockery::mock(InviteRepository::class); + $this->mailer = Mockery::mock(RegistrationMailer::class); + $this->controller = new GroupClassController( + $this->enrollments, + $this->offerings, + $this->payments, + $this->access, + $this->paymentService, + $this->invites, + $this->mailer, + ); Functions\when('current_user_can')->justReturn(true); Functions\when('get_current_user_id')->justReturn(3); + Functions\when('get_users')->justReturn([]); + Functions\when('esc_attr')->returnArg(); + Functions\when('esc_attr_e')->returnArg(); } private function offering(int $id, string $title, ?int $capacity): Offering @@ -157,4 +180,170 @@ class GroupClassControllerTest extends TestCase $this->controller->renderInstructorPage(); } + + protected function tearDown(): void + { + $_POST = []; + parent::tearDown(); + } + + private function inviteOnlyOffering(float $price = 0.0): Offering + { + return new Offering( + instructorId: 3, + kind: Offering::KIND_GROUP_CLASS, + title: 'Private Choir', + price: $price, + accessMode: Offering::ACCESS_INVITE_ONLY, + id: 8, + ); + } + + /** Stub the form-processing helpers and the render tail shared by all action tests. */ + private function stubActionContext(): void + { + Functions\when('check_admin_referer')->justReturn(true); + Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v)); + Functions\when('wp_unslash')->returnArg(); + Functions\when('sanitize_email')->returnArg(); + Functions\when('absint')->alias(static fn ($v) => abs((int) $v)); + + // Render tail: no classes/enrolments to draw so the assertion targets the notice. + $this->offerings->shouldReceive('findAll')->with(3, Offering::KIND_GROUP_CLASS)->andReturn([]); + $this->enrollments->shouldReceive('findByInstructor')->with(3)->andReturn([]); + } + + public function testAddDirectEnrolsStudentWithPendingPayment(): void + { + $_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]]; + $this->stubActionContext(); + + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0)); + $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); + $this->enrollments->shouldReceive('insert')->once()->andReturn(44); + + $payment = new Payment( + studentId: 5, + instructorId: 3, + registrationType: Payment::REG_ENROLLMENT, + registrationId: 44, + amount: 100.0, + method: Payment::METHOD_ETRANSFER, + status: Payment::STATUS_PENDING, + id: 12, + ); + $this->paymentService->shouldReceive('createForRegistration') + ->once() + ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 100.0, 'CAD', null) + ->andReturn($payment); + $this->enrollments->shouldReceive('setPaymentId')->once()->with(44, 12)->andReturn(true); + $this->access->shouldReceive('markEnrolled')->once()->with(8, 5); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('1 student(s) added to the class.', $html); + } + + public function testGrantAccessCreatesGrantAndEmailsStudent(): void + { + $_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]]; + $this->stubActionContext(); + + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); + $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); + $this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false); + $this->access->shouldReceive('insert')->once()->andReturn(1); + + $user = Mockery::mock(\WP_User::class); + $user->user_email = 'ada@b.test'; + Functions\when('get_userdata')->justReturn($user); + $this->mailer->shouldReceive('sendClassAccessGranted')->once()->with($user, 'Private Choir')->andReturn(true); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('1 student(s) granted access.', $html); + } + + public function testInviteEmailForNewAddressCreatesInviteAndSendsLink(): void + { + $_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => 'new@x.test']; + $this->stubActionContext(); + + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); + Functions\when('is_email')->justReturn(true); + Functions\when('email_exists')->justReturn(false); + $this->invites->shouldReceive('findPendingByEmail')->with('new@x.test')->andReturn(null); + Functions\when('wp_generate_password')->justReturn('rawtoken'); + Functions\when('get_option')->justReturn(0); + Functions\when('home_url')->justReturn('http://home.test/'); + Functions\when('add_query_arg')->justReturn('http://home.test/?us_invite=rawtoken'); + + $this->invites->shouldReceive('insert')->once()->andReturn(7); + $this->access->shouldReceive('insert')->once()->andReturn(2); + $this->mailer->shouldReceive('sendClassInvite')->once()->with('new@x.test', 'http://home.test/?us_invite=rawtoken', 'Private Choir')->andReturn(true); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('Invitation sent.', $html); + } + + public function testInviteEmailReusesPendingInviteWithoutSendingLink(): void + { + $_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => 'new@x.test']; + $this->stubActionContext(); + + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); + Functions\when('is_email')->justReturn(true); + Functions\when('email_exists')->justReturn(false); + $this->invites->shouldReceive('findPendingByEmail')->with('new@x.test')->andReturn( + new \Unsupervised\Schedular\Auth\Invite(email: 'new@x.test', token: 'hash', id: 9) + ); + + // No new invite row and no email — just a grant attached to the existing invite. + $this->invites->shouldReceive('insert')->never(); + $this->mailer->shouldReceive('sendClassInvite')->never(); + $this->access->shouldReceive('insert')->once()->andReturn(2); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('No new link was sent.', $html); + } + + public function testInviteEmailForExistingAccountGrantsAccess(): void + { + $_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => 'has@acct.test']; + $this->stubActionContext(); + + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering()); + Functions\when('is_email')->justReturn(true); + Functions\when('email_exists')->justReturn(55); + + $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 55)->andReturn(false); + $this->access->shouldReceive('hasGrant')->with(8, 55)->andReturn(false); + $this->access->shouldReceive('insert')->once()->andReturn(3); + + $user = Mockery::mock(\WP_User::class); + $user->user_email = 'has@acct.test'; + Functions\when('get_userdata')->justReturn($user); + $this->mailer->shouldReceive('sendClassAccessGranted')->once()->andReturn(true); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('1 student(s) granted access.', $html); + } + + public function testActionRejectedForClassNotOwnedByInstructor(): void + { + $_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]]; + $this->stubActionContext(); + + // Offering owned by a different instructor (7, not the current user 3). + $foreign = new Offering(instructorId: 7, kind: Offering::KIND_GROUP_CLASS, title: 'Other', accessMode: Offering::ACCESS_INVITE_ONLY, id: 8); + $this->offerings->shouldReceive('findById')->with(8)->andReturn($foreign); + $this->enrollments->shouldReceive('insert')->never(); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('That group class was not found.', $html); + } } diff --git a/tests/Unit/Offering/OfferingControllerTest.php b/tests/Unit/Offering/OfferingControllerTest.php index 79a7057..50aaecb 100644 --- a/tests/Unit/Offering/OfferingControllerTest.php +++ b/tests/Unit/Offering/OfferingControllerTest.php @@ -70,6 +70,39 @@ class OfferingControllerTest extends TestCase $this->render(); } + public function testAddInviteOnlyGroupClassStoresInviteOnlyAccess(): void + { + $_POST = [ + 'usc_action' => 'add', + 'title' => 'Private Choir', + 'kind' => Offering::KIND_GROUP_CLASS, + 'invite_only' => '1', + ]; + + $this->repository->shouldReceive('insert')->once()->with(Mockery::on( + static fn (Offering $o) => Offering::ACCESS_INVITE_ONLY === $o->accessMode + ))->andReturn(1); + $this->repository->shouldReceive('findAll')->andReturn([]); + + $this->render(); + } + + public function testAddWithoutInviteOnlyDefaultsToPublicAccess(): void + { + $_POST = [ + 'usc_action' => 'add', + 'title' => 'Open Choir', + 'kind' => Offering::KIND_GROUP_CLASS, + ]; + + $this->repository->shouldReceive('insert')->once()->with(Mockery::on( + static fn (Offering $o) => Offering::ACCESS_PUBLIC === $o->accessMode + ))->andReturn(1); + $this->repository->shouldReceive('findAll')->andReturn([]); + + $this->render(); + } + public function testAddOneOffGroupClassEndsOnItsStartDate(): void { $_POST = [ diff --git a/tests/Unit/Offering/OfferingEndpointTest.php b/tests/Unit/Offering/OfferingEndpointTest.php new file mode 100644 index 0000000..d0c136e --- /dev/null +++ b/tests/Unit/Offering/OfferingEndpointTest.php @@ -0,0 +1,102 @@ +justReturn(5); + + $this->repository = Mockery::mock(OfferingRepository::class); + $this->access = Mockery::mock(GroupAccessRepository::class); + $this->endpoint = new OfferingEndpoint($this->repository, $this->access); + } + + private function group(int $id, string $access): Offering + { + return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: "Class $id", accessMode: $access, id: $id); + } + + public function testIndexReturnsPublicOfferingsOnlyWhenNoGrants(): void + { + $this->repository->shouldReceive('findAll') + ->once() + ->with(0, '', Mockery::on(static fn ($v): bool => true === $v), Offering::ACCESS_PUBLIC) + ->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]); + $this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([]); + + $data = $this->endpoint->index(new \WP_REST_Request())->get_data(); + + self::assertCount(1, $data); + self::assertSame(1, $data[0]['id']); + } + + public function testIndexMergesGrantedInviteOnlyOfferings(): void + { + $this->repository->shouldReceive('findAll') + ->once() + ->with(0, '', Mockery::any(), Offering::ACCESS_PUBLIC) + ->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]); + $this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([8]); + $this->repository->shouldReceive('findById')->with(8)->andReturn($this->group(8, Offering::ACCESS_INVITE_ONLY)); + + $data = $this->endpoint->index(new \WP_REST_Request())->get_data(); + + self::assertSame([1, 8], array_column($data, 'id')); + } + + public function testIndexOmitsGrantedOfferingThatIsNoLongerInviteOnly(): void + { + $this->repository->shouldReceive('findAll')->andReturn([]); + $this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([8]); + // Grant persists but the class was flipped back to public — it is already + // in the public list, so it must not be appended a second time. + $this->repository->shouldReceive('findById')->with(8)->andReturn($this->group(8, Offering::ACCESS_PUBLIC)); + + $data = $this->endpoint->index(new \WP_REST_Request())->get_data(); + + self::assertSame([], $data); + } + + public function testIndexRespectsKindFilterForGrantedOfferings(): void + { + $this->repository->shouldReceive('findAll') + ->with(0, Offering::KIND_PRIVATE_LESSON, Mockery::any(), Offering::ACCESS_PUBLIC) + ->andReturn([]); + $this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([8]); + // Granted class is a group class; the request filters to private lessons. + $this->repository->shouldReceive('findById')->with(8)->andReturn($this->group(8, Offering::ACCESS_INVITE_ONLY)); + + $data = $this->endpoint->index(new \WP_REST_Request(['kind' => Offering::KIND_PRIVATE_LESSON]))->get_data(); + + self::assertSame([], $data); + } + + public function testIndexOmitsEtransferEmailFromPublicListing(): void + { + $this->repository->shouldReceive('findAll')->andReturn([ + new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', etransferEmail: 'studio@x.test', id: 1), + ]); + $this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([]); + + $data = $this->endpoint->index(new \WP_REST_Request())->get_data(); + + self::assertArrayNotHasKey('etransfer_email', $data[0]); + } +} diff --git a/tests/Unit/Offering/OfferingRepositoryTest.php b/tests/Unit/Offering/OfferingRepositoryTest.php index 8bc5271..b43dc83 100644 --- a/tests/Unit/Offering/OfferingRepositoryTest.php +++ b/tests/Unit/Offering/OfferingRepositoryTest.php @@ -154,6 +154,44 @@ class OfferingRepositoryTest extends TestCase $this->repo->findAll(3, Offering::KIND_GROUP_CLASS); } + public function testFindAllFiltersByAccessMode(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/access_mode = %s/'), + Mockery::on(static fn (array $p): bool => $p === ['wp_us_offerings', Offering::ACCESS_PUBLIC]) + ) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([]); + + self::assertSame([], $this->repo->findAll(accessMode: Offering::ACCESS_PUBLIC)); + } + + public function testInsertPersistsAccessMode(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_offerings', + Mockery::on(static fn (array $data): bool => $data['access_mode'] === Offering::ACCESS_INVITE_ONLY), + Mockery::type('array') + ); + $this->db->insert_id = 1; + + $offering = new Offering( + instructorId: 5, + kind: Offering::KIND_GROUP_CLASS, + title: 'Private Choir', + accessMode: Offering::ACCESS_INVITE_ONLY, + ); + + self::assertSame(1, $this->repo->insert($offering)); + } + public function testDeleteCallsWpdbDelete(): void { $this->db->shouldReceive('delete') diff --git a/tests/Unit/Offering/OfferingTest.php b/tests/Unit/Offering/OfferingTest.php index a7a022d..949ccf3 100644 --- a/tests/Unit/Offering/OfferingTest.php +++ b/tests/Unit/Offering/OfferingTest.php @@ -132,11 +132,53 @@ class OfferingTest extends TestCase $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', id: 10); $arr = $offering->toArray(); - foreach (['id', 'instructor_id', 'kind', 'title', 'price', 'billing_mode', 'is_active'] as $key) { + foreach (['id', 'instructor_id', 'kind', 'title', 'price', 'billing_mode', 'access_mode', 'is_active'] as $key) { self::assertArrayHasKey($key, $arr); } } + public function testDefaultsToPublicAccess(): void + { + $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', id: 10); + + self::assertSame(Offering::ACCESS_PUBLIC, $offering->accessMode); + self::assertFalse($offering->isInviteOnly()); + } + + public function testInviteOnlyAccessIsReported(): void + { + $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', accessMode: Offering::ACCESS_INVITE_ONLY, id: 10); + + self::assertTrue($offering->isInviteOnly()); + self::assertSame(Offering::ACCESS_INVITE_ONLY, $offering->toArray()['access_mode']); + } + + public function testFromRowReadsInviteOnlyAccessMode(): void + { + $row = (object) [ + 'id' => '7', + 'instructor_id' => '3', + 'kind' => Offering::KIND_GROUP_CLASS, + 'title' => 'Private Choir', + 'description' => null, + 'duration_minutes' => null, + 'price' => '0.00', + 'currency' => 'CAD', + 'billing_mode' => Offering::BILLING_FULL_TERM, + 'allow_weekly' => '0', + 'capacity' => null, + 'term_start' => null, + 'term_end' => null, + 'schedule_note' => null, + 'etransfer_email' => null, + 'cancellation_cutoff_hours' => null, + 'access_mode' => Offering::ACCESS_INVITE_ONLY, + 'is_active' => '1', + ]; + + self::assertTrue(Offering::fromRow($row)->isInviteOnly()); + } + public function testToArrayIncludesEtransferEmailByDefault(): void { $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', etransferEmail: 'studio@example.com', id: 10);