From cb347ffca0496b35e475363c8b5d7581452a9f75 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Thu, 30 Jul 2026 11:45:04 -0300 Subject: [PATCH] Demo follow-ups: editable policy name, one-page signup, group classes in upcoming lessons, deletion cleanup 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 --- CHANGELOG.md | 13 + assets/css/frontend.css | 31 ++- assets/js/booking.js | 29 +- assets/js/register.js | 125 +++------ docs/features/account-registration.md | 50 ++-- docs/features/group-classes.md | 33 +++ docs/features/lesson-booking.md | 10 +- docs/features/parent-guardian-accounts.md | 38 ++- docs/features/policies.md | 1 + docs/features/registration-questions.md | 10 +- docs/features/student-administration.md | 27 +- src/AdminMenu.php | 3 +- src/Auth/DeletedUserCleanup.php | 75 +++++ src/Auth/RegistrationPage.php | 23 +- src/Auth/StudentController.php | 40 ++- src/Booking/BookingEndpoint.php | 40 ++- src/GroupClass/SessionSchedule.php | 134 +++++++++ src/Guardian/GuardianService.php | 20 +- src/Plugin.php | 2 + src/Policy/PolicyController.php | 19 ++ src/Policy/PolicyRepository.php | 16 ++ src/RestRegistrar.php | 3 +- src/ShortcodeRegistrar.php | 2 +- templates/admin/policies.php | 16 ++ templates/admin/questions.php | 2 +- templates/admin/student-detail.php | 23 +- templates/frontend/register-page.php | 263 +++++++++--------- tests/Unit/Auth/DeletedUserCleanupTest.php | 137 +++++++++ tests/Unit/Auth/RegistrationPageTest.php | 146 +++++++++- tests/Unit/Booking/BookingEndpointTest.php | 97 +++++++ tests/Unit/GroupClass/SessionScheduleTest.php | 197 +++++++++++++ tests/Unit/Policy/PolicyControllerTest.php | 68 +++++ tests/Unit/Policy/PolicyRepositoryTest.php | 17 ++ 33 files changed, 1419 insertions(+), 291 deletions(-) create mode 100644 src/Auth/DeletedUserCleanup.php create mode 100644 src/GroupClass/SessionSchedule.php create mode 100644 tests/Unit/Auth/DeletedUserCleanupTest.php create mode 100644 tests/Unit/GroupClass/SessionScheduleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa0cbf..6a90a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ each change under the current top section as you work. ## [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 whose date, time or length has not been filled in yields no sessions and is left out rather than shown at a time nobody chose. +- 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 +- **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 diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 59e2960..6f10988 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -438,15 +438,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; } diff --git a/assets/js/booking.js b/assets/js/booking.js index 6e1147a..299fa45 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -588,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); } @@ -603,21 +606,31 @@ return ` — ${escHtml(String(l.student_name))}`; } + // 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'; + } + 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 ? ` (${escHtml(String(l.duration_minutes))} min)` : ''; + const badge = group ? ' Group class' : ''; + const action = group ? '' : ``; // 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 `
- ${title}${duration}${lessonWhoHtml(l)} + ${title}${duration}${badge}${lessonWhoHtml(l)} ${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}
${escHtml(lessonStatusLabel(String(l.status)))} - + ${action}
`; } @@ -634,13 +647,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 = `
-

Your upcoming lessons

+

${heading}

