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
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:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user