# 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\GroupClassController` — `renderPage` (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)