Both fields are marked in their labels the same way a required registration question is, and enforced on the server whichever form they arrive from: GuardianService::createChild() and updateChild() now refuse a blank name or an unusable birth year, and the signup form checks the same rule up front, before it creates a single user, so a bad block never leaves a half-registered family behind. normaliseBirthYear() became public and static so both paths share one definition of what a usable year is. The signup form cannot lean on the browser here. Its child blocks are hidden until the parent/guardian box is ticked, and a `required` field inside a hidden container makes the whole form unsubmittable with no control the user can reach to fix — the same trap the guardian's own question panel already sidesteps by disabling rather than hiding. So register.js puts `required` on and takes it off along with the block itself, and the server is what makes the rule hold with JavaScript off. The profile screen has no such problem: its forms are always visible, so the attribute is static there. One behaviour change beyond the requirement: a child block with anything typed into it is now reported back instead of dropped. Previously any block without a name was silently discarded, which would now mean losing a birth year the guardian had filled in. A wholly untouched spare block — the one the form always renders for "add another" — is still ignored. Verified the required-toggling in a headless browser: unticked submits, ticked blocks an empty block, a cloned block inherits the requirement, and re-unticking leaves nothing behind to block a non-guardian signup. Closes #148 Co-Authored-By: Claude Opus 5 <[email protected]>
347 lines
18 KiB
Markdown
347 lines
18 KiB
Markdown
# Feature: Parent/Guardian Accounts
|
|
|
|
## Overview
|
|
A parent or guardian registers **once** and manages lessons for **one or more
|
|
children**, without each child needing their own login. The guardian signs in,
|
|
picks which child a booking is for, and pays for all of them from one account.
|
|
|
|
A guardian may also be a student in their own right — they appear in their own
|
|
"who is this for?" selector alongside their children, so a parent taking lessons
|
|
next to their kids needs only the one account.
|
|
|
|
## Vocabulary: "child" in the code, "student" in the UI
|
|
|
|
The interface says **student** and **profile**; the code says **child** and
|
|
**family**. This is deliberate, not drift. Every identifier below — the
|
|
`us_guardian_links` columns, `GuardianService::createChild()`, the `children[]`
|
|
request parameters, the `child_name` form fields, the `us-scheduler/family`
|
|
block name and the `[us_family]` shortcode — is a stable contract with the
|
|
database, saved post content and existing installs, so renaming them would break
|
|
sites for no user-visible gain. Only the strings a person reads were changed.
|
|
|
|
When adding to this feature, keep the split: internal names follow the
|
|
data model, translatable strings follow the interface.
|
|
|
|
## Core Decision: children are accountless WordPress users
|
|
|
|
Every `student_id` column in `src/Schema.php` (`us_lessons`, `us_payments`,
|
|
`us_credits`, `us_group_enrollments`, `us_question_answers`,
|
|
`us_policy_acceptances`, `us_group_access`) is a `wp_users` id, and booking,
|
|
billing, credits, policies and registration answers all resolve it directly.
|
|
|
|
Rather than change what `student_id` means, **a child is a real `wp_users` row**
|
|
with the `us_student` role, created without a usable login:
|
|
|
|
- no password (`wp_generate_password()` is used and discarded — nothing is ever
|
|
emailed, so it cannot be guessed into a session),
|
|
- no real email address; a child gets a placeholder login on the RFC 2606
|
|
reserved `.invalid` TLD (`us-child-<random>@child.invalid`, see
|
|
`GuardianService::childEmail()`) — a well-formed address that can never
|
|
resolve, so nothing about a child's account can be emailed somewhere real,
|
|
- the `us_child` user meta flag set to `1`, which
|
|
`Guardian\ChildLoginGate` uses to block authentication outright.
|
|
|
|
Consequences:
|
|
|
|
- `us_lessons`, `us_group_enrollments`, `us_question_answers` and
|
|
`us_group_access` are **unchanged** — a child books like any other student.
|
|
- A child can be promoted to their own login later by setting a password and a
|
|
real email and clearing `us_child`; no data migrates.
|
|
- A `us_guardians` link table maps guardian → child.
|
|
|
|
The alternative — a standalone `us_students` table decoupled from `wp_users` —
|
|
was rejected for v1: it changes the meaning of `student_id` on seven tables and
|
|
requires migrating every existing row.
|
|
|
|
## Data Model — `{prefix}us_guardians`
|
|
|
|
| Column | Type | Notes |
|
|
|----------------|-----------------|-----------------------------------------------------------|
|
|
| `id` | BIGINT UNSIGNED | Primary key |
|
|
| `guardian_id` | BIGINT UNSIGNED | WordPress user ID of the parent/guardian |
|
|
| `student_id` | BIGINT UNSIGNED | WordPress user ID of the child |
|
|
| `relationship` | VARCHAR(50) | Free text shown in admin (e.g. "Parent", "Grandparent"); may be empty |
|
|
| `created_at` | DATETIME | Insertion time |
|
|
|
|
`UNIQUE KEY guardian_student (guardian_id, student_id)` — the same pair can
|
|
never be linked twice.
|
|
|
|
The table is a link table, not a child record: the child's **name** is their
|
|
`display_name` on `wp_users`, and their birth year is the `us_birth_year`
|
|
user meta. Keeping them on the user row means the admin student screens,
|
|
`get_users()` ordering, and every existing `student_id` lookup keep working with
|
|
no special-casing.
|
|
|
|
### The legacy `us_date_of_birth` meta
|
|
|
|
This feature originally collected a full date of birth in `us_date_of_birth`.
|
|
Nothing writes that key any more. It is handled entirely inside
|
|
`GuardianService`:
|
|
|
|
- **Read** — `birthYear()` falls back to the year of the old date when
|
|
`us_birth_year` is absent, so a child added before the change still shows one
|
|
without a migration step.
|
|
- **Write** — `setBirthYear()` deletes `us_date_of_birth` on *every* save,
|
|
including a save that clears the year. Without that the fallback would
|
|
resurrect the old date on the next read and the year could never be cleared.
|
|
|
|
The upshot is a lazy migration: a child's full date survives until their record
|
|
is next edited, then goes for good. There is no bulk purge — a site that wants
|
|
the remaining old dates gone should delete the `us_date_of_birth` meta directly.
|
|
|
|
v1 is deliberately **one guardian per child**: `GuardianRepository::insert()`
|
|
refuses to link a child that already has a guardian. The unique key and the
|
|
guardian-side lookups already support many-to-many, so adding a second guardian
|
|
(separated parents) later is an insert, not a migration.
|
|
|
|
## Schema changes to existing tables
|
|
|
|
| Table | Change | Why |
|
|
|---|---|---|
|
|
| `us_payments` | `payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0` + `KEY payer_id` | Who owes the money, when that is not the student |
|
|
| `us_credits` | `payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0` + `KEY payer_id` | Which account holds the balance |
|
|
| `us_policy_acceptances` | `accepted_by BIGINT UNSIGNED NOT NULL DEFAULT 0` | Who actually clicked, when that is not the student |
|
|
|
|
All three default to `0`, read back as "same as `student_id`" (see
|
|
`Payment::payerOrStudent()`, `Credit::payerOrStudent()`,
|
|
`PolicyAcceptance::acceptorOrStudent()`), so **an existing row keeps its current
|
|
meaning whatever happens** — a pre-guardian payment is still owed by, and was
|
|
still accepted by, the student it names.
|
|
|
|
The installer additionally backfills them (`PaymentRepository::backfillPayerIds()`,
|
|
`CreditRepository::backfillPayerIds()`, `AcceptanceRepository::backfillAcceptedBy()`,
|
|
run from `Installer::migrateData()`), because the *balance* lookups key on
|
|
`payer_id` directly and an indexed `WHERE payer_id = 5` would not see a legacy row
|
|
still holding `0`. The backfill is idempotent — it only touches rows still at `0` —
|
|
and the `payerOrStudent()` fallbacks remain as the belt to its braces.
|
|
|
|
## Billing: the guardian is the payer, the child is the subject
|
|
|
|
- `us_payments.student_id` keeps naming **the child the lesson was for**, so
|
|
per-child payment reporting is unchanged.
|
|
- `us_payments.payer_id` names **the guardian who owes it**. Payment notices,
|
|
receipts and the Stripe intent all resolve the payer.
|
|
- `us_credits.payer_id` is where a **family balance** lives. A credit from one
|
|
child's cancelled lesson is held by the guardian and can settle a sibling's
|
|
charge; `CreditRepository::availableBalance()` and `consume()` operate on the
|
|
payer.
|
|
- The **billing-method override** (`comp` / `card` / `etransfer`, user meta read
|
|
by `BillingMethodResolver`) resolves against the payer, so comping a family is
|
|
one setting on the guardian rather than one per child.
|
|
|
|
`PaymentService::createForRegistration()` takes the payer id alongside the
|
|
student id; `BookingEndpoint` and `ScheduledBillingRunner` both pass
|
|
`GuardianService::payerFor( $studentId )` — the child's guardian when they have
|
|
one, otherwise the student themselves.
|
|
|
|
Family discounts are **out of scope** for v1 but are not designed out: with the
|
|
payer on both the payment and the credit ledger, a discount rule has a family to
|
|
apply to.
|
|
|
|
## Registration
|
|
|
|
A **"I'm registering as a parent or guardian"** checkbox on the existing
|
|
`[us_student_register]` form (all three signup paths — personal invite, group
|
|
link, self-approval) reveals a repeatable child block. Ticking it requires at
|
|
least one child name.
|
|
|
|
Per child the form collects:
|
|
- **Name** (required)
|
|
- **Birth year** (required, `us_birth_year` meta) — a four-digit year between
|
|
1900 and the current year. `GuardianService::normaliseBirthYear()` is the one
|
|
definition of what counts, shared by the signup form's up-front validation and
|
|
by `createChild()`/`updateChild()` themselves, so a bad year is refused rather
|
|
than quietly discarded and a typo cannot leave a nonsense age on the record.
|
|
- **Every account-scope registration question** (`Registration\Question`,
|
|
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
|
|
practice they describe the student (instrument, level, school). The guardian
|
|
answers them on the child's behalf; the answer row's `student_id` is the child.
|
|
|
|
Name and birth year are marked required in the labels the same way a required
|
|
question is, but the signup form **cannot** lean on the browser to enforce them:
|
|
the child blocks are hidden until the parent/guardian box is ticked, and a
|
|
`required` field inside a hidden container makes the form unsubmittable with no
|
|
control the user can reach to fix. `register.js` therefore puts `required` on
|
|
and takes it off along with the block itself (`[data-us-child-required]`), and
|
|
the server checks regardless — which is what makes the rule hold with
|
|
JavaScript off. The profile screen has no such problem: its forms are always
|
|
visible, so the attribute is static there.
|
|
|
|
Order of operations in `RegistrationPage::handleSubmit()`:
|
|
|
|
1. Validate the guardian's own fields (email, password, policies).
|
|
2. Validate **every** child block — a missing name, a missing or unusable birth
|
|
year, or a missing required per-child answer fails the whole submission
|
|
**before** any user is created, so a half-registered family is never left
|
|
behind. An **entirely empty** block is dropped instead, because the form
|
|
always renders one spare for "add another"; a block with anything at all
|
|
typed into it is kept and reported on, rather than silently discarding what
|
|
the guardian entered.
|
|
3. Create the guardian user.
|
|
4. For each child: create the accountless user, link it, record its answers, and
|
|
record the signup policy acceptances **against the child** with
|
|
`accepted_by = <guardian>`.
|
|
5. Roll back — every child user created so far is deleted and the guardian user
|
|
with them — if any child creation fails, so a partial family never persists.
|
|
|
|
A guardian who does not tick the box registers exactly as before; nothing about
|
|
the single-student flow changes.
|
|
|
|
### Policy acceptance
|
|
|
|
`us_policy_acceptances` records **one row per child** for each signup-scoped
|
|
policy, with:
|
|
|
|
- `student_id` = the child (who the policy binds),
|
|
- `accepted_by` = the guardian (who actually agreed),
|
|
- `registration_type = 'account'`, `registration_id` = the child's user ID.
|
|
|
|
The guardian also gets their own acceptance row (`student_id = accepted_by =
|
|
guardian`) whether or not they book for themselves — they agreed to the terms as
|
|
an account holder. This is the legally meaningful record: "guardian X accepted
|
|
policy version N on behalf of child Y at time T from IP Z".
|
|
|
|
Booking-scope policies are accepted at booking time by whoever is signed in;
|
|
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a
|
|
child.
|
|
|
|
## Managing children
|
|
|
|
`[us_family]` (block: **Profile**) renders the guardian's manage-children screen:
|
|
list the children, add one, edit a name/birth year, remove one.
|
|
|
|
- **Add** creates another accountless child user and links it. Account-scope
|
|
questions are asked here too, so a child added later carries the same
|
|
information as one added at signup.
|
|
- **Edit** updates `display_name` and `us_birth_year`.
|
|
- **Remove** unlinks the child and **deletes the child user**, but only when the
|
|
child has no lessons and no enrolments — a child with history is refused, so
|
|
removing one can never orphan a lesson, payment or credit
|
|
(`GuardianService::removeChild()`). The guardian is told to contact the studio
|
|
instead.
|
|
|
|
Submissions are processed on `template_redirect` (like registration) and
|
|
post/redirect/get back to the page, so a refresh cannot resubmit.
|
|
|
|
## Booking
|
|
|
|
`GET /bookings` returns the lessons of the signed-in user **and of every child
|
|
they are guardian for**, each row carrying `student_id` and `student_name` so
|
|
the list can be grouped by child.
|
|
|
|
`POST /bookings` takes an optional **`student_id`**:
|
|
|
|
- absent or `0` → the current user books for themselves (unchanged),
|
|
- a child's id → the endpoint verifies with `GuardianService::canActFor()` that
|
|
the current user is that child's guardian, and returns `403 forbidden` when
|
|
they are not. **This is the authorisation boundary of the feature**: without
|
|
it any student could book, and bill, against any user id they cared to send.
|
|
|
|
The booking form gains a "Who is this for?" `<select>`, rendered only when the
|
|
account has more than one person on it, so a single-student account's form is
|
|
unchanged.
|
|
|
|
**Children are listed first and the account holder last**
|
|
(`GuardianService::bookableStudents()`). The order is the whole point: a
|
|
guardian's normal case is booking for a child, so the default selection — the
|
|
one a parent gets by not touching the picker at all — is a child, never
|
|
themselves. Booking for the wrong child is a correctable inconvenience; silently
|
|
billing a parent's account for a lesson meant for their kid is the error worth
|
|
designing out. The guardian is still offered, last, so a parent taking lessons
|
|
alongside their children can book for themselves.
|
|
|
|
The list is rendered server-side into `data-students` on the page wrapper and
|
|
read by `assets/js/guardian.js`, which both the booking and group-class scripts
|
|
share.
|
|
|
|
`POST /bookings/<id>/cancel` accepts a cancellation from the lesson's student
|
|
**or** their guardian, subject to the same cancellation cutoff.
|
|
|
|
## Group classes
|
|
|
|
`POST /enrollments` carries the same optional `student_id` and the same
|
|
`canActFor()` check, `GET /enrollments` covers the household, and
|
|
`POST /enrollments/<id>/withdraw` accepts the guardian — group enrolment is the
|
|
other place a family books and pays, so it gets the identical treatment rather
|
|
than being left as a single-student-only path.
|
|
|
|
## Admin
|
|
|
|
- **Students list** gains a **Profile** column: a child links to its
|
|
guardian's detail screen, a guardian lists its children as links. Children are
|
|
listed alongside every other student rather than nested, so nothing about
|
|
finding a student changes.
|
|
- **Student detail** gains a **Profile** panel — the guardian (for a child) or
|
|
the children (for a guardian), each a link to the other's screen — and the
|
|
credit balance shown is the **payer's** balance, labelled with whose it is, so
|
|
an admin looking at a child sees the family balance that will actually settle
|
|
their charges rather than an empty per-child one.
|
|
- Registration answers and policy acceptances on a child's screen show
|
|
"accepted by <guardian>" where the acceptor differs from the student.
|
|
|
|
Creating or attaching a child from wp-admin is **out of scope** for v1; a studio
|
|
admin adds children through the guardian's own family screen or asks the
|
|
guardian to.
|
|
|
|
## Capabilities
|
|
|
|
No new capability. A child user holds the `us_student` role (so every existing
|
|
`student_id` capability check keeps working) but can never sign in
|
|
(`Guardian\ChildLoginGate` blocks `wp_authenticate_user` and forces
|
|
`user_has_cap` to withhold `book_lesson` from a child), so the role grants them
|
|
nothing in practice. Guardians act for children through
|
|
`GuardianService::canActFor()`, checked at every REST and form boundary, rather
|
|
than through a capability.
|
|
|
|
## Instructor view
|
|
|
|
Lesson lists show the student's name. Where that student is a child, the
|
|
instructor also sees the guardian's name and email — the contact they actually
|
|
need — via `GuardianService::contactFor()`.
|
|
|
|
## Implementation
|
|
|
|
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
|
|
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
|
|
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
|
|
`canActFor()`, `payerFor()`, `contactFor()`, removal rules)
|
|
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
|
|
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
|
|
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
|
|
(one question rendered under a caller-supplied input name, so the same
|
|
question can appear once per child without colliding)
|
|
- Front-end script: `assets/js/guardian.js` (the shared picker),
|
|
`assets/js/register.js` (guardian toggle + "add another child")
|
|
- Extended: `Auth\RegistrationPage` (guardian checkbox, child blocks, per-child
|
|
answers/acceptances, rollback), `Booking\BookingEndpoint` and
|
|
`GroupClass\EnrollmentEndpoint` (`student_id` param + guardian
|
|
authorisation, household listings), `Booking\BookingPage`,
|
|
`GroupClass\GroupClassPage`, `Payment\PaymentService`,
|
|
`Payment\PaymentRepository`, `Payment\CreditRepository`,
|
|
`Payment\ScheduledBillingRunner` (one notice per payer),
|
|
`Policy\AcceptanceRepository`, `Registration\RegistrationGate`,
|
|
`Auth\StudentController`, `Installer` (backfills)
|
|
- Schema: `us_guardians`; `us_payments.payer_id`; `us_credits.payer_id`;
|
|
`us_policy_acceptances.accepted_by`
|
|
|
|
## Tests
|
|
|
|
- `tests/Unit/Guardian/GuardianLinkTest.php`
|
|
- `tests/Unit/Guardian/GuardianRepositoryTest.php`
|
|
- `tests/Unit/Guardian/GuardianServiceTest.php`
|
|
- `tests/Unit/Guardian/ChildLoginGateTest.php`
|
|
- `tests/Unit/Guardian/FamilyPageTest.php`
|
|
- `tests/Unit/Auth/RegistrationPageTest.php` (guardian signup path)
|
|
- `tests/Unit/Booking/BookingEndpointTest.php` and
|
|
`tests/Unit/GroupClass/EnrollmentEndpointTest.php` (booking/enrolling for a
|
|
child, and the 403 when the caller is not the guardian)
|
|
- `tests/Unit/Booking/BookingPageTest.php` (children lead the embedded list)
|
|
- `tests/Unit/Payment/PaymentServiceTest.php`, `CreditRepositoryTest.php`,
|
|
`ScheduledBillingRunnerTest.php` (payer, family balance, one notice)
|
|
|
|
## Related
|
|
|
|
`account-registration.md`, `lesson-booking.md`, `payments.md`, `credits.md`,
|
|
`group-classes.md`, `student-administration.md`, `policies.md`,
|
|
`registration-questions.md`.
|