CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m8s
CI / Build Plugin Zip (pull_request) Skipped
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
Five items from the latest demo pass: - A policy's title can be edited from the Policies screen. Only the title moves; the slug is what the gates resolve policies by, so a rename can never detach a policy from acceptances already recorded against it. - Signup is one page again. The studio's registration questions move from a second step behind "Next" onto the main form, in an "About you" panel above the students being added, and that panel also asks an adult student for their birth year (the same us_birth_year meta a child's uses). register.js disables and hides the whole panel for a pure guardian, since the questions describe a student. - The password is re-scored on submit, not only as it is typed. zxcvbn's dictionary arrives after page load, so a password typed straight away was never scored at all and the first the student heard of it was the server rejecting the whole form. - Group-class sessions appear alongside lessons wherever upcoming lessons are listed: the [us_scheduler] panel (students and instructors) and the admin student detail page. GroupClass\SessionSchedule derives them from Offering::sessionWindows(), the same derivation the billing scan uses. They carry kind = 'group_class' and no Cancel action - a session is one date in a term, not a booked slot. - Deleting a user releases what the account was holding: each upcoming lesson is cancelled, its slot freed for rebooking, its pending payment voided, and active class enrolments cancelled. Past lessons and paid history are left alone. Tests: composer test (851), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 5 <[email protected]>
236 lines
17 KiB
Markdown
236 lines
17 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`.
|
|
|
|
### Sessions in the "upcoming" views
|
|
`GroupClass\SessionSchedule` turns an enrolment into the dated sessions behind it,
|
|
so a class appears alongside one-to-one lessons wherever upcoming lessons are
|
|
listed. A class is a term, not rows in `us_availability`, so an enrolment carries
|
|
no date of its own — the concrete windows come from `Offering::sessionWindows()`,
|
|
the same derivation the billing scan and the class-slot reconciler use, which is
|
|
what keeps a student's list, an instructor's list and the invoice agreeing on when
|
|
the class meets.
|
|
|
|
- `upcomingForStudent()` — every not-yet-started session of each enrolment that is
|
|
not `cancelled`. `completed` is a *billing* state and says nothing about the
|
|
calendar, so those sessions stay listed.
|
|
- `upcomingForInstructor()` — every session of each active group class they own,
|
|
one row per session however many students are enrolled; enrolments are not
|
|
consulted, because a class still has to be taught if nobody has signed up yet.
|
|
|
|
A class whose schedule is not fully specified (no time, or no duration) yields no
|
|
windows and so contributes no rows — better absent from a dated list than shown at
|
|
a time nobody chose.
|
|
|
|
Consumers mark these rows `kind = 'group_class'` (`SessionSchedule::KIND`) and
|
|
withhold the per-lesson actions from them: a session is one date in a term, not a
|
|
booked slot, so there is nothing to cancel session by session and no slot to
|
|
release. Withdrawing from the class is the separate, whole-enrolment decision.
|
|
|
|
Where they show up: the `[us_scheduler]` upcoming panel via `GET /bookings`
|
|
(students and instructors both), and the **Upcoming lessons** table on the admin
|
|
student detail page. Only *upcoming* sessions are added there — the
|
|
**Group-class enrolments** table below already records the whole history, and a
|
|
term's worth of past dates would bury the lessons under "Past lessons".
|
|
|
|
## 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. Each class card shows its price with the **cadence** it is billed on — `120.00 CAD up front`, `40.00 CAD monthly`, and so on.
|
|
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. The enrolment form restates the price (with HST) and requires a second, separate agreement to pay that amount before it will submit. See **Price Display and the Pay Agreement** in `payments.md`.
|
|
5. Full-term payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
|
6. `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.
|
|
7. 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.
|
|
|
|
Enrolment also closes after the class's **enrolment deadline** (the instructor's
|
|
`enrollment_deadline`, defaulting to `term_start` — the first class day; see
|
|
`offerings.md`). Past the deadline `POST /enrollments` rejects the enrolment with
|
|
`403 enrollment_closed`, and the class list shows "Enrolment has closed." in place
|
|
of the Enrol button. While enrolment is still open the class card shows an
|
|
"Enrol by" line with the effective deadline date.
|
|
|
|
The deadline only bounds student **self**-enrolment. An instructor (or studio admin)
|
|
can still enrol someone by hand from the class **details page** — the **Add students
|
|
directly** control, available for every group class, deliberately bypasses the
|
|
deadline (and capacity) so a **late enrolment** can be added after the class has
|
|
closed. Past the deadline the details page labels these as late enrolments. See
|
|
**Admin Interface** below.
|
|
|
|
## Withdrawal Flow
|
|
A student may withdraw themselves from a class they are enrolled in through the same
|
|
group-class page: an active enrolment shows a **Withdraw** button.
|
|
`POST /enrollments/{id}/withdraw` marks the enrolment `cancelled` (freeing its
|
|
capacity seat) and voids any still-pending payment. It **never issues an account
|
|
credit** — a timely withdrawal is a clean exit, not a refund (credits are reserved
|
|
for cancelled lessons; see `credits.md`).
|
|
|
|
Self-withdrawal is bounded by the class's **withdrawal deadline** (the instructor's
|
|
`withdrawal_deadline`; see `offerings.md`). Unlike the enrolment deadline it has no
|
|
implicit default — a class with no deadline set stays open to withdrawal for its
|
|
whole life. Past the deadline `POST /enrollments/{id}/withdraw` rejects the request
|
|
with `403 withdrawal_closed`, and the class card shows "Withdrawal has closed —
|
|
contact the studio to withdraw." in place of the Withdraw button. The endpoint also
|
|
returns `404 not_found` for an unknown enrolment and `403 forbidden` when the
|
|
enrolment is not the caller's own; a withdrawal of an already-cancelled enrolment is
|
|
idempotent.
|
|
|
|
The deadline only bounds student **self**-withdrawal. A studio admin can withdraw a
|
|
student at any time from the **student detail page** (`Auth\StudentActions::withdrawEnrollment`),
|
|
which is never subject to the deadline.
|
|
|
|
## 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` | `/wp-json/us-scheduler/v1/enrollments/{id}/withdraw` | Owner (the enrolled student) |
|
|
|
|
`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**. **Add students directly** is available on every
|
|
class's details page (see **Admin Interface**); invite-only classes add two more
|
|
controls beneath it:
|
|
|
|
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`. It bypasses the enrolment deadline and
|
|
capacity, so it doubles as the **late-enrolment** path after a class has closed.
|
|
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, enrolment deadline, status), the roster of enrolled students with
|
|
enrolment and payment status, and an **Add students** section. Every class — public or
|
|
invite-only — carries the **Add students directly** control there, which enrols the
|
|
selected students immediately (a late enrolment past the deadline; the section says so
|
|
when the deadline has passed). Invite-only classes additionally get the
|
|
**make-available** and **invite-by-email** controls plus the list of who has been invited
|
|
but not yet enrolled. These are nonce-checked `usc_action` POSTs, scoped to the owning
|
|
instructor. 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`
|
|
- Sessions: `Unsupervised\Schedular\GroupClass\SessionSchedule` (`upcomingForStudent`, `upcomingForInstructor`) — consumed by `Booking\BookingEndpoint::myLessons()` and `Auth\StudentController`
|
|
- 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). In single-class mode `assets/js/group-classes.js` leaves the class description out of the card, since the page it is embedded on already describes the class; the schedule, instructor, schedule note, price and enrolment controls are still shown.
|
|
- 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/GroupClass/SessionScheduleTest.php`
|
|
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
|
|
|
|
## Enrolling A Child
|
|
`POST /enrollments` accepts the same optional **`student_id`** as booking,
|
|
authorised through `Guardian\GuardianService::canActFor()`; `GET /enrollments`
|
|
covers the guardian's whole household, and a guardian may withdraw any of their
|
|
children. See `parent-guardian-accounts.md`.
|