17 Commits
Author SHA1 Message Date
thatguygriff 5d98aedfa5 Merge pull request 'Editable policy name, one-page signup with adult birth year, group classes in upcoming lessons, and booking cleanup on user delete' (#161) from feature/demo-followups-2026-07-30 into main
CI / PHPStan (push) Successful in 2m53s
CI / Coding Standards (push) Successful in 3m2s
Release / Build and Publish Release (push) Successful in 2m59s
Release / Open next-version bump PR (push) Successful in 4s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
CI / Build Plugin Zip (push) Successful in 2m46s
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Tests (PHP 8.2) (push) Successful in 51s
CI / No Debug Code (push) Successful in 2s
Reviewed-on: #161
2026-07-30 15:28:12 +00:00
thatguygriffandClaude Opus 5 8fd7bf983d Name people by their name, not their email address
CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / Tests (PHP 8.1) (pull_request) Successful in 59s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / PHPStan (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / No Debug Code (pull_request) Successful in 3s
CI / Build Plugin Zip (pull_request) Skipped
Anywhere the plugin named a person it could show their email instead —
"Managed by [email protected]" in the students table, the same under
Booked by, instructor names on the class pages.

WordPress defaults a new account's `nickname` to its `user_login`, and
signup uses the email address as the login. So every self-registered
account carried its own address as its nickname, and UserName::format()
fell straight through to it. The name they typed was in `display_name`
all along. Accounts created by a guardian were never affected —
GuardianService::createChild() sets `nickname` outright, which is exactly
why children read correctly and their parents did not.

UserName::format() now walks nickname then display name, skipping either
when it is really the login or the email, so existing accounts read
correctly with nothing to migrate. An identifier still never reaches the
screen: an account with nothing but its address on file falls back to the
id, as before. Signup also sets `nickname` at insert, so new accounts are
right at the source rather than relying on the fallback.

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 12:24:04 -03:00
thatguygriffandClaude Opus 5 122f7a0f53 Show "Booked by" in the Account section of a student's detail page
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.1) (pull_request) Successful in 59s
CI / Coding Standards (pull_request) Successful in 3m1s
CI / Build Plugin Zip (pull_request) Skipped
The parent/guardian was only named further down under Profile, where it
reads as background rather than as an account fact, and only when there
was one — so a page with no such line was ambiguous between "books for
themselves" and "the lookup found nothing".

It now sits in the Account table beside display name and email, as the
guardian's name linked to their own detail page, and always renders: a
student who books for themselves says so outright. No email address —
theirs is one click away on their own page, and repeating it here only
makes the row harder to scan. The Profile section keeps only the note
explaining the placeholder email, which is a different point.

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 12:18:42 -03:00
thatguygriffandClaude Opus 5 c9a1205fc0 Never drop an enrolled class from upcoming lessons; delete a guardian's children with them; pin the panel's line spacing
CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m47s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 1m1s
CI / No Debug Code (pull_request) Successful in 3s
Three fixes from testing the branch.

A group class was only listed when its schedule resolved to exact
datetimes, which needs a class time *and* a duration — both optional on
the offering form, and the schedule note exists precisely so a studio can
write "Tuesdays 4:00pm" instead. A class configured that way vanished
from the list, which is the one thing this feature must never do. So
Offering::sessionStarts() splits "when does it meet" from "how long does
it run" (sessionWindows() is that plus the duration, unchanged), and
SessionSchedule degrades instead of disappearing: dated rows with an open
end when there is no duration, and a single row carrying
Offering::scheduleLabel() when there is no time to derive dates from.
Only a class whose last day has passed drops out.

Deleting a guardian now deletes the children linked to them, releasing
each one's lessons and enrolments first. A child account is login-less
and exists only so the guardian has somebody to book for; without the
guardian nobody can reach it, book for it, or be billed for it, so it was
left stranded on the roster still holding slots. A `handled` set makes
the re-entrant delete_user each child deletion fires a no-op, and stops a
circular link recursing.

The upcoming panel never stated its own line-height, so a theme setting
line-height: 0 above it — the usual icon-font reset — was inherited
straight through. Below 1 that produces both reported symptoms at once:
stacked lines overlap, and the status pill's background is shorter than
the text in it. Pinned at the same id-level specificity as the rest.

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 12:10:43 -03:00
thatguygriffandClaude Opus 5 cb347ffca0 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]>
2026-07-30 11:45:04 -03:00
thatguygriff 258468093b Merge pull request 'Bump version to 1.4.1' (#160) from release/bump-1.4.1 into main
CI / PHPStan (push) Successful in 2m47s
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.1) (push) Successful in 48s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
CI / Build Plugin Zip (push) Successful in 2m48s
Reviewed-on: #160
2026-07-30 02:30:31 +00:00
Release Bot 3a4b25a711 Bump version to 1.4.1 and open changelog section 2026-07-30 02:30:20 +00:00
thatguygriff 969d864106 Merge pull request 'Ask who the signup is for, and ask each student the studio's questions' (#159) from feature/145-registering-for-choice into main
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m54s
CI / PHPStan (push) Successful in 2m55s
CI / Tests (PHP 8.3) (push) Successful in 2m44s
CI / Tests (PHP 8.1) (push) Successful in 53s
Release / Build and Publish Release (push) Successful in 3m1s
Release / Open next-version bump PR (push) Successful in 4s
CI / Build Plugin Zip (push) Successful in 2m53s
Reviewed-on: #159
2026-07-30 02:24:52 +00:00
thatguygriffandClaude Opus 5 69179b75c9 Ask the account holder the studio's questions when they are a student too
CI / Tests (PHP 8.2) (pull_request) Successful in 43s
CI / Tests (PHP 8.1) (pull_request) Successful in 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Coding Standards (pull_request) Successful in 3m19s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m47s
CI / Build Plugin Zip (pull_request) Skipped
Under "both" the questions were collected per student only, so someone
registering themselves alongside their children was never asked their own
instrument, level or anything else — despite being able to book lessons. The
account-scope questions describe a student, and under "both" the account
holder is one.

Their answers are recorded against their own user id, not shared with a
child's, and recorded after the children so a rollback that deletes the
account cannot leave answers pointing at a user that no longer exists. A
pure guardian is unchanged: they are not a student, so anything posted for
them is still ignored.

Validation became two passes rather than one so the message can say whose
answers are missing — with one pass, "both" had to blame "each student" for
the account holder's own blank field.

In the form, the two questions turn out to be independent: whether student
blocks are in play, and whether the account holder answers for themselves.
"Both" is the case that needs its own answer to each, so sync() now tracks
them separately, and step two comes back into play under "both".

Verified in a headless browser: 21 checks across all three choices, now
including that "both" enables the account holder's own question panel and
offers Next rather than the early submit.

Closes #146

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 23:15:47 -03:00
thatguygriffandClaude Opus 5 4e5382e259 Ask who the signup is for as a three-way choice
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Replaces the single "I'm registering as a parent or guardian" tick with
"Just myself" / "On behalf of one or more students" / "Both".

Radios, not checkboxes as the feedback put it: the three answers are
mutually exclusive, and "both" only means anything as a third choice
alongside the other two.

The tick could only ever say whether there were children to add. It could
not say whether the account holder was a student, so bookableStudents()
always offered them their own name and any guardian could book themselves a
lesson nobody meant to sell. "On behalf of" now records us_guardian_only and
leaves them out of the picker.

That flag is stored as the negative on purpose. Every account predating this
choice is a bookable student, and absence has to keep meaning exactly that,
or the picker would quietly stop offering people themselves on upgrade.
setGuardianOnly() clears the key rather than writing 0, so "not set" stays
the single spelling of "yes, a student". A guardian-only account with nobody
linked to it is still offered itself — an empty picker is no way to book at
all, and they can put the account right from the profile page.

An unrecognised or absent value reads as "just myself": the choice that
collects the least and grants the least. A missing radio must never be taken
as "register these children".

Bumps to 1.4.0.

The account holder's own questions stay out of play whenever students are
being added, "both" included — asking them there is #146.

Verified the form in a headless browser across all three choices: which
blocks show, which fields carry `required`, whether the account holder's
question panel is disabled, which submit is offered, and that switching back
to "just myself" leaves no hidden required field blocking submit.

