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

Five items from the latest demo pass:

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-30 11:45:04 -03:00
co-authored by Claude Opus 5
parent 258468093b
commit cb347ffca0
33 changed files with 1419 additions and 291 deletions
+13
View File
@@ -13,6 +13,19 @@ each change under the current top section as you work.
## [1.4.1] ## [1.4.1]
### Added
- **Group classes now appear in "Your upcoming lessons".** A class is stored as a term rather than as bookable slots, so nothing that listed lessons could ever show one — a student whose whole term was a group class saw an empty schedule, and an instructor teaching one saw nothing on their My Lessons page. Each remaining session of a class you are enrolled in now sorts in among your lessons by date, labelled **group class**; instructors see every session of the classes they teach, one row per session however many students are in it. A session has no Cancel button, because there is no such thing as cancelling one date of a term — withdrawing from the class is still done from the class page. A class 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] ## [1.4.0]
### Added ### Added
+28 -3
View File
@@ -438,15 +438,40 @@
opacity: 0.75; 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; margin: 16px 0;
padding: 12px 14px; padding: 12px 14px;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
} }
.us-guardian legend { .us-reg-group legend {
padding: 0 6px; padding: 0 6px;
font-weight: 600; font-weight: 600;
} }
+24 -5
View File
@@ -588,6 +588,9 @@
function lessonStatusLabel(status) { function lessonStatusLabel(status) {
if (status === 'pending') return 'Pending payment'; if (status === 'pending') return 'Pending payment';
if (status === 'confirmed') return 'Confirmed'; 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); return status.charAt(0).toUpperCase() + status.slice(1);
} }
@@ -603,21 +606,31 @@
return ` <span class="us-my-lesson-who">— ${escHtml(String(l.student_name))}</span>`; return ` <span class="us-my-lesson-who">— ${escHtml(String(l.student_name))}</span>`;
} }
// A group-class session is a date in a term, not a booked slot: there is no
// lesson to cancel and no time to release, so it carries no Cancel button.
// Withdrawing from the class is a separate decision, made on the class page.
function isGroupSession(l) {
return l.kind === 'group_class';
}
function lessonRowHtml(l) { function lessonRowHtml(l) {
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson'; const group = isGroupSession(l);
const title = l.offering_title ? escHtml(String(l.offering_title)) : (group ? 'Group class' : 'Lesson');
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : ''; const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
const badge = group ? ' <span class="us-my-lesson-kind">Group class</span>' : '';
const action = group ? '' : `<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>`;
// The two columns are divs, not spans: as spans the layout only held up // 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 // while the stylesheet's display:flex won, and a theme rule on span
// collapsed the row onto itself. // collapsed the row onto itself.
return ` return `
<div class="us-my-lesson"> <div class="us-my-lesson">
<div class="us-my-lesson-info"> <div class="us-my-lesson-info">
<strong class="us-my-lesson-title">${title}${duration}${lessonWhoHtml(l)}</strong> <strong class="us-my-lesson-title">${title}${duration}${badge}${lessonWhoHtml(l)}</strong>
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span> <span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
</div> </div>
<div class="us-my-lesson-actions"> <div class="us-my-lesson-actions">
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span> <span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button> ${action}
</div> </div>
</div>`; </div>`;
} }
@@ -634,13 +647,19 @@
const visible = upcoming.slice(0, INITIAL_LESSON_COUNT); const visible = upcoming.slice(0, INITIAL_LESSON_COUNT);
const hidden = upcoming.slice(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 = ` myLessons.innerHTML = `
<div class="us-my-lessons"> <div class="us-my-lessons">
<h3>Your upcoming lessons</h3> <h3>${heading}</h3>
${visible.map(lessonRowHtml).join('')} ${visible.map(lessonRowHtml).join('')}
${hidden.length ? ` ${hidden.length ? `
<div class="us-my-lessons-more" hidden>${hidden.map(lessonRowHtml).join('')}</div> <div class="us-my-lessons-more" hidden>${hidden.map(lessonRowHtml).join('')}</div>
<button type="button" class="us-show-all-lessons">Show all ${upcoming.length} lessons</button> <button type="button" class="us-show-all-lessons">Show all ${upcoming.length}</button>
` : ''} ` : ''}
</div>`; </div>`;
+42 -87
View File
@@ -4,18 +4,14 @@
* Two independent behaviours, both optional — without JS every panel stays * Two independent behaviours, both optional — without JS every panel stays
* visible and the single submit still works: * visible and the single submit still works:
* *
* 1. **Two steps.** When account-signup questions are configured the form * 1. **Who are you registering?** The student section is hidden until the
* 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
* choice is "on behalf of students" or "both", and "Add another student" * choice is "on behalf of students" or "both", and "Add another student"
* clones the student block. "On behalf of students" *alone* also takes the * 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 * account holder's own **About you** panel out of play — they are not a
* in that case, so the server ignores those answers and the browser must not * student in that case, so the server ignores their birth year and answers
* demand them. Under "both" they are a student and do answer them. * and the browser must not demand them. Under "both" they are a student and
* 3. **Password strength.** The password is scored with zxcvbn (via WordPress's * 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 * own `wp.passwordStrength`) and a weak one is refused. The server applies
* its own, coarser rule regardless — see `Auth\PasswordPolicy`. * its own, coarser rule regardless — see `Auth\PasswordPolicy`.
*/ */
@@ -28,10 +24,15 @@
* Gate the form on password strength. * Gate the form on password strength.
* *
* The verdict is attached to the field with `setCustomValidity()` rather than * 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 * by disabling the submit button: an invalid field blocks the submit without
* one, the guardian-mode early one, and step two's) plus a "Next" that * the button having to know why.
* already gates on `checkValidity()`, and an invalid field blocks all of them *
* at once without any of them 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) { function enhancePassword(form) {
var field = form.querySelector('#us-reg-pass'); var field = form.querySelector('#us-reg-pass');
@@ -120,49 +121,17 @@
for (var i = 0; i < sources.length; i++) { for (var i = 0; i < sources.length; i++) {
sources[i].addEventListener('change', assess); sources[i].addEventListener('change', assess);
} }
// 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 (!field.checkValidity()) {
event.preventDefault();
field.reportValidity();
} }
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');
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;
}
}
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 choices = form.querySelectorAll('.us-registering-for');
var children = form.querySelector('#us-children'); var children = form.querySelector('#us-children');
var self = form.querySelector('#us-reg-self');
if (!choices.length || !children) { if (!choices.length || !children) {
return; return;
@@ -224,12 +194,15 @@
* each: * each:
* *
* - Are student blocks in play? For "students" and "both". * - Are student blocks in play? For "students" and "both".
* - Does the account holder answer the studio's questions themselves? For * - Is the account holder a student themselves? For "self" and "both" —
* "self" and "both" — they are a student in those two, and the questions * only then are they asked for their own birth year and answers. A pure
* describe a student. A pure guardian answers them per student instead, * guardian gives those 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 * Each panel is disabled as well as hidden. Disabling is what actually
* blocking submit. * 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() { function sync() {
var current = mode(); var current = mode();
@@ -237,32 +210,18 @@
var asksSelf = current !== 'students'; var asksSelf = current !== 'students';
children.hidden = !wantsStudents; children.hidden = !wantsStudents;
children.disabled = !wantsStudents;
// Each student's name and birth year are required, but only once the // Belt and braces alongside the disabled fieldset, so the required
// block is in play: a `required` field inside a hidden container makes // state is right if a browser ever renders the block on its own.
// 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.
var required = children.querySelectorAll('[data-us-child-required]'); var required = children.querySelectorAll('[data-us-child-required]');
for (var r = 0; r < required.length; r++) { for (var r = 0; r < required.length; r++) {
required[r].required = wantsStudents; required[r].required = wantsStudents;
} }
if (!steps) { if (self) {
return; self.hidden = !asksSelf;
} self.disabled = !asksSelf;
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;
} }
} }
@@ -292,11 +251,7 @@
var forms = document.querySelectorAll('.us-register-form form'); var forms = document.querySelectorAll('.us-register-form form');
for (var i = 0; i < forms.length; i++) { for (var i = 0; i < forms.length; i++) {
var steps = forms[i].getAttribute('data-steps') === '1' enhanceGuardian(forms[i]);
? enhanceSteps(forms[i])
: null;
enhanceGuardian(forms[i], steps);
enhancePassword(forms[i]); enhancePassword(forms[i]);
} }
}); });
+32 -18
View File
@@ -106,19 +106,33 @@ thresholds reach JavaScript via `wp_localize_script()` from the same constants
the server enforces, so the two cannot drift apart. the server enforces, so the two cannot drift apart.
The verdict is applied with `setCustomValidity()` on the password field rather 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 than by disabling a button: an invalid field stops the submit without the button
already gates on `checkValidity()`, and an invalid field stops all of them needing to know why. zxcvbn's dictionary loads asynchronously, so the gate stays
without any needing to know why. zxcvbn's dictionary loads asynchronously, so open until it arrives — the server is the check that always runs.
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 When the studio has configured **account-scope** registration questions
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the (**Offerings → Questions → "Account signup"**, see `registration-questions.md`), they
registration form becomes two steps: name/email/password/policies first, then the required are asked on the main form in an **About you** panel — alongside the account
questions. This applies to **every** signup path (invite, group link, self-approval). holder's birth year, above the students they are adding, and only when they are a
Required answers are validated before the account is created, and are stored against the new student themselves (`self` or `both`). This applies to **every** signup path
user (`us_question_answers`, `registration_type = 'account'`). A studio admin reviews them (invite, group link, self-approval). Required answers are validated before the
under **Registration Information** on the student's admin screen. 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 ## Policy Acceptance Scope
Policies declare **when** they must be accepted via `us_policies.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` - `tests/Unit/Payment/StudioSettingsTest.php`
## Parent/Guardian Signup ## Parent/Guardian Signup
The registration form also offers **"I'm registering as a parent or guardian"**, The registration form asks **"Who are you registering?"** — just myself, on behalf
which reveals a repeatable child block (name, birth year, and the of one or more students, or both — and the student-bearing choices reveal a
account-scope questions asked **per child**). Each child becomes a login-less repeatable child block (name, birth year, and the account-scope questions asked
`us_student` user linked to the guardian, and the signup policies are recorded **per child**). Each child becomes a login-less `us_student` user linked to the
once per child with the guardian as the acceptor. Available on every signup path guardian, and the signup policies are recorded once per child with the guardian as
— personal invite, group link, and self-approval. See the acceptor. Available on every signup path — personal invite, group link, and
`parent-guardian-accounts.md`. self-approval. See `parent-guardian-accounts.md`.
+33
View File
@@ -31,6 +31,37 @@ Assigning an instructor to a scheduled class removes that instructor's open
booking slots at the class time and flags any already-booked lesson that clashes; booking slots at the class time and flags any already-booked lesson that clashes;
see **Instructor assignment** in `offerings.md`. 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 ## Enrolment Flow
The class list is loaded together with the student's own enrolments The class list is loaded together with the student's own enrolments
(`GET /enrollments`); a class the student already has an `active` enrolment in (`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) - Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
- Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`) - Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`)
- Model: `Unsupervised\Schedular\GroupClass\Enrollment` - 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`) - 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` - 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. - 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/GroupAccessTest.php`
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php` - `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
- `tests/Unit/GroupClass/GroupClassPageTest.php` - `tests/Unit/GroupClass/GroupClassPageTest.php`
- `tests/Unit/GroupClass/SessionScheduleTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes) - `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
## Enrolling A Child ## Enrolling A Child
+9 -1
View File
@@ -126,12 +126,20 @@ active `private_lesson` offerings whose `duration_minutes` matches the slot.
for students; the instructor's for callers with `manage_availability`), each for students; the instructor's for callers with `manage_availability`), each
with the slot's `start_dt`/`end_dt`. 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 Group classes follow the same registration flow but enrol against an offering of
kind `group_class`; see `group-classes.md`. kind `group_class`; see `group-classes.md`.
## Admin Interface ## Admin Interface
- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors - **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` Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week`
query params, same pattern as the availability page, bucketed via query params, same pattern as the availability page, bucketed via
+28 -10
View File
@@ -201,19 +201,36 @@ Per child the form collects:
practice they describe the student (instrument, level, school). The guardian 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. 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 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: question is; `[data-us-child-required]` keeps the attribute on the child fields
the child blocks are hidden until the parent/guardian box is ticked, and a in step with the block they live in.
`required` field inside a hidden container makes the form unsubmittable with no
control the user can reach to fix. `register.js` therefore puts `required` on
and takes it off along with the block itself (`[data-us-child-required]`), and
the server checks regardless — which is what makes the rule hold with
JavaScript off. The profile screen has no such problem: its forms are always
visible, so the attribute is static there.
Order of operations in `RegistrationPage::handleSubmit()`: 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 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 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 **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 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 typed into it is kept and reported on, rather than silently discarding what
the guardian entered. 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 4. For each child: create the accountless user, link it, record its answers, and
record the signup policy acceptances **against the child** with record the signup policy acceptances **against the child** with
`accepted_by = <guardian>`. `accepted_by = <guardian>`.
+1
View File
@@ -47,6 +47,7 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa
## Admin Interface ## Admin Interface
**Policies** in wp-admin (`manage_policies`, studio admin only): **Policies** in wp-admin (`manage_policies`, studio admin only):
- Create a policy; draft version bodies - 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 - 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 - 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 - Publish a draft version; view acceptance history per version
+5 -5
View File
@@ -7,8 +7,8 @@ Questions come in two **scopes**:
booking a specific offering; authored per offering by the studio admin or the owning booking a specific offering; authored per offering by the studio admin or the owning
instructor, and stored against the resulting lesson or group enrolment. instructor, and stored against the resulting lesson or group enrolment.
- **Account scope** (`scope = 'account'`) — studio-wide questions every new student answers - **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 **once at account signup**, on the same page as their name and password. Authored by the
password. Authored by the studio admin only, and stored against the new user account. studio admin only, and stored against the new user account.
Both scopes share the `us_questions` / `us_question_answers` tables, the same field types, Both scopes share the `us_questions` / `us_question_answers` tables, the same field types,
and the same authoring page (**Offerings → Questions**). 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. 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. 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')`. 1. The `[us_student_register]` page (`Auth\RegistrationPage`) loads active account-scope questions via `QuestionRepository::findByScope('account')`.
2. The form renders as two steps: step one is email/name/password/policies, step two is the questions. `assets/js/register.js` reveals step two behind a "Next" button (progressive enhancement without JS both steps show and the single submit still works). This applies to **every** signup path (invite, group link, self-approval). 2. The form is a single page. The questions sit in an **About you** panel, alongside the account holder's birth year, between the "Who are you registering?" choice and the students being added. `assets/js/register.js` disables and hides that whole panel when the choice is "on behalf of students" — the questions describe a student and a pure guardian is not one — and puts the same questions in every child block instead. Progressive enhancement: without JS every panel shows and the single submit still works. This applies to **every** signup path (invite, group link, self-approval).
3. On submit, required answers are validated **before** the user is created (a missing answer returns an error and creates no account); after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`. 3. On submit, required answers are validated **before** the user is created (a missing answer returns an error and creates no account); after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`.
4. A studio admin reviews the answers on the student's admin screen under **Registration Information** (`Auth\StudentHistory::registrationInfo()` lists every account question paired with the student's answer, "—" when unanswered). These rows are excluded from the offering-scope "Intake answers" table. 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`) - Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController` - Admin controller: `Unsupervised\Schedular\Registration\QuestionController`
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint` (offering scope only) - 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` - 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) - 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) - Nullability repair: `dbDelta` does **not** reliably relax a column from `NOT NULL` to `NULL`, so sites created before account-scope questions kept `offering_id NOT NULL` and rejected account inserts. `QuestionRepository::ensureOfferingNullable()` re-applies the nullable definition (idempotent `ALTER … MODIFY`); `Plugin::boot()` runs it once, guarded by the `us_questions_offering_nullable` option rather than the version gate (affected sites may already be on the current version)
+26 -1
View File
@@ -27,6 +27,10 @@ No new tables. The views are composed from existing data:
- **Account** — display name, email, registered date. - **Account** — display name, email, registered date.
- **Upcoming lessons** and **Past lessons** — split by the linked availability - **Upcoming lessons** and **Past lessons** — split by the linked availability
slot's `start_dt`; each shows date/time, offering, instructor, and status. 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. - **Group-class enrolments** — active/past, with offering title and status.
- **Policy acceptances** — every acceptance the student has recorded, newest - **Policy acceptances** — every acceptance the student has recorded, newest
first: policy title, version, context (account signup / lesson / enrolment), 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 voided. A paid lesson is credited back to the student's account (see
`credits.md`) rather than refunded. `credits.md`) rather than refunded.
- **Withdraw** — on an active group-class enrolment: marked `cancelled` (freeing - **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 ## Capabilities
- `manage_students` — studio admin (administrators inherit it via the - `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 refuse records that don't belong to the student, and reuse
`Payment\PaymentService::voidPending`) and account updates via `Payment\PaymentService::voidPending`) and account updates via
`wp_update_user` (unit-tested with mocked repositories). `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) - Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested)
- The upcoming/past split is extracted into a small pure helper so it is - 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 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
- `tests/Unit/Auth/StudentScheduleTest.php` (the pure upcoming/past split helper) - `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/StudentHistoryTest.php` (history display rows + fallbacks)
- `tests/Unit/Auth/StudentActionsTest.php` (cancel/withdraw guards + side - `tests/Unit/Auth/StudentActionsTest.php` (cancel/withdraw guards + side
effects, account validation) effects, account validation)
+2 -1
View File
@@ -23,6 +23,7 @@ use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Offering\ClassSlotReconciler; use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -74,7 +75,7 @@ class AdminMenu {
$this->registrationController = new RegistrationController( $invites ); $this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer ); $this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $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->instructorController = new InstructorController();
$this->settings = $settings; $this->settings = $settings;
$this->accessSettings = new AccessSettings(); $this->accessSettings = new AccessSettings();
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
/**
* Gives back what a deleted account was holding.
*
* WordPress deletes a user without knowing anything about lessons, so a student
* removed from **Users Delete** used to leave their bookings behind: the
* availability slots stayed marked booked and unbookable by anyone else, the
* lessons stayed on the instructor's schedule under a name that no longer
* resolved, and a group class kept a seat filled by nobody.
*
* So each upcoming booking is cancelled the same way a real cancellation is
* marked cancelled, its slot released, its still-pending payment voided. Past
* lessons are deliberately left alone: they happened, they may have been paid
* for, and the payment report has to keep adding up.
*
* No account credit is issued for a paid lesson, unlike a cancellation the
* student asks for. A credit only has value against future billing on the
* account it belongs to, and that account is being deleted; a refund owed to
* someone who has left is a decision for the studio to make and record, not one
* to silently write into a table nobody will read again.
*/
class DeletedUserCleanup {
public function __construct(
private BookingRepository $bookings,
private AvailabilityRepository $availability,
private EnrollmentRepository $enrollments,
private PaymentService $payments,
) {}
public function register(): void {
// `delete_user` fires before the row goes, which is what lets the lookups
// below still find the account's bookings. `wpmu_delete_user` is the
// multisite equivalent for a user removed from the network entirely.
add_action( 'delete_user', [ $this, 'releaseBookings' ] );
add_action( 'wpmu_delete_user', [ $this, 'releaseBookings' ] );
}
/**
* Cancel and release everything the account had booked ahead of it.
*/
public function releaseBookings( int $userId ): void {
if ( $userId <= 0 ) {
return;
}
// Upcoming and not already cancelled — the only bookings that are still
// holding anything.
foreach ( $this->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 );
}
}
}
+20 -3
View File
@@ -128,9 +128,10 @@ class RegistrationPage {
// gate, so it needs the plugin stylesheet that formats it. // gate, so it needs the plugin stylesheet that formats it.
wp_enqueue_style( 'us-scheduler' ); wp_enqueue_style( 'us-scheduler' );
// The script drives both the second step and the parent/guardian section // The script drives the parent/guardian section (revealing it, taking the
// (revealing it, and cloning the child block for "add another"), so it is // account holder's own panel out of play, and cloning the child block for
// needed whenever the form itself is on screen. // "add another") and the password meter, so it is needed whenever the form
// itself is on screen.
if ( $canRegister && '' === $successType ) { if ( $canRegister && '' === $successType ) {
wp_enqueue_script( 'us-scheduler-register' ); wp_enqueue_script( 'us-scheduler-register' );
@@ -328,6 +329,9 @@ class RegistrationPage {
$children = $isGuardian ? $this->submittedChildren() : []; $children = $isGuardian ? $this->submittedChildren() : [];
$answers = $asksSelf ? $this->submittedAnswers() : []; $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 // Everything is validated before a single user is created, so a bad child
// block never leaves a half-registered family behind. // block never leaves a half-registered family behind.
if ( $isGuardian && [] === $children ) { 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 ) ) { if ( $asksSelf && $this->hasUnansweredRequired( $accountQuestions, $answers ) ) {
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' ); 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. // "both" registers them alongside the people they book for.
$this->guardians->setGuardianOnly( (int) $userId, self::FOR_STUDENTS === $registeringFor ); $this->guardians->setGuardianOnly( (int) $userId, self::FOR_STUDENTS === $registeringFor );
if ( $asksSelf ) {
$this->guardians->setBirthYear( (int) $userId, $birthYear );
}
if ( $isGuardian ) { if ( $isGuardian ) {
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId ); $failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
if ( '' !== $failure ) { if ( '' !== $failure ) {
+39 -1
View File
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson; use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\Enrollment; use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
@@ -25,6 +26,7 @@ class StudentController {
private StudentHistory $history, private StudentHistory $history,
private StudentActions $actions, private StudentActions $actions,
private GuardianService $guardians, private GuardianService $guardians,
private SessionSchedule $sessions,
) {} ) {}
public function renderPage(): void { public function renderPage(): void {
@@ -134,7 +136,15 @@ class StudentController {
$this->bookings->findByStudent( (int) $student->ID ) $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']; $upcoming = $schedule['upcoming'];
$past = $schedule['past']; $past = $schedule['past'];
@@ -194,6 +204,7 @@ class StudentController {
return [ return [
'id' => (int) $lesson->id, 'id' => (int) $lesson->id,
'kind' => 'lesson',
'start_dt' => $slot ? $slot->startDt : '', 'start_dt' => $slot ? $slot->startDt : '',
'end_dt' => $slot ? $slot->endDt : '', 'end_dt' => $slot ? $slot->endDt : '',
'offering' => $offering ? $offering->title : '—', 'offering' => $offering ? $offering->title : '—',
@@ -201,4 +212,31 @@ class StudentController {
'status' => $lesson->status, 'status' => $lesson->status,
]; ];
} }
/**
* The student's upcoming group-class sessions, shaped like the lesson rows
* they sit beside. `kind` is what keeps the table honest: a session is a date
* in a term, not a booked slot, so the row offers no "Cancel" withdrawing
* is done from the enrolment table, which removes the whole class at once.
*
* @return list<array<string, mixed>>
*/
private function groupSessionRows( int $studentId, string $now ): array {
return array_map(
static function ( array $session ): array {
$instructor = get_userdata( $session['instructor_id'] );
return [
'id' => $session['enrollment_id'],
'kind' => SessionSchedule::KIND,
'start_dt' => $session['start_dt'],
'end_dt' => $session['end_dt'],
'offering' => $session['offering_title'],
'instructor' => $instructor ? $instructor->display_name : (string) $session['instructor_id'],
'status' => $session['status'],
];
},
$this->sessions->upcomingForStudent( $studentId, $now )
);
}
} }
+35 -1
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -30,6 +31,7 @@ class BookingEndpoint {
private PaymentService $payments, private PaymentService $payments,
private CancellationPolicy $cancellationPolicy, private CancellationPolicy $cancellationPolicy,
private GuardianService $guardians, 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 public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$userId = get_current_user_id(); $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 ) ) { if ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
$lessons = $this->bookings->findUpcomingForInstructor( $userId ); $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 { } else {
// A guardian's list covers the whole household — their own lessons and // A guardian's list covers the whole household — their own lessons and
// every child's — merged and re-sorted so the soonest is first // every child's — merged and re-sorted so the soonest is first
// regardless of whose it is. // regardless of whose it is.
$lessons = []; $lessons = [];
$sessions = [];
foreach ( $this->guardians->householdIds( $userId ) as $studentId ) { 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 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'] ?? '' ) ); 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 ); return new \WP_REST_Response( $rows, 200 );
} }
/**
* One student's upcoming group-class sessions, shaped like the lesson rows
* beside them so a single list renders both. `kind` is what tells them apart:
* a session is not a booked slot, so it carries no cancel action.
*
* @return list<array<string, mixed>>
*/
private function sessionRows( int $studentId, string $now ): array {
return array_map(
fn( array $session ): array => $session + [
'kind' => SessionSchedule::KIND,
'student_name' => $this->guardians->studentName( $studentId ),
],
$this->sessions->upcomingForStudent( $studentId, $now )
);
}
/** /**
* A lesson's array form plus its slot's start/end times and the booked * 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 * offering's name, so front-end lists can show what the session is and when
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
/**
* Turns group-class enrolments into dated sessions, so a class can appear
* alongside one-to-one lessons in every "upcoming" view.
*
* A group class is stored as a term (`term_start`, `term_end`, `class_time`)
* rather than as rows in `us_availability`, which is why an enrolment on its own
* has no date on it and why nothing that listed lessons ever showed one. The
* concrete windows come from {@see Offering::sessionWindows()} the same
* derivation the billing scan and the class-slot reconciler use, so a student's
* list, an instructor's list and the invoice all agree on when the class meets.
*
* A class whose schedule is not fully specified yields no windows and so
* contributes no rows: better to leave it out of a dated list than to invent a
* time for it.
*/
class SessionSchedule {
/**
* Marks a row as a group-class session rather than a one-to-one lesson.
* Callers use it to withhold the per-lesson actions (cancel, detail links)
* that only mean something for a booked slot.
*/
public const KIND = 'group_class';
public function __construct(
private EnrollmentRepository $enrollments,
private OfferingRepository $offerings,
) {}
/**
* Upcoming sessions of every class a student is enrolled in, soonest first.
*
* A withdrawn (cancelled) enrolment contributes nothing; a completed one is
* kept, since "completed" describes the enrolment's billing state and says
* nothing about whether the class has met yet.
*
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null}>
*/
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<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null}>
*/
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<array{start: string, end: string}>
*/
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<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null}> $rows
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null}>
*/
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;
}
}
+18 -2
View File
@@ -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 * 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 * 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 * on a child who predates this change would leave the old date behind for the
* fallback to resurrect on the very next read. * 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 ); delete_user_meta( $userId, self::META_DOB );
$year = self::normaliseBirthYear( $birthYear ); $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. * A child's birth year, or an empty string when none is recorded.
* *
+2
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular; namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\DeletedUserCleanup;
use Unsupervised\Schedular\Auth\EmailConfirmationHandler; use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\AccountPage; use Unsupervised\Schedular\Auth\AccountPage;
@@ -109,6 +110,7 @@ class Plugin {
( new RegistrationLoginGate() )->register(); ( new RegistrationLoginGate() )->register();
( new ChildLoginGate() )->register(); ( new ChildLoginGate() )->register();
( new StudentAdminGuard() )->register(); ( new StudentAdminGuard() )->register();
( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService ) )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->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 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(); ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
+19
View File
@@ -76,6 +76,25 @@ class PolicyController {
return [ '', 0 ]; 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 ) { if ( 'add_version' === $action ) {
$body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) ); $body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) );
$this->service->addDraftVersion( $policyId, $body ); $this->service->addDraftVersion( $policyId, $body );
+16
View File
@@ -46,6 +46,22 @@ class PolicyRepository {
return array_map( Policy::fromRow( ... ), $rows ?? [] ); 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 { public function updateCurrentVersion( int $policyId, int $versionId ): bool {
return false !== $this->db->update( return false !== $this->db->update(
$this->table, $this->table,
+2 -1
View File
@@ -12,6 +12,7 @@ use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository; 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 ) { 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->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->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService ); $this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
+1 -1
View File
@@ -92,7 +92,7 @@ class ShortcodeRegistrar {
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true ); 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 * `password-strength-meter` is WordPress's own wrapper around zxcvbn, so
* the signup form scores a password exactly the way wp-admin does rather * the signup form scores a password exactly the way wp-admin does rather
+16
View File
@@ -70,6 +70,22 @@ if (! defined('ABSPATH')) {
<?php if (null !== $selectedPolicy) : ?> <?php if (null !== $selectedPolicy) : ?>
<h2><?php echo esc_html(sprintf(/* translators: %s: policy title */ __('Versions of "%s"', 'unsupervised-schedular'), $selectedPolicy->title)); ?></h2> <h2><?php echo esc_html(sprintf(/* translators: %s: policy title */ __('Versions of "%s"', 'unsupervised-schedular'), $selectedPolicy->title)); ?></h2>
<h3><?php esc_html_e('Rename', 'unsupervised-schedular'); ?></h3>
<p class="description">
<?php
/* translators: %s: the policy's slug. */
echo esc_html(sprintf(__('The title is what students see above the policy text. Its slug (%s) does not change, so every version already accepted stays attached to this policy.', 'unsupervised-schedular'), $selectedPolicy->slug));
?>
</p>
<form method="post">
<?php wp_nonce_field('usc_policy_action'); ?>
<input type="hidden" name="usc_action" value="rename_policy">
<input type="hidden" name="policy_id" value="<?php echo esc_attr((string) $selectedPolicy->id); ?>">
<label class="screen-reader-text" for="usc-policy-title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label>
<input type="text" name="title" id="usc-policy-title" class="regular-text" maxlength="<?php echo esc_attr((string) Policy::MAX_TITLE_LENGTH); ?>" value="<?php echo esc_attr($selectedPolicy->title); ?>" required>
<?php submit_button(esc_html__('Save Title', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
</form>
<h3><?php esc_html_e('Add Draft Version', 'unsupervised-schedular'); ?></h3> <h3><?php esc_html_e('Add Draft Version', 'unsupervised-schedular'); ?></h3>
<form method="post"> <form method="post">
<?php wp_nonce_field('usc_policy_action'); ?> <?php wp_nonce_field('usc_policy_action'); ?>
+1 -1
View File
@@ -41,7 +41,7 @@ if (! defined('ABSPATH')) {
<?php else : ?> <?php else : ?>
<?php if ($accountScope) : ?> <?php if ($accountScope) : ?>
<h2><?php esc_html_e('Account signup questions', 'unsupervised-schedular'); ?></h2> <h2><?php esc_html_e('Account signup questions', 'unsupervised-schedular'); ?></h2>
<p><?php esc_html_e('Every new student answers these required-if-marked questions as a second step after choosing their name and password.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('Every new student answers these required-if-marked questions when they register — the account holder on the signup form itself, and once per student they are registering on behalf of.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
<h2><?php echo esc_html(sprintf(/* translators: %s: offering title */ __('Questions for "%s"', 'unsupervised-schedular'), $selectedOffering->title)); ?></h2> <h2><?php echo esc_html(sprintf(/* translators: %s: offering title */ __('Questions for "%s"', 'unsupervised-schedular'), $selectedOffering->title)); ?></h2>
<?php endif; ?> <?php endif; ?>
+19 -4
View File
@@ -5,10 +5,12 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
use Unsupervised\Schedular\GroupClass\SessionSchedule;
/** /**
* @var \WP_User $student * @var \WP_User $student
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming * @var list<array{id: int, kind: string, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming Booked lessons and upcoming group-class sessions, soonest first.
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past * @var list<array{id: int, kind: string, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
* @var list<array{id: int, offering: string, status: string}> $enrolments * @var list<array{id: int, offering: string, status: string}> $enrolments
* @var list<array{policy: string, version: string, context: string, accepted_at: string}> $acceptances * @var list<array{policy: string, version: string, context: string, accepted_at: string}> $acceptances
* @var list<array{question: string, answer: string, required: bool}> $registrationInfo * @var list<array{question: string, answer: string, required: bool}> $registrationInfo
@@ -49,14 +51,27 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
</thead> </thead>
<tbody> <tbody>
<?php foreach ($rows as $row) : ?> <?php foreach ($rows as $row) : ?>
<?php $isGroupSession = ($row['kind'] ?? 'lesson') === SessionSchedule::KIND; ?>
<tr> <tr>
<td><?php echo esc_html($row['start_dt'] !== '' ? (string) mysql2date('M j, Y g:i A', $row['start_dt']) : '—'); ?></td> <td><?php echo esc_html($row['start_dt'] !== '' ? (string) mysql2date('M j, Y g:i A', $row['start_dt']) : '—'); ?></td>
<td><?php echo esc_html($row['offering']); ?></td> <td>
<?php echo esc_html($row['offering']); ?>
<?php if ($isGroupSession) : ?>
<span class="description"> <?php esc_html_e('group class', 'unsupervised-schedular'); ?></span>
<?php endif; ?>
</td>
<td><?php echo esc_html($row['instructor']); ?></td> <td><?php echo esc_html($row['instructor']); ?></td>
<td><?php echo esc_html($row['status']); ?></td> <td><?php echo esc_html($row['status']); ?></td>
<?php if ($withActions) : ?> <?php if ($withActions) : ?>
<td> <td>
<?php if ($row['status'] !== 'cancelled') : ?> <?php
/*
* A group-class session is one date in a term, not a booked
* slot: there is nothing to cancel session by session. The
* whole enrolment is withdrawn from the table below.
*/
?>
<?php if (! $isGroupSession && $row['status'] !== 'cancelled') : ?>
<form method="post" style="display:inline"> <form method="post" style="display:inline">
<?php wp_nonce_field('usc_student_actions'); ?> <?php wp_nonce_field('usc_student_actions'); ?>
<input type="hidden" name="usc_action" value="cancel_lesson"> <input type="hidden" name="usc_action" value="cancel_lesson">
+30 -43
View File
@@ -22,7 +22,7 @@ if (! defined('ABSPATH')) {
* @var string $loginUrl Where the post-confirmation sign-in link points. * @var string $loginUrl Where the post-confirmation sign-in link points.
* @var string $error * @var string $error
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms * @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
* @var list<Question> $accountQuestions Studio-wide questions answered as step two. * @var list<Question> $accountQuestions Studio-wide questions, asked of every student the account holder included when they are one.
*/ */
?> ?>
@@ -49,12 +49,10 @@ if (! defined('ABSPATH')) {
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p> <p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
<?php endif; ?> <?php endif; ?>
<?php $hasQuestions = ! empty($accountQuestions); ?> <form method="post" action="">
<form method="post" action="" <?php echo $hasQuestions ? 'data-steps="1"' : ''; ?>>
<?php wp_nonce_field('us_student_register'); ?> <?php wp_nonce_field('us_student_register'); ?>
<input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>"> <input type="hidden" name="us_invite" value="<?php echo esc_attr($token); ?>">
<div class="us-reg-step" data-step="1">
<p> <p>
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label> <label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
<?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?> <?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?>
@@ -80,7 +78,7 @@ if (! defined('ABSPATH')) {
<span class="us-password-strength" id="us-reg-pass-strength" role="status" aria-live="polite"></span> <span class="us-password-strength" id="us-reg-pass-strength" role="status" aria-live="polite"></span>
</p> </p>
<fieldset class="us-guardian"> <fieldset class="us-reg-group">
<legend><?php esc_html_e('Who are you registering?', 'unsupervised-schedular'); ?></legend> <legend><?php esc_html_e('Who are you registering?', 'unsupervised-schedular'); ?></legend>
<?php <?php
/* /*
@@ -104,9 +102,35 @@ if (! defined('ABSPATH')) {
</label> </label>
</p> </p>
<?php endforeach; ?> <?php endforeach; ?>
</fieldset>
<?php
/*
* The account holder's own student details, asked on the same page
* as everything else rather than behind a "Next": what the studio
* needs to know about them is part of registering, not a sequel to
* it. Taken out of play by register.js when they say they are
* registering *only* on behalf of other people the questions
* describe a student, and in that case they are not one.
*/
?>
<fieldset class="us-reg-group us-reg-self" id="us-reg-self">
<legend><?php esc_html_e('About you', 'unsupervised-schedular'); ?></legend>
<p>
<label for="us-reg-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="number" name="birth_year" id="us-reg-birth-year" aria-required="true" required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" autocomplete="bday-year" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p>
<?php foreach ($accountQuestions as $question) : ?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-reg-q-' . (int) $question->id);
?>
<?php endforeach; ?>
</fieldset>
<?php /* Revealed by the two student-bearing choices; without JS it is simply always visible. */ ?> <?php /* Revealed by the two student-bearing choices; without JS it is simply always visible. */ ?>
<div class="us-children" id="us-children"> <fieldset class="us-reg-group us-children" id="us-children">
<legend><?php esc_html_e('Students', 'unsupervised-schedular'); ?></legend>
<p class="us-children-intro"><?php esc_html_e('Add each student you will be booking lessons for. They do not need their own login — you book and pay for them from this account.', 'unsupervised-schedular'); ?></p> <p class="us-children-intro"><?php esc_html_e('Add each student you will be booking lessons for. They do not need their own login — you book and pay for them from this account.', 'unsupervised-schedular'); ?></p>
<?php /* The first block is the template the "Add another student" button clones. */ ?> <?php /* The first block is the template the "Add another student" button clones. */ ?>
@@ -135,7 +159,6 @@ if (! defined('ABSPATH')) {
<p> <p>
<button type="button" class="us-add-child"><?php esc_html_e('Add another student', 'unsupervised-schedular'); ?></button> <button type="button" class="us-add-child"><?php esc_html_e('Add another student', 'unsupervised-schedular'); ?></button>
</p> </p>
</div>
</fieldset> </fieldset>
<?php if (! empty($policyForms)) : ?> <?php if (! empty($policyForms)) : ?>
@@ -157,45 +180,9 @@ if (! defined('ABSPATH')) {
</fieldset> </fieldset>
<?php endif; ?> <?php endif; ?>
<?php if ($hasQuestions) : ?>
<p>
<button type="button" class="us-reg-next"><?php esc_html_e('Next', 'unsupervised-schedular'); ?></button>
<?php
/*
* In parent/guardian mode the questions are asked per child,
* up in the children section, so step two has nothing left to
* ask and "Next" leads nowhere. This submit takes its place
* hidden until the guardian box is ticked (and never shown at
* all without JS, where both steps are visible anyway and the
* step-two submit does the job).
*/
?>
<input type="submit" name="us_register" class="us-reg-submit-early" hidden value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p>
<?php else : ?>
<p> <p>
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>"> <input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p> </p>
<?php endif; ?>
</div>
<?php if ($hasQuestions) : ?>
<div class="us-reg-step" data-step="2">
<fieldset class="us-reg-questions">
<legend><?php esc_html_e('Registration information', 'unsupervised-schedular'); ?></legend>
<?php foreach ($accountQuestions as $question) : ?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-reg-q-' . (int) $question->id);
?>
<?php endforeach; ?>
</fieldset>
<p>
<button type="button" class="us-reg-back"><?php esc_html_e('Back', 'unsupervised-schedular'); ?></button>
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
</p>
</div>
<?php endif; ?>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Actions;
use Mockery;
use Unsupervised\Schedular\Auth\DeletedUserCleanup;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class DeletedUserCleanupTest extends TestCase
{
private BookingRepository&Mockery\MockInterface $bookings;
private AvailabilityRepository&Mockery\MockInterface $availability;
private EnrollmentRepository&Mockery\MockInterface $enrollments;
private PaymentService&Mockery\MockInterface $payments;
private DeletedUserCleanup $cleanup;
protected function setUp(): void
{
parent::setUp();
$this->bookings = Mockery::mock(BookingRepository::class);
$this->availability = Mockery::mock(AvailabilityRepository::class);
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->payments = Mockery::mock(PaymentService::class);
$this->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);
}
}
+138 -8
View File
@@ -78,6 +78,7 @@ class RegistrationPageTest extends TestCase
$this->ctx['guardians'] = Mockery::mock(GuardianService::class); $this->ctx['guardians'] = Mockery::mock(GuardianService::class);
// Recorded on every successful signup; the tests that care assert on it. // Recorded on every successful signup; the tests that care assert on it.
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->byDefault(); $this->ctx['guardians']->shouldReceive('setGuardianOnly')->byDefault();
$this->ctx['guardians']->shouldReceive('setBirthYear')->byDefault();
$this->ctx['page'] = new RegistrationPage( $this->ctx['page'] = new RegistrationPage(
$invites, $invites,
@@ -133,7 +134,7 @@ class RegistrationPageTest extends TestCase
public function testInviteBranchCreatesAndLogsInTheStudent(): void 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('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
@@ -150,7 +151,7 @@ class RegistrationPageTest extends TestCase
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void 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('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
@@ -170,7 +171,7 @@ class RegistrationPageTest extends TestCase
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
{ {
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ]; $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]', 'birth_year' => '1990' ];
Functions\when('is_email')->justReturn(true); Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
@@ -197,7 +198,7 @@ class RegistrationPageTest extends TestCase
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
{ {
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ]; $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]', 'birth_year' => '1990' ];
Functions\when('is_email')->justReturn(true); Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
@@ -365,7 +366,7 @@ class RegistrationPageTest extends TestCase
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
{ {
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ]; $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]', 'birth_year' => '1990' ];
Functions\when('is_email')->justReturn(true); Functions\when('is_email')->justReturn(true);
@@ -384,7 +385,7 @@ class RegistrationPageTest extends TestCase
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
{ {
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ]; $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]', 'birth_year' => '1990' ];
Functions\when('is_email')->justReturn(true); Functions\when('is_email')->justReturn(true);
@@ -406,6 +407,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Ada', 'display_name' => 'Ada',
'birth_year' => '1990',
'us_answers' => [ '5' => 'By a friend' ], 'us_answers' => [ '5' => 'By a friend' ],
]; ];
@@ -438,7 +440,7 @@ class RegistrationPageTest extends TestCase
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void 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' ]; $_REQUEST = [ 'us_invite' => 'raw-token' ];
Functions\when('is_user_logged_in')->justReturn(false); Functions\when('is_user_logged_in')->justReturn(false);
@@ -756,6 +758,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => $mode, 'us_registering_for' => $mode,
]; ];
@@ -811,6 +814,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => 'something-else', 'us_registering_for' => 'something-else',
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]], 'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
]; ];
@@ -836,6 +840,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => RegistrationPage::FOR_BOTH, 'us_registering_for' => RegistrationPage::FOR_BOTH,
'us_answers' => ['7' => 'Cello'], 'us_answers' => ['7' => 'Cello'],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]], 'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
@@ -866,6 +871,7 @@ class RegistrationPageTest extends TestCase
$_POST = [ $_POST = [
'password' => 'thistle-marrow-42', 'password' => 'thistle-marrow-42',
'display_name' => 'Grace', 'display_name' => 'Grace',
'birth_year' => '1990',
'us_registering_for' => RegistrationPage::FOR_BOTH, 'us_registering_for' => RegistrationPage::FOR_BOTH,
'us_answers' => ['7' => ' '], 'us_answers' => ['7' => ' '],
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]], 'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
@@ -1104,7 +1110,7 @@ class RegistrationPageTest extends TestCase
public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void 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('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
@@ -1117,4 +1123,128 @@ class RegistrationPageTest extends TestCase
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false)); self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
} }
/**
* The account holder is a student under "self" and "both", so they give the
* same birth year every other student does and it is stored against their
* own account under the same meta key a child's uses.
*
* @dataProvider modesWhereTheAccountHolderIsAStudent
*/
public function testTheAccountHoldersBirthYearIsRecordedWhenTheyAreAStudent(string $mode, bool $withChildren): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => '1988',
'us_registering_for' => $mode,
];
if ($withChildren) {
$_POST['children'] = [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]];
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
}
$this->stubInviteSuccess();
$this->ctx['guardians']->shouldReceive('setBirthYear')->once()->with(42, '1988');
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/** @return array<string, array{string, bool}> */
public static function modesWhereTheAccountHolderIsAStudent(): array
{
return [
'just myself' => [RegistrationPage::FOR_SELF, false],
'myself and students' => [RegistrationPage::FOR_BOTH, true],
];
}
/**
* Missing or nonsense years are refused before a single user is created, the
* same way a student's is — the browser's `required` cannot be trusted here
* because the panel is hidden for a pure guardian.
*
* @dataProvider unusableBirthYears
*/
public function testAnUnusableBirthYearForTheAccountHolderIsRejected(string $submitted): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => $submitted,
];
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
// Addressed to the person filling the form in, not to "each student".
self::assertStringContainsString('Please give your birth year', $result);
self::assertStringNotContainsString('each student', $result);
}
/** @return array<string, array{string}> */
public static function unusableBirthYears(): array
{
return [
'missing' => [''],
'two digits' => ['88'],
'not a year' => ['nineteen'],
'in future' => ['3000'],
];
}
/**
* A pure guardian is not a student, so no birth year is asked of them and
* none is stored anything posted for one is ignored, exactly as their
* answers are.
*/
public function testNoBirthYearIsStoredForAGuardianWhoIsNotAStudent(): void
{
$_POST = [
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'birth_year' => 'should be ignored',
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
];
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
$this->stubInviteSuccess();
$this->ctx['guardians']->shouldNotReceive('setBirthYear');
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
}
/**
* Everything the studio needs is asked on one page: the account holder's own
* birth year and questions sit above the students they are adding, and there
* is no second step to advance to.
*/
public function testTheFormAsksTheAccountHoldersQuestionsAboveTheStudents(): void
{
$this->stubRenderContext();
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('name="birth_year"', $html);
self::assertStringContainsString('name="us_answers[7]"', $html);
// One page, one submit: no "Next", no step panels.
self::assertStringNotContainsString('us-reg-next', $html);
self::assertStringNotContainsString('data-step', $html);
self::assertLessThan(
strpos($html, 'id="us-children"'),
strpos($html, 'name="us_answers[7]"'),
'The account holder answers the questions above the students they are adding.'
);
}
} }
@@ -11,6 +11,7 @@ use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy; use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\Booking\Lesson; use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -30,6 +31,7 @@ class BookingEndpointTest extends TestCase
private RegistrationGate $gate; private RegistrationGate $gate;
private PaymentService $payments; private PaymentService $payments;
private StudioSettings $settings; private StudioSettings $settings;
private SessionSchedule&Mockery\MockInterface $sessions;
private BookingEndpoint $endpoint; private BookingEndpoint $endpoint;
protected function setUp(): void 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('householdIds')->andReturnUsing(static fn (int $id): array => [$id])->byDefault();
$this->guardians->shouldReceive('studentName')->andReturn('Ada')->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->endpoint = new BookingEndpoint(
$this->availability, $this->availability,
$this->bookings, $this->bookings,
@@ -71,6 +78,7 @@ class BookingEndpointTest extends TestCase
$this->payments, $this->payments,
new CancellationPolicy($this->settings), new CancellationPolicy($this->settings),
$this->guardians, $this->guardians,
$this->sessions,
); );
} }
@@ -796,4 +804,93 @@ class BookingEndpointTest extends TestCase
self::assertSame([77, 78], array_column($data, 'id')); self::assertSame([77, 78], array_column($data, 'id'));
self::assertSame(['Ada', 'Grace'], array_column($data, 'student_name')); self::assertSame(['Ada', 'Grace'], array_column($data, 'student_name'));
} }
/**
* A group class has no availability slot behind it, so it never appeared in
* this list at all a student whose whole term was a group class saw an
* empty schedule. Its sessions now sort in among the booked lessons.
*/
public function testMyLessonsInterleavesGroupClassSessionsWithLessons(): void
{
Functions\when('current_user_can')->justReturn(false);
$this->guardians->shouldReceive('householdIds')->with(5)->andReturn([5]);
$this->guardians->shouldReceive('studentName')->with(5)->andReturn('Grace');
// Slot 10 starts 2026-07-01 10:00 (the fixture), so the class on
// 2026-06-30 comes first and the one on 2026-07-07 last.
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CONFIRMED, id: 78);
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->sessions->shouldReceive('upcomingForStudent')->with(5, '2026-06-01 10:00:00')->andReturn([
[
'enrollment_id' => 40,
'offering_id' => 8,
'offering_title' => 'Choir',
'instructor_id' => 3,
'status' => 'active',
'start_dt' => '2026-06-30 16:00:00',
'end_dt' => '2026-06-30 17:00:00',
'duration_minutes' => 60,
],
[
'enrollment_id' => 40,
'offering_id' => 8,
'offering_title' => 'Choir',
'instructor_id' => 3,
'status' => 'active',
'start_dt' => '2026-07-07 16:00:00',
'end_dt' => '2026-07-07 17:00:00',
'duration_minutes' => 60,
],
]);
$data = $this->endpoint->myLessons(new \WP_REST_Request([]))->get_data();
self::assertSame(
['2026-06-30 16:00:00', '2026-07-01 10:00:00', '2026-07-07 16:00:00'],
array_column($data, 'start_dt')
);
// `kind` is what lets the panel withhold a Cancel button from a session
// that is a date in a term rather than a booked slot. A lesson carries no
// `kind` at all, which is the absence the panel reads as "cancellable".
self::assertSame('group_class', $data[0]['kind']);
self::assertSame('group_class', $data[2]['kind']);
self::assertSame('Choir', $data[0]['offering_title']);
self::assertSame('Grace', $data[0]['student_name']);
self::assertArrayNotHasKey('kind', $data[1]);
}
/**
* An instructor's own group classes join their schedule the same way, and
* one session is one row however many students are enrolled in it.
*/
public function testMyLessonsAddsAnInstructorsOwnGroupClassSessions(): void
{
Functions\when('current_user_can')->justReturn(true);
$this->bookings->shouldReceive('findUpcomingForInstructor')->with(5)->andReturn([]);
$this->sessions->shouldReceive('upcomingForInstructor')->with(5, '2026-06-01 10:00:00')->andReturn([
[
'enrollment_id' => 0,
'offering_id' => 8,
'offering_title' => 'Choir',
'instructor_id' => 5,
'status' => 'active',
'start_dt' => '2026-06-30 16:00:00',
'end_dt' => '2026-06-30 17:00:00',
'duration_minutes' => 60,
],
]);
$data = $this->endpoint->myLessons(new \WP_REST_Request([]))->get_data();
self::assertCount(1, $data);
self::assertSame('group_class', $data[0]['kind']);
self::assertSame('Choir', $data[0]['offering_title']);
// Nobody's name: the row is the class, not one student's place in it.
self::assertArrayNotHasKey('student_name', $data[0]);
}
} }
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Mockery;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class SessionScheduleTest extends TestCase
{
private EnrollmentRepository&Mockery\MockInterface $enrollments;
private OfferingRepository&Mockery\MockInterface $offerings;
private SessionSchedule $schedule;
protected function setUp(): void
{
parent::setUp();
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->schedule = new SessionSchedule($this->enrollments, $this->offerings);
}
/** A three-week Tuesday class at 16:00, one hour long. */
private function choir(int $id = 8, string $title = 'Choir'): Offering
{
return new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: $title,
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-09-22',
classTime: '16:00:00',
id: $id,
);
}
public function testAStudentsEnrolmentBecomesOneRowPerRemainingSession(): void
{
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
]);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir());
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
self::assertCount(3, $rows);
self::assertSame(
['2026-09-08 16:00:00', '2026-09-15 16:00:00', '2026-09-22 16:00:00'],
array_column($rows, 'start_dt')
);
self::assertSame('2026-09-08 17:00:00', $rows[0]['end_dt']);
self::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']);
}
}
@@ -246,6 +246,74 @@ class PolicyControllerTest extends TestCase
$this->render(); $this->render();
} }
public function testRenamingAPolicyUpdatesTheTitleAndLeavesTheSlugAlone(): void
{
$policy = new Policy('Studio Policy', 'studio-policy', id: 4);
$renamed = new Policy('Terms of Enrolment', 'studio-policy', id: 4);
$_GET = ['policy_id' => '4'];
$_POST = [
'usc_action' => 'rename_policy',
'policy_id' => '4',
'title' => 'Terms of Enrolment',
];
// The lookups that guard the action see the old title; the page is
// rendered from a fresh read, so it shows the new one.
$this->policies->shouldReceive('findById')->with(4)->once()->andReturn($policy);
$this->policies->shouldReceive('updateTitle')->once()->with(4, 'Terms of Enrolment')->andReturn(true);
$this->policies->shouldReceive('findAll')->andReturn([$renamed]);
$this->policies->shouldReceive('findById')->with(4)->andReturn($renamed);
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
$html = $this->render();
self::assertStringContainsString('Policy renamed to &quot;Terms of Enrolment&quot;', $html);
self::assertStringContainsString('Versions of &quot;Terms of Enrolment&quot;', $html);
}
public function testRenamingAPolicyToNothingIsRejected(): void
{
$policy = new Policy('Studio Policy', 'studio-policy', id: 4);
$_GET = ['policy_id' => '4'];
$_POST = [
'usc_action' => 'rename_policy',
'policy_id' => '4',
'title' => ' ',
];
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
$this->policies->shouldNotReceive('updateTitle');
$html = $this->render();
self::assertStringContainsString('Versions of &quot;Studio Policy&quot;', $html);
}
public function testRenamingAPolicyBeyondTheColumnLengthIsRejected(): void
{
$policy = new Policy('Studio Policy', 'studio-policy', id: 4);
$_GET = ['policy_id' => '4'];
$_POST = [
'usc_action' => 'rename_policy',
'policy_id' => '4',
'title' => str_repeat('a', Policy::MAX_TITLE_LENGTH + 1),
];
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
$this->policies->shouldNotReceive('updateTitle');
$this->render();
}
private function render(): string private function render(): string
{ {
ob_start(); ob_start();
@@ -61,6 +61,23 @@ class PolicyRepositoryTest extends TestCase
self::assertSame(Policy::SCOPE_SIGNUP, $found[0]->acceptanceScope); 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 public function testUpdateCurrentVersion(): void
{ {
$this->db->shouldReceive('update') $this->db->shouldReceive('update')