A parent registers once and manages lessons for one or more children, who need no login of their own. A child is a real wp_users row with the student role but no usable login — so student_id keeps meaning "a WordPress user" on every table, and booking, credits, policies and enrolments work unchanged. A us_guardians link table maps guardian to child. The signup form gains a parent/guardian tick that reveals a block per child, with the account-signup questions asked per child rather than per guardian — they describe the student, not the account holder. Signup policies are recorded once per child with the guardian as the acceptor, which is the record that actually means something. A family that half-creates is rolled back entirely rather than leaving a guardian who cannot re-register. The booking and enrolment forms gain a "Who is this for?" picker listing children first, so the default selection is never the parent — booking for the wrong child is correctable, quietly billing a parent for their kid's lesson is not. POST /bookings and POST /enrollments take an optional student_id honoured only for that child's guardian; anything else is a 403. That check is the authorisation boundary of the feature. Payments and credits gain a payer: the charge names the child it was for and the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step reach the parent. Credit is held by the payer, so one child's cancellation can settle a sibling's charge, and the daily billing scan sends a guardian one notice covering every child. Closes #132 Co-Authored-By: Claude Opus 5 <[email protected]>
14 KiB
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).
- 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. - Student answers the offering's questions (
GET /offerings/{id}/questions). - Student accepts the current published policy versions (
GET /policies) — required to continue. - 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. - Full-term payment is taken per the student's billing method (card by default;
pendingfor e-transfer; skipped for comp). Seepayments.md. POST /enrollmentscreates the enrolment (status = active), records answers and policy acceptances, and links the payment — but only if the offering'scapacityhas not been reached.- 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:
- 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 byPaymentService). No access grant is needed — this writes straight tous_group_enrollments+us_payments. It bypasses the enrolment deadline and capacity, so it doubles as the late-enrolment path after a class has closed. - Make available — the selected registered students get an
invitedgrant 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. - Invite by email — for an address with no account yet: a tokenised personal invite
(
us_invites, carryingoffering_id) is created and the registration link emailed, alongside aninvitedgrant keyed byemail+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 anyview_all_lessonsstudio 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-checkedusc_actionPOSTs, 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/hasActiveEnrollmentenforce 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) andrenderInstructorPage(instructor summary +?class_idroster 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 theofferingIdattribute). In single-class modeassets/js/group-classes.jsleaves 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, typeenrollment)
Payment: a priced enrolment creates a payment via
Payment\PaymentService(registration_type = enrollment) and links it aspayment_id; unpriced enrolments returnpayment: nulland skip the payment step. Seepayments.mdfor the card/e-transfer/comp flows.
Tests
tests/Unit/GroupClass/GroupClassControllerTest.php(roster + add/make-available/invite actions)tests/Unit/GroupClass/EnrollmentTest.phptests/Unit/GroupClass/EnrollmentRepositoryTest.phptests/Unit/GroupClass/EnrollmentEndpointTest.php(invite-only gating)tests/Unit/GroupClass/GroupAccessTest.phptests/Unit/GroupClass/GroupAccessRepositoryTest.phptests/Unit/GroupClass/GroupClassPageTest.phptests/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.