Closes #145

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 23:09:47 -03:00
thatguygriff 28046e0fd1 Merge pull request 'Return to a bookable calendar after a booking is confirmed' (#158) from fix/143-return-to-bookable into main
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / No Debug Code (push) Successful in 1s
CI / Build Plugin Zip (push) Successful in 2m49s
CI / Tests (PHP 8.2) (push) Successful in 51s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m59s
CI / Tests (PHP 8.3) (push) Successful in 2m41s
Reviewed-on: #158
2026-07-30 02:02:51 +00:00
thatguygriffandClaude Opus 5 699e479805 Return to a bookable calendar after a booking is confirmed
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
CI / Build Plugin Zip (pull_request) Skipped
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m46s
showConfirmation() hid the slot list and put the confirmation in its place,
which is a dead end: a student wanting a second lesson had nothing to click
and no way back short of reloading the page. Enrolling in a group class did
the same thing.

The confirmation is now a dismissible notice above the calendar. The
calendar is reloaded first — so the slot just taken is already gone and the
upcoming-lessons panel is current — and the notice is shown over it, which
is why loadSlots() had to start returning its promise. "It worked" and "book
another" are the same screen.

The notice clears when dismissed, when another slot's form is opened, and on
any reload of the calendar. group-classes.js gets the identical treatment.

It is built from DOM nodes rather than innerHTML because the message can
carry a studio's e-transfer address, and it is toggled with the `hidden`
attribute rather than an inline display — an inline style would outrank the
stylesheet's display:flex and stack the notice's parts. `hidden` needs the
!important guard for the same reason the upcoming-lessons panel does: the
div{display:block} theme reset outranks the UA sheet.

The slotList/list `display = 'block'` lines went with it. Nothing hides
those any more, so restoring them each load only implied otherwise.

Verified in a headless browser against a stubbed REST API: booking twice in
a row without a reload, the booked slot leaving the calendar, the upcoming
panel updating, dismissal, the notice clearing when the next form opens, and
the notice staying hidden under div{display:block}.

Closes #143

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:57:16 -03:00
thatguygriff 1b42d20541 Merge pull request 'Add an account block showing who is signed in' (#156) from feature/142-account-block into main
CI / Build Plugin Zip (push) Successful in 2m48s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / Tests (PHP 8.1) (push) Successful in 52s
CI / PHPStan (push) Successful in 2m53s
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.3) (push) Successful in 2m44s
CI / No Debug Code (push) Successful in 2s
Reviewed-on: #156
2026-07-30 01:53:31 +00:00
thatguygriffandClaude Opus 5 ab5212282d Show only the name and email, not who the account books for
CI / Tests (PHP 8.2) (pull_request) Successful in 41s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m48s
CI / Coding Standards (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
CI / Build Plugin Zip (pull_request) Skipped
The block reports who is signed in and nothing more. Dropping the "Booking
for …" line takes GuardianService with it — it was the only reason the page
had a dependency at all, so AccountPage now constructs with no arguments.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:42:25 -03:00
thatguygriffandClaude Opus 5 6e3affb1cb Add an account block showing who is signed in
[us_account], or the Account block: the signed-in visitor's name, their
email, a Sign out link, and — only when the account books for someone
besides itself — the students it books for. A parent's first question on
seeing "signed in as Grace" is whether this is the account their children's
lessons are on.

Two decisions worth naming.

Signed out with no login page chosen, the block renders nothing. Its whole
subject is the person signed in, which a stranger is not, and a bare "you
are not signed in" in a site header is noise with no way to act on it. With
a login page chosen it offers a Sign in link instead. The editor preview is
populated regardless, so the block is never an invisible box to the person
placing it.

Signing out returns to the chosen login page, or to the current page when
there is none. A block meant for a header should not also navigate someone
somewhere when they use it; the login page wins when configured, because the
page they were on may well be members-only.

The name comes from UserName::format(), so the block never exposes a
username the way display_name can.

Also brings docs/features/editor-blocks.md back in step: it still described
"four shortcodes" and had never listed the family block.

Closes #142

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:42:25 -03:00
thatguygriff 7875cb1bf7 Merge pull request 'Fix main: two signup fixtures use a now-rejected password' (#157) from fix/green-main into main
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.1) (push) Successful in 57s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m51s
CI / Tests (PHP 8.3) (push) Successful in 2m46s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #157
2026-07-30 01:41:29 +00:00
thatguygriffandClaude Opus 5 04cba9702c Fix main: two signup fixtures use a now-rejected password
CI / Tests (PHP 8.2) (pull_request) Successful in 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m46s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m49s
CI / Build Plugin Zip (pull_request) Skipped
#154 and #155 each passed on their own branch and broke on landing
together. #154 added two guardian-signup tests using 'password123' as
their fixture; #155 then added PasswordPolicy, which rejects exactly
that. Neither branch ever saw the other's change, because #155 was cut
from main before #154 merged.

Both tests now use the same policy-clearing fixture as the rest of the
file. The deliberate 'password123' in the rejected-passwords provider
stays — that one is the point.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:40:51 -03:00
42 changed files with 2740 additions and 384 deletions
+24 -1
View File
@@ -11,7 +11,27 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work.
## [1.3.1]
## [1.4.1]
### Added
- **Group classes now appear in "Your upcoming lessons".** A class is stored as a term rather than as bookable slots, so nothing that listed lessons could ever show one — a student whose whole term was a group class saw an empty schedule, and an instructor teaching one saw nothing on their My Lessons page. Each remaining session of a class you are enrolled in now sorts in among your lessons by date, labelled **group class**; instructors see every session of the classes they teach, one row per session however many students are in it. A session has no Cancel button, because there is no such thing as cancelling one date of a term — withdrawing from the class is still done from the class page.
- A class you are enrolled in shows up **whether or not its schedule is pinned to a clock**. Class time and duration are both optional on the offering form, and the schedule note is there so a studio can simply write "Tuesdays 4:00pm" — so a class with a time but no duration lists its dates and says when each session starts rather than guessing when it ends, and a class with no time at all gets a single row carrying its schedule note (or its term dates) where the time would go. Only a class whose last day has passed drops off the list.
- A student's admin detail page now shows **Booked by** in the Account section — the name of the parent or guardian who books and pays for them, linked to their own page. It was only stated further down under Profile, and only when there was one; the row is now always there, saying in words when a student books for themselves.
- The same group-class sessions now appear in **Upcoming lessons** on a student's admin detail page, so one table answers "what are they booked into next week?". Only upcoming ones — the **Group-class enrolments** table below already holds the history.
- **A policy can be renamed.** The title was fixed at creation, so a typo or a change of wording meant creating a second policy and re-collecting everyone's acceptance. Renaming changes only what students read above the policy text: the slug stays put, so every version already accepted stays attached.
### Fixed
- **People are named by their name again, not their email address.** Anywhere the plugin named a person it could show their email instead — "Managed by grace@example.com" in the students table, the same under **Booked by**, and instructor names on the class pages. WordPress starts a new account's nickname off as its username, and signup uses the email address as the username, so the address became the nickname of every self-registered account; the name they had typed was sitting in the account's display name the whole time. Names are now read from there when the nickname turns out to be an address, so existing accounts read correctly with nothing to fix by hand, and new signups store the name properly in the first place. Students added by a parent were never affected.
- **Deleting a parent now removes the students they booked for.** A managed student account has no login of its own and exists only so its parent has somebody to book for — with the parent gone nobody can reach it, book for it, or be billed for it, so it was left stranded on the roster still holding lesson times. Deleting a parent now releases each of their students' upcoming lessons and enrolments on the same terms as their own, and deletes the accounts. Removing a student from the family screen is unchanged and still refuses one with lessons on record.
- **The upcoming-lessons panel no longer collapses onto itself in some themes.** Rows could render on top of one another and the status badge's colour could stop short of the text inside it. Both came from the same thing: the panel never stated its own line spacing, so a theme setting a line height of zero anywhere above it — a common icon-font reset — was inherited straight through, leaving each line of text taller than the space allotted to it. The panel now sets its own.
- **Deleting a student now gives back what they had booked.** WordPress deletes a user without knowing anything about lessons, so their bookings were left behind: the times stayed marked as booked and nobody else could take them, the lessons stayed on the instructor's schedule under a name that no longer resolved, and a group class kept a seat filled by nobody. Deleting an account now cancels each of its upcoming lessons, frees the time for rebooking, cancels its active class enrolments, and voids any payment still pending on them. Past lessons are left exactly as they are — they happened, and the payment report has to keep adding up. A paid lesson is not credited back: a credit could only be spent on the account being deleted, so a refund owed to someone who has left stays the studio's decision to make.
- **A weak password is now caught before the form is submitted, not after.** The strength meter scores the password as you type, but zxcvbn's dictionary arrives a moment after the page loads — so a password typed straight away was never scored at all, and the first you heard of it was the server rejecting the whole form. The password is now re-scored on submit, so the verdict is always the one your password actually earns.
### Changed
- **Signup is one page again.** The studio's registration questions used to be a second step behind a **Next** button; they are now asked on the main form, in an **About you** panel above the students you are adding. What the studio needs to know about you is part of registering, not a sequel to it — and there is now one submit rather than three.
- **Signup asks an adult student for their birth year**, the same four-digit year already asked of every student being registered on someone else's behalf. It is asked only when you are a student yourself — choosing **on behalf of one or more students** leaves the whole **About you** panel out, since those questions describe a student and in that case you are not one.
## [1.4.0]
### Added
- An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header.
@@ -20,11 +40,14 @@ each change under the current top section as you work.
- Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered.
### Changed
- Signup now asks **"Who are you registering?"** as a three-way choice — **just myself**, **on behalf of one or more students**, or **both** — in place of the single parent/guardian tick. The tick could only ever say "I have children to add"; it could not say whether the account holder was a student themselves, so every account was offered its own name in the **Who is this for?** picker whether or not anyone meant to book them a lesson. Choosing *on behalf of* now leaves the account holder out of that picker. Existing accounts are unaffected and stay bookable, since the flag records only the new "not a student" case.
- The studio's **account-signup questions are now asked of anyone registering as a student**, including someone registering themselves alongside their children. Choosing **both** previously collected the questions per child only, so the account holder's own instrument, level and the rest were never asked for or stored, even though they could book lessons. Their answers are recorded against their own account, and a blank required answer now names them rather than blaming "each student".
- A student's **name and birth year are now required**, marked in the form the same way a required registration question is and enforced on the server whichever way they were submitted. On signup the requirement applies only once the parent/guardian box is ticked, so registering for yourself is unaffected. A student block you have started filling in is now reported back to you rather than silently dropped when the name is missing — only a completely untouched spare block is still ignored.
- Signup and the profile page now ask for a **birth year** rather than a full date of birth — a four-digit year between 1900 and the current year, with anything else discarded rather than stored. Students added before this change keep showing a birth year, derived from the date already on file; that old full date is then dropped the first time the record is saved, so the studio ends up holding only what it now asks for. No bulk purge runs, so a site wanting the remaining old dates gone should clear the `us_date_of_birth` user meta directly.
- The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content.
### Fixed
- **Booking a lesson no longer dead-ends on the confirmation.** The confirmation used to replace the calendar entirely, leaving a student who wanted a second lesson with nothing to click and no way back short of reloading the page. It is now a dismissible notice sitting above a freshly loaded calendar — the slot just taken already gone from it, the upcoming-lessons panel already updated — so "it worked" and "book another" are the same screen. Enrolling in a group class did the same thing and is fixed the same way.
- Upcoming lesson rows no longer render on top of each other. The row's text sits in inline elements that a theme can pull out of normal flow, which dropped the date and time onto the lesson title and the status pill onto the Cancel button; those elements are now pinned into flow alongside the rest of the panel's theme-proofing. The rows held behind **Show all** also stayed visible under the `div { display: block }` reset that many themes still carry, since `[hidden]` is only a browser default — they are now hidden for real.
## [1.3.0]
+96 -5
View File
@@ -104,18 +104,41 @@
* explicit flex rules above, but the text itself still sits in inline elements
* a theme is free to take out of normal flow — an absolutely positioned,
* floated or negatively offset span drops the date/time on top of the title and
* the status pill on top of the Cancel button. Pinning the three properties
* that would have to change keeps the leaves in flow, at the same id-level
* the status pill on top of the Cancel button. Pinning the properties that
* would have to change keeps the leaves in flow, at the same id-level
* specificity the rules above rely on.
*
* `line-height` is pinned for the same reason and is the subtler one, because a
* theme does not have to target this panel to break it — it is inherited, so a
* `line-height: 0` anywhere above (the usual icon-font or sprite reset) reaches
* these elements untouched. Below 1 it produces both halves of the same bug: a
* line box shorter than its glyphs, so stacked lines in the details column
* overlap, and an inline-block pill whose background is shorter than the text
* sitting in it. Nothing here should ever inherit a line-height, so the panel
* states its own.
*/
#us-booking-app .us-my-lesson,
#us-booking-app .us-my-lesson-info,
#us-booking-app .us-my-lesson-actions,
#us-booking-app .us-my-lesson-title,
#us-booking-app .us-my-lesson-when,
#us-booking-app .us-my-lesson-duration,
#us-booking-app .us-my-lesson-who,
#us-booking-app .us-my-lesson-kind,
#us-booking-app .us-lesson-status {
line-height: 1.45;
}
#us-booking-app .us-my-lesson-title,
#us-booking-app .us-my-lesson-when,
#us-booking-app .us-my-lesson-duration,
#us-booking-app .us-my-lesson-who,
#us-booking-app .us-my-lesson-kind,
#us-booking-app .us-lesson-status {
position: static;
float: none;
margin: 0;
vertical-align: middle;
}
/*
@@ -438,15 +461,40 @@
opacity: 0.75;
}
/* Parent/guardian signup: the child blocks revealed by the checkbox. */
.us-guardian {
/*
* Marks a row in the upcoming panel as a group-class session. The list mixes
* one-to-one lessons and classes, and only the class rows have no Cancel button
* — without a label that reads as a missing button rather than a different kind
* of thing.
*/
#us-booking-app .us-my-lesson-kind {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 10px;
background: #eef1f5;
color: #3c434a;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
vertical-align: middle;
}
/*
* The signup form's grouped sections: who you are registering, your own
* details, and the students you are adding. One box style for all three so the
* form reads as a short list of decisions rather than an undifferentiated
* column of fields.
*/
.us-reg-group {
margin: 16px 0;
padding: 12px 14px;
border: 1px solid #ddd;
border-radius: 4px;
}
.us-guardian legend {
.us-reg-group legend {
padding: 0 6px;
font-weight: 600;
}
@@ -572,6 +620,49 @@
margin-top: 8px;
}
/*
* `[hidden]` is a UA-stylesheet rule, so the widespread `div { display: block }`
* theme reset outranks it — the same trap the upcoming-lessons panel hit. An
* author !important is the only way to win, and it has to sit before the
* display rule it guards against.
*/
.us-notice[hidden] {
display: none !important;
}
/*
* The "you're booked" / "you're enrolled" notice. It sits above the calendar
* or class list rather than replacing it, so it needs to read as a banner
* about something that just happened — not as the page's content.
*/
.us-notice {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 8px 16px;
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid #b7dfc0;
border-left-width: 4px;
border-radius: 4px;
background: #f2faf4;
color: #1a5c2a;
}
.us-notice p {
margin: 0;
}
.us-notice-dismiss {
background: transparent;
border: 1px solid currentColor;
border-radius: 4px;
padding: 4px 12px;
color: inherit;
cursor: pointer;
}
/* Shown only in block-editor previews (see BlockPreview). */
.us-editor-note {
font-size: 0.85em;
+92 -16
View File
@@ -335,7 +335,12 @@
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
if (slot) btn.addEventListener('click', () => openRegistration(slot));
if (slot) {
btn.addEventListener('click', () => {
hideConfirmation();
openRegistration(slot);
});
}
});
}
@@ -571,8 +576,11 @@
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
: null))
.then((result) => {
loadMyLessons();
showConfirmation(window.usPayment.message(result));
const message = window.usPayment.message(result);
// Order matters: loadSlots() clears any standing notice, and it
// is what puts the calendar back with the booked slot gone.
return loadSlots().then(() => showConfirmation(message));
})
.catch((err) => showError(err.message));
}
@@ -580,6 +588,9 @@
function lessonStatusLabel(status) {
if (status === 'pending') return 'Pending payment';
if (status === 'confirmed') return 'Confirmed';
// A group-class session carries its enrolment's status, and "active"
// reads as jargon next to "Confirmed".
if (status === 'active') return 'Enrolled';
return status.charAt(0).toUpperCase() + status.slice(1);
}
@@ -595,21 +606,46 @@
return ` <span class="us-my-lesson-who">— ${escHtml(String(l.student_name))}</span>`;
}
// A group-class session is a date in a term, not a booked slot: there is no
// lesson to cancel and no time to release, so it carries no Cancel button.
// Withdrawing from the class is a separate decision, made on the class page.
function isGroupSession(l) {
return l.kind === 'group_class';
}
// When a row meets. A class with no class time set has no clock to put it on,
// so it carries `schedule` — the studio's own wording, or its term dates — and
// that is shown verbatim in place of a date and time. A dated row with no
// duration knows when it starts but not when it ends, and says only that
// rather than inventing a finish.
function lessonWhenHtml(l) {
if (l.schedule) {
return escHtml(String(l.schedule));
}
const when = `${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}`;
return l.end_dt ? `${when}${escHtml(timeOf(l.end_dt))}` : when;
}
function lessonRowHtml(l) {
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
const group = isGroupSession(l);
const title = l.offering_title ? escHtml(String(l.offering_title)) : (group ? 'Group class' : 'Lesson');
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
const badge = group ? ' <span class="us-my-lesson-kind">Group class</span>' : '';
const action = group ? '' : `<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>`;
// The two columns are divs, not spans: as spans the layout only held up
// while the stylesheet's display:flex won, and a theme rule on span
// collapsed the row onto itself.
return `
<div class="us-my-lesson">
<div class="us-my-lesson-info">
<strong class="us-my-lesson-title">${title}${duration}${lessonWhoHtml(l)}</strong>
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
<strong class="us-my-lesson-title">${title}${duration}${badge}${lessonWhoHtml(l)}</strong>
<span class="us-my-lesson-when">${lessonWhenHtml(l)}</span>
</div>
<div class="us-my-lesson-actions">
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
${action}
</div>
</div>`;
}
@@ -626,13 +662,19 @@
const visible = upcoming.slice(0, INITIAL_LESSON_COUNT);
const hidden = upcoming.slice(INITIAL_LESSON_COUNT);
// Named for what the list actually holds now that group-class sessions
// sit in it alongside booked lessons.
const heading = upcoming.some(isGroupSession)
? 'Your upcoming lessons and classes'
: 'Your upcoming lessons';
myLessons.innerHTML = `
<div class="us-my-lessons">
<h3>Your upcoming lessons</h3>
<h3>${heading}</h3>
${visible.map(lessonRowHtml).join('')}
${hidden.length ? `
<div class="us-my-lessons-more" hidden>${hidden.map(lessonRowHtml).join('')}</div>
<button type="button" class="us-show-all-lessons">Show all ${upcoming.length} lessons</button>
<button type="button" class="us-show-all-lessons">Show all ${upcoming.length}</button>
` : ''}
</div>`;
@@ -668,10 +710,43 @@
.catch(() => { myLessons.innerHTML = ''; });
}
/**
* Report a completed booking without taking the calendar away.
*
* This used to hide the slot list and leave the confirmation as the whole
* page, which is a dead end: the student had nothing to click and no way
* back to booking short of reloading. The notice now sits above a freshly
* loaded calendar, so "it worked" and "you can book again" are the same
* screen.
*
* Built from nodes rather than innerHTML because the message can carry a
* studio's e-transfer address.
*/
function showConfirmation(message) {
confirm.textContent = message;
slotList.style.display = 'none';
confirm.style.display = 'block';
confirm.textContent = '';
const text = document.createElement('p');
text.textContent = message;
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'us-notice-dismiss';
dismiss.textContent = 'Dismiss';
dismiss.addEventListener('click', hideConfirmation);
confirm.appendChild(text);
confirm.appendChild(dismiss);
// The `hidden` attribute rather than an inline display, which would
// outrank the stylesheet's `display: flex` and stack the notice's
// parts instead of laying them out in a row.
confirm.hidden = false;
}
function hideConfirmation() {
if (!confirm) return;
confirm.hidden = true;
confirm.textContent = '';
}
// The private-lesson catalog drives both the filter and the registration
@@ -696,16 +771,17 @@
});
}
/** Returns the load, so a caller can act once the calendar is back. */
function loadSlots() {
clearError();
loadMyLessons();
// An upcoming-lessons-only embed has no calendar to fill.
if (!slotList) return;
if (!slotList) return Promise.resolve();
slotList.style.display = 'block';
confirm.style.display = 'none';
Promise.all([apiFetch('availability'), loadCatalog()])
hideConfirmation();
return Promise.all([apiFetch('availability'), loadCatalog()])
.then(([slots]) => {
allSlots = slots;
render();
+46 -8
View File
@@ -173,7 +173,10 @@
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
btn.addEventListener('click', () => openEnrolment(offering));
btn.addEventListener('click', () => {
hideConfirmation();
openEnrolment(offering);
});
});
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
@@ -254,25 +257,60 @@
.then((res) => (res.payment
? window.usPayment.collect('enrollment', res.id, list)
: null))
.then((result) => showConfirmation(window.usPayment.message(result)))
.then((result) => {
const message = window.usPayment.message(result);
// Order matters: loadClasses() clears any standing notice, and
// it is what puts the list back showing the new enrolment.
return loadClasses().then(() => showConfirmation(message));
})
.catch((err) => showError(err.message));
}
/**
* Report a completed enrolment without taking the class list away. Hiding
* the list left the student on a dead-end screen with no way back to
* browsing short of a reload; the notice now sits above a freshly loaded
* list instead. Mirrors booking.js.
*
* Built from nodes rather than innerHTML because the message can carry a
* studio's e-transfer address.
*/
function showConfirmation(message) {
confirm.textContent = message;
list.style.display = 'none';
confirm.style.display = 'block';
confirm.textContent = '';
const text = document.createElement('p');
text.textContent = message;
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'us-notice-dismiss';
dismiss.textContent = 'Dismiss';
dismiss.addEventListener('click', hideConfirmation);
confirm.appendChild(text);
confirm.appendChild(dismiss);
// The `hidden` attribute rather than an inline display, which would
// outrank the stylesheet's `display: flex` and stack the notice's
// parts instead of laying them out in a row.
confirm.hidden = false;
}
function hideConfirmation() {
confirm.hidden = true;
confirm.textContent = '';
}
/** Returns the load, so a caller can act once the list is back. */
function loadClasses() {
clearError();
list.style.display = 'block';
confirm.style.display = 'none';
hideConfirmation();
// The student's own enrolments are fetched alongside the catalog so a
// class they already have an active enrolment in shows its status
// instead of offering to enrol them again (the API would reject the
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
Promise.all([
return Promise.all([
apiFetch('offerings?kind=group_class'),
apiFetch('enrollments'),
])
+70 -89
View File
@@ -4,17 +4,14 @@
* Two independent behaviours, both optional — without JS every panel stays
* visible and the single submit still works:
*
* 1. **Two steps.** When account-signup questions are configured the form
* renders two panels (`[data-step="1"]` account details, `[data-step="2"]`
* the questions) inside a form marked `data-steps="1"`. Step two is hidden
* behind a "Next" button that only advances once step one passes native
* validation.
* 2. **Parent/guardian.** The children section is hidden until the
* parent/guardian box is ticked, and "Add another child" clones the child
* block. Ticking the box also takes the guardian's *own* question panel out
* of play — in guardian mode the questions are asked per child, so the
* server ignores those answers and the browser must not demand them.
* 3. **Password strength.** The password is scored with zxcvbn (via WordPress's
* 1. **Who are you registering?** The student section is hidden until the
* choice is "on behalf of students" or "both", and "Add another student"
* clones the student block. "On behalf of students" *alone* also takes the
* account holder's own **About you** panel out of play — they are not a
* student in that case, so the server ignores their birth year and answers
* and the browser must not demand them. Under "both" they are a student and
* do fill it in.
* 2. **Password strength.** The password is scored with zxcvbn (via WordPress's
* own `wp.passwordStrength`) and a weak one is refused. The server applies
* its own, coarser rule regardless — see `Auth\PasswordPolicy`.
*/
@@ -27,10 +24,15 @@
* Gate the form on password strength.
*
* The verdict is attached to the field with `setCustomValidity()` rather than
* by disabling the submit button: the form has up to three submits (the plain
* one, the guardian-mode early one, and step two's) plus a "Next" that
* already gates on `checkValidity()`, and an invalid field blocks all of them
* at once without any of them having to know why.
* by disabling the submit button: an invalid field blocks the submit without
* the button having to know why.
*
* It is also re-scored on submit, which is the case the input handler alone
* misses. zxcvbn's dictionary arrives after page load, and until it does the
* meter has no opinion and the field is left valid — so a password typed in
* the first second and submitted straight away would otherwise never be
* scored at all, and the first the student heard of it would be the server
* rejecting the whole form.
*/
function enhancePassword(form) {
var field = form.querySelector('#us-reg-pass');
@@ -119,49 +121,17 @@
for (var i = 0; i < sources.length; i++) {
sources[i].addEventListener('change', assess);
}
}
function enhanceSteps(form) {
var step1 = form.querySelector('[data-step="1"]');
var step2 = form.querySelector('[data-step="2"]');
var next = form.querySelector('.us-reg-next');
var back = form.querySelector('.us-reg-back');
// Native validation has already run by the time `submit` fires, so a
// verdict reached here has to stop the submit by hand.
form.addEventListener('submit', function (event) {
assess();
if (!step1 || !step2 || !next) {
return null;
}
function show(step) {
step1.hidden = step !== 1;
step2.hidden = step !== 2;
}
show(1);
next.addEventListener('click', function () {
var fields = step1.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
if (!fields[i].checkValidity()) {
fields[i].reportValidity();
return;
}
if (!field.checkValidity()) {
event.preventDefault();
field.reportValidity();
}
show(2);
});
if (back) {
back.addEventListener('click', function () {
show(1);
});
}
return {
step2: step2,
next: next,
earlySubmit: form.querySelector('.us-reg-submit-early'),
};
}
/**
@@ -197,52 +167,67 @@
}
}
function enhanceGuardian(form, steps) {
var toggle = form.querySelector('#us-is-guardian');
function enhanceGuardian(form) {
var choices = form.querySelectorAll('.us-registering-for');
var children = form.querySelector('#us-children');
var self = form.querySelector('#us-reg-self');
if (!toggle || !children) {
if (!choices.length || !children) {
return;
}
var addButton = children.querySelector('.us-add-child');
var nextIndex = 1;
// The guardian's own question panel is only meaningful when they are
// registering for themselves. Disabling it (rather than hiding it) is what
// stops a `required` question the server will ignore from blocking submit.
function sync() {
children.hidden = !toggle.checked;
/** The selected "who are you registering?" value; 'self' if somehow none is. */
function mode() {
for (var i = 0; i < choices.length; i++) {
if (choices[i].checked) return choices[i].value;
}
return 'self';
}
// Each student's name and birth year are required, but only once the
// block is in play: a `required` field inside a hidden container makes
// the form unsubmittable with no way to reach the offending control, so
// the attribute goes on and comes off with the block itself. The server
// enforces the same rule either way.
/**
* Keep the form in step with the choice.
*
* Two independent questions, which is why "both" needs its own answer to
* each:
*
* - Are student blocks in play? For "students" and "both".
* - Is the account holder a student themselves? For "self" and "both" —
* only then are they asked for their own birth year and answers. A pure
* guardian gives those per student instead.
*
* Each panel is disabled as well as hidden. Disabling is what actually
* settles it: a `required` field inside a hidden container makes the form
* unsubmittable with no way to reach the offending control, and a disabled
* fieldset is neither validated nor submitted. The server enforces the
* same rules either way.
*/
function sync() {
var current = mode();
var wantsStudents = current !== 'self';
var asksSelf = current !== 'students';
children.hidden = !wantsStudents;
children.disabled = !wantsStudents;
// Belt and braces alongside the disabled fieldset, so the required
// state is right if a browser ever renders the block on its own.
var required = children.querySelectorAll('[data-us-child-required]');
for (var r = 0; r < required.length; r++) {
required[r].required = toggle.checked;
required[r].required = wantsStudents;
}
if (!steps) {
return;
}
var fields = steps.step2.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
fields[i].disabled = toggle.checked;
}
// With the questions out of play there is no second step to advance to,
// so "Next" would be a dead end — swap it for the submit.
steps.next.hidden = toggle.checked;
if (steps.earlySubmit) {
steps.earlySubmit.hidden = !toggle.checked;
if (self) {
self.hidden = !asksSelf;
self.disabled = !asksSelf;
}
}
toggle.addEventListener('change', sync);
for (var c = 0; c < choices.length; c++) {
choices[c].addEventListener('change', sync);
}
sync();
if (addButton) {
@@ -266,11 +251,7 @@
var forms = document.querySelectorAll('.us-register-form form');
for (var i = 0; i < forms.length; i++) {
var steps = forms[i].getAttribute('data-steps') === '1'
? enhanceSteps(forms[i])
: null;
enhanceGuardian(forms[i], steps);
enhanceGuardian(forms[i]);
enhancePassword(forms[i]);
}
});
+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`.
+51
View File
@@ -31,6 +31,55 @@ 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 you are enrolled in must never silently vanish from these lists.** Both
the class time and the duration are optional on the offering form, and the
schedule note exists precisely so a studio can write "Tuesdays 4:00pm" rather than
pin the class to a clock. So the schedule degrades instead of disappearing:
| Class has | What the list gets |
|---|---|
| date + time + duration | one dated row per remaining session, with an end time |
| date + time, no duration | one dated row per remaining session, `end_dt` empty — when it starts is worth showing without guessing when it ends |
| no class time | **one** row for the class as a whole, sorted by term start (or by "now" once the term is under way), with `schedule` text from `Offering::scheduleLabel()` — the studio's note, else the term dates, else "Schedule to be confirmed" |
| a term whose last day has passed | nothing |
`schedule` is the tell: non-null means "a class, described in words, not a session
at a known time", and every renderer shows that text in place of a date and time.
An undated row's `start_dt` is a **sort key only** — never displayed.
`Offering::sessionStarts()` is the split that makes this work: it needs only the
date and the time, because knowing *when* a class meets is a separate question
from knowing how long it runs. `sessionWindows()` is that plus the duration, and
still returns nothing without one — availability blocking and per-session billing
need both ends of a window.
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 +224,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 +243,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
+11 -2
View File
@@ -30,7 +30,8 @@ Students register for a private lesson by choosing an offering, picking a time (
8. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
11. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
11. The confirmation is a **dismissible notice above the calendar**, not a screen of its own. The calendar is reloaded first — so the slot just taken is gone and the upcoming-lessons panel is current — and the notice is shown over it. Booking again therefore needs no page reload. The notice clears when it is dismissed, when another slot's booking form is opened, and on any reload of the calendar. `group-classes.js` does the same for enrolments.
12. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
## Lesson-Type Filter
Not every open slot can be booked as every private-lesson type — a slot tied to
@@ -125,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
+76 -14
View File
@@ -140,10 +140,54 @@ 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.
A **"Who are you registering?"** choice on the existing `[us_student_register]`
form (all three signup paths — personal invite, group link, self-approval), as
three radios:
| Choice | `us_registering_for` | Student blocks | Account holder is a student | Answers the studio's questions |
|---|---|---|---|---|
| Just myself | `self` | no | yes | for themselves |
| On behalf of one or more students | `students` | yes | **no** | per student only |
| Both — myself and one or more students | `both` | yes | yes | **per student *and* for themselves** |
The last column follows from the third, and is the whole of it: the
account-scope questions describe a *student* — instrument, level, school — so
they are asked of everyone being registered as one. Under `both` that is each
student **and** the account holder, whose answers are stored against their own
user id, not shared with anyone. Under `students` the account holder is not a
student, so anything posted for them is ignored outright.
Required answers are checked in two passes rather than one, so the error can say
whose are missing: `both` would otherwise have to blame "each student" for the
account holder's own blank field.
Radios rather than checkboxes because the three answers are mutually exclusive:
"both" only means anything as a third choice alongside the other two. Either
student-bearing choice requires at least one student name.
Anything unrecognised — a form posted without the field, an old cached page, a
crafted request — is read as `self`, the choice that collects the least and
grants the least. A missing radio must never be taken as "register these
children".
### The account holder as a student
This replaced a single "I'm registering as a parent or guardian" checkbox, which
could only say *whether there were children to add*. It could not say whether the
**account holder** was a student, so `bookableStudents()` always offered them
their own name and every guardian could book themselves a lesson nobody intended
to sell.
`students` now records `us_guardian_only = 1` and `bookableStudents()` leaves the
account holder out. The flag is stored as the **negative** deliberately: every
account predating the choice is a bookable student, and absence has to keep
meaning exactly that, or the picker would silently stop offering people
themselves on upgrade. `GuardianService::setGuardianOnly()` clears the key rather
than writing `0`, so "not set" stays the one spelling of "yes, a student".
One guard: a guardian-only account with **nobody linked to it** is still offered
itself, because an empty picker is no way to book at all. They can put the
account right from the profile page.
Per child the form collects:
- **Name** (required)
@@ -157,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
@@ -177,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)
+44 -2
View File
@@ -24,9 +24,17 @@ No new tables. The views are composed from existing data:
quick counts (upcoming lessons, active group enrolments). Each row links to the
detail view.
- **Detail** (`?student_id=`):
- **Account** — display name, email, registered date.
- **Account** — display name, email, registered date, and **Booked by**: the
name of the parent/guardian who books and pays for this student, linked to
their own detail page. Always rendered — a student who books for themselves
says so in words, so an empty row can never be mistaken for a lookup that
failed.
- **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 +59,38 @@ 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.
**A guardian takes their children with them.** A child account is login-less and
exists only so the guardian has somebody to book for; without the guardian nobody
can reach it, book for it, or be billed for it, so leaving it behind leaves an
unreachable student on the roster holding slots that will never be used. Each
child's bookings are released on the same terms, the `us_guardians` link row is
deleted, and the account goes. Deleting a child fires `delete_user` again and
re-enters the same handler; a `handled` set of user ids makes that a no-op and
also stops a self-referential or circular link recursing.
(This is a different rule from the family screen's **Remove**, which still refuses
a child with any lesson or enrolment history — that is a guardian tidying up, not
an admin deleting an account, and `GuardianService::removeChild()` is unchanged.)
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 +112,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 +121,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)
+2 -1
View File
@@ -23,6 +23,7 @@ use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -74,7 +75,7 @@ class AdminMenu {
$this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ), $guardians );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->instructorController = new InstructorController();
$this->settings = $settings;
$this->accessSettings = new AccessSettings();
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Guardian\GuardianRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Payment\PaymentService;
/**
* Gives back what a deleted account was holding, and takes the accounts that
* only existed underneath it with it.
*
* WordPress deletes a user without knowing anything about lessons, so a student
* removed from **Users Delete** used to leave their bookings behind: the
* availability 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 group class kept a seat filled by nobody.
*
* So each upcoming booking is cancelled the same way a real cancellation is
* marked cancelled, its slot released, its still-pending payment voided. Past
* lessons are deliberately left alone: they happened, they may have been paid
* for, and the payment report has to keep adding up.
*
* A **guardian** takes their children with them. A child account is login-less
* and exists only so the guardian has somebody to book for; without the
* guardian nobody can reach it, book for it, or be billed for it, so leaving it
* behind leaves an unreachable student on the roster holding slots that will
* never be used. Each child's bookings are released on the same terms, the link
* row goes, and the account is deleted.
*
* No account credit is issued for a paid lesson, unlike a cancellation the
* student asks for. A credit only has value against future billing on the
* account it belongs to, and that account is being deleted; a refund owed to
* someone who has left is a decision for the studio to make and record, not one
* to silently write into a table nobody will read again.
*/
class DeletedUserCleanup {
/**
* Accounts already dealt with this request, so deleting a guardian's child
* which fires `delete_user` again and re-enters this very handler cannot
* loop or redo work. It also makes a self-referential or circular guardian
* link, however it got into the table, terminate rather than recurse.
*
* @var array<int, true>
*/
private array $handled = [];
public function __construct(
private BookingRepository $bookings,
private AvailabilityRepository $availability,
private EnrollmentRepository $enrollments,
private PaymentService $payments,
private GuardianRepository $links,
private GuardianService $guardians,
) {}
public function register(): void {
// `delete_user` fires before the row goes, which is what lets the lookups
// below still find the account's bookings and children. `wpmu_delete_user`
// is the multisite equivalent for a user removed from the network entirely.
add_action( 'delete_user', [ $this, 'releaseBookings' ] );
add_action( 'wpmu_delete_user', [ $this, 'releaseBookings' ] );
}
/**
* Release everything the account had booked ahead of it, then remove any
* children that only existed to be booked for.
*/
public function releaseBookings( int $userId ): void {
if ( $userId <= 0 || isset( $this->handled[ $userId ] ) ) {
return;
}
$this->handled[ $userId ] = true;
$this->release( $userId );
$this->removeChildren( $userId );
}
/**
* Cancel one account's upcoming lessons and active enrolments, freeing the
* slot and voiding the pending payment behind each.
*/
private function release( int $studentId ): void {
// Upcoming and not already cancelled — the only bookings that are still
// holding anything.
foreach ( $this->bookings->findUpcomingForStudent( $studentId ) as $lesson ) {
$this->bookings->updateStatus( (int) $lesson->id, Lesson::STATUS_CANCELLED );
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
}
foreach ( $this->enrollments->findByStudent( $studentId ) as $enrollment ) {
if ( Enrollment::STATUS_ACTIVE !== $enrollment->status ) {
continue;
}
$this->enrollments->updateStatus( (int) $enrollment->id, Enrollment::STATUS_CANCELLED );
$this->payments->voidPending( $enrollment->paymentId );
}
}
/**
* Delete every child linked to a departing guardian, releasing what each was
* holding first. Each child is marked handled *before* it is deleted, so the
* `delete_user` this fires re-enters and returns without redoing the release.
*/
private function removeChildren( int $guardianId ): void {
foreach ( $this->links->findByGuardian( $guardianId ) as $link ) {
$childId = $link->studentId;
if ( $childId <= 0 || $childId === $guardianId || isset( $this->handled[ $childId ] ) ) {
continue;
}
$this->handled[ $childId ] = true;
$this->release( $childId );
$this->links->delete( $guardianId, $childId );
$this->guardians->deleteUser( $childId );
}
}
}
+99 -22
View File
@@ -19,6 +19,15 @@ use Unsupervised\Schedular\Val;
class RegistrationPage {
/** "Who are you registering?": the account holder, and nobody else. */
public const FOR_SELF = 'self';
/** Only other people — the account holder is not a student. */
public const FOR_STUDENTS = 'students';
/** The account holder *and* other people. */
public const FOR_BOTH = 'both';
/** Success signal: an invited student was created and logged in. */
private const RESULT_INVITE = 'invite';
@@ -119,9 +128,10 @@ class RegistrationPage {
// gate, so it needs the plugin stylesheet that formats it.
wp_enqueue_style( 'us-scheduler' );
// The script drives both the second step and the parent/guardian section
// (revealing it, and cloning the child block for "add another"), so it is
// needed whenever the form itself is on screen.
// The script drives the parent/guardian section (revealing it, taking the
// account holder's own panel out of play, and cloning the child block for
// "add another") and the password meter, so it is needed whenever the form
// itself is on screen.
if ( $canRegister && '' === $successType ) {
wp_enqueue_script( 'us-scheduler-register' );
@@ -303,17 +313,29 @@ class RegistrationPage {
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
// Registering as a parent/guardian turns the account-signup questions from
// "about you" into "about each child" — they describe the student
// (instrument, level, school), not the person holding the account.
$isGuardian = $this->submittedIsGuardian();
$children = $isGuardian ? $this->submittedChildren() : [];
$answers = $isGuardian ? [] : $this->submittedAnswers();
// The account-signup questions describe a *student* — instrument, level,
// school — not whoever holds the account. So they are asked of each
// student being added, and of the account holder only when they are a
// student themselves. "Both" is both.
$registeringFor = $this->submittedRegisteringFor();
// "Students" and "both" collect student blocks; only "self" does not.
$isGuardian = self::FOR_SELF !== $registeringFor;
// "Self" and "both" make the account holder a student, so they answer the
// questions in their own right. Only a pure guardian does not.
$asksSelf = self::FOR_STUDENTS !== $registeringFor;
$children = $isGuardian ? $this->submittedChildren() : [];
$answers = $asksSelf ? $this->submittedAnswers() : [];
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by the caller.
$birthYear = $asksSelf ? trim( sanitize_text_field( Val::string( wp_unslash( $_POST['birth_year'] ?? '' ) ) ) ) : '';
// Everything is validated before a single user is created, so a bad child
// block never leaves a half-registered family behind.
if ( $isGuardian && [] === $children ) {
return esc_html__( 'Please add at least one student, or uncheck the parent/guardian option.', 'unsupervised-schedular' );
return esc_html__( 'Please add at least one student, or choose "Just myself" instead.', 'unsupervised-schedular' );
}
// Name and birth year are required per student, and are checked here for
@@ -330,26 +352,46 @@ class RegistrationPage {
}
}
foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) {
foreach ( $accountQuestions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) {
return $isGuardian
? esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' )
: esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
}
// Checked as two passes rather than one so the message can say *whose*
// answers are missing — under "both" a single message could not.
foreach ( array_column( $children, 'answers' ) as $set ) {
if ( $this->hasUnansweredRequired( $accountQuestions, $set ) ) {
return esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' );
}
}
// The account holder is a student too under "self" and "both", so the same
// birth year every other student gives is asked of them — and checked
// here rather than left to the browser, for the same reason as the
// children's: the panel is hidden for a pure guardian, so `required`
// alone cannot be trusted to have applied.
if ( $asksSelf && 0 === GuardianService::normaliseBirthYear( $birthYear ) ) {
return esc_html( GuardianService::ownBirthYearError() );
}
if ( $asksSelf && $this->hasUnansweredRequired( $accountQuestions, $answers ) ) {
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
}
if ( email_exists( $email ) ) {
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
}
// Nickname as well as display name. WordPress defaults nickname to
// `user_login`, which here is the email address — so without this the
// account's own address became its nickname, and every screen that names
// a person through `UserName` showed the address instead of the name they
// had just typed. `UserName` copes with the accounts already created that
// way; this stops any more of them.
$name = '' !== $displayName ? $displayName : $email;
$userId = wp_insert_user(
[
'user_login' => $email,
'user_email' => $email,
'user_pass' => $password,
'display_name' => '' !== $displayName ? $displayName : $email,
'display_name' => $name,
'nickname' => $name,
'role' => $inviteValid ? $invite->role : RoleManager::STUDENT,
]
);
@@ -360,12 +402,24 @@ class RegistrationPage {
$this->recordAcceptances( $policyForms, (int) $userId, (int) $userId );
// Only "students" means the account holder is not a student themselves;
// "both" registers them alongside the people they book for.
$this->guardians->setGuardianOnly( (int) $userId, self::FOR_STUDENTS === $registeringFor );
if ( $asksSelf ) {
$this->guardians->setBirthYear( (int) $userId, $birthYear );
}
if ( $isGuardian ) {
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
if ( '' !== $failure ) {
return $failure;
}
} else {
}
// After the children, so a rollback that deletes this account cannot
// leave its answers behind pointing at a user that no longer exists.
if ( $asksSelf ) {
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
}
@@ -508,12 +562,35 @@ class RegistrationPage {
}
/**
* Whether the "I'm registering as a parent or guardian" box was ticked.
* Whether any required question in `$questions` is left blank in `$answers`.
*
* @param list<Question> $questions
* @param array<int, string> $answers
*/
private function submittedIsGuardian(): bool {
private function hasUnansweredRequired( array $questions, array $answers ): bool {
foreach ( $questions as $question ) {
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
return true;
}
}
return false;
}
/**
* Who this signup is for: {@see FOR_SELF}, {@see FOR_STUDENTS} or
* {@see FOR_BOTH}.
*
* Anything unrecognised including a form posted without the field at all
* falls back to "just myself", the choice that collects the least and grants
* the least. A missing radio must not be read as "register these children".
*/
private function submittedRegisteringFor(): string {
// The submit nonce is verified by the caller before this runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing
return '1' === sanitize_text_field( Val::string( wp_unslash( $_POST['us_is_guardian'] ?? '' ) ) );
$value = sanitize_key( Val::string( wp_unslash( $_POST['us_registering_for'] ?? '' ) ) );
return in_array( $value, [ self::FOR_STUDENTS, self::FOR_BOTH ], true ) ? $value : self::FOR_SELF;
}
/**
+43 -1
View File
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
@@ -25,6 +26,7 @@ class StudentController {
private StudentHistory $history,
private StudentActions $actions,
private GuardianService $guardians,
private SessionSchedule $sessions,
) {}
public function renderPage(): void {
@@ -134,7 +136,15 @@ class StudentController {
$this->bookings->findByStudent( (int) $student->ID )
);
$schedule = StudentSchedule::partition( $rows, $now );
// Group classes join the upcoming table so "what is this student booked
// into next week?" has one answer instead of two. Only their upcoming
// sessions are added: the enrolment table below already records the whole
// history, and a term's worth of past dates would bury the lessons under
// "Past lessons".
$schedule = StudentSchedule::partition(
array_merge( $rows, $this->groupSessionRows( (int) $student->ID, $now ) ),
$now
);
$upcoming = $schedule['upcoming'];
$past = $schedule['past'];
@@ -194,6 +204,8 @@ class StudentController {
return [
'id' => (int) $lesson->id,
'kind' => 'lesson',
'schedule' => null,
'start_dt' => $slot ? $slot->startDt : '',
'end_dt' => $slot ? $slot->endDt : '',
'offering' => $offering ? $offering->title : '—',
@@ -201,4 +213,34 @@ class StudentController {
'status' => $lesson->status,
];
}
/**
* The student's upcoming group-class sessions, shaped like the lesson rows
* they sit beside. `kind` is what keeps the table honest: a session is a date
* in a term, not a booked slot, so the row offers no "Cancel" withdrawing
* is done from the enrolment table, which removes the whole class at once.
*
* @return list<array<string, mixed>>
*/
private function groupSessionRows( int $studentId, string $now ): array {
return array_map(
static function ( array $session ): array {
$instructor = get_userdata( $session['instructor_id'] );
return [
'id' => $session['enrollment_id'],
'kind' => SessionSchedule::KIND,
'start_dt' => $session['start_dt'],
'end_dt' => $session['end_dt'],
// Set when the class has no time to put on a clock; shown in the
// When column in place of a date. See GroupClass\SessionSchedule.
'schedule' => $session['schedule'],
'offering' => $session['offering_title'],
'instructor' => $instructor ? $instructor->display_name : (string) $session['instructor_id'],
'status' => $session['status'],
];
},
$this->sessions->upcomingForStudent( $studentId, $now )
);
}
}
+32 -8
View File
@@ -5,15 +5,25 @@ namespace Unsupervised\Schedular\Auth;
/**
* Resolves a person's public-facing name for display. Prefers their real name
* (first + last), then their nickname deliberately avoiding the account's
* login/username, which `display_name` can otherwise expose.
* (first + last), then their nickname, then the display name skipping any of
* them that is really the account's login or email address, which is the thing
* this class exists to keep off the screen.
*/
class UserName {
/**
* The display name for a user: "First Last" when a real name is set,
* otherwise the WordPress nickname. Falls back to the numeric id (or an empty
* string when none is given) when the user cannot be loaded or has no name.
* The display name for a user: "First Last" when a real name is set, else the
* first of nickname / display name that is an actual name. Falls back to the
* numeric id (or an empty string when none is given) when the user cannot be
* loaded or has nothing but identifiers on file.
*
* Display name is consulted at all because WordPress defaults **nickname** to
* `user_login`, and signup uses the email address as the login so a
* self-registered account carries its own email as its nickname, and every
* screen naming that person showed the address instead. The name they typed
* was on file the whole time, in `display_name`. (Accounts created by a
* guardian never hit this: `GuardianService::createChild()` sets `nickname`
* outright, which is why children read correctly and their parents did not.)
*/
public static function format( ?\WP_User $user, int $fallbackId = 0 ): string {
if ( ! $user instanceof \WP_User ) {
@@ -25,11 +35,25 @@ class UserName {
return $full;
}
$nickname = trim( $user->nickname );
if ( '' !== $nickname ) {
return $nickname;
foreach ( [ $user->nickname, $user->display_name ] as $candidate ) {
$candidate = trim( (string) $candidate );
if ( '' !== $candidate && ! self::isIdentifier( $candidate, $user ) ) {
return $candidate;
}
}
return $fallbackId > 0 ? (string) $fallbackId : '';
}
/**
* Whether a candidate name is really the account's login or email address
* wearing a name's clothing the case this class must never pass through.
*/
private static function isIdentifier( string $candidate, \WP_User $user ): bool {
$candidate = strtolower( $candidate );
return strtolower( (string) $user->user_login ) === $candidate
|| strtolower( (string) $user->user_email ) === $candidate;
}
}
+37 -3
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -30,6 +31,7 @@ class BookingEndpoint {
private PaymentService $payments,
private CancellationPolicy $cancellationPolicy,
private GuardianService $guardians,
private SessionSchedule $sessions,
) {}
/**
@@ -124,20 +126,35 @@ class BookingEndpoint {
public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$userId = get_current_user_id();
$now = current_time( 'mysql' );
// Group classes are listed here too. A term-based class has no row in
// us_availability, so nothing that only read lessons could show one, and a
// student whose whole week was a group class saw an empty schedule.
if ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
$lessons = $this->bookings->findUpcomingForInstructor( $userId );
// One row per session the instructor teaches, not per student in it.
$sessions = array_map(
static fn( array $session ): array => $session + [ 'kind' => SessionSchedule::KIND ],
$this->sessions->upcomingForInstructor( $userId, $now )
);
} else {
// A guardian's list covers the whole household — their own lessons and
// every child's — merged and re-sorted so the soonest is first
// regardless of whose it is.
$lessons = [];
$lessons = [];
$sessions = [];
foreach ( $this->guardians->householdIds( $userId ) as $studentId ) {
$lessons = array_merge( $lessons, $this->bookings->findUpcomingForStudent( $studentId ) );
$lessons = array_merge( $lessons, $this->bookings->findUpcomingForStudent( $studentId ) );
$sessions = array_merge( $sessions, $this->sessionRows( $studentId, $now ) );
}
}
$rows = array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons );
$rows = array_merge(
array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ),
$sessions
);
// usort reindexes in place, so the response is already a list.
usort( $rows, static fn( array $a, array $b ): int => Val::string( $a['start_dt'] ?? '' ) <=> Val::string( $b['start_dt'] ?? '' ) );
@@ -145,6 +162,23 @@ class BookingEndpoint {
return new \WP_REST_Response( $rows, 200 );
}
/**
* One student's upcoming group-class sessions, shaped like the lesson rows
* beside them so a single list renders both. `kind` is what tells them apart:
* a session is not a booked slot, so it carries no cancel action.
*
* @return list<array<string, mixed>>
*/
private function sessionRows( int $studentId, string $now ): array {
return array_map(
fn( array $session ): array => $session + [
'kind' => SessionSchedule::KIND,
'student_name' => $this->guardians->studentName( $studentId ),
],
$this->sessions->upcomingForStudent( $studentId, $now )
);
}
/**
* A lesson's array form plus its slot's start/end times and the booked
* offering's name, so front-end lists can show what the session is and when
+214
View File
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
/**
* Turns group-class enrolments into dated sessions, so a class can appear
* alongside one-to-one lessons in every "upcoming" view.
*
* A group class is stored as a term (`term_start`, `term_end`, `class_time`)
* rather than as rows in `us_availability`, which is why an enrolment on its own
* has no date on it and why nothing that listed lessons ever showed one. The
* dates come from {@see Offering::sessionStarts()} the same derivation the
* billing scan and the class-slot reconciler build on, so a student's list, an
* instructor's list and the invoice all agree on when the class meets.
*
* **A class you are enrolled in must never silently vanish from the list.** Both
* the class time and the duration are optional on the offering form, and the
* schedule note exists precisely so a studio can write "Tuesdays 4:00pm" instead
* of pinning the class to a clock. So the schedule degrades rather than
* disappearing:
*
* - date **and** time set one dated row per remaining session, closed off with
* the duration when there is one and left open-ended when there is not;
* - no time to derive dates from a single row for the class as a whole, sorted
* by when the term starts and labelled with `schedule` text
* ({@see Offering::scheduleLabel()}) in place of a time.
*
* A row's `schedule` is the tell: non-null means "this is a class, described in
* words, not a session at a known time", and every renderer shows that text
* instead of a date and time.
*/
class SessionSchedule {
/**
* Marks a row as a group-class session rather than a one-to-one lesson.
* Callers use it to withhold the per-lesson actions (cancel, detail links)
* that only mean something for a booked slot.
*/
public const KIND = 'group_class';
public function __construct(
private EnrollmentRepository $enrollments,
private OfferingRepository $offerings,
) {}
/**
* Upcoming sessions of every class a student is enrolled in, soonest first.
*
* A withdrawn (cancelled) enrolment contributes nothing; a completed one is
* kept, since "completed" describes the enrolment's billing state and says
* nothing about whether the class has met yet.
*
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
*/
public function upcomingForStudent( int $studentId, string $now ): array {
$rows = [];
foreach ( $this->enrollments->findByStudent( $studentId ) as $enrollment ) {
if ( Enrollment::STATUS_CANCELLED === $enrollment->status ) {
continue;
}
$offering = $this->offerings->findById( $enrollment->offeringId );
if ( null === $offering ) {
continue;
}
$rows = array_merge(
$rows,
$this->rowsFor( $offering, $now, (int) $enrollment->id, $enrollment->instructorId, $enrollment->status )
);
}
return self::sortedByStart( $rows );
}
/**
* Upcoming sessions of every active group class an instructor teaches,
* soonest first one row per session, not per enrolled student. Enrolments
* are not consulted at all: a class the instructor has to turn up and teach
* belongs on their schedule whether or not anyone has signed up yet.
*
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
*/
public function upcomingForInstructor( int $instructorId, string $now ): array {
$rows = [];
$classes = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS, activeOnly: true );
foreach ( $classes as $offering ) {
$rows = array_merge( $rows, $this->rowsFor( $offering, $now, 0, $instructorId, Enrollment::STATUS_ACTIVE ) );
}
return self::sortedByStart( $rows );
}
/**
* One class's contribution to an upcoming list: its remaining dated sessions,
* or when it has no time to derive dates from a single row describing the
* class in words. Empty only when the class has demonstrably finished.
*
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
*/
private function rowsFor( Offering $offering, string $now, int $enrollmentId, int $instructorId, string $status ): array {
$base = [
'enrollment_id' => $enrollmentId,
'offering_id' => (int) $offering->id,
'offering_title' => $offering->title,
'instructor_id' => $instructorId,
'status' => $status,
'duration_minutes' => $offering->durationMinutes,
];
$starts = $offering->sessionStarts();
// Dated: the class says exactly when it meets, so list what is left of it
// — and nothing at all once the term is over.
if ( [] !== $starts ) {
$rows = [];
foreach ( $starts as $start ) {
if ( $start < $now ) {
continue;
}
$rows[] = $base + [
'start_dt' => $start,
// Left open when no duration is set. Knowing a class starts at
// four o'clock is worth showing even without knowing when it
// ends; guessing an end time is not.
'end_dt' => $this->endOf( $offering, $start ),
'schedule' => null,
];
}
return $rows;
}
// Undated: no class time, so there is nothing to put on a clock. The class
// still gets a row — it is enrolled in and running — described by the
// studio's own schedule note or its term dates.
if ( ! $this->isStillRunning( $offering, $now ) ) {
return [];
}
return [
$base + [
// A sort key, not a claim about when the class meets: a class yet to
// start sorts to its first day, one already under way to right now.
// `schedule` is what any renderer actually shows.
'start_dt' => $this->sortKeyFor( $offering, $now ),
'end_dt' => '',
'schedule' => $offering->scheduleLabel(),
],
];
}
/**
* When a session that starts at `$start` finishes, or an empty string when the
* class has no duration to close it off with.
*/
private function endOf( Offering $offering, string $start ): string {
if ( null === $offering->durationMinutes || $offering->durationMinutes <= 0 ) {
return '';
}
return ( new \DateTimeImmutable( $start ) )
->add( new \DateInterval( 'PT' . $offering->durationMinutes . 'M' ) )
->format( 'Y-m-d H:i:s' );
}
/**
* Whether an undated class still has life in it: its last day has not passed,
* or it has no dates at all (in which case nothing says it has ended, and
* dropping it would be the very disappearance this class exists to prevent).
*/
private function isStillRunning( Offering $offering, string $now ): bool {
$lastDay = $offering->lastClassDay();
return null === $lastDay || $lastDay >= substr( $now, 0, 10 );
}
/**
* Where an undated class sits in a list ordered by time: at its first day when
* that is still ahead, otherwise at `$now`, so a term already under way reads
* as current rather than as ancient history.
*/
private function sortKeyFor( Offering $offering, string $now ): string {
if ( null === $offering->termStart ) {
return $now;
}
$firstDay = $offering->termStart . ' 00:00:00';
return $firstDay > $now ? $firstDay : $now;
}
/**
* Soonest session first, so classes from separate enrolments interleave by
* date rather than arriving grouped by class.
*
* @param list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}> $rows
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
*/
private static function sortedByStart( array $rows ): array {
usort( $rows, static fn( array $a, array $b ): int => strcmp( $a['start_dt'], $b['start_dt'] ) );
return $rows;
}
}
+61 -3
View File
@@ -34,6 +34,17 @@ class GuardianService {
*/
public const META_DOB = 'us_date_of_birth';
/**
* Set on an account that registered **only** to book for other people, so it
* is not offered as a student in its own right.
*
* Stored as the negative on purpose. Every account that existed before this
* choice was offered is a bookable student, and absence of the flag has to
* keep meaning exactly that otherwise the picker would quietly stop
* offering people themselves on upgrade.
*/
public const META_GUARDIAN_ONLY = 'us_guardian_only';
/**
* The earliest birth year the form will accept. Old enough for any student a
* studio will ever enrol, and late enough to reject a typo like `19` or `190`
@@ -228,7 +239,9 @@ class GuardianService {
* 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 from the same account.
* their children can book for themselves from the same account unless they
* said at signup that they are not a student, in which case offering them is
* an invitation to book a lesson nobody meant to buy.
*
* @return list<array{id: int, name: string, is_self: bool}>
*/
@@ -243,6 +256,13 @@ class GuardianService {
];
}
// A guardian-only account with nobody linked to it would otherwise get an
// empty list and no way to book at all. Offering them themselves is the
// lesser wrong: they can still correct the account from the profile page.
if ( self::isGuardianOnly( $userId ) && [] !== $out ) {
return $out;
}
$self = get_userdata( $userId );
$out[] = [
@@ -254,6 +274,28 @@ class GuardianService {
return $out;
}
/**
* Whether this account books only for other people. False for every account
* that predates the choice see {@see META_GUARDIAN_ONLY}.
*/
public static function isGuardianOnly( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_GUARDIAN_ONLY, true ) );
}
/**
* Record whether this account is a student in its own right. Clears the flag
* rather than storing a `0`, so "not set" stays the single meaning of "yes,
* they are a student".
*/
public function setGuardianOnly( int $userId, bool $guardianOnly ): void {
if ( $guardianOnly ) {
update_user_meta( $userId, self::META_GUARDIAN_ONLY, '1' );
return;
}
delete_user_meta( $userId, self::META_GUARDIAN_ONLY );
}
/**
* A guardian's children, in link order, with the details the family and admin
* screens display.
@@ -350,14 +392,17 @@ class GuardianService {
}
/**
* Store a child's birth year, or clear it when blank or out of range.
* Store a student's birth year, or clear it when blank or out of range. Used
* for a child added by their guardian and for an account holder who is a
* student in their own right the same fact about the same kind of person,
* so the same meta key holds both.
*
* Either way the legacy full date of birth goes with it. That is what makes
* the read fallback in {@see birthYear()} safe: without it, clearing the year
* on a child who predates this change would leave the old date behind for the
* fallback to resurrect on the very next read.
*/
private function setBirthYear( int $userId, string $birthYear ): void {
public function setBirthYear( int $userId, string $birthYear ): void {
delete_user_meta( $userId, self::META_DOB );
$year = self::normaliseBirthYear( $birthYear );
@@ -407,6 +452,19 @@ class GuardianService {
);
}
/**
* The same message for the account holder's own birth year. Separate wording
* because "each student" is nobody when the student in question is the person
* reading it.
*/
public static function ownBirthYearError(): string {
return sprintf(
/* translators: %d: the earliest birth year the form accepts. */
__( 'Please give your birth year, as four digits from %d onwards.', 'unsupervised-schedular' ),
self::MIN_BIRTH_YEAR
);
}
/**
* A child's birth year, or an empty string when none is recorded.
*
+78 -21
View File
@@ -175,22 +175,19 @@ class Offering {
}
/**
* The concrete start/end datetimes of every session of this group class,
* derived from the class date(s), the class time, and the duration. A weekly
* class yields one window per week from `term_start` through `term_end`; a
* one-off class yields a single window. Returns an empty list unless the
* schedule is fully specified (date, time, and a positive duration), so it can
* never fabricate a session window from partial data.
* The datetime each session of this group class starts, derived from the class
* date(s) and the class time. A weekly class yields one per week from
* `term_start` through `term_end`; a one-off class yields a single one.
*
* @return list<array{start: string, end: string}>
* Deliberately does **not** need a duration: knowing *when* a class meets is a
* separate question from knowing how long it runs, and a studio can quite
* reasonably set the first without the second. Returns an empty list when
* there is no date or no time, since neither can be invented.
*
* @return list<string> `Y-m-d H:i:s` starts, earliest first.
*/
public function sessionWindows(): array {
if (
null === $this->termStart
|| null === $this->classTime
|| null === $this->durationMinutes
|| $this->durationMinutes <= 0
) {
public function sessionStarts(): array {
if ( null === $this->termStart || null === $this->classTime ) {
return [];
}
@@ -200,25 +197,85 @@ class Offering {
}
$lastDay = null !== $this->termEnd ? $this->termEnd : $this->termStart;
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
$windows = [];
$starts = [];
$cursor = $first;
$cursorDay = $cursor->format( 'Y-m-d' );
// Cap the walk at ten years of weeks so a term_end before term_start (or a
// bad value) can never spin into an unbounded loop.
for ( $i = 0; $i < 520 && $cursorDay <= $lastDay; $i++ ) {
$windows[] = [
'start' => $cursor->format( 'Y-m-d H:i:s' ),
'end' => $cursor->add( $step )->format( 'Y-m-d H:i:s' ),
];
$starts[] = $cursor->format( 'Y-m-d H:i:s' );
$cursor = $cursor->modify( '+7 days' );
$cursorDay = $cursor->format( 'Y-m-d' );
}
return $windows;
return $starts;
}
/**
* The concrete start/end datetimes of every session of this group class:
* {@see sessionStarts()} closed off with the class duration. Returns an empty
* list unless the schedule is fully specified (date, time, *and* a positive
* duration), so it can never fabricate a session window from partial data
* callers that block availability or bill per session need both ends.
*
* @return list<array{start: string, end: string}>
*/
public function sessionWindows(): array {
if ( null === $this->durationMinutes || $this->durationMinutes <= 0 ) {
return [];
}
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
return array_map(
static fn( string $start ): array => [
'start' => $start,
'end' => ( new \DateTimeImmutable( $start ) )->add( $step )->format( 'Y-m-d H:i:s' ),
],
$this->sessionStarts()
);
}
/**
* The last day this class meets, or null when it has no dates at all.
*/
public function lastClassDay(): ?string {
return $this->termEnd ?? $this->termStart;
}
/**
* Plain-language wording for when this class meets, for the places that have
* to say something about a class whose schedule cannot be resolved to dates.
* Prefers the studio's own note ("Tuesdays 4:00pm") that field exists
* precisely so a class can describe its schedule without pinning it to a
* time then the term dates, and finally an honest admission that nothing
* has been set.
*/
public function scheduleLabel(): string {
$note = null !== $this->scheduleNote ? trim( $this->scheduleNote ) : '';
if ( '' !== $note ) {
return $note;
}
if ( null === $this->termStart ) {
return __( 'Schedule to be confirmed', 'unsupervised-schedular' );
}
$start = (string) mysql2date( 'M j, Y', $this->termStart );
if ( null === $this->termEnd || $this->termEnd === $this->termStart ) {
return $start;
}
return sprintf(
/* translators: 1: first class date, 2: last class date. */
__( '%1$s %2$s', 'unsupervised-schedular' ),
$start,
(string) mysql2date( 'M j, Y', $this->termEnd )
);
}
public static function fromRow( \stdClass $row ): self {
+2
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\DeletedUserCleanup;
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\AccountPage;
@@ -109,6 +110,7 @@ class Plugin {
( new RegistrationLoginGate() )->register();
( new ChildLoginGate() )->register();
( new StudentAdminGuard() )->register();
( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService, $guardianRepo, $guardians ) )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
+19
View File
@@ -76,6 +76,25 @@ class PolicyController {
return [ '', 0 ];
}
if ( 'rename_policy' === $action ) {
$title = trim( sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) ) );
if ( '' === $title || mb_strlen( $title ) > Policy::MAX_TITLE_LENGTH ) {
return [ '', 0 ];
}
$this->policies->updateTitle( $policyId, $title );
return [
sprintf(
/* translators: %s: the policy's new title. */
__( 'Policy renamed to "%s".', 'unsupervised-schedular' ),
$title
),
0,
];
}
if ( 'add_version' === $action ) {
$body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) );
$this->service->addDraftVersion( $policyId, $body );
+16
View File
@@ -46,6 +46,22 @@ class PolicyRepository {
return array_map( Policy::fromRow( ... ), $rows ?? [] );
}
/**
* Rename a policy. Only the title moves: the slug is the identifier the
* booking and signup gates look policies up by, so renaming "Studio Policy"
* to "Terms of Enrolment" must not quietly detach it from the versions
* students have already accepted.
*/
public function updateTitle( int $policyId, string $title ): bool {
return false !== $this->db->update(
$this->table,
[ 'title' => $title ],
[ 'id' => $policyId ],
[ '%s' ],
[ '%d' ]
);
}
public function updateCurrentVersion( int $policyId, int $versionId ): bool {
return false !== $this->db->update(
$this->table,
+2 -1
View File
@@ -12,6 +12,7 @@ use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -40,7 +41,7 @@ class RestRegistrar {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ), $guardians );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
+1 -1
View File
@@ -92,7 +92,7 @@ class ShortcodeRegistrar {
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
/*
* Progressive enhancement for the two-step registration form.
* Progressive enhancement for the registration form.
*
* `password-strength-meter` is WordPress's own wrapper around zxcvbn, so
* the signup form scores a password exactly the way wp-admin does rather
+16
View File
@@ -70,6 +70,22 @@ if (! defined('ABSPATH')) {
<?php if (null !== $selectedPolicy) : ?>
<h2><?php echo esc_html(sprintf(/* translators: %s: policy title */ __('Versions of "%s"', 'unsupervised-schedular'), $selectedPolicy->title)); ?></h2>
<h3><?php esc_html_e('Rename', 'unsupervised-schedular'); ?></h3>
<p class="description">
<?php
/* translators: %s: the policy's slug. */
echo esc_html(sprintf(__('The title is what students see above the policy text. Its slug (%s) does not change, so every version already accepted stays attached to this policy.', 'unsupervised-schedular'), $selectedPolicy->slug));
?>
</p>
<form method="post">
<?php wp_nonce_field('usc_policy_action'); ?>
<input type="hidden" name="usc_action" value="rename_policy">
<input type="hidden" name="policy_id" value="<?php echo esc_attr((string) $selectedPolicy->id); ?>">
<label class="screen-reader-text" for="usc-policy-title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label>
<input type="text" name="title" id="usc-policy-title" class="regular-text" maxlength="<?php echo esc_attr((string) Policy::MAX_TITLE_LENGTH); ?>" value="<?php echo esc_attr($selectedPolicy->title); ?>" required>
<?php submit_button(esc_html__('Save Title', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
</form>
<h3><?php esc_html_e('Add Draft Version', 'unsupervised-schedular'); ?></h3>
<form method="post">
<?php wp_nonce_field('usc_policy_action'); ?>
+1 -1
View File
@@ -41,7 +41,7 @@ if (! defined('ABSPATH')) {
<?php else : ?>
<?php if ($accountScope) : ?>
<h2><?php esc_html_e('Account signup questions', 'unsupervised-schedular'); ?></h2>
<p><?php esc_html_e('Every new student answers these required-if-marked questions as a second step after choosing their name and password.', 'unsupervised-schedular'); ?></p>
<p><?php esc_html_e('Every new student answers these required-if-marked questions when they register — the account holder on the signup form itself, and once per student they are registering on behalf of.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<h2><?php echo esc_html(sprintf(/* translators: %s: offering title */ __('Questions for "%s"', 'unsupervised-schedular'), $selectedOffering->title)); ?></h2>
<?php endif; ?>
+52 -16
View File
@@ -5,10 +5,12 @@ if (! defined('ABSPATH')) {
exit;
}
use Unsupervised\Schedular\GroupClass\SessionSchedule;
/**
* @var \WP_User $student
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
* @var list<array{id: int, kind: string, schedule: string|null, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming Booked lessons and upcoming group-class sessions, soonest first. `schedule` is wording to show instead of a date, for a class with no class time set.
* @var list<array{id: int, kind: string, schedule: string|null, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
* @var list<array{id: int, offering: string, status: string}> $enrolments
* @var list<array{policy: string, version: string, context: string, accepted_at: string}> $acceptances
* @var list<array{question: string, answer: string, required: bool}> $registrationInfo
@@ -49,14 +51,38 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
</thead>
<tbody>
<?php foreach ($rows as $row) : ?>
<?php $isGroupSession = ($row['kind'] ?? 'lesson') === SessionSchedule::KIND; ?>
<tr>
<td><?php echo esc_html($row['start_dt'] !== '' ? (string) mysql2date('M j, Y g:i A', $row['start_dt']) : '—'); ?></td>
<td><?php echo esc_html($row['offering']); ?></td>
<td>
<?php
// A class with no class time set has no clock to put it on,
// so it carries wording ("Tuesdays 4:00pm", or its term
// dates) that stands in for the date entirely.
if (($row['schedule'] ?? null) !== null && $row['schedule'] !== '') {
echo esc_html((string) $row['schedule']);
} else {
echo esc_html($row['start_dt'] !== '' ? (string) mysql2date('M j, Y g:i A', $row['start_dt']) : '—');
}
?>
</td>
<td>
<?php echo esc_html($row['offering']); ?>
<?php if ($isGroupSession) : ?>
<span class="description"> <?php esc_html_e('group class', 'unsupervised-schedular'); ?></span>
<?php endif; ?>
</td>
<td><?php echo esc_html($row['instructor']); ?></td>
<td><?php echo esc_html($row['status']); ?></td>
<?php if ($withActions) : ?>
<td>
<?php if ($row['status'] !== 'cancelled') : ?>
<?php
/*
* A group-class session is one date in a term, not a booked
* slot: there is nothing to cancel session by session. The
* whole enrolment is withdrawn from the table below.
*/
?>
<?php if (! $isGroupSession && $row['status'] !== 'cancelled') : ?>
<form method="post" style="display:inline">
<?php wp_nonce_field('usc_student_actions'); ?>
<input type="hidden" name="usc_action" value="cancel_lesson">
@@ -88,6 +114,8 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
<?php endif; ?>
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
<form method="post">
<?php wp_nonce_field('usc_student_actions'); ?>
@@ -105,24 +133,32 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($student->user_registered); ?></td>
</tr>
<?php
/*
* Always rendered, both ways round. "Books for themselves" is a fact
* worth stating outright: an empty row would be indistinguishable
* from a guardian the page failed to look up.
*/
?>
<tr>
<th><?php esc_html_e('Booked by', 'unsupervised-schedular'); ?></th>
<td>
<?php if ($guardian !== null) : ?>
<?php // The name alone. Their address is one click away on their own page, and repeating it here only makes the row harder to scan. ?>
<a href="<?php echo esc_url($detailUrl($guardian['id'])); ?>"><?php echo esc_html($guardian['name']); ?></a>
<?php else : ?>
<span class="description"><?php esc_html_e('Nobody — this student books and pays for themselves.', 'unsupervised-schedular'); ?></span>
<?php endif; ?>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
</form>
<?php if ($guardian !== null || ! empty($children)) : ?>
<h2><?php esc_html_e('Profile', 'unsupervised-schedular'); ?></h2>
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
<?php if ($guardian !== null) : ?>
<p>
<?php
printf(
/* translators: 1: linked name of the parent/guardian, 2: their email address. */
esc_html__('Books and pays through %1$s (%2$s).', 'unsupervised-schedular'),
'<a href="' . esc_url($detailUrl($guardian['id'])) . '">' . esc_html($guardian['name']) . '</a>',
esc_html($guardian['email'])
);
?>
</p>
<?php // Who they book through is up in Account; this explains the placeholder email. ?>
<p class="description"><?php esc_html_e('This is a managed student account: it has no login of its own, and its email address is a placeholder that cannot receive mail.', 'unsupervised-schedular'); ?></p>
<?php endif; ?>
<?php if (! empty($children)) : ?>
+8 -3
View File
@@ -21,12 +21,17 @@ $studentsJson = wp_json_encode(array_values($students));
<div id="us-my-lessons"></div>
<?php endif; ?>
<?php if ($showBooking) : ?>
<?php
/*
* Above the calendar, because it reports on what the student just did and
* the calendar below it is what they do next. Filled and shown by
* booking.js; empty and hidden until then.
*/
?>
<div id="us-booking-confirmation" class="us-notice" role="status" aria-live="polite" hidden></div>
<div id="us-slot-list">
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
</div>
<div id="us-booking-confirmation" style="display:none;">
<p><?php esc_html_e('Your lesson has been booked. The instructor will confirm shortly.', 'unsupervised-schedular'); ?></p>
</div>
<?php endif; ?>
<div id="us-booking-error" style="display:none;" role="alert"></div>
</div>
+2 -3
View File
@@ -13,11 +13,10 @@ if (! defined('ABSPATH')) {
$studentsJson = wp_json_encode(array_values($students));
?>
<div id="us-group-app" data-students="<?php echo esc_attr(is_string($studentsJson) ? $studentsJson : '[]'); ?>"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
<?php /* Above the list, for the same reason as the booking page. */ ?>
<div id="us-group-confirmation" class="us-notice" role="status" aria-live="polite" hidden></div>
<div id="us-group-list">
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
</div>
<div id="us-group-confirmation" style="display:none;">
<p><?php esc_html_e('You are enrolled. The studio will be in touch.', 'unsupervised-schedular'); ?></p>
</div>
<div id="us-group-error" style="display:none;" role="alert"></div>
</div>
+120 -116
View File
@@ -2,6 +2,7 @@
declare(strict_types=1);
use Unsupervised\Schedular\Auth\PasswordPolicy;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionField;
@@ -21,7 +22,7 @@ if (! defined('ABSPATH')) {
* @var string $loginUrl Where the post-confirmation sign-in link points.
* @var string $error
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
* @var list<Question> $accountQuestions Studio-wide questions answered as step two.
* @var list<Question> $accountQuestions Studio-wide questions, asked of every student the account holder included when they are one.
*/
?>
@@ -48,137 +49,140 @@ if (! defined('ABSPATH')) {
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
<?php endif; ?>
<?php $hasQuestions = ! empty($accountQuestions); ?>
<form method="post" action="" <?php echo $hasQuestions ? 'data-steps="1"' : ''; ?>>
<form method="post" action="">
<?php wp_nonce_field('us_student_register'); ?>
<input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>">
<div class="us-reg-step" data-step="1">
<p>
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
<?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?>
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
<?php else : ?>
<input type="email" name="email" id="us-reg-email" autocomplete="email" required>
<?php endif; ?>
</p>
<p>
<label for="us-reg-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?></label>
<input type="text" name="display_name" id="us-reg-name" autocomplete="name" required>
</p>
<p>
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="<?php echo esc_attr((string) PasswordPolicy::MIN_LENGTH); ?>" required aria-describedby="us-reg-pass-strength">
<?php
/*
* Filled in by register.js. `aria-live` announces the verdict as
* it changes, and it starts empty so nothing is announced or
* takes up space before anything has been typed.
*/
?>
<span class="us-password-strength" id="us-reg-pass-strength" role="status" aria-live="polite"></span>
</p>
<p>
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
<?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?>
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
<?php else : ?>
<input type="email" name="email" id="us-reg-email" autocomplete="email" required>
<?php endif; ?>
</p>
<p>
<label for="us-reg-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?></label>
<input type="text" name="display_name" id="us-reg-name" autocomplete="name" required>
</p>
<p>
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="<?php echo esc_attr((string) PasswordPolicy::MIN_LENGTH); ?>" required aria-describedby="us-reg-pass-strength">
<?php
/*
* Filled in by register.js. `aria-live` announces the verdict as
* it changes, and it starts empty so nothing is announced or
* takes up space before anything has been typed.
*/
?>
<span class="us-password-strength" id="us-reg-pass-strength" role="status" aria-live="polite"></span>
</p>
<fieldset class="us-guardian">
<legend><?php esc_html_e('Who are you registering?', 'unsupervised-schedular'); ?></legend>
<fieldset class="us-reg-group">
<legend><?php esc_html_e('Who are you registering?', 'unsupervised-schedular'); ?></legend>
<?php
/*
* Radios, not checkboxes: the three answers are mutually
* exclusive, and "both" only means anything as a third
* choice alongside the other two. "Just myself" is
* pre-selected because it is the commonest signup and the
* one that collects the least.
*/
$registeringForChoices = [
RegistrationPage::FOR_SELF => __('Just myself', 'unsupervised-schedular'),
RegistrationPage::FOR_STUDENTS => __('On behalf of one or more students', 'unsupervised-schedular'),
RegistrationPage::FOR_BOTH => __('Both — myself and one or more students', 'unsupervised-schedular'),
];
?>
<?php foreach ($registeringForChoices as $value => $label) : ?>
<p>
<label>
<input type="checkbox" name="us_is_guardian" id="us-is-guardian" value="1">
<?php esc_html_e("I'm registering as a parent or guardian, for one or more students", 'unsupervised-schedular'); ?>
<input type="radio" name="us_registering_for" value="<?php echo esc_attr($value); ?>" class="us-registering-for"<?php checked($value, RegistrationPage::FOR_SELF); ?>>
<?php echo esc_html($label); ?>
</label>
</p>
<?php endforeach; ?>
</fieldset>
<?php /* Revealed by the checkbox; without JS it is simply always visible. */ ?>
<div class="us-children" id="us-children">
<p class="us-children-intro"><?php esc_html_e('Add each student you will be booking lessons for. They do not need their own login — you book and pay for them from this account.', 'unsupervised-schedular'); ?></p>
<?php
/*
* The account holder's own student details, asked on the same page
* as everything else rather than behind a "Next": what the studio
* needs to know about them is part of registering, not a sequel to
* it. Taken out of play by register.js when they say they are
* registering *only* on behalf of other people the questions
* describe a student, and in that case they are not one.
*/
?>
<fieldset class="us-reg-group us-reg-self" id="us-reg-self">
<legend><?php esc_html_e('About you', 'unsupervised-schedular'); ?></legend>
<p>
<label for="us-reg-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="number" name="birth_year" id="us-reg-birth-year" aria-required="true" required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" autocomplete="bday-year" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p>
<?php foreach ($accountQuestions as $question) : ?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-reg-q-' . (int) $question->id);
?>
<?php endforeach; ?>
</fieldset>
<?php /* The first block is the template the "Add another student" button clones. */ ?>
<div class="us-child" data-child-index="0">
<p>
<label for="us-child-0-name"><?php esc_html_e("Student's name", 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="children[0][name]" id="us-child-0-name" aria-required="true" data-us-child-required>
</p>
<p>
<label for="us-child-0-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="number" name="children[0][birth_year]" id="us-child-0-birth-year" aria-required="true" data-us-child-required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p>
<?php foreach ($accountQuestions as $question) : ?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render(
$question,
'children[0][answers][' . (int) $question->id . ']',
'us-child-0-q-' . (int) $question->id,
enforceRequired: false
);
?>
<?php endforeach; ?>
</div>
<?php /* Revealed by the two student-bearing choices; without JS it is simply always visible. */ ?>
<fieldset class="us-reg-group us-children" id="us-children">
<legend><?php esc_html_e('Students', 'unsupervised-schedular'); ?></legend>
<p class="us-children-intro"><?php esc_html_e('Add each student you will be booking lessons for. They do not need their own login — you book and pay for them from this account.', 'unsupervised-schedular'); ?></p>
<p>
<button type="button" class="us-add-child"><?php esc_html_e('Add another student', 'unsupervised-schedular'); ?></button>
</p>
</div>
</fieldset>
<?php if (! empty($policyForms)) : ?>
<fieldset class="us-policies">
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
<?php foreach ($policyForms as $form) : ?>
<div class="us-policy">
<h4><?php echo esc_html($form['policy']->title); ?></h4>
<div class="us-policy-body"><?php echo wp_kses_post($form['version']->bodyHtml()); ?></div>
<label>
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
<?php
/* translators: %s: policy title */
echo esc_html(sprintf(__('I have read and agree to the %s.', 'unsupervised-schedular'), $form['policy']->title));
?>
</label>
</div>
<?php endforeach; ?>
</fieldset>
<?php endif; ?>
<?php if ($hasQuestions) : ?>
<?php /* The first block is the template the "Add another student" button clones. */ ?>
<div class="us-child" data-child-index="0">
<p>
<button type="button" class="us-reg-next"><?php esc_html_e('Next', 'unsupervised-schedular'); ?></button>
<label for="us-child-0-name"><?php esc_html_e("Student's name", 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="children[0][name]" id="us-child-0-name" aria-required="true" data-us-child-required>
</p>
<p>
<label for="us-child-0-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="number" name="children[0][birth_year]" id="us-child-0-birth-year" aria-required="true" data-us-child-required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p>
<?php foreach ($accountQuestions as $question) : ?>
<?php
/*
* In parent/guardian mode the questions are asked per child,
* up in the children section, so step two has nothing left to
* ask and "Next" leads nowhere. This submit takes its place
* hidden until the guardian box is ticked (and never shown at
* all without JS, where both steps are visible anyway and the
* step-two submit does the job).
*/
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render(
$question,
'children[0][answers][' . (int) $question->id . ']',
'us-child-0-q-' . (int) $question->id,
enforceRequired: false
);
?>
<input type="submit" name="us_register" class="us-reg-submit-early" hidden value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p>
<?php else : ?>
<p>
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p>
<?php endif; ?>
</div>
<?php if ($hasQuestions) : ?>
<div class="us-reg-step" data-step="2">
<fieldset class="us-reg-questions">
<legend><?php esc_html_e('Registration information', 'unsupervised-schedular'); ?></legend>
<?php foreach ($accountQuestions as $question) : ?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-reg-q-' . (int) $question->id);
?>
<?php endforeach; ?>
</fieldset>
<p>
<button type="button" class="us-reg-back"><?php esc_html_e('Back', 'unsupervised-schedular'); ?></button>
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p>
<?php endforeach; ?>
</div>
<p>
<button type="button" class="us-add-child"><?php esc_html_e('Add another student', 'unsupervised-schedular'); ?></button>
</p>
</fieldset>
<?php if (! empty($policyForms)) : ?>
<fieldset class="us-policies">
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
<?php foreach ($policyForms as $form) : ?>
<div class="us-policy">
<h4><?php echo esc_html($form['policy']->title); ?></h4>
<div class="us-policy-body"><?php echo wp_kses_post($form['version']->bodyHtml()); ?></div>
<label>
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
<?php
/* translators: %s: policy title */
echo esc_html(sprintf(__('I have read and agree to the %s.', 'unsupervised-schedular'), $form['policy']->title));
?>
</label>
</div>
<?php endforeach; ?>
</fieldset>
<?php endif; ?>
<p>
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p>
</form>
<?php endif; ?>
<?php endif; ?>
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Actions;
use Mockery;
use Unsupervised\Schedular\Auth\DeletedUserCleanup;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Guardian\GuardianLink;
use Unsupervised\Schedular\Guardian\GuardianRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class DeletedUserCleanupTest extends TestCase
{
private BookingRepository&Mockery\MockInterface $bookings;
private AvailabilityRepository&Mockery\MockInterface $availability;
private EnrollmentRepository&Mockery\MockInterface $enrollments;
private PaymentService&Mockery\MockInterface $payments;
private GuardianRepository&Mockery\MockInterface $links;
private GuardianService&Mockery\MockInterface $guardians;
private DeletedUserCleanup $cleanup;
protected function setUp(): void
{
parent::setUp();
$this->bookings = Mockery::mock(BookingRepository::class);
$this->availability = Mockery::mock(AvailabilityRepository::class);
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->payments = Mockery::mock(PaymentService::class);
$this->links = Mockery::mock(GuardianRepository::class);
$this->guardians = Mockery::mock(GuardianService::class);
// Most accounts have nobody linked to them; the guardian tests say so.
$this->links->shouldReceive('findByGuardian')->andReturn([])->byDefault();
$this->cleanup = new DeletedUserCleanup(
$this->bookings,
$this->availability,
$this->enrollments,
$this->payments,
$this->links,
$this->guardians
);
}
public function testHooksBothSingleSiteAndNetworkDeletion(): void
{
Actions\expectAdded('delete_user')->once();
Actions\expectAdded('wpmu_delete_user')->once();
$this->cleanup->register();
}
public function testEachUpcomingLessonIsCancelledItsSlotFreedAndItsPendingPaymentVoided(): void
{
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([
new Lesson(slotId: 7, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 40, id: 12),
new Lesson(slotId: 8, studentId: 5, instructorId: 3, status: Lesson::STATUS_CONFIRMED, paymentId: null, id: 13),
]);
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CANCELLED)->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(13, Lesson::STATUS_CANCELLED)->andReturn(true);
// The point of the whole exercise: the times go back on sale.
$this->availability->shouldReceive('release')->once()->with(7)->andReturn(true);
$this->availability->shouldReceive('release')->once()->with(8)->andReturn(true);
$this->payments->shouldReceive('voidPending')->once()->with(40);
$this->payments->shouldReceive('voidPending')->once()->with(null);
$this->cleanup->releaseBookings(5);
}
/**
* A paid lesson is not credited back. The credit could only ever be spent on
* the account being deleted, so writing one would be book-keeping nobody can
* act on a refund is the studio's call to make and record.
*/
public function testNoCreditIsIssuedForAPaidLesson(): void
{
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([
new Lesson(slotId: 7, studentId: 5, instructorId: 3, status: Lesson::STATUS_CONFIRMED, paymentId: 40, id: 12),
]);
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
$this->bookings->shouldReceive('updateStatus')->andReturn(true);
$this->availability->shouldReceive('release')->andReturn(true);
$this->payments->shouldReceive('voidPending');
$this->payments->shouldNotReceive('creditForCancelledLesson');
$this->cleanup->releaseBookings(5);
}
public function testActiveEnrolmentsAreCancelledAndTheirPendingPaymentsVoided(): void
{
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 41, id: 40),
new Enrollment(
offeringId: 9,
studentId: 5,
instructorId: 3,
status: Enrollment::STATUS_CANCELLED,
paymentId: 42,
id: 41,
),
]);
// Only the active one: a withdrawn enrolment is already holding nothing.
$this->enrollments->shouldReceive('updateStatus')->once()->with(40, Enrollment::STATUS_CANCELLED)->andReturn(true);
$this->payments->shouldReceive('voidPending')->once()->with(41);
$this->cleanup->releaseBookings(5);
}
/**
* Past lessons happened and may have been paid for, so they stay exactly as
* they are `findUpcomingForStudent` is what draws that line.
*/
public function testNothingHappensWhenTheAccountHasNothingBookedAhead(): void
{
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
$this->bookings->shouldNotReceive('updateStatus');
$this->availability->shouldNotReceive('release');
$this->enrollments->shouldNotReceive('updateStatus');
$this->cleanup->releaseBookings(5);
}
public function testAnInvalidUserIdIsIgnored(): void
{
$this->bookings->shouldNotReceive('findUpcomingForStudent');
$this->enrollments->shouldNotReceive('findByStudent');
$this->cleanup->releaseBookings(0);
}
/**
* A child account is login-less and exists only so its guardian has somebody
* to book for. Without the guardian nobody can reach it, book for it, or be
* billed for it so it goes too, and what it was holding goes back.
*/
public function testDeletingAGuardianReleasesAndDeletesEachChild(): void
{
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
$this->links->shouldReceive('findByGuardian')->with(5)->andReturn([
new GuardianLink(guardianId: 5, studentId: 42, id: 1),
new GuardianLink(guardianId: 5, studentId: 43, id: 2),
]);
$this->bookings->shouldReceive('findUpcomingForStudent')->with(42)->andReturn([
new Lesson(slotId: 7, studentId: 42, instructorId: 3, status: Lesson::STATUS_CONFIRMED, paymentId: 40, id: 12),
]);
$this->bookings->shouldReceive('findUpcomingForStudent')->with(43)->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(43)->andReturn([
new Enrollment(offeringId: 8, studentId: 43, instructorId: 3, paymentId: 41, id: 40),
]);
// The child's lesson is cancelled and its time freed, exactly as the
// guardian's own would have been.
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CANCELLED)->andReturn(true);
$this->availability->shouldReceive('release')->once()->with(7)->andReturn(true);
$this->enrollments->shouldReceive('updateStatus')->once()->with(40, Enrollment::STATUS_CANCELLED)->andReturn(true);
$this->payments->shouldReceive('voidPending')->with(40)->once();
$this->payments->shouldReceive('voidPending')->with(41)->once();
// Then the link row and the account itself.
$this->links->shouldReceive('delete')->once()->with(5, 42)->andReturn(true);
$this->links->shouldReceive('delete')->once()->with(5, 43)->andReturn(true);
$this->guardians->shouldReceive('deleteUser')->once()->with(42);
$this->guardians->shouldReceive('deleteUser')->once()->with(43);
$this->cleanup->releaseBookings(5);
}
/**
* Deleting a child fires `delete_user` again, which lands back in this same
* handler. It must return without redoing the release and a self-link,
* however it got into the table, must not recurse for ever.
*/
public function testAChildAlreadyDealtWithIsNotProcessedTwice(): void
{
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
$this->links->shouldReceive('findByGuardian')->with(5)->andReturn([
new GuardianLink(guardianId: 5, studentId: 42, id: 1),
// A duplicate row, and a self-link: neither may cause a second pass.
new GuardianLink(guardianId: 5, studentId: 42, id: 2),
new GuardianLink(guardianId: 5, studentId: 5, id: 3),
]);
$this->bookings->shouldReceive('findUpcomingForStudent')->with(42)->once()->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(42)->once()->andReturn([]);
$this->links->shouldReceive('delete')->once()->with(5, 42)->andReturn(true);
$this->guardians->shouldReceive('deleteUser')->once()->with(42);
$this->cleanup->releaseBookings(5);
// The re-entrant call the child's own deletion triggers is a no-op.
$this->cleanup->releaseBookings(42);
}
/**
* A child's own deletion (from the family screen, say) touches nothing but
* that child they have nobody linked beneath them.
*/
public function testDeletingAStudentWithNoChildrenDeletesNobodyElse(): void
{
$this->bookings->shouldReceive('findUpcomingForStudent')->with(42)->andReturn([]);
$this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([]);
$this->links->shouldReceive('findByGuardian')->with(42)->andReturn([]);
$this->guardians->shouldNotReceive('deleteUser');
$this->links->shouldNotReceive('delete');
$this->cleanup->releaseBookings(42);
}
}
+350 -17
View File
@@ -35,6 +35,8 @@ class RegistrationPageTest extends TestCase
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
Functions\when('sanitize_text_field')->alias(static fn ($v) => $v);
// Every submit reads the "who are you registering?" radio through it.
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v);
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
// Reached on every submit now that the email is validated before the
@@ -74,6 +76,9 @@ class RegistrationPageTest extends TestCase
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
$this->ctx['guardians'] = Mockery::mock(GuardianService::class);
// Recorded on every successful signup; the tests that care assert on it.
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->byDefault();
$this->ctx['guardians']->shouldReceive('setBirthYear')->byDefault();
$this->ctx['page'] = new RegistrationPage(
$invites,
@@ -109,6 +114,17 @@ class RegistrationPageTest extends TestCase
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
}
/** Everything the invite success branch touches once the account is created. */
private function stubInviteSuccess(): void
{
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
Functions\when('wp_set_current_user')->justReturn(null);
Functions\when('wp_set_auth_cookie')->justReturn(null);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
}
private function submit(?Invite $invite, bool $open): string
{
$method = new \ReflectionMethod(RegistrationPage::class, 'handleSubmit');
@@ -118,7 +134,7 @@ class RegistrationPageTest extends TestCase
public function testInviteBranchCreatesAndLogsInTheStudent(): void
{
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990' ];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
@@ -135,7 +151,7 @@ class RegistrationPageTest extends TestCase
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
{
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990' ];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
@@ -155,7 +171,7 @@ class RegistrationPageTest extends TestCase
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
{
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]', 'birth_year' => '1990' ];
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
@@ -182,7 +198,7 @@ class RegistrationPageTest extends TestCase
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
{
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]', 'birth_year' => '1990' ];
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
@@ -350,7 +366,7 @@ class RegistrationPageTest extends TestCase
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
{
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]', 'birth_year' => '1990' ];
Functions\when('is_email')->justReturn(true);
@@ -369,7 +385,7 @@ class RegistrationPageTest extends TestCase
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
{
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]', 'birth_year' => '1990' ];
Functions\when('is_email')->justReturn(true);
@@ -391,6 +407,7 @@ class RegistrationPageTest extends TestCase
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Ada',
'birth_year' => '1990',
'us_answers' => [ '5' => 'By a friend' ],
];
@@ -423,7 +440,7 @@ class RegistrationPageTest extends TestCase
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
{
$_POST = [ 'us_register' => '1', 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
$_POST = [ 'us_register' => '1', 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990' ];
$_REQUEST = [ 'us_invite' => 'raw-token' ];
Functions\when('is_user_logged_in')->justReturn(false);
@@ -624,7 +641,7 @@ class RegistrationPageTest extends TestCase
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => 'Violin']],
@@ -711,12 +728,204 @@ class RegistrationPageTest extends TestCase
);
}
/**
* "On behalf of students" is the one choice that says the account holder is
* not a student, so it is the one that sets the flag.
*/
public function testRegisteringOnlyForStudentsMarksTheAccountGuardianOnly(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
$this->stubInviteSuccess();
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, true);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/**
* @dataProvider modesThatKeepTheAccountHolderAStudent
*/
public function testTheAccountHolderStaysAStudentForTheOtherTwoChoices(string $mode, bool $withChildren): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => $mode,
];
if ($withChildren) {
$_POST['children'] = [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]];
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
}
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
$this->stubInviteSuccess();
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, false);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/** @return array<string, array{string, bool}> */
public static function modesThatKeepTheAccountHolderAStudent(): array
{
return [
'just myself' => [RegistrationPage::FOR_SELF, false],
'myself and students' => [RegistrationPage::FOR_BOTH, true],
];
}
/**
* "Both" collects students exactly as "on behalf of" does the only
* difference is whether the account holder is one of them.
*/
public function testBothStillRequiresAtLeastOneStudent(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_registering_for' => RegistrationPage::FOR_BOTH,
'children' => [],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
self::assertStringContainsString('at least one student', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/**
* A form posted without the radio an old cached page, or a crafted
* request must fall to the choice that collects and grants the least,
* never be read as "register these children".
*/
public function testAMissingOrUnknownChoiceFallsBackToJustMyself(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => 'something-else',
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
];
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
$this->stubInviteSuccess();
// No student is created from children[] the caller never asked to register.
$this->ctx['guardians']->shouldNotReceive('createChild');
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, false);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/**
* Under "both" the account holder is a student too, so the studio's
* questions are asked of them as well as of each student they add. Before
* this they were asked per student only, and the account holder's own
* answers were never collected or stored.
*/
public function testBothRecordsAnswersForTheAccountHolderAndEachStudent(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => RegistrationPage::FOR_BOTH,
'us_answers' => ['7' => 'Cello'],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
];
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
$this->stubInviteSuccess();
$recorded = [];
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
static function (Answer $answer) use (&$recorded): int {
$recorded[] = [$answer->studentId, $answer->answerValue];
return 1;
}
);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
// The student's answer against the student, the account holder's against
// themselves — not one answer shared between them.
self::assertEqualsCanonicalizing([[101, 'Piano'], [42, 'Cello']], $recorded);
}
public function testBothRejectsAnUnansweredQuestionForTheAccountHolder(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => RegistrationPage::FOR_BOTH,
'us_answers' => ['7' => ' '],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
];
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
// The message names nobody else — the student's answer was fine.
self::assertStringContainsString('Please answer all required registration questions.', $result);
self::assertStringNotContainsString('for each student', $result);
}
/**
* A pure guardian is not a student, so the questions are theirs to answer
* per student and never about them. Anything posted for them is ignored.
*/
public function testRegisteringOnlyForStudentsStoresNoAnswersForTheAccountHolder(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'us_answers' => ['7' => 'Should be ignored'],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
];
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
$this->stubInviteSuccess();
$students = [];
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
static function (Answer $answer) use (&$students): int {
$students[] = $answer->studentId;
return 1;
}
);
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
self::assertSame([101], $students);
}
public function testGuardianSignupWithNoChildrenIsRejected(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [['name' => '', 'birth_year' => '', 'answers' => []]],
];
@@ -738,9 +947,9 @@ class RegistrationPageTest extends TestCase
public function testGuardianSignupRejectsAHalfFilledChildRatherThanDroppingIt(): void
{
$_POST = [
'password' => 'password123',
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
['name' => '', 'birth_year' => '2017', 'answers' => []],
@@ -764,9 +973,9 @@ class RegistrationPageTest extends TestCase
public function testGuardianSignupRejectsAChildWithoutAUsableBirthYear(string $submitted): void
{
$_POST = [
'password' => 'password123',
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [['name' => 'Ada', 'birth_year' => $submitted, 'answers' => []]],
];
@@ -800,7 +1009,7 @@ class RegistrationPageTest extends TestCase
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => ' ']],
@@ -827,7 +1036,7 @@ class RegistrationPageTest extends TestCase
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
['name' => 'Alan', 'birth_year' => '2017', 'answers' => []],
@@ -864,7 +1073,7 @@ class RegistrationPageTest extends TestCase
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'accept' => [3],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
];
@@ -901,7 +1110,7 @@ class RegistrationPageTest extends TestCase
public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void
{
$_POST = ['password' => 'thistle-marrow-42', 'display_name' => 'Ada'];
$_POST = ['password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990'];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
@@ -914,4 +1123,128 @@ class RegistrationPageTest extends TestCase
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/**
* The account holder is a student under "self" and "both", so they give the
* same birth year every other student does and it is stored against their
* own account under the same meta key a child's uses.
*
* @dataProvider modesWhereTheAccountHolderIsAStudent
*/
public function testTheAccountHoldersBirthYearIsRecordedWhenTheyAreAStudent(string $mode, bool $withChildren): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => '1988',
'us_registering_for' => $mode,
];
if ($withChildren) {
$_POST['children'] = [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]];
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
}
$this->stubInviteSuccess();
$this->ctx['guardians']->shouldReceive('setBirthYear')->once()->with(42, '1988');
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/** @return array<string, array{string, bool}> */
public static function modesWhereTheAccountHolderIsAStudent(): array
{
return [
'just myself' => [RegistrationPage::FOR_SELF, false],
'myself and students' => [RegistrationPage::FOR_BOTH, true],
];
}
/**
* Missing or nonsense years are refused before a single user is created, the
* same way a student's is — the browser's `required` cannot be trusted here
* because the panel is hidden for a pure guardian.
*
* @dataProvider unusableBirthYears
*/
public function testAnUnusableBirthYearForTheAccountHolderIsRejected(string $submitted): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => $submitted,
];
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
// Addressed to the person filling the form in, not to "each student".
self::assertStringContainsString('Please give your birth year', $result);
self::assertStringNotContainsString('each student', $result);
}
/** @return array<string, array{string}> */
public static function unusableBirthYears(): array
{
return [
'missing' => [''],
'two digits' => ['88'],
'not a year' => ['nineteen'],
'in future' => ['3000'],
];
}
/**
* A pure guardian is not a student, so no birth year is asked of them and
* none is stored anything posted for one is ignored, exactly as their
* answers are.
*/
public function testNoBirthYearIsStoredForAGuardianWhoIsNotAStudent(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => 'should be ignored',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
];
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
$this->stubInviteSuccess();
$this->ctx['guardians']->shouldNotReceive('setBirthYear');
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/**
* Everything the studio needs is asked on one page: the account holder's own
* birth year and questions sit above the students they are adding, and there
* is no second step to advance to.
*/
public function testTheFormAsksTheAccountHoldersQuestionsAboveTheStudents(): void
{
$this->stubRenderContext();
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('name="birth_year"', $html);
self::assertStringContainsString('name="us_answers[7]"', $html);
// One page, one submit: no "Next", no step panels.
self::assertStringNotContainsString('us-reg-next', $html);
self::assertStringNotContainsString('data-step', $html);
self::assertLessThan(
strpos($html, 'id="us-children"'),
strpos($html, 'name="us_answers[7]"'),
'The account holder answers the questions above the students they are adding.'
);
}
}
+68 -5
View File
@@ -9,12 +9,21 @@ use Unsupervised\Schedular\Tests\Unit\TestCase;
class UserNameTest extends TestCase
{
private function user(string $first, string $last, string $nickname): \WP_User
{
private function user(
string $first,
string $last,
string $nickname,
string $displayName = '',
string $login = 'ada_login',
string $email = '[email protected]',
): \WP_User {
$user = Mockery::mock(\WP_User::class);
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = $nickname;
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = $nickname;
$user->display_name = $displayName;
$user->user_login = $login;
$user->user_email = $email;
return $user;
}
@@ -34,6 +43,60 @@ class UserNameTest extends TestCase
self::assertSame('Countess', UserName::format($this->user('', '', 'Countess')));
}
/**
* WordPress defaults a new account's nickname to its `user_login`, and signup
* uses the email address as the login so a self-registered account carried
* its own address as its nickname and every screen naming that person showed
* the address. The name they typed was in `display_name` all along.
*/
public function testSkipsANicknameThatIsReallyTheLoginAndUsesTheDisplayName(): void
{
$user = $this->user(
'',
'',
'[email protected]',
'Grace Hopper',
login: '[email protected]',
email: '[email protected]',
);
self::assertSame('Grace Hopper', UserName::format($user, 42));
}
public function testSkipsANicknameThatIsReallyTheEmailAddress(): void
{
$user = $this->user(
'',
'',
'[email protected]',
'Grace Hopper',
login: 'gracehopper',
email: '[email protected]',
);
// Case-insensitively: the address is the address however it was typed.
self::assertSame('Grace Hopper', UserName::format($user, 42));
}
/**
* The whole point of the class: an identifier never reaches the screen, even
* when it is the only thing on file. Someone who registered without giving a
* name is shown as their id rather than as their email address.
*/
public function testNeverFallsThroughToAnIdentifier(): void
{
$user = $this->user(
'',
'',
'[email protected]',
'[email protected]',
login: '[email protected]',
email: '[email protected]',
);
self::assertSame('42', UserName::format($user, 42));
}
public function testFallsBackToIdWhenNothingSet(): void
{
self::assertSame('42', UserName::format($this->user('', '', ''), 42));
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -30,6 +31,7 @@ class BookingEndpointTest extends TestCase
private RegistrationGate $gate;
private PaymentService $payments;
private StudioSettings $settings;
private SessionSchedule&Mockery\MockInterface $sessions;
private BookingEndpoint $endpoint;
protected function setUp(): void
@@ -63,6 +65,11 @@ class BookingEndpointTest extends TestCase
$this->guardians->shouldReceive('householdIds')->andReturnUsing(static fn (int $id): array => [$id])->byDefault();
$this->guardians->shouldReceive('studentName')->andReturn('Ada')->byDefault();
$this->sessions = Mockery::mock(SessionSchedule::class);
// Most tests are about one-to-one lessons; the group-class ones say so.
$this->sessions->shouldReceive('upcomingForStudent')->andReturn([])->byDefault();
$this->sessions->shouldReceive('upcomingForInstructor')->andReturn([])->byDefault();
$this->endpoint = new BookingEndpoint(
$this->availability,
$this->bookings,
@@ -71,6 +78,7 @@ class BookingEndpointTest extends TestCase
$this->payments,
new CancellationPolicy($this->settings),
$this->guardians,
$this->sessions,
);
}
@@ -796,4 +804,93 @@ class BookingEndpointTest extends TestCase
self::assertSame([77, 78], array_column($data, 'id'));
self::assertSame(['Ada', 'Grace'], array_column($data, 'student_name'));
}
/**
* A group class has no availability slot behind it, so it never appeared in
* this list at all a student whose whole term was a group class saw an
* empty schedule. Its sessions now sort in among the booked lessons.
*/
public function testMyLessonsInterleavesGroupClassSessionsWithLessons(): void
{
Functions\when('current_user_can')->justReturn(false);
$this->guardians->shouldReceive('householdIds')->with(5)->andReturn([5]);
$this->guardians->shouldReceive('studentName')->with(5)->andReturn('Grace');
// Slot 10 starts 2026-07-01 10:00 (the fixture), so the class on
// 2026-06-30 comes first and the one on 2026-07-07 last.
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 78);
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->sessions->shouldReceive('upcomingForStudent')->with(5, '2026-06-01 10:00:00')->andReturn([
[
'enrollment_id' => 40,
'offering_id' => 8,
'offering_title' => 'Choir',
'instructor_id' => 3,
'status' => 'active',
'start_dt' => '2026-06-30 16:00:00',
'end_dt' => '2026-06-30 17:00:00',
'duration_minutes' => 60,
],
[
'enrollment_id' => 40,
'offering_id' => 8,
'offering_title' => 'Choir',
'instructor_id' => 3,
'status' => 'active',
'start_dt' => '2026-07-07 16:00:00',
'end_dt' => '2026-07-07 17:00:00',
'duration_minutes' => 60,
],
]);
$data = $this->endpoint->myLessons(new \WP_REST_Request([]))->get_data();
self::assertSame(
['2026-06-30 16:00:00', '2026-07-01 10:00:00', '2026-07-07 16:00:00'],
array_column($data, 'start_dt')
);
// `kind` is what lets the panel withhold a Cancel button from a session
// that is a date in a term rather than a booked slot. A lesson carries no
// `kind` at all, which is the absence the panel reads as "cancellable".
self::assertSame('group_class', $data[0]['kind']);
self::assertSame('group_class', $data[2]['kind']);
self::assertSame('Choir', $data[0]['offering_title']);
self::assertSame('Grace', $data[0]['student_name']);
self::assertArrayNotHasKey('kind', $data[1]);
}
/**
* An instructor's own group classes join their schedule the same way, and
* one session is one row however many students are enrolled in it.
*/
public function testMyLessonsAddsAnInstructorsOwnGroupClassSessions(): void
{
Functions\when('current_user_can')->justReturn(true);
$this->bookings->shouldReceive('findUpcomingForInstructor')->with(5)->andReturn([]);
$this->sessions->shouldReceive('upcomingForInstructor')->with(5, '2026-06-01 10:00:00')->andReturn([
[
'enrollment_id' => 0,
'offering_id' => 8,
'offering_title' => 'Choir',
'instructor_id' => 5,
'status' => 'active',
'start_dt' => '2026-06-30 16:00:00',
'end_dt' => '2026-06-30 17:00:00',
'duration_minutes' => 60,
],
]);
$data = $this->endpoint->myLessons(new \WP_REST_Request([]))->get_data();
self::assertCount(1, $data);
self::assertSame('group_class', $data[0]['kind']);
self::assertSame('Choir', $data[0]['offering_title']);
// Nobody's name: the row is the class, not one student's place in it.
self::assertArrayNotHasKey('student_name', $data[0]);
}
}
@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class SessionScheduleTest extends TestCase
{
private EnrollmentRepository&Mockery\MockInterface $enrollments;
private OfferingRepository&Mockery\MockInterface $offerings;
private SessionSchedule $schedule;
protected function setUp(): void
{
parent::setUp();
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->schedule = new SessionSchedule($this->enrollments, $this->offerings);
}
/** A three-week Tuesday class at 16:00, one hour long. */
private function choir(int $id = 8, string $title = 'Choir'): Offering
{
return new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: $title,
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-09-22',
classTime: '16:00:00',
id: $id,
);
}
public function testAStudentsEnrolmentBecomesOneRowPerRemainingSession(): void
{
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir());
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
self::assertCount(3, $rows);
self::assertSame(
['2026-09-08 16:00:00', '2026-09-15 16:00:00', '2026-09-22 16:00:00'],
array_column($rows, 'start_dt')
);
self::assertSame('2026-09-08 17:00:00', $rows[0]['end_dt']);
self::assertNull($rows[0]['schedule']);
self::assertSame('Choir', $rows[0]['offering_title']);
self::assertSame(40, $rows[0]['enrollment_id']);
self::assertSame(3, $rows[0]['instructor_id']);
self::assertSame(60, $rows[0]['duration_minutes']);
}
public function testSessionsThatHaveAlreadyStartedAreLeftOut(): void
{
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir());
// Mid-term: the first two dates are gone, the last is still to come.
$rows = $this->schedule->upcomingForStudent(5, '2026-09-16 09:00:00');
self::assertSame(['2026-09-22 16:00:00'], array_column($rows, 'start_dt'));
}
public function testAWithdrawnEnrolmentContributesNothing(): void
{
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(
offeringId: 8,
studentId: 5,
instructorId: 3,
status: Enrollment::STATUS_CANCELLED,
id: 40,
),
]);
$this->offerings->shouldNotReceive('findById');
self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00'));
}
/**
* "Completed" is a billing state, not a calendar one the class may still
* have dates left to run, so its sessions stay on the list.
*/
public function testACompletedEnrolmentStillListsItsRemainingSessions(): void
{
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(
offeringId: 8,
studentId: 5,
instructorId: 3,
status: Enrollment::STATUS_COMPLETED,
id: 40,
),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir());
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
self::assertCount(3, $rows);
self::assertSame(Enrollment::STATUS_COMPLETED, $rows[0]['status']);
}
/**
* Both the class time and the duration are optional on the offering form, and
* the schedule note exists so a studio can say "Tuesdays 4:00pm" instead of
* pinning the class to a clock. A class configured that way used to vanish
* from the list entirely the bug this covers. It now gets one row carrying
* the note in place of a date.
*/
public function testAClassWithNoClassTimeStillGetsARowDescribedInWords(): void
{
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-12-08',
scheduleNote: 'Tuesdays 4:00pm',
id: 8,
));
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
self::assertCount(1, $rows);
self::assertSame('Tuesdays 4:00pm', $rows[0]['schedule']);
self::assertSame('Choir', $rows[0]['offering_title']);
// A sort key, not a claim about the time: the class has not started yet,
// so it sorts to its first day.
self::assertSame('2026-09-08 00:00:00', $rows[0]['start_dt']);
self::assertSame('', $rows[0]['end_dt']);
}
/** Without a note, the term dates do the describing. */
public function testAnUndatedClassFallsBackToItsTermDates(): void
{
Functions\when('mysql2date')->alias(
static fn (string $format, string $date): string => date($format, (int) strtotime($date))
);
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
termEnd: '2026-12-08',
id: 8,
));
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
self::assertSame('Sep 8, 2026 Dec 8, 2026', $rows[0]['schedule']);
}
/**
* A term already under way sorts to "now" rather than to a start date in the
* past, so an ongoing class reads as current instead of dropping to the
* bottom of a list ordered by time.
*/
public function testAnUndatedClassAlreadyUnderWaySortsToNow(): void
{
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
termEnd: '2026-12-08',
scheduleNote: 'Tuesdays 4:00pm',
id: 8,
));
$rows = $this->schedule->upcomingForStudent(5, '2026-10-01 09:00:00');
self::assertSame('2026-10-01 09:00:00', $rows[0]['start_dt']);
}
/** An undated class whose last day has passed is over, and drops out. */
public function testAnUndatedClassThatHasFinishedIsDropped(): void
{
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
termEnd: '2026-12-08',
scheduleNote: 'Tuesdays 4:00pm',
id: 8,
));
self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-12-09 00:00:00'));
}
/**
* A class time but no duration: the dates are still known, so they are still
* listed the row just says when it starts and not when it ends, rather than
* inventing a finish.
*/
public function testAClassWithNoDurationKeepsItsDatesAndLeavesTheEndOpen(): void
{
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
termEnd: '2026-09-15',
classTime: '16:00:00',
id: 8,
));
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
self::assertSame(['2026-09-08 16:00:00', '2026-09-15 16:00:00'], array_column($rows, 'start_dt'));
self::assertSame(['', ''], array_column($rows, 'end_dt'));
self::assertNull($rows[0]['schedule']);
}
public function testADeletedOfferingIsSkippedRatherThanFatal(): void
{
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn(null);
self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00'));
}
public function testTwoEnrolmentsAreInterleavedByDate(): void
{
$band = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Band',
durationMinutes: 45,
termStart: '2026-09-10',
termEnd: '2026-09-10',
classTime: '09:00:00',
id: 9,
);
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 41),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir());
$this->offerings->shouldReceive('findById')->with(9)->andReturn($band);
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
self::assertSame(
['Choir', 'Band', 'Choir', 'Choir'],
array_column($rows, 'offering_title')
);
}
/**
* An instructor's list is built from the classes they teach, not from who
* has signed up: a class with no enrolments yet is still on their schedule.
*/
public function testAnInstructorSeesEachSessionOfTheirActiveClassesOnce(): void
{
$this->offerings->shouldReceive('findAll')
->once()
->with(3, Offering::KIND_GROUP_CLASS, true)
->andReturn([$this->choir()]);
$this->enrollments->shouldNotReceive('findByStudent');
$rows = $this->schedule->upcomingForInstructor(3, '2026-09-01 00:00:00');
self::assertCount(3, $rows);
self::assertSame(0, $rows[0]['enrollment_id']);
self::assertSame(3, $rows[0]['instructor_id']);
self::assertSame('Choir', $rows[0]['offering_title']);
}
}
+90
View File
@@ -128,6 +128,96 @@ class OfferingTest extends TestCase
self::assertSame([], $noDuration->sessionWindows());
}
/**
* Knowing *when* a class meets is a separate question from knowing how long
* it runs, so the dates survive a missing duration even though the windows
* (which need both ends) do not.
*/
public function testSessionStartsNeedsNoDuration(): void
{
$noDuration = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
termEnd: '2026-09-22',
classTime: '16:00:00',
);
self::assertSame(
['2026-09-08 16:00:00', '2026-09-15 16:00:00', '2026-09-22 16:00:00'],
$noDuration->sessionStarts()
);
self::assertSame([], $noDuration->sessionWindows());
}
public function testSessionStartsEmptyWithoutADateOrATime(): void
{
$noTime = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
);
self::assertSame([], $noTime->sessionStarts());
$noDate = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
classTime: '16:00:00',
);
self::assertSame([], $noDate->sessionStarts());
}
public function testLastClassDayPrefersTheTermEnd(): void
{
$run = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
termEnd: '2026-12-08',
);
self::assertSame('2026-12-08', $run->lastClassDay());
$oneOff = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Recital',
termStart: '2026-09-08',
);
self::assertSame('2026-09-08', $oneOff->lastClassDay());
$undated = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir');
self::assertNull($undated->lastClassDay());
}
/**
* The studio's own wording wins: the schedule-note field exists precisely so
* a class can describe when it meets without being pinned to a clock.
*/
public function testScheduleLabelPrefersTheScheduleNote(): void
{
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
termEnd: '2026-12-08',
scheduleNote: ' Tuesdays 4:00pm ',
);
self::assertSame('Tuesdays 4:00pm', $offering->scheduleLabel());
}
public function testScheduleLabelSaysSoWhenNothingIsSet(): void
{
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir');
self::assertSame('Schedule to be confirmed', $offering->scheduleLabel());
}
public function testDefaults(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
@@ -246,6 +246,74 @@ class PolicyControllerTest extends TestCase
$this->render();
}
public function testRenamingAPolicyUpdatesTheTitleAndLeavesTheSlugAlone(): void
{
$policy = new Policy('Studio Policy', 'studio-policy', id: 4);
$renamed = new Policy('Terms of Enrolment', 'studio-policy', id: 4);
$_GET = ['policy_id' => '4'];
$_POST = [
'usc_action' => 'rename_policy',
'policy_id' => '4',
'title' => 'Terms of Enrolment',
];
// The lookups that guard the action see the old title; the page is
// rendered from a fresh read, so it shows the new one.
$this->policies->shouldReceive('findById')->with(4)->once()->andReturn($policy);
$this->policies->shouldReceive('updateTitle')->once()->with(4, 'Terms of Enrolment')->andReturn(true);
$this->policies->shouldReceive('findAll')->andReturn([$renamed]);
$this->policies->shouldReceive('findById')->with(4)->andReturn($renamed);
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
$html = $this->render();
self::assertStringContainsString('Policy renamed to &quot;Terms of Enrolment&quot;', $html);
self::assertStringContainsString('Versions of &quot;Terms of Enrolment&quot;', $html);
}
public function testRenamingAPolicyToNothingIsRejected(): void
{
$policy = new Policy('Studio Policy', 'studio-policy', id: 4);
$_GET = ['policy_id' => '4'];
$_POST = [
'usc_action' => 'rename_policy',
'policy_id' => '4',
'title' => ' ',
];
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
$this->policies->shouldNotReceive('updateTitle');
$html = $this->render();
self::assertStringContainsString('Versions of &quot;Studio Policy&quot;', $html);
}
public function testRenamingAPolicyBeyondTheColumnLengthIsRejected(): void
{
$policy = new Policy('Studio Policy', 'studio-policy', id: 4);
$_GET = ['policy_id' => '4'];
$_POST = [
'usc_action' => 'rename_policy',
'policy_id' => '4',
'title' => str_repeat('a', Policy::MAX_TITLE_LENGTH + 1),
];
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
$this->policies->shouldNotReceive('updateTitle');
$this->render();
}
private function render(): string
{
ob_start();
@@ -61,6 +61,23 @@ class PolicyRepositoryTest extends TestCase
self::assertSame(Policy::SCOPE_SIGNUP, $found[0]->acceptanceScope);
}
public function testUpdateTitleWritesOnlyTheTitle(): void
{
$this->db->shouldReceive('update')
->once()
->with('wp_us_policies', ['title' => 'Terms of Enrolment'], ['id' => 7], ['%s'], ['%d'])
->andReturn(1);
self::assertTrue($this->repo->updateTitle(7, 'Terms of Enrolment'));
}
public function testUpdateTitleReportsFailure(): void
{
$this->db->shouldReceive('update')->once()->andReturn(false);
self::assertFalse($this->repo->updateTitle(7, 'Terms of Enrolment'));
}
public function testUpdateCurrentVersion(): void
{
$this->db->shouldReceive('update')
+26
View File
@@ -17,6 +17,32 @@ abstract class TestCase extends BaseTestCase
Monkey\setUp();
Monkey\Functions\stubTranslationFunctions();
Monkey\Functions\stubEscapeFunctions();
// WordPress's form-state helpers. Stubbed here rather than per test
// because they are pure output helpers with no behaviour worth faking
// differently anywhere — a template either emits the attribute or not.
Monkey\Functions\when('checked')->alias(
static fn (mixed $checked, mixed $current = true, bool $echo = true): string
=> self::formAttribute('checked', $checked, $current, $echo)
);
Monkey\Functions\when('selected')->alias(
static fn (mixed $selected, mixed $current = true, bool $echo = true): string
=> self::formAttribute('selected', $selected, $current, $echo)
);
}
/** Mirrors WordPress's `__checked_selected_helper()`, echo included. */
private static function formAttribute(string $type, mixed $helper, mixed $current, bool $echo): string
{
// WordPress compares loosely, and templates rely on that: `checked($a, $b)`
// is routinely called with an int against a numeric string.
$result = $helper == $current ? " {$type}='{$type}'" : ''; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
if ($echo) {
echo $result; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed literal.
}
return $result;
}
protected function tearDown(): void
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.3.1
* Version: 1.4.1
* Requires at least: 6.2
* Requires PHP: 8.1
* Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit;
}
define('USC_VERSION', '1.3.1');
define('USC_VERSION', '1.4.1');
define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));