Let a guardian enrol every child in the same group class #171

Merged
thatguygriff merged 1 commits from fix/group-multi-student-enrolment into main 2026-08-11 20:44:30 +00:00
Owner

Fixes #170.

Enrolling a second student in the same group class was impossible: the first enrolment went through, the card switched to "You are enrolled in this class." with a Withdraw button, and the Enrol button was gone for the rest of the household.

Cause

The server was never the constraint — EnrollmentEndpoint::enroll() checks hasActiveEnrollment( $offeringId, $studentId ) on the (offering, student) pair, and GET /enrollments deliberately returns the guardian's whole household. assets/js/group-classes.js threw student_id away when it indexed the response:

new Map(enrollments
    .filter((e) => e.status === 'active')
    .map((e) => [Number(e.offering_id), e.id]))

One key per class, one enrolment per account. Every render decision — the Enrol button, the Withdraw button, the deadline line — hung off enrolledMap.has(offering.id), so one child's seat spoke for the whole family.

Change

  • Active enrolments are grouped per class as a list of { id, studentId }.
  • Each enrolled student gets their own line, named — "Ada Lovelace is enrolled in this class." — and their own Withdraw button labelled with the name (the confirm dialog names them too, so the right seat is the one released). A single-student account keeps the second-person wording and a bare Withdraw.
  • The Enrol button stays, reading Enrol another student, while anyone the account may enrol is still out of the class; it disappears only when the whole household is in.
  • The "Who is this for?" picker offers only the students not yet enrolled, so the same child cannot be double-booked into a 409 already_enrolled.

One case needed care. When exactly one student is left to enrol, usGuardian.selectorHtml() renders nothing (its rule is "no picker for a list of one") and selectedId() then returns 0 — which the server reads as enrol the account holder. Left alone, a parent enrolling their last remaining child would have enrolled themselves. The single-student case now renders a hidden field carrying the real id, plus a "For Ada Lovelace." line so it is visible who the form is about.

No data migration: seats were always recorded per student, so existing enrolments are already correct and simply render properly now.

Testing

  • composer test — 908 tests, 2576 assertions, OK
  • composer lint — no errors
  • composer cs — clean
  • The change is entirely client-side, so it is outside the PHP suite's reach. I drove assets/js/group-classes.js through a stubbed DOM with a fake API mirroring the server's per-student duplicate check, and confirmed against the pre-fix file that the harness reproduces the report (Enrol button vanishes after the first enrolment). Post-fix, both scenarios pass end to end: a three-member household enrols all three one after another, each row named, each withdraw scoped to one student, the Enrol button returning after a withdrawal — and a single-student account renders no picker, still posts a real id, and keeps its original wording.
Fixes #170. Enrolling a second student in the same group class was impossible: the first enrolment went through, the card switched to "You are enrolled in this class." with a **Withdraw** button, and the Enrol button was gone for the rest of the household. ## Cause The server was never the constraint — `EnrollmentEndpoint::enroll()` checks `hasActiveEnrollment( $offeringId, $studentId )` on the `(offering, student)` pair, and `GET /enrollments` deliberately returns the guardian's whole household. `assets/js/group-classes.js` threw `student_id` away when it indexed the response: ```js new Map(enrollments .filter((e) => e.status === 'active') .map((e) => [Number(e.offering_id), e.id])) ``` One key per class, one enrolment per account. Every render decision — the Enrol button, the Withdraw button, the deadline line — hung off `enrolledMap.has(offering.id)`, so one child's seat spoke for the whole family. ## Change - Active enrolments are grouped per class as a **list** of `{ id, studentId }`. - Each enrolled student gets their own line, named — "Ada Lovelace is enrolled in this class." — and their own Withdraw button labelled with the name (the confirm dialog names them too, so the right seat is the one released). A single-student account keeps the second-person wording and a bare **Withdraw**. - The Enrol button stays, reading **Enrol another student**, while anyone the account may enrol is still out of the class; it disappears only when the whole household is in. - The "Who is this for?" picker offers only the students not yet enrolled, so the same child cannot be double-booked into a `409 already_enrolled`. One case needed care. When exactly one student is left to enrol, `usGuardian.selectorHtml()` renders nothing (its rule is "no picker for a list of one") and `selectedId()` then returns `0` — which the server reads as *enrol the account holder*. Left alone, a parent enrolling their last remaining child would have enrolled **themselves**. The single-student case now renders a hidden field carrying the real id, plus a "For Ada Lovelace." line so it is visible who the form is about. No data migration: seats were always recorded per student, so existing enrolments are already correct and simply render properly now. ## Testing - `composer test` — 908 tests, 2576 assertions, OK - `composer lint` — no errors - `composer cs` — clean - The change is entirely client-side, so it is outside the PHP suite's reach. I drove `assets/js/group-classes.js` through a stubbed DOM with a fake API mirroring the server's per-student duplicate check, and confirmed against the **pre-fix** file that the harness reproduces the report (Enrol button vanishes after the first enrolment). Post-fix, both scenarios pass end to end: a three-member household enrols all three one after another, each row named, each withdraw scoped to one student, the Enrol button returning after a withdrawal — and a single-student account renders no picker, still posts a real id, and keeps its original wording.
thatguygriff added 1 commit 2026-08-11 20:36:35 +00:00
Let a guardian enrol every child in the same group class
CI / No Debug Code (pull_request) Successful in 26s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m5s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 6m9s
CI / Build Plugin Zip (pull_request) Skipped
8017dbb9ff
The group-class page matched enrolments to the account instead of to the
student: the lookup it built from GET /enrollments was keyed by offering id
alone, so the first household enrolment marked the class as "yours" and took
the Enrol button away from everyone else on the account. A parent could enrol
one child and was then offered nothing but Withdraw.

The server was never the constraint — hasActiveEnrollment() checks the
(offering, student) pair and GET /enrollments deliberately returns the whole
household — so the fix is to stop discarding student_id on the way in. Active
enrolments are now grouped per class as a list, each student gets their own
"… is enrolled in this class." line and their own named Withdraw button, and
the Enrol button stays (as "Enrol another student") while anyone the account
may enrol is still out.

The enrolment form offers only the students not yet enrolled. When exactly one
of them is left the picker collapses, and that case needed care: an omitted
student_id reads as "enrol the account holder" server-side, so a hidden field
carries the id rather than posting nothing and signing up the parent instead of
the last child.

Closes #170

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jy9UPhmpLUAfimsyecHN2Z
thatguygriff merged commit b220de48c5 into main 2026-08-11 20:44:30 +00:00
thatguygriff deleted branch fix/group-multi-student-enrolment 2026-08-11 20:44:30 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Unsupervised/unsupervised-scheduler#171