Add invite-only group classes
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

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]>
This commit is contained in:
2026-07-23 13:51:02 -03:00
co-authored by Claude Opus 4.8
parent 25aeba9dc1
commit a281935811
33 changed files with 1598 additions and 38 deletions
+2 -1
View File
@@ -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=<token>`); 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`).
+56 -2
View File
@@ -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)
+1
View File
@@ -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 |