Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c73b10d779 | ||
|
|
7ea8d653ee | ||
|
|
1e4e21e8d3 | ||
|
|
df3462a8b3
|
||
|
|
748478f2f1 | ||
|
|
f97b8a4576
|
||
|
|
325a86f247 | ||
|
|
434fe801ba
|
||
|
|
84378e856b | ||
|
|
a2cece750b | ||
|
|
5d98aedfa5 | ||
|
|
8fd7bf983d
|
||
|
|
122f7a0f53
|
||
|
|
c9a1205fc0
|
||
|
|
cb347ffca0
|
||
|
|
258468093b | ||
|
|
3a4b25a711 | ||
|
|
969d864106 | ||
|
|
69179b75c9
|
||
|
|
4e5382e259
|
||
|
|
28046e0fd1 | ||
|
|
699e479805
|
||
|
|
1b42d20541 | ||
|
|
ab5212282d
|
||
|
|
6e3affb1cb
|
||
|
|
7875cb1bf7 | ||
|
|
04cba9702c
|
||
|
|
d554e35d80 | ||
|
|
b5b9a7ac54
|
||
|
|
f0149042cc | ||
|
|
1d2f95d388
|
||
|
|
2878beb221 | ||
|
|
7e2bba79fe
|
||
|
|
3a83decc82 | ||
|
|
76caf178f0
|
||
|
|
8013d05d68 | ||
|
|
6b29c0e78e
|
||
|
|
7ea6616ba0 | ||
|
|
7fdf97b073
|
||
|
|
d3843186c0 | ||
|
|
e44972abe9 |
@@ -5,7 +5,8 @@
|
|||||||
"Bash(composer lint *)",
|
"Bash(composer lint *)",
|
||||||
"Bash(tea actions:*)",
|
"Bash(tea actions:*)",
|
||||||
"Bash(tea issue *)",
|
"Bash(tea issue *)",
|
||||||
"Bash(tea label *)"
|
"Bash(tea label *)",
|
||||||
|
"Bash(composer cs *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,57 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
|
|||||||
the plugin to the next patch version and adds a fresh section here for it. Record
|
the plugin to the next patch version and adds a fresh section here for it. Record
|
||||||
each change under the current top section as you work.
|
each change under the current top section as you work.
|
||||||
|
|
||||||
|
## [1.5.1]
|
||||||
|
|
||||||
|
## [1.5.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **A registration question can now be asked of students only, and can be required of a student without being required of the account holder.** Every account-signup question was asked of everybody who registered, on the same terms — so "School and grade" had to be put to the adult signing themselves up, and a question a studio needed answered for a child could only be made required by demanding it of everyone. Each question now says who it is asked of — everyone, or only the students you register on behalf of — and carries its own **Required** setting for each: optional for you, required for every student you enrol, is now a thing a studio can ask for. Existing questions are untouched: they stay asked of everyone, and one that was required stays required of everyone.
|
||||||
|
- **You can now edit your own details on the profile page**, not just your students'. The page is called **Your profile**, and until now the one person on it you could not change was yourself: a mistyped name at signup, or a name that had since changed, meant asking the studio to fix it. **Your details** now sits at the top of the page with your name, your birth year, and whether you take lessons yourself. Your email address is shown but not editable — it is also how you sign in, so changing it stays a studio-side job.
|
||||||
|
- **"I take lessons myself" can be corrected after signup.** Signup asks whether you are registering just yourself, only on behalf of students, or both, and the answer decides whether you are offered as a student when booking. Choosing wrongly — or taking up lessons later alongside the children you book for — used to leave you asking the studio to change it. Ticking the box makes you bookable again and asks for your birth year like any other student; unticking it takes you back off the list without discarding the birth year you already gave, so ticking it back on costs you nothing.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A recurring lesson now shows the policies the student accepted on every week of it, not just the first.** Booking a weekly lesson reserves a series of them, and the student answers the intake questions and agrees to the studio's policies once, for the whole reservation. Opening any week after the first showed no answers and no policies accepted — as though nothing had been agreed to. Nothing was ever missing: the agreement was recorded against the first lesson of the series and every other week was looking for one of its own. Each week of a series now shows the intake answers and the full acceptance record — policy, version, when it was accepted, and from where — captured when the reservation was booked. Existing bookings read correctly straight away; there is nothing to re-collect from anyone.
|
||||||
|
|
||||||
|
## [1.4.1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Group classes now appear in "Your upcoming lessons".** A class is stored as a term rather than as bookable slots, so nothing that listed lessons could ever show one — a student whose whole term was a group class saw an empty schedule, and an instructor teaching one saw nothing on their My Lessons page. Each remaining session of a class you are enrolled in now sorts in among your lessons by date, labelled **group class**; instructors see every session of the classes they teach, one row per session however many students are in it. A session has no Cancel button, because there is no such thing as cancelling one date of a term — withdrawing from the class is still done from the class page.
|
||||||
|
- A class you are enrolled in shows up **whether or not its schedule is pinned to a clock**. Class time and duration are both optional on the offering form, and the schedule note is there so a studio can simply write "Tuesdays 4:00pm" — so a class with a time but no duration lists its dates and says when each session starts rather than guessing when it ends, and a class with no time at all gets a single row carrying its schedule note (or its term dates) where the time would go. Only a class whose last day has passed drops off the list.
|
||||||
|
- A student's admin detail page now shows **Booked by** in the Account section — the name of the parent or guardian who books and pays for them, linked to their own page. It was only stated further down under Profile, and only when there was one; the row is now always there, saying in words when a student books for themselves.
|
||||||
|
- The same group-class sessions now appear in **Upcoming lessons** on a student's admin detail page, so one table answers "what are they booked into next week?". Only upcoming ones — the **Group-class enrolments** table below already holds the history.
|
||||||
|
- **A policy can be renamed.** The title was fixed at creation, so a typo or a change of wording meant creating a second policy and re-collecting everyone's acceptance. Renaming changes only what students read above the policy text: the slug stays put, so every version already accepted stays attached.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **People are named by their name again, not their email address.** Anywhere the plugin named a person it could show their email instead — "Managed by grace@example.com" in the students table, the same under **Booked by**, and instructor names on the class pages. WordPress starts a new account's nickname off as its username, and signup uses the email address as the username, so the address became the nickname of every self-registered account; the name they had typed was sitting in the account's display name the whole time. Names are now read from there when the nickname turns out to be an address, so existing accounts read correctly with nothing to fix by hand, and new signups store the name properly in the first place. Students added by a parent were never affected.
|
||||||
|
- **Deleting a parent now removes the students they booked for.** A managed student account has no login of its own and exists only so its parent has somebody to book for — with the parent gone nobody can reach it, book for it, or be billed for it, so it was left stranded on the roster still holding lesson times. Deleting a parent now releases each of their students' upcoming lessons and enrolments on the same terms as their own, and deletes the accounts. Removing a student from the family screen is unchanged and still refuses one with lessons on record.
|
||||||
|
- **The upcoming-lessons panel no longer collapses onto itself in some themes.** Rows could render on top of one another and the status badge's colour could stop short of the text inside it. Both came from the same thing: the panel never stated its own line spacing, so a theme setting a line height of zero anywhere above it — a common icon-font reset — was inherited straight through, leaving each line of text taller than the space allotted to it. The panel now sets its own.
|
||||||
|
- **Deleting a student now gives back what they had booked.** WordPress deletes a user without knowing anything about lessons, so their bookings were left behind: the times stayed marked as booked and nobody else could take them, the lessons stayed on the instructor's schedule under a name that no longer resolved, and a group class kept a seat filled by nobody. Deleting an account now cancels each of its upcoming lessons, frees the time for rebooking, cancels its active class enrolments, and voids any payment still pending on them. Past lessons are left exactly as they are — they happened, and the payment report has to keep adding up. A paid lesson is not credited back: a credit could only be spent on the account being deleted, so a refund owed to someone who has left stays the studio's decision to make.
|
||||||
|
- **A weak password is now caught before the form is submitted, not after.** The strength meter scores the password as you type, but zxcvbn's dictionary arrives a moment after the page loads — so a password typed straight away was never scored at all, and the first you heard of it was the server rejecting the whole form. The password is now re-scored on submit, so the verdict is always the one your password actually earns.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Signup is one page again.** The studio's registration questions used to be a second step behind a **Next** button; they are now asked on the main form, in an **About you** panel above the students you are adding. What the studio needs to know about you is part of registering, not a sequel to it — and there is now one submit rather than three.
|
||||||
|
- **Signup asks an adult student for their birth year**, the same four-digit year already asked of every student being registered on someone else's behalf. It is asked only when you are a student yourself — choosing **on behalf of one or more students** leaves the whole **About you** panel out, since those questions describe a student and in that case you are not one.
|
||||||
|
|
||||||
|
## [1.4.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Signup now asks **"Who are you registering?"** as a three-way choice — **just myself**, **on behalf of one or more students**, or **both** — in place of the single parent/guardian tick. The tick could only ever say "I have children to add"; it could not say whether the account holder was a student themselves, so every account was offered its own name in the **Who is this for?** picker whether or not anyone meant to book them a lesson. Choosing *on behalf of* now leaves the account holder out of that picker. Existing accounts are unaffected and stay bookable, since the flag records only the new "not a student" case.
|
||||||
|
- The studio's **account-signup questions are now asked of anyone registering as a student**, including someone registering themselves alongside their children. Choosing **both** previously collected the questions per child only, so the account holder's own instrument, level and the rest were never asked for or stored, even though they could book lessons. Their answers are recorded against their own account, and a blank required answer now names them rather than blaming "each student".
|
||||||
|
- A student's **name and birth year are now required**, marked in the form the same way a required registration question is and enforced on the server whichever way they were submitted. On signup the requirement applies only once the parent/guardian box is ticked, so registering for yourself is unaffected. A student block you have started filling in is now reported back to you rather than silently dropped when the name is missing — only a completely untouched spare block is still ignored.
|
||||||
|
- Signup and the profile page now ask for a **birth year** rather than a full date of birth — a four-digit year between 1900 and the current year, with anything else discarded rather than stored. Students added before this change keep showing a birth year, derived from the date already on file; that old full date is then dropped the first time the record is saved, so the studio ends up holding only what it now asks for. No bulk purge runs, so a site wanting the remaining old dates gone should clear the `us_date_of_birth` user meta directly.
|
||||||
|
- The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Booking a lesson no longer dead-ends on the confirmation.** The confirmation used to replace the calendar entirely, leaving a student who wanted a second lesson with nothing to click and no way back short of reloading the page. It is now a dismissible notice sitting above a freshly loaded calendar — the slot just taken already gone from it, the upcoming-lessons panel already updated — so "it worked" and "book another" are the same screen. Enrolling in a group class did the same thing and is fixed the same way.
|
||||||
|
- Upcoming lesson rows no longer render on top of each other. The row's text sits in inline elements that a theme can pull out of normal flow, which dropped the date and time onto the lesson title and the status pill onto the Cancel button; those elements are now pinned into flow alongside the rest of the panel's theme-proofing. The rows held behind **Show all** also stayed visible under the `div { display: block }` reset that many themes still carry, since `[hidden]` is only a browser default — they are now hidden for real.
|
||||||
|
|
||||||
## [1.3.0]
|
## [1.3.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,100 +4,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
|
||||||
composer install # Install all dependencies
|
|
||||||
|
|
||||||
composer test # Run the full test suite (required after every change)
|
|
||||||
composer lint # PHPStan static analysis
|
|
||||||
composer cs # PHPCS coding standards check
|
|
||||||
composer cs:fix # Auto-fix coding standards
|
|
||||||
|
|
||||||
# Run a single test file
|
|
||||||
./vendor/bin/phpunit tests/Unit/Availability/AvailabilityRepositoryTest.php
|
|
||||||
|
|
||||||
# Run a single test by name
|
|
||||||
./vendor/bin/phpunit --filter testInsertCallsWpdbInsertAndReturnsId
|
|
||||||
```
|
|
||||||
|
|
||||||
**Run `composer test` after every code change before considering a task complete.**
|
**Run `composer test` after every code change before considering a task complete.**
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Plugin Bootstrap
|
### Code organisation
|
||||||
`unsupervised-schedular.php` defines constants (`USC_VERSION`, `USC_PLUGIN_DIR`, `USC_PLUGIN_URL`), registers activation/deactivation hooks, then calls `Plugin::boot()` on `plugins_loaded`. No logic lives in the root file.
|
**Code is organised package-by-domain.** Each domain package under `src/<Domain>/` contains everything related to that domain: value objects, repositories, controllers, REST endpoints, and shortcode pages. Cross-cutting wiring classes (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) live directly under `src/`.
|
||||||
|
|
||||||
### Directory Structure
|
|
||||||
```
|
|
||||||
src/ — All plugin PHP (PSR-4 namespace: Unsupervised\Schedular\)
|
|
||||||
Availability/ — Availability slots: value object, repository, controller, REST endpoint
|
|
||||||
Booking/ — Lessons/bookings: value object, repository, controller, REST endpoint, shortcode page
|
|
||||||
Auth/ — Roles, capabilities, login page
|
|
||||||
Plugin.php — Wires all components together on plugins_loaded
|
|
||||||
Installer.php — Creates DB tables and roles on activation
|
|
||||||
Schema.php — CREATE TABLE SQL for dbDelta
|
|
||||||
AdminMenu.php — Registers wp-admin menu pages
|
|
||||||
RestRegistrar.php — Registers all REST routes under us-scheduler/v1
|
|
||||||
ShortcodeRegistrar.php — Registers [us_booking] and [us_student_login] shortcodes
|
|
||||||
BlockRegistrar.php — Registers Gutenberg dynamic-block wrappers for the shortcodes
|
|
||||||
BlockPreview.php — Static editor-preview markup for the blocks
|
|
||||||
templates/ — PHP view files included by controllers/shortcodes
|
|
||||||
assets/ — CSS and JS (vanilla JS, no build step)
|
|
||||||
tests/Unit/ — PHPUnit unit tests (PSR-4: Unsupervised\Schedular\Tests\)
|
|
||||||
Availability/ — Tests for src/Availability/
|
|
||||||
Booking/ — Tests for src/Booking/
|
|
||||||
Auth/ — Tests for src/Auth/
|
|
||||||
docs/features/ — One markdown file per feature describing data model, API, and test locations
|
|
||||||
```
|
|
||||||
|
|
||||||
**Code is organised package-by-domain** (Availability, Booking, Auth). Each domain package contains everything related to that domain: value objects, repositories, controllers, REST endpoints, and shortcode pages. Cross-cutting wiring classes (Plugin, AdminMenu, RestRegistrar, ShortcodeRegistrar, Schema) live directly under `src/`.
|
|
||||||
|
|
||||||
### Data Storage
|
### Data Storage
|
||||||
Two custom database tables (created via `dbDelta` on activation):
|
Custom database tables are created via `dbDelta` on activation; `Schema.php` holds the SQL.
|
||||||
- `{prefix}us_availability` — instructor availability windows
|
|
||||||
- `{prefix}us_lessons` — booked lessons
|
|
||||||
|
|
||||||
All database access goes through repository classes within their domain package. No direct `$wpdb` calls outside repositories.
|
All database access goes through repository classes within their domain package. No direct `$wpdb` calls outside repositories.
|
||||||
|
|
||||||
### Key Classes
|
|
||||||
|
|
||||||
| Class | Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| `Plugin` | Wires all components together on `plugins_loaded` |
|
|
||||||
| `Installer` | Creates DB tables and roles on activation |
|
|
||||||
| `Schema` | CREATE TABLE SQL strings for dbDelta |
|
|
||||||
| `AdminMenu` | Registers wp-admin menu pages |
|
|
||||||
| `RestRegistrar` | Registers all REST routes under `us-scheduler/v1` |
|
|
||||||
| `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes |
|
|
||||||
| `BlockRegistrar` | Registers Gutenberg dynamic-block wrappers for the shortcodes |
|
|
||||||
| `BlockPreview` | Static editor-preview markup for the blocks |
|
|
||||||
| `Val` | Runtime coercion of untyped WP boundary values (wpdb rows, REST params, superglobals) |
|
|
||||||
| `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps |
|
|
||||||
| `Auth\LoginPage` | Renders front-end student login form |
|
|
||||||
| `Availability\AvailabilitySlot` | Immutable value object for a slot row |
|
|
||||||
| `Availability\AvailabilityRepository` | CRUD for availability slots |
|
|
||||||
| `Availability\AvailabilityController` | Instructor availability management page |
|
|
||||||
| `Availability\AvailabilityEndpoint` | REST handlers for availability CRUD |
|
|
||||||
| `Booking\Lesson` | Immutable value object for a lesson row |
|
|
||||||
| `Booking\BookingRepository` | CRUD for lesson bookings |
|
|
||||||
| `Booking\BookingEndpoint` | REST handlers for booking and status updates |
|
|
||||||
| `Booking\BookingPage` | Renders student booking UI shell (JS takes over) |
|
|
||||||
| `Booking\LessonController` | Admin and instructor lesson list pages |
|
|
||||||
|
|
||||||
### REST API Namespace
|
### REST API Namespace
|
||||||
All endpoints live under `/wp-json/us-scheduler/v1/`. Permissions are enforced via `permission_callback` using capability checks (`manage_availability`, `book_lesson`), never role name checks.
|
All endpoints live under `/wp-json/us-scheduler/v1/`. Permissions are enforced via `permission_callback` using capability checks (`manage_availability`, `book_lesson`), never role name checks.
|
||||||
|
|
||||||
### Testing Approach
|
### Testing Approach
|
||||||
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) to stub WordPress functions without a full WP installation, and Mockery to mock `$wpdb` and other dependencies.
|
Tests stub WordPress with Brain\Monkey rather than booting a real WP install. The setup and the Brain\Monkey/Mockery API gotchas are in `tests/CLAUDE.md`.
|
||||||
|
|
||||||
All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()` / `Monkey\tearDown()` and stubs all WP translation/escape functions automatically.
|
|
||||||
|
|
||||||
**Brain\Monkey API notes:**
|
|
||||||
- `Functions\when('fn')->alias(fn() => ...)` — stub with a closure (NOT `returnUsing()`)
|
|
||||||
- `Functions\when('fn')->justReturn($val)` — stub returning a fixed value
|
|
||||||
- `Functions\expect('fn')->once()->with(...)` — assert call count and arguments
|
|
||||||
- Use `Functions\when()` (not `Functions\expect()`) when you need argument-routing (e.g. `get_role` returning different values per argument) to avoid chaining ambiguity
|
|
||||||
- Mockery matchers (e.g. `\Mockery::type()`) inside plain PHP arrays do not work with `with()` — use `\Mockery::on(fn($arr) => ...)` or `\Mockery::any()` instead
|
|
||||||
- When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
|
|
||||||
|
|
||||||
### Adding a Feature
|
### Adding a Feature
|
||||||
0. **If the feature touches `Schema.php`, bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php`.** `Plugin::boot()` only re-runs `Installer`/`dbDelta` when the stored `us_schedular_version` differs, so a schema change without a version bump never reaches existing sites and inserts into new columns fail silently.
|
0. **If the feature touches `Schema.php`, bump both the `Version:` header and `USC_VERSION` in `unsupervised-schedular.php`.** `Plugin::boot()` only re-runs `Installer`/`dbDelta` when the stored `us_schedular_version` differs, so a schema change without a version bump never reaches existing sites and inserts into new columns fail silently.
|
||||||
@@ -106,10 +29,3 @@ All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()
|
|||||||
3. Add template(s) under `templates/` if needed.
|
3. Add template(s) under `templates/` if needed.
|
||||||
4. Write unit tests under `tests/Unit/<Domain>/` mirroring the `src/<Domain>/` structure.
|
4. Write unit tests under `tests/Unit/<Domain>/` mirroring the `src/<Domain>/` structure.
|
||||||
5. Run `composer test` — all tests must pass before the feature is complete.
|
5. Run `composer test` — all tests must pass before the feature is complete.
|
||||||
|
|
||||||
### CI
|
|
||||||
Gitea Actions (`.gitea/workflows/ci.yml`) runs on every push and pull request:
|
|
||||||
- **lint** — PHPCS WordPress coding standards
|
|
||||||
- **static-analysis** — PHPStan level 10
|
|
||||||
- **test** — PHPUnit on PHP 8.1, 8.2, 8.3
|
|
||||||
- **no-debug** — rejects commits with `var_dump`, `error_log`, etc. in `src/`
|
|
||||||
|
|||||||
+205
-6
@@ -99,6 +99,59 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Theme-proofing for the leaf text. The row and its two columns are divs with
|
||||||
|
* explicit flex rules above, but the text itself still sits in inline elements
|
||||||
|
* a theme is free to take out of normal flow — an absolutely positioned,
|
||||||
|
* floated or negatively offset span drops the date/time on top of the title and
|
||||||
|
* the status pill on top of the Cancel button. Pinning the properties that
|
||||||
|
* would have to change keeps the leaves in flow, at the same id-level
|
||||||
|
* specificity the rules above rely on.
|
||||||
|
*
|
||||||
|
* `line-height` is pinned for the same reason and is the subtler one, because a
|
||||||
|
* theme does not have to target this panel to break it — it is inherited, so a
|
||||||
|
* `line-height: 0` anywhere above (the usual icon-font or sprite reset) reaches
|
||||||
|
* these elements untouched. Below 1 it produces both halves of the same bug: a
|
||||||
|
* line box shorter than its glyphs, so stacked lines in the details column
|
||||||
|
* overlap, and an inline-block pill whose background is shorter than the text
|
||||||
|
* sitting in it. Nothing here should ever inherit a line-height, so the panel
|
||||||
|
* states its own.
|
||||||
|
*/
|
||||||
|
#us-booking-app .us-my-lesson,
|
||||||
|
#us-booking-app .us-my-lesson-info,
|
||||||
|
#us-booking-app .us-my-lesson-actions,
|
||||||
|
#us-booking-app .us-my-lesson-title,
|
||||||
|
#us-booking-app .us-my-lesson-when,
|
||||||
|
#us-booking-app .us-my-lesson-duration,
|
||||||
|
#us-booking-app .us-my-lesson-who,
|
||||||
|
#us-booking-app .us-my-lesson-kind,
|
||||||
|
#us-booking-app .us-lesson-status {
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
#us-booking-app .us-my-lesson-title,
|
||||||
|
#us-booking-app .us-my-lesson-when,
|
||||||
|
#us-booking-app .us-my-lesson-duration,
|
||||||
|
#us-booking-app .us-my-lesson-who,
|
||||||
|
#us-booking-app .us-my-lesson-kind,
|
||||||
|
#us-booking-app .us-lesson-status {
|
||||||
|
position: static;
|
||||||
|
float: none;
|
||||||
|
margin: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The rows the "Show all" button reveals. `[hidden]` is only a UA-stylesheet
|
||||||
|
* rule, so any author rule setting a display on div beats it — the html5-reset
|
||||||
|
* `div { display: block }` is still widespread in themes — and the rows the
|
||||||
|
* button is meant to gate render anyway. An author !important is the only way
|
||||||
|
* to win that cascade.
|
||||||
|
*/
|
||||||
|
#us-booking-app [hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
#us-booking-app .us-show-all-lessons {
|
#us-booking-app .us-show-all-lessons {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
@@ -397,21 +450,51 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Whose lesson a row in the upcoming panel is — only shown on a family account. */
|
/*
|
||||||
.us-my-lesson-who {
|
* Whose lesson a row in the upcoming panel is — only shown on an account that
|
||||||
|
* books for more than one person. Scoped under #us-booking-app like the rest of
|
||||||
|
* the panel; as a bare class it was the one rule in the group a theme could
|
||||||
|
* outrank on a plain span.
|
||||||
|
*/
|
||||||
|
#us-booking-app .us-my-lesson-who {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -438,6 +521,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The guardian's manage-children screen ([us_family]). */
|
/* The guardian's manage-children screen ([us_family]). */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The account holder's own details, set off from the students below so the two
|
||||||
|
* halves of the page do not read as one long form.
|
||||||
|
*/
|
||||||
|
.us-family-self {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-family-self-email span {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Guidance under a control, not a label: it explains when a field matters
|
||||||
|
* rather than naming it, so it is sized down and reads after the input.
|
||||||
|
*/
|
||||||
|
.us-field-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.us-family-list {
|
.us-family-list {
|
||||||
margin: 0 0 20px;
|
margin: 0 0 20px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -457,7 +566,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-family-child-dob {
|
.us-family-child-birth-year {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
@@ -490,6 +599,96 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The live password verdict under the signup field. Colour is a reinforcement,
|
||||||
|
* not the message — the text says what is wrong on its own, so this still reads
|
||||||
|
* correctly to anyone who cannot separate the hues.
|
||||||
|
*/
|
||||||
|
.us-password-strength {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-password-strength.is-short,
|
||||||
|
.us-password-strength.is-weak {
|
||||||
|
color: #c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-password-strength.is-medium {
|
||||||
|
color: #7a5c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-password-strength.is-strong {
|
||||||
|
color: #1a7d2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The account panel: who is signed in, and the way out. Sized to sit in a
|
||||||
|
* header or sidebar, so the rules stay minimal and inherit the theme's type —
|
||||||
|
* a block that lands in a site header should look like it belongs there.
|
||||||
|
*/
|
||||||
|
.us-account p {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-account-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-account-email {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-account-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `[hidden]` is a UA-stylesheet rule, so the widespread `div { display: block }`
|
||||||
|
* theme reset outranks it — the same trap the upcoming-lessons panel hit. An
|
||||||
|
* author !important is the only way to win, and it has to sit before the
|
||||||
|
* display rule it guards against.
|
||||||
|
*/
|
||||||
|
.us-notice[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The "you're booked" / "you're enrolled" notice. It sits above the calendar
|
||||||
|
* or class list rather than replacing it, so it needs to read as a banner
|
||||||
|
* about something that just happened — not as the page's content.
|
||||||
|
*/
|
||||||
|
.us-notice {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #b7dfc0;
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f2faf4;
|
||||||
|
color: #1a5c2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-notice p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-notice-dismiss {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Shown only in block-editor previews (see BlockPreview). */
|
/* Shown only in block-editor previews (see BlockPreview). */
|
||||||
.us-editor-note {
|
.us-editor-note {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
+28
-3
@@ -284,10 +284,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'us-scheduler/family',
|
name: 'us-scheduler/family',
|
||||||
title: __('Family', 'unsupervised-schedular'),
|
title: __('Profile', 'unsupervised-schedular'),
|
||||||
description: __('Lets a parent or guardian add, edit and remove the children they book lessons for.', 'unsupervised-schedular'),
|
description: __('Lets a parent or guardian add, edit and remove the students they book lessons for.', 'unsupervised-schedular'),
|
||||||
icon: 'groups',
|
icon: 'groups',
|
||||||
keywords: ['family', 'children', 'guardian', 'parent'],
|
// 'family' and 'children' are kept as search terms only — they are
|
||||||
|
// never displayed, and the block answered to them before it was
|
||||||
|
// renamed, so anyone reaching for the old word still finds it.
|
||||||
|
keywords: ['profile', 'students', 'family', 'children', 'guardian', 'parent'],
|
||||||
shortcode: 'us_family',
|
shortcode: 'us_family',
|
||||||
attributes: {
|
attributes: {
|
||||||
loginPageId: { type: 'number', default: 0 },
|
loginPageId: { type: 'number', default: 0 },
|
||||||
@@ -304,6 +307,28 @@
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'us-scheduler/account',
|
||||||
|
title: __('Account', 'unsupervised-schedular'),
|
||||||
|
description: __('Shows the name and email of whoever is signed in, with a sign out link. Renders nothing for signed-out visitors unless a login page is chosen.', 'unsupervised-schedular'),
|
||||||
|
icon: 'admin-users',
|
||||||
|
keywords: ['account', 'sign out', 'log out', 'signed in', 'profile'],
|
||||||
|
shortcode: 'us_account',
|
||||||
|
attributes: {
|
||||||
|
loginPageId: { type: 'number', default: 0 },
|
||||||
|
},
|
||||||
|
inspector: (attributes, setAttributes) => el(
|
||||||
|
PanelBody,
|
||||||
|
{ title: __('Signing in and out', 'unsupervised-schedular') },
|
||||||
|
el(PageSelect, {
|
||||||
|
label: __('Login page', 'unsupervised-schedular'),
|
||||||
|
help: __('Where signing out returns to, and where signed-out visitors are offered a link to sign in. Without one, signing out returns to the current page and signed-out visitors see nothing.', 'unsupervised-schedular'),
|
||||||
|
defaultLabel: __('Stay on the current page', 'unsupervised-schedular'),
|
||||||
|
value: attributes.loginPageId,
|
||||||
|
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
blocks.forEach((def) => {
|
blocks.forEach((def) => {
|
||||||
|
|||||||
+92
-16
@@ -335,7 +335,12 @@
|
|||||||
|
|
||||||
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
|
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
|
||||||
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
|
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
|
||||||
if (slot) btn.addEventListener('click', () => openRegistration(slot));
|
if (slot) {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
hideConfirmation();
|
||||||
|
openRegistration(slot);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,8 +576,11 @@
|
|||||||
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
|
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
|
||||||
: null))
|
: null))
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
loadMyLessons();
|
const message = window.usPayment.message(result);
|
||||||
showConfirmation(window.usPayment.message(result));
|
|
||||||
|
// Order matters: loadSlots() clears any standing notice, and it
|
||||||
|
// is what puts the calendar back with the booked slot gone.
|
||||||
|
return loadSlots().then(() => showConfirmation(message));
|
||||||
})
|
})
|
||||||
.catch((err) => showError(err.message));
|
.catch((err) => showError(err.message));
|
||||||
}
|
}
|
||||||
@@ -580,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,21 +606,46 @@
|
|||||||
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a row meets. A class with no class time set has no clock to put it on,
|
||||||
|
// so it carries `schedule` — the studio's own wording, or its term dates — and
|
||||||
|
// that is shown verbatim in place of a date and time. A dated row with no
|
||||||
|
// duration knows when it starts but not when it ends, and says only that
|
||||||
|
// rather than inventing a finish.
|
||||||
|
function lessonWhenHtml(l) {
|
||||||
|
if (l.schedule) {
|
||||||
|
return escHtml(String(l.schedule));
|
||||||
|
}
|
||||||
|
|
||||||
|
const when = `${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}`;
|
||||||
|
|
||||||
|
return l.end_dt ? `${when}–${escHtml(timeOf(l.end_dt))}` : when;
|
||||||
|
}
|
||||||
|
|
||||||
function lessonRowHtml(l) {
|
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">${lessonWhenHtml(l)}</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>`;
|
||||||
}
|
}
|
||||||
@@ -626,13 +662,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>`;
|
||||||
|
|
||||||
@@ -668,10 +710,43 @@
|
|||||||
.catch(() => { myLessons.innerHTML = ''; });
|
.catch(() => { myLessons.innerHTML = ''; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a completed booking without taking the calendar away.
|
||||||
|
*
|
||||||
|
* This used to hide the slot list and leave the confirmation as the whole
|
||||||
|
* page, which is a dead end: the student had nothing to click and no way
|
||||||
|
* back to booking short of reloading. The notice now sits above a freshly
|
||||||
|
* loaded calendar, so "it worked" and "you can book again" are the same
|
||||||
|
* screen.
|
||||||
|
*
|
||||||
|
* Built from nodes rather than innerHTML because the message can carry a
|
||||||
|
* studio's e-transfer address.
|
||||||
|
*/
|
||||||
function showConfirmation(message) {
|
function showConfirmation(message) {
|
||||||
confirm.textContent = message;
|
confirm.textContent = '';
|
||||||
slotList.style.display = 'none';
|
|
||||||
confirm.style.display = 'block';
|
const text = document.createElement('p');
|
||||||
|
text.textContent = message;
|
||||||
|
|
||||||
|
const dismiss = document.createElement('button');
|
||||||
|
dismiss.type = 'button';
|
||||||
|
dismiss.className = 'us-notice-dismiss';
|
||||||
|
dismiss.textContent = 'Dismiss';
|
||||||
|
dismiss.addEventListener('click', hideConfirmation);
|
||||||
|
|
||||||
|
confirm.appendChild(text);
|
||||||
|
confirm.appendChild(dismiss);
|
||||||
|
|
||||||
|
// The `hidden` attribute rather than an inline display, which would
|
||||||
|
// outrank the stylesheet's `display: flex` and stack the notice's
|
||||||
|
// parts instead of laying them out in a row.
|
||||||
|
confirm.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideConfirmation() {
|
||||||
|
if (!confirm) return;
|
||||||
|
confirm.hidden = true;
|
||||||
|
confirm.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// The private-lesson catalog drives both the filter and the registration
|
// The private-lesson catalog drives both the filter and the registration
|
||||||
@@ -696,16 +771,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the load, so a caller can act once the calendar is back. */
|
||||||
function loadSlots() {
|
function loadSlots() {
|
||||||
clearError();
|
clearError();
|
||||||
loadMyLessons();
|
loadMyLessons();
|
||||||
|
|
||||||
// An upcoming-lessons-only embed has no calendar to fill.
|
// An upcoming-lessons-only embed has no calendar to fill.
|
||||||
if (!slotList) return;
|
if (!slotList) return Promise.resolve();
|
||||||
|
|
||||||
slotList.style.display = 'block';
|
hideConfirmation();
|
||||||
confirm.style.display = 'none';
|
|
||||||
Promise.all([apiFetch('availability'), loadCatalog()])
|
return Promise.all([apiFetch('availability'), loadCatalog()])
|
||||||
.then(([slots]) => {
|
.then(([slots]) => {
|
||||||
allSlots = slots;
|
allSlots = slots;
|
||||||
render();
|
render();
|
||||||
|
|||||||
@@ -173,7 +173,10 @@
|
|||||||
|
|
||||||
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
|
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
|
||||||
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
|
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
|
||||||
btn.addEventListener('click', () => openEnrolment(offering));
|
btn.addEventListener('click', () => {
|
||||||
|
hideConfirmation();
|
||||||
|
openEnrolment(offering);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
|
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
|
||||||
@@ -254,25 +257,60 @@
|
|||||||
.then((res) => (res.payment
|
.then((res) => (res.payment
|
||||||
? window.usPayment.collect('enrollment', res.id, list)
|
? window.usPayment.collect('enrollment', res.id, list)
|
||||||
: null))
|
: null))
|
||||||
.then((result) => showConfirmation(window.usPayment.message(result)))
|
.then((result) => {
|
||||||
|
const message = window.usPayment.message(result);
|
||||||
|
|
||||||
|
// Order matters: loadClasses() clears any standing notice, and
|
||||||
|
// it is what puts the list back showing the new enrolment.
|
||||||
|
return loadClasses().then(() => showConfirmation(message));
|
||||||
|
})
|
||||||
.catch((err) => showError(err.message));
|
.catch((err) => showError(err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a completed enrolment without taking the class list away. Hiding
|
||||||
|
* the list left the student on a dead-end screen with no way back to
|
||||||
|
* browsing short of a reload; the notice now sits above a freshly loaded
|
||||||
|
* list instead. Mirrors booking.js.
|
||||||
|
*
|
||||||
|
* Built from nodes rather than innerHTML because the message can carry a
|
||||||
|
* studio's e-transfer address.
|
||||||
|
*/
|
||||||
function showConfirmation(message) {
|
function showConfirmation(message) {
|
||||||
confirm.textContent = message;
|
confirm.textContent = '';
|
||||||
list.style.display = 'none';
|
|
||||||
confirm.style.display = 'block';
|
const text = document.createElement('p');
|
||||||
|
text.textContent = message;
|
||||||
|
|
||||||
|
const dismiss = document.createElement('button');
|
||||||
|
dismiss.type = 'button';
|
||||||
|
dismiss.className = 'us-notice-dismiss';
|
||||||
|
dismiss.textContent = 'Dismiss';
|
||||||
|
dismiss.addEventListener('click', hideConfirmation);
|
||||||
|
|
||||||
|
confirm.appendChild(text);
|
||||||
|
confirm.appendChild(dismiss);
|
||||||
|
|
||||||
|
// The `hidden` attribute rather than an inline display, which would
|
||||||
|
// outrank the stylesheet's `display: flex` and stack the notice's
|
||||||
|
// parts instead of laying them out in a row.
|
||||||
|
confirm.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideConfirmation() {
|
||||||
|
confirm.hidden = true;
|
||||||
|
confirm.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the load, so a caller can act once the list is back. */
|
||||||
function loadClasses() {
|
function loadClasses() {
|
||||||
clearError();
|
clearError();
|
||||||
list.style.display = 'block';
|
hideConfirmation();
|
||||||
confirm.style.display = 'none';
|
|
||||||
// The student's own enrolments are fetched alongside the catalog so a
|
// The student's own enrolments are fetched alongside the catalog so a
|
||||||
// class they already have an active enrolment in shows its status
|
// class they already have an active enrolment in shows its status
|
||||||
// instead of offering to enrol them again (the API would reject the
|
// instead of offering to enrol them again (the API would reject the
|
||||||
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
|
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
|
||||||
Promise.all([
|
return Promise.all([
|
||||||
apiFetch('offerings?kind=group_class'),
|
apiFetch('offerings?kind=group_class'),
|
||||||
apiFetch('enrollments'),
|
apiFetch('enrollments'),
|
||||||
])
|
])
|
||||||
|
|||||||
+167
-68
@@ -4,61 +4,134 @@
|
|||||||
* 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"]`
|
* choice is "on behalf of students" or "both", and "Add another student"
|
||||||
* the questions) inside a form marked `data-steps="1"`. Step two is hidden
|
* clones the student block. "On behalf of students" *alone* also takes the
|
||||||
* behind a "Next" button that only advances once step one passes native
|
* account holder's own **About you** panel out of play — they are not a
|
||||||
* validation.
|
* student in that case, so the server ignores their birth year and answers
|
||||||
* 2. **Parent/guardian.** The children section is hidden until the
|
* and the browser must not demand them. Under "both" they are a student and
|
||||||
* parent/guardian box is ticked, and "Add another child" clones the child
|
* do fill it in.
|
||||||
* block. Ticking the box also takes the guardian's *own* question panel out
|
* 2. **Password strength.** The password is scored with zxcvbn (via WordPress's
|
||||||
* of play — in guardian mode the questions are asked per child, so the
|
* own `wp.passwordStrength`) and a weak one is refused. The server applies
|
||||||
* server ignores those answers and the browser must not demand them.
|
* its own, coarser rule regardless — see `Auth\PasswordPolicy`.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
function enhanceSteps(form) {
|
var PASSWORD = window.usSchedulerPassword || {};
|
||||||
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;
|
* Gate the form on password strength.
|
||||||
|
*
|
||||||
|
* The verdict is attached to the field with `setCustomValidity()` rather than
|
||||||
|
* by disabling the submit button: an invalid field blocks the submit without
|
||||||
|
* the button having to know why.
|
||||||
|
*
|
||||||
|
* It is also re-scored on submit, which is the case the input handler alone
|
||||||
|
* misses. zxcvbn's dictionary arrives after page load, and until it does the
|
||||||
|
* meter has no opinion and the field is left valid — so a password typed in
|
||||||
|
* the first second and submitted straight away would otherwise never be
|
||||||
|
* scored at all, and the first the student heard of it would be the server
|
||||||
|
* rejecting the whole form.
|
||||||
|
*/
|
||||||
|
function enhancePassword(form) {
|
||||||
|
var field = form.querySelector('#us-reg-pass');
|
||||||
|
var output = form.querySelector('#us-reg-pass-strength');
|
||||||
|
var strings = PASSWORD.strings || {};
|
||||||
|
|
||||||
|
if (!field || !PASSWORD.minScore) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(step) {
|
// What the password must not simply repeat back. Mirrors the identity
|
||||||
step1.hidden = step !== 1;
|
// check PasswordPolicy makes server-side.
|
||||||
step2.hidden = step !== 2;
|
function identity() {
|
||||||
}
|
var out = [];
|
||||||
|
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
|
||||||
|
|
||||||
show(1);
|
for (var i = 0; i < sources.length; i++) {
|
||||||
|
var value = (sources[i].value || '').trim();
|
||||||
next.addEventListener('click', function () {
|
if (value) {
|
||||||
var fields = step1.querySelectorAll('input, select, textarea');
|
out.push(value);
|
||||||
|
if (value.indexOf('@') > 0) {
|
||||||
for (var i = 0; i < fields.length; i++) {
|
out.push(value.split('@')[0]);
|
||||||
if (!fields[i].checkValidity()) {
|
}
|
||||||
fields[i].reportValidity();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
show(2);
|
return out;
|
||||||
});
|
|
||||||
|
|
||||||
if (back) {
|
|
||||||
back.addEventListener('click', function () {
|
|
||||||
show(1);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
function assess() {
|
||||||
step2: step2,
|
var value = field.value || '';
|
||||||
next: next,
|
|
||||||
earlySubmit: form.querySelector('.us-reg-submit-early'),
|
if (!value) {
|
||||||
};
|
report('', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length < (PASSWORD.minLength || 8)) {
|
||||||
|
report(strings.short, 'short');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// zxcvbn's dictionary is fetched after load, and wp.passwordStrength
|
||||||
|
// reports -1 until it arrives. Say nothing and allow the submit in that
|
||||||
|
// window — the server still checks, and the next keystroke re-runs this
|
||||||
|
// once the dictionary is in.
|
||||||
|
if (!window.wp || !window.wp.passwordStrength || typeof window.zxcvbn === 'undefined') {
|
||||||
|
report('', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var score = window.wp.passwordStrength.meter(value, identity(), '');
|
||||||
|
|
||||||
|
if (score < 0) {
|
||||||
|
report('', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score >= 3) {
|
||||||
|
report(strings.strong, 'strong');
|
||||||
|
} else if (score >= PASSWORD.minScore) {
|
||||||
|
report(strings.medium, 'medium');
|
||||||
|
} else {
|
||||||
|
report(score <= 0 ? strings.veryWeak : strings.weak, 'weak');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show the verdict, and make it the field's validity at the same time. */
|
||||||
|
function report(message, level) {
|
||||||
|
var acceptable = '' === level || 'medium' === level || 'strong' === level;
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
output.textContent = message || '';
|
||||||
|
output.className = 'us-password-strength' + (level ? ' is-' + level : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
field.setCustomValidity(acceptable ? '' : message || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
field.addEventListener('input', assess);
|
||||||
|
field.addEventListener('blur', assess);
|
||||||
|
|
||||||
|
// The identity check depends on these, so a password typed first and an
|
||||||
|
// email typed second is still caught.
|
||||||
|
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
|
||||||
|
for (var i = 0; i < sources.length; i++) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,42 +167,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function enhanceGuardian(form, steps) {
|
function enhanceGuardian(form) {
|
||||||
var toggle = form.querySelector('#us-is-guardian');
|
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 (!toggle || !children) {
|
if (!choices.length || !children) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var addButton = children.querySelector('.us-add-child');
|
var addButton = children.querySelector('.us-add-child');
|
||||||
var nextIndex = 1;
|
var nextIndex = 1;
|
||||||
|
|
||||||
// The guardian's own question panel is only meaningful when they are
|
/** The selected "who are you registering?" value; 'self' if somehow none is. */
|
||||||
// registering for themselves. Disabling it (rather than hiding it) is what
|
function mode() {
|
||||||
// stops a `required` question the server will ignore from blocking submit.
|
for (var i = 0; i < choices.length; i++) {
|
||||||
|
if (choices[i].checked) return choices[i].value;
|
||||||
|
}
|
||||||
|
return 'self';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the form in step with the choice.
|
||||||
|
*
|
||||||
|
* Two independent questions, which is why "both" needs its own answer to
|
||||||
|
* each:
|
||||||
|
*
|
||||||
|
* - Are student blocks in play? For "students" and "both".
|
||||||
|
* - Is the account holder a student themselves? For "self" and "both" —
|
||||||
|
* only then are they asked for their own birth year and answers. A pure
|
||||||
|
* guardian gives those per student instead.
|
||||||
|
*
|
||||||
|
* Each panel is disabled as well as hidden. Disabling is what actually
|
||||||
|
* settles it: a `required` field inside a hidden container makes the form
|
||||||
|
* unsubmittable with no way to reach the offending control, and a disabled
|
||||||
|
* fieldset is neither validated nor submitted. The server enforces the
|
||||||
|
* same rules either way.
|
||||||
|
*/
|
||||||
function sync() {
|
function sync() {
|
||||||
children.hidden = !toggle.checked;
|
var current = mode();
|
||||||
|
var wantsStudents = current !== 'self';
|
||||||
|
var asksSelf = current !== 'students';
|
||||||
|
|
||||||
if (!steps) {
|
children.hidden = !wantsStudents;
|
||||||
return;
|
children.disabled = !wantsStudents;
|
||||||
|
|
||||||
|
// Belt and braces alongside the disabled fieldset, so the required
|
||||||
|
// state is right if a browser ever renders the block on its own.
|
||||||
|
var required = children.querySelectorAll('[data-us-child-required]');
|
||||||
|
for (var r = 0; r < required.length; r++) {
|
||||||
|
required[r].required = wantsStudents;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fields = steps.step2.querySelectorAll('input, select, textarea');
|
if (self) {
|
||||||
for (var i = 0; i < fields.length; i++) {
|
self.hidden = !asksSelf;
|
||||||
fields[i].disabled = toggle.checked;
|
self.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 = toggle.checked;
|
|
||||||
|
|
||||||
if (steps.earlySubmit) {
|
|
||||||
steps.earlySubmit.hidden = !toggle.checked;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle.addEventListener('change', sync);
|
for (var c = 0; c < choices.length; c++) {
|
||||||
|
choices[c].addEventListener('change', sync);
|
||||||
|
}
|
||||||
sync();
|
sync();
|
||||||
|
|
||||||
if (addButton) {
|
if (addButton) {
|
||||||
@@ -141,6 +239,10 @@
|
|||||||
nextIndex += 1;
|
nextIndex += 1;
|
||||||
|
|
||||||
children.insertBefore(clone, addButton.parentNode);
|
children.insertBefore(clone, addButton.parentNode);
|
||||||
|
|
||||||
|
// The clone carries the data attribute but not necessarily the
|
||||||
|
// current required state, so settle it the same way as the rest.
|
||||||
|
sync();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,11 +251,8 @@
|
|||||||
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])
|
enhancePassword(forms[i]);
|
||||||
: null;
|
|
||||||
|
|
||||||
enhanceGuardian(forms[i], steps);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -77,14 +77,62 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
|
|||||||
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
|
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
|
||||||
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
|
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
|
||||||
|
|
||||||
## Registration Questions (signup step two)
|
## Email and password validation
|
||||||
|
|
||||||
|
Both are checked on the server on every signup path, and the browser is given a
|
||||||
|
matching but *stricter* job so a bad password is caught before submitting.
|
||||||
|
|
||||||
|
**Email** — `type="email"` and `required` in the markup, `is_email()` on the
|
||||||
|
server, then `email_exists()` for "an account already exists for this email". A
|
||||||
|
personal invite fixes the address and the server always uses the invite's own
|
||||||
|
value, so a tampered field is ignored rather than validated.
|
||||||
|
|
||||||
|
**Password** — `Auth\PasswordPolicy` is the authority. It deliberately does
|
||||||
|
*not* try to reproduce a strength score in PHP; it rejects the categorically
|
||||||
|
bad, which is what a server can check without shipping a dictionary:
|
||||||
|
|
||||||
|
- shorter than `PasswordPolicy::MIN_LENGTH` (8 — NIST SP 800-63B's floor;
|
||||||
|
composition rules like "must contain a symbol" are deliberately **not** used,
|
||||||
|
as they push people towards predictable substitutions),
|
||||||
|
- one of the well-known leaked passwords,
|
||||||
|
- built from fewer than four distinct characters (`aaaaaaaa`, `abababab`),
|
||||||
|
- containing the user's own display name, email, or the part before the `@`.
|
||||||
|
|
||||||
|
The nuance happens in the browser. `register.js` scores the password with
|
||||||
|
zxcvbn through WordPress's own `password-strength-meter` script and refuses to
|
||||||
|
submit below `PasswordPolicy::MIN_SCORE` (2 of 4 — "medium"; enough to stop a
|
||||||
|
guessable password without demanding a passphrase to book a piano lesson). The
|
||||||
|
thresholds reach JavaScript via `wp_localize_script()` from the same constants
|
||||||
|
the server enforces, so the two cannot drift apart.
|
||||||
|
|
||||||
|
The verdict is applied with `setCustomValidity()` on the password field rather
|
||||||
|
than by disabling a button: an invalid field stops the submit without the button
|
||||||
|
needing to know why. zxcvbn's dictionary loads asynchronously, so the gate stays
|
||||||
|
open until it arrives — the server is the check that always runs.
|
||||||
|
|
||||||
|
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`:
|
||||||
@@ -167,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, date of birth, 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`.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Editor Blocks
|
# Editor Blocks
|
||||||
|
|
||||||
Gutenberg dynamic-block wrappers for the plugin's four front-end shortcodes,
|
Gutenberg dynamic-block wrappers for the plugin's front-end shortcodes, so the
|
||||||
so the pages can be previewed and styled inside the block editor instead of
|
pages can be previewed and styled inside the block editor instead of appearing
|
||||||
appearing as grey shortcode text.
|
as grey shortcode text.
|
||||||
|
|
||||||
## Blocks
|
## Blocks
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ appearing as grey shortcode text.
|
|||||||
| `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` |
|
| `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` |
|
||||||
| `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` |
|
| `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` |
|
||||||
| `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` |
|
| `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` |
|
||||||
|
| `us-scheduler/family` | `[us_family]` | `Guardian\FamilyPage::render()` |
|
||||||
|
| `us-scheduler/account` | `[us_account]` | `Auth\AccountPage::render()` |
|
||||||
|
|
||||||
The shortcodes remain registered for back-compat; blocks and shortcodes share
|
The shortcodes remain registered for back-compat; blocks and shortcodes share
|
||||||
the same page objects (constructed once in `Plugin::boot()`), so front-end
|
the same page objects (constructed once in `Plugin::boot()`), so front-end
|
||||||
@@ -21,7 +23,7 @@ transform.
|
|||||||
|
|
||||||
## Block options
|
## Block options
|
||||||
|
|
||||||
Four blocks have sidebar (inspector) options:
|
Most blocks have sidebar (inspector) options:
|
||||||
|
|
||||||
| Block | Attribute | Default | Effect |
|
| Block | Attribute | Default | Effect |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -34,6 +36,8 @@ Four blocks have sidebar (inspector) options:
|
|||||||
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
|
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
|
||||||
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
|
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
|
||||||
| `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. |
|
| `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. |
|
||||||
|
| `us-scheduler/family` | `loginPageId` (number) | `0` | Where visitors who are not signed in are sent to log in. Shortcode equivalent: `[us_family login_page_id="…"]`. |
|
||||||
|
| `us-scheduler/account` | `loginPageId` (number) | `0` | Where signing out returns to, and where a signed-out visitor is offered a **Sign in** link. `0` = signing out returns to the current page, and a signed-out visitor sees **nothing at all** — see below. Shortcode equivalent: `[us_account login_page_id="…"]`. |
|
||||||
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. |
|
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. |
|
||||||
|
|
||||||
The page selects list all published pages; if a chosen page is later deleted,
|
The page selects list all published pages; if a chosen page is later deleted,
|
||||||
@@ -105,6 +109,10 @@ placeholder content:
|
|||||||
- **Login** — the real `templates/frontend/login-page.php` template (it has
|
- **Login** — the real `templates/frontend/login-page.php` template (it has
|
||||||
no request-state dependencies).
|
no request-state dependencies).
|
||||||
- **Registration** — a disabled sample of the `.us-register-form` fields.
|
- **Registration** — a disabled sample of the `.us-register-form` fields.
|
||||||
|
- **Account** — a populated sample panel. Deliberately populated whatever the
|
||||||
|
editor user's own state: on the published page a signed-out visitor may see
|
||||||
|
nothing at all, and an empty box tells the person placing the block nothing
|
||||||
|
about where it will sit.
|
||||||
|
|
||||||
Each preview starts with a `.us-editor-note` paragraph explaining what the
|
Each preview starts with a `.us-editor-note` paragraph explaining what the
|
||||||
published page shows instead. The note class only appears in editor previews.
|
published page shows instead. The note class only appears in editor previews.
|
||||||
@@ -118,5 +126,22 @@ published page shows instead. The note class only appears in editor previews.
|
|||||||
and fallbacks.
|
and fallbacks.
|
||||||
- `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and
|
- `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and
|
||||||
fallbacks.
|
fallbacks.
|
||||||
|
- `tests/Unit/Auth/AccountPageTest.php` — what each visitor sees, the
|
||||||
|
sign-out redirect target, and the signed-out empty render.
|
||||||
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
|
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
|
||||||
classes/ids and includes the editor note.
|
classes/ids and includes the editor note.
|
||||||
|
|
||||||
|
## The account block's signed-out behaviour
|
||||||
|
|
||||||
|
`us-scheduler/account` is the one block that can render **nothing**. It is meant
|
||||||
|
for a header, sidebar or account page, and its whole subject is the person
|
||||||
|
signed in — which a stranger is not. A bare "you are not signed in" in a site
|
||||||
|
header is noise that cannot be acted on, so:
|
||||||
|
|
||||||
|
- **No login page chosen** → empty string for signed-out visitors.
|
||||||
|
- **Login page chosen** → a single **Sign in** link.
|
||||||
|
|
||||||
|
Signed in, it shows the display name (`Auth\UserName::format()`, so a username
|
||||||
|
is never exposed), the account email, and a **Sign out** link — deliberately
|
||||||
|
nothing else. Signing out returns to the chosen login page, or to the current page when there
|
||||||
|
is none, so a header sign-out does not also navigate the visitor somewhere.
|
||||||
|
|||||||
@@ -31,6 +31,55 @@ Assigning an instructor to a scheduled class removes that instructor's open
|
|||||||
booking slots at the class time and flags any already-booked lesson that clashes;
|
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 you are enrolled in must never silently vanish from these lists.** Both
|
||||||
|
the class time and the duration are optional on the offering form, and the
|
||||||
|
schedule note exists precisely so a studio can write "Tuesdays 4:00pm" rather than
|
||||||
|
pin the class to a clock. So the schedule degrades instead of disappearing:
|
||||||
|
|
||||||
|
| Class has | What the list gets |
|
||||||
|
|---|---|
|
||||||
|
| date + time + duration | one dated row per remaining session, with an end time |
|
||||||
|
| date + time, no duration | one dated row per remaining session, `end_dt` empty — when it starts is worth showing without guessing when it ends |
|
||||||
|
| no class time | **one** row for the class as a whole, sorted by term start (or by "now" once the term is under way), with `schedule` text from `Offering::scheduleLabel()` — the studio's note, else the term dates, else "Schedule to be confirmed" |
|
||||||
|
| a term whose last day has passed | nothing |
|
||||||
|
|
||||||
|
`schedule` is the tell: non-null means "a class, described in words, not a session
|
||||||
|
at a known time", and every renderer shows that text in place of a date and time.
|
||||||
|
An undated row's `start_dt` is a **sort key only** — never displayed.
|
||||||
|
|
||||||
|
`Offering::sessionStarts()` is the split that makes this work: it needs only the
|
||||||
|
date and the time, because knowing *when* a class meets is a separate question
|
||||||
|
from knowing how long it runs. `sessionWindows()` is that plus the duration, and
|
||||||
|
still returns nothing without one — availability blocking and per-session billing
|
||||||
|
need both ends of a window.
|
||||||
|
|
||||||
|
Consumers mark these rows `kind = 'group_class'` (`SessionSchedule::KIND`) and
|
||||||
|
withhold the per-lesson actions from them: a session is one date in a term, not a
|
||||||
|
booked slot, so there is nothing to cancel session by session and no slot to
|
||||||
|
release. Withdrawing from the class is the separate, whole-enrolment decision.
|
||||||
|
|
||||||
|
Where they show up: the `[us_scheduler]` upcoming panel via `GET /bookings`
|
||||||
|
(students and instructors both), and the **Upcoming lessons** table on the admin
|
||||||
|
student detail page. Only *upcoming* sessions are added there — the
|
||||||
|
**Group-class enrolments** table below already records the whole history, and a
|
||||||
|
term's worth of past dates would bury the lessons under "Past lessons".
|
||||||
|
|
||||||
## Enrolment Flow
|
## 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 +224,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 +243,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
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ Students register for a private lesson by choosing an offering, picking a time (
|
|||||||
8. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
8. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
||||||
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||||
10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||||
11. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
11. The confirmation is a **dismissible notice above the calendar**, not a screen of its own. The calendar is reloaded first — so the slot just taken is gone and the upcoming-lessons panel is current — and the notice is shown over it. Booking again therefore needs no page reload. The notice clears when it is dismissed, when another slot's booking form is opened, and on any reload of the calendar. `group-classes.js` does the same for enrolments.
|
||||||
|
12. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||||
|
|
||||||
## Lesson-Type Filter
|
## Lesson-Type Filter
|
||||||
Not every open slot can be booked as every private-lesson type — a slot tied to
|
Not every open slot can be booked as every private-lesson type — a slot tied to
|
||||||
@@ -125,12 +126,20 @@ active `private_lesson` offerings whose `duration_minutes` matches the slot.
|
|||||||
for students; the instructor's for callers with `manage_availability`), each
|
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
|
||||||
@@ -151,7 +160,7 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
|||||||
- Model: `Unsupervised\Schedular\Booking\Lesson`
|
- Model: `Unsupervised\Schedular\Booking\Lesson`
|
||||||
- Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment
|
- Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment
|
||||||
- Admin controller: `Unsupervised\Schedular\Booking\LessonController`
|
- Admin controller: `Unsupervised\Schedular\Booking\LessonController`
|
||||||
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`
|
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`. A weekly series is answered for and agreed to once, against the anchor lesson, so the presenter reads `series_id ?? id` — every occurrence shows the same intake and audit trail, not just the first.
|
||||||
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
||||||
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
||||||
- Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step.
|
- Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step.
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ A guardian may also be a student in their own right — they appear in their own
|
|||||||
"who is this for?" selector alongside their children, so a parent taking lessons
|
"who is this for?" selector alongside their children, so a parent taking lessons
|
||||||
next to their kids needs only the one account.
|
next to their kids needs only the one account.
|
||||||
|
|
||||||
|
## Vocabulary: "child" in the code, "student" in the UI
|
||||||
|
|
||||||
|
The interface says **student** and **profile**; the code says **child** and
|
||||||
|
**family**. This is deliberate, not drift. Every identifier below — the
|
||||||
|
`us_guardian_links` columns, `GuardianService::createChild()`, the `children[]`
|
||||||
|
request parameters, the `child_name` form fields, the `us-scheduler/family`
|
||||||
|
block name and the `[us_family]` shortcode — is a stable contract with the
|
||||||
|
database, saved post content and existing installs, so renaming them would break
|
||||||
|
sites for no user-visible gain. Only the strings a person reads were changed.
|
||||||
|
|
||||||
|
When adding to this feature, keep the split: internal names follow the
|
||||||
|
data model, translatable strings follow the interface.
|
||||||
|
|
||||||
## Core Decision: children are accountless WordPress users
|
## Core Decision: children are accountless WordPress users
|
||||||
|
|
||||||
Every `student_id` column in `src/Schema.php` (`us_lessons`, `us_payments`,
|
Every `student_id` column in `src/Schema.php` (`us_lessons`, `us_payments`,
|
||||||
@@ -54,11 +67,28 @@ requires migrating every existing row.
|
|||||||
never be linked twice.
|
never be linked twice.
|
||||||
|
|
||||||
The table is a link table, not a child record: the child's **name** is their
|
The table is a link table, not a child record: the child's **name** is their
|
||||||
`display_name` on `wp_users`, and their date of birth is the `us_date_of_birth`
|
`display_name` on `wp_users`, and their birth year is the `us_birth_year`
|
||||||
user meta. Keeping them on the user row means the admin student screens,
|
user meta. Keeping them on the user row means the admin student screens,
|
||||||
`get_users()` ordering, and every existing `student_id` lookup keep working with
|
`get_users()` ordering, and every existing `student_id` lookup keep working with
|
||||||
no special-casing.
|
no special-casing.
|
||||||
|
|
||||||
|
### The legacy `us_date_of_birth` meta
|
||||||
|
|
||||||
|
This feature originally collected a full date of birth in `us_date_of_birth`.
|
||||||
|
Nothing writes that key any more. It is handled entirely inside
|
||||||
|
`GuardianService`:
|
||||||
|
|
||||||
|
- **Read** — `birthYear()` falls back to the year of the old date when
|
||||||
|
`us_birth_year` is absent, so a child added before the change still shows one
|
||||||
|
without a migration step.
|
||||||
|
- **Write** — `setBirthYear()` deletes `us_date_of_birth` on *every* save,
|
||||||
|
including a save that clears the year. Without that the fallback would
|
||||||
|
resurrect the old date on the next read and the year could never be cleared.
|
||||||
|
|
||||||
|
The upshot is a lazy migration: a child's full date survives until their record
|
||||||
|
is next edited, then goes for good. There is no bulk purge — a site that wants
|
||||||
|
the remaining old dates gone should delete the `us_date_of_birth` meta directly.
|
||||||
|
|
||||||
v1 is deliberately **one guardian per child**: `GuardianRepository::insert()`
|
v1 is deliberately **one guardian per child**: `GuardianRepository::insert()`
|
||||||
refuses to link a child that already has a guardian. The unique key and the
|
refuses to link a child that already has a guardian. The unique key and the
|
||||||
guardian-side lookups already support many-to-many, so adding a second guardian
|
guardian-side lookups already support many-to-many, so adding a second guardian
|
||||||
@@ -110,26 +140,107 @@ apply to.
|
|||||||
|
|
||||||
## Registration
|
## Registration
|
||||||
|
|
||||||
A **"I'm registering as a parent or guardian"** checkbox on the existing
|
A **"Who are you registering?"** choice on the existing `[us_student_register]`
|
||||||
`[us_student_register]` form (all three signup paths — personal invite, group
|
form (all three signup paths — personal invite, group link, self-approval), as
|
||||||
link, self-approval) reveals a repeatable child block. Ticking it requires at
|
three radios:
|
||||||
least one child name.
|
|
||||||
|
| Choice | `us_registering_for` | Student blocks | Account holder is a student | Answers the studio's questions |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Just myself | `self` | no | yes | for themselves |
|
||||||
|
| On behalf of one or more students | `students` | yes | **no** | per student only |
|
||||||
|
| Both — myself and one or more students | `both` | yes | yes | **per student *and* for themselves** |
|
||||||
|
|
||||||
|
The last column follows from the third, and is the whole of it: the
|
||||||
|
account-scope questions describe a *student* — instrument, level, school — so
|
||||||
|
they are asked of everyone being registered as one. Under `both` that is each
|
||||||
|
student **and** the account holder, whose answers are stored against their own
|
||||||
|
user id, not shared with anyone. Under `students` the account holder is not a
|
||||||
|
student, so anything posted for them is ignored outright.
|
||||||
|
|
||||||
|
Required answers are checked in two passes rather than one, so the error can say
|
||||||
|
whose are missing: `both` would otherwise have to blame "each student" for the
|
||||||
|
account holder's own blank field.
|
||||||
|
|
||||||
|
Radios rather than checkboxes because the three answers are mutually exclusive:
|
||||||
|
"both" only means anything as a third choice alongside the other two. Either
|
||||||
|
student-bearing choice requires at least one student name.
|
||||||
|
|
||||||
|
Anything unrecognised — a form posted without the field, an old cached page, a
|
||||||
|
crafted request — is read as `self`, the choice that collects the least and
|
||||||
|
grants the least. A missing radio must never be taken as "register these
|
||||||
|
children".
|
||||||
|
|
||||||
|
### The account holder as a student
|
||||||
|
|
||||||
|
This replaced a single "I'm registering as a parent or guardian" checkbox, which
|
||||||
|
could only say *whether there were children to add*. It could not say whether the
|
||||||
|
**account holder** was a student, so `bookableStudents()` always offered them
|
||||||
|
their own name and every guardian could book themselves a lesson nobody intended
|
||||||
|
to sell.
|
||||||
|
|
||||||
|
`students` now records `us_guardian_only = 1` and `bookableStudents()` leaves the
|
||||||
|
account holder out. The flag is stored as the **negative** deliberately: every
|
||||||
|
account predating the choice is a bookable student, and absence has to keep
|
||||||
|
meaning exactly that, or the picker would silently stop offering people
|
||||||
|
themselves on upgrade. `GuardianService::setGuardianOnly()` clears the key rather
|
||||||
|
than writing `0`, so "not set" stays the one spelling of "yes, a student".
|
||||||
|
|
||||||
|
One guard: a guardian-only account with **nobody linked to it** is still offered
|
||||||
|
itself, because an empty picker is no way to book at all. They can put the
|
||||||
|
account right from the profile page — see **Your details** below, where the flag
|
||||||
|
is editable as "I take lessons myself".
|
||||||
|
|
||||||
Per child the form collects:
|
Per child the form collects:
|
||||||
- **Name** (required)
|
- **Name** (required)
|
||||||
- **Date of birth** (optional, `us_date_of_birth` meta)
|
- **Birth year** (required, `us_birth_year` meta) — a four-digit year between
|
||||||
|
1900 and the current year. `GuardianService::normaliseBirthYear()` is the one
|
||||||
|
definition of what counts, shared by the signup form's up-front validation and
|
||||||
|
by `createChild()`/`updateChild()` themselves, so a bad year is refused rather
|
||||||
|
than quietly discarded and a typo cannot leave a nonsense age on the record.
|
||||||
- **Every account-scope registration question** (`Registration\Question`,
|
- **Every account-scope registration question** (`Registration\Question`,
|
||||||
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
|
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
|
||||||
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
|
||||||
|
question is; `[data-us-child-required]` keeps the attribute on the child fields
|
||||||
|
in step with the block they live in.
|
||||||
|
|
||||||
Order of operations in `RegistrationPage::handleSubmit()`:
|
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 —
|
||||||
2. Validate **every** child block — a missing child name or a missing required
|
when they are a student — their birth year and answers).
|
||||||
per-child answer fails the whole submission **before** any user is created, so
|
2. Validate **every** child block — a missing name, a missing or unusable birth
|
||||||
a half-registered family is never left behind.
|
year, or a missing required per-child answer fails the whole submission
|
||||||
3. Create the guardian user.
|
**before** any user is created, so a half-registered family is never left
|
||||||
|
behind. An **entirely empty** block is dropped instead, because the form
|
||||||
|
always renders one spare for "add another"; a block with anything at all
|
||||||
|
typed into it is kept and reported on, rather than silently discarding what
|
||||||
|
the guardian entered.
|
||||||
|
3. Create the guardian user, 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>`.
|
||||||
@@ -157,15 +268,57 @@ Booking-scope policies are accepted at booking time by whoever is signed in;
|
|||||||
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a
|
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a
|
||||||
child.
|
child.
|
||||||
|
|
||||||
## Managing children
|
## Managing the account
|
||||||
|
|
||||||
`[us_family]` (block: **Family**) renders the guardian's manage-children screen:
|
### Your details
|
||||||
list the children, add one, edit a name/date of birth, remove one.
|
|
||||||
|
The profile screen opens with the account holder's own record, because the
|
||||||
|
alternative was a page called **Your profile** on which the one person who could
|
||||||
|
not be edited was you. The form saves through
|
||||||
|
`GuardianService::updateSelf()` and holds:
|
||||||
|
|
||||||
|
- **Your name** — `display_name` and `nickname`, written together for the reason
|
||||||
|
`updateChild()` does: `UserName` reads the nickname first, and leaving it
|
||||||
|
behind would put the account's email address back on every screen that names a
|
||||||
|
person.
|
||||||
|
- **I take lessons myself** — the positive of `us_guardian_only`, so the form
|
||||||
|
asks the question the way a person answers it and `updateSelf()` is the one
|
||||||
|
place the sense is flipped. This is what makes good on "they can put the
|
||||||
|
account right from the profile page": an account that registered as a pure
|
||||||
|
guardian and later took up lessons — or ticked the wrong radio at signup — can
|
||||||
|
now correct itself instead of asking the studio to.
|
||||||
|
- **Your birth year** — `us_birth_year`, the same meta and the same
|
||||||
|
`normaliseBirthYear()` rule every student is held to.
|
||||||
|
|
||||||
|
Two decisions worth keeping:
|
||||||
|
|
||||||
|
- The birth-year field carries **no `required` attribute**. It is asked of a
|
||||||
|
student only, and the family screen loads no JavaScript, so a browser-enforced
|
||||||
|
`required` would leave a guardian who books solely for other people unable to
|
||||||
|
submit the form at all. `FamilyPage::handleSelf()` enforces it against the
|
||||||
|
checkbox instead, which is where the condition actually lives.
|
||||||
|
- Unticking **I take lessons myself** does **not** clear a stored birth year.
|
||||||
|
The box says who books, not "forget what you know about me", and someone who
|
||||||
|
ticks it back on the next visit should find their details as they left them.
|
||||||
|
|
||||||
|
The **email** is shown but not editable: it is the account's `user_login` as
|
||||||
|
well as its address, so changing it is a studio-side job rather than a
|
||||||
|
profile-screen one.
|
||||||
|
|
||||||
|
The account-scope questions are not re-asked here, in either direction — the
|
||||||
|
child rows do not offer them on edit either, and a studio that needs a newly
|
||||||
|
self-declared student's answers asks for them the same way it would for any
|
||||||
|
other change of circumstance.
|
||||||
|
|
||||||
|
### Managing children
|
||||||
|
|
||||||
|
The rest of `[us_family]` (block: **Profile**) is the manage-children screen:
|
||||||
|
list the children, add one, edit a name/birth year, remove one.
|
||||||
|
|
||||||
- **Add** creates another accountless child user and links it. Account-scope
|
- **Add** creates another accountless child user and links it. Account-scope
|
||||||
questions are asked here too, so a child added later carries the same
|
questions are asked here too, so a child added later carries the same
|
||||||
information as one added at signup.
|
information as one added at signup.
|
||||||
- **Edit** updates `display_name` and `us_date_of_birth`.
|
- **Edit** updates `display_name` and `us_birth_year`.
|
||||||
- **Remove** unlinks the child and **deletes the child user**, but only when the
|
- **Remove** unlinks the child and **deletes the child user**, but only when the
|
||||||
child has no lessons and no enrolments — a child with history is refused, so
|
child has no lessons and no enrolments — a child with history is refused, so
|
||||||
removing one can never orphan a lesson, payment or credit
|
removing one can never orphan a lesson, payment or credit
|
||||||
@@ -219,11 +372,11 @@ than being left as a single-student-only path.
|
|||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
- **Students list** gains a **Guardian / Children** column: a child links to its
|
- **Students list** gains a **Profile** column: a child links to its
|
||||||
guardian's detail screen, a guardian lists its children as links. Children are
|
guardian's detail screen, a guardian lists its children as links. Children are
|
||||||
listed alongside every other student rather than nested, so nothing about
|
listed alongside every other student rather than nested, so nothing about
|
||||||
finding a student changes.
|
finding a student changes.
|
||||||
- **Student detail** gains a **Family** panel — the guardian (for a child) or
|
- **Student detail** gains a **Profile** panel — the guardian (for a child) or
|
||||||
the children (for a guardian), each a link to the other's screen — and the
|
the children (for a guardian), each a link to the other's screen — and the
|
||||||
credit balance shown is the **payer's** balance, labelled with whose it is, so
|
credit balance shown is the **payer's** balance, labelled with whose it is, so
|
||||||
an admin looking at a child sees the family balance that will actually settle
|
an admin looking at a child sees the family balance that will actually settle
|
||||||
@@ -256,7 +409,8 @@ need — via `GuardianService::contactFor()`.
|
|||||||
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
|
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
|
||||||
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
|
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
|
||||||
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
|
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
|
||||||
`canActFor()`, `payerFor()`, `contactFor()`, removal rules)
|
`canActFor()`, `payerFor()`, `contactFor()`, removal rules, and the account
|
||||||
|
holder's own record via `accountHolder()`/`updateSelf()`)
|
||||||
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
|
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
|
||||||
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
|
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
|
||||||
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
|
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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**).
|
||||||
@@ -23,11 +23,32 @@ and the same authoring page (**Offerings → Questions**).
|
|||||||
| `label` | VARCHAR(255) | The question text shown to the registrant |
|
| `label` | VARCHAR(255) | The question text shown to the registrant |
|
||||||
| `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` |
|
| `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` |
|
||||||
| `options` | TEXT | JSON array of choices (for `select`); NULL otherwise |
|
| `options` | TEXT | JSON array of choices (for `select`); NULL otherwise |
|
||||||
| `is_required` | TINYINT(1) | 1 = registrant must answer to continue |
|
| `audience` | VARCHAR(20) | `all` (default) or `child` — who the question is asked of (account scope) |
|
||||||
|
| `is_required` | TINYINT(1) | 1 = the **account holder** must answer to continue |
|
||||||
|
| `is_required_child` | TINYINT(1) | 1 = each **student being registered** must answer to continue |
|
||||||
| `sort_order` | INT | Display order within the scope |
|
| `sort_order` | INT | Display order within the scope |
|
||||||
| `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
|
| `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
|
||||||
| `created_at` | DATETIME | Insertion time |
|
| `created_at` | DATETIME | Insertion time |
|
||||||
|
|
||||||
|
## Audience and Required-ness (account scope)
|
||||||
|
An account-scope question is asked in two places, and the two are configured separately:
|
||||||
|
|
||||||
|
- **The account holder's own "About you" panel** — shown when they are registering
|
||||||
|
themselves (`self` or `both`). Governed by `audience` (a `child` question is not asked
|
||||||
|
here at all) and by `is_required`.
|
||||||
|
- **Each student block** — one per person they are registering on behalf of, on the signup
|
||||||
|
form and on the guardian's family screen. Every question is asked here regardless of
|
||||||
|
`audience`; `is_required_child` decides whether it blocks submission.
|
||||||
|
|
||||||
|
That split is what lets a studio ask "School and grade" of children only, or make
|
||||||
|
"Previous experience" optional for an adult signing themselves up but required for every
|
||||||
|
child they enrol. `audience = 'child'` leaves `is_required` moot — the question never
|
||||||
|
reaches the account holder's panel.
|
||||||
|
|
||||||
|
`audience` and `is_required_child` are ignored for offering-scope questions: booking and
|
||||||
|
enrolment ask their intake questions once, about the student being booked, with no separate
|
||||||
|
account-holder form to differ from.
|
||||||
|
|
||||||
## Data Model — `{prefix}us_question_answers`
|
## Data Model — `{prefix}us_question_answers`
|
||||||
|
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
@@ -49,21 +70,26 @@ 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 — minus any `audience = 'child'` question, which is never asked of the account holder. `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 full question set 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) — `is_required` against the account holder's panel, `is_required_child` against each student block; after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`. An answer posted for a `child`-audience question against the account holder is discarded, not stored.
|
||||||
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.
|
||||||
|
|
||||||
## Admin Interface
|
## Admin Interface
|
||||||
Both scopes are edited from **Offerings → Questions** (`Registration\QuestionController`):
|
Both scopes are edited from **Offerings → Questions** (`Registration\QuestionController`):
|
||||||
- Pick an offering to edit its questions, or **"Account signup (all registrations)"** for the account-scope questions.
|
- Pick an offering to edit its questions, or **"Account signup (all registrations)"** for the account-scope questions.
|
||||||
|
- The account-scope form adds **Asked of** (everyone / students only) and a second **Required** checkbox for students; both are hidden for offering scope, where they have no meaning.
|
||||||
- Studio admin (`manage_questions` + `manage_instructors`) edits any offering's questions and the account-scope questions.
|
- Studio admin (`manage_questions` + `manage_instructors`) edits any offering's questions and the account-scope questions.
|
||||||
- Instructor (`manage_questions`) edits questions only on their own offerings; the account-scope option is hidden.
|
- Instructor (`manage_questions`) edits questions only on their own offerings; the account-scope option is hidden.
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
Only offering-scope questions are exposed over REST. Account-scope questions are managed
|
Only offering-scope questions are exposed over REST. Account-scope questions are managed
|
||||||
through the server-rendered admin page and read directly by `RegistrationPage`.
|
through the server-rendered admin page and read directly by `RegistrationPage` — a request
|
||||||
|
naming one is turned away as not found, since the owner check has no offering to check
|
||||||
|
against, so REST can neither read nor overwrite an `audience`. An offering question written
|
||||||
|
over REST mirrors its single `is_required` into `is_required_child`, as the admin form and
|
||||||
|
the upgrade backfill both do.
|
||||||
|
|
||||||
| Method | Endpoint | Permission |
|
| Method | Endpoint | Permission |
|
||||||
|----------|---------------------------------------------------|----------------------|
|
|----------|---------------------------------------------------|----------------------|
|
||||||
@@ -74,12 +100,13 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
|
|||||||
|
|
||||||
## Implementation
|
## Implementation
|
||||||
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository` (`findByOffering`, `findByScope`), `Unsupervised\Schedular\Registration\AnswerRepository`
|
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository` (`findByOffering`, `findByScope`), `Unsupervised\Schedular\Registration\AnswerRepository`
|
||||||
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
|
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`, `audience`, `isRequiredChild`, and the `askedOfSelf()` / `isRequiredForSelf()` / `isRequiredForChild()` readers every caller uses instead of touching `isRequired` directly), `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`, `us_questions.audience`, `us_questions.is_required_child` (each requires a plugin version bump so `dbDelta` runs)
|
||||||
|
- Required-for-students backfill: `is_required_child` arrives with `DEFAULT 0`, which would quietly make every existing required question optional for students. `QuestionRepository::backfillChildRequired()` copies `is_required` into it once; `Plugin::boot()` runs it guarded by the `us_questions_child_required_backfilled` option, after the version gate has let `dbDelta` add the column
|
||||||
- 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)
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
@@ -87,8 +114,10 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
|
|||||||
- `tests/Unit/Registration/AnswerRepositoryTest.php`
|
- `tests/Unit/Registration/AnswerRepositoryTest.php`
|
||||||
- `tests/Unit/Registration/QuestionTest.php`
|
- `tests/Unit/Registration/QuestionTest.php`
|
||||||
- `tests/Unit/Registration/AnswerTest.php`
|
- `tests/Unit/Registration/AnswerTest.php`
|
||||||
|
- `tests/Unit/Registration/QuestionFieldTest.php`
|
||||||
- `tests/Unit/Auth/RegistrationPageTest.php`
|
- `tests/Unit/Auth/RegistrationPageTest.php`
|
||||||
- `tests/Unit/Auth/StudentHistoryTest.php`
|
- `tests/Unit/Auth/StudentHistoryTest.php`
|
||||||
|
- `tests/Unit/Guardian/FamilyPageTest.php`
|
||||||
|
|
||||||
## Per-Child Answers
|
## Per-Child Answers
|
||||||
For a parent/guardian signup, **account-scope** questions are asked **once per
|
For a parent/guardian signup, **account-scope** questions are asked **once per
|
||||||
@@ -96,5 +125,5 @@ child** rather than once per guardian — in practice they describe the student
|
|||||||
(instrument, level, school), not the account holder. Each answer's `student_id`
|
(instrument, level, school), not the account holder. Each answer's `student_id`
|
||||||
and `registration_id` are the child's user ID, so a studio admin reading a
|
and `registration_id` are the child's user ID, so a studio admin reading a
|
||||||
child's screen sees the information that describes them. The guardian's family
|
child's screen sees the information that describes them. The guardian's family
|
||||||
screen asks the same questions when a child is added later. See
|
screen asks the same questions when a child is added later, under the same
|
||||||
`parent-guardian-accounts.md`.
|
`is_required_child` rule as the signup form. See `parent-guardian-accounts.md`.
|
||||||
|
|||||||
@@ -24,9 +24,17 @@ No new tables. The views are composed from existing data:
|
|||||||
quick counts (upcoming lessons, active group enrolments). Each row links to the
|
quick counts (upcoming lessons, active group enrolments). Each row links to the
|
||||||
detail view.
|
detail view.
|
||||||
- **Detail** (`?student_id=`):
|
- **Detail** (`?student_id=`):
|
||||||
- **Account** — display name, email, registered date.
|
- **Account** — display name, email, registered date, and **Booked by**: the
|
||||||
|
name of the parent/guardian who books and pays for this student, linked to
|
||||||
|
their own detail page. Always rendered — a student who books for themselves
|
||||||
|
says so in words, so an empty row can never be mistaken for a lookup that
|
||||||
|
failed.
|
||||||
- **Upcoming lessons** and **Past lessons** — split by the linked availability
|
- **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 +59,38 @@ All actions are nonce-protected POSTs handled on the detail page:
|
|||||||
voided. A paid lesson is credited back to the student's account (see
|
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.
|
||||||
|
|
||||||
|
**A guardian takes their children with them.** A child account is login-less and
|
||||||
|
exists only so the guardian has somebody to book for; without the guardian nobody
|
||||||
|
can reach it, book for it, or be billed for it, so leaving it behind leaves an
|
||||||
|
unreachable student on the roster holding slots that will never be used. Each
|
||||||
|
child's bookings are released on the same terms, the `us_guardians` link row is
|
||||||
|
deleted, and the account goes. Deleting a child fires `delete_user` again and
|
||||||
|
re-enters the same handler; a `handled` set of user ids makes that a no-op and
|
||||||
|
also stops a self-referential or circular link recursing.
|
||||||
|
|
||||||
|
(This is a different rule from the family screen's **Remove**, which still refuses
|
||||||
|
a child with any lesson or enrolment history — that is a guardian tidying up, not
|
||||||
|
an admin deleting an account, and `GuardianService::removeChild()` is unchanged.)
|
||||||
|
|
||||||
|
Past lessons are deliberately untouched: they happened, they may have been paid
|
||||||
|
for, and the payment report has to keep adding up. No account credit is issued
|
||||||
|
for a paid lesson either, unlike a cancellation the student asks for — a credit
|
||||||
|
can only be spent on the account being deleted, so a refund owed to someone who
|
||||||
|
has left is the studio's decision to make and record.
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
- `manage_students` — studio admin (administrators inherit it via the
|
- `manage_students` — studio admin (administrators inherit it via the
|
||||||
@@ -73,6 +112,8 @@ All actions are nonce-protected POSTs handled on the detail page:
|
|||||||
refuse records that don't belong to the student, and reuse
|
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 +121,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)
|
||||||
@@ -88,8 +130,8 @@ All actions are nonce-protected POSTs handled on the detail page:
|
|||||||
`tests/Unit/Payment/PaymentRepositoryTest.php`
|
`tests/Unit/Payment/PaymentRepositoryTest.php`
|
||||||
|
|
||||||
## Family Relationships
|
## Family Relationships
|
||||||
The students list gains a **Family** column — a child links to their guardian,
|
The students list gains a **Profile** column — a child links to their guardian,
|
||||||
a guardian lists their children — and the student screen a **Family** panel. A
|
a guardian lists their children — and the student screen a **Profile** panel. A
|
||||||
child's listed email is their guardian's, since a child's own address is an
|
child's listed email is their guardian's, since a child's own address is an
|
||||||
undeliverable placeholder, and the credit balance shown is the payer's, labelled
|
undeliverable placeholder, and the credit balance shown is the payer's, labelled
|
||||||
with whose account holds it. See `parent-guardian-accounts.md`.
|
with whose account holds it. See `parent-guardian-accounts.md`.
|
||||||
|
|||||||
+2
-1
@@ -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();
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who is signed in, and the way out.
|
||||||
|
*
|
||||||
|
* Meant for a header, sidebar or account page — somewhere it sits alongside
|
||||||
|
* other content rather than being the whole of it. That shapes the two
|
||||||
|
* decisions below.
|
||||||
|
*/
|
||||||
|
class AccountPage {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the account shortcode/block output.
|
||||||
|
*
|
||||||
|
* Signed out, this renders a sign-in link when a login page is configured and
|
||||||
|
* **nothing at all** when one is not. A block whose whole job is "you are
|
||||||
|
* signed in as X" has nothing to say to a stranger, and a bare "you are not
|
||||||
|
* signed in" in a site header is noise with no way to act on it. The editor
|
||||||
|
* preview shows the populated state regardless, so the block is never
|
||||||
|
* invisible to the person placing it.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
||||||
|
* shortcode attributes (`login_page_id`).
|
||||||
|
*/
|
||||||
|
public function render( array $atts ): string {
|
||||||
|
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
|
||||||
|
$loginUrl = $this->pageUrl( $loginPageId );
|
||||||
|
|
||||||
|
wp_enqueue_style( 'us-scheduler' );
|
||||||
|
|
||||||
|
if ( ! is_user_logged_in() ) {
|
||||||
|
if ( null === $loginUrl ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<div class="us-account us-account-out"><a class="us-account-signin" href="%s">%s</a></div>',
|
||||||
|
esc_url( $loginUrl ),
|
||||||
|
esc_html__( 'Sign in', 'unsupervised-schedular' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always a WP_User here — is_user_logged_in() above rules out the
|
||||||
|
// id-0 placeholder wp_get_current_user() returns for a visitor.
|
||||||
|
$user = wp_get_current_user();
|
||||||
|
|
||||||
|
$name = UserName::format( $user, get_current_user_id() );
|
||||||
|
$email = $user->user_email;
|
||||||
|
|
||||||
|
// Back to where they were, so signing out of a header link does not also
|
||||||
|
// navigate them somewhere. The login page is the better landing spot when
|
||||||
|
// one is configured, since the current page may be members-only.
|
||||||
|
$logoutUrl = wp_logout_url( $loginUrl ?? (string) get_permalink() );
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
include USC_PLUGIN_DIR . 'templates/frontend/account-page.php';
|
||||||
|
return (string) ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permalink of a configured page, or null when none is chosen or the chosen
|
||||||
|
* page has since been deleted.
|
||||||
|
*/
|
||||||
|
private function pageUrl( int $pageId ): ?string {
|
||||||
|
if ( $pageId <= 0 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = get_permalink( $pageId );
|
||||||
|
|
||||||
|
return is_string( $url ) ? $url : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
|
use Unsupervised\Schedular\Booking\Lesson;
|
||||||
|
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||||
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
|
use Unsupervised\Schedular\Payment\PaymentService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives back what a deleted account was holding, and takes the accounts that
|
||||||
|
* only existed underneath it with it.
|
||||||
|
*
|
||||||
|
* WordPress deletes a user without knowing anything about lessons, so a student
|
||||||
|
* removed from **Users → Delete** used to leave their bookings behind: the
|
||||||
|
* availability slots stayed marked booked and unbookable by anyone else, the
|
||||||
|
* lessons stayed on the instructor's schedule under a name that no longer
|
||||||
|
* resolved, and a group class kept a seat filled by nobody.
|
||||||
|
*
|
||||||
|
* So each upcoming booking is cancelled the same way a real cancellation is —
|
||||||
|
* marked cancelled, its slot released, its still-pending payment voided. Past
|
||||||
|
* lessons are deliberately left alone: they happened, they may have been paid
|
||||||
|
* for, and the payment report has to keep adding up.
|
||||||
|
*
|
||||||
|
* A **guardian** takes their children with them. A child account is login-less
|
||||||
|
* and exists only so the guardian has somebody to book for; without the
|
||||||
|
* guardian nobody can reach it, book for it, or be billed for it, so leaving it
|
||||||
|
* behind leaves an unreachable student on the roster holding slots that will
|
||||||
|
* never be used. Each child's bookings are released on the same terms, the link
|
||||||
|
* row goes, and the account is deleted.
|
||||||
|
*
|
||||||
|
* No account credit is issued for a paid lesson, unlike a cancellation the
|
||||||
|
* student asks for. A credit only has value against future billing on the
|
||||||
|
* account it belongs to, and that account is being deleted; a refund owed to
|
||||||
|
* someone who has left is a decision for the studio to make and record, not one
|
||||||
|
* to silently write into a table nobody will read again.
|
||||||
|
*/
|
||||||
|
class DeletedUserCleanup {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accounts already dealt with this request, so deleting a guardian's child
|
||||||
|
* — which fires `delete_user` again and re-enters this very handler — cannot
|
||||||
|
* loop or redo work. It also makes a self-referential or circular guardian
|
||||||
|
* link, however it got into the table, terminate rather than recurse.
|
||||||
|
*
|
||||||
|
* @var array<int, true>
|
||||||
|
*/
|
||||||
|
private array $handled = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private BookingRepository $bookings,
|
||||||
|
private AvailabilityRepository $availability,
|
||||||
|
private EnrollmentRepository $enrollments,
|
||||||
|
private PaymentService $payments,
|
||||||
|
private GuardianRepository $links,
|
||||||
|
private GuardianService $guardians,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
// `delete_user` fires before the row goes, which is what lets the lookups
|
||||||
|
// below still find the account's bookings and children. `wpmu_delete_user`
|
||||||
|
// is the multisite equivalent for a user removed from the network entirely.
|
||||||
|
add_action( 'delete_user', [ $this, 'releaseBookings' ] );
|
||||||
|
add_action( 'wpmu_delete_user', [ $this, 'releaseBookings' ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release everything the account had booked ahead of it, then remove any
|
||||||
|
* children that only existed to be booked for.
|
||||||
|
*/
|
||||||
|
public function releaseBookings( int $userId ): void {
|
||||||
|
if ( $userId <= 0 || isset( $this->handled[ $userId ] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->handled[ $userId ] = true;
|
||||||
|
|
||||||
|
$this->release( $userId );
|
||||||
|
$this->removeChildren( $userId );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel one account's upcoming lessons and active enrolments, freeing the
|
||||||
|
* slot and voiding the pending payment behind each.
|
||||||
|
*/
|
||||||
|
private function release( int $studentId ): void {
|
||||||
|
// Upcoming and not already cancelled — the only bookings that are still
|
||||||
|
// holding anything.
|
||||||
|
foreach ( $this->bookings->findUpcomingForStudent( $studentId ) as $lesson ) {
|
||||||
|
$this->bookings->updateStatus( (int) $lesson->id, Lesson::STATUS_CANCELLED );
|
||||||
|
$this->availability->release( $lesson->slotId );
|
||||||
|
$this->payments->voidPending( $lesson->paymentId );
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $this->enrollments->findByStudent( $studentId ) as $enrollment ) {
|
||||||
|
if ( Enrollment::STATUS_ACTIVE !== $enrollment->status ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->enrollments->updateStatus( (int) $enrollment->id, Enrollment::STATUS_CANCELLED );
|
||||||
|
$this->payments->voidPending( $enrollment->paymentId );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete every child linked to a departing guardian, releasing what each was
|
||||||
|
* holding first. Each child is marked handled *before* it is deleted, so the
|
||||||
|
* `delete_user` this fires re-enters and returns without redoing the release.
|
||||||
|
*/
|
||||||
|
private function removeChildren( int $guardianId ): void {
|
||||||
|
foreach ( $this->links->findByGuardian( $guardianId ) as $link ) {
|
||||||
|
$childId = $link->studentId;
|
||||||
|
|
||||||
|
if ( $childId <= 0 || $childId === $guardianId || isset( $this->handled[ $childId ] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->handled[ $childId ] = true;
|
||||||
|
|
||||||
|
$this->release( $childId );
|
||||||
|
$this->links->delete( $guardianId, $childId );
|
||||||
|
$this->guardians->deleteUser( $childId );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What counts as an acceptable signup password.
|
||||||
|
*
|
||||||
|
* The check is deliberately split across the two sides, because the two sides
|
||||||
|
* can do different things:
|
||||||
|
*
|
||||||
|
* - **The browser** runs zxcvbn (WordPress ships it as `password-strength-meter`)
|
||||||
|
* and gates the submit button on {@see MIN_SCORE}. That is the nuanced test —
|
||||||
|
* it knows that `Tr0ub4dor&3` is weaker than `correct horse battery staple` —
|
||||||
|
* but it is only advice, because anything in a browser can be turned off.
|
||||||
|
* - **This class** runs on the server and is the rule that actually holds. It
|
||||||
|
* cannot score a password the way zxcvbn does without shipping a dictionary,
|
||||||
|
* so it does not pretend to: it rejects the categorically bad — too short,
|
||||||
|
* the user's own name or email, a password from the well-known lists, or one
|
||||||
|
* built from almost no distinct characters.
|
||||||
|
*
|
||||||
|
* Neither half is sufficient alone, which is the point. A password that clears
|
||||||
|
* both is not guaranteed strong; one that fails either is definitely not.
|
||||||
|
*/
|
||||||
|
class PasswordPolicy {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum length. NIST SP 800-63B puts the floor at 8 and explicitly advises
|
||||||
|
* against composition rules ("must contain a symbol") on the grounds that they
|
||||||
|
* push people towards predictable substitutions. Length plus the checks below
|
||||||
|
* does more for less annoyance.
|
||||||
|
*/
|
||||||
|
public const MIN_LENGTH = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zxcvbn score the browser demands before it will let the form submit,
|
||||||
|
* on WordPress's 0-4 scale: 0-1 weak, 2 medium, 3-4 strong. Two rejects the
|
||||||
|
* passwords a stranger would guess while still accepting an ordinary
|
||||||
|
* memorable one — a studio signup form is not a bank.
|
||||||
|
*/
|
||||||
|
public const MIN_SCORE = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the user's own identity has to appear in the password before it
|
||||||
|
* is refused. Short enough to catch a name inside a longer password, long
|
||||||
|
* enough that a two- or three-letter coincidence does not trip it.
|
||||||
|
*/
|
||||||
|
private const IDENTITY_FRAGMENT_LENGTH = 4;
|
||||||
|
|
||||||
|
/** Fewest distinct characters a password may be built from. */
|
||||||
|
private const MIN_DISTINCT_CHARACTERS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why this password is unacceptable, or null when it passes.
|
||||||
|
*
|
||||||
|
* `$email` and `$displayName` are what the same submission is claiming as an
|
||||||
|
* identity, so they can be checked against the password before either exists
|
||||||
|
* as a user.
|
||||||
|
*/
|
||||||
|
public static function validate( string $password, string $email = '', string $displayName = '' ): ?string {
|
||||||
|
// Not trimmed: a leading or trailing space is a legitimate character, and
|
||||||
|
// silently changing what someone typed would lock them out later.
|
||||||
|
if ( strlen( $password ) < self::MIN_LENGTH ) {
|
||||||
|
return sprintf(
|
||||||
|
/* translators: %d: minimum number of characters. */
|
||||||
|
__( 'Please choose a password of at least %d characters.', 'unsupervised-schedular' ),
|
||||||
|
self::MIN_LENGTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lower = strtolower( $password );
|
||||||
|
|
||||||
|
if ( in_array( $lower, self::commonPasswords(), true ) ) {
|
||||||
|
return __( 'That password is one of the most commonly used ones. Please choose something less guessable.', 'unsupervised-schedular' );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( count( array_unique( str_split( $lower ) ) ) < self::MIN_DISTINCT_CHARACTERS ) {
|
||||||
|
return __( 'Please choose a password built from more than a few repeated characters.', 'unsupervised-schedular' );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( self::echoesIdentity( $lower, $email, $displayName ) ) {
|
||||||
|
return __( 'Please choose a password that does not contain your name or email address.', 'unsupervised-schedular' );
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the password contains the user's display name, their email address,
|
||||||
|
* or the part of it before the `@` — the first things anyone guessing would
|
||||||
|
* try, and the reason "grace2019" is worse than its length suggests.
|
||||||
|
*/
|
||||||
|
private static function echoesIdentity( string $lowerPassword, string $email, string $displayName ): bool {
|
||||||
|
$email = strtolower( trim( $email ) );
|
||||||
|
$localPart = '' !== $email ? (string) strstr( $email . '@', '@', true ) : '';
|
||||||
|
|
||||||
|
$fragments = [ $email, $localPart, strtolower( trim( $displayName ) ) ];
|
||||||
|
|
||||||
|
foreach ( $fragments as $fragment ) {
|
||||||
|
if ( strlen( $fragment ) >= self::IDENTITY_FRAGMENT_LENGTH && str_contains( $lowerPassword, $fragment ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwords common enough that a guess costs nothing. Only entries at least
|
||||||
|
* {@see MIN_LENGTH} long are worth listing — anything shorter is already
|
||||||
|
* refused — so this is the long tail of the usual leaked-password lists
|
||||||
|
* rather than the whole of it. zxcvbn in the browser covers the rest.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function commonPasswords(): array {
|
||||||
|
return [
|
||||||
|
'password',
|
||||||
|
'password1',
|
||||||
|
'password12',
|
||||||
|
'password123',
|
||||||
|
'passw0rd',
|
||||||
|
'p@ssword',
|
||||||
|
'p@ssw0rd',
|
||||||
|
'12345678',
|
||||||
|
'123456789',
|
||||||
|
'1234567890',
|
||||||
|
'123123123',
|
||||||
|
'qwertyui',
|
||||||
|
'qwertyuiop',
|
||||||
|
'qwerty123',
|
||||||
|
'qwerty12',
|
||||||
|
'1qaz2wsx',
|
||||||
|
'zaq12wsx',
|
||||||
|
'iloveyou',
|
||||||
|
'princess',
|
||||||
|
'sunshine',
|
||||||
|
'football',
|
||||||
|
'baseball',
|
||||||
|
'basketball',
|
||||||
|
'superman',
|
||||||
|
'batman123',
|
||||||
|
'trustno1',
|
||||||
|
'welcome1',
|
||||||
|
'welcome123',
|
||||||
|
'letmein1',
|
||||||
|
'letmein123',
|
||||||
|
'admin123',
|
||||||
|
'administrator',
|
||||||
|
'abc12345',
|
||||||
|
'abcd1234',
|
||||||
|
'monkey123',
|
||||||
|
'dragon123',
|
||||||
|
'michael1',
|
||||||
|
'jennifer',
|
||||||
|
'starwars',
|
||||||
|
'computer',
|
||||||
|
'whatever',
|
||||||
|
'freedom1',
|
||||||
|
'changeme',
|
||||||
|
'secret123',
|
||||||
|
'login123',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
-40
@@ -19,6 +19,15 @@ use Unsupervised\Schedular\Val;
|
|||||||
|
|
||||||
class RegistrationPage {
|
class RegistrationPage {
|
||||||
|
|
||||||
|
/** "Who are you registering?": the account holder, and nobody else. */
|
||||||
|
public const FOR_SELF = 'self';
|
||||||
|
|
||||||
|
/** Only other people — the account holder is not a student. */
|
||||||
|
public const FOR_STUDENTS = 'students';
|
||||||
|
|
||||||
|
/** The account holder *and* other people. */
|
||||||
|
public const FOR_BOTH = 'both';
|
||||||
|
|
||||||
/** Success signal: an invited student was created and logged in. */
|
/** Success signal: an invited student was created and logged in. */
|
||||||
private const RESULT_INVITE = 'invite';
|
private const RESULT_INVITE = 'invite';
|
||||||
|
|
||||||
@@ -119,11 +128,34 @@ 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' );
|
||||||
|
|
||||||
|
// The browser gate reads the same numbers the server enforces, so the
|
||||||
|
// two cannot drift into disagreeing about what it accepted.
|
||||||
|
wp_localize_script(
|
||||||
|
'us-scheduler-register',
|
||||||
|
'usSchedulerPassword',
|
||||||
|
[
|
||||||
|
'minLength' => PasswordPolicy::MIN_LENGTH,
|
||||||
|
'minScore' => PasswordPolicy::MIN_SCORE,
|
||||||
|
'strings' => [
|
||||||
|
'short' => sprintf(
|
||||||
|
/* translators: %d: minimum number of characters. */
|
||||||
|
__( 'At least %d characters, please.', 'unsupervised-schedular' ),
|
||||||
|
PasswordPolicy::MIN_LENGTH
|
||||||
|
),
|
||||||
|
'veryWeak' => __( 'Too weak — a stranger could guess this.', 'unsupervised-schedular' ),
|
||||||
|
'weak' => __( 'Still too weak. Try a longer phrase.', 'unsupervised-schedular' ),
|
||||||
|
'medium' => __( 'Good enough.', 'unsupervised-schedular' ),
|
||||||
|
'strong' => __( 'Strong password.', 'unsupervised-schedular' ),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
@@ -248,10 +280,6 @@ class RegistrationPage {
|
|||||||
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
|
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
|
||||||
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
|
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
|
||||||
|
|
||||||
if ( strlen( $password ) < 8 ) {
|
|
||||||
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// The email is fixed by a personal invite; group-link signups and
|
// The email is fixed by a personal invite; group-link signups and
|
||||||
// self-signups supply their own.
|
// self-signups supply their own.
|
||||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||||
@@ -263,6 +291,15 @@ class RegistrationPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After the email, so the password can be checked against it. The browser
|
||||||
|
// scores the password with zxcvbn and refuses to submit a weak one, but
|
||||||
|
// that is advice a client can decline to take — this is the check that
|
||||||
|
// holds. See PasswordPolicy for why the two halves differ.
|
||||||
|
$passwordError = PasswordPolicy::validate( $password, $email, $displayName );
|
||||||
|
if ( null !== $passwordError ) {
|
||||||
|
return esc_html( $passwordError );
|
||||||
|
}
|
||||||
|
|
||||||
$policyForms = $this->signupPolicies();
|
$policyForms = $this->signupPolicies();
|
||||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
|
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
|
||||||
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
|
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
|
||||||
@@ -276,39 +313,92 @@ class RegistrationPage {
|
|||||||
|
|
||||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||||
|
|
||||||
// Registering as a parent/guardian turns the account-signup questions from
|
// The account-signup questions describe a *student* — instrument, level,
|
||||||
// "about you" into "about each child" — they describe the student
|
// school — not whoever holds the account. So they are asked of each
|
||||||
// (instrument, level, school), not the person holding the account.
|
// student being added, and of the account holder only when they are a
|
||||||
$isGuardian = $this->submittedIsGuardian();
|
// student themselves. "Both" is both.
|
||||||
$children = $isGuardian ? $this->submittedChildren() : [];
|
$registeringFor = $this->submittedRegisteringFor();
|
||||||
$answers = $isGuardian ? [] : $this->submittedAnswers();
|
|
||||||
|
// A "students only" question is never put to the account holder, so it is
|
||||||
|
// dropped before their answers are validated or stored — a crafted post
|
||||||
|
// cannot file one against them.
|
||||||
|
$selfQuestions = array_values(
|
||||||
|
array_filter( $accountQuestions, static fn( Question $question ): bool => $question->askedOfSelf() )
|
||||||
|
);
|
||||||
|
|
||||||
|
// "Students" and "both" collect student blocks; only "self" does not.
|
||||||
|
$isGuardian = self::FOR_SELF !== $registeringFor;
|
||||||
|
|
||||||
|
// "Self" and "both" make the account holder a student, so they answer the
|
||||||
|
// questions in their own right. Only a pure guardian does not.
|
||||||
|
$asksSelf = self::FOR_STUDENTS !== $registeringFor;
|
||||||
|
|
||||||
|
$children = $isGuardian ? $this->submittedChildren() : [];
|
||||||
|
$answers = $asksSelf ? $this->submittedAnswers() : [];
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by the caller.
|
||||||
|
$birthYear = $asksSelf ? trim( sanitize_text_field( Val::string( wp_unslash( $_POST['birth_year'] ?? '' ) ) ) ) : '';
|
||||||
|
|
||||||
// Everything is validated before a single user is created, so a bad child
|
// 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 ) {
|
||||||
return esc_html__( 'Please add at least one child, or uncheck the parent/guardian option.', 'unsupervised-schedular' );
|
return esc_html__( 'Please add at least one student, or choose "Just myself" instead.', 'unsupervised-schedular' );
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) {
|
// Name and birth year are required per student, and are checked here for
|
||||||
foreach ( $accountQuestions as $question ) {
|
// the same reason the questions below are: the child blocks are hidden
|
||||||
if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) {
|
// until the guardian box is ticked, so the browser cannot be asked to
|
||||||
return $isGuardian
|
// enforce them without blocking a signup that has no children at all.
|
||||||
? esc_html__( 'Please answer all required registration questions for each child.', 'unsupervised-schedular' )
|
foreach ( $children as $child ) {
|
||||||
: esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
if ( '' === $child['name'] ) {
|
||||||
}
|
return esc_html__( 'Please give each student a name.', 'unsupervised-schedular' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( 0 === GuardianService::normaliseBirthYear( $child['birth_year'] ) ) {
|
||||||
|
return esc_html( GuardianService::birthYearError() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked as two passes rather than one so the message can say *whose*
|
||||||
|
// answers are missing — under "both" a single message could not.
|
||||||
|
foreach ( array_column( $children, 'answers' ) as $set ) {
|
||||||
|
if ( $this->hasUnansweredRequired( $accountQuestions, $set, forChild: true ) ) {
|
||||||
|
return esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account holder is a student too under "self" and "both", so the same
|
||||||
|
// birth year every other student gives is asked of them — and checked
|
||||||
|
// here rather than left to the browser, for the same reason as the
|
||||||
|
// children's: the panel is hidden for a pure guardian, so `required`
|
||||||
|
// alone cannot be trusted to have applied.
|
||||||
|
if ( $asksSelf && 0 === GuardianService::normaliseBirthYear( $birthYear ) ) {
|
||||||
|
return esc_html( GuardianService::ownBirthYearError() );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $asksSelf && $this->hasUnansweredRequired( $selfQuestions, $answers ) ) {
|
||||||
|
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( email_exists( $email ) ) {
|
if ( email_exists( $email ) ) {
|
||||||
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
|
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nickname as well as display name. WordPress defaults nickname to
|
||||||
|
// `user_login`, which here is the email address — so without this the
|
||||||
|
// account's own address became its nickname, and every screen that names
|
||||||
|
// a person through `UserName` showed the address instead of the name they
|
||||||
|
// had just typed. `UserName` copes with the accounts already created that
|
||||||
|
// way; this stops any more of them.
|
||||||
|
$name = '' !== $displayName ? $displayName : $email;
|
||||||
|
|
||||||
$userId = wp_insert_user(
|
$userId = wp_insert_user(
|
||||||
[
|
[
|
||||||
'user_login' => $email,
|
'user_login' => $email,
|
||||||
'user_email' => $email,
|
'user_email' => $email,
|
||||||
'user_pass' => $password,
|
'user_pass' => $password,
|
||||||
'display_name' => '' !== $displayName ? $displayName : $email,
|
'display_name' => $name,
|
||||||
|
'nickname' => $name,
|
||||||
'role' => $inviteValid ? $invite->role : RoleManager::STUDENT,
|
'role' => $inviteValid ? $invite->role : RoleManager::STUDENT,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -319,13 +409,25 @@ class RegistrationPage {
|
|||||||
|
|
||||||
$this->recordAcceptances( $policyForms, (int) $userId, (int) $userId );
|
$this->recordAcceptances( $policyForms, (int) $userId, (int) $userId );
|
||||||
|
|
||||||
|
// Only "students" means the account holder is not a student themselves;
|
||||||
|
// "both" registers them alongside the people they book for.
|
||||||
|
$this->guardians->setGuardianOnly( (int) $userId, self::FOR_STUDENTS === $registeringFor );
|
||||||
|
|
||||||
|
if ( $asksSelf ) {
|
||||||
|
$this->guardians->setBirthYear( (int) $userId, $birthYear );
|
||||||
|
}
|
||||||
|
|
||||||
if ( $isGuardian ) {
|
if ( $isGuardian ) {
|
||||||
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
|
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
|
||||||
if ( '' !== $failure ) {
|
if ( '' !== $failure ) {
|
||||||
return $failure;
|
return $failure;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
|
||||||
|
// After the children, so a rollback that deletes this account cannot
|
||||||
|
// leave its answers behind pointing at a user that no longer exists.
|
||||||
|
if ( $asksSelf ) {
|
||||||
|
$this->recordAnswers( $selfQuestions, $answers, (int) $userId );
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||||
@@ -467,21 +569,54 @@ class RegistrationPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the "I'm registering as a parent or guardian" box was ticked.
|
* Whether any required question in `$questions` is left blank in `$answers`.
|
||||||
|
*
|
||||||
|
* `$forChild` picks which required-ness applies: a question can be optional
|
||||||
|
* for the account holder answering about themselves and still required of
|
||||||
|
* every student they register.
|
||||||
|
*
|
||||||
|
* @param list<Question> $questions
|
||||||
|
* @param array<int, string> $answers
|
||||||
*/
|
*/
|
||||||
private function submittedIsGuardian(): bool {
|
private function hasUnansweredRequired( array $questions, array $answers, bool $forChild = false ): bool {
|
||||||
|
foreach ( $questions as $question ) {
|
||||||
|
$required = $forChild ? $question->isRequiredForChild() : $question->isRequiredForSelf();
|
||||||
|
|
||||||
|
if ( $required && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who this signup is for: {@see FOR_SELF}, {@see FOR_STUDENTS} or
|
||||||
|
* {@see FOR_BOTH}.
|
||||||
|
*
|
||||||
|
* Anything unrecognised — including a form posted without the field at all —
|
||||||
|
* falls back to "just myself", the choice that collects the least and grants
|
||||||
|
* the least. A missing radio must not be read as "register these children".
|
||||||
|
*/
|
||||||
|
private function submittedRegisteringFor(): string {
|
||||||
// The submit nonce is verified by the caller before this runs.
|
// The submit nonce is verified by the caller before this runs.
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||||
return '1' === sanitize_text_field( Val::string( wp_unslash( $_POST['us_is_guardian'] ?? '' ) ) );
|
$value = sanitize_key( Val::string( wp_unslash( $_POST['us_registering_for'] ?? '' ) ) );
|
||||||
|
|
||||||
|
return in_array( $value, [ self::FOR_STUDENTS, self::FOR_BOTH ], true ) ? $value : self::FOR_SELF;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The child blocks submitted with a guardian signup, as
|
* The child blocks submitted with a guardian signup, as
|
||||||
* `children[<n>][name|dob|answers]`. Blocks with no name are dropped rather
|
* `children[<n>][name|birth_year|answers]`.
|
||||||
* than rejected — the form always renders one spare block for "add another",
|
|
||||||
* and an untouched spare is not a mistake the guardian needs telling about.
|
|
||||||
*
|
*
|
||||||
* @return list<array{name: string, dob: string, answers: array<int, string>}>
|
* An **entirely empty** block is dropped rather than rejected — the form always
|
||||||
|
* renders one spare for "add another", and an untouched spare is not a mistake
|
||||||
|
* the guardian needs telling about. A block with anything at all filled in is
|
||||||
|
* kept, so {@see handleSubmit()} can reject it for the missing name or birth
|
||||||
|
* year rather than silently discarding what they typed.
|
||||||
|
*
|
||||||
|
* @return list<array{name: string, birth_year: string, answers: array<int, string>}>
|
||||||
*/
|
*/
|
||||||
private function submittedChildren(): array {
|
private function submittedChildren(): array {
|
||||||
// The submit nonce is verified by the caller before this runs.
|
// The submit nonce is verified by the caller before this runs.
|
||||||
@@ -497,20 +632,22 @@ class RegistrationPage {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$name = sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) );
|
$name = trim( sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) ) );
|
||||||
if ( '' === trim( $name ) ) {
|
$birthYear = trim( sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ) );
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$answers = [];
|
$answers = [];
|
||||||
foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) {
|
foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) {
|
||||||
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( '' === $name && '' === $birthYear && '' === trim( implode( '', $answers ) ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$out[] = [
|
$out[] = [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'dob' => sanitize_text_field( Val::string( wp_unslash( $child['dob'] ?? '' ) ) ),
|
'birth_year' => $birthYear,
|
||||||
'answers' => $answers,
|
'answers' => $answers,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +665,7 @@ class RegistrationPage {
|
|||||||
* re-register and children they never confirmed, so it is undone entirely and
|
* re-register and children they never confirmed, so it is undone entirely and
|
||||||
* they simply try again.
|
* they simply try again.
|
||||||
*
|
*
|
||||||
* @param list<array{name: string, dob: string, answers: array<int, string>}> $children
|
* @param list<array{name: string, birth_year: string, answers: array<int, string>}> $children
|
||||||
* @param list<Question> $questions
|
* @param list<Question> $questions
|
||||||
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||||
*/
|
*/
|
||||||
@@ -536,7 +673,7 @@ class RegistrationPage {
|
|||||||
$created = [];
|
$created = [];
|
||||||
|
|
||||||
foreach ( $children as $child ) {
|
foreach ( $children as $child ) {
|
||||||
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['dob'] );
|
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['birth_year'] );
|
||||||
|
|
||||||
if ( $childId instanceof \WP_Error ) {
|
if ( $childId instanceof \WP_Error ) {
|
||||||
foreach ( $created as $id ) {
|
foreach ( $created as $id ) {
|
||||||
|
|||||||
@@ -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,8 @@ class StudentController {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) $lesson->id,
|
'id' => (int) $lesson->id,
|
||||||
|
'kind' => 'lesson',
|
||||||
|
'schedule' => null,
|
||||||
'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 +213,34 @@ 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'],
|
||||||
|
// Set when the class has no time to put on a clock; shown in the
|
||||||
|
// When column in place of a date. See GroupClass\SessionSchedule.
|
||||||
|
'schedule' => $session['schedule'],
|
||||||
|
'offering' => $session['offering_title'],
|
||||||
|
'instructor' => $instructor ? $instructor->display_name : (string) $session['instructor_id'],
|
||||||
|
'status' => $session['status'],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
$this->sessions->upcomingForStudent( $studentId, $now )
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-8
@@ -5,15 +5,25 @@ namespace Unsupervised\Schedular\Auth;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a person's public-facing name for display. Prefers their real name
|
* Resolves a person's public-facing name for display. Prefers their real name
|
||||||
* (first + last), then their nickname — deliberately avoiding the account's
|
* (first + last), then their nickname, then the display name — skipping any of
|
||||||
* login/username, which `display_name` can otherwise expose.
|
* them that is really the account's login or email address, which is the thing
|
||||||
|
* this class exists to keep off the screen.
|
||||||
*/
|
*/
|
||||||
class UserName {
|
class UserName {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The display name for a user: "First Last" when a real name is set,
|
* The display name for a user: "First Last" when a real name is set, else the
|
||||||
* otherwise the WordPress nickname. Falls back to the numeric id (or an empty
|
* first of nickname / display name that is an actual name. Falls back to the
|
||||||
* string when none is given) when the user cannot be loaded or has no name.
|
* numeric id (or an empty string when none is given) when the user cannot be
|
||||||
|
* loaded or has nothing but identifiers on file.
|
||||||
|
*
|
||||||
|
* Display name is consulted at all because WordPress defaults **nickname** to
|
||||||
|
* `user_login`, and signup uses the email address as the login — so a
|
||||||
|
* self-registered account carries its own email as its nickname, and every
|
||||||
|
* screen naming that person showed the address instead. The name they typed
|
||||||
|
* was on file the whole time, in `display_name`. (Accounts created by a
|
||||||
|
* guardian never hit this: `GuardianService::createChild()` sets `nickname`
|
||||||
|
* outright, which is why children read correctly and their parents did not.)
|
||||||
*/
|
*/
|
||||||
public static function format( ?\WP_User $user, int $fallbackId = 0 ): string {
|
public static function format( ?\WP_User $user, int $fallbackId = 0 ): string {
|
||||||
if ( ! $user instanceof \WP_User ) {
|
if ( ! $user instanceof \WP_User ) {
|
||||||
@@ -25,11 +35,25 @@ class UserName {
|
|||||||
return $full;
|
return $full;
|
||||||
}
|
}
|
||||||
|
|
||||||
$nickname = trim( $user->nickname );
|
foreach ( [ $user->nickname, $user->display_name ] as $candidate ) {
|
||||||
if ( '' !== $nickname ) {
|
$candidate = trim( (string) $candidate );
|
||||||
return $nickname;
|
|
||||||
|
if ( '' !== $candidate && ! self::isIdentifier( $candidate, $user ) ) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $fallbackId > 0 ? (string) $fallbackId : '';
|
return $fallbackId > 0 ? (string) $fallbackId : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a candidate name is really the account's login or email address
|
||||||
|
* wearing a name's clothing — the case this class must never pass through.
|
||||||
|
*/
|
||||||
|
private static function isIdentifier( string $candidate, \WP_User $user ): bool {
|
||||||
|
$candidate = strtolower( $candidate );
|
||||||
|
|
||||||
|
return strtolower( (string) $user->user_login ) === $candidate
|
||||||
|
|| strtolower( (string) $user->user_email ) === $candidate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-10
@@ -15,6 +15,12 @@ namespace Unsupervised\Schedular;
|
|||||||
*/
|
*/
|
||||||
class BlockPreview {
|
class BlockPreview {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The marker a required field's label carries, matching the one
|
||||||
|
* {@see Registration\QuestionField::render()} puts on a required question.
|
||||||
|
*/
|
||||||
|
private const REQUIRED_MARK = ' <span class="us-required" aria-hidden="true">*</span>';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sample booking page.
|
* Sample booking page.
|
||||||
*
|
*
|
||||||
@@ -170,10 +176,26 @@ class BlockPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sample family (manage-children) page: two representative children and the
|
* Sample family page: the account holder's own details, two representative
|
||||||
* add form, with the controls inert so the editor preview cannot post.
|
* children and the add form, with the controls inert so the editor preview
|
||||||
|
* cannot post.
|
||||||
*/
|
*/
|
||||||
public static function family(): string {
|
public static function family(): string {
|
||||||
|
$self = sprintf(
|
||||||
|
'<h4>%s</h4><p class="us-family-self-email">%s <span>[email protected]</span></p>'
|
||||||
|
. '<p><label for="us-own-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-own-name" value="%s"></p>'
|
||||||
|
. '<p><label><input type="checkbox" checked disabled> %s</label></p>'
|
||||||
|
. '<p><label for="us-own-birth-year">%s</label><input type="number" id="us-own-birth-year" placeholder="YYYY"></p>'
|
||||||
|
. '<p><button type="button" disabled>%s</button></p>',
|
||||||
|
esc_html__( 'Your details', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Email', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Your name', 'unsupervised-schedular' ),
|
||||||
|
esc_attr__( 'Grace Hopper', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'I take lessons myself', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Your birth year', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Save my details', 'unsupervised-schedular' )
|
||||||
|
);
|
||||||
|
|
||||||
$children = '';
|
$children = '';
|
||||||
foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) {
|
foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) {
|
||||||
$children .= sprintf(
|
$children .= sprintf(
|
||||||
@@ -186,24 +208,45 @@ class BlockPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$add = sprintf(
|
$add = sprintf(
|
||||||
'<h4>%s</h4><p><label for="us-child-name">%s</label><input type="text" id="us-child-name"></p>'
|
'<h4>%s</h4><p><label for="us-child-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-child-name"></p>'
|
||||||
. '<p><label for="us-child-dob">%s</label><input type="date" id="us-child-dob"></p>'
|
. '<p><label for="us-child-birth-year">%s' . self::REQUIRED_MARK . '</label><input type="number" id="us-child-birth-year" placeholder="YYYY"></p>'
|
||||||
. '<p><button type="button" disabled>%s</button></p>',
|
. '<p><button type="button" disabled>%s</button></p>',
|
||||||
esc_html__( 'Add a child', 'unsupervised-schedular' ),
|
esc_html__( 'Add a student', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Name', 'unsupervised-schedular' ),
|
esc_html__( 'Name', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Date of birth', 'unsupervised-schedular' ),
|
esc_html__( 'Birth year', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Add child', 'unsupervised-schedular' )
|
esc_html__( 'Add student', 'unsupervised-schedular' )
|
||||||
);
|
);
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'<div class="us-family">%s<h3>%s</h3><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
|
'<div class="us-family">%s<h3>%s</h3><form class="us-family-self">%s</form>'
|
||||||
self::note( __( 'Editor preview — signed-in guardians see and manage their own children here.', 'unsupervised-schedular' ) ),
|
. '<h4>%s</h4><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
|
||||||
esc_html__( 'Your family', 'unsupervised-schedular' ),
|
self::note( __( 'Editor preview — signed-in visitors see and manage their own details and students here.', 'unsupervised-schedular' ) ),
|
||||||
|
esc_html__( 'Your profile', 'unsupervised-schedular' ),
|
||||||
|
$self,
|
||||||
|
esc_html__( 'Your students', 'unsupervised-schedular' ),
|
||||||
$children,
|
$children,
|
||||||
$add
|
$add
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample account panel. Shown populated whatever the editor's own login
|
||||||
|
* state, since on the published page a signed-out visitor may see nothing at
|
||||||
|
* all and an empty box tells the person placing the block nothing.
|
||||||
|
*/
|
||||||
|
public static function account(): string {
|
||||||
|
return sprintf(
|
||||||
|
'<div class="us-account">%s'
|
||||||
|
. '<p class="us-account-who"><span class="us-account-name">%s</span>'
|
||||||
|
. '<span class="us-account-email">%s</span></p>'
|
||||||
|
. '<p class="us-account-actions"><a class="us-account-signout" href="#">%s</a></p></div>',
|
||||||
|
self::note( __( 'Editor preview — each visitor sees their own account here.', 'unsupervised-schedular' ) ),
|
||||||
|
esc_html__( 'Grace Hopper', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( '[email protected]', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Sign out', 'unsupervised-schedular' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static function note( string $text ): string {
|
private static function note( string $text ): string {
|
||||||
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
|
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Unsupervised\Schedular;
|
namespace Unsupervised\Schedular;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Auth\AccountPage;
|
||||||
use Unsupervised\Schedular\Auth\LoginPage;
|
use Unsupervised\Schedular\Auth\LoginPage;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
@@ -30,6 +31,7 @@ class BlockRegistrar {
|
|||||||
private RegistrationPage $registrationPage,
|
private RegistrationPage $registrationPage,
|
||||||
private GroupClassPage $groupClassPage,
|
private GroupClassPage $groupClassPage,
|
||||||
private FamilyPage $familyPage,
|
private FamilyPage $familyPage,
|
||||||
|
private AccountPage $accountPage,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function register(): void {
|
public function register(): void {
|
||||||
@@ -148,6 +150,15 @@ class BlockRegistrar {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'us-scheduler/account' => [
|
||||||
|
'render' => [ $this, 'renderAccount' ],
|
||||||
|
'attributes' => [
|
||||||
|
'loginPageId' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +206,15 @@ class BlockRegistrar {
|
|||||||
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
|
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the account (who is signed in) block.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes Block attributes.
|
||||||
|
*/
|
||||||
|
public function renderAccount( array $attributes = [] ): string {
|
||||||
|
return $this->isEditorPreview() ? BlockPreview::account() : $this->accountPage->render( $attributes );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the family (manage-children) block.
|
* Renders the family (manage-children) block.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ class LessonController {
|
|||||||
$accepts = [];
|
$accepts = [];
|
||||||
} else {
|
} else {
|
||||||
$row = $this->row( $lesson );
|
$row = $this->row( $lesson );
|
||||||
$answers = $this->detail->answers( $lessonId );
|
$answers = $this->detail->answers( $lesson );
|
||||||
$accepts = $this->detail->acceptances( $lessonId );
|
$accepts = $this->detail->acceptances( $lesson );
|
||||||
}
|
}
|
||||||
|
|
||||||
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
|
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
|
|||||||
*
|
*
|
||||||
* Scoped to a single lesson (the `lesson` registration type), mirroring the
|
* Scoped to a single lesson (the `lesson` registration type), mirroring the
|
||||||
* per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
|
* per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
|
||||||
|
*
|
||||||
|
* A weekly reservation is answered for and agreed to once, so its answers and
|
||||||
|
* acceptances hang off the series anchor. Every occurrence therefore reads its
|
||||||
|
* series' registration, not its own id — otherwise only the first lesson of a
|
||||||
|
* series showed the intake and the audit trail, and the rest looked as though
|
||||||
|
* nothing had been accepted.
|
||||||
*/
|
*/
|
||||||
class LessonDetail {
|
class LessonDetail {
|
||||||
|
|
||||||
@@ -33,7 +39,7 @@ class LessonDetail {
|
|||||||
*
|
*
|
||||||
* @return list<array{question: string, answer: string}>
|
* @return list<array{question: string, answer: string}>
|
||||||
*/
|
*/
|
||||||
public function answers( int $lessonId ): array {
|
public function answers( Lesson $lesson ): array {
|
||||||
return array_map(
|
return array_map(
|
||||||
function ( Answer $answer ): array {
|
function ( Answer $answer ): array {
|
||||||
$question = $this->questions->findById( $answer->questionId );
|
$question = $this->questions->findById( $answer->questionId );
|
||||||
@@ -44,7 +50,7 @@ class LessonDetail {
|
|||||||
'answer' => '' === $value ? '—' : $value,
|
'answer' => '' === $value ? '—' : $value,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
$this->answers->findByRegistration( Answer::REG_LESSON, $lessonId )
|
$this->answers->findByRegistration( Answer::REG_LESSON, $this->registrationId( $lesson ) )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +60,7 @@ class LessonDetail {
|
|||||||
*
|
*
|
||||||
* @return list<array{policy: string, version: string, accepted_at: string, ip: string}>
|
* @return list<array{policy: string, version: string, accepted_at: string, ip: string}>
|
||||||
*/
|
*/
|
||||||
public function acceptances( int $lessonId ): array {
|
public function acceptances( Lesson $lesson ): array {
|
||||||
return array_map(
|
return array_map(
|
||||||
function ( PolicyAcceptance $acceptance ): array {
|
function ( PolicyAcceptance $acceptance ): array {
|
||||||
$version = $this->versions->findById( $acceptance->policyVersionId );
|
$version = $this->versions->findById( $acceptance->policyVersionId );
|
||||||
@@ -67,7 +73,15 @@ class LessonDetail {
|
|||||||
'ip' => $acceptance->ipAddress ?? '',
|
'ip' => $acceptance->ipAddress ?? '',
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
$this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $lessonId )
|
$this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $this->registrationId( $lesson ) )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lesson id the booking's answers and acceptances were recorded against:
|
||||||
|
* the series anchor for a weekly reservation, the lesson itself otherwise.
|
||||||
|
*/
|
||||||
|
private function registrationId( Lesson $lesson ): int {
|
||||||
|
return $lesson->seriesId ?? (int) $lesson->id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\GroupClass;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns group-class enrolments into dated sessions, so a class can appear
|
||||||
|
* alongside one-to-one lessons in every "upcoming" view.
|
||||||
|
*
|
||||||
|
* A group class is stored as a term (`term_start`, `term_end`, `class_time`)
|
||||||
|
* rather than as rows in `us_availability`, which is why an enrolment on its own
|
||||||
|
* has no date on it and why nothing that listed lessons ever showed one. The
|
||||||
|
* dates come from {@see Offering::sessionStarts()} — the same derivation the
|
||||||
|
* billing scan and the class-slot reconciler build on, so a student's list, an
|
||||||
|
* instructor's list and the invoice all agree on when the class meets.
|
||||||
|
*
|
||||||
|
* **A class you are enrolled in must never silently vanish from the list.** Both
|
||||||
|
* the class time and the duration are optional on the offering form, and the
|
||||||
|
* schedule note exists precisely so a studio can write "Tuesdays 4:00pm" instead
|
||||||
|
* of pinning the class to a clock. So the schedule degrades rather than
|
||||||
|
* disappearing:
|
||||||
|
*
|
||||||
|
* - date **and** time set — one dated row per remaining session, closed off with
|
||||||
|
* the duration when there is one and left open-ended when there is not;
|
||||||
|
* - no time to derive dates from — a single row for the class as a whole, sorted
|
||||||
|
* by when the term starts and labelled with `schedule` text
|
||||||
|
* ({@see Offering::scheduleLabel()}) in place of a time.
|
||||||
|
*
|
||||||
|
* A row's `schedule` is the tell: non-null means "this is a class, described in
|
||||||
|
* words, not a session at a known time", and every renderer shows that text
|
||||||
|
* instead of a date and time.
|
||||||
|
*/
|
||||||
|
class SessionSchedule {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a row as a group-class session rather than a one-to-one lesson.
|
||||||
|
* Callers use it to withhold the per-lesson actions (cancel, detail links)
|
||||||
|
* that only mean something for a booked slot.
|
||||||
|
*/
|
||||||
|
public const KIND = 'group_class';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EnrollmentRepository $enrollments,
|
||||||
|
private OfferingRepository $offerings,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upcoming sessions of every class a student is enrolled in, soonest first.
|
||||||
|
*
|
||||||
|
* A withdrawn (cancelled) enrolment contributes nothing; a completed one is
|
||||||
|
* kept, since "completed" describes the enrolment's billing state and says
|
||||||
|
* nothing about whether the class has met yet.
|
||||||
|
*
|
||||||
|
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
|
||||||
|
*/
|
||||||
|
public function upcomingForStudent( int $studentId, string $now ): array {
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ( $this->enrollments->findByStudent( $studentId ) as $enrollment ) {
|
||||||
|
if ( Enrollment::STATUS_CANCELLED === $enrollment->status ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||||
|
if ( null === $offering ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = array_merge(
|
||||||
|
$rows,
|
||||||
|
$this->rowsFor( $offering, $now, (int) $enrollment->id, $enrollment->instructorId, $enrollment->status )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::sortedByStart( $rows );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upcoming sessions of every active group class an instructor teaches,
|
||||||
|
* soonest first — one row per session, not per enrolled student. Enrolments
|
||||||
|
* are not consulted at all: a class the instructor has to turn up and teach
|
||||||
|
* belongs on their schedule whether or not anyone has signed up yet.
|
||||||
|
*
|
||||||
|
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
|
||||||
|
*/
|
||||||
|
public function upcomingForInstructor( int $instructorId, string $now ): array {
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
$classes = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS, activeOnly: true );
|
||||||
|
|
||||||
|
foreach ( $classes as $offering ) {
|
||||||
|
$rows = array_merge( $rows, $this->rowsFor( $offering, $now, 0, $instructorId, Enrollment::STATUS_ACTIVE ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::sortedByStart( $rows );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One class's contribution to an upcoming list: its remaining dated sessions,
|
||||||
|
* or — when it has no time to derive dates from — a single row describing the
|
||||||
|
* class in words. Empty only when the class has demonstrably finished.
|
||||||
|
*
|
||||||
|
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
|
||||||
|
*/
|
||||||
|
private function rowsFor( Offering $offering, string $now, int $enrollmentId, int $instructorId, string $status ): array {
|
||||||
|
$base = [
|
||||||
|
'enrollment_id' => $enrollmentId,
|
||||||
|
'offering_id' => (int) $offering->id,
|
||||||
|
'offering_title' => $offering->title,
|
||||||
|
'instructor_id' => $instructorId,
|
||||||
|
'status' => $status,
|
||||||
|
'duration_minutes' => $offering->durationMinutes,
|
||||||
|
];
|
||||||
|
|
||||||
|
$starts = $offering->sessionStarts();
|
||||||
|
|
||||||
|
// Dated: the class says exactly when it meets, so list what is left of it
|
||||||
|
// — and nothing at all once the term is over.
|
||||||
|
if ( [] !== $starts ) {
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ( $starts as $start ) {
|
||||||
|
if ( $start < $now ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = $base + [
|
||||||
|
'start_dt' => $start,
|
||||||
|
// Left open when no duration is set. Knowing a class starts at
|
||||||
|
// four o'clock is worth showing even without knowing when it
|
||||||
|
// ends; guessing an end time is not.
|
||||||
|
'end_dt' => $this->endOf( $offering, $start ),
|
||||||
|
'schedule' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undated: no class time, so there is nothing to put on a clock. The class
|
||||||
|
// still gets a row — it is enrolled in and running — described by the
|
||||||
|
// studio's own schedule note or its term dates.
|
||||||
|
if ( ! $this->isStillRunning( $offering, $now ) ) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
$base + [
|
||||||
|
// A sort key, not a claim about when the class meets: a class yet to
|
||||||
|
// start sorts to its first day, one already under way to right now.
|
||||||
|
// `schedule` is what any renderer actually shows.
|
||||||
|
'start_dt' => $this->sortKeyFor( $offering, $now ),
|
||||||
|
'end_dt' => '',
|
||||||
|
'schedule' => $offering->scheduleLabel(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a session that starts at `$start` finishes, or an empty string when the
|
||||||
|
* class has no duration to close it off with.
|
||||||
|
*/
|
||||||
|
private function endOf( Offering $offering, string $start ): string {
|
||||||
|
if ( null === $offering->durationMinutes || $offering->durationMinutes <= 0 ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ( new \DateTimeImmutable( $start ) )
|
||||||
|
->add( new \DateInterval( 'PT' . $offering->durationMinutes . 'M' ) )
|
||||||
|
->format( 'Y-m-d H:i:s' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an undated class still has life in it: its last day has not passed,
|
||||||
|
* or it has no dates at all (in which case nothing says it has ended, and
|
||||||
|
* dropping it would be the very disappearance this class exists to prevent).
|
||||||
|
*/
|
||||||
|
private function isStillRunning( Offering $offering, string $now ): bool {
|
||||||
|
$lastDay = $offering->lastClassDay();
|
||||||
|
|
||||||
|
return null === $lastDay || $lastDay >= substr( $now, 0, 10 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an undated class sits in a list ordered by time: at its first day when
|
||||||
|
* that is still ahead, otherwise at `$now`, so a term already under way reads
|
||||||
|
* as current rather than as ancient history.
|
||||||
|
*/
|
||||||
|
private function sortKeyFor( Offering $offering, string $now ): string {
|
||||||
|
if ( null === $offering->termStart ) {
|
||||||
|
return $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstDay = $offering->termStart . ' 00:00:00';
|
||||||
|
|
||||||
|
return $firstDay > $now ? $firstDay : $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soonest session first, so classes from separate enrolments interleave by
|
||||||
|
* date rather than arriving grouped by class.
|
||||||
|
*
|
||||||
|
* @param list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}> $rows
|
||||||
|
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
|
||||||
|
*/
|
||||||
|
private static function sortedByStart( array $rows ): array {
|
||||||
|
usort( $rows, static fn( array $a, array $b ): int => strcmp( $a['start_dt'], $b['start_dt'] ) );
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ class ChildLoginGate {
|
|||||||
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
|
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
|
||||||
return new \WP_Error(
|
return new \WP_Error(
|
||||||
'us_child_account',
|
'us_child_account',
|
||||||
esc_html__( 'This is a child account and cannot be signed in to. Please sign in with the parent or guardian account.', 'unsupervised-schedular' )
|
esc_html__( 'This is a managed student account and cannot be signed in to. Please sign in with the parent or guardian account.', 'unsupervised-schedular' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-10
@@ -10,8 +10,8 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
|
|||||||
use Unsupervised\Schedular\Val;
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The guardian's "my family" screen (`[us_family]`): list, add, edit and remove
|
* The guardian's "my family" screen (`[us_family]`): their own details, plus
|
||||||
* the children they book for.
|
* list, add, edit and remove the children they book for.
|
||||||
*
|
*
|
||||||
* Submissions are processed on `template_redirect` — before any output — and
|
* Submissions are processed on `template_redirect` — before any output — and
|
||||||
* post/redirect/get back to the page, so a refresh cannot resubmit and add the
|
* post/redirect/get back to the page, so a refresh cannot resubmit and add the
|
||||||
@@ -23,6 +23,7 @@ class FamilyPage {
|
|||||||
private const RESULT_ADDED = 'added';
|
private const RESULT_ADDED = 'added';
|
||||||
private const RESULT_UPDATED = 'updated';
|
private const RESULT_UPDATED = 'updated';
|
||||||
private const RESULT_REMOVED = 'removed';
|
private const RESULT_REMOVED = 'removed';
|
||||||
|
private const RESULT_SELF = 'self';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error from the most recent submission processed on `template_redirect`,
|
* Error from the most recent submission processed on `template_redirect`,
|
||||||
@@ -50,7 +51,7 @@ class FamilyPage {
|
|||||||
'<p>%s <a href="%s">%s</a>.</p>',
|
'<p>%s <a href="%s">%s</a>.</p>',
|
||||||
esc_html__( 'Please', 'unsupervised-schedular' ),
|
esc_html__( 'Please', 'unsupervised-schedular' ),
|
||||||
esc_url( $this->loginUrl( $loginPageId ) ),
|
esc_url( $this->loginUrl( $loginPageId ) ),
|
||||||
esc_html__( 'log in to manage your family', 'unsupervised-schedular' )
|
esc_html__( 'log in to manage your profile', 'unsupervised-schedular' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ class FamilyPage {
|
|||||||
|
|
||||||
$userId = get_current_user_id();
|
$userId = get_current_user_id();
|
||||||
|
|
||||||
|
$self = $this->guardians->accountHolder( $userId );
|
||||||
$children = $this->guardians->children( $userId );
|
$children = $this->guardians->children( $userId );
|
||||||
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||||
$error = $this->submitError;
|
$error = $this->submitError;
|
||||||
@@ -99,6 +101,7 @@ class FamilyPage {
|
|||||||
'add' => $this->handleAdd( $userId ),
|
'add' => $this->handleAdd( $userId ),
|
||||||
'edit' => $this->handleEdit( $userId ),
|
'edit' => $this->handleEdit( $userId ),
|
||||||
'remove' => $this->handleRemove( $userId ),
|
'remove' => $this->handleRemove( $userId ),
|
||||||
|
'self' => $this->handleSelf( $userId ),
|
||||||
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
|
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,7 +122,7 @@ class FamilyPage {
|
|||||||
*/
|
*/
|
||||||
private function handleAdd( int $guardianId ): string|\WP_Error {
|
private function handleAdd( int $guardianId ): string|\WP_Error {
|
||||||
$name = $this->postString( 'child_name' );
|
$name = $this->postString( 'child_name' );
|
||||||
$dateOfBirth = $this->postString( 'child_dob' );
|
$birthYear = $this->postString( 'child_birth_year' );
|
||||||
$relationship = $this->postString( 'child_relationship' );
|
$relationship = $this->postString( 'child_relationship' );
|
||||||
|
|
||||||
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||||
@@ -130,7 +133,7 @@ class FamilyPage {
|
|||||||
return $missing;
|
return $missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
$childId = $this->guardians->createChild( $guardianId, $name, $dateOfBirth, $relationship );
|
$childId = $this->guardians->createChild( $guardianId, $name, $birthYear, $relationship );
|
||||||
if ( $childId instanceof \WP_Error ) {
|
if ( $childId instanceof \WP_Error ) {
|
||||||
return $childId;
|
return $childId;
|
||||||
}
|
}
|
||||||
@@ -144,11 +147,26 @@ class FamilyPage {
|
|||||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||||
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
||||||
|
|
||||||
$error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) );
|
$error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_birth_year' ) );
|
||||||
|
|
||||||
return $error ?? self::RESULT_UPDATED;
|
return $error ?? self::RESULT_UPDATED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the account holder's own details. The birth year is only asked of a
|
||||||
|
* student, so it is the checkbox — not the browser — that decides whether one
|
||||||
|
* is required; the field carries no `required` attribute, or a guardian who
|
||||||
|
* books only for other people could never submit the form at all.
|
||||||
|
*/
|
||||||
|
private function handleSelf( int $userId ): string|\WP_Error {
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||||
|
$isStudent = isset( $_POST['is_student'] );
|
||||||
|
|
||||||
|
$error = $this->guardians->updateSelf( $userId, $this->postString( 'own_name' ), $this->postString( 'own_birth_year' ), $isStudent );
|
||||||
|
|
||||||
|
return $error ?? self::RESULT_SELF;
|
||||||
|
}
|
||||||
|
|
||||||
private function handleRemove( int $guardianId ): string|\WP_Error {
|
private function handleRemove( int $guardianId ): string|\WP_Error {
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||||
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
||||||
@@ -162,13 +180,17 @@ class FamilyPage {
|
|||||||
* The first required question left unanswered, as the error to show — or null
|
* The first required question left unanswered, as the error to show — or null
|
||||||
* when every required question has a value.
|
* when every required question has a value.
|
||||||
*
|
*
|
||||||
|
* This screen only ever adds a student the guardian registers, so the
|
||||||
|
* students' required-ness is the one that applies — the same rule the child
|
||||||
|
* blocks on the signup form are held to.
|
||||||
|
*
|
||||||
* @param list<Question> $questions
|
* @param list<Question> $questions
|
||||||
* @param array<int, string> $answers question_id => submitted value
|
* @param array<int, string> $answers question_id => submitted value
|
||||||
*/
|
*/
|
||||||
private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error {
|
private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error {
|
||||||
foreach ( $questions as $question ) {
|
foreach ( $questions as $question ) {
|
||||||
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
if ( $question->isRequiredForChild() && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||||
return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this child.', 'unsupervised-schedular' ) );
|
return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this student.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,9 +259,10 @@ class FamilyPage {
|
|||||||
*/
|
*/
|
||||||
private function noticeFor( string $result ): string {
|
private function noticeFor( string $result ): string {
|
||||||
return match ( $result ) {
|
return match ( $result ) {
|
||||||
self::RESULT_ADDED => __( 'Child added.', 'unsupervised-schedular' ),
|
self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ),
|
||||||
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
|
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
|
||||||
self::RESULT_REMOVED => __( 'Child removed.', 'unsupervised-schedular' ),
|
self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ),
|
||||||
|
self::RESULT_SELF => __( 'Your details have been updated.', 'unsupervised-schedular' ),
|
||||||
default => '',
|
default => '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,35 @@ class GuardianService {
|
|||||||
*/
|
*/
|
||||||
public const META_CHILD = 'us_child';
|
public const META_CHILD = 'us_child';
|
||||||
|
|
||||||
/** A child's date of birth (`Y-m-d`), collected at signup and editable after. */
|
/** A child's birth year (`YYYY`), collected at signup and editable after. */
|
||||||
|
public const META_BIRTH_YEAR = 'us_birth_year';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full date of birth this feature used to collect. Nothing writes it any
|
||||||
|
* more: it is read once, to derive a birth year for a child who predates the
|
||||||
|
* change, and cleared the moment that child's record is next saved. Kept
|
||||||
|
* public so a site that wants to purge the old dates outright can find them.
|
||||||
|
*/
|
||||||
public const META_DOB = 'us_date_of_birth';
|
public const META_DOB = 'us_date_of_birth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set on an account that registered **only** to book for other people, so it
|
||||||
|
* is not offered as a student in its own right.
|
||||||
|
*
|
||||||
|
* Stored as the negative on purpose. Every account that existed before this
|
||||||
|
* choice was offered is a bookable student, and absence of the flag has to
|
||||||
|
* keep meaning exactly that — otherwise the picker would quietly stop
|
||||||
|
* offering people themselves on upgrade.
|
||||||
|
*/
|
||||||
|
public const META_GUARDIAN_ONLY = 'us_guardian_only';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The earliest birth year the form will accept. Old enough for any student a
|
||||||
|
* studio will ever enrol, and late enough to reject a typo like `19` or `190`
|
||||||
|
* that would otherwise be stored as a plausible-looking year.
|
||||||
|
*/
|
||||||
|
private const MIN_BIRTH_YEAR = 1900;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain used for a child's placeholder login address. `.invalid` is reserved
|
* Domain used for a child's placeholder login address. `.invalid` is reserved
|
||||||
* by RFC 2606 and can never resolve, so a child's address is guaranteed
|
* by RFC 2606 and can never resolve, so a child's address is guaranteed
|
||||||
@@ -45,13 +71,17 @@ class GuardianService {
|
|||||||
* is random and discarded — it is never stored anywhere readable, emailed, or
|
* is random and discarded — it is never stored anywhere readable, emailed, or
|
||||||
* shown — so the account cannot be signed into even if the gate were removed.
|
* shown — so the account cannot be signed into even if the gate were removed.
|
||||||
*
|
*
|
||||||
* Returns the new user ID, or a `WP_Error` when the name is blank or WordPress
|
* Returns the new user ID, or a `WP_Error` when the name is blank, the birth
|
||||||
* refuses the insert.
|
* year is missing or unusable, or WordPress refuses the insert.
|
||||||
*/
|
*/
|
||||||
public function createChild( int $guardianId, string $name, string $dateOfBirth = '', string $relationship = '' ): int|\WP_Error {
|
public function createChild( int $guardianId, string $name, string $birthYear = '', string $relationship = '' ): int|\WP_Error {
|
||||||
$name = trim( $name );
|
$name = trim( $name );
|
||||||
if ( '' === $name ) {
|
if ( '' === $name ) {
|
||||||
return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) );
|
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
|
||||||
|
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
|
||||||
}
|
}
|
||||||
|
|
||||||
$email = $this->childEmail();
|
$email = $this->childEmail();
|
||||||
@@ -73,7 +103,7 @@ class GuardianService {
|
|||||||
$userId = (int) $userId;
|
$userId = (int) $userId;
|
||||||
|
|
||||||
update_user_meta( $userId, self::META_CHILD, '1' );
|
update_user_meta( $userId, self::META_CHILD, '1' );
|
||||||
$this->setDateOfBirth( $userId, $dateOfBirth );
|
$this->setBirthYear( $userId, $birthYear );
|
||||||
|
|
||||||
$linkId = $this->guardians->insert(
|
$linkId = $this->guardians->insert(
|
||||||
new GuardianLink(
|
new GuardianLink(
|
||||||
@@ -89,27 +119,31 @@ class GuardianService {
|
|||||||
if ( $linkId <= 0 ) {
|
if ( $linkId <= 0 ) {
|
||||||
$this->deleteUser( $userId );
|
$this->deleteUser( $userId );
|
||||||
|
|
||||||
return new \WP_Error( 'link_failed', __( 'Could not add this child. Please contact the studio.', 'unsupervised-schedular' ) );
|
return new \WP_Error( 'link_failed', __( 'Could not add this student. Please contact the studio.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
return $userId;
|
return $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename a child and update their date of birth. Refuses a student the caller
|
* Rename a child and update their birth year. Refuses a student the caller
|
||||||
* is not the guardian of, so the family screen cannot be turned into an
|
* is not the guardian of, so the family screen cannot be turned into an
|
||||||
* arbitrary user editor by posting someone else's id.
|
* arbitrary user editor by posting someone else's id.
|
||||||
*
|
*
|
||||||
* Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}.
|
* Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}.
|
||||||
*/
|
*/
|
||||||
public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): ?\WP_Error {
|
public function updateChild( int $guardianId, int $studentId, string $name, string $birthYear = '' ): ?\WP_Error {
|
||||||
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
||||||
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) );
|
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
$name = trim( $name );
|
$name = trim( $name );
|
||||||
if ( '' === $name ) {
|
if ( '' === $name ) {
|
||||||
return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) );
|
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 0 === self::normaliseBirthYear( $birthYear ) ) {
|
||||||
|
return new \WP_Error( 'missing_birth_year', self::birthYearError() );
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = wp_update_user(
|
$result = wp_update_user(
|
||||||
@@ -124,7 +158,53 @@ class GuardianService {
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->setDateOfBirth( $studentId, $dateOfBirth );
|
$this->setBirthYear( $studentId, $birthYear );
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the account holder's own details from the profile screen: their
|
||||||
|
* name, whether they are a student in their own right, and — when they are —
|
||||||
|
* their birth year.
|
||||||
|
*
|
||||||
|
* `$isStudent` is the positive of what {@see META_GUARDIAN_ONLY} stores, so
|
||||||
|
* the form can ask the question the way a person would answer it and this is
|
||||||
|
* the single place the sense is flipped.
|
||||||
|
*
|
||||||
|
* Returns null on success, mirroring {@see updateChild()}.
|
||||||
|
*/
|
||||||
|
public function updateSelf( int $userId, string $name, string $birthYear, bool $isStudent ): ?\WP_Error {
|
||||||
|
$name = trim( $name );
|
||||||
|
if ( '' === $name ) {
|
||||||
|
return new \WP_Error( 'missing_name', __( 'Please give your name.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $isStudent && 0 === self::normaliseBirthYear( $birthYear ) ) {
|
||||||
|
return new \WP_Error( 'missing_birth_year', self::ownBirthYearError() );
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = wp_update_user(
|
||||||
|
[
|
||||||
|
'ID' => $userId,
|
||||||
|
'display_name' => $name,
|
||||||
|
'nickname' => $name,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setGuardianOnly( $userId, ! $isStudent );
|
||||||
|
|
||||||
|
// Only written when they are a student. Saying "I only book for other
|
||||||
|
// people" is a statement about who books, not an instruction to forget a
|
||||||
|
// year already on file — and someone who ticks the box back on the next
|
||||||
|
// visit should find their own details as they left them.
|
||||||
|
if ( $isStudent ) {
|
||||||
|
$this->setBirthYear( $userId, $birthYear );
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -139,13 +219,13 @@ class GuardianService {
|
|||||||
*/
|
*/
|
||||||
public function removeChild( int $guardianId, int $studentId ): ?\WP_Error {
|
public function removeChild( int $guardianId, int $studentId ): ?\WP_Error {
|
||||||
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
||||||
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) );
|
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) {
|
if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) {
|
||||||
return new \WP_Error(
|
return new \WP_Error(
|
||||||
'has_history',
|
'has_history',
|
||||||
__( 'This child has lessons or enrolments on record and cannot be removed here. Please contact the studio.', 'unsupervised-schedular' )
|
__( 'This student has lessons or enrolments on record and cannot be removed here. Please contact the studio.', 'unsupervised-schedular' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +285,9 @@ class GuardianService {
|
|||||||
* their kid is the error worth designing out.
|
* their kid is the error worth designing out.
|
||||||
*
|
*
|
||||||
* The guardian is still offered, last, so a parent taking lessons alongside
|
* The guardian is still offered, last, so a parent taking lessons alongside
|
||||||
* their children can book for themselves from the same account.
|
* their children can book for themselves from the same account — unless they
|
||||||
|
* said at signup that they are not a student, in which case offering them is
|
||||||
|
* an invitation to book a lesson nobody meant to buy.
|
||||||
*
|
*
|
||||||
* @return list<array{id: int, name: string, is_self: bool}>
|
* @return list<array{id: int, name: string, is_self: bool}>
|
||||||
*/
|
*/
|
||||||
@@ -220,6 +302,13 @@ class GuardianService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A guardian-only account with nobody linked to it would otherwise get an
|
||||||
|
// empty list and no way to book at all. Offering them themselves is the
|
||||||
|
// lesser wrong: they can still correct the account from the profile page.
|
||||||
|
if ( self::isGuardianOnly( $userId ) && [] !== $out ) {
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
$self = get_userdata( $userId );
|
$self = get_userdata( $userId );
|
||||||
|
|
||||||
$out[] = [
|
$out[] = [
|
||||||
@@ -231,11 +320,33 @@ class GuardianService {
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this account books only for other people. False for every account
|
||||||
|
* that predates the choice — see {@see META_GUARDIAN_ONLY}.
|
||||||
|
*/
|
||||||
|
public static function isGuardianOnly( int $userId ): bool {
|
||||||
|
return '1' === Val::string( get_user_meta( $userId, self::META_GUARDIAN_ONLY, true ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record whether this account is a student in its own right. Clears the flag
|
||||||
|
* rather than storing a `0`, so "not set" stays the single meaning of "yes,
|
||||||
|
* they are a student".
|
||||||
|
*/
|
||||||
|
public function setGuardianOnly( int $userId, bool $guardianOnly ): void {
|
||||||
|
if ( $guardianOnly ) {
|
||||||
|
update_user_meta( $userId, self::META_GUARDIAN_ONLY, '1' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_user_meta( $userId, self::META_GUARDIAN_ONLY );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A guardian's children, in link order, with the details the family and admin
|
* A guardian's children, in link order, with the details the family and admin
|
||||||
* screens display.
|
* screens display.
|
||||||
*
|
*
|
||||||
* @return list<array{id: int, name: string, date_of_birth: string, relationship: string}>
|
* @return list<array{id: int, name: string, birth_year: string, relationship: string}>
|
||||||
*/
|
*/
|
||||||
public function children( int $guardianId ): array {
|
public function children( int $guardianId ): array {
|
||||||
$out = [];
|
$out = [];
|
||||||
@@ -244,16 +355,36 @@ class GuardianService {
|
|||||||
$user = get_userdata( $link->studentId );
|
$user = get_userdata( $link->studentId );
|
||||||
|
|
||||||
$out[] = [
|
$out[] = [
|
||||||
'id' => $link->studentId,
|
'id' => $link->studentId,
|
||||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
|
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
|
||||||
'date_of_birth' => Val::string( get_user_meta( $link->studentId, self::META_DOB, true ) ),
|
'birth_year' => $this->birthYear( $link->studentId ),
|
||||||
'relationship' => $link->relationship,
|
'relationship' => $link->relationship,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account holder's own details, as the profile screen's form needs them.
|
||||||
|
* The counterpart to {@see children()} for the person reading the page.
|
||||||
|
*
|
||||||
|
* `is_student` is the positive of {@see META_GUARDIAN_ONLY} — see
|
||||||
|
* {@see updateSelf()}, which reads it back the same way round.
|
||||||
|
*
|
||||||
|
* @return array{name: string, email: string, birth_year: string, is_student: bool}
|
||||||
|
*/
|
||||||
|
public function accountHolder( int $userId ): array {
|
||||||
|
$user = get_userdata( $userId );
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $userId ),
|
||||||
|
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
||||||
|
'birth_year' => $this->birthYear( $userId ),
|
||||||
|
'is_student' => ! self::isGuardianOnly( $userId ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The guardian behind a child, or null when the student books for themselves.
|
* The guardian behind a child, or null when the student books for themselves.
|
||||||
*
|
*
|
||||||
@@ -327,24 +458,96 @@ class GuardianService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a child's date of birth, or clear it when blank or unparseable. Kept
|
* Store a student's birth year, or clear it when blank or out of range. Used
|
||||||
* as `Y-m-d` so it sorts and displays consistently wherever it is read.
|
* for a child added by their guardian and for an account holder who is a
|
||||||
|
* student in their own right — the same fact about the same kind of person,
|
||||||
|
* so the same meta key holds both.
|
||||||
|
*
|
||||||
|
* Either way the legacy full date of birth goes with it. That is what makes
|
||||||
|
* the read fallback in {@see birthYear()} safe: without it, clearing the year
|
||||||
|
* on a child who predates this change would leave the old date behind for the
|
||||||
|
* fallback to resurrect on the very next read.
|
||||||
*/
|
*/
|
||||||
private function setDateOfBirth( int $userId, string $dateOfBirth ): void {
|
public function setBirthYear( int $userId, string $birthYear ): void {
|
||||||
$dateOfBirth = trim( $dateOfBirth );
|
delete_user_meta( $userId, self::META_DOB );
|
||||||
|
|
||||||
if ( '' === $dateOfBirth ) {
|
$year = self::normaliseBirthYear( $birthYear );
|
||||||
delete_user_meta( $userId, self::META_DOB );
|
|
||||||
|
if ( 0 === $year ) {
|
||||||
|
delete_user_meta( $userId, self::META_BIRTH_YEAR );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d', $dateOfBirth );
|
update_user_meta( $userId, self::META_BIRTH_YEAR, (string) $year );
|
||||||
if ( false === $parsed ) {
|
}
|
||||||
delete_user_meta( $userId, self::META_DOB );
|
|
||||||
return;
|
/**
|
||||||
|
* A submitted birth year as an integer, or 0 when it is blank, not a number,
|
||||||
|
* or outside {@see MIN_BIRTH_YEAR}..this year. A year in the future is a typo
|
||||||
|
* every time, so it is refused rather than stored.
|
||||||
|
*
|
||||||
|
* Public and static so the signup form can reject a bad year up front, before
|
||||||
|
* it creates any users, without a second copy of the rule to keep in step.
|
||||||
|
*/
|
||||||
|
public static function normaliseBirthYear( string $birthYear ): int {
|
||||||
|
$birthYear = trim( $birthYear );
|
||||||
|
|
||||||
|
if ( '' === $birthYear || 1 !== preg_match( '/^\d{4}$/', $birthYear ) ) {
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
update_user_meta( $userId, self::META_DOB, $parsed->format( 'Y-m-d' ) );
|
$year = (int) $birthYear;
|
||||||
|
|
||||||
|
if ( $year < self::MIN_BIRTH_YEAR || $year > (int) current_time( 'Y' ) ) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $year;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message shown when a birth year is missing or unusable. One phrasing,
|
||||||
|
* shared by the signup form and the profile screen, so a guardian is told the
|
||||||
|
* same thing whichever way they got there.
|
||||||
|
*/
|
||||||
|
public static function birthYearError(): string {
|
||||||
|
return sprintf(
|
||||||
|
/* translators: %d: the earliest birth year the form accepts. */
|
||||||
|
__( 'Please give each student a birth year, as four digits from %d onwards.', 'unsupervised-schedular' ),
|
||||||
|
self::MIN_BIRTH_YEAR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Falls back to the year of the full date of birth this feature used to
|
||||||
|
* collect, so a child added before the change still shows one. The fallback
|
||||||
|
* is read-only and one-way: {@see setBirthYear()} drops the old date as soon
|
||||||
|
* as the record is saved again.
|
||||||
|
*/
|
||||||
|
private function birthYear( int $userId ): string {
|
||||||
|
$year = Val::string( get_user_meta( $userId, self::META_BIRTH_YEAR, true ) );
|
||||||
|
if ( '' !== $year ) {
|
||||||
|
return $year;
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacy = Val::string( get_user_meta( $userId, self::META_DOB, true ) );
|
||||||
|
|
||||||
|
return 1 === preg_match( '/^(\d{4})-/', $legacy, $m ) ? $m[1] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+78
-21
@@ -175,22 +175,19 @@ class Offering {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The concrete start/end datetimes of every session of this group class,
|
* The datetime each session of this group class starts, derived from the class
|
||||||
* derived from the class date(s), the class time, and the duration. A weekly
|
* date(s) and the class time. A weekly class yields one per week from
|
||||||
* class yields one window per week from `term_start` through `term_end`; a
|
* `term_start` through `term_end`; a one-off class yields a single one.
|
||||||
* one-off class yields a single window. Returns an empty list unless the
|
|
||||||
* schedule is fully specified (date, time, and a positive duration), so it can
|
|
||||||
* never fabricate a session window from partial data.
|
|
||||||
*
|
*
|
||||||
* @return list<array{start: string, end: string}>
|
* Deliberately does **not** need a duration: knowing *when* a class meets is a
|
||||||
|
* separate question from knowing how long it runs, and a studio can quite
|
||||||
|
* reasonably set the first without the second. Returns an empty list when
|
||||||
|
* there is no date or no time, since neither can be invented.
|
||||||
|
*
|
||||||
|
* @return list<string> `Y-m-d H:i:s` starts, earliest first.
|
||||||
*/
|
*/
|
||||||
public function sessionWindows(): array {
|
public function sessionStarts(): array {
|
||||||
if (
|
if ( null === $this->termStart || null === $this->classTime ) {
|
||||||
null === $this->termStart
|
|
||||||
|| null === $this->classTime
|
|
||||||
|| null === $this->durationMinutes
|
|
||||||
|| $this->durationMinutes <= 0
|
|
||||||
) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,25 +197,85 @@ class Offering {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$lastDay = null !== $this->termEnd ? $this->termEnd : $this->termStart;
|
$lastDay = null !== $this->termEnd ? $this->termEnd : $this->termStart;
|
||||||
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
|
|
||||||
|
|
||||||
$windows = [];
|
$starts = [];
|
||||||
$cursor = $first;
|
$cursor = $first;
|
||||||
$cursorDay = $cursor->format( 'Y-m-d' );
|
$cursorDay = $cursor->format( 'Y-m-d' );
|
||||||
|
|
||||||
// Cap the walk at ten years of weeks so a term_end before term_start (or a
|
// Cap the walk at ten years of weeks so a term_end before term_start (or a
|
||||||
// bad value) can never spin into an unbounded loop.
|
// bad value) can never spin into an unbounded loop.
|
||||||
for ( $i = 0; $i < 520 && $cursorDay <= $lastDay; $i++ ) {
|
for ( $i = 0; $i < 520 && $cursorDay <= $lastDay; $i++ ) {
|
||||||
$windows[] = [
|
$starts[] = $cursor->format( 'Y-m-d H:i:s' );
|
||||||
'start' => $cursor->format( 'Y-m-d H:i:s' ),
|
|
||||||
'end' => $cursor->add( $step )->format( 'Y-m-d H:i:s' ),
|
|
||||||
];
|
|
||||||
|
|
||||||
$cursor = $cursor->modify( '+7 days' );
|
$cursor = $cursor->modify( '+7 days' );
|
||||||
$cursorDay = $cursor->format( 'Y-m-d' );
|
$cursorDay = $cursor->format( 'Y-m-d' );
|
||||||
}
|
}
|
||||||
|
|
||||||
return $windows;
|
return $starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The concrete start/end datetimes of every session of this group class:
|
||||||
|
* {@see sessionStarts()} closed off with the class duration. Returns an empty
|
||||||
|
* list unless the schedule is fully specified (date, time, *and* a positive
|
||||||
|
* duration), so it can never fabricate a session window from partial data —
|
||||||
|
* callers that block availability or bill per session need both ends.
|
||||||
|
*
|
||||||
|
* @return list<array{start: string, end: string}>
|
||||||
|
*/
|
||||||
|
public function sessionWindows(): array {
|
||||||
|
if ( null === $this->durationMinutes || $this->durationMinutes <= 0 ) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn( string $start ): array => [
|
||||||
|
'start' => $start,
|
||||||
|
'end' => ( new \DateTimeImmutable( $start ) )->add( $step )->format( 'Y-m-d H:i:s' ),
|
||||||
|
],
|
||||||
|
$this->sessionStarts()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last day this class meets, or null when it has no dates at all.
|
||||||
|
*/
|
||||||
|
public function lastClassDay(): ?string {
|
||||||
|
return $this->termEnd ?? $this->termStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-language wording for when this class meets, for the places that have
|
||||||
|
* to say something about a class whose schedule cannot be resolved to dates.
|
||||||
|
* Prefers the studio's own note ("Tuesdays 4:00pm") — that field exists
|
||||||
|
* precisely so a class can describe its schedule without pinning it to a
|
||||||
|
* time — then the term dates, and finally an honest admission that nothing
|
||||||
|
* has been set.
|
||||||
|
*/
|
||||||
|
public function scheduleLabel(): string {
|
||||||
|
$note = null !== $this->scheduleNote ? trim( $this->scheduleNote ) : '';
|
||||||
|
if ( '' !== $note ) {
|
||||||
|
return $note;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( null === $this->termStart ) {
|
||||||
|
return __( 'Schedule to be confirmed', 'unsupervised-schedular' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = (string) mysql2date( 'M j, Y', $this->termStart );
|
||||||
|
|
||||||
|
if ( null === $this->termEnd || $this->termEnd === $this->termStart ) {
|
||||||
|
return $start;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
/* translators: 1: first class date, 2: last class date. */
|
||||||
|
__( '%1$s – %2$s', 'unsupervised-schedular' ),
|
||||||
|
$start,
|
||||||
|
(string) mysql2date( 'M j, Y', $this->termEnd )
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fromRow( \stdClass $row ): self {
|
public static function fromRow( \stdClass $row ): self {
|
||||||
|
|||||||
+15
-2
@@ -3,8 +3,10 @@ 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\LoginPage;
|
use Unsupervised\Schedular\Auth\LoginPage;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationLoginGate;
|
use Unsupervised\Schedular\Auth\RegistrationLoginGate;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||||
@@ -70,6 +72,15 @@ class Plugin {
|
|||||||
update_option( 'us_questions_offering_nullable', '1' );
|
update_option( 'us_questions_offering_nullable', '1' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-time backfill of us_questions.is_required_child, which dbDelta adds
|
||||||
|
// defaulting to 0 — leaving every question that *was* required no longer
|
||||||
|
// required of the students a guardian registers. Runs after the version
|
||||||
|
// gate above, so the column it writes to exists. Guarded so a question
|
||||||
|
// later made optional for students stays that way.
|
||||||
|
if ( '1' !== get_option( 'us_questions_child_required_backfilled', '' ) && $questions->backfillChildRequired() ) {
|
||||||
|
update_option( 'us_questions_child_required_backfilled', '1' );
|
||||||
|
}
|
||||||
|
|
||||||
$answers = new AnswerRepository( $wpdb );
|
$answers = new AnswerRepository( $wpdb );
|
||||||
$policies = new PolicyRepository( $wpdb );
|
$policies = new PolicyRepository( $wpdb );
|
||||||
$policyVersions = new PolicyVersionRepository( $wpdb );
|
$policyVersions = new PolicyVersionRepository( $wpdb );
|
||||||
@@ -99,6 +110,7 @@ class Plugin {
|
|||||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians );
|
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians );
|
||||||
$groupClassPage = new GroupClassPage( $guardians );
|
$groupClassPage = new GroupClassPage( $guardians );
|
||||||
$familyPage = new FamilyPage( $guardians, $questions, $answers );
|
$familyPage = new FamilyPage( $guardians, $questions, $answers );
|
||||||
|
$accountPage = new AccountPage();
|
||||||
|
|
||||||
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
|
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
|
||||||
|
|
||||||
@@ -107,10 +119,11 @@ 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, $guardianRepo, $guardians ) )->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();
|
||||||
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register();
|
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
|
||||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register();
|
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 );
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ class Question {
|
|||||||
/** Question is studio-wide, asked once at account signup (no offering). */
|
/** Question is studio-wide, asked once at account signup (no offering). */
|
||||||
public const SCOPE_ACCOUNT = 'account';
|
public const SCOPE_ACCOUNT = 'account';
|
||||||
|
|
||||||
|
/** Asked of everyone: the account holder as a student, and each student they register. */
|
||||||
|
public const AUDIENCE_ALL = 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asked only of the students someone registers on behalf of — never of the
|
||||||
|
* account holder's own "About you" panel. For the questions that only make
|
||||||
|
* sense about a child ("school and grade", "who may collect them").
|
||||||
|
*/
|
||||||
|
public const AUDIENCE_CHILD = 'child';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All valid field types.
|
* All valid field types.
|
||||||
*
|
*
|
||||||
@@ -43,9 +53,29 @@ class Question {
|
|||||||
self::SCOPE_ACCOUNT,
|
self::SCOPE_ACCOUNT,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All valid audiences.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const VALID_AUDIENCES = [
|
||||||
|
self::AUDIENCE_ALL,
|
||||||
|
self::AUDIENCE_CHILD,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an intake question value object.
|
* Build an intake question value object.
|
||||||
*
|
*
|
||||||
|
* `$isRequired` and `$isRequiredChild` are deliberately separate: a studio may
|
||||||
|
* want an answer from every student it enrols without demanding the same of an
|
||||||
|
* adult signing themselves up. Read them through {@see isRequiredForSelf()} and
|
||||||
|
* {@see isRequiredForChild()} rather than directly, so the audience is applied
|
||||||
|
* with them.
|
||||||
|
*
|
||||||
|
* Both `$audience` and `$isRequiredChild` are meaningless for offering scope,
|
||||||
|
* where a booking asks its questions once about the student being booked and
|
||||||
|
* there is no separate account-holder form to differ from.
|
||||||
|
*
|
||||||
* @param int|null $offeringId The owning offering, or null for account-scoped questions.
|
* @param int|null $offeringId The owning offering, or null for account-scoped questions.
|
||||||
* @param list<string>|null $options Choices for a `select` field.
|
* @param list<string>|null $options Choices for a `select` field.
|
||||||
*/
|
*/
|
||||||
@@ -58,9 +88,36 @@ class Question {
|
|||||||
public readonly int $sortOrder = 0,
|
public readonly int $sortOrder = 0,
|
||||||
public readonly bool $isActive = true,
|
public readonly bool $isActive = true,
|
||||||
public readonly string $scope = self::SCOPE_OFFERING,
|
public readonly string $scope = self::SCOPE_OFFERING,
|
||||||
|
public readonly string $audience = self::AUDIENCE_ALL,
|
||||||
|
public readonly bool $isRequiredChild = false,
|
||||||
public readonly ?int $id = null,
|
public readonly ?int $id = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the account holder is asked this question in their own right — true
|
||||||
|
* for everything except a child-audience question.
|
||||||
|
*/
|
||||||
|
public function askedOfSelf(): bool {
|
||||||
|
return self::AUDIENCE_CHILD !== $this->audience;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the account holder must answer before the form will submit. A
|
||||||
|
* child-audience question never reaches them, so it can never block them.
|
||||||
|
*/
|
||||||
|
public function isRequiredForSelf(): bool {
|
||||||
|
return $this->isRequired && $this->askedOfSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether each student being registered must answer before the form will
|
||||||
|
* submit. Every question is asked in the student blocks whatever its audience,
|
||||||
|
* so this stands on its own.
|
||||||
|
*/
|
||||||
|
public function isRequiredForChild(): bool {
|
||||||
|
return $this->isRequiredChild;
|
||||||
|
}
|
||||||
|
|
||||||
public static function fromRow( \stdClass $row ): self {
|
public static function fromRow( \stdClass $row ): self {
|
||||||
$options = null;
|
$options = null;
|
||||||
if ( null !== $row->options && '' !== $row->options ) {
|
if ( null !== $row->options && '' !== $row->options ) {
|
||||||
@@ -70,16 +127,25 @@ class Question {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `audience` and `is_required_child` arrived after the table did, so a row
|
||||||
|
// read on a site whose dbDelta has not run yet simply lacks them: the
|
||||||
|
// pre-existing behaviour (asked of everyone, required of nobody in
|
||||||
|
// particular) is the right reading of a question authored before the
|
||||||
|
// distinction existed.
|
||||||
|
$audience = Val::string( $row->audience ?? '' );
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
offeringId: Val::intOrNull( $row->offering_id ),
|
offeringId: Val::intOrNull( $row->offering_id ),
|
||||||
label: Val::string( $row->label ),
|
label: Val::string( $row->label ),
|
||||||
fieldType: Val::string( $row->field_type ),
|
fieldType: Val::string( $row->field_type ),
|
||||||
options: $options,
|
options: $options,
|
||||||
isRequired: Val::bool( $row->is_required ),
|
isRequired: Val::bool( $row->is_required ),
|
||||||
sortOrder: Val::int( $row->sort_order ),
|
sortOrder: Val::int( $row->sort_order ),
|
||||||
isActive: Val::bool( $row->is_active ),
|
isActive: Val::bool( $row->is_active ),
|
||||||
scope: Val::string( $row->scope ),
|
scope: Val::string( $row->scope ),
|
||||||
id: Val::int( $row->id ),
|
audience: in_array( $audience, self::VALID_AUDIENCES, true ) ? $audience : self::AUDIENCE_ALL,
|
||||||
|
isRequiredChild: Val::bool( $row->is_required_child ?? false ),
|
||||||
|
id: Val::int( $row->id ),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,15 +156,17 @@ class Question {
|
|||||||
*/
|
*/
|
||||||
public function toArray(): array {
|
public function toArray(): array {
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'offering_id' => $this->offeringId,
|
'offering_id' => $this->offeringId,
|
||||||
'scope' => $this->scope,
|
'scope' => $this->scope,
|
||||||
'label' => $this->label,
|
'label' => $this->label,
|
||||||
'field_type' => $this->fieldType,
|
'field_type' => $this->fieldType,
|
||||||
'options' => $this->options,
|
'options' => $this->options,
|
||||||
'is_required' => $this->isRequired,
|
'audience' => $this->audience,
|
||||||
'sort_order' => $this->sortOrder,
|
'is_required' => $this->isRequired,
|
||||||
'is_active' => $this->isActive,
|
'is_required_child' => $this->isRequiredChild,
|
||||||
|
'sort_order' => $this->sortOrder,
|
||||||
|
'is_active' => $this->isActive,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,15 +89,25 @@ class QuestionController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audience and the students' own required-ness are asked for on the
|
||||||
|
// account-scope form only; an offering's questions are answered once about
|
||||||
|
// the student being booked, so there is no second audience to differ from.
|
||||||
|
// An offering question therefore mirrors its single "required" into both
|
||||||
|
// columns rather than storing a distinction it does not have.
|
||||||
|
$accountScope = null === $offering;
|
||||||
|
$audience = sanitize_key( Val::string( wp_unslash( $_POST['audience'] ?? '' ) ) );
|
||||||
|
|
||||||
$this->questions->insert(
|
$this->questions->insert(
|
||||||
new Question(
|
new Question(
|
||||||
offeringId: null === $offering ? null : (int) $offering->id,
|
offeringId: $accountScope ? null : (int) $offering->id,
|
||||||
label: $label,
|
label: $label,
|
||||||
fieldType: $fieldType,
|
fieldType: $fieldType,
|
||||||
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ),
|
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ),
|
||||||
isRequired: isset( $_POST['is_required'] ),
|
isRequired: isset( $_POST['is_required'] ),
|
||||||
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ),
|
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ),
|
||||||
scope: null === $offering ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
|
scope: $accountScope ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
|
||||||
|
audience: $accountScope && in_array( $audience, Question::VALID_AUDIENCES, true ) ? $audience : Question::AUDIENCE_ALL,
|
||||||
|
isRequiredChild: $accountScope ? isset( $_POST['is_required_child'] ) : isset( $_POST['is_required'] ),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
|||||||
@@ -88,14 +88,21 @@ class QuestionEndpoint {
|
|||||||
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$isRequired = (bool) $request->get_param( 'is_required' );
|
||||||
|
|
||||||
$question = new Question(
|
$question = new Question(
|
||||||
offeringId: $offeringId,
|
offeringId: $offeringId,
|
||||||
label: $label,
|
label: $label,
|
||||||
fieldType: $fieldType,
|
fieldType: $fieldType,
|
||||||
options: $this->sanitizeOptions( $request->get_param( 'options' ) ),
|
options: $this->sanitizeOptions( $request->get_param( 'options' ) ),
|
||||||
isRequired: (bool) $request->get_param( 'is_required' ),
|
isRequired: $isRequired,
|
||||||
sortOrder: Val::int( $request->get_param( 'sort_order' ) ),
|
sortOrder: Val::int( $request->get_param( 'sort_order' ) ),
|
||||||
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
|
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
|
||||||
|
// An offering asks its questions once, about the student being booked,
|
||||||
|
// so there is no second audience to differ from: the single "required"
|
||||||
|
// stands for both, the same way the upgrade backfill left every
|
||||||
|
// question authored before the two could differ.
|
||||||
|
isRequiredChild: $isRequired,
|
||||||
);
|
);
|
||||||
|
|
||||||
$id = $this->questions->insert( $question );
|
$id = $this->questions->insert( $question );
|
||||||
@@ -129,16 +136,23 @@ class QuestionEndpoint {
|
|||||||
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
|
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only offering-scope questions reach here — an account-scope one has no
|
||||||
|
// offering to own it and is turned away as not found above — so the same
|
||||||
|
// single "required" applies to everyone asked. See create().
|
||||||
|
$isRequired = $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired;
|
||||||
|
|
||||||
$question = new Question(
|
$question = new Question(
|
||||||
offeringId: $existing->offeringId,
|
offeringId: $existing->offeringId,
|
||||||
label: $label,
|
label: $label,
|
||||||
fieldType: $fieldType,
|
fieldType: $fieldType,
|
||||||
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
|
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
|
||||||
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
|
isRequired: $isRequired,
|
||||||
sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder,
|
sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder,
|
||||||
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
|
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
|
||||||
scope: $existing->scope,
|
scope: $existing->scope,
|
||||||
id: $id,
|
audience: $existing->audience,
|
||||||
|
isRequiredChild: $isRequired,
|
||||||
|
id: $id,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->questions->update( $id, $question );
|
$this->questions->update( $id, $question );
|
||||||
|
|||||||
@@ -21,12 +21,18 @@ class QuestionField {
|
|||||||
* the HTML attribute, for a block the browser must not block submission on
|
* the HTML attribute, for a block the browser must not block submission on
|
||||||
* because it may not apply at all — the child blocks, which only count when
|
* because it may not apply at all — the child blocks, which only count when
|
||||||
* the parent/guardian box is ticked. The server validates those either way.
|
* the parent/guardian box is ticked. The server validates those either way.
|
||||||
|
*
|
||||||
|
* `$isRequired` overrides which of the question's two required flags applies
|
||||||
|
* here — a question can be optional for the account holder and required for
|
||||||
|
* each student they register, and only the caller knows which block this is.
|
||||||
|
* Null falls back to the question's own {@see Question::$isRequired}.
|
||||||
*/
|
*/
|
||||||
public static function render( Question $question, string $name, string $id, bool $enforceRequired = true ): string {
|
public static function render( Question $question, string $name, string $id, bool $enforceRequired = true, ?bool $isRequired = null ): string {
|
||||||
$required = $question->isRequired && $enforceRequired ? ' required' : '';
|
$mustAnswer = $isRequired ?? $question->isRequired;
|
||||||
|
$required = $mustAnswer && $enforceRequired ? ' required' : '';
|
||||||
|
|
||||||
$label = '<label for="' . esc_attr( $id ) . '">' . esc_html( $question->label )
|
$label = '<label for="' . esc_attr( $id ) . '">' . esc_html( $question->label )
|
||||||
. ( $question->isRequired ? ' <span class="us-required" aria-hidden="true">*</span>' : '' )
|
. ( $mustAnswer ? ' <span class="us-required" aria-hidden="true">*</span>' : '' )
|
||||||
. '</label>';
|
. '</label>';
|
||||||
|
|
||||||
return '<p>' . $label . self::input( $question, $name, $id, $required ) . '</p>';
|
return '<p>' . $label . self::input( $question, $name, $id, $required ) . '</p>';
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class QuestionRepository {
|
|||||||
$this->db->insert(
|
$this->db->insert(
|
||||||
$this->table,
|
$this->table,
|
||||||
$this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ],
|
$this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ],
|
||||||
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
[ '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s' ]
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->db->insert_id;
|
return $this->db->insert_id;
|
||||||
@@ -26,7 +26,7 @@ class QuestionRepository {
|
|||||||
$this->table,
|
$this->table,
|
||||||
$this->columns( $question ),
|
$this->columns( $question ),
|
||||||
[ 'id' => $id ],
|
[ 'id' => $id ],
|
||||||
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d' ],
|
[ '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d' ],
|
||||||
[ '%d' ]
|
[ '%d' ]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,14 +38,16 @@ class QuestionRepository {
|
|||||||
*/
|
*/
|
||||||
private function columns( Question $question ): array {
|
private function columns( Question $question ): array {
|
||||||
return [
|
return [
|
||||||
'offering_id' => $question->offeringId,
|
'offering_id' => $question->offeringId,
|
||||||
'scope' => $question->scope,
|
'scope' => $question->scope,
|
||||||
'label' => $question->label,
|
'label' => $question->label,
|
||||||
'field_type' => $question->fieldType,
|
'field_type' => $question->fieldType,
|
||||||
'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ),
|
'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ),
|
||||||
'is_required' => $question->isRequired ? 1 : 0,
|
'audience' => $question->audience,
|
||||||
'sort_order' => $question->sortOrder,
|
'is_required' => $question->isRequired ? 1 : 0,
|
||||||
'is_active' => $question->isActive ? 1 : 0,
|
'is_required_child' => $question->isRequiredChild ? 1 : 0,
|
||||||
|
'sort_order' => $question->sortOrder,
|
||||||
|
'is_active' => $question->isActive ? 1 : 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,4 +130,30 @@ class QuestionRepository {
|
|||||||
|
|
||||||
return null !== $sql && false !== $this->db->query( $sql );
|
return null !== $sql && false !== $this->db->query( $sql );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give every question authored before students had a required-ness of their
|
||||||
|
* own the one it used to have.
|
||||||
|
*
|
||||||
|
* `is_required_child` arrives with `DEFAULT 0`, so without this a question the
|
||||||
|
* studio had marked required would quietly stop being required of the students
|
||||||
|
* a guardian registers — the case it most likely existed for. Copying
|
||||||
|
* `is_required` across preserves exactly the old behaviour: required of
|
||||||
|
* everyone, or of nobody.
|
||||||
|
*
|
||||||
|
* Run once, guarded by an option in {@see \Unsupervised\Schedular\Plugin::boot()},
|
||||||
|
* so a question deliberately made optional for students afterwards is not
|
||||||
|
* quietly made required again.
|
||||||
|
*
|
||||||
|
* @return bool True when the statement ran, false if it could not be prepared
|
||||||
|
* or the query failed.
|
||||||
|
*/
|
||||||
|
public function backfillChildRequired(): bool {
|
||||||
|
$sql = $this->db->prepare(
|
||||||
|
'UPDATE %i SET is_required_child = 1 WHERE is_required = 1',
|
||||||
|
$this->table
|
||||||
|
);
|
||||||
|
|
||||||
|
return null !== $sql && false !== $this->db->query( $sql );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 );
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ class Schema {
|
|||||||
label VARCHAR(255) NOT NULL,
|
label VARCHAR(255) NOT NULL,
|
||||||
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
|
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
|
||||||
options TEXT,
|
options TEXT,
|
||||||
|
audience VARCHAR(20) NOT NULL DEFAULT 'all',
|
||||||
is_required TINYINT(1) NOT NULL DEFAULT 0,
|
is_required TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
is_required_child TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Unsupervised\Schedular;
|
namespace Unsupervised\Schedular;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Auth\AccountPage;
|
||||||
use Unsupervised\Schedular\Auth\LoginPage;
|
use Unsupervised\Schedular\Auth\LoginPage;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
@@ -18,6 +19,7 @@ class ShortcodeRegistrar {
|
|||||||
private RegistrationPage $registrationPage,
|
private RegistrationPage $registrationPage,
|
||||||
private GroupClassPage $groupClassPage,
|
private GroupClassPage $groupClassPage,
|
||||||
private FamilyPage $familyPage,
|
private FamilyPage $familyPage,
|
||||||
|
private AccountPage $accountPage,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function register(): void {
|
public function register(): void {
|
||||||
@@ -26,6 +28,7 @@ class ShortcodeRegistrar {
|
|||||||
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
|
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
|
||||||
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
|
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
|
||||||
add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) );
|
add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) );
|
||||||
|
add_shortcode( 'us_account', self::shortcode( [ $this->accountPage, 'render' ] ) );
|
||||||
// Process registration submissions before output so the invite branch's
|
// Process registration submissions before output so the invite branch's
|
||||||
// auth cookie is actually sent (render() runs too late, during the_content).
|
// auth cookie is actually sent (render() runs too late, during the_content).
|
||||||
add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] );
|
add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] );
|
||||||
@@ -88,7 +91,21 @@ class ShortcodeRegistrar {
|
|||||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
|
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.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 );
|
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 (no dependencies).
|
/*
|
||||||
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
|
* Progressive enhancement for the registration form.
|
||||||
|
*
|
||||||
|
* `password-strength-meter` is WordPress's own wrapper around zxcvbn, so
|
||||||
|
* the signup form scores a password exactly the way wp-admin does rather
|
||||||
|
* than inventing a second opinion. It pulls in `zxcvbn-async`, which
|
||||||
|
* fetches the (large) dictionary only once the page has loaded — hence
|
||||||
|
* the guard in register.js for the window where it is not there yet.
|
||||||
|
*/
|
||||||
|
wp_register_script(
|
||||||
|
'us-scheduler-register',
|
||||||
|
USC_PLUGIN_URL . 'assets/js/register.js',
|
||||||
|
[ 'password-strength-meter' ],
|
||||||
|
USC_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'); ?>
|
||||||
|
|||||||
@@ -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 questions when they register — the account holder on the signup form itself, and once per student they are registering on behalf of. Each question says who it is asked of, and can be required of the account holder, of the students, or of both.', '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; ?>
|
||||||
@@ -76,10 +76,31 @@ if (! defined('ABSPATH')) {
|
|||||||
<th><label for="sort_order"><?php esc_html_e('Sort order', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="sort_order"><?php esc_html_e('Sort order', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="number" name="sort_order" id="sort_order" min="0" step="1" value="0"></td>
|
<td><input type="number" name="sort_order" id="sort_order" min="0" step="1" value="0"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<?php if ($accountScope) : ?>
|
||||||
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
|
<tr>
|
||||||
<td><label><input type="checkbox" name="is_required" value="1"> <?php esc_html_e('Registrant must answer', 'unsupervised-schedular'); ?></label></td>
|
<th><label for="audience"><?php esc_html_e('Asked of', 'unsupervised-schedular'); ?></label></th>
|
||||||
</tr>
|
<td>
|
||||||
|
<select name="audience" id="audience">
|
||||||
|
<option value="<?php echo esc_attr(Question::AUDIENCE_ALL); ?>"><?php esc_html_e('Everyone registering', 'unsupervised-schedular'); ?></option>
|
||||||
|
<option value="<?php echo esc_attr(Question::AUDIENCE_CHILD); ?>"><?php esc_html_e('Students only — not the account holder', 'unsupervised-schedular'); ?></option>
|
||||||
|
</select>
|
||||||
|
<p class="description"><?php esc_html_e('"Students only" leaves the question off the account holder\'s own section, for anything that only makes sense about a student someone is registering on behalf of.', 'unsupervised-schedular'); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
|
||||||
|
<td>
|
||||||
|
<label><input type="checkbox" name="is_required" value="1"> <?php esc_html_e('The account holder must answer for themselves', 'unsupervised-schedular'); ?></label><br>
|
||||||
|
<label><input type="checkbox" name="is_required_child" value="1"> <?php esc_html_e('Each student they register must answer', 'unsupervised-schedular'); ?></label>
|
||||||
|
<p class="description"><?php esc_html_e('Tick either, both, or neither — a question can be optional for an adult signing themselves up and still required for every student they enrol.', 'unsupervised-schedular'); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else : ?>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
|
||||||
|
<td><label><input type="checkbox" name="is_required" value="1"> <?php esc_html_e('Registrant must answer', 'unsupervised-schedular'); ?></label></td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
</table>
|
</table>
|
||||||
<?php submit_button(esc_html__('Add Question', 'unsupervised-schedular')); ?>
|
<?php submit_button(esc_html__('Add Question', 'unsupervised-schedular')); ?>
|
||||||
</form>
|
</form>
|
||||||
@@ -94,7 +115,12 @@ if (! defined('ABSPATH')) {
|
|||||||
<th><?php esc_html_e('Order', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Order', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Type', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Type', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
|
<?php if ($accountScope) : ?>
|
||||||
|
<th><?php esc_html_e('Asked of', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Required of', 'unsupervised-schedular'); ?></th>
|
||||||
|
<?php else : ?>
|
||||||
|
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
|
||||||
|
<?php endif; ?>
|
||||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -104,7 +130,32 @@ if (! defined('ABSPATH')) {
|
|||||||
<td><?php echo esc_html((string) $question->sortOrder); ?></td>
|
<td><?php echo esc_html((string) $question->sortOrder); ?></td>
|
||||||
<td><?php echo esc_html($question->label); ?></td>
|
<td><?php echo esc_html($question->label); ?></td>
|
||||||
<td><?php echo esc_html($question->fieldType); ?></td>
|
<td><?php echo esc_html($question->fieldType); ?></td>
|
||||||
<td><?php echo $question->isRequired ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
|
<?php if ($accountScope) : ?>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
echo $question->askedOfSelf()
|
||||||
|
? esc_html__('Everyone', 'unsupervised-schedular')
|
||||||
|
: esc_html__('Students only', 'unsupervised-schedular');
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
// Named rather than two ticks, so "required of the
|
||||||
|
// students but not of you" reads as the deliberate
|
||||||
|
// setting it is rather than as a half-filled row.
|
||||||
|
$requiredOf = [];
|
||||||
|
if ($question->isRequiredForSelf()) {
|
||||||
|
$requiredOf[] = __('account holder', 'unsupervised-schedular');
|
||||||
|
}
|
||||||
|
if ($question->isRequiredForChild()) {
|
||||||
|
$requiredOf[] = __('students', 'unsupervised-schedular');
|
||||||
|
}
|
||||||
|
echo esc_html([] === $requiredOf ? __('—', 'unsupervised-schedular') : implode(', ', $requiredOf));
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<?php else : ?>
|
||||||
|
<td><?php echo $question->isRequired ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
|
||||||
|
<?php endif; ?>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" style="display:inline;">
|
<form method="post" style="display:inline;">
|
||||||
<?php wp_nonce_field('usc_question_action'); ?>
|
<?php wp_nonce_field('usc_question_action'); ?>
|
||||||
|
|||||||
@@ -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, schedule: string|null, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming Booked lessons and upcoming group-class sessions, soonest first. `schedule` is wording to show instead of a date, for a class with no class time set.
|
||||||
* @var list<array{id: int, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
|
* @var list<array{id: int, kind: string, schedule: string|null, start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $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
|
||||||
@@ -18,7 +20,7 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var float $creditBalance Balance of the account that settles this student's charges — the guardian's for a child.
|
* @var float $creditBalance Balance of the account that settles this student's charges — the guardian's for a child.
|
||||||
* @var string $creditCurrency
|
* @var string $creditCurrency
|
||||||
* @var array{id: int, name: string, email: string}|null $guardian The parent/guardian who books for this student, or null when they book for themselves.
|
* @var array{id: int, name: string, email: string}|null $guardian The parent/guardian who books for this student, or null when they book for themselves.
|
||||||
* @var list<array{id: int, name: string, date_of_birth: string, relationship: string}> $children Children this student books for.
|
* @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children Children this student books for.
|
||||||
* @var array{id: int, name: string, email: string} $payer Who is billed for this student — themselves, or their guardian.
|
* @var array{id: int, name: string, email: string} $payer Who is billed for this student — themselves, or their guardian.
|
||||||
* @var string $pageSlug
|
* @var string $pageSlug
|
||||||
* @var string $backUrl
|
* @var string $backUrl
|
||||||
@@ -49,14 +51,38 @@ $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>
|
||||||
<td><?php echo esc_html($row['offering']); ?></td>
|
<?php
|
||||||
|
// A class with no class time set has no clock to put it on,
|
||||||
|
// so it carries wording ("Tuesdays 4:00pm", or its term
|
||||||
|
// dates) that stands in for the date entirely.
|
||||||
|
if (($row['schedule'] ?? null) !== null && $row['schedule'] !== '') {
|
||||||
|
echo esc_html((string) $row['schedule']);
|
||||||
|
} else {
|
||||||
|
echo esc_html($row['start_dt'] !== '' ? (string) mysql2date('M j, Y g:i A', $row['start_dt']) : '—');
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php echo esc_html($row['offering']); ?>
|
||||||
|
<?php if ($isGroupSession) : ?>
|
||||||
|
<span class="description">— <?php esc_html_e('group class', 'unsupervised-schedular'); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
<td><?php echo esc_html($row['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">
|
||||||
@@ -88,6 +114,8 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
|||||||
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
|
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Account', 'unsupervised-schedular'); ?></h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<?php wp_nonce_field('usc_student_actions'); ?>
|
<?php wp_nonce_field('usc_student_actions'); ?>
|
||||||
@@ -105,25 +133,33 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
|||||||
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
|
||||||
<td><?php echo esc_html($student->user_registered); ?></td>
|
<td><?php echo esc_html($student->user_registered); ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Always rendered, both ways round. "Books for themselves" is a fact
|
||||||
|
* worth stating outright: an empty row would be indistinguishable
|
||||||
|
* from a guardian the page failed to look up.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('Booked by', 'unsupervised-schedular'); ?></th>
|
||||||
|
<td>
|
||||||
|
<?php if ($guardian !== null) : ?>
|
||||||
|
<?php // The name alone. Their address is one click away on their own page, and repeating it here only makes the row harder to scan. ?>
|
||||||
|
<a href="<?php echo esc_url($detailUrl($guardian['id'])); ?>"><?php echo esc_html($guardian['name']); ?></a>
|
||||||
|
<?php else : ?>
|
||||||
|
<span class="description"><?php esc_html_e('Nobody — this student books and pays for themselves.', 'unsupervised-schedular'); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
|
<?php submit_button(esc_html__('Save account details', 'unsupervised-schedular'), 'secondary', 'submit', false); ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php if ($guardian !== null || ! empty($children)) : ?>
|
<?php if ($guardian !== null || ! empty($children)) : ?>
|
||||||
<h2><?php esc_html_e('Family', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Profile', 'unsupervised-schedular'); ?></h2>
|
||||||
<?php $detailUrl = static fn(int $id): string => add_query_arg(['page' => $pageSlug, 'student_id' => $id], admin_url('admin.php')); ?>
|
|
||||||
<?php if ($guardian !== null) : ?>
|
<?php if ($guardian !== null) : ?>
|
||||||
<p>
|
<?php // Who they book through is up in Account; this explains the placeholder email. ?>
|
||||||
<?php
|
<p class="description"><?php esc_html_e('This is a managed student account: it has no login of its own, and its email address is a placeholder that cannot receive mail.', 'unsupervised-schedular'); ?></p>
|
||||||
printf(
|
|
||||||
/* translators: 1: linked name of the parent/guardian, 2: their email address. */
|
|
||||||
esc_html__('Books and pays through %1$s (%2$s).', 'unsupervised-schedular'),
|
|
||||||
'<a href="' . esc_url($detailUrl($guardian['id'])) . '">' . esc_html($guardian['name']) . '</a>',
|
|
||||||
esc_html($guardian['email'])
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
</p>
|
|
||||||
<p class="description"><?php esc_html_e('This is a child account: it has no login of its own, and its email address is a placeholder that cannot receive mail.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (! empty($children)) : ?>
|
<?php if (! empty($children)) : ?>
|
||||||
<p><?php esc_html_e('Books and pays for:', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Books and pays for:', 'unsupervised-schedular'); ?></p>
|
||||||
@@ -131,8 +167,8 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
|||||||
<?php foreach ($children as $child) : ?>
|
<?php foreach ($children as $child) : ?>
|
||||||
<li>
|
<li>
|
||||||
<a href="<?php echo esc_url($detailUrl($child['id'])); ?>"><?php echo esc_html($child['name']); ?></a>
|
<a href="<?php echo esc_url($detailUrl($child['id'])); ?>"><?php echo esc_html($child['name']); ?></a>
|
||||||
<?php if ($child['date_of_birth'] !== '') : ?>
|
<?php if ($child['birth_year'] !== '') : ?>
|
||||||
<span class="description"><?php echo esc_html($child['date_of_birth']); ?></span>
|
<span class="description"><?php echo esc_html($child['birth_year']); ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -293,7 +329,7 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
|
|||||||
<?php
|
<?php
|
||||||
printf(
|
printf(
|
||||||
/* translators: %s: name of the parent/guardian whose account holds the balance. */
|
/* translators: %s: name of the parent/guardian whose account holds the balance. */
|
||||||
esc_html__('Held on %s’s account — the family shares one balance.', 'unsupervised-schedular'),
|
esc_html__('Held on %s’s account — the profile shares one balance.', 'unsupervised-schedular'),
|
||||||
esc_html($payer['name'])
|
esc_html($payer['name'])
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ if (! defined('ABSPATH')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var list<array{id: int, name: string, email: string, registered: string, upcoming: int, enrolments: int, guardian: array{id: int, name: string, email: string}|null, children: list<array{id: int, name: string, date_of_birth: string, relationship: string}>}> $students
|
* @var list<array{id: int, name: string, email: string, registered: string, upcoming: int, enrolments: int, guardian: array{id: int, name: string, email: string}|null, children: list<array{id: int, name: string, birth_year: string, relationship: string}>}> $students
|
||||||
* @var string $pageSlug
|
* @var string $pageSlug
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ $familyCell = static function (array $student) use ($pageSlug): string {
|
|||||||
if ($student['guardian'] !== null) {
|
if ($student['guardian'] !== null) {
|
||||||
return sprintf(
|
return sprintf(
|
||||||
/* translators: %s: linked name of the parent/guardian who books for this student. */
|
/* translators: %s: linked name of the parent/guardian who books for this student. */
|
||||||
esc_html__('Child of %s', 'unsupervised-schedular'),
|
esc_html__('Managed by %s', 'unsupervised-schedular'),
|
||||||
$link($student['guardian']['id'], $student['guardian']['name'])
|
$link($student['guardian']['id'], $student['guardian']['name'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ $familyCell = static function (array $student) use ($pageSlug): string {
|
|||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Name', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Family', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Profile', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Registered', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Upcoming lessons', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Upcoming lessons', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Active enrolments', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Active enrolments', 'unsupervised-schedular'); ?></th>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (! defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $name Display name of the signed-in visitor.
|
||||||
|
* @var string $email Their account email.
|
||||||
|
* @var string $logoutUrl Nonced sign-out URL, already carrying its redirect.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<div class="us-account">
|
||||||
|
<p class="us-account-who">
|
||||||
|
<span class="us-account-name"><?php echo esc_html($name); ?></span>
|
||||||
|
<?php if ($email !== '') : ?>
|
||||||
|
<span class="us-account-email"><?php echo esc_html($email); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="us-account-actions">
|
||||||
|
<a class="us-account-signout" href="<?php echo esc_url($logoutUrl); ?>"><?php esc_html_e('Sign out', 'unsupervised-schedular'); ?></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -21,12 +21,17 @@ $studentsJson = wp_json_encode(array_values($students));
|
|||||||
<div id="us-my-lessons"></div>
|
<div id="us-my-lessons"></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($showBooking) : ?>
|
<?php if ($showBooking) : ?>
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Above the calendar, because it reports on what the student just did and
|
||||||
|
* the calendar below it is what they do next. Filled and shown by
|
||||||
|
* booking.js; empty and hidden until then.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<div id="us-booking-confirmation" class="us-notice" role="status" aria-live="polite" hidden></div>
|
||||||
<div id="us-slot-list">
|
<div id="us-slot-list">
|
||||||
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="us-booking-confirmation" style="display:none;">
|
|
||||||
<p><?php esc_html_e('Your lesson has been booked. The instructor will confirm shortly.', 'unsupervised-schedular'); ?></p>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div id="us-booking-error" style="display:none;" role="alert"></div>
|
<div id="us-booking-error" style="display:none;" role="alert"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ if (! defined('ABSPATH')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var list<array{id: int, name: string, date_of_birth: string, relationship: string}> $children
|
* @var array{name: string, email: string, birth_year: string, is_student: bool} $self The account holder's own details.
|
||||||
|
* @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children
|
||||||
* @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child.
|
* @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child.
|
||||||
* @var string $error Validation error from the last submission, if any.
|
* @var string $error Validation error from the last submission, if any.
|
||||||
* @var string $notice Confirmation of a completed add/edit/remove, if any.
|
* @var string $notice Confirmation of a completed add/edit/remove, if any.
|
||||||
@@ -16,7 +17,7 @@ if (! defined('ABSPATH')) {
|
|||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<div class="us-family">
|
<div class="us-family">
|
||||||
<h3><?php esc_html_e('Your family', 'unsupervised-schedular'); ?></h3>
|
<h3><?php esc_html_e('Your profile', 'unsupervised-schedular'); ?></h3>
|
||||||
|
|
||||||
<?php if ($notice !== '') : ?>
|
<?php if ($notice !== '') : ?>
|
||||||
<p class="us-success"><?php echo esc_html($notice); ?></p>
|
<p class="us-success"><?php echo esc_html($notice); ?></p>
|
||||||
@@ -26,8 +27,54 @@ 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; ?>
|
||||||
|
|
||||||
|
<form method="post" action="" class="us-family-self">
|
||||||
|
<?php wp_nonce_field('us_family'); ?>
|
||||||
|
<input type="hidden" name="us_family_action" value="self">
|
||||||
|
|
||||||
|
<h4><?php esc_html_e('Your details', 'unsupervised-schedular'); ?></h4>
|
||||||
|
|
||||||
|
<?php if ($self['email'] !== '') : ?>
|
||||||
|
<?php /* Shown, not editable: the address is the account's login, and changing it is a studio-side job. */ ?>
|
||||||
|
<p class="us-family-self-email">
|
||||||
|
<?php esc_html_e('Email', 'unsupervised-schedular'); ?>
|
||||||
|
<span><?php echo esc_html($self['email']); ?></span>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<label for="us-own-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
|
||||||
|
<input type="text" name="own_name" id="us-own-name" autocomplete="name" required value="<?php echo esc_attr($self['name']); ?>">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_student" value="1"<?php checked($self['is_student']); ?>>
|
||||||
|
<?php esc_html_e('I take lessons myself', 'unsupervised-schedular'); ?>
|
||||||
|
</label>
|
||||||
|
<span class="us-field-hint"><?php esc_html_e('Leave this unticked if you only book for the students below — you will not be offered as a student yourself.', 'unsupervised-schedular'); ?></span>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Asked of a student only, so `required` is deliberately absent: the box
|
||||||
|
* above is what decides, and the browser cannot be told to enforce a
|
||||||
|
* field conditionally without JavaScript this page does not load.
|
||||||
|
* `handleSelf()` enforces it on the server either way.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<p>
|
||||||
|
<label for="us-own-birth-year"><?php esc_html_e('Your birth year', 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="number" name="own_birth_year" id="us-own-birth-year" value="<?php echo esc_attr($self['birth_year']); ?>" 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'); ?>" aria-describedby="us-own-birth-year-hint">
|
||||||
|
<span class="us-field-hint" id="us-own-birth-year-hint"><?php esc_html_e('Needed only if you take lessons yourself.', 'unsupervised-schedular'); ?></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<button type="submit"><?php esc_html_e('Save my details', 'unsupervised-schedular'); ?></button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4><?php esc_html_e('Your students', 'unsupervised-schedular'); ?></h4>
|
||||||
|
|
||||||
<?php if (empty($children)) : ?>
|
<?php if (empty($children)) : ?>
|
||||||
<p><?php esc_html_e('You have not added any children yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('You have not added any students yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<ul class="us-family-list">
|
<ul class="us-family-list">
|
||||||
<?php foreach ($children as $child) : ?>
|
<?php foreach ($children as $child) : ?>
|
||||||
@@ -38,12 +85,12 @@ if (! defined('ABSPATH')) {
|
|||||||
<input type="hidden" name="us_family_action" value="edit">
|
<input type="hidden" name="us_family_action" value="edit">
|
||||||
<input type="hidden" name="child_id" value="<?php echo esc_attr((string) $child['id']); ?>">
|
<input type="hidden" name="child_id" value="<?php echo esc_attr((string) $child['id']); ?>">
|
||||||
<p>
|
<p>
|
||||||
<label for="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Name', 'unsupervised-schedular'); ?></label>
|
<label for="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
|
||||||
<input type="text" name="child_name" id="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['name']); ?>" required>
|
<input type="text" name="child_name" id="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['name']); ?>" required>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label for="us-edit-dob-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label>
|
<label for="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
|
||||||
<input type="date" name="child_dob" id="us-edit-dob-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['date_of_birth']); ?>">
|
<input type="number" name="child_birth_year" required id="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['birth_year']); ?>" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<button type="submit"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button>
|
<button type="submit"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button>
|
||||||
@@ -52,8 +99,8 @@ if (! defined('ABSPATH')) {
|
|||||||
</form>
|
</form>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<span class="us-family-child-name"><?php echo esc_html($child['name']); ?></span>
|
<span class="us-family-child-name"><?php echo esc_html($child['name']); ?></span>
|
||||||
<?php if ($child['date_of_birth'] !== '') : ?>
|
<?php if ($child['birth_year'] !== '') : ?>
|
||||||
<span class="us-family-child-dob"><?php echo esc_html($child['date_of_birth']); ?></span>
|
<span class="us-family-child-birth-year"><?php echo esc_html($child['birth_year']); ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<span class="us-family-child-actions">
|
<span class="us-family-child-actions">
|
||||||
<a href="<?php echo esc_url(add_query_arg('us_edit_child', $child['id'], (string) get_permalink())); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a>
|
<a href="<?php echo esc_url(add_query_arg('us_edit_child', $child['id'], (string) get_permalink())); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a>
|
||||||
@@ -74,14 +121,14 @@ if (! defined('ABSPATH')) {
|
|||||||
<?php wp_nonce_field('us_family'); ?>
|
<?php wp_nonce_field('us_family'); ?>
|
||||||
<input type="hidden" name="us_family_action" value="add">
|
<input type="hidden" name="us_family_action" value="add">
|
||||||
|
|
||||||
<h4><?php esc_html_e('Add a child', 'unsupervised-schedular'); ?></h4>
|
<h4><?php esc_html_e('Add a student', 'unsupervised-schedular'); ?></h4>
|
||||||
<p>
|
<p>
|
||||||
<label for="us-child-name"><?php esc_html_e('Name', 'unsupervised-schedular'); ?></label>
|
<label for="us-child-name"><?php esc_html_e('Name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
|
||||||
<input type="text" name="child_name" id="us-child-name" required>
|
<input type="text" name="child_name" id="us-child-name" required>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label for="us-child-dob"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label>
|
<label for="us-child-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
|
||||||
<input type="date" name="child_dob" id="us-child-dob">
|
<input type="number" name="child_birth_year" id="us-child-birth-year" required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label for="us-child-relationship"><?php esc_html_e('Your relationship to them', 'unsupervised-schedular'); ?></label>
|
<label for="us-child-relationship"><?php esc_html_e('Your relationship to them', 'unsupervised-schedular'); ?></label>
|
||||||
@@ -90,18 +137,23 @@ if (! defined('ABSPATH')) {
|
|||||||
|
|
||||||
<?php if (! empty($questions)) : ?>
|
<?php if (! empty($questions)) : ?>
|
||||||
<fieldset class="us-reg-questions">
|
<fieldset class="us-reg-questions">
|
||||||
<legend><?php esc_html_e('About this child', 'unsupervised-schedular'); ?></legend>
|
<legend><?php esc_html_e('About this student', 'unsupervised-schedular'); ?></legend>
|
||||||
<?php foreach ($questions as $question) : ?>
|
<?php foreach ($questions as $question) : ?>
|
||||||
<?php
|
<?php
|
||||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
|
||||||
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-family-q-' . (int) $question->id);
|
echo QuestionField::render(
|
||||||
|
$question,
|
||||||
|
'us_answers[' . (int) $question->id . ']',
|
||||||
|
'us-family-q-' . (int) $question->id,
|
||||||
|
isRequired: $question->isRequiredForChild()
|
||||||
|
);
|
||||||
?>
|
?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<button type="submit"><?php esc_html_e('Add child', 'unsupervised-schedular'); ?></button>
|
<button type="submit"><?php esc_html_e('Add student', 'unsupervised-schedular'); ?></button>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,11 +13,10 @@ if (! defined('ABSPATH')) {
|
|||||||
$studentsJson = wp_json_encode(array_values($students));
|
$studentsJson = wp_json_encode(array_values($students));
|
||||||
?>
|
?>
|
||||||
<div id="us-group-app" data-students="<?php echo esc_attr(is_string($studentsJson) ? $studentsJson : '[]'); ?>"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
|
<div id="us-group-app" data-students="<?php echo esc_attr(is_string($studentsJson) ? $studentsJson : '[]'); ?>"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
|
||||||
|
<?php /* Above the list, for the same reason as the booking page. */ ?>
|
||||||
|
<div id="us-group-confirmation" class="us-notice" role="status" aria-live="polite" hidden></div>
|
||||||
<div id="us-group-list">
|
<div id="us-group-list">
|
||||||
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="us-group-confirmation" style="display:none;">
|
|
||||||
<p><?php esc_html_e('You are enrolled. The studio will be in touch.', 'unsupervised-schedular'); ?></p>
|
|
||||||
</div>
|
|
||||||
<div id="us-group-error" style="display:none;" role="alert"></div>
|
<div id="us-group-error" style="display:none;" role="alert"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Auth\PasswordPolicy;
|
||||||
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\Registration\Question;
|
use Unsupervised\Schedular\Registration\Question;
|
||||||
use Unsupervised\Schedular\Registration\QuestionField;
|
use Unsupervised\Schedular\Registration\QuestionField;
|
||||||
|
|
||||||
@@ -20,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 being registered — the account holder included when they are one, unless the question is for students only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
?>
|
?>
|
||||||
@@ -47,129 +49,153 @@ 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()) : ?>
|
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
|
||||||
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
|
<?php else : ?>
|
||||||
<?php else : ?>
|
<input type="email" name="email" id="us-reg-email" autocomplete="email" required>
|
||||||
<input type="email" name="email" id="us-reg-email" autocomplete="email" required>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
</p>
|
||||||
</p>
|
<p>
|
||||||
<p>
|
<label for="us-reg-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?></label>
|
||||||
<label for="us-reg-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?></label>
|
<input type="text" name="display_name" id="us-reg-name" autocomplete="name" required>
|
||||||
<input type="text" name="display_name" id="us-reg-name" autocomplete="name" required>
|
</p>
|
||||||
</p>
|
<p>
|
||||||
<p>
|
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
|
||||||
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
|
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="<?php echo esc_attr((string) PasswordPolicy::MIN_LENGTH); ?>" required aria-describedby="us-reg-pass-strength">
|
||||||
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="8" required>
|
<?php
|
||||||
</p>
|
/*
|
||||||
|
* Filled in by register.js. `aria-live` announces the verdict as
|
||||||
|
* it changes, and it starts empty so nothing is announced — or
|
||||||
|
* takes up space — before anything has been typed.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<span class="us-password-strength" id="us-reg-pass-strength" role="status" aria-live="polite"></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
<fieldset class="us-guardian">
|
<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
|
||||||
|
/*
|
||||||
|
* Radios, not checkboxes: the three answers are mutually
|
||||||
|
* exclusive, and "both" only means anything as a third
|
||||||
|
* choice alongside the other two. "Just myself" is
|
||||||
|
* pre-selected because it is the commonest signup and the
|
||||||
|
* one that collects the least.
|
||||||
|
*/
|
||||||
|
$registeringForChoices = [
|
||||||
|
RegistrationPage::FOR_SELF => __('Just myself', 'unsupervised-schedular'),
|
||||||
|
RegistrationPage::FOR_STUDENTS => __('On behalf of one or more students', 'unsupervised-schedular'),
|
||||||
|
RegistrationPage::FOR_BOTH => __('Both — myself and one or more students', 'unsupervised-schedular'),
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<?php foreach ($registeringForChoices as $value => $label) : ?>
|
||||||
<p>
|
<p>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="us_is_guardian" id="us-is-guardian" value="1">
|
<input type="radio" name="us_registering_for" value="<?php echo esc_attr($value); ?>" class="us-registering-for"<?php checked($value, RegistrationPage::FOR_SELF); ?>>
|
||||||
<?php esc_html_e("I'm registering as a parent or guardian, for one or more children", 'unsupervised-schedular'); ?>
|
<?php echo esc_html($label); ?>
|
||||||
</label>
|
</label>
|
||||||
</p>
|
</p>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<?php /* Revealed by the checkbox; without JS it is simply always visible. */ ?>
|
<?php
|
||||||
<div class="us-children" id="us-children">
|
/*
|
||||||
<p class="us-children-intro"><?php esc_html_e('Add each child 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>
|
* 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
|
||||||
|
// A "students only" question describes a child being registered,
|
||||||
|
// so it is never put to the account holder about themselves.
|
||||||
|
if (! $question->askedOfSelf()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<?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,
|
||||||
|
isRequired: $question->isRequiredForSelf()
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<?php /* The first block is the template the "Add another child" button clones. */ ?>
|
<?php /* Revealed by the two student-bearing choices; without JS it is simply always visible. */ ?>
|
||||||
<div class="us-child" data-child-index="0">
|
<fieldset class="us-reg-group us-children" id="us-children">
|
||||||
<p>
|
<legend><?php esc_html_e('Students', 'unsupervised-schedular'); ?></legend>
|
||||||
<label for="us-child-0-name"><?php esc_html_e("Child's name", 'unsupervised-schedular'); ?></label>
|
<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>
|
||||||
<input type="text" name="children[0][name]" id="us-child-0-name">
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<label for="us-child-0-dob"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label>
|
|
||||||
<input type="date" name="children[0][dob]" id="us-child-0-dob">
|
|
||||||
</p>
|
|
||||||
<?php foreach ($accountQuestions as $question) : ?>
|
|
||||||
<?php
|
|
||||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
|
|
||||||
echo QuestionField::render(
|
|
||||||
$question,
|
|
||||||
'children[0][answers][' . (int) $question->id . ']',
|
|
||||||
'us-child-0-q-' . (int) $question->id,
|
|
||||||
enforceRequired: false
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>
|
<?php /* The first block is the template the "Add another student" button clones. */ ?>
|
||||||
<button type="button" class="us-add-child"><?php esc_html_e('Add another child', 'unsupervised-schedular'); ?></button>
|
<div class="us-child" data-child-index="0">
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<?php if (! empty($policyForms)) : ?>
|
|
||||||
<fieldset class="us-policies">
|
|
||||||
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
|
|
||||||
<?php foreach ($policyForms as $form) : ?>
|
|
||||||
<div class="us-policy">
|
|
||||||
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
|
||||||
<div class="us-policy-body"><?php echo wp_kses_post($form['version']->bodyHtml()); ?></div>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
|
||||||
<?php
|
|
||||||
/* translators: %s: policy title */
|
|
||||||
echo esc_html(sprintf(__('I have read and agree to the %s.', 'unsupervised-schedular'), $form['policy']->title));
|
|
||||||
?>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</fieldset>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($hasQuestions) : ?>
|
|
||||||
<p>
|
<p>
|
||||||
<button type="button" class="us-reg-next"><?php esc_html_e('Next', 'unsupervised-schedular'); ?></button>
|
<label for="us-child-0-name"><?php esc_html_e("Student's name", 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
|
||||||
|
<input type="text" name="children[0][name]" id="us-child-0-name" aria-required="true" data-us-child-required>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="us-child-0-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
|
||||||
|
<input type="number" name="children[0][birth_year]" id="us-child-0-birth-year" aria-required="true" data-us-child-required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
|
||||||
|
</p>
|
||||||
|
<?php foreach ($accountQuestions as $question) : ?>
|
||||||
<?php
|
<?php
|
||||||
/*
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
|
||||||
* In parent/guardian mode the questions are asked per child,
|
echo QuestionField::render(
|
||||||
* up in the children section, so step two has nothing left to
|
$question,
|
||||||
* ask and "Next" leads nowhere. This submit takes its place —
|
'children[0][answers][' . (int) $question->id . ']',
|
||||||
* hidden until the guardian box is ticked (and never shown at
|
'us-child-0-q-' . (int) $question->id,
|
||||||
* all without JS, where both steps are visible anyway and the
|
enforceRequired: false,
|
||||||
* step-two submit does the job).
|
isRequired: $question->isRequiredForChild()
|
||||||
*/
|
);
|
||||||
?>
|
?>
|
||||||
<input type="submit" name="us_register" class="us-reg-submit-early" hidden value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
|
<?php endforeach; ?>
|
||||||
</p>
|
|
||||||
<?php else : ?>
|
|
||||||
<p>
|
|
||||||
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if ($hasQuestions) : ?>
|
|
||||||
<div class="us-reg-step" data-step="2">
|
|
||||||
<fieldset class="us-reg-questions">
|
|
||||||
<legend><?php esc_html_e('Registration information', 'unsupervised-schedular'); ?></legend>
|
|
||||||
<?php foreach ($accountQuestions as $question) : ?>
|
|
||||||
<?php
|
|
||||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
|
|
||||||
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-reg-q-' . (int) $question->id);
|
|
||||||
?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</fieldset>
|
|
||||||
<p>
|
|
||||||
<button type="button" class="us-reg-back"><?php esc_html_e('Back', 'unsupervised-schedular'); ?></button>
|
|
||||||
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<button type="button" class="us-add-child"><?php esc_html_e('Add another student', 'unsupervised-schedular'); ?></button>
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<?php if (! empty($policyForms)) : ?>
|
||||||
|
<fieldset class="us-policies">
|
||||||
|
<legend><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></legend>
|
||||||
|
<?php foreach ($policyForms as $form) : ?>
|
||||||
|
<div class="us-policy">
|
||||||
|
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
||||||
|
<div class="us-policy-body"><?php echo wp_kses_post($form['version']->bodyHtml()); ?></div>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
||||||
|
<?php
|
||||||
|
/* translators: %s: policy title */
|
||||||
|
echo esc_html(sprintf(__('I have read and agree to the %s.', 'unsupervised-schedular'), $form['policy']->title));
|
||||||
|
?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</fieldset>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<input type="submit" name="us_register" value="<?php esc_attr_e('Create Account', 'unsupervised-schedular'); ?>">
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Writing tests
|
||||||
|
|
||||||
|
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) to stub WordPress functions without a full WP installation, and Mockery to mock `$wpdb` and other dependencies.
|
||||||
|
|
||||||
|
All test classes extend `tests/Unit/TestCase.php`, which handles `Monkey\setUp()` / `Monkey\tearDown()` and stubs all WP translation/escape functions automatically.
|
||||||
|
|
||||||
|
**Brain\Monkey API notes:**
|
||||||
|
|
||||||
|
- `Functions\when('fn')->alias(fn() => ...)` — stub with a closure (NOT `returnUsing()`)
|
||||||
|
- `Functions\when('fn')->justReturn($val)` — stub returning a fixed value
|
||||||
|
- `Functions\expect('fn')->once()->with(...)` — assert call count and arguments
|
||||||
|
- Use `Functions\when()` (not `Functions\expect()`) when you need argument-routing (e.g. `get_role` returning different values per argument) to avoid chaining ambiguity
|
||||||
|
- Mockery matchers (e.g. `\Mockery::type()`) inside plain PHP arrays do not work with `with()` — use `\Mockery::on(fn($arr) => ...)` or `\Mockery::any()` instead
|
||||||
|
- When mocking `$wpdb`, set `$mock->prefix = 'wp_'` explicitly — it is a public property, not a method
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Auth\AccountPage;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class AccountPageTest extends TestCase
|
||||||
|
{
|
||||||
|
private AccountPage $page;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->page = new AccountPage();
|
||||||
|
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('get_current_user_id')->justReturn(5);
|
||||||
|
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||||
|
Functions\when('get_permalink')->alias(
|
||||||
|
static fn (int $id = 0): string => $id > 0
|
||||||
|
? 'https://studio.test/sign-in/'
|
||||||
|
: 'https://studio.test/current/'
|
||||||
|
);
|
||||||
|
Functions\when('wp_logout_url')->alias(
|
||||||
|
static fn (string $redirect): string => 'https://studio.test/wp-login.php?action=logout&redirect_to=' . rawurlencode($redirect)
|
||||||
|
);
|
||||||
|
Functions\when('wp_get_current_user')->justReturn($this->user('Grace', 'Hopper', '[email protected]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function user(string $first, string $last, string $email): \WP_User
|
||||||
|
{
|
||||||
|
$user = Mockery::mock(\WP_User::class);
|
||||||
|
$user->ID = 5;
|
||||||
|
$user->first_name = $first;
|
||||||
|
$user->last_name = $last;
|
||||||
|
$user->nickname = '';
|
||||||
|
$user->user_email = $email;
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShowsTheSignedInNameAndEmail(): void
|
||||||
|
{
|
||||||
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Grace Hopper', $html);
|
||||||
|
self::assertStringContainsString('[email protected]', $html);
|
||||||
|
self::assertStringContainsString('Sign out', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSigningOutReturnsToTheConfiguredLoginPage(): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString(
|
||||||
|
rawurlencode('https://studio.test/sign-in/'),
|
||||||
|
$this->page->render(['loginPageId' => 9])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With no page chosen, signing out from a header link should leave the
|
||||||
|
* visitor where they were rather than navigating them somewhere.
|
||||||
|
*/
|
||||||
|
public function testSigningOutReturnsToTheCurrentPageWhenNoLoginPageIsSet(): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString(
|
||||||
|
rawurlencode('https://studio.test/current/'),
|
||||||
|
$this->page->render([])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheShortcodeAttributeNameIsAccepted(): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString(
|
||||||
|
rawurlencode('https://studio.test/sign-in/'),
|
||||||
|
$this->page->render(['login_page_id' => 9])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A block whose whole job is "you are signed in as X" has nothing to say to
|
||||||
|
* a stranger, and a bare notice in a site header cannot be acted on.
|
||||||
|
*/
|
||||||
|
public function testRendersNothingForASignedOutVisitorWithNoLoginPage(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(false);
|
||||||
|
|
||||||
|
self::assertSame('', $this->page->render([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOffersASignInLinkToASignedOutVisitorWhenAPageIsChosen(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(false);
|
||||||
|
|
||||||
|
$html = $this->page->render(['loginPageId' => 9]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('https://studio.test/sign-in/', $html);
|
||||||
|
self::assertStringContainsString('Sign in', $html);
|
||||||
|
self::assertStringNotContainsString('Sign out', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page can be deleted after it has been chosen in the block, which
|
||||||
|
* get_permalink() reports as false.
|
||||||
|
*/
|
||||||
|
public function testTreatsADeletedLoginPageAsNoneChosen(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(false);
|
||||||
|
Functions\when('get_permalink')->justReturn(false);
|
||||||
|
|
||||||
|
self::assertSame('', $this->page->render(['loginPageId' => 9]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use Brain\Monkey\Actions;
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Auth\DeletedUserCleanup;
|
||||||
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
|
use Unsupervised\Schedular\Booking\Lesson;
|
||||||
|
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||||
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianLink;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianRepository;
|
||||||
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
|
use Unsupervised\Schedular\Payment\PaymentService;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class DeletedUserCleanupTest extends TestCase
|
||||||
|
{
|
||||||
|
private BookingRepository&Mockery\MockInterface $bookings;
|
||||||
|
private AvailabilityRepository&Mockery\MockInterface $availability;
|
||||||
|
private EnrollmentRepository&Mockery\MockInterface $enrollments;
|
||||||
|
private PaymentService&Mockery\MockInterface $payments;
|
||||||
|
private GuardianRepository&Mockery\MockInterface $links;
|
||||||
|
private GuardianService&Mockery\MockInterface $guardians;
|
||||||
|
private DeletedUserCleanup $cleanup;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||||
|
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||||
|
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
||||||
|
$this->payments = Mockery::mock(PaymentService::class);
|
||||||
|
$this->links = Mockery::mock(GuardianRepository::class);
|
||||||
|
$this->guardians = Mockery::mock(GuardianService::class);
|
||||||
|
|
||||||
|
// Most accounts have nobody linked to them; the guardian tests say so.
|
||||||
|
$this->links->shouldReceive('findByGuardian')->andReturn([])->byDefault();
|
||||||
|
|
||||||
|
$this->cleanup = new DeletedUserCleanup(
|
||||||
|
$this->bookings,
|
||||||
|
$this->availability,
|
||||||
|
$this->enrollments,
|
||||||
|
$this->payments,
|
||||||
|
$this->links,
|
||||||
|
$this->guardians
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHooksBothSingleSiteAndNetworkDeletion(): void
|
||||||
|
{
|
||||||
|
Actions\expectAdded('delete_user')->once();
|
||||||
|
Actions\expectAdded('wpmu_delete_user')->once();
|
||||||
|
|
||||||
|
$this->cleanup->register();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEachUpcomingLessonIsCancelledItsSlotFreedAndItsPendingPaymentVoided(): void
|
||||||
|
{
|
||||||
|
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([
|
||||||
|
new Lesson(slotId: 7, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 40, id: 12),
|
||||||
|
new Lesson(slotId: 8, studentId: 5, instructorId: 3, status: Lesson::STATUS_CONFIRMED, paymentId: null, id: 13),
|
||||||
|
]);
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
|
||||||
|
|
||||||
|
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CANCELLED)->andReturn(true);
|
||||||
|
$this->bookings->shouldReceive('updateStatus')->once()->with(13, Lesson::STATUS_CANCELLED)->andReturn(true);
|
||||||
|
|
||||||
|
// The point of the whole exercise: the times go back on sale.
|
||||||
|
$this->availability->shouldReceive('release')->once()->with(7)->andReturn(true);
|
||||||
|
$this->availability->shouldReceive('release')->once()->with(8)->andReturn(true);
|
||||||
|
|
||||||
|
$this->payments->shouldReceive('voidPending')->once()->with(40);
|
||||||
|
$this->payments->shouldReceive('voidPending')->once()->with(null);
|
||||||
|
|
||||||
|
$this->cleanup->releaseBookings(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A paid lesson is not credited back. The credit could only ever be spent on
|
||||||
|
* the account being deleted, so writing one would be book-keeping nobody can
|
||||||
|
* act on — a refund is the studio's call to make and record.
|
||||||
|
*/
|
||||||
|
public function testNoCreditIsIssuedForAPaidLesson(): void
|
||||||
|
{
|
||||||
|
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([
|
||||||
|
new Lesson(slotId: 7, studentId: 5, instructorId: 3, status: Lesson::STATUS_CONFIRMED, paymentId: 40, id: 12),
|
||||||
|
]);
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
|
||||||
|
|
||||||
|
$this->bookings->shouldReceive('updateStatus')->andReturn(true);
|
||||||
|
$this->availability->shouldReceive('release')->andReturn(true);
|
||||||
|
$this->payments->shouldReceive('voidPending');
|
||||||
|
|
||||||
|
$this->payments->shouldNotReceive('creditForCancelledLesson');
|
||||||
|
|
||||||
|
$this->cleanup->releaseBookings(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testActiveEnrolmentsAreCancelledAndTheirPendingPaymentsVoided(): void
|
||||||
|
{
|
||||||
|
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([]);
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 41, id: 40),
|
||||||
|
new Enrollment(
|
||||||
|
offeringId: 9,
|
||||||
|
studentId: 5,
|
||||||
|
instructorId: 3,
|
||||||
|
status: Enrollment::STATUS_CANCELLED,
|
||||||
|
paymentId: 42,
|
||||||
|
id: 41,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only the active one: a withdrawn enrolment is already holding nothing.
|
||||||
|
$this->enrollments->shouldReceive('updateStatus')->once()->with(40, Enrollment::STATUS_CANCELLED)->andReturn(true);
|
||||||
|
$this->payments->shouldReceive('voidPending')->once()->with(41);
|
||||||
|
|
||||||
|
$this->cleanup->releaseBookings(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Past lessons happened and may have been paid for, so they stay exactly as
|
||||||
|
* they are — `findUpcomingForStudent` is what draws that line.
|
||||||
|
*/
|
||||||
|
public function testNothingHappensWhenTheAccountHasNothingBookedAhead(): void
|
||||||
|
{
|
||||||
|
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([]);
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
|
||||||
|
|
||||||
|
$this->bookings->shouldNotReceive('updateStatus');
|
||||||
|
$this->availability->shouldNotReceive('release');
|
||||||
|
$this->enrollments->shouldNotReceive('updateStatus');
|
||||||
|
|
||||||
|
$this->cleanup->releaseBookings(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnInvalidUserIdIsIgnored(): void
|
||||||
|
{
|
||||||
|
$this->bookings->shouldNotReceive('findUpcomingForStudent');
|
||||||
|
$this->enrollments->shouldNotReceive('findByStudent');
|
||||||
|
|
||||||
|
$this->cleanup->releaseBookings(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A child account is login-less and exists only so its guardian has somebody
|
||||||
|
* to book for. Without the guardian nobody can reach it, book for it, or be
|
||||||
|
* billed for it — so it goes too, and what it was holding goes back.
|
||||||
|
*/
|
||||||
|
public function testDeletingAGuardianReleasesAndDeletesEachChild(): void
|
||||||
|
{
|
||||||
|
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([]);
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
|
||||||
|
|
||||||
|
$this->links->shouldReceive('findByGuardian')->with(5)->andReturn([
|
||||||
|
new GuardianLink(guardianId: 5, studentId: 42, id: 1),
|
||||||
|
new GuardianLink(guardianId: 5, studentId: 43, id: 2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->bookings->shouldReceive('findUpcomingForStudent')->with(42)->andReturn([
|
||||||
|
new Lesson(slotId: 7, studentId: 42, instructorId: 3, status: Lesson::STATUS_CONFIRMED, paymentId: 40, id: 12),
|
||||||
|
]);
|
||||||
|
$this->bookings->shouldReceive('findUpcomingForStudent')->with(43)->andReturn([]);
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([]);
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(43)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 43, instructorId: 3, paymentId: 41, id: 40),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The child's lesson is cancelled and its time freed, exactly as the
|
||||||
|
// guardian's own would have been.
|
||||||
|
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CANCELLED)->andReturn(true);
|
||||||
|
$this->availability->shouldReceive('release')->once()->with(7)->andReturn(true);
|
||||||
|
$this->enrollments->shouldReceive('updateStatus')->once()->with(40, Enrollment::STATUS_CANCELLED)->andReturn(true);
|
||||||
|
$this->payments->shouldReceive('voidPending')->with(40)->once();
|
||||||
|
$this->payments->shouldReceive('voidPending')->with(41)->once();
|
||||||
|
|
||||||
|
// Then the link row and the account itself.
|
||||||
|
$this->links->shouldReceive('delete')->once()->with(5, 42)->andReturn(true);
|
||||||
|
$this->links->shouldReceive('delete')->once()->with(5, 43)->andReturn(true);
|
||||||
|
$this->guardians->shouldReceive('deleteUser')->once()->with(42);
|
||||||
|
$this->guardians->shouldReceive('deleteUser')->once()->with(43);
|
||||||
|
|
||||||
|
$this->cleanup->releaseBookings(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting a child fires `delete_user` again, which lands back in this same
|
||||||
|
* handler. It must return without redoing the release — and a self-link,
|
||||||
|
* however it got into the table, must not recurse for ever.
|
||||||
|
*/
|
||||||
|
public function testAChildAlreadyDealtWithIsNotProcessedTwice(): void
|
||||||
|
{
|
||||||
|
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->andReturn([]);
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([]);
|
||||||
|
|
||||||
|
$this->links->shouldReceive('findByGuardian')->with(5)->andReturn([
|
||||||
|
new GuardianLink(guardianId: 5, studentId: 42, id: 1),
|
||||||
|
// A duplicate row, and a self-link: neither may cause a second pass.
|
||||||
|
new GuardianLink(guardianId: 5, studentId: 42, id: 2),
|
||||||
|
new GuardianLink(guardianId: 5, studentId: 5, id: 3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->bookings->shouldReceive('findUpcomingForStudent')->with(42)->once()->andReturn([]);
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(42)->once()->andReturn([]);
|
||||||
|
$this->links->shouldReceive('delete')->once()->with(5, 42)->andReturn(true);
|
||||||
|
$this->guardians->shouldReceive('deleteUser')->once()->with(42);
|
||||||
|
|
||||||
|
$this->cleanup->releaseBookings(5);
|
||||||
|
|
||||||
|
// The re-entrant call the child's own deletion triggers is a no-op.
|
||||||
|
$this->cleanup->releaseBookings(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A child's own deletion (from the family screen, say) touches nothing but
|
||||||
|
* that child — they have nobody linked beneath them.
|
||||||
|
*/
|
||||||
|
public function testDeletingAStudentWithNoChildrenDeletesNobodyElse(): void
|
||||||
|
{
|
||||||
|
$this->bookings->shouldReceive('findUpcomingForStudent')->with(42)->andReturn([]);
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(42)->andReturn([]);
|
||||||
|
$this->links->shouldReceive('findByGuardian')->with(42)->andReturn([]);
|
||||||
|
|
||||||
|
$this->guardians->shouldNotReceive('deleteUser');
|
||||||
|
$this->links->shouldNotReceive('delete');
|
||||||
|
|
||||||
|
$this->cleanup->releaseBookings(42);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Auth\PasswordPolicy;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class PasswordPolicyTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAcceptsAnOrdinaryMemorablePassword(): void
|
||||||
|
{
|
||||||
|
self::assertNull(PasswordPolicy::validate('thistle-marrow-42', '[email protected]', 'Grace Hopper'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A leading or trailing space is a character like any other. Trimming it
|
||||||
|
* would accept a password the user could then never type back.
|
||||||
|
*/
|
||||||
|
public function testCountsSurroundingSpaceAsPartOfThePassword(): void
|
||||||
|
{
|
||||||
|
self::assertNull(PasswordPolicy::validate(' spaced-out-phrase '));
|
||||||
|
|
||||||
|
// Seven characters counting both spaces: one short, and still one short
|
||||||
|
// after the spaces are counted rather than stripped.
|
||||||
|
self::assertNotNull(PasswordPolicy::validate(' short '));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider tooShort
|
||||||
|
*/
|
||||||
|
public function testRejectsAPasswordShorterThanTheMinimum(string $password): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString('at least', (string) PasswordPolicy::validate($password));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string}> */
|
||||||
|
public static function tooShort(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'empty' => [''],
|
||||||
|
'one short' => ['sevench'],
|
||||||
|
'a few chars' => ['abc'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider commonPasswords
|
||||||
|
*/
|
||||||
|
public function testRejectsAWellKnownPassword(string $password): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString('commonly used', (string) PasswordPolicy::validate($password));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string}> */
|
||||||
|
public static function commonPasswords(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'password123' => ['password123'],
|
||||||
|
'shouting' => ['PASSWORD123'],
|
||||||
|
'mixed case' => ['PassWord123'],
|
||||||
|
'a keyboard walk' => ['qwertyuiop'],
|
||||||
|
'digits in a row' => ['123456789'],
|
||||||
|
'the classic' => ['iloveyou'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider tooFewDistinctCharacters
|
||||||
|
*/
|
||||||
|
public function testRejectsAPasswordBuiltFromAlmostNoDistinctCharacters(string $password): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString('repeated characters', (string) PasswordPolicy::validate($password));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string}> */
|
||||||
|
public static function tooFewDistinctCharacters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'one character' => ['aaaaaaaaaa'],
|
||||||
|
'two alternating' => ['abababababab'],
|
||||||
|
'three' => ['abcabcabcabc'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider identityEchoes
|
||||||
|
*/
|
||||||
|
public function testRejectsAPasswordContainingTheUsersOwnDetails(string $password, string $email, string $name): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString('name or email', (string) PasswordPolicy::validate($password, $email, $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, string, string}> */
|
||||||
|
public static function identityEchoes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'the whole email' => ['[email protected]!', '[email protected]', 'Grace'],
|
||||||
|
'the local part' => ['grace-hopper-1906', '[email protected]', ''],
|
||||||
|
'the display name' => ['xxhopperxx-2019', '[email protected]', 'Hopper'],
|
||||||
|
'differing in case' => ['MyGRACEpassword', '[email protected]', ''],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A two- or three-letter overlap with a name is coincidence, not a weakness,
|
||||||
|
* and refusing it would be baffling to the person typing.
|
||||||
|
*/
|
||||||
|
public function testShortIdentityFragmentsDoNotTripTheCheck(): void
|
||||||
|
{
|
||||||
|
self::assertNull(PasswordPolicy::validate('bramble-thicket', '[email protected]', 'Bo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnEmptyIdentityIsNotTreatedAsContainedInEverything(): void
|
||||||
|
{
|
||||||
|
self::assertNull(PasswordPolicy::validate('bramble-thicket', '', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,12 +35,22 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
|
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
|
||||||
Functions\when('sanitize_text_field')->alias(static fn ($v) => $v);
|
Functions\when('sanitize_text_field')->alias(static fn ($v) => $v);
|
||||||
|
// Every submit reads the "who are you registering?" radio through it.
|
||||||
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||||
Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v);
|
Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v);
|
||||||
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
|
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
|
||||||
|
// Reached on every submit now that the email is validated before the
|
||||||
|
// password, so the password can be checked against it.
|
||||||
|
Functions\when('is_email')->alias(static fn (string $v): bool => (bool) preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $v));
|
||||||
Functions\when('absint')->alias(static fn ($v) => (int) $v);
|
Functions\when('absint')->alias(static fn ($v) => (int) $v);
|
||||||
Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
|
// The birth-year check reads current_time('Y'), so answer that format
|
||||||
|
// properly rather than leaving it to cast out of the datetime string.
|
||||||
|
Functions\when('current_time')->alias(
|
||||||
|
static fn (string $type = 'mysql'): string => 'Y' === $type ? '2024' : '2024-01-01 00:00:00'
|
||||||
|
);
|
||||||
Functions\when('wp_enqueue_style')->justReturn(null);
|
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||||
Functions\when('wp_enqueue_script')->justReturn(null);
|
Functions\when('wp_enqueue_script')->justReturn(null);
|
||||||
|
Functions\when('wp_localize_script')->justReturn(true);
|
||||||
|
|
||||||
$invites = Mockery::mock(InviteRepository::class);
|
$invites = Mockery::mock(InviteRepository::class);
|
||||||
$policies = Mockery::mock(PolicyRepository::class);
|
$policies = Mockery::mock(PolicyRepository::class);
|
||||||
@@ -66,6 +76,9 @@ class RegistrationPageTest extends TestCase
|
|||||||
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
|
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
|
||||||
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
|
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
|
||||||
$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.
|
||||||
|
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->byDefault();
|
||||||
|
$this->ctx['guardians']->shouldReceive('setBirthYear')->byDefault();
|
||||||
|
|
||||||
$this->ctx['page'] = new RegistrationPage(
|
$this->ctx['page'] = new RegistrationPage(
|
||||||
$invites,
|
$invites,
|
||||||
@@ -101,6 +114,17 @@ class RegistrationPageTest extends TestCase
|
|||||||
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
|
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Everything the invite success branch touches once the account is created. */
|
||||||
|
private function stubInviteSuccess(): void
|
||||||
|
{
|
||||||
|
Functions\when('email_exists')->justReturn(false);
|
||||||
|
Functions\when('wp_insert_user')->justReturn(42);
|
||||||
|
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
||||||
|
Functions\when('wp_set_current_user')->justReturn(null);
|
||||||
|
Functions\when('wp_set_auth_cookie')->justReturn(null);
|
||||||
|
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
||||||
|
}
|
||||||
|
|
||||||
private function submit(?Invite $invite, bool $open): string
|
private function submit(?Invite $invite, bool $open): string
|
||||||
{
|
{
|
||||||
$method = new \ReflectionMethod(RegistrationPage::class, 'handleSubmit');
|
$method = new \ReflectionMethod(RegistrationPage::class, 'handleSubmit');
|
||||||
@@ -110,7 +134,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
public function testInviteBranchCreatesAndLogsInTheStudent(): void
|
public function testInviteBranchCreatesAndLogsInTheStudent(): void
|
||||||
{
|
{
|
||||||
$_POST = [ 'password' => 'password123', '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);
|
||||||
@@ -127,7 +151,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
|
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
|
||||||
{
|
{
|
||||||
$_POST = [ 'password' => 'password123', '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);
|
||||||
@@ -147,7 +171,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
|
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
|
||||||
{
|
{
|
||||||
$_POST = [ 'password' => 'password123', '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);
|
||||||
@@ -174,7 +198,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
|
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
|
||||||
{
|
{
|
||||||
$_POST = [ 'password' => 'password123', '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);
|
||||||
@@ -342,7 +366,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
|
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
|
||||||
{
|
{
|
||||||
$_POST = [ 'password' => 'password123', '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);
|
||||||
|
|
||||||
@@ -361,7 +385,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
|
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
|
||||||
{
|
{
|
||||||
$_POST = [ 'password' => 'password123', '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);
|
||||||
|
|
||||||
@@ -381,8 +405,9 @@ class RegistrationPageTest extends TestCase
|
|||||||
public function testRecordsAccountAnswersOnSuccess(): void
|
public function testRecordsAccountAnswersOnSuccess(): void
|
||||||
{
|
{
|
||||||
$_POST = [
|
$_POST = [
|
||||||
'password' => 'password123',
|
'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' ],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -415,7 +440,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
|
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
|
||||||
{
|
{
|
||||||
$_POST = [ 'us_register' => '1', 'password' => 'password123', '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);
|
||||||
@@ -614,14 +639,14 @@ class RegistrationPageTest extends TestCase
|
|||||||
public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void
|
public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void
|
||||||
{
|
{
|
||||||
$_POST = [
|
$_POST = [
|
||||||
'password' => 'password123',
|
'password' => 'thistle-marrow-42',
|
||||||
'display_name' => 'Grace',
|
'display_name' => 'Grace',
|
||||||
'us_is_guardian' => '1',
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||||
'children' => [
|
'children' => [
|
||||||
['name' => 'Ada', 'dob' => '2015-04-02', 'answers' => [7 => 'Piano']],
|
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
|
||||||
['name' => 'Alan', 'dob' => '', 'answers' => [7 => 'Violin']],
|
['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => 'Violin']],
|
||||||
// An untouched spare block is dropped, not rejected.
|
// An untouched spare block is dropped, not rejected.
|
||||||
['name' => ' ', 'dob' => '', 'answers' => []],
|
['name' => ' ', 'birth_year' => '', 'answers' => []],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -633,8 +658,8 @@ class RegistrationPageTest extends TestCase
|
|||||||
Functions\when('wp_insert_user')->justReturn(42);
|
Functions\when('wp_insert_user')->justReturn(42);
|
||||||
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
||||||
|
|
||||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015-04-02')->andReturn(101);
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->andReturn(101);
|
||||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->andReturn(102);
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '2017')->andReturn(102);
|
||||||
|
|
||||||
$recorded = [];
|
$recorded = [];
|
||||||
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
||||||
@@ -652,13 +677,256 @@ class RegistrationPageTest extends TestCase
|
|||||||
self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded);
|
self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser gates on zxcvbn, but that is advice a client can decline to
|
||||||
|
* take. Nothing is created for a password the server refuses.
|
||||||
|
*
|
||||||
|
* @dataProvider refusedPasswords
|
||||||
|
*/
|
||||||
|
public function testSignupRefusesAPasswordThePolicyRejects(string $password, string $expected): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'email' => '[email protected]',
|
||||||
|
'password' => $password,
|
||||||
|
'display_name' => 'Grace Hopper',
|
||||||
|
];
|
||||||
|
|
||||||
|
Functions\when('email_exists')->justReturn(false);
|
||||||
|
Functions\expect('wp_insert_user')->never();
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
$expected,
|
||||||
|
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, string}> */
|
||||||
|
public static function refusedPasswords(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'too short' => ['abc123', 'at least'],
|
||||||
|
'a known password' => ['password123', 'commonly used'],
|
||||||
|
'barely any variety' => ['ababababab', 'repeated characters'],
|
||||||
|
'their own name' => ['grace-hopper-1906', 'name or email'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSignupRefusesAnAddressThatIsNotAnEmail(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'email' => 'not-an-email',
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
];
|
||||||
|
|
||||||
|
Functions\when('email_exists')->justReturn(false);
|
||||||
|
Functions\expect('wp_insert_user')->never();
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'valid email address',
|
||||||
|
$this->submit(null, true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "On behalf of students" is the one choice that says the account holder is
|
||||||
|
* not a student, so it is the one that sets the flag.
|
||||||
|
*/
|
||||||
|
public function testRegisteringOnlyForStudentsMarksTheAccountGuardianOnly(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||||
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||||
|
$this->stubInviteSuccess();
|
||||||
|
|
||||||
|
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, true);
|
||||||
|
|
||||||
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider modesThatKeepTheAccountHolderAStudent
|
||||||
|
*/
|
||||||
|
public function testTheAccountHolderStaysAStudentForTheOtherTwoChoices(string $mode, bool $withChildren): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'birth_year' => '1990',
|
||||||
|
'us_registering_for' => $mode,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($withChildren) {
|
||||||
|
$_POST['children'] = [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]];
|
||||||
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
$this->stubInviteSuccess();
|
||||||
|
|
||||||
|
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, false);
|
||||||
|
|
||||||
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string, bool}> */
|
||||||
|
public static function modesThatKeepTheAccountHolderAStudent(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'just myself' => [RegistrationPage::FOR_SELF, false],
|
||||||
|
'myself and students' => [RegistrationPage::FOR_BOTH, true],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Both" collects students exactly as "on behalf of" does — the only
|
||||||
|
* difference is whether the account holder is one of them.
|
||||||
|
*/
|
||||||
|
public function testBothStillRequiresAtLeastOneStudent(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||||
|
'children' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
Functions\when('email_exists')->justReturn(false);
|
||||||
|
Functions\expect('wp_insert_user')->never();
|
||||||
|
|
||||||
|
self::assertStringContainsString('at least one student', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A form posted without the radio — an old cached page, or a crafted
|
||||||
|
* request — must fall to the choice that collects and grants the least,
|
||||||
|
* never be read as "register these children".
|
||||||
|
*/
|
||||||
|
public function testAMissingOrUnknownChoiceFallsBackToJustMyself(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'birth_year' => '1990',
|
||||||
|
'us_registering_for' => 'something-else',
|
||||||
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
$this->stubInviteSuccess();
|
||||||
|
|
||||||
|
// No student is created from children[] the caller never asked to register.
|
||||||
|
$this->ctx['guardians']->shouldNotReceive('createChild');
|
||||||
|
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, false);
|
||||||
|
|
||||||
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Under "both" the account holder is a student too, so the studio's
|
||||||
|
* questions are asked of them as well as of each student they add. Before
|
||||||
|
* this they were asked per student only, and the account holder's own
|
||||||
|
* answers were never collected or stored.
|
||||||
|
*/
|
||||||
|
public function testBothRecordsAnswersForTheAccountHolderAndEachStudent(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'birth_year' => '1990',
|
||||||
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||||
|
'us_answers' => ['7' => 'Cello'],
|
||||||
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||||
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||||
|
$this->stubInviteSuccess();
|
||||||
|
|
||||||
|
$recorded = [];
|
||||||
|
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
||||||
|
static function (Answer $answer) use (&$recorded): int {
|
||||||
|
$recorded[] = [$answer->studentId, $answer->answerValue];
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||||
|
|
||||||
|
// The student's answer against the student, the account holder's against
|
||||||
|
// themselves — not one answer shared between them.
|
||||||
|
self::assertEqualsCanonicalizing([[101, 'Piano'], [42, 'Cello']], $recorded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBothRejectsAnUnansweredQuestionForTheAccountHolder(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'birth_year' => '1990',
|
||||||
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||||
|
'us_answers' => ['7' => ' '],
|
||||||
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||||
|
Functions\when('email_exists')->justReturn(false);
|
||||||
|
Functions\expect('wp_insert_user')->never();
|
||||||
|
|
||||||
|
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
||||||
|
|
||||||
|
// The message names nobody else — the student's answer was fine.
|
||||||
|
self::assertStringContainsString('Please answer all required registration questions.', $result);
|
||||||
|
self::assertStringNotContainsString('for each student', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pure guardian is not a student, so the questions are theirs to answer
|
||||||
|
* per student and never about them. Anything posted for them is ignored.
|
||||||
|
*/
|
||||||
|
public function testRegisteringOnlyForStudentsStoresNoAnswersForTheAccountHolder(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||||
|
'us_answers' => ['7' => 'Should be ignored'],
|
||||||
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||||
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||||
|
$this->stubInviteSuccess();
|
||||||
|
|
||||||
|
$students = [];
|
||||||
|
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
||||||
|
static function (Answer $answer) use (&$students): int {
|
||||||
|
$students[] = $answer->studentId;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||||
|
self::assertSame([101], $students);
|
||||||
|
}
|
||||||
|
|
||||||
public function testGuardianSignupWithNoChildrenIsRejected(): void
|
public function testGuardianSignupWithNoChildrenIsRejected(): void
|
||||||
{
|
{
|
||||||
$_POST = [
|
$_POST = [
|
||||||
'password' => 'password123',
|
'password' => 'thistle-marrow-42',
|
||||||
'display_name' => 'Grace',
|
'display_name' => 'Grace',
|
||||||
'us_is_guardian' => '1',
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||||
'children' => [['name' => '', 'dob' => '', 'answers' => []]],
|
'children' => [['name' => '', 'birth_year' => '', 'answers' => []]],
|
||||||
];
|
];
|
||||||
|
|
||||||
Functions\when('email_exists')->justReturn(false);
|
Functions\when('email_exists')->justReturn(false);
|
||||||
@@ -667,7 +935,69 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
||||||
|
|
||||||
self::assertStringContainsString('at least one child', $result);
|
self::assertStringContainsString('at least one student', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A block the guardian actually typed into is theirs to correct, not ours to
|
||||||
|
* discard — only a wholly untouched spare is dropped. Losing the birth year
|
||||||
|
* they filled in and registering a nameless student would be worse than
|
||||||
|
* telling them what is missing.
|
||||||
|
*/
|
||||||
|
public function testGuardianSignupRejectsAHalfFilledChildRatherThanDroppingIt(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||||
|
'children' => [
|
||||||
|
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
|
||||||
|
['name' => '', 'birth_year' => '2017', 'answers' => []],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
Functions\when('email_exists')->justReturn(false);
|
||||||
|
Functions\expect('wp_insert_user')->never();
|
||||||
|
$this->ctx['guardians']->shouldNotReceive('createChild');
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'give each student a name',
|
||||||
|
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider rejectedBirthYears
|
||||||
|
*/
|
||||||
|
public function testGuardianSignupRejectsAChildWithoutAUsableBirthYear(string $submitted): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||||
|
'children' => [['name' => 'Ada', 'birth_year' => $submitted, 'answers' => []]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
Functions\when('email_exists')->justReturn(false);
|
||||||
|
Functions\expect('wp_insert_user')->never();
|
||||||
|
$this->ctx['guardians']->shouldNotReceive('createChild');
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'birth year',
|
||||||
|
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string}> */
|
||||||
|
public static function rejectedBirthYears(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'left blank' => [''],
|
||||||
|
'a full date' => ['2015-04-02'],
|
||||||
|
'in the future' => ['2027'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -677,24 +1007,24 @@ class RegistrationPageTest extends TestCase
|
|||||||
public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void
|
public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void
|
||||||
{
|
{
|
||||||
$_POST = [
|
$_POST = [
|
||||||
'password' => 'password123',
|
'password' => 'thistle-marrow-42',
|
||||||
'display_name' => 'Grace',
|
'display_name' => 'Grace',
|
||||||
'us_is_guardian' => '1',
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||||
'children' => [
|
'children' => [
|
||||||
['name' => 'Ada', 'dob' => '', 'answers' => [7 => 'Piano']],
|
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
|
||||||
['name' => 'Alan', 'dob' => '', 'answers' => [7 => ' ']],
|
['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => ' ']],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
|
||||||
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
|
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Functions\when('email_exists')->justReturn(false);
|
Functions\when('email_exists')->justReturn(false);
|
||||||
Functions\expect('wp_insert_user')->never();
|
Functions\expect('wp_insert_user')->never();
|
||||||
$this->ctx['guardians']->shouldNotReceive('createChild');
|
$this->ctx['guardians']->shouldNotReceive('createChild');
|
||||||
|
|
||||||
self::assertStringContainsString('for each child', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
self::assertStringContainsString('for each student', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -704,12 +1034,12 @@ class RegistrationPageTest extends TestCase
|
|||||||
public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void
|
public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void
|
||||||
{
|
{
|
||||||
$_POST = [
|
$_POST = [
|
||||||
'password' => 'password123',
|
'password' => 'thistle-marrow-42',
|
||||||
'display_name' => 'Grace',
|
'display_name' => 'Grace',
|
||||||
'us_is_guardian' => '1',
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||||
'children' => [
|
'children' => [
|
||||||
['name' => 'Ada', 'dob' => '', 'answers' => []],
|
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
|
||||||
['name' => 'Alan', 'dob' => '', 'answers' => []],
|
['name' => 'Alan', 'birth_year' => '2017', 'answers' => []],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -717,8 +1047,8 @@ class RegistrationPageTest extends TestCase
|
|||||||
Functions\when('wp_insert_user')->justReturn(42);
|
Functions\when('wp_insert_user')->justReturn(42);
|
||||||
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
||||||
|
|
||||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '')->andReturn(101);
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->andReturn(101);
|
||||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '2017')
|
||||||
->andReturn(new \WP_Error('link_failed', 'Nope.'));
|
->andReturn(new \WP_Error('link_failed', 'Nope.'));
|
||||||
|
|
||||||
$deleted = [];
|
$deleted = [];
|
||||||
@@ -741,11 +1071,11 @@ class RegistrationPageTest extends TestCase
|
|||||||
public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void
|
public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void
|
||||||
{
|
{
|
||||||
$_POST = [
|
$_POST = [
|
||||||
'password' => 'password123',
|
'password' => 'thistle-marrow-42',
|
||||||
'display_name' => 'Grace',
|
'display_name' => 'Grace',
|
||||||
'us_is_guardian' => '1',
|
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||||
'accept' => [3],
|
'accept' => [3],
|
||||||
'children' => [['name' => 'Ada', 'dob' => '', 'answers' => []]],
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
|
||||||
];
|
];
|
||||||
|
|
||||||
$version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3);
|
$version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3);
|
||||||
@@ -780,7 +1110,7 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void
|
public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void
|
||||||
{
|
{
|
||||||
$_POST = ['password' => 'password123', '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);
|
||||||
@@ -793,4 +1123,281 @@ 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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "students only" question describes a child being registered, so it is put
|
||||||
|
* to each student and never to the account holder about themselves.
|
||||||
|
*/
|
||||||
|
public function testAStudentsOnlyQuestionIsAskedOfTheStudentsAndNotOfTheAccountHolder(): void
|
||||||
|
{
|
||||||
|
$this->stubRenderContext();
|
||||||
|
|
||||||
|
$question = new Question(
|
||||||
|
null,
|
||||||
|
'School and grade',
|
||||||
|
scope: Question::SCOPE_ACCOUNT,
|
||||||
|
audience: Question::AUDIENCE_CHILD,
|
||||||
|
isRequiredChild: true,
|
||||||
|
id: 7
|
||||||
|
);
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
||||||
|
|
||||||
|
$html = $this->ctx['page']->render([]);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('name="us_answers[7]"', $html);
|
||||||
|
self::assertStringContainsString('name="children[0][answers][7]"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two required flags are read where each applies: the browser is asked to
|
||||||
|
* enforce the account holder's, and the students' block carries the marker
|
||||||
|
* without the attribute (it may not be in play at all).
|
||||||
|
*/
|
||||||
|
public function testTheFormMarksAQuestionRequiredWhereItActuallyIs(): void
|
||||||
|
{
|
||||||
|
$this->stubRenderContext();
|
||||||
|
|
||||||
|
$question = new Question(
|
||||||
|
null,
|
||||||
|
'Previous experience',
|
||||||
|
scope: Question::SCOPE_ACCOUNT,
|
||||||
|
isRequired: false,
|
||||||
|
isRequiredChild: true,
|
||||||
|
id: 7
|
||||||
|
);
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
||||||
|
|
||||||
|
$html = $this->ctx['page']->render([]);
|
||||||
|
|
||||||
|
// No `required` attribute on the account holder's copy, and no marker on
|
||||||
|
// its label — they may leave it blank.
|
||||||
|
self::assertStringContainsString('<input type="text" name="us_answers[7]" id="us-reg-q-7">', $html);
|
||||||
|
self::assertStringContainsString('<label for="us-reg-q-7">Previous experience</label>', $html);
|
||||||
|
|
||||||
|
// The student's copy is marked required, without the attribute: the block
|
||||||
|
// may not be in play at all, so the server is what enforces it.
|
||||||
|
self::assertStringContainsString('<label for="us-child-0-q-7">Previous experience <span class="us-required" aria-hidden="true">*</span></label>', $html);
|
||||||
|
self::assertStringContainsString('<input type="text" name="children[0][answers][7]" id="us-child-0-q-7">', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The point of the two flags: an adult signing themselves up can leave the
|
||||||
|
* question blank, while every student they enrol must answer it.
|
||||||
|
*/
|
||||||
|
public function testAQuestionOptionalForYouIsStillRequiredOfEachStudent(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'birth_year' => '1990',
|
||||||
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||||
|
'us_answers' => ['7' => ' '],
|
||||||
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => ' ']]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$question = new Question(null, 'Instrument', isRequired: false, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7);
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||||
|
Functions\when('email_exists')->justReturn(false);
|
||||||
|
Functions\expect('wp_insert_user')->never();
|
||||||
|
|
||||||
|
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
||||||
|
|
||||||
|
// The student's blank is what stopped it — the account holder's was fine.
|
||||||
|
self::assertStringContainsString('for each student', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheAccountHolderMayLeaveBlankWhatTheirStudentsMustAnswer(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'birth_year' => '1990',
|
||||||
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||||
|
'us_answers' => ['7' => ' '],
|
||||||
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$question = new Question(null, 'Instrument', isRequired: false, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7);
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||||
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||||
|
$this->stubInviteSuccess();
|
||||||
|
|
||||||
|
$students = [];
|
||||||
|
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
||||||
|
static function (Answer $answer) use (&$students): int {
|
||||||
|
$students[] = $answer->studentId;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||||
|
|
||||||
|
// Only the student answered, so only the student has an answer stored.
|
||||||
|
self::assertSame([101], $students);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A question the account holder is never shown cannot be one they are held
|
||||||
|
* to, nor one an answer can be filed against them for — a crafted post that
|
||||||
|
* supplies both is ignored on both counts.
|
||||||
|
*/
|
||||||
|
public function testAStudentsOnlyQuestionNeitherBlocksNorStoresAgainstTheAccountHolder(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'password' => 'thistle-marrow-42',
|
||||||
|
'display_name' => 'Grace',
|
||||||
|
'birth_year' => '1990',
|
||||||
|
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||||
|
'us_answers' => ['7' => 'Crafted by hand'],
|
||||||
|
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Grade 4']]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$question = new Question(
|
||||||
|
null,
|
||||||
|
'School and grade',
|
||||||
|
isRequired: true,
|
||||||
|
scope: Question::SCOPE_ACCOUNT,
|
||||||
|
audience: Question::AUDIENCE_CHILD,
|
||||||
|
isRequiredChild: true,
|
||||||
|
id: 7
|
||||||
|
);
|
||||||
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||||
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||||
|
$this->stubInviteSuccess();
|
||||||
|
|
||||||
|
$recorded = [];
|
||||||
|
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
||||||
|
static function (Answer $answer) use (&$recorded): int {
|
||||||
|
$recorded[] = [$answer->studentId, $answer->answerValue];
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||||
|
self::assertSame([[101, 'Grade 4']], $recorded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,21 @@ use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|||||||
|
|
||||||
class UserNameTest extends TestCase
|
class UserNameTest extends TestCase
|
||||||
{
|
{
|
||||||
private function user(string $first, string $last, string $nickname): \WP_User
|
private function user(
|
||||||
{
|
string $first,
|
||||||
|
string $last,
|
||||||
|
string $nickname,
|
||||||
|
string $displayName = '',
|
||||||
|
string $login = 'ada_login',
|
||||||
|
string $email = '[email protected]',
|
||||||
|
): \WP_User {
|
||||||
$user = Mockery::mock(\WP_User::class);
|
$user = Mockery::mock(\WP_User::class);
|
||||||
$user->first_name = $first;
|
$user->first_name = $first;
|
||||||
$user->last_name = $last;
|
$user->last_name = $last;
|
||||||
$user->nickname = $nickname;
|
$user->nickname = $nickname;
|
||||||
|
$user->display_name = $displayName;
|
||||||
|
$user->user_login = $login;
|
||||||
|
$user->user_email = $email;
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
@@ -34,6 +43,60 @@ class UserNameTest extends TestCase
|
|||||||
self::assertSame('Countess', UserName::format($this->user('', '', 'Countess')));
|
self::assertSame('Countess', UserName::format($this->user('', '', 'Countess')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPress defaults a new account's nickname to its `user_login`, and signup
|
||||||
|
* uses the email address as the login — so a self-registered account carried
|
||||||
|
* its own address as its nickname and every screen naming that person showed
|
||||||
|
* the address. The name they typed was in `display_name` all along.
|
||||||
|
*/
|
||||||
|
public function testSkipsANicknameThatIsReallyTheLoginAndUsesTheDisplayName(): void
|
||||||
|
{
|
||||||
|
$user = $this->user(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'[email protected]',
|
||||||
|
'Grace Hopper',
|
||||||
|
login: '[email protected]',
|
||||||
|
email: '[email protected]',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('Grace Hopper', UserName::format($user, 42));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSkipsANicknameThatIsReallyTheEmailAddress(): void
|
||||||
|
{
|
||||||
|
$user = $this->user(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'[email protected]',
|
||||||
|
'Grace Hopper',
|
||||||
|
login: 'gracehopper',
|
||||||
|
email: '[email protected]',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Case-insensitively: the address is the address however it was typed.
|
||||||
|
self::assertSame('Grace Hopper', UserName::format($user, 42));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole point of the class: an identifier never reaches the screen, even
|
||||||
|
* when it is the only thing on file. Someone who registered without giving a
|
||||||
|
* name is shown as their id rather than as their email address.
|
||||||
|
*/
|
||||||
|
public function testNeverFallsThroughToAnIdentifier(): void
|
||||||
|
{
|
||||||
|
$user = $this->user(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'[email protected]',
|
||||||
|
'[email protected]',
|
||||||
|
login: '[email protected]',
|
||||||
|
email: '[email protected]',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('42', UserName::format($user, 42));
|
||||||
|
}
|
||||||
|
|
||||||
public function testFallsBackToIdWhenNothingSet(): void
|
public function testFallsBackToIdWhenNothingSet(): void
|
||||||
{
|
{
|
||||||
self::assertSame('42', UserName::format($this->user('', '', ''), 42));
|
self::assertSame('42', UserName::format($this->user('', '', ''), 42));
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ class BlockPreviewTest extends TestCase
|
|||||||
self::assertStringContainsString('us-editor-note', $html);
|
self::assertStringContainsString('us-editor-note', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview is what someone placing the block styles against, so it has to
|
||||||
|
* show both halves of the page — your own details as well as your students'.
|
||||||
|
*/
|
||||||
|
public function testFamilyPreviewShowsTheAccountHoldersDetailsAndTheirStudents(): void
|
||||||
|
{
|
||||||
|
$html = BlockPreview::family();
|
||||||
|
|
||||||
|
self::assertStringContainsString('class="us-family-self"', $html);
|
||||||
|
self::assertStringContainsString('id="us-own-name"', $html);
|
||||||
|
self::assertStringContainsString('id="us-own-birth-year"', $html);
|
||||||
|
self::assertStringContainsString('I take lessons myself', $html);
|
||||||
|
self::assertStringContainsString('class="us-family-list"', $html);
|
||||||
|
self::assertStringContainsString('class="us-family-add"', $html);
|
||||||
|
self::assertStringContainsString('us-editor-note', $html);
|
||||||
|
}
|
||||||
|
|
||||||
public function testRegistrationPreviewShowsADisabledSampleForm(): void
|
public function testRegistrationPreviewShowsADisabledSampleForm(): void
|
||||||
{
|
{
|
||||||
$html = BlockPreview::registration();
|
$html = BlockPreview::registration();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
|
|||||||
use Brain\Monkey\Actions;
|
use Brain\Monkey\Actions;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Auth\AccountPage;
|
||||||
use Unsupervised\Schedular\Auth\LoginPage;
|
use Unsupervised\Schedular\Auth\LoginPage;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\BlockRegistrar;
|
use Unsupervised\Schedular\BlockRegistrar;
|
||||||
@@ -43,6 +44,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
||||||
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
||||||
private FamilyPage&Mockery\MockInterface $familyPage;
|
private FamilyPage&Mockery\MockInterface $familyPage;
|
||||||
|
private AccountPage&Mockery\MockInterface $accountPage;
|
||||||
private TestableBlockRegistrar $registrar;
|
private TestableBlockRegistrar $registrar;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
@@ -54,6 +56,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
||||||
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
||||||
$this->familyPage = Mockery::mock(FamilyPage::class);
|
$this->familyPage = Mockery::mock(FamilyPage::class);
|
||||||
|
$this->accountPage = Mockery::mock(AccountPage::class);
|
||||||
|
|
||||||
// Most requests are not a just-finished registration; the tests that
|
// Most requests are not a just-finished registration; the tests that
|
||||||
// exercise that path override this.
|
// exercise that path override this.
|
||||||
@@ -67,6 +70,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
$this->registrationPage,
|
$this->registrationPage,
|
||||||
$this->groupClassPage,
|
$this->groupClassPage,
|
||||||
$this->familyPage,
|
$this->familyPage,
|
||||||
|
$this->accountPage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +120,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
'us-scheduler/student-register',
|
'us-scheduler/student-register',
|
||||||
'us-scheduler/group-classes',
|
'us-scheduler/group-classes',
|
||||||
'us-scheduler/family',
|
'us-scheduler/family',
|
||||||
|
'us-scheduler/account',
|
||||||
],
|
],
|
||||||
array_keys($registered)
|
array_keys($registered)
|
||||||
);
|
);
|
||||||
@@ -213,6 +218,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
$this->registrationPage,
|
$this->registrationPage,
|
||||||
$this->groupClassPage,
|
$this->groupClassPage,
|
||||||
$this->familyPage,
|
$this->familyPage,
|
||||||
|
$this->accountPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
|
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,10 +233,12 @@ class LessonControllerTest extends TestCase
|
|||||||
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
|
||||||
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
|
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
|
||||||
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
|
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
|
||||||
$this->detail->shouldReceive('answers')->once()->with(1)->andReturn([
|
// The lesson itself is handed over, so the presenter can follow a series
|
||||||
|
// occurrence back to the anchor its answers and acceptances hang off.
|
||||||
|
$this->detail->shouldReceive('answers')->once()->with($lesson)->andReturn([
|
||||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||||
]);
|
]);
|
||||||
$this->detail->shouldReceive('acceptances')->once()->with(1)->andReturn([
|
$this->detail->shouldReceive('acceptances')->once()->with($lesson)->andReturn([
|
||||||
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
|
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||||
|
|
||||||
use Mockery;
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Booking\Lesson;
|
||||||
use Unsupervised\Schedular\Booking\LessonDetail;
|
use Unsupervised\Schedular\Booking\LessonDetail;
|
||||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||||
use Unsupervised\Schedular\Policy\Policy;
|
use Unsupervised\Schedular\Policy\Policy;
|
||||||
@@ -60,7 +61,7 @@ class LessonDetailTest extends TestCase
|
|||||||
['question' => 'Skill level', 'answer' => 'Beginner'],
|
['question' => 'Skill level', 'answer' => 'Beginner'],
|
||||||
['question' => '#9', 'answer' => '—'],
|
['question' => '#9', 'answer' => '—'],
|
||||||
],
|
],
|
||||||
$this->detail->answers(7)
|
$this->detail->answers($this->lesson(7))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +90,59 @@ class LessonDetailTest extends TestCase
|
|||||||
'ip' => '1.2.3.4',
|
'ip' => '1.2.3.4',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
$this->detail->acceptances(7)
|
$this->detail->acceptances($this->lesson(7))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeriesOccurrenceReadsTheAnchorsAnswersAndAcceptances(): void
|
||||||
|
{
|
||||||
|
// Occurrence #12 of a weekly reservation anchored on lesson 7: the intake
|
||||||
|
// and the agreement were recorded once, against the anchor.
|
||||||
|
$occurrence = $this->lesson(12, seriesId: 7);
|
||||||
|
|
||||||
|
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([
|
||||||
|
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: 'Beginner'),
|
||||||
|
]);
|
||||||
|
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Skill level', id: 2));
|
||||||
|
|
||||||
|
$this->acceptances->shouldReceive('findByRegistration')->once()->with(PolicyAcceptance::REG_LESSON, 7)->andReturn([
|
||||||
|
new PolicyAcceptance(
|
||||||
|
policyVersionId: 4,
|
||||||
|
studentId: 5,
|
||||||
|
registrationType: PolicyAcceptance::REG_LESSON,
|
||||||
|
registrationId: 7,
|
||||||
|
ipAddress: '1.2.3.4',
|
||||||
|
acceptedAt: '2026-07-01 10:00:00'
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
$this->versions->shouldReceive('findById')->with(4)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 4));
|
||||||
|
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[['question' => 'Skill level', 'answer' => 'Beginner']],
|
||||||
|
$this->detail->answers($occurrence)
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
[[
|
||||||
|
'policy' => 'Cancellation',
|
||||||
|
'version' => 'v2',
|
||||||
|
'accepted_at' => '2026-07-01 10:00:00',
|
||||||
|
'ip' => '1.2.3.4',
|
||||||
|
]],
|
||||||
|
$this->detail->acceptances($occurrence)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lesson(int $id, ?int $seriesId = null): Lesson
|
||||||
|
{
|
||||||
|
return new Lesson(
|
||||||
|
slotId: 1,
|
||||||
|
studentId: 5,
|
||||||
|
instructorId: 9,
|
||||||
|
offeringId: 1,
|
||||||
|
recurrence: null === $seriesId ? Lesson::RECURRENCE_SINGLE : Lesson::RECURRENCE_WEEKLY,
|
||||||
|
seriesId: $seriesId,
|
||||||
|
id: $id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||||
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
|
use Unsupervised\Schedular\GroupClass\SessionSchedule;
|
||||||
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class SessionScheduleTest extends TestCase
|
||||||
|
{
|
||||||
|
private EnrollmentRepository&Mockery\MockInterface $enrollments;
|
||||||
|
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||||
|
private SessionSchedule $schedule;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
||||||
|
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||||
|
$this->schedule = new SessionSchedule($this->enrollments, $this->offerings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A three-week Tuesday class at 16:00, one hour long. */
|
||||||
|
private function choir(int $id = 8, string $title = 'Choir'): Offering
|
||||||
|
{
|
||||||
|
return new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: $title,
|
||||||
|
durationMinutes: 60,
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-09-22',
|
||||||
|
classTime: '16:00:00',
|
||||||
|
id: $id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAStudentsEnrolmentBecomesOneRowPerRemainingSession(): void
|
||||||
|
{
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir());
|
||||||
|
|
||||||
|
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
|
||||||
|
|
||||||
|
self::assertCount(3, $rows);
|
||||||
|
self::assertSame(
|
||||||
|
['2026-09-08 16:00:00', '2026-09-15 16:00:00', '2026-09-22 16:00:00'],
|
||||||
|
array_column($rows, 'start_dt')
|
||||||
|
);
|
||||||
|
self::assertSame('2026-09-08 17:00:00', $rows[0]['end_dt']);
|
||||||
|
self::assertNull($rows[0]['schedule']);
|
||||||
|
self::assertSame('Choir', $rows[0]['offering_title']);
|
||||||
|
self::assertSame(40, $rows[0]['enrollment_id']);
|
||||||
|
self::assertSame(3, $rows[0]['instructor_id']);
|
||||||
|
self::assertSame(60, $rows[0]['duration_minutes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSessionsThatHaveAlreadyStartedAreLeftOut(): void
|
||||||
|
{
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir());
|
||||||
|
|
||||||
|
// Mid-term: the first two dates are gone, the last is still to come.
|
||||||
|
$rows = $this->schedule->upcomingForStudent(5, '2026-09-16 09:00:00');
|
||||||
|
|
||||||
|
self::assertSame(['2026-09-22 16:00:00'], array_column($rows, 'start_dt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAWithdrawnEnrolmentContributesNothing(): void
|
||||||
|
{
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(
|
||||||
|
offeringId: 8,
|
||||||
|
studentId: 5,
|
||||||
|
instructorId: 3,
|
||||||
|
status: Enrollment::STATUS_CANCELLED,
|
||||||
|
id: 40,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldNotReceive('findById');
|
||||||
|
|
||||||
|
self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Completed" is a billing state, not a calendar one — the class may still
|
||||||
|
* have dates left to run, so its sessions stay on the list.
|
||||||
|
*/
|
||||||
|
public function testACompletedEnrolmentStillListsItsRemainingSessions(): void
|
||||||
|
{
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(
|
||||||
|
offeringId: 8,
|
||||||
|
studentId: 5,
|
||||||
|
instructorId: 3,
|
||||||
|
status: Enrollment::STATUS_COMPLETED,
|
||||||
|
id: 40,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir());
|
||||||
|
|
||||||
|
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
|
||||||
|
|
||||||
|
self::assertCount(3, $rows);
|
||||||
|
self::assertSame(Enrollment::STATUS_COMPLETED, $rows[0]['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both the class time and the duration are optional on the offering form, and
|
||||||
|
* the schedule note exists so a studio can say "Tuesdays 4:00pm" instead of
|
||||||
|
* pinning the class to a clock. A class configured that way used to vanish
|
||||||
|
* from the list entirely — the bug this covers. It now gets one row carrying
|
||||||
|
* the note in place of a date.
|
||||||
|
*/
|
||||||
|
public function testAClassWithNoClassTimeStillGetsARowDescribedInWords(): void
|
||||||
|
{
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Choir',
|
||||||
|
durationMinutes: 60,
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-12-08',
|
||||||
|
scheduleNote: 'Tuesdays 4:00pm',
|
||||||
|
id: 8,
|
||||||
|
));
|
||||||
|
|
||||||
|
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
|
||||||
|
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
self::assertSame('Tuesdays 4:00pm', $rows[0]['schedule']);
|
||||||
|
self::assertSame('Choir', $rows[0]['offering_title']);
|
||||||
|
// A sort key, not a claim about the time: the class has not started yet,
|
||||||
|
// so it sorts to its first day.
|
||||||
|
self::assertSame('2026-09-08 00:00:00', $rows[0]['start_dt']);
|
||||||
|
self::assertSame('', $rows[0]['end_dt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Without a note, the term dates do the describing. */
|
||||||
|
public function testAnUndatedClassFallsBackToItsTermDates(): void
|
||||||
|
{
|
||||||
|
Functions\when('mysql2date')->alias(
|
||||||
|
static fn (string $format, string $date): string => date($format, (int) strtotime($date))
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Choir',
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-12-08',
|
||||||
|
id: 8,
|
||||||
|
));
|
||||||
|
|
||||||
|
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
|
||||||
|
|
||||||
|
self::assertSame('Sep 8, 2026 – Dec 8, 2026', $rows[0]['schedule']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A term already under way sorts to "now" rather than to a start date in the
|
||||||
|
* past, so an ongoing class reads as current instead of dropping to the
|
||||||
|
* bottom of a list ordered by time.
|
||||||
|
*/
|
||||||
|
public function testAnUndatedClassAlreadyUnderWaySortsToNow(): void
|
||||||
|
{
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Choir',
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-12-08',
|
||||||
|
scheduleNote: 'Tuesdays 4:00pm',
|
||||||
|
id: 8,
|
||||||
|
));
|
||||||
|
|
||||||
|
$rows = $this->schedule->upcomingForStudent(5, '2026-10-01 09:00:00');
|
||||||
|
|
||||||
|
self::assertSame('2026-10-01 09:00:00', $rows[0]['start_dt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An undated class whose last day has passed is over, and drops out. */
|
||||||
|
public function testAnUndatedClassThatHasFinishedIsDropped(): void
|
||||||
|
{
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Choir',
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-12-08',
|
||||||
|
scheduleNote: 'Tuesdays 4:00pm',
|
||||||
|
id: 8,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-12-09 00:00:00'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A class time but no duration: the dates are still known, so they are still
|
||||||
|
* listed — the row just says when it starts and not when it ends, rather than
|
||||||
|
* inventing a finish.
|
||||||
|
*/
|
||||||
|
public function testAClassWithNoDurationKeepsItsDatesAndLeavesTheEndOpen(): void
|
||||||
|
{
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Choir',
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-09-15',
|
||||||
|
classTime: '16:00:00',
|
||||||
|
id: 8,
|
||||||
|
));
|
||||||
|
|
||||||
|
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
|
||||||
|
|
||||||
|
self::assertSame(['2026-09-08 16:00:00', '2026-09-15 16:00:00'], array_column($rows, 'start_dt'));
|
||||||
|
self::assertSame(['', ''], array_column($rows, 'end_dt'));
|
||||||
|
self::assertNull($rows[0]['schedule']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADeletedOfferingIsSkippedRatherThanFatal(): void
|
||||||
|
{
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn(null);
|
||||||
|
|
||||||
|
self::assertSame([], $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTwoEnrolmentsAreInterleavedByDate(): void
|
||||||
|
{
|
||||||
|
$band = new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Band',
|
||||||
|
durationMinutes: 45,
|
||||||
|
termStart: '2026-09-10',
|
||||||
|
termEnd: '2026-09-10',
|
||||||
|
classTime: '09:00:00',
|
||||||
|
id: 9,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->enrollments->shouldReceive('findByStudent')->with(5)->andReturn([
|
||||||
|
new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 40),
|
||||||
|
new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 41),
|
||||||
|
]);
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->choir());
|
||||||
|
$this->offerings->shouldReceive('findById')->with(9)->andReturn($band);
|
||||||
|
|
||||||
|
$rows = $this->schedule->upcomingForStudent(5, '2026-09-01 00:00:00');
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['Choir', 'Band', 'Choir', 'Choir'],
|
||||||
|
array_column($rows, 'offering_title')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An instructor's list is built from the classes they teach, not from who
|
||||||
|
* has signed up: a class with no enrolments yet is still on their schedule.
|
||||||
|
*/
|
||||||
|
public function testAnInstructorSeesEachSessionOfTheirActiveClassesOnce(): void
|
||||||
|
{
|
||||||
|
$this->offerings->shouldReceive('findAll')
|
||||||
|
->once()
|
||||||
|
->with(3, Offering::KIND_GROUP_CLASS, true)
|
||||||
|
->andReturn([$this->choir()]);
|
||||||
|
$this->enrollments->shouldNotReceive('findByStudent');
|
||||||
|
|
||||||
|
$rows = $this->schedule->upcomingForInstructor(3, '2026-09-01 00:00:00');
|
||||||
|
|
||||||
|
self::assertCount(3, $rows);
|
||||||
|
self::assertSame(0, $rows[0]['enrollment_id']);
|
||||||
|
self::assertSame(3, $rows[0]['instructor_id']);
|
||||||
|
self::assertSame('Choir', $rows[0]['offering_title']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ class FamilyPageTest extends TestCase
|
|||||||
Functions\when('wp_enqueue_style')->justReturn(null);
|
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||||
Functions\when('wp_nonce_field')->justReturn('');
|
Functions\when('wp_nonce_field')->justReturn('');
|
||||||
Functions\when('get_permalink')->justReturn('https://studio.test/family/');
|
Functions\when('get_permalink')->justReturn('https://studio.test/family/');
|
||||||
|
// The birth-year input caps itself at the current year.
|
||||||
|
Functions\when('current_time')->justReturn('2026');
|
||||||
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
||||||
Functions\when('sanitize_key')->alias(static fn (string $v): string => strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $v) ?? ''));
|
Functions\when('sanitize_key')->alias(static fn (string $v): string => strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $v) ?? ''));
|
||||||
Functions\when('sanitize_text_field')->alias(static fn (string $v): string => trim($v));
|
Functions\when('sanitize_text_field')->alias(static fn (string $v): string => trim($v));
|
||||||
@@ -74,9 +76,36 @@ class FamilyPageTest extends TestCase
|
|||||||
return $page;
|
return $page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account holder's own details, which every render reads.
|
||||||
|
*
|
||||||
|
* @param array{name?: string, email?: string, birth_year?: string, is_student?: bool} $overrides
|
||||||
|
*/
|
||||||
|
private function expectAccountHolder(array $overrides = []): void
|
||||||
|
{
|
||||||
|
$this->guardians->shouldReceive('accountHolder')->with(5)->andReturn($overrides + [
|
||||||
|
'name' => 'Grace',
|
||||||
|
'email' => '[email protected]',
|
||||||
|
'birth_year' => '1984',
|
||||||
|
'is_student' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A question required of everyone, or of nobody — the shape every question
|
||||||
|
* had before the account holder and the students could differ, and the shape
|
||||||
|
* the upgrade backfill leaves them in.
|
||||||
|
*/
|
||||||
private function question(int $id, bool $required): Question
|
private function question(int $id, bool $required): Question
|
||||||
{
|
{
|
||||||
return new Question(offeringId: null, label: 'Instrument', isRequired: $required, scope: Question::SCOPE_ACCOUNT, id: $id);
|
return new Question(
|
||||||
|
offeringId: null,
|
||||||
|
label: 'Instrument',
|
||||||
|
isRequired: $required,
|
||||||
|
scope: Question::SCOPE_ACCOUNT,
|
||||||
|
isRequiredChild: $required,
|
||||||
|
id: $id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoggedOutVisitorIsOfferedALoginLink(): void
|
public function testLoggedOutVisitorIsOfferedALoginLink(): void
|
||||||
@@ -86,21 +115,117 @@ class FamilyPageTest extends TestCase
|
|||||||
|
|
||||||
$html = $this->page->render([]);
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
self::assertStringContainsString('log in to manage your family', $html);
|
self::assertStringContainsString('log in to manage your profile', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRenderListsTheGuardiansChildren(): void
|
public function testRenderListsTheGuardiansChildren(): void
|
||||||
{
|
{
|
||||||
|
$this->expectAccountHolder();
|
||||||
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
|
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
|
||||||
['id' => 42, 'name' => 'Ada', 'date_of_birth' => '2015-04-02', 'relationship' => 'Parent'],
|
['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
|
||||||
]);
|
]);
|
||||||
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
|
||||||
$html = $this->page->render([]);
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
self::assertStringContainsString('Ada', $html);
|
self::assertStringContainsString('Ada', $html);
|
||||||
self::assertStringContainsString('2015-04-02', $html);
|
self::assertStringContainsString('2015', $html);
|
||||||
self::assertStringContainsString('Add a child', $html);
|
self::assertStringContainsString('Add a student', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderShowsTheAccountHoldersOwnDetails(): void
|
||||||
|
{
|
||||||
|
$this->expectAccountHolder();
|
||||||
|
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||||
|
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Your details', $html);
|
||||||
|
self::assertStringContainsString('value="Grace"', $html);
|
||||||
|
self::assertStringContainsString('[email protected]', $html);
|
||||||
|
self::assertStringContainsString('value="1984"', $html);
|
||||||
|
// A student in their own right has the box ticked.
|
||||||
|
self::assertStringContainsString("checked='checked'", $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAGuardianOnlyAccountRendersTheStudentBoxUnticked(): void
|
||||||
|
{
|
||||||
|
$this->expectAccountHolder(['is_student' => false]);
|
||||||
|
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||||
|
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('name="is_student"', $html);
|
||||||
|
self::assertStringNotContainsString("checked='checked'", $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The birth-year field must not carry `required`: it is asked of a student
|
||||||
|
* only, and the browser would otherwise block a guardian who books solely
|
||||||
|
* for other people from ever saving the form.
|
||||||
|
*/
|
||||||
|
public function testTheOwnBirthYearFieldIsNotBrowserRequired(): void
|
||||||
|
{
|
||||||
|
$this->expectAccountHolder(['is_student' => false, 'birth_year' => '']);
|
||||||
|
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||||
|
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
|
self::assertMatchesRegularExpression('/<input[^>]*name="own_birth_year"(?![^>]*\brequired\b)[^>]*>/', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSavingOwnDetailsDelegatesToTheServiceAndRedirects(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'us_family_action' => 'self',
|
||||||
|
'own_name' => 'Grace H',
|
||||||
|
'own_birth_year' => '1984',
|
||||||
|
'is_student' => '1',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->guardians->shouldReceive('updateSelf')->once()->with(5, 'Grace H', '1984', true)->andReturn(null);
|
||||||
|
|
||||||
|
$captured = null;
|
||||||
|
$this->capturingPage($captured)->maybeHandleSubmit();
|
||||||
|
|
||||||
|
self::assertSame('https://studio.test/family/?us_family=self', $captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unticked checkbox is simply absent from the post — that is the "no". */
|
||||||
|
public function testAnAbsentStudentBoxSavesTheAccountAsGuardianOnly(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'us_family_action' => 'self',
|
||||||
|
'own_name' => 'Grace H',
|
||||||
|
'own_birth_year' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->guardians->shouldReceive('updateSelf')->once()->with(5, 'Grace H', '', false)->andReturn(null);
|
||||||
|
|
||||||
|
$captured = null;
|
||||||
|
$this->capturingPage($captured)->maybeHandleSubmit();
|
||||||
|
|
||||||
|
self::assertSame('https://studio.test/family/?us_family=self', $captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOwnDetailsRefusalIsShownRatherThanRedirected(): void
|
||||||
|
{
|
||||||
|
$_POST = ['us_family_action' => 'self', 'own_name' => 'Grace', 'is_student' => '1'];
|
||||||
|
|
||||||
|
$this->guardians->shouldReceive('updateSelf')->once()->andReturn(
|
||||||
|
new \WP_Error('missing_birth_year', 'Please give your birth year.')
|
||||||
|
);
|
||||||
|
|
||||||
|
$captured = null;
|
||||||
|
$page = $this->capturingPage($captured);
|
||||||
|
$page->shouldNotReceive('redirect');
|
||||||
|
|
||||||
|
$page->maybeHandleSubmit();
|
||||||
|
|
||||||
|
self::assertNull($captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
|
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
|
||||||
@@ -108,13 +233,13 @@ class FamilyPageTest extends TestCase
|
|||||||
$_POST = [
|
$_POST = [
|
||||||
'us_family_action' => 'add',
|
'us_family_action' => 'add',
|
||||||
'child_name' => 'Ada',
|
'child_name' => 'Ada',
|
||||||
'child_dob' => '2015-04-02',
|
'child_birth_year' => '2015',
|
||||||
'child_relationship' => 'Parent',
|
'child_relationship' => 'Parent',
|
||||||
'us_answers' => [7 => 'Piano'],
|
'us_answers' => [7 => 'Piano'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]);
|
$this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]);
|
||||||
$this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015-04-02', 'Parent')->andReturn(42);
|
$this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015', 'Parent')->andReturn(42);
|
||||||
|
|
||||||
$this->answers->shouldReceive('insert')
|
$this->answers->shouldReceive('insert')
|
||||||
->once()
|
->once()
|
||||||
@@ -156,12 +281,77 @@ class FamilyPageTest extends TestCase
|
|||||||
self::assertNull($captured);
|
self::assertNull($captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This screen only ever adds a student, so the students' required-ness is the
|
||||||
|
* one that applies: a question required of the account holder alone must not
|
||||||
|
* stop a guardian adding a child.
|
||||||
|
*/
|
||||||
|
public function testAddIsNotBlockedByAQuestionRequiredOnlyOfTheAccountHolder(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'us_family_action' => 'add',
|
||||||
|
'child_name' => 'Ada',
|
||||||
|
'child_birth_year' => '2015',
|
||||||
|
'us_answers' => [7 => ' '],
|
||||||
|
];
|
||||||
|
|
||||||
|
$question = new Question(
|
||||||
|
offeringId: null,
|
||||||
|
label: 'Instrument',
|
||||||
|
isRequired: true,
|
||||||
|
scope: Question::SCOPE_ACCOUNT,
|
||||||
|
isRequiredChild: false,
|
||||||
|
id: 7
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->questions->shouldReceive('findByScope')->once()->andReturn([$question]);
|
||||||
|
$this->guardians->shouldReceive('createChild')->once()->andReturn(42);
|
||||||
|
|
||||||
|
// Nothing was typed, so nothing is stored — but the add went through.
|
||||||
|
$this->answers->shouldNotReceive('insert');
|
||||||
|
|
||||||
|
$captured = null;
|
||||||
|
$this->capturingPage($captured)->maybeHandleSubmit();
|
||||||
|
|
||||||
|
self::assertSame('https://studio.test/family/?us_family=added', $captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddIsBlockedByAQuestionRequiredOnlyOfTheStudents(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'us_family_action' => 'add',
|
||||||
|
'child_name' => 'Ada',
|
||||||
|
'child_birth_year' => '2015',
|
||||||
|
'us_answers' => [7 => ''],
|
||||||
|
];
|
||||||
|
|
||||||
|
$question = new Question(
|
||||||
|
offeringId: null,
|
||||||
|
label: 'Instrument',
|
||||||
|
isRequired: false,
|
||||||
|
scope: Question::SCOPE_ACCOUNT,
|
||||||
|
isRequiredChild: true,
|
||||||
|
id: 7
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->questions->shouldReceive('findByScope')->once()->andReturn([$question]);
|
||||||
|
$this->guardians->shouldNotReceive('createChild');
|
||||||
|
|
||||||
|
$captured = null;
|
||||||
|
$page = $this->capturingPage($captured);
|
||||||
|
$page->shouldNotReceive('redirect');
|
||||||
|
|
||||||
|
$page->maybeHandleSubmit();
|
||||||
|
|
||||||
|
self::assertNull($captured);
|
||||||
|
}
|
||||||
|
|
||||||
public function testAddSurfacesAServiceErrorInsteadOfRedirecting(): void
|
public function testAddSurfacesAServiceErrorInsteadOfRedirecting(): void
|
||||||
{
|
{
|
||||||
$_POST = ['us_family_action' => 'add', 'child_name' => ''];
|
$_POST = ['us_family_action' => 'add', 'child_name' => ''];
|
||||||
|
|
||||||
$this->questions->shouldReceive('findByScope')->once()->andReturn([]);
|
$this->questions->shouldReceive('findByScope')->once()->andReturn([]);
|
||||||
$this->guardians->shouldReceive('createChild')->once()->andReturn(new \WP_Error('missing_name', 'Please give each child a name.'));
|
$this->guardians->shouldReceive('createChild')->once()->andReturn(new \WP_Error('missing_name', 'Please give each student a name.'));
|
||||||
$this->answers->shouldNotReceive('insert');
|
$this->answers->shouldNotReceive('insert');
|
||||||
|
|
||||||
$captured = null;
|
$captured = null;
|
||||||
@@ -179,10 +369,10 @@ class FamilyPageTest extends TestCase
|
|||||||
'us_family_action' => 'edit',
|
'us_family_action' => 'edit',
|
||||||
'child_id' => '42',
|
'child_id' => '42',
|
||||||
'child_name' => 'Ada L',
|
'child_name' => 'Ada L',
|
||||||
'child_dob' => '2015-04-02',
|
'child_birth_year' => '2015',
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->andReturn(null);
|
$this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015')->andReturn(null);
|
||||||
|
|
||||||
$captured = null;
|
$captured = null;
|
||||||
$this->capturingPage($captured)->maybeHandleSubmit();
|
$this->capturingPage($captured)->maybeHandleSubmit();
|
||||||
@@ -207,7 +397,7 @@ class FamilyPageTest extends TestCase
|
|||||||
$_POST = ['us_family_action' => 'remove', 'child_id' => '42'];
|
$_POST = ['us_family_action' => 'remove', 'child_id' => '42'];
|
||||||
|
|
||||||
$this->guardians->shouldReceive('removeChild')->once()->andReturn(
|
$this->guardians->shouldReceive('removeChild')->once()->andReturn(
|
||||||
new \WP_Error('has_history', 'This child has lessons or enrolments on record.')
|
new \WP_Error('has_history', 'This student has lessons or enrolments on record.')
|
||||||
);
|
);
|
||||||
|
|
||||||
$captured = null;
|
$captured = null;
|
||||||
@@ -268,9 +458,10 @@ class FamilyPageTest extends TestCase
|
|||||||
{
|
{
|
||||||
$_GET = ['us_family' => 'added'];
|
$_GET = ['us_family' => 'added'];
|
||||||
|
|
||||||
|
$this->expectAccountHolder();
|
||||||
$this->guardians->shouldReceive('children')->andReturn([]);
|
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||||
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
|
||||||
self::assertStringContainsString('Child added.', $this->page->render([]));
|
self::assertStringContainsString('Student added.', $this->page->render([]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ class GuardianServiceTest extends TestCase
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
// The birth-year range is validated against "this year", so pin it.
|
||||||
|
Functions\when('current_time')->justReturn('2026');
|
||||||
Functions\when('wp_generate_password')->justReturn('abc123def456');
|
Functions\when('wp_generate_password')->justReturn('abc123def456');
|
||||||
Functions\when('email_exists')->justReturn(false);
|
Functions\when('email_exists')->justReturn(false);
|
||||||
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
||||||
@@ -84,14 +86,14 @@ class GuardianServiceTest extends TestCase
|
|||||||
->with(Mockery::on(static fn (GuardianLink $l): bool => $l->guardianId === 5 && $l->studentId === 42 && $l->relationship === 'Parent'))
|
->with(Mockery::on(static fn (GuardianLink $l): bool => $l->guardianId === 5 && $l->studentId === 42 && $l->relationship === 'Parent'))
|
||||||
->andReturn(7);
|
->andReturn(7);
|
||||||
|
|
||||||
$result = $this->service->createChild(5, ' Ada ', '2015-04-02', 'Parent');
|
$result = $this->service->createChild(5, ' Ada ', '2015', 'Parent');
|
||||||
|
|
||||||
self::assertSame(42, $result);
|
self::assertSame(42, $result);
|
||||||
self::assertSame('Ada', $captured['display_name']);
|
self::assertSame('Ada', $captured['display_name']);
|
||||||
// The address is on the reserved .invalid TLD, so it can never receive mail.
|
// The address is on the reserved .invalid TLD, so it can never receive mail.
|
||||||
self::assertStringEndsWith('@child.invalid', $captured['user_email']);
|
self::assertStringEndsWith('@child.invalid', $captured['user_email']);
|
||||||
self::assertSame('1', $this->meta[42][GuardianService::META_CHILD]);
|
self::assertSame('1', $this->meta[42][GuardianService::META_CHILD]);
|
||||||
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]);
|
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateChildRejectsABlankName(): void
|
public function testCreateChildRejectsABlankName(): void
|
||||||
@@ -114,17 +116,49 @@ class GuardianServiceTest extends TestCase
|
|||||||
|
|
||||||
Functions\expect('wp_delete_user')->once()->with(42);
|
Functions\expect('wp_delete_user')->once()->with(42);
|
||||||
|
|
||||||
self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada'));
|
self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada', '2015'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateChildClearsAnUnparseableDateOfBirth(): void
|
/**
|
||||||
|
* @dataProvider unusableBirthYears
|
||||||
|
*/
|
||||||
|
public function testCreateChildRefusesAnUnusableBirthYear(string $submitted): void
|
||||||
{
|
{
|
||||||
Functions\when('wp_insert_user')->justReturn(42);
|
// Refused before anything is written, so no orphan user is left behind.
|
||||||
$this->guardians->shouldReceive('insert')->once()->andReturn(7);
|
Functions\expect('wp_insert_user')->never();
|
||||||
|
$this->guardians->shouldNotReceive('insert');
|
||||||
|
|
||||||
$this->service->createChild(5, 'Ada', 'not-a-date');
|
$result = $this->service->createChild(5, 'Ada', $submitted);
|
||||||
|
|
||||||
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('missing_birth_year', $result->get_error_code());
|
||||||
|
self::assertArrayNotHasKey(42, $this->meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider unusableBirthYears */
|
||||||
|
public function testUpdateChildRefusesAnUnusableBirthYear(string $submitted): void
|
||||||
|
{
|
||||||
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
||||||
|
Functions\expect('wp_update_user')->never();
|
||||||
|
|
||||||
|
$result = $this->service->updateChild(5, 42, 'Ada', $submitted);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('missing_birth_year', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string}> */
|
||||||
|
public static function unusableBirthYears(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'not a number' => ['not-a-year'],
|
||||||
|
'a full date' => ['2015-04-02'],
|
||||||
|
'too few digits' => ['15'],
|
||||||
|
'too many digits' => ['20155'],
|
||||||
|
'before 1900' => ['1899'],
|
||||||
|
'later than today' => ['2027'],
|
||||||
|
'left blank' => [''],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCanActForSelfAndOwnChildOnly(): void
|
public function testCanActForSelfAndOwnChildOnly(): void
|
||||||
@@ -187,6 +221,53 @@ class GuardianServiceTest extends TestCase
|
|||||||
self::assertSame([false, false, true], array_column($students, 'is_self'));
|
self::assertSame([false, false, true], array_column($students, 'is_self'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testChildrenReportsTheStoredBirthYear(): void
|
||||||
|
{
|
||||||
|
$this->meta[42][GuardianService::META_BIRTH_YEAR] = '2015';
|
||||||
|
|
||||||
|
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
|
||||||
|
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
|
||||||
|
|
||||||
|
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A child added before this feature switched to a year has only the old full
|
||||||
|
* date on record, and must still show a birth year.
|
||||||
|
*/
|
||||||
|
public function testChildrenDerivesABirthYearFromALegacyDateOfBirth(): void
|
||||||
|
{
|
||||||
|
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
|
||||||
|
|
||||||
|
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
|
||||||
|
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
|
||||||
|
|
||||||
|
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saving a child drops the legacy full date, so the fallback above can never
|
||||||
|
* outrank a year the guardian has since corrected by hand.
|
||||||
|
*/
|
||||||
|
public function testSavingAChildClearsTheLegacyDateOfBirth(): void
|
||||||
|
{
|
||||||
|
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
|
||||||
|
|
||||||
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
||||||
|
Functions\when('wp_update_user')->justReturn(42);
|
||||||
|
|
||||||
|
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2016'));
|
||||||
|
|
||||||
|
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
|
||||||
|
self::assertSame('2016', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
||||||
|
|
||||||
|
// The corrected year is what is read back, not the year of the old date.
|
||||||
|
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
|
||||||
|
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
|
||||||
|
|
||||||
|
self::assertSame('2016', $this->service->children(5)[0]['birth_year']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testBookableStudentsIsJustTheUserWithoutChildren(): void
|
public function testBookableStudentsIsJustTheUserWithoutChildren(): void
|
||||||
{
|
{
|
||||||
$this->guardians->shouldReceive('findByGuardian')->with(9)->andReturn([]);
|
$this->guardians->shouldReceive('findByGuardian')->with(9)->andReturn([]);
|
||||||
@@ -228,7 +309,7 @@ class GuardianServiceTest extends TestCase
|
|||||||
self::assertInstanceOf(\WP_Error::class, $this->service->updateChild(5, 99, 'Mallory'));
|
self::assertInstanceOf(\WP_Error::class, $this->service->updateChild(5, 99, 'Mallory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUpdateChildRenamesAndStoresTheDateOfBirth(): void
|
public function testUpdateChildRenamesAndStoresTheBirthYear(): void
|
||||||
{
|
{
|
||||||
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
||||||
Functions\expect('wp_update_user')
|
Functions\expect('wp_update_user')
|
||||||
@@ -236,8 +317,89 @@ class GuardianServiceTest extends TestCase
|
|||||||
->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L'])
|
->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L'])
|
||||||
->andReturn(42);
|
->andReturn(42);
|
||||||
|
|
||||||
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015-04-02'));
|
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015'));
|
||||||
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]);
|
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateSelfRenamesAndStoresTheBirthYear(): void
|
||||||
|
{
|
||||||
|
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
|
||||||
|
|
||||||
|
Functions\expect('wp_update_user')
|
||||||
|
->once()
|
||||||
|
->with(['ID' => 5, 'display_name' => 'Grace H', 'nickname' => 'Grace H'])
|
||||||
|
->andReturn(5);
|
||||||
|
|
||||||
|
self::assertNull($this->service->updateSelf(5, 'Grace H', '1984', true));
|
||||||
|
|
||||||
|
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
|
||||||
|
// Saying they take lessons makes them a bookable student again.
|
||||||
|
self::assertFalse(GuardianService::isGuardianOnly(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateSelfMarksTheAccountGuardianOnly(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_update_user')->justReturn(5);
|
||||||
|
|
||||||
|
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
|
||||||
|
|
||||||
|
self::assertSame('1', $this->meta[5][GuardianService::META_GUARDIAN_ONLY]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "I only book for other people" says who books, not "forget my birth year" —
|
||||||
|
* ticking the box back on should not have cost them what was on file.
|
||||||
|
*/
|
||||||
|
public function testUpdateSelfKeepsAStoredBirthYearWhenTheyAreNoLongerAStudent(): void
|
||||||
|
{
|
||||||
|
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
|
||||||
|
|
||||||
|
Functions\when('wp_update_user')->justReturn(5);
|
||||||
|
|
||||||
|
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
|
||||||
|
|
||||||
|
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateSelfRejectsABlankName(): void
|
||||||
|
{
|
||||||
|
Functions\expect('wp_update_user')->never();
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, ' ', '1984', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser cannot enforce the year conditionally, so the server is the
|
||||||
|
* only thing standing between a student and a nonsense age on their record.
|
||||||
|
*
|
||||||
|
* @dataProvider unusableBirthYears
|
||||||
|
*/
|
||||||
|
public function testUpdateSelfRefusesAnUnusableBirthYearFromAStudent(string $submitted): void
|
||||||
|
{
|
||||||
|
Functions\expect('wp_update_user')->never();
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, 'Grace H', $submitted, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAccountHolderReportsTheirOwnDetails(): void
|
||||||
|
{
|
||||||
|
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
|
||||||
|
|
||||||
|
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['name' => 'Grace Hopper', 'email' => '[email protected]', 'birth_year' => '1984', 'is_student' => true],
|
||||||
|
$this->service->accountHolder(5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAccountHolderReportsAGuardianOnlyAccountAsNotAStudent(): void
|
||||||
|
{
|
||||||
|
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
|
||||||
|
|
||||||
|
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
|
||||||
|
|
||||||
|
self::assertFalse($this->service->accountHolder(5)['is_student']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
|
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
|
||||||
|
|||||||
@@ -128,6 +128,96 @@ class OfferingTest extends TestCase
|
|||||||
self::assertSame([], $noDuration->sessionWindows());
|
self::assertSame([], $noDuration->sessionWindows());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knowing *when* a class meets is a separate question from knowing how long
|
||||||
|
* it runs, so the dates survive a missing duration even though the windows
|
||||||
|
* (which need both ends) do not.
|
||||||
|
*/
|
||||||
|
public function testSessionStartsNeedsNoDuration(): void
|
||||||
|
{
|
||||||
|
$noDuration = new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Choir',
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-09-22',
|
||||||
|
classTime: '16:00:00',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['2026-09-08 16:00:00', '2026-09-15 16:00:00', '2026-09-22 16:00:00'],
|
||||||
|
$noDuration->sessionStarts()
|
||||||
|
);
|
||||||
|
self::assertSame([], $noDuration->sessionWindows());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSessionStartsEmptyWithoutADateOrATime(): void
|
||||||
|
{
|
||||||
|
$noTime = new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Choir',
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
);
|
||||||
|
self::assertSame([], $noTime->sessionStarts());
|
||||||
|
|
||||||
|
$noDate = new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Choir',
|
||||||
|
classTime: '16:00:00',
|
||||||
|
);
|
||||||
|
self::assertSame([], $noDate->sessionStarts());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLastClassDayPrefersTheTermEnd(): void
|
||||||
|
{
|
||||||
|
$run = new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Choir',
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-12-08',
|
||||||
|
);
|
||||||
|
self::assertSame('2026-12-08', $run->lastClassDay());
|
||||||
|
|
||||||
|
$oneOff = new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Recital',
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
);
|
||||||
|
self::assertSame('2026-09-08', $oneOff->lastClassDay());
|
||||||
|
|
||||||
|
$undated = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir');
|
||||||
|
self::assertNull($undated->lastClassDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The studio's own wording wins: the schedule-note field exists precisely so
|
||||||
|
* a class can describe when it meets without being pinned to a clock.
|
||||||
|
*/
|
||||||
|
public function testScheduleLabelPrefersTheScheduleNote(): void
|
||||||
|
{
|
||||||
|
$offering = new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Choir',
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-12-08',
|
||||||
|
scheduleNote: ' Tuesdays 4:00pm ',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('Tuesdays 4:00pm', $offering->scheduleLabel());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testScheduleLabelSaysSoWhenNothingIsSet(): void
|
||||||
|
{
|
||||||
|
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir');
|
||||||
|
|
||||||
|
self::assertSame('Schedule to be confirmed', $offering->scheduleLabel());
|
||||||
|
}
|
||||||
|
|
||||||
public function testDefaults(): void
|
public function testDefaults(): void
|
||||||
{
|
{
|
||||||
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
|
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
|
||||||
|
|||||||
@@ -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 "Terms of Enrolment"', $html);
|
||||||
|
self::assertStringContainsString('Versions of "Terms of Enrolment"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenamingAPolicyToNothingIsRejected(): void
|
||||||
|
{
|
||||||
|
$policy = new Policy('Studio Policy', 'studio-policy', id: 4);
|
||||||
|
|
||||||
|
$_GET = ['policy_id' => '4'];
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'rename_policy',
|
||||||
|
'policy_id' => '4',
|
||||||
|
'title' => ' ',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||||
|
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||||
|
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
|
||||||
|
|
||||||
|
$this->policies->shouldNotReceive('updateTitle');
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('Versions of "Studio Policy"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenamingAPolicyBeyondTheColumnLengthIsRejected(): void
|
||||||
|
{
|
||||||
|
$policy = new Policy('Studio Policy', 'studio-policy', id: 4);
|
||||||
|
|
||||||
|
$_GET = ['policy_id' => '4'];
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'rename_policy',
|
||||||
|
'policy_id' => '4',
|
||||||
|
'title' => str_repeat('a', Policy::MAX_TITLE_LENGTH + 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||||
|
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||||
|
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
|
||||||
|
|
||||||
|
$this->policies->shouldNotReceive('updateTitle');
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
}
|
||||||
|
|
||||||
private function render(): string
|
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')
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Registration;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Registration\Question;
|
||||||
|
use Unsupervised\Schedular\Registration\QuestionField;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class QuestionFieldTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testRendersATextInputNamedAndLabelledAsAsked(): void
|
||||||
|
{
|
||||||
|
$html = QuestionField::render(new Question(7, 'Your level?', id: 3), 'us_answers[3]', 'us-q-3');
|
||||||
|
|
||||||
|
self::assertStringContainsString('<label for="us-q-3">Your level?</label>', $html);
|
||||||
|
self::assertStringContainsString('<input type="text" name="us_answers[3]" id="us-q-3">', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARequiredQuestionIsMarkedAndEnforced(): void
|
||||||
|
{
|
||||||
|
$question = new Question(7, 'Your level?', isRequired: true, id: 3);
|
||||||
|
|
||||||
|
$html = QuestionField::render($question, 'us_answers[3]', 'us-q-3');
|
||||||
|
|
||||||
|
self::assertStringContainsString('us-required', $html);
|
||||||
|
self::assertStringContainsString(' required', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A block that may not apply at all keeps the marker and drops the attribute,
|
||||||
|
* so the browser cannot refuse a submit over a field that is out of play.
|
||||||
|
*/
|
||||||
|
public function testNotEnforcingRequiredKeepsTheMarkerButDropsTheAttribute(): void
|
||||||
|
{
|
||||||
|
$question = new Question(7, 'Your level?', isRequired: true, id: 3);
|
||||||
|
|
||||||
|
$html = QuestionField::render($question, 'us_answers[3]', 'us-q-3', enforceRequired: false);
|
||||||
|
|
||||||
|
self::assertStringContainsString('us-required', $html);
|
||||||
|
self::assertStringNotContainsString(' required>', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the question's two required flags applies depends on whose block
|
||||||
|
* this is, and only the caller knows that.
|
||||||
|
*/
|
||||||
|
public function testTheCallerCanOverrideWhichRequiredFlagApplies(): void
|
||||||
|
{
|
||||||
|
$question = new Question(
|
||||||
|
null,
|
||||||
|
'Previous experience',
|
||||||
|
scope: Question::SCOPE_ACCOUNT,
|
||||||
|
isRequired: false,
|
||||||
|
isRequiredChild: true,
|
||||||
|
id: 3
|
||||||
|
);
|
||||||
|
|
||||||
|
$forSelf = QuestionField::render($question, 'us_answers[3]', 'us-q-3', isRequired: $question->isRequiredForSelf());
|
||||||
|
$forChild = QuestionField::render($question, 'children[0][answers][3]', 'us-child-0-q-3', isRequired: $question->isRequiredForChild());
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('us-required', $forSelf);
|
||||||
|
self::assertStringNotContainsString(' required', $forSelf);
|
||||||
|
|
||||||
|
self::assertStringContainsString('us-required', $forChild);
|
||||||
|
self::assertStringContainsString(' required', $forChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASelectRendersItsOptionsBehindAnEmptyChoice(): void
|
||||||
|
{
|
||||||
|
$question = new Question(
|
||||||
|
7,
|
||||||
|
'Pick a level',
|
||||||
|
fieldType: Question::FIELD_SELECT,
|
||||||
|
options: ['Beginner', 'Advanced'],
|
||||||
|
id: 3
|
||||||
|
);
|
||||||
|
|
||||||
|
$html = QuestionField::render($question, 'us_answers[3]', 'us-q-3');
|
||||||
|
|
||||||
|
self::assertStringContainsString('<select name="us_answers[3]" id="us-q-3">', $html);
|
||||||
|
self::assertStringContainsString('<option value="Beginner">Beginner</option>', $html);
|
||||||
|
self::assertStringContainsString('<option value="Advanced">Advanced</option>', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,6 +156,59 @@ class QuestionRepositoryTest extends TestCase
|
|||||||
self::assertSame(30, $this->repo->insert($question));
|
self::assertSame(30, $this->repo->insert($question));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testInsertStoresAudienceAndTheStudentsRequiredFlag(): void
|
||||||
|
{
|
||||||
|
Functions\expect('current_time')->andReturn('2026-04-01 12:00:00');
|
||||||
|
|
||||||
|
$this->db->shouldReceive('insert')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
'wp_us_questions',
|
||||||
|
Mockery::on(static function (array $data): bool {
|
||||||
|
return $data['audience'] === Question::AUDIENCE_CHILD
|
||||||
|
&& $data['is_required'] === 0
|
||||||
|
&& $data['is_required_child'] === 1;
|
||||||
|
}),
|
||||||
|
// One placeholder per column, in the same order.
|
||||||
|
Mockery::on(static fn (array $format): bool => count($format) === 11)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->db->insert_id = 31;
|
||||||
|
|
||||||
|
$question = new Question(
|
||||||
|
null,
|
||||||
|
'School and grade',
|
||||||
|
scope: Question::SCOPE_ACCOUNT,
|
||||||
|
audience: Question::AUDIENCE_CHILD,
|
||||||
|
isRequiredChild: true
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(31, $this->repo->insert($question));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBackfillChildRequiredCopiesTheOldRequiredFlagAcross(): void
|
||||||
|
{
|
||||||
|
$this->db->shouldReceive('prepare')
|
||||||
|
->once()
|
||||||
|
->with(Mockery::pattern('/UPDATE %i SET is_required_child = 1 WHERE is_required = 1/'), 'wp_us_questions')
|
||||||
|
->andReturn('UPDATE `wp_us_questions` SET is_required_child = 1 WHERE is_required = 1');
|
||||||
|
|
||||||
|
$this->db->shouldReceive('query')
|
||||||
|
->once()
|
||||||
|
->with('UPDATE `wp_us_questions` SET is_required_child = 1 WHERE is_required = 1')
|
||||||
|
->andReturn(2);
|
||||||
|
|
||||||
|
self::assertTrue($this->repo->backfillChildRequired());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBackfillChildRequiredReportsFailureWhenQueryFails(): void
|
||||||
|
{
|
||||||
|
$this->db->shouldReceive('prepare')->once()->andReturn('UPDATE ...');
|
||||||
|
$this->db->shouldReceive('query')->once()->andReturn(false);
|
||||||
|
|
||||||
|
self::assertFalse($this->repo->backfillChildRequired());
|
||||||
|
}
|
||||||
|
|
||||||
public function testFindByScopeActiveOnlyPreparesQuery(): void
|
public function testFindByScopeActiveOnlyPreparesQuery(): void
|
||||||
{
|
{
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
|
|||||||
@@ -101,11 +101,112 @@ class QuestionTest extends TestCase
|
|||||||
$question = new Question(7, 'Label', Question::FIELD_TEXT, id: 9);
|
$question = new Question(7, 'Label', Question::FIELD_TEXT, id: 9);
|
||||||
$arr = $question->toArray();
|
$arr = $question->toArray();
|
||||||
|
|
||||||
foreach (['id', 'offering_id', 'scope', 'label', 'field_type', 'options', 'is_required', 'sort_order', 'is_active'] as $key) {
|
foreach (['id', 'offering_id', 'scope', 'label', 'field_type', 'options', 'audience', 'is_required', 'is_required_child', 'sort_order', 'is_active'] as $key) {
|
||||||
self::assertArrayHasKey($key, $arr);
|
self::assertArrayHasKey($key, $arr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testDefaultsToBeingAskedOfEveryoneAndRequiredOfNobody(): void
|
||||||
|
{
|
||||||
|
$question = new Question(null, 'Instrument', scope: Question::SCOPE_ACCOUNT);
|
||||||
|
|
||||||
|
self::assertSame(Question::AUDIENCE_ALL, $question->audience);
|
||||||
|
self::assertTrue($question->askedOfSelf());
|
||||||
|
self::assertFalse($question->isRequiredForSelf());
|
||||||
|
self::assertFalse($question->isRequiredForChild());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAChildAudienceQuestionIsNeverAskedOfTheAccountHolder(): void
|
||||||
|
{
|
||||||
|
$question = new Question(
|
||||||
|
null,
|
||||||
|
'School and grade',
|
||||||
|
scope: Question::SCOPE_ACCOUNT,
|
||||||
|
audience: Question::AUDIENCE_CHILD,
|
||||||
|
isRequired: true,
|
||||||
|
isRequiredChild: true
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertFalse($question->askedOfSelf());
|
||||||
|
|
||||||
|
// Required-ness cannot outlive the audience: a question the account
|
||||||
|
// holder is never shown must never be one they are held to.
|
||||||
|
self::assertFalse($question->isRequiredForSelf());
|
||||||
|
self::assertTrue($question->isRequiredForChild());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAQuestionCanBeOptionalForYouAndRequiredForYourStudents(): void
|
||||||
|
{
|
||||||
|
$question = new Question(
|
||||||
|
null,
|
||||||
|
'Previous experience',
|
||||||
|
scope: Question::SCOPE_ACCOUNT,
|
||||||
|
isRequired: false,
|
||||||
|
isRequiredChild: true
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($question->askedOfSelf());
|
||||||
|
self::assertFalse($question->isRequiredForSelf());
|
||||||
|
self::assertTrue($question->isRequiredForChild());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFromRowReadsAudienceAndTheStudentsRequiredFlag(): void
|
||||||
|
{
|
||||||
|
$row = (object) [
|
||||||
|
'id' => '6',
|
||||||
|
'offering_id' => null,
|
||||||
|
'scope' => Question::SCOPE_ACCOUNT,
|
||||||
|
'label' => 'School and grade',
|
||||||
|
'field_type' => Question::FIELD_TEXT,
|
||||||
|
'options' => null,
|
||||||
|
'audience' => Question::AUDIENCE_CHILD,
|
||||||
|
'is_required' => '0',
|
||||||
|
'is_required_child' => '1',
|
||||||
|
'sort_order' => '0',
|
||||||
|
'is_active' => '1',
|
||||||
|
];
|
||||||
|
|
||||||
|
$question = Question::fromRow($row);
|
||||||
|
|
||||||
|
self::assertSame(Question::AUDIENCE_CHILD, $question->audience);
|
||||||
|
self::assertFalse($question->askedOfSelf());
|
||||||
|
self::assertTrue($question->isRequiredForChild());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row read before dbDelta has added the columns — or one carrying a value
|
||||||
|
* no longer recognised — falls back to the behaviour every question had
|
||||||
|
* before the distinction existed: asked of everyone.
|
||||||
|
*/
|
||||||
|
public function testFromRowFallsBackToEveryoneWhenAudienceIsMissingOrUnknown(): void
|
||||||
|
{
|
||||||
|
$base = [
|
||||||
|
'id' => '7',
|
||||||
|
'offering_id' => null,
|
||||||
|
'scope' => Question::SCOPE_ACCOUNT,
|
||||||
|
'label' => 'Instrument',
|
||||||
|
'field_type' => Question::FIELD_TEXT,
|
||||||
|
'options' => null,
|
||||||
|
'is_required' => '1',
|
||||||
|
'sort_order' => '0',
|
||||||
|
'is_active' => '1',
|
||||||
|
];
|
||||||
|
|
||||||
|
$missing = Question::fromRow((object) $base);
|
||||||
|
$unknown = Question::fromRow((object) ($base + ['audience' => 'grown-ups']));
|
||||||
|
|
||||||
|
self::assertSame(Question::AUDIENCE_ALL, $missing->audience);
|
||||||
|
self::assertTrue($missing->isRequiredForSelf());
|
||||||
|
self::assertFalse($missing->isRequiredForChild());
|
||||||
|
self::assertSame(Question::AUDIENCE_ALL, $unknown->audience);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidAudienceConstants(): void
|
||||||
|
{
|
||||||
|
self::assertContains(Question::AUDIENCE_ALL, Question::VALID_AUDIENCES);
|
||||||
|
self::assertContains(Question::AUDIENCE_CHILD, Question::VALID_AUDIENCES);
|
||||||
|
}
|
||||||
|
|
||||||
public function testValidFieldTypeConstants(): void
|
public function testValidFieldTypeConstants(): void
|
||||||
{
|
{
|
||||||
self::assertContains(Question::FIELD_TEXT, Question::VALID_FIELD_TYPES);
|
self::assertContains(Question::FIELD_TEXT, Question::VALID_FIELD_TYPES);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
|
|||||||
use Brain\Monkey\Actions;
|
use Brain\Monkey\Actions;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Auth\AccountPage;
|
||||||
use Unsupervised\Schedular\Auth\LoginPage;
|
use Unsupervised\Schedular\Auth\LoginPage;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
@@ -20,6 +21,7 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
||||||
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
||||||
private FamilyPage&Mockery\MockInterface $familyPage;
|
private FamilyPage&Mockery\MockInterface $familyPage;
|
||||||
|
private AccountPage&Mockery\MockInterface $accountPage;
|
||||||
private ShortcodeRegistrar $registrar;
|
private ShortcodeRegistrar $registrar;
|
||||||
|
|
||||||
/** @var array<string, callable> */
|
/** @var array<string, callable> */
|
||||||
@@ -37,6 +39,7 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
||||||
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
||||||
$this->familyPage = Mockery::mock(FamilyPage::class);
|
$this->familyPage = Mockery::mock(FamilyPage::class);
|
||||||
|
$this->accountPage = Mockery::mock(AccountPage::class);
|
||||||
|
|
||||||
$this->registrar = new ShortcodeRegistrar(
|
$this->registrar = new ShortcodeRegistrar(
|
||||||
$this->bookingPage,
|
$this->bookingPage,
|
||||||
@@ -44,6 +47,7 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
$this->registrationPage,
|
$this->registrationPage,
|
||||||
$this->groupClassPage,
|
$this->groupClassPage,
|
||||||
$this->familyPage,
|
$this->familyPage,
|
||||||
|
$this->accountPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
$shortcodes = &$this->shortcodes;
|
$shortcodes = &$this->shortcodes;
|
||||||
@@ -66,7 +70,7 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
$this->registrar->register();
|
$this->registrar->register();
|
||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family'],
|
['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family', 'us_account'],
|
||||||
array_keys($this->shortcodes)
|
array_keys($this->shortcodes)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,32 @@ abstract class TestCase extends BaseTestCase
|
|||||||
Monkey\setUp();
|
Monkey\setUp();
|
||||||
Monkey\Functions\stubTranslationFunctions();
|
Monkey\Functions\stubTranslationFunctions();
|
||||||
Monkey\Functions\stubEscapeFunctions();
|
Monkey\Functions\stubEscapeFunctions();
|
||||||
|
|
||||||
|
// WordPress's form-state helpers. Stubbed here rather than per test
|
||||||
|
// because they are pure output helpers with no behaviour worth faking
|
||||||
|
// differently anywhere — a template either emits the attribute or not.
|
||||||
|
Monkey\Functions\when('checked')->alias(
|
||||||
|
static fn (mixed $checked, mixed $current = true, bool $echo = true): string
|
||||||
|
=> self::formAttribute('checked', $checked, $current, $echo)
|
||||||
|
);
|
||||||
|
Monkey\Functions\when('selected')->alias(
|
||||||
|
static fn (mixed $selected, mixed $current = true, bool $echo = true): string
|
||||||
|
=> self::formAttribute('selected', $selected, $current, $echo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors WordPress's `__checked_selected_helper()`, echo included. */
|
||||||
|
private static function formAttribute(string $type, mixed $helper, mixed $current, bool $echo): string
|
||||||
|
{
|
||||||
|
// WordPress compares loosely, and templates rely on that: `checked($a, $b)`
|
||||||
|
// is routinely called with an int against a numeric string.
|
||||||
|
$result = $helper == $current ? " {$type}='{$type}'" : ''; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
|
||||||
|
|
||||||
|
if ($echo) {
|
||||||
|
echo $result; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed literal.
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: Unsupervised Scheduler
|
* Plugin Name: Unsupervised Scheduler
|
||||||
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
||||||
* Description: Instructor/student lesson scheduling for WordPress.
|
* Description: Instructor/student lesson scheduling for WordPress.
|
||||||
* Version: 1.3.0
|
* Version: 1.5.1
|
||||||
* Requires at least: 6.2
|
* Requires at least: 6.2
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
* Author: Unsupervised
|
* Author: Unsupervised
|
||||||
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define('USC_VERSION', '1.3.0');
|
define('USC_VERSION', '1.5.1');
|
||||||
define('USC_PLUGIN_FILE', __FILE__);
|
define('USC_PLUGIN_FILE', __FILE__);
|
||||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
|||||||
Reference in New Issue
Block a user