${visible.map(lessonRowHtml).join('')} ${hidden.length ? ` - + ` : ''}
`; diff --git a/assets/js/register.js b/assets/js/register.js index 8cbf3bd..09e323a 100644 --- a/assets/js/register.js +++ b/assets/js/register.js @@ -4,18 +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. **Who are you registering?** The student section is hidden until the + * 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 question panel out of play — they are not a student - * in that case, so the server ignores those answers and the browser must not - * demand them. Under "both" they are a student and do answer them. - * 3. **Password strength.** The password is scored with zxcvbn (via WordPress's + * 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`. */ @@ -28,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'); @@ -120,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'), - }; } /** @@ -198,9 +167,10 @@ } } - function enhanceGuardian(form, steps) { + function enhanceGuardian(form) { var choices = form.querySelectorAll('.us-registering-for'); var children = form.querySelector('#us-children'); + var self = form.querySelector('#us-reg-self'); if (!choices.length || !children) { return; @@ -224,12 +194,15 @@ * each: * * - Are student blocks in play? For "students" and "both". - * - Does the account holder answer the studio's questions themselves? For - * "self" and "both" — they are a student in those two, and the questions - * describe a student. A pure guardian answers them per student instead, - * so their own copy goes out of play; disabling it rather than hiding it - * is what stops a `required` question the server will ignore from - * blocking submit. + * - 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(); @@ -237,32 +210,18 @@ var asksSelf = current !== 'students'; children.hidden = !wantsStudents; + children.disabled = !wantsStudents; - // 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. + // 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 = wantsStudents; } - if (!steps) { - return; - } - - var fields = steps.step2.querySelectorAll('input, select, textarea'); - for (var i = 0; i < fields.length; i++) { - fields[i].disabled = !asksSelf; - } - - // 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 = !asksSelf; - - if (steps.earlySubmit) { - steps.earlySubmit.hidden = asksSelf; + if (self) { + self.hidden = !asksSelf; + self.disabled = !asksSelf; } } @@ -292,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]); } }); diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md index 0b2d1c5..f78e538 100644 --- a/docs/features/account-registration.md +++ b/docs/features/account-registration.md @@ -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`. diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index 99207d7..e8f5b30 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.md @@ -31,6 +31,37 @@ Assigning an instructor to a scheduled class removes that instructor's open booking slots at the class time and flags any already-booked lesson that clashes; see **Instructor assignment** in `offerings.md`. +### Sessions in the "upcoming" views +`GroupClass\SessionSchedule` turns an enrolment into the dated sessions behind it, +so a class appears alongside one-to-one lessons wherever upcoming lessons are +listed. A class is a term, not rows in `us_availability`, so an enrolment carries +no date of its own — the concrete windows come from `Offering::sessionWindows()`, +the same derivation the billing scan and the class-slot reconciler use, which is +what keeps a student's list, an instructor's list and the invoice agreeing on when +the class meets. + +- `upcomingForStudent()` — every not-yet-started session of each enrolment that is + not `cancelled`. `completed` is a *billing* state and says nothing about the + calendar, so those sessions stay listed. +- `upcomingForInstructor()` — every session of each active group class they own, + one row per session however many students are enrolled; enrolments are not + consulted, because a class still has to be taught if nobody has signed up yet. + +A class whose schedule is not fully specified (no time, or no duration) yields no +windows and so contributes no rows — better absent from a dated list than shown at +a time nobody chose. + +Consumers mark these rows `kind = 'group_class'` (`SessionSchedule::KIND`) and +withhold the per-lesson actions from them: a session is one date in a term, not a +booked slot, so there is nothing to cancel session by session and no slot to +release. Withdrawing from the class is the separate, whole-enrolment decision. + +Where they show up: the `[us_scheduler]` upcoming panel via `GET /bookings` +(students and instructors both), and the **Upcoming lessons** table on the admin +student detail page. Only *upcoming* sessions are added there — the +**Group-class enrolments** table below already records the whole history, and a +term's worth of past dates would bury the lessons under "Past lessons". + ## Enrolment Flow The class list is loaded together with the student's own enrolments (`GET /enrollments`); a class the student already has an `active` enrolment in @@ -175,6 +206,7 @@ class becomes enrollable for them — they choose whether to enrol. - Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates) - Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`) - Model: `Unsupervised\Schedular\GroupClass\Enrollment` +- Sessions: `Unsupervised\Schedular\GroupClass\SessionSchedule` (`upcomingForStudent`, `upcomingForInstructor`) — consumed by `Booking\BookingEndpoint::myLessons()` and `Auth\StudentController` - Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` — `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`) - REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint` - Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute). In single-class mode `assets/js/group-classes.js` leaves the class description out of the card, since the page it is embedded on already describes the class; the schedule, instructor, schedule note, price and enrolment controls are still shown. @@ -193,6 +225,7 @@ class becomes enrollable for them — they choose whether to enrol. - `tests/Unit/GroupClass/GroupAccessTest.php` - `tests/Unit/GroupClass/GroupAccessRepositoryTest.php` - `tests/Unit/GroupClass/GroupClassPageTest.php` +- `tests/Unit/GroupClass/SessionScheduleTest.php` - `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes) ## Enrolling A Child diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index d724fb4..0d34633 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.md @@ -126,12 +126,20 @@ active `private_lesson` offerings whose `duration_minutes` matches the slot. for students; the instructor's for callers with `manage_availability`), each with the slot's `start_dt`/`end_dt`. +It also returns **upcoming group-class sessions**, sorted in among the lessons by +start time (`GroupClass\SessionSchedule`). A student gets every remaining session +of every class they are enrolled in; an instructor gets every session of the +classes they teach. These rows carry `kind: "group_class"` — a session is a date +in a term rather than a booked slot, so `booking.js` labels it and gives it no +Cancel button. Lesson rows carry no `kind`, and that absence is what marks them +cancellable. + Group classes follow the same registration flow but enrol against an offering of kind `group_class`; see `group-classes.md`. ## Admin Interface - **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors -- **My Lessons** (`view_own_lessons`): upcoming lessons for the logged-in instructor. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it. +- **My Lessons** (`view_own_lessons`): upcoming lessons — and upcoming sessions of the instructor's own group classes — for the logged-in instructor. Hidden for users who also hold `view_all_lessons` — Scheduler is a superset, so the menu item would only duplicate it. Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week` query params, same pattern as the availability page, bucketed via diff --git a/docs/features/parent-guardian-accounts.md b/docs/features/parent-guardian-accounts.md index bb2f0de..445540b 100644 --- a/docs/features/parent-guardian-accounts.md +++ b/docs/features/parent-guardian-accounts.md @@ -201,19 +201,36 @@ Per child the form collects: practice they describe the student (instrument, level, school). The guardian answers them on the child's behalf; the answer row's `student_id` is the child. +### What the account holder gives when they are a student + +Under `self` and `both` the account holder is a student too, so the **About you** +panel asks them for exactly the same two things every other student gives: their +**birth year** (`us_birth_year`, the same meta key and the same +`normaliseBirthYear()` rule — `GuardianService::setBirthYear()` writes both cases) +and the **account-scope questions**. Both are stored against their own user id. + +The panel sits on the main form, above the students, rather than behind a "Next". +The questions used to be a second step, which put what the studio needs to know +about an adult student on a screen they reached only after everything else; now +one page holds one decision each — who you are registering, about you, about +them. + +`register.js` takes the whole **About you** fieldset out of play under +`students`, by `disabled` as well as `hidden`: a disabled fieldset is neither +validated nor submitted, so a `required` field cannot block a form on a control +nobody can reach. The students block is toggled the same way, and the server +enforces both rules regardless — which is what makes them hold with JavaScript +off. The profile screen has no such problem: its forms are always visible, so the +attribute is static there. + Name and birth year are marked required in the labels the same way a required -question is, but the signup form **cannot** lean on the browser to enforce them: -the child blocks are hidden until the parent/guardian box is ticked, and a -`required` field inside a hidden container makes the form unsubmittable with no -control the user can reach to fix. `register.js` therefore puts `required` on -and takes it off along with the block itself (`[data-us-child-required]`), and -the server checks regardless — which is what makes the rule hold with -JavaScript off. The profile screen has no such problem: its forms are always -visible, so the attribute is static there. +question is; `[data-us-child-required]` keeps the attribute on the child fields +in step with the block they live in. Order of operations in `RegistrationPage::handleSubmit()`: -1. Validate the guardian's own fields (email, password, policies). +1. Validate the account holder's own fields (email, password, policies, and — + when they are a student — their birth year and answers). 2. Validate **every** child block — a missing name, a missing or unusable birth year, or a missing required per-child answer fails the whole submission **before** any user is created, so a half-registered family is never left @@ -221,7 +238,8 @@ Order of operations in `RegistrationPage::handleSubmit()`: always renders one spare for "add another"; a block with anything at all typed into it is kept and reported on, rather than silently discarding what the guardian entered. -3. Create the guardian user. +3. Create the guardian user, and record `us_guardian_only` and (when they are a + student) their birth year against it. 4. For each child: create the accountless user, link it, record its answers, and record the signup policy acceptances **against the child** with `accepted_by = `. diff --git a/docs/features/policies.md b/docs/features/policies.md index 20fc24b..aea0b01 100644 --- a/docs/features/policies.md +++ b/docs/features/policies.md @@ -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 diff --git a/docs/features/registration-questions.md b/docs/features/registration-questions.md index 60940a0..363c7c0 100644 --- a/docs/features/registration-questions.md +++ b/docs/features/registration-questions.md @@ -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 = `. 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) diff --git a/docs/features/student-administration.md b/docs/features/student-administration.md index 4a0abfc..fa01b86 100644 --- a/docs/features/student-administration.md +++ b/docs/features/student-administration.md @@ -27,6 +27,10 @@ No new tables. The views are composed from existing data: - **Account** — display name, email, registered date. - **Upcoming lessons** and **Past lessons** — split by the linked availability slot's `start_dt`; each shows date/time, offering, instructor, and status. + **Upcoming lessons** also lists the student's upcoming group-class sessions + (`GroupClass\SessionSchedule`, marked "group class"), so one table answers + "what are they booked into next week?". Only upcoming ones: past dates would + bury the lessons, and the enrolment table below already holds the history. - **Group-class enrolments** — active/past, with offering title and status. - **Policy acceptances** — every acceptance the student has recorded, newest first: policy title, version, context (account signup / lesson / enrolment), @@ -51,7 +55,25 @@ All actions are nonce-protected POSTs handled on the detail page: voided. A paid lesson is credited back to the student's account (see `credits.md`) rather than refunded. - **Withdraw** — on an active group-class enrolment: marked `cancelled` (freeing - its capacity seat), with the same pending-payment voiding. + its capacity seat), with the same pending-payment voiding. This is the only way + to remove a class; the group-class rows in **Upcoming lessons** carry no Cancel + action, because there is no such thing as cancelling one session of a term. + +## Deleting a user +Deleting a WordPress user is a core action that knows nothing about lessons, so +`Auth\DeletedUserCleanup` hooks `delete_user` (and `wpmu_delete_user`) and gives +back what the account was holding: every **upcoming** lesson is marked +`cancelled`, its availability slot released for rebooking, and its still-pending +payment voided; every **active** group-class enrolment is cancelled and its +pending payment voided. Without it the slots stayed marked booked and unbookable +by anyone else, the lessons stayed on the instructor's schedule under a name that +no longer resolved, and a class kept a seat filled by nobody. + +Past lessons are deliberately untouched: they happened, they may have been paid +for, and the payment report has to keep adding up. No account credit is issued +for a paid lesson either, unlike a cancellation the student asks for — a credit +can only be spent on the account being deleted, so a refund owed to someone who +has left is the studio's decision to make and record. ## Capabilities - `manage_students` — studio admin (administrators inherit it via the @@ -73,6 +95,8 @@ All actions are nonce-protected POSTs handled on the detail page: refuse records that don't belong to the student, and reuse `Payment\PaymentService::voidPending`) and account updates via `wp_update_user` (unit-tested with mocked repositories). +- Group-class sessions in the upcoming table: `GroupClass\SessionSchedule::upcomingForStudent()` +- Deletion cleanup: `Auth\DeletedUserCleanup` (hooked in `Plugin::boot()`) - Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested) - The upcoming/past split is extracted into a small pure helper so it is unit-testable (the controller itself follows the repo convention of not being @@ -80,6 +104,7 @@ All actions are nonce-protected POSTs handled on the detail page: ## Tests - `tests/Unit/Auth/StudentScheduleTest.php` (the pure upcoming/past split helper) +- `tests/Unit/Auth/DeletedUserCleanupTest.php` (release on user deletion) - `tests/Unit/Auth/StudentHistoryTest.php` (history display rows + fallbacks) - `tests/Unit/Auth/StudentActionsTest.php` (cancel/withdraw guards + side effects, account validation) diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 31a1c0e..1a6a387 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -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(); diff --git a/src/Auth/DeletedUserCleanup.php b/src/Auth/DeletedUserCleanup.php new file mode 100644 index 0000000..b12e833 --- /dev/null +++ b/src/Auth/DeletedUserCleanup.php @@ -0,0 +1,75 @@ +bookings->findUpcomingForStudent( $userId ) 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( $userId ) as $enrollment ) { + if ( Enrollment::STATUS_ACTIVE !== $enrollment->status ) { + continue; + } + + $this->enrollments->updateStatus( (int) $enrollment->id, Enrollment::STATUS_CANCELLED ); + $this->payments->voidPending( $enrollment->paymentId ); + } + } +} diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index b3754f4..64ff3f7 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -128,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' ); @@ -328,6 +329,9 @@ class RegistrationPage { $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 ) { @@ -356,6 +360,15 @@ class RegistrationPage { } } + // 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' ); } @@ -384,6 +397,10 @@ class RegistrationPage { // "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 ) { diff --git a/src/Auth/StudentController.php b/src/Auth/StudentController.php index de71c17..2db29b0 100644 --- a/src/Auth/StudentController.php +++ b/src/Auth/StudentController.php @@ -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,7 @@ class StudentController { return [ 'id' => (int) $lesson->id, + 'kind' => 'lesson', 'start_dt' => $slot ? $slot->startDt : '', 'end_dt' => $slot ? $slot->endDt : '', 'offering' => $offering ? $offering->title : '—', @@ -201,4 +212,31 @@ 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> + */ + 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'], + 'offering' => $session['offering_title'], + 'instructor' => $instructor ? $instructor->display_name : (string) $session['instructor_id'], + 'status' => $session['status'], + ]; + }, + $this->sessions->upcomingForStudent( $studentId, $now ) + ); + } } diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index ba4cd74..1f13980 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -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> + */ + 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 diff --git a/src/GroupClass/SessionSchedule.php b/src/GroupClass/SessionSchedule.php new file mode 100644 index 0000000..d96a59e --- /dev/null +++ b/src/GroupClass/SessionSchedule.php @@ -0,0 +1,134 @@ + + */ + 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; + } + + foreach ( $this->windowsFrom( $offering, $now ) as $window ) { + $rows[] = [ + 'enrollment_id' => (int) $enrollment->id, + 'offering_id' => (int) $offering->id, + 'offering_title' => $offering->title, + 'instructor_id' => $enrollment->instructorId, + 'status' => $enrollment->status, + 'start_dt' => $window['start'], + 'end_dt' => $window['end'], + 'duration_minutes' => $offering->durationMinutes, + ]; + } + } + + 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 + */ + public function upcomingForInstructor( int $instructorId, string $now ): array { + $rows = []; + + $classes = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS, activeOnly: true ); + + foreach ( $classes as $offering ) { + foreach ( $this->windowsFrom( $offering, $now ) as $window ) { + $rows[] = [ + 'enrollment_id' => 0, + 'offering_id' => (int) $offering->id, + 'offering_title' => $offering->title, + 'instructor_id' => $instructorId, + 'status' => Enrollment::STATUS_ACTIVE, + 'start_dt' => $window['start'], + 'end_dt' => $window['end'], + 'duration_minutes' => $offering->durationMinutes, + ]; + } + } + + return self::sortedByStart( $rows ); + } + + /** + * The class's session windows that have not started yet. + * + * @return list + */ + private function windowsFrom( Offering $offering, string $now ): array { + return array_values( + array_filter( + $offering->sessionWindows(), + static fn( array $window ): bool => $window['start'] >= $now + ) + ); + } + + /** + * Soonest session first, so classes from separate enrolments interleave by + * date rather than arriving grouped by class. + * + * @param list $rows + * @return list + */ + 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; + } +} diff --git a/src/Guardian/GuardianService.php b/src/Guardian/GuardianService.php index 33de21b..48b4e22 100644 --- a/src/Guardian/GuardianService.php +++ b/src/Guardian/GuardianService.php @@ -392,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 ); @@ -449,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. * diff --git a/src/Plugin.php b/src/Plugin.php index 394f92e..5efacb6 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -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 ) )->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(); diff --git a/src/Policy/PolicyController.php b/src/Policy/PolicyController.php index ed62822..9f02d8f 100644 --- a/src/Policy/PolicyController.php +++ b/src/Policy/PolicyController.php @@ -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 ); diff --git a/src/Policy/PolicyRepository.php b/src/Policy/PolicyRepository.php index 428a0ae..ff2cc3a 100644 --- a/src/Policy/PolicyRepository.php +++ b/src/Policy/PolicyRepository.php @@ -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, diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index 00ae324..1bdddab 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -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 ); diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php index 977af61..2eb8519 100644 --- a/src/ShortcodeRegistrar.php +++ b/src/ShortcodeRegistrar.php @@ -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 diff --git a/templates/admin/policies.php b/templates/admin/policies.php index e6f3c09..bcebb82 100644 --- a/templates/admin/policies.php +++ b/templates/admin/policies.php @@ -70,6 +70,22 @@ if (! defined('ABSPATH')) {

title)); ?>

