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:
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.
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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()checkshasActiveEnrollment( $offeringId, $studentId )on the(offering, student)pair, andGET /enrollmentsdeliberately returns the guardian's whole household.assets/js/group-classes.jsthrewstudent_idaway when it indexed the response: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
{ id, studentId }.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") andselectedId()then returns0— 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, OKcomposer lint— no errorscomposer cs— cleanassets/js/group-classes.jsthrough 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.