CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m3s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m24s
CI / Coding Standards & Static Analysis (pull_request) Successful in 8m21s
CI / Build Plugin Zip (pull_request) Skipped
The Book a lesson for a student panel built its picker from the us_student role but vetted the submission with the book_lesson capability. ChildLoginGate and RegistrationLoginGate withhold that capability from accounts that keep the role, so the panel offered every guardian-managed child and every unapproved signup and then refused them — with a message claiming no student had been chosen, and a form cleared of all five fields. Withholding book_lesson stops those accounts registering in their own name. It was never meant to stop the studio acting for them, which is what the panel is for, and for a child is the only route to a lesson besides their guardian. Guard the student role instead, via a new RoleManager::isStudent() shared with every picker and guard on the staff side so the two cannot drift apart again. Group enrolment gets the same predicate: addDirect() and grantAccess() vetted their posted ids not at all, and would enrol an instructor, an administrator, or an account deleted since the page was drawn — raising a real payment against them for a priced class. Keep a refused booking's fields as submitted, reading the form through one LessonController::submittedBooking() so what gets booked and what is shown again cannot disagree about a field name. A booking that succeeds still leaves an empty form, so the next one does not inherit it. Closes #185 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01XunBYk2sFEc1oL14sUiuBU
312 lines
22 KiB
Markdown
312 lines
22 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_by` | BIGINT UNSIGNED | Staff member who added the student from wp-admin; 0 when the student (or their guardian) enrolled themselves |
|
|
| `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 you are enrolled in must never silently vanish from these lists.** Both
|
|
the class time and the duration are optional on the offering form, and the
|
|
schedule note exists precisely so a studio can write "Tuesdays 4:00pm" rather than
|
|
pin the class to a clock. So the schedule degrades instead of disappearing:
|
|
|
|
| Class has | What the list gets |
|
|
|---|---|
|
|
| date + time + duration | one dated row per remaining session, with an end time |
|
|
| date + time, no duration | one dated row per remaining session, `end_dt` empty — when it starts is worth showing without guessing when it ends |
|
|
| no class time | **one** row for the class as a whole, sorted by term start (or by "now" once the term is under way), with `schedule` text from `Offering::scheduleLabel()` — the studio's note, else the term dates, else "Schedule to be confirmed" |
|
|
| a term whose last day has passed | nothing |
|
|
|
|
`schedule` is the tell: non-null means "a class, described in words, not a session
|
|
at a known time", and every renderer shows that text in place of a date and time.
|
|
An undated row's `start_dt` is a **sort key only** — never displayed.
|
|
|
|
`Offering::sessionStarts()` is the split that makes this work: it needs only the
|
|
date and the time, because knowing *when* a class meets is a separate question
|
|
from knowing how long it runs. `sessionWindows()` is that plus the duration, and
|
|
still returns nothing without one — availability blocking and per-session billing
|
|
need both ends of a window.
|
|
|
|
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 household's enrolments
|
|
(`GET /enrollments`), and the two are matched up **per student**, not per account.
|
|
Each active enrolment in a class adds its own line to the card — "Ada is enrolled
|
|
in this class." — with its own **Withdraw** button, and the Enrol button stays
|
|
(reading "Enrol another student") for as long as anyone the account may enrol is
|
|
still out of the class. The enrolment form then offers only those students; when
|
|
exactly one is left the picker collapses to a hidden field carrying that student's
|
|
id, because an omitted `student_id` reads as "enrol the account holder" and would
|
|
sign up the parent instead of the last child. Only when the whole household is
|
|
enrolled does the Enrol button disappear.
|
|
|
|
The per-student matching mirrors the server, which rejects a duplicate with
|
|
`409 already_enrolled` for that `(offering, student)` pair alone — a sibling is
|
|
never a duplicate, and 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. A guardian sees one
|
|
per enrolled child, labelled with the child's name, so the right seat is the one released.
|
|
`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.
|
|
The enrolment records who added them (`enrolled_by`), which is what later allows
|
|
its intake to be recorded — see **Recording Intake Collected Elsewhere**.
|
|
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.
|
|
|
|
Both student-picking controls vet every posted id with `Auth\RoleManager::isStudent()`
|
|
before acting on it — the same predicate the picker is built from, and the same one
|
|
`Booking\AdminBooking` guards a staff booking with. A posted id naming an instructor,
|
|
an administrator, or an account deleted since the page was drawn is skipped rather
|
|
than enrolled, so nothing can put a non-student on a roster or raise a payment
|
|
against one. Being a student is a matter of the **role**, not the `book_lesson`
|
|
capability, so a guardian's child and a signup still awaiting approval are both
|
|
fully enrollable — neither may enrol *themselves*, which is exactly what the studio
|
|
adding them is for. The reported count is what was actually added, so a skipped id
|
|
shows up as a smaller number.
|
|
|
|
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.
|
|
|
|
## Recording Intake Collected Elsewhere
|
|
A student the studio added with **Add students directly** has no intake answers
|
|
and no policy acceptances: they were never shown the enrolment form. The answers
|
|
are collected another way — a paper form at the first class, a phone call to a
|
|
parent — and recorded afterwards from the **enrolment detail page**, reached from
|
|
the **Intake → View** link on each roster row.
|
|
|
|
The page shows who and what the enrolment is, the audit trail of everything
|
|
answered and agreed to so far, and — for a studio-made enrolment only — a
|
|
**Record intake collected elsewhere** panel offering whatever is still missing.
|
|
Every recording must say **how** it was collected (signed paper form / in person /
|
|
over the phone / by email / some other way, the last requiring an explanation),
|
|
and that is stamped on every row along with who entered it. Both audit tables
|
|
carry a **How it was given** column, so a policy ticked online and one transcribed
|
|
from paper never look alike.
|
|
|
|
**Only a studio-made enrolment qualifies** (`Enrollment::isStaffRegistered()`,
|
|
i.e. `enrolled_by > 0`). An enrolment the student made already holds their own
|
|
answers, and letting staff add to it would make the record editable after the
|
|
event. Nothing already recorded can be overwritten: the submission is narrowed to
|
|
what is genuinely still pending before anything is written, so a stale or
|
|
double-posted form is harmless.
|
|
|
|
This is the same mechanism the Scheduler uses for lessons it booked, and the
|
|
reasoning behind each rule — why no IP is stored, why `accepted_by` stays the
|
|
student while `recorded_by` names the staff member — is set out once in
|
|
**Recording Intake Collected Elsewhere** in `lesson-booking.md`. An enrolment is
|
|
its own registration, so unlike a weekly lesson series there is no anchor to
|
|
follow: one enrolment, one intake record, however many sessions the term holds.
|
|
|
|
Scoping matches the rest of the detail pages: an instructor may only open
|
|
enrolments in their own classes, a `view_all_lessons` studio admin any.
|
|
|
|
## 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`). Both also route `?enrollment_id=` to the enrolment detail view (`maybeRenderEnrollmentDetail`, template `templates/admin/enrollment-detail.php`)
|
|
- Intake audit + late recording: `Unsupervised\Schedular\Registration\IntakeAudit` and `IntakeRecording`, shared with lesson bookings. `Enrollment` implements `Registration\IntakeSubject` to take part
|
|
- 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`.
|