+

+

+ slug)); + ?> +

+
+ + + + + + +
+

diff --git a/templates/admin/questions.php b/templates/admin/questions.php index dd0bbf6..b7ea7e6 100644 --- a/templates/admin/questions.php +++ b/templates/admin/questions.php @@ -41,7 +41,7 @@ if (! defined('ABSPATH')) {

-

+

title)); ?>

diff --git a/templates/admin/student-detail.php b/templates/admin/student-detail.php index dd21b4e..f1869c2 100644 --- a/templates/admin/student-detail.php +++ b/templates/admin/student-detail.php @@ -5,10 +5,12 @@ if (! defined('ABSPATH')) { exit; } +use Unsupervised\Schedular\GroupClass\SessionSchedule; + /** * @var \WP_User $student - * @var list $upcoming - * @var list $past + * @var list $upcoming Booked lessons and upcoming group-class sessions, soonest first. + * @var list $past * @var list $enrolments * @var list $acceptances * @var list $registrationInfo @@ -49,14 +51,27 @@ $renderLessons = static function (array $rows, bool $withActions = false): void + - + + + + + + - + + diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php index ebe3149..97e0470 100644 --- a/templates/frontend/register-page.php +++ b/templates/frontend/register-page.php @@ -22,7 +22,7 @@ if (! defined('ABSPATH')) { * @var string $loginUrl Where the post-confirmation sign-in link points. * @var string $error * @var list $policyForms - * @var list $accountQuestions Studio-wide questions answered as step two. + * @var list $accountQuestions Studio-wide questions, asked of every student — the account holder included when they are one. */ ?> @@ -49,153 +49,140 @@ if (! defined('ABSPATH')) { - - > + -
-

- - isGroup()) : ?> - - - - -

