Demo follow-ups: editable policy name, one-page signup, group classes in upcoming lessons, deletion cleanup
CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m8s
CI / Build Plugin Zip (pull_request) Skipped
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s

Five items from the latest demo pass:

- A policy's title can be edited from the Policies screen. Only the title
  moves; the slug is what the gates resolve policies by, so a rename can
  never detach a policy from acceptances already recorded against it.
- Signup is one page again. The studio's registration questions move from
  a second step behind "Next" onto the main form, in an "About you" panel
  above the students being added, and that panel also asks an adult
  student for their birth year (the same us_birth_year meta a child's
  uses). register.js disables and hides the whole panel for a pure
  guardian, since the questions describe a student.
- The password is re-scored on submit, not only as it is typed. zxcvbn's
  dictionary arrives after page load, so a password typed straight away
  was never scored at all and the first the student heard of it was the
  server rejecting the whole form.
- Group-class sessions appear alongside lessons wherever upcoming lessons
  are listed: the [us_scheduler] panel (students and instructors) and the
  admin student detail page. GroupClass\SessionSchedule derives them from
  Offering::sessionWindows(), the same derivation the billing scan uses.
  They carry kind = 'group_class' and no Cancel action - a session is one
  date in a term, not a booked slot.
- Deleting a user releases what the account was holding: each upcoming
  lesson is cancelled, its slot freed for rebooking, its pending payment
  voided, and active class enrolments cancelled. Past lessons and paid
  history are left alone.

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

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-30 11:45:04 -03:00
co-authored by Claude Opus 5
parent 258468093b
commit cb347ffca0
33 changed files with 1419 additions and 291 deletions
+32 -18
View File
@@ -106,19 +106,33 @@ thresholds reach JavaScript via `wp_localize_script()` from the same constants
the server enforces, so the two cannot drift apart.
The verdict is applied with `setCustomValidity()` on the password field rather
than by disabling a button: the form has up to three submits plus a "Next" that
already gates on `checkValidity()`, and an invalid field stops all of them
without any needing to know why. zxcvbn's dictionary loads asynchronously, so
the gate stays open until it arrives — the server is the check that always runs.
than by disabling a button: an invalid field stops the submit without the button
needing to know why. zxcvbn's dictionary loads asynchronously, so the gate stays
open until it arrives — the server is the check that always runs.
## Registration Questions (signup step two)
The password is also **re-scored on submit**, not only as it is typed. Native
validation has already run by the time the `submit` event fires, so a verdict
reached there stops the submit by hand (`preventDefault()` + `reportValidity()`).
Without that, a password typed in the second before the dictionary arrived was
never scored at all, and the first the person heard of it was the server
rejecting the whole form.
## Registration Questions
When the studio has configured **account-scope** registration questions
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the
registration form becomes two steps: name/email/password/policies first, then the required
questions. This applies to **every** signup path (invite, group link, self-approval).
Required answers are validated before the account is created, and are stored against the new
user (`us_question_answers`, `registration_type = 'account'`). A studio admin reviews them
under **Registration Information** on the student's admin screen.
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), they
are asked on the main form in an **About you** panel — alongside the account
holder's birth year, above the students they are adding, and only when they are a
student themselves (`self` or `both`). This applies to **every** signup path
(invite, group link, self-approval). Required answers are validated before the
account is created, and are stored against the new user (`us_question_answers`,
`registration_type = 'account'`). A studio admin reviews them under **Registration
Information** on the student's admin screen.
The form is one page with one submit. The questions used to be a second step
behind a "Next" button; that put what the studio needs to know about an adult
student on a screen reached only after everything else, and the two-step gate is
what made a weak password reachable — it advanced on a `checkValidity()` that had
not yet scored anything.
## Policy Acceptance Scope
Policies declare **when** they must be accepted via `us_policies.acceptance_scope`:
@@ -201,10 +215,10 @@ No-op when no registration page is set.
- `tests/Unit/Payment/StudioSettingsTest.php`
## Parent/Guardian Signup
The registration form also offers **"I'm registering as a parent or guardian"**,
which reveals a repeatable child block (name, birth year, and the
account-scope questions asked **per child**). Each child becomes a login-less
`us_student` user linked to the guardian, and the signup policies are recorded
once per child with the guardian as the acceptor. Available on every signup path
— personal invite, group link, and self-approval. See
`parent-guardian-accounts.md`.
The registration form asks **"Who are you registering?"** — just myself, on behalf
of one or more students, or both — and the student-bearing choices reveal a
repeatable child block (name, birth year, and the account-scope questions asked
**per child**). Each child becomes a login-less `us_student` user linked to the
guardian, and the signup policies are recorded once per child with the guardian as
the acceptor. Available on every signup path — personal invite, group link, and
self-approval. See `parent-guardian-accounts.md`.
+33
View File
@@ -31,6 +31,37 @@ 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 whose schedule is not fully specified (no time, or no duration) yields no
windows and so contributes no rows — better absent from a dated list than shown at
a time nobody chose.
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
@@ -175,6 +206,7 @@ class becomes enrollable for them — they choose whether to enrol.
- 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\GroupClassController``renderPage` (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.
@@ -193,6 +225,7 @@ class becomes enrollable for them — they choose whether to enrol.
- `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
+9 -1
View File
@@ -126,12 +126,20 @@ active `private_lesson` offerings whose `duration_minutes` matches the slot.
for students; the instructor's for callers with `manage_availability`), each
with the slot's `start_dt`/`end_dt`.
It also returns **upcoming group-class sessions**, sorted in among the lessons by
start time (`GroupClass\SessionSchedule`). A student gets every remaining session
of every class they are enrolled in; an instructor gets every session of the
classes they teach. These rows carry `kind: "group_class"` — a session is a date
in a term rather than a booked slot, so `booking.js` labels it and gives it no
Cancel button. Lesson rows carry no `kind`, and that absence is what marks them
cancellable.
Group classes follow the same registration flow but enrol against an offering of
kind `group_class`; see `group-classes.md`.
## Admin Interface
- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors
- **My Lessons** (`view_own_lessons`): upcoming lessons for the logged-in instructor. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it.
- **My Lessons** (`view_own_lessons`): upcoming lessons — and upcoming sessions of the instructor's own group classes — for the logged-in instructor. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it.
Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week`
query params, same pattern as the availability page, bucketed via
+28 -10
View File
@@ -201,19 +201,36 @@ Per child the form collects:
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.
### What the account holder gives when they are a student
Under `self` and `both` the account holder is a student too, so the **About you**
panel asks them for exactly the same two things every other student gives: their
**birth year** (`us_birth_year`, the same meta key and the same
`normaliseBirthYear()` rule — `GuardianService::setBirthYear()` writes both cases)
and the **account-scope questions**. Both are stored against their own user id.
The panel sits on the main form, above the students, rather than behind a "Next".
The questions used to be a second step, which put what the studio needs to know
about an adult student on a screen they reached only after everything else; now
one page holds one decision each — who you are registering, about you, about
them.
`register.js` takes the whole **About you** fieldset out of play under
`students`, by `disabled` as well as `hidden`: a disabled fieldset is neither
validated nor submitted, so a `required` field cannot block a form on a control
nobody can reach. The students block is toggled the same way, and the server
enforces both rules regardless — which is what makes them hold with JavaScript
off. The profile screen has no such problem: its forms are always visible, so the
attribute is static there.
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.
question is; `[data-us-child-required]` keeps the attribute on the child fields
in step with the block they live in.
Order of operations in `RegistrationPage::handleSubmit()`:
1. Validate the guardian's own fields (email, password, policies).
1. Validate the account holder's own fields (email, password, policies, and —
when they are a student — their birth year and answers).
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
@@ -221,7 +238,8 @@ Order of operations in `RegistrationPage::handleSubmit()`:
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.
3. Create the guardian user, and record `us_guardian_only` and (when they are a
student) their birth year against it.
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>`.
+1
View File
@@ -47,6 +47,7 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa
## Admin Interface
**Policies** in wp-admin (`manage_policies`, studio admin only):
- Create a policy; draft version bodies
- **Rename** the selected policy (`rename_policy`, `PolicyRepository::updateTitle()`). Only the title changes: the slug is the identifier `findBySlug()` and the gates resolve policies by, so renaming can never detach a policy from versions students have already accepted. A blank title, or one longer than `Policy::MAX_TITLE_LENGTH`, is ignored
- View the content of any version (`?page=us-policies&policy_id={id}&version_id={vid}`), whatever its status
- Edit from the viewer: a draft is saved in place; editing a published or archived version instead saves the text as a **new draft version** (the viewer follows to it), so text students have already accepted is never rewritten
- Publish a draft version; view acceptance history per version
+5 -5
View File
@@ -7,8 +7,8 @@ Questions come in two **scopes**:
booking a specific offering; authored per offering by the studio admin or the owning
instructor, and stored against the resulting lesson or group enrolment.
- **Account scope** (`scope = 'account'`) — studio-wide questions every new student answers
**once at account signup**, as a required second step after choosing their name and
password. Authored by the studio admin only, and stored against the new user account.
**once at account signup**, on the same page as their name and password. Authored by the
studio admin only, and stored against the new user account.
Both scopes share the `us_questions` / `us_question_answers` tables, the same field types,
and the same authoring page (**Offerings → Questions**).
@@ -49,9 +49,9 @@ lesson, a group enrolment, or an account signup (`account` + the user ID).
2. Required questions block submission until answered.
3. Answers are sent in the `answers[]` array on `POST /bookings` or `POST /enrollments` and written to `us_question_answers` alongside the new registration row.
## Account-scope Flow (signup step two)
## Account-scope Flow (signup)
1. The `[us_student_register]` page (`Auth\RegistrationPage`) loads active account-scope questions via `QuestionRepository::findByScope('account')`.
2. The form renders as two steps: step one is email/name/password/policies, step two is the questions. `assets/js/register.js` reveals step two behind a "Next" button (progressive enhancement without JS both steps show and the single submit still works). This applies to **every** signup path (invite, group link, self-approval).
2. The form is a single page. The questions sit in an **About you** panel, alongside the account holder's birth year, between the "Who are you registering?" choice and the students being added. `assets/js/register.js` disables and hides that whole panel when the choice is "on behalf of students" — the questions describe a student and a pure guardian is not one — and puts the same questions in every child block instead. Progressive enhancement: without JS every panel shows and the single submit still works. This applies to **every** signup path (invite, group link, self-approval).
3. On submit, required answers are validated **before** the user is created (a missing answer returns an error and creates no account); after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`.
4. A studio admin reviews the answers on the student's admin screen under **Registration Information** (`Auth\StudentHistory::registrationInfo()` lists every account question paired with the student's answer, "—" when unanswered). These rows are excluded from the offering-scope "Intake answers" table.
@@ -77,7 +77,7 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController`
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint` (offering scope only)
- Signup step two: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js`
- Signup form: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js`
- Admin review: `Unsupervised\Schedular\Auth\StudentHistory::registrationInfo()`, `templates/admin/student-detail.php`
- Schema: `us_questions.scope` + nullable `us_questions.offering_id` (requires a plugin version bump so `dbDelta` runs)
- Nullability repair: `dbDelta` does **not** reliably relax a column from `NOT NULL` to `NULL`, so sites created before account-scope questions kept `offering_id NOT NULL` and rejected account inserts. `QuestionRepository::ensureOfferingNullable()` re-applies the nullable definition (idempotent `ALTER … MODIFY`); `Plugin::boot()` runs it once, guarded by the `us_questions_offering_nullable` option rather than the version gate (affected sites may already be on the current version)
+26 -1
View File
@@ -27,6 +27,10 @@ No new tables. The views are composed from existing data:
- **Account** — display name, email, registered date.
- **Upcoming lessons** and **Past lessons** — split by the linked availability
slot's `start_dt`; each shows date/time, offering, instructor, and status.
**Upcoming lessons** also lists the student's upcoming group-class sessions
(`GroupClass\SessionSchedule`, marked "group class"), so one table answers
"what are they booked into next week?". Only upcoming ones: past dates would
bury the lessons, and the enrolment table below already holds the history.
- **Group-class enrolments** — active/past, with offering title and status.
- **Policy acceptances** — every acceptance the student has recorded, newest
first: policy title, version, context (account signup / lesson / enrolment),
@@ -51,7 +55,25 @@ All actions are nonce-protected POSTs handled on the detail page:
voided. A paid lesson is credited back to the student's account (see
`credits.md`) rather than refunded.
- **Withdraw** — on an active group-class enrolment: marked `cancelled` (freeing
its capacity seat), with the same pending-payment voiding.
its capacity seat), with the same pending-payment voiding. This is the only way
to remove a class; the group-class rows in **Upcoming lessons** carry no Cancel
action, because there is no such thing as cancelling one session of a term.
## Deleting a user
Deleting a WordPress user is a core action that knows nothing about lessons, so
`Auth\DeletedUserCleanup` hooks `delete_user` (and `wpmu_delete_user`) and gives
back what the account was holding: every **upcoming** lesson is marked
`cancelled`, its availability slot released for rebooking, and its still-pending
payment voided; every **active** group-class enrolment is cancelled and its
pending payment voided. Without it the slots stayed marked booked and unbookable
by anyone else, the lessons stayed on the instructor's schedule under a name that
no longer resolved, and a class kept a seat filled by nobody.
Past lessons are deliberately untouched: they happened, they may have been paid
for, and the payment report has to keep adding up. No account credit is issued
for a paid lesson either, unlike a cancellation the student asks for — a credit
can only be spent on the account being deleted, so a refund owed to someone who
has left is the studio's decision to make and record.
## Capabilities
- `manage_students` — studio admin (administrators inherit it via the
@@ -73,6 +95,8 @@ All actions are nonce-protected POSTs handled on the detail page:
refuse records that don't belong to the student, and reuse
`Payment\PaymentService::voidPending`) and account updates via
`wp_update_user` (unit-tested with mocked repositories).
- Group-class sessions in the upcoming table: `GroupClass\SessionSchedule::upcomingForStudent()`
- Deletion cleanup: `Auth\DeletedUserCleanup` (hooked in `Plugin::boot()`)
- Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested)
- The upcoming/past split is extracted into a small pure helper so it is
unit-testable (the controller itself follows the repo convention of not being
@@ -80,6 +104,7 @@ All actions are nonce-protected POSTs handled on the detail page:
## Tests
- `tests/Unit/Auth/StudentScheduleTest.php` (the pure upcoming/past split helper)
- `tests/Unit/Auth/DeletedUserCleanupTest.php` (release on user deletion)
- `tests/Unit/Auth/StudentHistoryTest.php` (history display rows + fallbacks)
- `tests/Unit/Auth/StudentActionsTest.php` (cancel/withdraw guards + side
effects, account validation)