Files
unsupervised-scheduler/docs/features/group-classes.md
thatguygriffandClaude Opus 5 c9a1205fc0
CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m47s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 1m1s
CI / No Debug Code (pull_request) Successful in 3s
Never drop an enrolled class from upcoming lessons; delete a guardian's children with them; pin the panel's line spacing
Three fixes from testing the branch.

A group class was only listed when its schedule resolved to exact
datetimes, which needs a class time *and* a duration — both optional on
the offering form, and the schedule note exists precisely so a studio can
write "Tuesdays 4:00pm" instead. A class configured that way vanished
from the list, which is the one thing this feature must never do. So
Offering::sessionStarts() splits "when does it meet" from "how long does
it run" (sessionWindows() is that plus the duration, unchanged), and
SessionSchedule degrades instead of disappearing: dated rows with an open
end when there is no duration, and a single row carrying
Offering::scheduleLabel() when there is no time to derive dates from.
Only a class whose last day has passed drops out.

Deleting a guardian now deletes the children linked to them, releasing
each one's lessons and enrolments first. A child account is login-less
and exists only so the guardian has somebody to book for; without the
guardian nobody can reach it, book for it, or be billed for it, so it was
left stranded on the roster still holding slots. A `handled` set makes
the re-entrant delete_user each child deletion fires a no-op, and stops a
circular link recursing.

The upcoming panel never stated its own line-height, so a theme setting
line-height: 0 above it — the usual icon-font reset — was inherited
straight through. Below 1 that produces both reported symptoms at once:
stacked lines overlap, and the status pill's background is shorter than
the text in it. Pinned at the same id-level specificity as the rest.

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 12:10:43 -03:00

18 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.

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 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\GroupClassControllerrenderPage (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.