-

- - -

-

- - - - -

- -
- - __('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'), - ]; - ?> - $label) : ?> -

- -

- - - -
-

- - -
-

- - -

-

- - -

- - id . ']', - 'us-child-0-q-' . (int) $question->id, - enforceRequired: false - ); - ?> - -
- -

- -

-
-
- - -
- - -
-

title); ?>

-
bodyHtml()); ?>
- -
- -
- - - -

- - - -

+

+ + isGroup()) : ?> + -

- -

+ -
+

+

+ + +

+

+ + + + +

- -
-
- - - id . ']', 'us-reg-q-' . (int) $question->id); - ?> - -
+
+ + __('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'), + ]; + ?> + $label) : ?>

- - +

+ +
+ + +
+ +

+ + +

+ + id . ']', 'us-reg-q-' . (int) $question->id); + ?> + +
+ + +
+ +

+ + +
+

+ + +

+

+ + +

+ + id . ']', + 'us-child-0-q-' . (int) $question->id, + enforceRequired: false + ); + ?> +
+ +

+ +

+
+ + +
+ + +
+

title); ?>

+
bodyHtml()); ?>
+ +
+ +
+ +

+ +

diff --git a/tests/Unit/Auth/DeletedUserCleanupTest.php b/tests/Unit/Auth/DeletedUserCleanupTest.php new file mode 100644 index 0000000..cf6e6ce --- /dev/null +++ b/tests/Unit/Auth/DeletedUserCleanupTest.php @@ -0,0 +1,137 @@ +bookings = Mockery::mock(BookingRepository::class); + $this->availability = Mockery::mock(AvailabilityRepository::class); + $this->enrollments = Mockery::mock(EnrollmentRepository::class); + $this->payments = Mockery::mock(PaymentService::class); + + $this->cleanup = new DeletedUserCleanup( + $this->bookings, + $this->availability, + $this->enrollments, + $this->payments + ); + } + + 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); + } +} diff --git a/tests/Unit/Auth/RegistrationPageTest.php b/tests/Unit/Auth/RegistrationPageTest.php index 92cbb39..323870c 100644 --- a/tests/Unit/Auth/RegistrationPageTest.php +++ b/tests/Unit/Auth/RegistrationPageTest.php @@ -78,6 +78,7 @@ class RegistrationPageTest extends TestCase $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, @@ -133,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); @@ -150,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); @@ -170,7 +171,7 @@ class RegistrationPageTest extends TestCase public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void { - $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test', 'birth_year' => '1990' ]; Functions\when('is_email')->justReturn(true); Functions\when('email_exists')->justReturn(false); @@ -197,7 +198,7 @@ class RegistrationPageTest extends TestCase public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void { - $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test', 'birth_year' => '1990' ]; Functions\when('is_email')->justReturn(true); Functions\when('email_exists')->justReturn(false); @@ -365,7 +366,7 @@ class RegistrationPageTest extends TestCase public function testRejectsWhenARequiredPolicyIsUnaccepted(): void { - $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test', 'birth_year' => '1990' ]; Functions\when('is_email')->justReturn(true); @@ -384,7 +385,7 @@ class RegistrationPageTest extends TestCase public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void { - $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test', 'birth_year' => '1990' ]; Functions\when('is_email')->justReturn(true); @@ -406,6 +407,7 @@ class RegistrationPageTest extends TestCase $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', + 'birth_year' => '1990', 'us_answers' => [ '5' => 'By a friend' ], ]; @@ -438,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); @@ -756,6 +758,7 @@ class RegistrationPageTest extends TestCase $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Grace', + 'birth_year' => '1990', 'us_registering_for' => $mode, ]; @@ -811,6 +814,7 @@ class RegistrationPageTest extends TestCase $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Grace', + 'birth_year' => '1990', 'us_registering_for' => 'something-else', 'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]], ]; @@ -836,6 +840,7 @@ class RegistrationPageTest extends TestCase $_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']]], @@ -866,6 +871,7 @@ class RegistrationPageTest extends TestCase $_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']]], @@ -1104,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); @@ -1117,4 +1123,128 @@ class RegistrationPageTest extends TestCase self::assertSame('invite', $this->submit(new Invite(email: 'a@b.test', 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: 'a@b.test', token: 'hash'), false)); + } + + /** @return array */ + 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: 'a@b.test', 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 */ + 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: 'a@b.test', 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.' + ); + } } diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index cbd324c..f8f5669 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -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]); + } } diff --git a/tests/Unit/GroupClass/SessionScheduleTest.php b/tests/Unit/GroupClass/SessionScheduleTest.php new file mode 100644 index 0000000..7b3290b --- /dev/null +++ b/tests/Unit/GroupClass/SessionScheduleTest.php @@ -0,0 +1,197 @@ +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::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']); + } + + /** + * A class with no time set has no derivable sessions, so it is left out of a + * dated list rather than shown at a time nobody chose. + */ + public function testAClassWithoutAScheduleYieldsNoRows(): void + { + $undated = new Offering( + instructorId: 3, + kind: Offering::KIND_GROUP_CLASS, + title: 'Choir', + durationMinutes: 60, + termStart: '2026-09-08', + id: 8, + ); + + $this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([ + new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40), + ]); + $this->offerings->shouldReceive('findById')->with(8)->andReturn($undated); + + self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00')); + } + + 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']); + } +} diff --git a/tests/Unit/Policy/PolicyControllerTest.php b/tests/Unit/Policy/PolicyControllerTest.php index db38770..c1c4a78 100644 --- a/tests/Unit/Policy/PolicyControllerTest.php +++ b/tests/Unit/Policy/PolicyControllerTest.php @@ -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 "Terms of Enrolment"', $html); + self::assertStringContainsString('Versions of "Terms of Enrolment"', $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 "Studio Policy"', $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(); diff --git a/tests/Unit/Policy/PolicyRepositoryTest.php b/tests/Unit/Policy/PolicyRepositoryTest.php index 37e2e3a..71e62d8 100644 --- a/tests/Unit/Policy/PolicyRepositoryTest.php +++ b/tests/Unit/Policy/PolicyRepositoryTest.php @@ -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')