Files
unsupervised-scheduler/docs/features/group-classes.md
T
thatguygriffandClaude Opus 4.8 a281935811
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
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 <[email protected]>
2026-07-23 13:51:02 -03:00

9.2 KiB

Feature: Group Classes

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
id BIGINT UNSIGNED Primary key
offering_id BIGINT UNSIGNED FK → us_offerings.id (kind = group_class)
student_id BIGINT UNSIGNED WordPress user ID
instructor_id BIGINT UNSIGNED WordPress user ID (denormalised from the offering)
status VARCHAR(20) active / cancelled / completed
payment_id BIGINT UNSIGNED Nullable FK → us_payments.id
enrolled_at DATETIME Insertion time

Class Dates

A group class offering carries term_start/term_end (see offerings.md): one-off classes end the day they start; weekly classes run a set number of sessions. The class card on the enrolment page shows the date or date range with the session count.

Enrolment Flow

The class list is loaded together with the student's own enrolments (GET /enrollments); a class the student already has an active enrolment in shows "You are enrolled in this class." instead of the Enrol button (the server would reject the duplicate with 409 already_enrolled regardless — a cancelled enrolment does not block re-enrolling).

  1. Student opens a group class from the offering catalog.
  2. Student answers the offering's questions (GET /offerings/{id}/questions).
  3. Student accepts the current published policy versions (GET /policies) — required to continue.
  4. Full-term payment is taken per the student's billing method (card by default; pending for e-transfer; skipped for comp). See payments.md.
  5. POST /enrollments creates the enrolment (status = active), records answers and policy acceptances, and links the payment — but only if the offering's capacity has not been reached.
  6. On successful payment (or comp) a receipt is emailed.

Capacity is enforced at enrolment time by counting active rows for the offering; a class at capacity rejects further enrolments.

REST API

Method Endpoint Permission
GET /wp-json/us-scheduler/v1/enrollments Any logged-in user
POST /wp-json/us-scheduler/v1/enrollments book_lesson

POST /enrollments body: offering_id, answers[] (question_id → value), accepted_policy_version_ids[], and payment data (see payments.md). The response includes id, status, and payment — a {id, method, status} summary, or null when the class is free (the front end then skips the 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. 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\GroupClassControllerrenderPage (studio admin, view_all_lessons) and renderInstructorPage (instructor, view_own_lessons)
  • REST endpoint: Unsupervised\Schedular\GroupClass\EnrollmentEndpoint
  • Frontend: Unsupervised\Schedular\GroupClass\GroupClassPage ([us_group_classes] shortcode; offering="…" restricts it to a single class for embedding on a dedicated page — the block equivalent is the offeringId attribute)
  • Reuses Registration\RegistrationGate (intake answers + booking-scoped policy acceptance, type enrollment)

Payment: a priced enrolment creates a payment via Payment\PaymentService (registration_type = enrollment) and links it as payment_id; unpriced enrolments return payment: null and skip the payment step. See payments.md for the card/e-transfer/comp flows.

Tests

  • 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)