Files
unsupervised-scheduler/docs/features/group-classes.md
T
thatguygriffandClaude Opus 4.8 87cfe921a9
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m47s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Show instructor real name or nickname in group-class views, not the login
Add Auth\UserName::format(), which prefers a user's first + last name, then
their nickname, avoiding display_name (which can be the login/username).
Route the instructor name through it in both the front-end offerings response
(instructor_name) and the back-end group-class summary and details views.

Tests: composer test (513), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 17:50:34 -03:00

155 lines
11 KiB
Markdown

# 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, Time, and Instructor
A group class offering carries `term_start`/`term_end` plus a `class_time` and an
owning `instructor_id` (see `offerings.md`): one-off classes end the day they
start; weekly classes run a set number of sessions, all at `class_time`. The class
card on the enrolment page shows **when** the class meets (the date or date range
plus the start time) and **who** teaches it (the assigned instructor's name,
surfaced as `instructor_name` on the `GET /offerings` response). Instructor names
in the group-class views (front and back end) use the instructor's real name
(first + last) or nickname, never their login — see `Auth\UserName::format()`.
Assigning an instructor to a scheduled class removes that instructor's open
booking slots at the class time and flags any already-booked lesson that clashes;
see **Instructor assignment** in `offerings.md`.
## 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): a per-class summary across
instructors — each class with its instructor, when it meets, and its active-enrolment
count against capacity (not a flat list of individual student enrolments). Selecting a
class (`?class_id=<id>`) opens the same per-class **details page** described below, so a
studio admin — including an owner-operator who also teaches, for whom the instructor
**My Group Classes** menu is hidden — can view any class's roster and manage invite-only
membership from here. Invite actions are permitted for the class's own instructor or any
`view_all_lessons` studio admin.
- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): a summary of the
instructor's own group classes — each with when it meets and its active-enrolment count
against capacity, plus a **View details** link (**View & invite** for invite-only
classes). Selecting a class (`?class_id=<id>`, scoped to the owning instructor) opens its
**details page**: a class-details panel (when, instructor, enrolled/capacity, duration,
price, schedule note, description, status), the roster of enrolled students with enrolment
and payment status, and — for invite-only classes — an **Invite & enrol students** section
listing who has been invited but not yet enrolled alongside the add/make-available/
invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to the owning
instructor). Managing who is in an invite-only class is therefore done entirely from this
page. The summary (`templates/admin/my-group-classes.php`) and the details page
(`templates/admin/my-group-class-detail.php`) are separate templates.
## 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 per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `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)